Allow super admin to create users and reset password (#238)
1. Add user creation and password reset dialog functionalities; added Junie guidelines Introduced new `AdminCreateUserDialog` and `AdminResetPasswordDialog` components for managing user accounts in the admin panel. Updated the `AdminAccountsTable` page with a button for user creation and implemented backend logic for password resets with robust error handling. 2. Added Jetbrains AI guidelines
This commit is contained in:
committed by
GitHub
parent
e193c94f06
commit
4f41304be4
@@ -36,6 +36,7 @@ import {
|
||||
import { AdminDeleteAccountDialog } from './admin-delete-account-dialog';
|
||||
import { AdminDeleteUserDialog } from './admin-delete-user-dialog';
|
||||
import { AdminImpersonateUserDialog } from './admin-impersonate-user-dialog';
|
||||
import { AdminResetPasswordDialog } from './admin-reset-password-dialog';
|
||||
|
||||
type Account = Database['public']['Tables']['accounts']['Row'];
|
||||
|
||||
@@ -203,50 +204,58 @@ function getColumns(): ColumnDef<Account>[] {
|
||||
const userId = row.original.id;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant={'ghost'}>
|
||||
<EllipsisVertical className={'h-4'} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<div className={'flex justify-end'}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant={'outline'} size={'icon'}>
|
||||
<EllipsisVertical className={'h-4'} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align={'end'}>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuContent align={'end'}>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className={'h-full w-full'}
|
||||
href={`/admin/accounts/${userId}`}
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className={'h-full w-full'}
|
||||
href={`/admin/accounts/${userId}`}
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<If condition={isPersonalAccount}>
|
||||
<AdminImpersonateUserDialog userId={userId}>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
Impersonate User
|
||||
</DropdownMenuItem>
|
||||
</AdminImpersonateUserDialog>
|
||||
<If condition={isPersonalAccount}>
|
||||
<AdminResetPasswordDialog userId={userId}>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
Send Reset Password link
|
||||
</DropdownMenuItem>
|
||||
</AdminResetPasswordDialog>
|
||||
|
||||
<AdminDeleteUserDialog userId={userId}>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
Delete Personal Account
|
||||
</DropdownMenuItem>
|
||||
</AdminDeleteUserDialog>
|
||||
</If>
|
||||
<AdminImpersonateUserDialog userId={userId}>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
Impersonate User
|
||||
</DropdownMenuItem>
|
||||
</AdminImpersonateUserDialog>
|
||||
|
||||
<If condition={!isPersonalAccount}>
|
||||
<AdminDeleteAccountDialog accountId={row.original.id}>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
Delete Team Account
|
||||
</DropdownMenuItem>
|
||||
</AdminDeleteAccountDialog>
|
||||
</If>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<AdminDeleteUserDialog userId={userId}>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
Delete Personal Account
|
||||
</DropdownMenuItem>
|
||||
</AdminDeleteUserDialog>
|
||||
</If>
|
||||
|
||||
<If condition={!isPersonalAccount}>
|
||||
<AdminDeleteAccountDialog accountId={row.original.id}>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
Delete Team Account
|
||||
</DropdownMenuItem>
|
||||
</AdminDeleteAccountDialog>
|
||||
</If>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@kit/ui/alert-dialog';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Checkbox } from '@kit/ui/checkbox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { createUserAction } from '../lib/server/admin-server-actions';
|
||||
import {
|
||||
CreateUserSchema,
|
||||
CreateUserSchemaType,
|
||||
} from '../lib/server/schema/create-user.schema';
|
||||
|
||||
export function AdminCreateUserDialog(props: React.PropsWithChildren) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm<CreateUserSchemaType>({
|
||||
resolver: zodResolver(CreateUserSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
emailConfirm: false,
|
||||
},
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const onSubmit = (data: CreateUserSchemaType) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await createUserAction(data);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('User creates successfully');
|
||||
form.reset();
|
||||
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Error');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Create New User</AlertDialogTitle>
|
||||
|
||||
<AlertDialogDescription>
|
||||
Complete the form below to create a new user.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
data-test={'admin-create-user-form'}
|
||||
className={'flex flex-col space-y-4'}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<If condition={!!error}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<FormField
|
||||
name={'email'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
required
|
||||
type="email"
|
||||
placeholder={'user@example.com'}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name={'password'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
required
|
||||
type="password"
|
||||
placeholder={'Password'}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
Password must be at least 8 characters long.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name={'emailConfirm'}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<div className="flex flex-col space-y-1">
|
||||
<FormLabel>Auto-confirm email</FormLabel>
|
||||
|
||||
<FormDescription>
|
||||
If checked, the user's email will be automatically
|
||||
confirmed.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
|
||||
<Button disabled={pending} type={'submit'}>
|
||||
{pending ? 'Creating...' : 'Create User'}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -74,9 +74,16 @@ export function AdminImpersonateUserDialog(
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Impersonate User</AlertDialogTitle>
|
||||
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to impersonate this user? You will be logged
|
||||
in as this user. To stop impersonating, log out.
|
||||
<AlertDialogDescription className={'flex flex-col space-y-1'}>
|
||||
<span>
|
||||
Are you sure you want to impersonate this user? You will be logged
|
||||
in as this user. To stop impersonating, log out.
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<b>NB:</b> If the user has 2FA enabled, you will not be able to
|
||||
impersonate them.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@kit/ui/alert-dialog';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { resetPasswordAction } from '../lib/server/admin-server-actions';
|
||||
|
||||
const FormSchema = z.object({
|
||||
userId: z.string().uuid(),
|
||||
confirmation: z.custom<string>((value) => value === 'CONFIRM'),
|
||||
});
|
||||
|
||||
export function AdminResetPasswordDialog(
|
||||
props: React.PropsWithChildren<{
|
||||
userId: string;
|
||||
}>,
|
||||
) {
|
||||
const form = useForm({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
userId: props.userId,
|
||||
confirmation: '',
|
||||
},
|
||||
});
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await resetPasswordAction(data);
|
||||
|
||||
setSuccess(true);
|
||||
form.reset({ userId: props.userId, confirmation: '' });
|
||||
|
||||
toast.success('Password reset email successfully sent');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
|
||||
toast.error('We hit an error. Please read the logs.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Send a Reset Password Email</AlertDialogTitle>
|
||||
|
||||
<AlertDialogDescription>
|
||||
Do you want to send a reset password email to this user?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<Form {...form}>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmation"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirmation</FormLabel>
|
||||
|
||||
<FormDescription>
|
||||
Type CONFIRM to execute this request.
|
||||
</FormDescription>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="CONFIRM"
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<If condition={!!error}>
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>
|
||||
We encountered an error while sending the email
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
Please check the server logs for more details.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<If condition={success}>
|
||||
<Alert>
|
||||
<AlertTitle>
|
||||
Password reset email sent successfully
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
The password reset email has been sent to the user.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<input type="hidden" name="userId" value={props.userId} />
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
variant="destructive"
|
||||
>
|
||||
{isPending ? 'Sending...' : 'Send Reset Email'}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
ImpersonateUserSchema,
|
||||
ReactivateUserSchema,
|
||||
} from './schema/admin-actions.schema';
|
||||
import { CreateUserSchema } from './schema/create-user.schema';
|
||||
import { ResetPasswordSchema } from './schema/reset-password.schema';
|
||||
import { createAdminAccountsService } from './services/admin-accounts.service';
|
||||
import { createAdminAuthUserService } from './services/admin-auth-user.service';
|
||||
import { adminAction } from './utils/admin-action';
|
||||
@@ -150,6 +152,80 @@ export const deleteAccountAction = adminAction(
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name createUserAction
|
||||
* @description Create a new user in the system.
|
||||
*/
|
||||
export const createUserAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ email, password, emailConfirm }) => {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ email }, `Super Admin is creating a new user...`);
|
||||
|
||||
const { data, error } = await adminClient.auth.admin.createUser({
|
||||
email,
|
||||
password,
|
||||
email_confirm: emailConfirm,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
logger.error({ error }, `Error creating user`);
|
||||
throw new Error(`Error creating user: ${error.message}`);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ userId: data.user.id },
|
||||
`Super Admin has successfully created a new user`,
|
||||
);
|
||||
|
||||
revalidateAdmin();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: data.user,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: CreateUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name resetPasswordAction
|
||||
* @description Reset a user's password by sending a password reset email.
|
||||
*/
|
||||
export const resetPasswordAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ userId }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ userId }, `Super Admin is resetting user password...`);
|
||||
|
||||
const result = await service.resetPassword(userId);
|
||||
|
||||
logger.info(
|
||||
{ userId },
|
||||
`Super Admin has successfully sent password reset email`,
|
||||
);
|
||||
|
||||
revalidateAdmin();
|
||||
|
||||
return result;
|
||||
},
|
||||
{
|
||||
schema: ResetPasswordSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
function revalidateAdmin() {
|
||||
revalidatePath('/admin', 'layout');
|
||||
}
|
||||
|
||||
function getAdminAuthService() {
|
||||
const client = getSupabaseServerClient();
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
@@ -162,7 +238,3 @@ function getAdminAccountsService() {
|
||||
|
||||
return createAdminAccountsService(adminClient);
|
||||
}
|
||||
|
||||
function revalidateAdmin() {
|
||||
revalidatePath('/admin', 'layout');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateUserSchema = z.object({
|
||||
email: z.string().email({ message: 'Please enter a valid email address' }),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, { message: 'Password must be at least 8 characters' }),
|
||||
emailConfirm: z.boolean().default(false).optional(),
|
||||
});
|
||||
|
||||
export type CreateUserSchemaType = z.infer<typeof CreateUserSchema>;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Schema for resetting a user's password
|
||||
*/
|
||||
export const ResetPasswordSchema = z.object({
|
||||
userId: z.string().uuid(),
|
||||
confirmation: z.custom<string>((value) => value === 'CONFIRM'),
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createAdminAuthUserService(
|
||||
@@ -155,4 +157,47 @@ class AdminAuthUserService {
|
||||
ban_duration: banDuration,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a user's password by sending a password reset email.
|
||||
* @param userId
|
||||
*/
|
||||
async resetPassword(userId: string) {
|
||||
await this.assertUserIsNotCurrentSuperAdmin(userId);
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await this.adminClient.auth.admin.getUserById(userId);
|
||||
|
||||
if (error ?? !user) {
|
||||
throw new Error(`Error fetching user`);
|
||||
}
|
||||
|
||||
const email = user.email;
|
||||
|
||||
if (!email) {
|
||||
throw new Error(`User has no email. Cannot reset password`);
|
||||
}
|
||||
|
||||
// Get the site URL from environment variable
|
||||
const siteUrl = z.string().url().parse(process.env.NEXT_PUBLIC_SITE_URL);
|
||||
|
||||
const redirectTo = `${siteUrl}/update-password`;
|
||||
|
||||
const { error: resetError } =
|
||||
await this.adminClient.auth.resetPasswordForEmail(email, {
|
||||
redirectTo,
|
||||
});
|
||||
|
||||
if (resetError) {
|
||||
throw new Error(
|
||||
`Error sending password reset email: ${resetError.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user