Files
myeasycms-v2/packages/features/auth/src/components/multi-factor-challenge-container.tsx
Zaid Marzguioui 0bd5d0cf42
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m40s
Workflow / ⚫️ Test (push) Has been skipped
fix: QA audit — lint cleanup, i18n fixes, module visibility, sidebar UX
- Fix 97 lint errors → 0 (unused imports, params, variables across 40+ files)
- Fix i18n key format: colon → dot notation for next-intl compatibility
- Add missing i18n keys (routes.application, routes.home, confirm)
- Fix module visibility: sidebar now respects per-account DB features
- Fix inject function: use dot-notation keys, add collapsed:true defaults
- Fix ConfirmDialog: use useTranslations instead of hardcoded German defaults
- Fix events page: replace placeholder 'Beschreibung' with proper description
- Fix Dockerfile: add NEXT_PUBLIC_CI ARG for Docker builds
- Collapse secondary sidebar sections by default for cleaner UX
2026-04-02 14:39:20 +02:00

449 lines
12 KiB
TypeScript

'use client';
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 { Fingerprint, KeyRound, TriangleAlert } from 'lucide-react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { useFetchAuthFactors } from '@kit/supabase/hooks/use-fetch-mfa-factors';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormMessage,
} from '@kit/ui/form';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from '@kit/ui/input-otp';
import { Spinner } from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans';
export function MultiFactorChallengeContainer({
paths,
userId,
}: React.PropsWithChildren<{
userId: string;
paths: {
redirectPath: string;
};
}>) {
const router = useRouter();
const [selectedFactor, setSelectedFactor] = useState<Factor | null>(null);
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({
verificationCode: z.string().min(6).max(6),
}),
),
defaultValues: {
verificationCode: '',
},
});
return (
<Form {...verificationCodeForm}>
<form
className={'w-full'}
onSubmit={verificationCodeForm.handleSubmit(async (data) => {
await verifyMFAChallenge.mutateAsync({
factorId: factor.id,
verificationCode: data.verificationCode,
});
})}
>
<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={'auth.verifyCodeHeading'} />
</Heading>
</div>
<div className={'flex w-full flex-col gap-y-2.5'}>
<div className={'flex flex-col gap-y-4'}>
<If condition={verifyMFAChallenge.error}>
<Alert variant={'destructive'}>
<TriangleAlert className={'h-5'} />
<AlertTitle>
<Trans i18nKey={'account.invalidVerificationCodeHeading'} />
</AlertTitle>
<AlertDescription>
<Trans
i18nKey={'account.invalidVerificationCodeDescription'}
/>
</AlertDescription>
</Alert>
</If>
<FormField
name={'verificationCode'}
render={({ field }) => {
return (
<FormItem
className={
'mx-auto flex flex-col items-center justify-center'
}
>
<FormControl>
<InputOTP {...field} maxLength={6} minLength={6}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription className="text-center">
<Trans
i18nKey={'account.verifyActivationCodeDescription'}
/>
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
</div>
</div>
<Button
type="submit"
className="w-full"
data-test={'submit-mfa-button'}
disabled={
verifyMFAChallenge.isPending ||
verifyMFAChallenge.isSuccess ||
!verificationCodeForm.formState.isValid
}
>
<If condition={verifyMFAChallenge.isPending}>
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
<Trans i18nKey={'account.verifyingCode'} />
</span>
</If>
<If condition={verifyMFAChallenge.isSuccess}>
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
<Trans i18nKey={'auth.redirecting'} />
</span>
</If>
<If
condition={
!verifyMFAChallenge.isPending && !verifyMFAChallenge.isSuccess
}
>
<Trans i18nKey={'account.submitVerificationCode'} />
</If>
</Button>
</div>
</form>
</Form>
);
}
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'];
const mutationFn = async (params: {
factorId: string;
verificationCode: string;
}) => {
const { factorId, verificationCode: code } = params;
const response = await client.auth.mfa.challengeAndVerify({
factorId,
code,
});
if (response.error) {
throw response.error;
}
return response.data;
};
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: Factor) => void;
}>) {
const signOut = useSignOut();
const { data: factors, isLoading, error } = useFetchAuthFactors(userId);
const isSuccess = factors && !isLoading && !error;
const signOutFn = useEffectEvent(() => {
void signOut.mutateAsync();
});
useEffect(() => {
// If there is an error, sign out
if (error) {
void signOutFn();
}
}, [error]);
useEffect(() => {
if (!isSuccess) return;
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]);
}
});
if (isLoading) {
return (
<div className={'flex flex-col items-center space-y-4 py-8'}>
<Spinner />
<div className={'text-sm'}>
<Trans i18nKey={'account.loadingFactors'} />
</div>
</div>
);
}
if (error) {
return (
<div className={'w-full'}>
<Alert variant={'destructive'}>
<TriangleAlert className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account.factorsListError'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account.factorsListErrorDescription'} />
</AlertDescription>
</Alert>
</div>
);
}
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'}>
<div>
<span className={'font-medium'}>
<Trans i18nKey={'account.selectFactor'} />
</span>
</div>
<div className={'flex flex-col space-y-2'}>
{totpFactors.map((factor) => (
<div key={factor.id}>
<Button
variant={'outline'}
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>
))}
</div>
</div>
);
}