Add support for OTPs and enhance sensitive apis with OTP verification (#191)
One-Time Password (OTP) package added with comprehensive token management, including OTP verification for team account deletion and ownership transfer.
This commit is contained in:
committed by
GitHub
parent
20f7fd2c22
commit
d31f3eb993
@@ -4,9 +4,11 @@ import { useFormStatus } from 'react-dom';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
import { ErrorBoundary } from '@kit/monitoring/components';
|
||||
import { VerifyOtpForm } from '@kit/otp/components';
|
||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -18,8 +20,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from '@kit/ui/alert-dialog';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Form, FormControl, FormItem, FormLabel } from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Form } from '@kit/ui/form';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { DeletePersonalAccountSchema } from '../../schema/delete-personal-account.schema';
|
||||
@@ -46,6 +47,12 @@ export function AccountDangerZone() {
|
||||
}
|
||||
|
||||
function DeleteAccountModal() {
|
||||
const { data: user } = useUser();
|
||||
|
||||
if (!user?.email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
@@ -61,22 +68,39 @@ function DeleteAccountModal() {
|
||||
</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<ErrorBoundary fallback={<DeleteAccountErrorAlert />}>
|
||||
<DeleteAccountForm />
|
||||
<ErrorBoundary fallback={<DeleteAccountErrorContainer />}>
|
||||
<DeleteAccountForm email={user.email} />
|
||||
</ErrorBoundary>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountForm() {
|
||||
function DeleteAccountForm(props: { email: string }) {
|
||||
const form = useForm({
|
||||
resolver: zodResolver(DeletePersonalAccountSchema),
|
||||
defaultValues: {
|
||||
confirmation: '' as 'DELETE'
|
||||
otp: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { otp } = useWatch({ control: form.control });
|
||||
|
||||
if (!otp) {
|
||||
return (
|
||||
<VerifyOtpForm
|
||||
purpose={'delete-personal-account'}
|
||||
email={props.email}
|
||||
onSuccess={(otp) => form.setValue('otp', otp, { shouldValidate: true })}
|
||||
CancelButton={
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -84,9 +108,13 @@ function DeleteAccountForm() {
|
||||
action={deletePersonalAccountAction}
|
||||
className={'flex flex-col space-y-4'}
|
||||
>
|
||||
<input type="hidden" name="otp" value={otp} />
|
||||
|
||||
<div className={'flex flex-col space-y-6'}>
|
||||
<div
|
||||
className={'border-destructive text-destructive border p-4 text-sm'}
|
||||
className={
|
||||
'border-destructive text-destructive rounded-md border p-4 text-sm'
|
||||
}
|
||||
>
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<div>
|
||||
@@ -98,25 +126,6 @@ function DeleteAccountForm() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'account:deleteProfileConfirmationInputLabel'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete={'off'}
|
||||
data-test={'delete-account-input-field'}
|
||||
required
|
||||
name={'confirmation'}
|
||||
type={'text'}
|
||||
className={'w-full'}
|
||||
placeholder={''}
|
||||
pattern={`DELETE`}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
@@ -124,21 +133,21 @@ function DeleteAccountForm() {
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
|
||||
<DeleteAccountSubmitButton />
|
||||
<DeleteAccountSubmitButton disabled={!form.formState.isValid} />
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountSubmitButton() {
|
||||
function DeleteAccountSubmitButton(props: { disabled: boolean }) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-test={'confirm-delete-account-button'}
|
||||
type={'submit'}
|
||||
disabled={pending}
|
||||
disabled={pending || props.disabled}
|
||||
name={'action'}
|
||||
variant={'destructive'}
|
||||
>
|
||||
@@ -151,6 +160,20 @@ function DeleteAccountSubmitButton() {
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountErrorContainer() {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<DeleteAccountErrorAlert />
|
||||
|
||||
<div>
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
|
||||
@@ -412,8 +412,15 @@ function FactorNameForm(
|
||||
}
|
||||
|
||||
function QrImage({ src }: { src: string }) {
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img alt={'QR Code'} src={src} width={160} height={160} className={'p-2 bg-white'} />;
|
||||
return (
|
||||
<img
|
||||
alt={'QR Code'}
|
||||
src={src}
|
||||
width={160}
|
||||
height={160}
|
||||
className={'bg-white p-2'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useEnrollFactor(userId: string) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const DeletePersonalAccountSchema = z.object({
|
||||
confirmation: z.string().refine((value) => value === 'DELETE'),
|
||||
otp: z.string().min(6),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { createOtpApi } from '@kit/otp';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
@@ -40,6 +41,12 @@ export const deletePersonalAccountAction = enhanceAction(
|
||||
userId: user.id,
|
||||
};
|
||||
|
||||
const otp = formData.get('otp') as string;
|
||||
|
||||
if (!otp) {
|
||||
throw new Error('OTP is required');
|
||||
}
|
||||
|
||||
if (!enableAccountDeletion) {
|
||||
logger.warn(ctx, `Account deletion is not enabled`);
|
||||
|
||||
@@ -48,14 +55,33 @@ export const deletePersonalAccountAction = enhanceAction(
|
||||
|
||||
logger.info(ctx, `Deleting account...`);
|
||||
|
||||
// verify the OTP
|
||||
const client = getSupabaseServerClient();
|
||||
const otpApi = createOtpApi(client);
|
||||
|
||||
const otpResult = await otpApi.verifyToken({
|
||||
token: otp,
|
||||
userId: user.id,
|
||||
purpose: 'delete-personal-account',
|
||||
});
|
||||
|
||||
if (!otpResult.valid) {
|
||||
throw new Error('Invalid OTP');
|
||||
}
|
||||
|
||||
// validate the user ID matches the nonce's user ID
|
||||
if (otpResult.user_id !== user.id) {
|
||||
logger.error(
|
||||
ctx,
|
||||
`This token was meant to be used by a different user. Exiting.`,
|
||||
);
|
||||
|
||||
throw new Error('Nonce mismatch');
|
||||
}
|
||||
|
||||
// create a new instance of the personal accounts service
|
||||
const service = createDeletePersonalAccountService();
|
||||
|
||||
// sign out the user before deleting their account
|
||||
await client.auth.signOut();
|
||||
|
||||
// delete the user's account and cancel all subscriptions
|
||||
await service.deletePersonalAccount({
|
||||
adminClient: getSupabaseServerAdminClient(),
|
||||
@@ -63,6 +89,9 @@ export const deletePersonalAccountAction = enhanceAction(
|
||||
userEmail: user.email ?? null,
|
||||
});
|
||||
|
||||
// sign out the user after deleting their account
|
||||
await client.auth.signOut();
|
||||
|
||||
logger.info(ctx, `Account request successfully sent`);
|
||||
|
||||
// clear the cache for all pages
|
||||
|
||||
@@ -47,6 +47,12 @@ class DeletePersonalAccountService {
|
||||
// execute the deletion of the user
|
||||
try {
|
||||
await params.adminClient.auth.admin.deleteUser(userId);
|
||||
|
||||
logger.info(ctx, 'User successfully deleted!');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
@@ -58,7 +64,5 @@ class DeletePersonalAccountService {
|
||||
|
||||
throw new Error('Error deleting user');
|
||||
}
|
||||
|
||||
logger.info(ctx, 'User successfully deleted!');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user