Add account hierarchy framework with migrations, RLS policies, and UI components

This commit is contained in:
T. Zehetbauer
2026-03-31 22:18:04 +02:00
parent 7e7da0b465
commit 59546ad6d2
262 changed files with 11671 additions and 3927 deletions

View File

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

View File

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