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

@@ -1,12 +1,14 @@
'use client';
import { useEffect, useEffectEvent } from 'react';
import { useCallback, useEffect, useEffectEvent, useState } from 'react';
import { useRouter } from 'next/navigation';
import type { Factor } from '@supabase/supabase-js';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query';
import { TriangleAlert } from 'lucide-react';
import { Fingerprint, KeyRound, TriangleAlert } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
@@ -44,49 +46,58 @@ export function MultiFactorChallengeContainer({
};
}>) {
const router = useRouter();
const [selectedFactor, setSelectedFactor] = useState<Factor | null>(null);
const verifyMFAChallenge = useVerifyMFAChallenge({
onSuccess: () => {
router.replace(paths.redirectPath);
},
});
const onSuccess = useCallback(() => {
router.replace(paths.redirectPath);
}, [router, paths.redirectPath]);
if (!selectedFactor) {
return (
<FactorsListContainer userId={userId} onSelect={setSelectedFactor} />
);
}
if (selectedFactor.factor_type === 'webauthn') {
return (
<WebAuthnChallengeView
factor={selectedFactor}
onSuccess={onSuccess}
onBack={() => setSelectedFactor(null)}
/>
);
}
return <TotpChallengeView factor={selectedFactor} onSuccess={onSuccess} />;
}
function TotpChallengeView({
factor,
onSuccess,
}: {
factor: Factor;
onSuccess: () => void;
}) {
const verifyMFAChallenge = useVerifyMFAChallenge({ onSuccess });
const verificationCodeForm = useForm({
resolver: zodResolver(
z.object({
factorId: z.string().min(1),
verificationCode: z.string().min(6).max(6),
}),
),
defaultValues: {
factorId: '',
verificationCode: '',
},
});
const factorId = useWatch({
name: 'factorId',
control: verificationCodeForm.control,
});
if (!factorId) {
return (
<FactorsListContainer
userId={userId}
onSelect={(factorId) => {
verificationCodeForm.setValue('factorId', factorId);
}}
/>
);
}
return (
<Form {...verificationCodeForm}>
<form
className={'w-full'}
onSubmit={verificationCodeForm.handleSubmit(async (data) => {
await verifyMFAChallenge.mutateAsync({
factorId,
factorId: factor.id,
verificationCode: data.verificationCode,
});
})}
@@ -191,6 +202,97 @@ export function MultiFactorChallengeContainer({
);
}
function WebAuthnChallengeView({
factor,
onSuccess,
onBack,
}: {
factor: Factor;
onSuccess: () => void;
onBack: () => void;
}) {
const authenticatePasskey = useAuthenticatePasskey({ onSuccess });
const [autoTriggered, setAutoTriggered] = useState(false);
// Auto-trigger the passkey challenge on mount
useEffect(() => {
if (!autoTriggered) {
setAutoTriggered(true);
authenticatePasskey.mutate(factor.id);
}
}, [autoTriggered, authenticatePasskey, factor.id]);
return (
<div className={'flex flex-col items-center gap-y-6'}>
<div className="flex flex-col items-center gap-y-4">
<Heading level={5}>
<Trans i18nKey={'account.passkeyVerifyHeading'} />
</Heading>
</div>
<If condition={authenticatePasskey.error}>
<Alert variant={'destructive'}>
<TriangleAlert className={'h-5'} />
<AlertTitle>
<Trans i18nKey={'account.passkeyVerifyErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account.passkeyVerifyErrorDescription'} />
</AlertDescription>
</Alert>
</If>
<div
className={'flex flex-col items-center space-y-2 rounded-lg border p-6'}
>
<Fingerprint className={'text-muted-foreground h-12 w-12'} />
<p className={'text-muted-foreground text-center text-sm'}>
<If condition={authenticatePasskey.isPending}>
<Trans i18nKey={'account.passkeyVerifyWaiting'} />
</If>
<If condition={authenticatePasskey.isSuccess}>
<Trans i18nKey={'auth.redirecting'} />
</If>
<If
condition={
!authenticatePasskey.isPending && !authenticatePasskey.isSuccess
}
>
<Trans i18nKey={'account.passkeyVerifyPrompt'} />
</If>
</p>
</div>
<div className={'flex w-full gap-2'}>
<Button
variant={'outline'}
className={'flex-1'}
onClick={onBack}
disabled={authenticatePasskey.isPending}
>
<Trans i18nKey={'common.back'} />
</Button>
<Button
className={'flex-1'}
onClick={() => authenticatePasskey.mutate(factor.id)}
disabled={
authenticatePasskey.isPending || authenticatePasskey.isSuccess
}
>
<Fingerprint className={'h-4'} />
<Trans i18nKey={'account.passkeyRetry'} />
</Button>
</div>
</div>
);
}
function useVerifyMFAChallenge({ onSuccess }: { onSuccess: () => void }) {
const client = useSupabase();
const mutationKey = ['mfa-verify-challenge'];
@@ -216,12 +318,31 @@ function useVerifyMFAChallenge({ onSuccess }: { onSuccess: () => void }) {
return useMutation({ mutationKey, mutationFn, onSuccess });
}
function useAuthenticatePasskey({ onSuccess }: { onSuccess: () => void }) {
const client = useSupabase();
const mutationKey = ['mfa-webauthn-authenticate'];
const mutationFn = async (factorId: string) => {
const { data, error } = await client.auth.mfa.webauthn.authenticate({
factorId,
});
if (error) {
throw error;
}
return data;
};
return useMutation({ mutationKey, mutationFn, onSuccess });
}
function FactorsListContainer({
onSelect,
userId,
}: React.PropsWithChildren<{
userId: string;
onSelect: (factor: string) => void;
onSelect: (factor: Factor) => void;
}>) {
const signOut = useSignOut();
const { data: factors, isLoading, error } = useFetchAuthFactors(userId);
@@ -240,13 +361,16 @@ function FactorsListContainer({
}, [error]);
useEffect(() => {
// If there is only one factor, select it automatically
if (isSuccess && factors.totp.length === 1) {
const factorId = factors.totp[0]?.id;
if (!isSuccess) return;
if (factorId) {
onSelect(factorId);
}
const allVerified = [
...(factors.totp ?? []),
...((factors as Record<string, Factor[]>).webauthn ?? []),
];
// If there is only one factor, select it automatically
if (allVerified.length === 1 && allVerified[0]) {
onSelect(allVerified[0]);
}
});
@@ -280,7 +404,9 @@ function FactorsListContainer({
);
}
const verifiedFactors = factors?.totp ?? [];
const totpFactors = factors?.totp ?? [];
const webauthnFactors =
(factors as Record<string, Factor[]> | undefined)?.webauthn ?? [];
return (
<div className={'animate-in fade-in flex flex-col space-y-4 duration-500'}>
@@ -291,13 +417,27 @@ function FactorsListContainer({
</div>
<div className={'flex flex-col space-y-2'}>
{verifiedFactors.map((factor) => (
{totpFactors.map((factor) => (
<div key={factor.id}>
<Button
variant={'outline'}
className={'w-full'}
onClick={() => onSelect(factor.id)}
className={'w-full justify-start gap-2'}
onClick={() => onSelect(factor)}
>
<KeyRound className={'h-4 w-4'} />
{factor.friendly_name}
</Button>
</div>
))}
{webauthnFactors.map((factor) => (
<div key={factor.id}>
<Button
variant={'outline'}
className={'w-full justify-start gap-2'}
onClick={() => onSelect(factor)}
>
<Fingerprint className={'h-4 w-4'} />
{factor.friendly_name}
</Button>
</div>