Files
soorya-carpet/resources/js/components/two-factor-setup-modal.tsx
Chief-spartan-117 2162084b95
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
INit
2025-09-28 19:55:43 +05:45

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