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:
Giancarlo Buomprisco
2025-04-22 06:36:34 +07:00
committed by GitHub
parent e193c94f06
commit 4f41304be4
11 changed files with 2291 additions and 47 deletions

27
.aiignore Normal file
View File

@@ -0,0 +1,27 @@
# An .aiignore file follows the same syntax as a .gitignore file.
# .gitignore documentation: https://git-scm.com/docs/gitignore
# you can ignore files
.DS_Store
*.log
*.tmp
# or folders
dist/
build/
out/
.cursor
.cursorignore
database.types.ts
playwright-report
test-results
web/supabase/migrations
pnpm-lock.yaml
.env.local
.env.production.local
.idea
.vscode
.zed
tsconfig.tsbuildinfo
.windsurfrules

1715
.junie/guidelines.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,11 @@
import { ServerDataLoader } from '@makerkit/data-loader-supabase-nextjs'; import { ServerDataLoader } from '@makerkit/data-loader-supabase-nextjs';
import { AdminAccountsTable } from '@kit/admin/components/admin-accounts-table'; import { AdminAccountsTable } from '@kit/admin/components/admin-accounts-table';
import { AdminCreateUserDialog } from '@kit/admin/components/admin-create-user-dialog';
import { AdminGuard } from '@kit/admin/components/admin-guard'; import { AdminGuard } from '@kit/admin/components/admin-guard';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Button } from '@kit/ui/button';
import { PageBody, PageHeader } from '@kit/ui/page'; import { PageBody, PageHeader } from '@kit/ui/page';
interface SearchParams { interface SearchParams {
@@ -27,7 +29,13 @@ async function AccountsPage(props: AdminAccountsPageProps) {
return ( return (
<> <>
<PageHeader description={<AppBreadcrumbs />} /> <PageHeader description={<AppBreadcrumbs />}>
<div className="flex justify-end">
<AdminCreateUserDialog>
<Button data-test="admin-create-user-button">Create User</Button>
</AdminCreateUserDialog>
</div>
</PageHeader>
<PageBody> <PageBody>
<ServerDataLoader <ServerDataLoader

View File

@@ -36,6 +36,7 @@ import {
import { AdminDeleteAccountDialog } from './admin-delete-account-dialog'; import { AdminDeleteAccountDialog } from './admin-delete-account-dialog';
import { AdminDeleteUserDialog } from './admin-delete-user-dialog'; import { AdminDeleteUserDialog } from './admin-delete-user-dialog';
import { AdminImpersonateUserDialog } from './admin-impersonate-user-dialog'; import { AdminImpersonateUserDialog } from './admin-impersonate-user-dialog';
import { AdminResetPasswordDialog } from './admin-reset-password-dialog';
type Account = Database['public']['Tables']['accounts']['Row']; type Account = Database['public']['Tables']['accounts']['Row'];
@@ -203,50 +204,58 @@ function getColumns(): ColumnDef<Account>[] {
const userId = row.original.id; const userId = row.original.id;
return ( return (
<DropdownMenu> <div className={'flex justify-end'}>
<DropdownMenuTrigger asChild> <DropdownMenu>
<Button variant={'ghost'}> <DropdownMenuTrigger asChild>
<EllipsisVertical className={'h-4'} /> <Button variant={'outline'} size={'icon'}>
</Button> <EllipsisVertical className={'h-4'} />
</DropdownMenuTrigger> </Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={'end'}> <DropdownMenuContent align={'end'}>
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem> <DropdownMenuItem>
<Link <Link
className={'h-full w-full'} className={'h-full w-full'}
href={`/admin/accounts/${userId}`} href={`/admin/accounts/${userId}`}
> >
View View
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<If condition={isPersonalAccount}> <If condition={isPersonalAccount}>
<AdminImpersonateUserDialog userId={userId}> <AdminResetPasswordDialog userId={userId}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}> <DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Impersonate User Send Reset Password link
</DropdownMenuItem> </DropdownMenuItem>
</AdminImpersonateUserDialog> </AdminResetPasswordDialog>
<AdminDeleteUserDialog userId={userId}> <AdminImpersonateUserDialog userId={userId}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}> <DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Delete Personal Account Impersonate User
</DropdownMenuItem> </DropdownMenuItem>
</AdminDeleteUserDialog> </AdminImpersonateUserDialog>
</If>
<If condition={!isPersonalAccount}> <AdminDeleteUserDialog userId={userId}>
<AdminDeleteAccountDialog accountId={row.original.id}> <DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}> Delete Personal Account
Delete Team Account </DropdownMenuItem>
</DropdownMenuItem> </AdminDeleteUserDialog>
</AdminDeleteAccountDialog> </If>
</If>
</DropdownMenuGroup> <If condition={!isPersonalAccount}>
</DropdownMenuContent> <AdminDeleteAccountDialog accountId={row.original.id}>
</DropdownMenu> <DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Delete Team Account
</DropdownMenuItem>
</AdminDeleteAccountDialog>
</If>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
); );
}, },
}, },

View File

@@ -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&apos;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>
);
}

View File

@@ -74,9 +74,16 @@ export function AdminImpersonateUserDialog(
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Impersonate User</AlertDialogTitle> <AlertDialogTitle>Impersonate User</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription className={'flex flex-col space-y-1'}>
Are you sure you want to impersonate this user? You will be logged <span>
in as this user. To stop impersonating, log out. 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> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>

View File

@@ -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>
);
}

View File

@@ -15,6 +15,8 @@ import {
ImpersonateUserSchema, ImpersonateUserSchema,
ReactivateUserSchema, ReactivateUserSchema,
} from './schema/admin-actions.schema'; } 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 { createAdminAccountsService } from './services/admin-accounts.service';
import { createAdminAuthUserService } from './services/admin-auth-user.service'; import { createAdminAuthUserService } from './services/admin-auth-user.service';
import { adminAction } from './utils/admin-action'; 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() { function getAdminAuthService() {
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const adminClient = getSupabaseServerAdminClient(); const adminClient = getSupabaseServerAdminClient();
@@ -162,7 +238,3 @@ function getAdminAccountsService() {
return createAdminAccountsService(adminClient); return createAdminAccountsService(adminClient);
} }
function revalidateAdmin() {
revalidatePath('/admin', 'layout');
}

View File

@@ -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>;

View File

@@ -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'),
});

View File

@@ -2,6 +2,8 @@ import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
export function createAdminAuthUserService( export function createAdminAuthUserService(
@@ -155,4 +157,47 @@ class AdminAuthUserService {
ban_duration: banDuration, 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,
};
}
} }