339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
import InputError from '@/components/input-error';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
InputOTP,
|
|
InputOTPGroup,
|
|
InputOTPSlot,
|
|
} from '@/components/ui/input-otp';
|
|
import { useClipboard } from '@/hooks/use-clipboard';
|
|
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
|
|
import { confirm } from '@/routes/two-factor';
|
|
import { Form } from '@inertiajs/react';
|
|
import { REGEXP_ONLY_DIGITS } from 'input-otp';
|
|
import { Check, Copy, Loader2, ScanLine } from 'lucide-react';
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import AlertError from './alert-error';
|
|
|
|
function GridScanIcon() {
|
|
return (
|
|
<div className="mb-3 rounded-full border border-border bg-card p-0.5 shadow-sm">
|
|
<div className="relative overflow-hidden rounded-full border border-border bg-muted p-2.5">
|
|
<div className="absolute inset-0 grid grid-cols-5 opacity-50">
|
|
{Array.from({ length: 5 }, (_, i) => (
|
|
<div
|
|
key={`col-${i + 1}`}
|
|
className="border-r border-border last:border-r-0"
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="absolute inset-0 grid grid-rows-5 opacity-50">
|
|
{Array.from({ length: 5 }, (_, i) => (
|
|
<div
|
|
key={`row-${i + 1}`}
|
|
className="border-b border-border last:border-b-0"
|
|
/>
|
|
))}
|
|
</div>
|
|
<ScanLine className="relative z-20 size-6 text-foreground" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TwoFactorSetupStep({
|
|
qrCodeSvg,
|
|
manualSetupKey,
|
|
buttonText,
|
|
onNextStep,
|
|
errors,
|
|
}: {
|
|
qrCodeSvg: string | null;
|
|
manualSetupKey: string | null;
|
|
buttonText: string;
|
|
onNextStep: () => void;
|
|
errors: string[];
|
|
}) {
|
|
const [copiedText, copy] = useClipboard();
|
|
const IconComponent = copiedText === manualSetupKey ? Check : Copy;
|
|
|
|
return (
|
|
<>
|
|
{errors?.length ? (
|
|
<AlertError errors={errors} />
|
|
) : (
|
|
<>
|
|
<div className="mx-auto flex max-w-md overflow-hidden">
|
|
<div className="mx-auto aspect-square w-64 rounded-lg border border-border">
|
|
<div className="z-10 flex h-full w-full items-center justify-center p-5">
|
|
{qrCodeSvg ? (
|
|
<div
|
|
dangerouslySetInnerHTML={{
|
|
__html: qrCodeSvg,
|
|
}}
|
|
/>
|
|
) : (
|
|
<Loader2 className="flex size-4 animate-spin" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex w-full space-x-5">
|
|
<Button className="w-full" onClick={onNextStep}>
|
|
{buttonText}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="relative flex w-full items-center justify-center">
|
|
<div className="absolute inset-0 top-1/2 h-px w-full bg-border" />
|
|
<span className="relative bg-card px-2 py-1">
|
|
or, enter the code manually
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex w-full space-x-2">
|
|
<div className="flex w-full items-stretch overflow-hidden rounded-xl border border-border">
|
|
{!manualSetupKey ? (
|
|
<div className="flex h-full w-full items-center justify-center bg-muted p-3">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
<input
|
|
type="text"
|
|
readOnly
|
|
value={manualSetupKey}
|
|
className="h-full w-full bg-background p-3 text-foreground outline-none"
|
|
/>
|
|
<button
|
|
onClick={() => copy(manualSetupKey)}
|
|
className="border-l border-border px-3 hover:bg-muted"
|
|
>
|
|
<IconComponent className="w-4" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function TwoFactorVerificationStep({
|
|
onClose,
|
|
onBack,
|
|
}: {
|
|
onClose: () => void;
|
|
onBack: () => void;
|
|
}) {
|
|
const [code, setCode] = useState<string>('');
|
|
const pinInputContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
setTimeout(() => {
|
|
pinInputContainerRef.current?.querySelector('input')?.focus();
|
|
}, 0);
|
|
}, []);
|
|
|
|
return (
|
|
<Form
|
|
{...confirm.form()}
|
|
onSuccess={() => onClose()}
|
|
resetOnError
|
|
resetOnSuccess
|
|
>
|
|
{({
|
|
processing,
|
|
errors,
|
|
}: {
|
|
processing: boolean;
|
|
errors?: { confirmTwoFactorAuthentication?: { code?: string } };
|
|
}) => (
|
|
<>
|
|
<div
|
|
ref={pinInputContainerRef}
|
|
className="relative w-full space-y-3"
|
|
>
|
|
<div className="flex w-full flex-col items-center space-y-3 py-2">
|
|
<InputOTP
|
|
id="otp"
|
|
name="code"
|
|
maxLength={OTP_MAX_LENGTH}
|
|
onChange={setCode}
|
|
disabled={processing}
|
|
pattern={REGEXP_ONLY_DIGITS}
|
|
>
|
|
<InputOTPGroup>
|
|
{Array.from(
|
|
{ length: OTP_MAX_LENGTH },
|
|
(_, index) => (
|
|
<InputOTPSlot
|
|
key={index}
|
|
index={index}
|
|
/>
|
|
),
|
|
)}
|
|
</InputOTPGroup>
|
|
</InputOTP>
|
|
<InputError
|
|
message={
|
|
errors?.confirmTwoFactorAuthentication?.code
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex w-full space-x-5">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="flex-1"
|
|
onClick={onBack}
|
|
disabled={processing}
|
|
>
|
|
Back
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
className="flex-1"
|
|
disabled={
|
|
processing || code.length < OTP_MAX_LENGTH
|
|
}
|
|
>
|
|
Confirm
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Form>
|
|
);
|
|
}
|
|
|
|
interface TwoFactorSetupModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
requiresConfirmation: boolean;
|
|
twoFactorEnabled: boolean;
|
|
qrCodeSvg: string | null;
|
|
manualSetupKey: string | null;
|
|
clearSetupData: () => void;
|
|
fetchSetupData: () => Promise<void>;
|
|
errors: string[];
|
|
}
|
|
|
|
export default function TwoFactorSetupModal({
|
|
isOpen,
|
|
onClose,
|
|
requiresConfirmation,
|
|
twoFactorEnabled,
|
|
qrCodeSvg,
|
|
manualSetupKey,
|
|
clearSetupData,
|
|
fetchSetupData,
|
|
errors,
|
|
}: TwoFactorSetupModalProps) {
|
|
const [showVerificationStep, setShowVerificationStep] =
|
|
useState<boolean>(false);
|
|
|
|
const modalConfig = useMemo<{
|
|
title: string;
|
|
description: string;
|
|
buttonText: string;
|
|
}>(() => {
|
|
if (twoFactorEnabled) {
|
|
return {
|
|
title: 'Two-Factor Authentication Enabled',
|
|
description:
|
|
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
|
|
buttonText: 'Close',
|
|
};
|
|
}
|
|
|
|
if (showVerificationStep) {
|
|
return {
|
|
title: 'Verify Authentication Code',
|
|
description:
|
|
'Enter the 6-digit code from your authenticator app',
|
|
buttonText: 'Continue',
|
|
};
|
|
}
|
|
|
|
return {
|
|
title: 'Enable Two-Factor Authentication',
|
|
description:
|
|
'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
|
|
buttonText: 'Continue',
|
|
};
|
|
}, [twoFactorEnabled, showVerificationStep]);
|
|
|
|
const handleModalNextStep = useCallback(() => {
|
|
if (requiresConfirmation) {
|
|
setShowVerificationStep(true);
|
|
|
|
return;
|
|
}
|
|
|
|
clearSetupData();
|
|
onClose();
|
|
}, [requiresConfirmation, clearSetupData, onClose]);
|
|
|
|
const resetModalState = useCallback(() => {
|
|
setShowVerificationStep(false);
|
|
if (twoFactorEnabled) {
|
|
clearSetupData();
|
|
}
|
|
}, [twoFactorEnabled, clearSetupData]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
resetModalState();
|
|
|
|
return;
|
|
}
|
|
|
|
if (!qrCodeSvg) {
|
|
fetchSetupData();
|
|
}
|
|
}, [isOpen, qrCodeSvg, fetchSetupData, resetModalState]);
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader className="flex items-center justify-center">
|
|
<GridScanIcon />
|
|
<DialogTitle>{modalConfig.title}</DialogTitle>
|
|
<DialogDescription className="text-center">
|
|
{modalConfig.description}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex flex-col items-center space-y-5">
|
|
{showVerificationStep ? (
|
|
<TwoFactorVerificationStep
|
|
onClose={onClose}
|
|
onBack={() => setShowVerificationStep(false)}
|
|
/>
|
|
) : (
|
|
<TwoFactorSetupStep
|
|
qrCodeSvg={qrCodeSvg}
|
|
manualSetupKey={manualSetupKey}
|
|
buttonText={modalConfig.buttonText}
|
|
onNextStep={handleModalNextStep}
|
|
errors={errors}
|
|
/>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|