Add account hierarchy framework with migrations, RLS policies, and UI components
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user