Add account hierarchy framework with migrations, RLS policies, and UI components
This commit is contained in:
@@ -51,14 +51,16 @@ import {
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { MultiFactorAuthSetupDialog } from './multi-factor-auth-setup-dialog';
|
||||
import { PasskeySetupDialog } from './passkey-setup-dialog';
|
||||
|
||||
export function MultiFactorAuthFactorsList(props: { userId: string }) {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<FactorsTableContainer userId={props.userId} />
|
||||
|
||||
<div>
|
||||
<div className={'flex flex-wrap gap-2'}>
|
||||
<MultiFactorAuthSetupDialog userId={props.userId} />
|
||||
<PasskeySetupDialog userId={props.userId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Fingerprint, TriangleAlert } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { useFactorsMutationKey } from '@kit/supabase/hooks/use-user-factors-mutation-key';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@kit/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { refreshAuthSession } from '../../../server/personal-accounts-server-actions';
|
||||
|
||||
export function PasskeySetupDialog(props: { userId: string }) {
|
||||
const t = useTranslations();
|
||||
const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog();
|
||||
|
||||
const onEnrollSuccess = useCallback(() => {
|
||||
setIsPending(false);
|
||||
setOpen(false);
|
||||
|
||||
return toast.success(t(`account.passkeySetupSuccess` as never));
|
||||
}, [t, setIsPending, setOpen]);
|
||||
|
||||
return (
|
||||
<Dialog {...dialogProps}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button variant={'outline'}>
|
||||
<Fingerprint className={'h-4'} />
|
||||
<Trans i18nKey={'account.setupPasskeyButtonLabel'} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DialogContent showCloseButton={!isPending}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'account.setupPasskeyButtonLabel'} />
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans i18nKey={'account.passkeyDescription'} />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<PasskeySetupForm
|
||||
userId={props.userId}
|
||||
isPending={isPending}
|
||||
setIsPending={setIsPending}
|
||||
onCancel={() => setOpen(false)}
|
||||
onEnrolled={onEnrollSuccess}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PasskeySetupForm({
|
||||
onEnrolled,
|
||||
onCancel,
|
||||
userId,
|
||||
isPending,
|
||||
setIsPending,
|
||||
}: {
|
||||
userId: string;
|
||||
onCancel: () => void;
|
||||
onEnrolled: () => void;
|
||||
isPending: boolean;
|
||||
setIsPending: (pending: boolean) => void;
|
||||
}) {
|
||||
const registerPasskeyMutation = useRegisterPasskey(userId);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
}),
|
||||
),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async ({ name }: { name: string }) => {
|
||||
setIsPending(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await registerPasskeyMutation.mutateAsync(name);
|
||||
await refreshAuthSession();
|
||||
onEnrolled();
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || 'Unknown error';
|
||||
setIsPending(false);
|
||||
setError(message);
|
||||
}
|
||||
},
|
||||
[onEnrolled, registerPasskeyMutation, setIsPending],
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={'flex w-full flex-col space-y-4'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<TriangleAlert className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account.passkeySetupErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className={'flex justify-end space-x-2'}>
|
||||
<Button variant={'outline'} onClick={onCancel}>
|
||||
<Trans i18nKey={'common.cancel'} />
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => setError('')}>
|
||||
<Trans i18nKey={'common.retry'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form className={'w-full'} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-center space-y-2 rounded-lg border p-4'
|
||||
}
|
||||
>
|
||||
<Fingerprint className={'text-muted-foreground h-12 w-12'} />
|
||||
|
||||
<p className={'text-muted-foreground text-center text-sm'}>
|
||||
<Trans i18nKey={'account.passkeySetupInstructions'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
name={'name'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'account.passkeyNameLabel'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete={'off'}
|
||||
placeholder={'z.B. MacBook Touch ID'}
|
||||
required
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans i18nKey={'account.passkeyNameHint'} />
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={'flex justify-end space-x-2'}>
|
||||
<Button
|
||||
type={'button'}
|
||||
variant={'ghost'}
|
||||
disabled={isPending}
|
||||
onClick={onCancel}
|
||||
>
|
||||
<Trans i18nKey={'common.cancel'} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type={'submit'}
|
||||
disabled={isPending || !form.formState.isValid}
|
||||
>
|
||||
<If condition={isPending}>
|
||||
<Trans i18nKey={'account.registeringPasskey'} />
|
||||
</If>
|
||||
|
||||
<If condition={!isPending}>
|
||||
<Fingerprint className={'h-4'} />
|
||||
<Trans i18nKey={'account.registerPasskey'} />
|
||||
</If>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function useRegisterPasskey(userId: string) {
|
||||
const client = useSupabase();
|
||||
const queryClient = useQueryClient();
|
||||
const mutationKey = useFactorsMutationKey(userId);
|
||||
|
||||
const mutationFn = async (friendlyName: string) => {
|
||||
const { data, error } = await client.auth.mfa.webauthn.register({
|
||||
friendlyName,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
mutationKey,
|
||||
onSuccess() {
|
||||
return queryClient.refetchQueries({
|
||||
queryKey: mutationKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user