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,9 +204,10 @@ function getColumns(): ColumnDef<Account>[] {
const userId = row.original.id; const userId = row.original.id;
return ( return (
<div className={'flex justify-end'}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant={'ghost'}> <Button variant={'outline'} size={'icon'}>
<EllipsisVertical className={'h-4'} /> <EllipsisVertical className={'h-4'} />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -224,6 +226,12 @@ function getColumns(): ColumnDef<Account>[] {
</DropdownMenuItem> </DropdownMenuItem>
<If condition={isPersonalAccount}> <If condition={isPersonalAccount}>
<AdminResetPasswordDialog userId={userId}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Send Reset Password link
</DropdownMenuItem>
</AdminResetPasswordDialog>
<AdminImpersonateUserDialog userId={userId}> <AdminImpersonateUserDialog userId={userId}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}> <DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Impersonate User Impersonate User
@@ -247,6 +255,7 @@ function getColumns(): ColumnDef<Account>[] {
</DropdownMenuGroup> </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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'}>
<span>
Are you sure you want to impersonate this user? You will be logged Are you sure you want to impersonate this user? You will be logged
in as this user. To stop impersonating, log out. 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,
};
}
} }