Spaces:
Sleeping
Sleeping
| import { Form } from '@inertiajs/react'; | |
| import { REGEXP_ONLY_DIGITS } from 'input-otp'; | |
| import { Check, Copy, ScanLine } from 'lucide-react'; | |
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | |
| import AlertError from '@/components/alert-error'; | |
| 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 { Spinner } from '@/components/ui/spinner'; | |
| import { useAppearance } from '@/hooks/use-appearance'; | |
| import { useClipboard } from '@/hooks/use-clipboard'; | |
| import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth'; | |
| import { confirm } from '@/routes/two-factor'; | |
| 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 { resolvedAppearance } = useAppearance(); | |
| 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 | |
| className="aspect-square w-full rounded-lg bg-white p-2 [&_svg]:size-full" | |
| dangerouslySetInnerHTML={{ | |
| __html: qrCodeSvg, | |
| }} | |
| style={{ | |
| filter: | |
| resolvedAppearance === 'dark' | |
| ? 'invert(1) brightness(1.5)' | |
| : undefined, | |
| }} | |
| /> | |
| ) : ( | |
| <Spinner /> | |
| )} | |
| </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"> | |
| <Spinner /> | |
| </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} | |
| autoFocus | |
| > | |
| <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> | |
| ); | |
| } | |
| type Props = { | |
| 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, | |
| }: Props) { | |
| 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 resetModalState = useCallback(() => { | |
| setShowVerificationStep(false); | |
| clearSetupData(); | |
| }, [clearSetupData]); | |
| const handleClose = useCallback(() => { | |
| resetModalState(); | |
| onClose(); | |
| }, [onClose, resetModalState]); | |
| const handleModalNextStep = useCallback(() => { | |
| if (requiresConfirmation) { | |
| setShowVerificationStep(true); | |
| return; | |
| } | |
| handleClose(); | |
| }, [requiresConfirmation, handleClose]); | |
| const fetchSetupDataRef = useRef(fetchSetupData); | |
| useEffect(() => { | |
| fetchSetupDataRef.current = fetchSetupData; | |
| }, [fetchSetupData]); | |
| useEffect(() => { | |
| if (isOpen && !qrCodeSvg) { | |
| fetchSetupDataRef.current(); | |
| } | |
| }, [isOpen, qrCodeSvg]); | |
| return ( | |
| <Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}> | |
| <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={handleClose} | |
| onBack={() => setShowVerificationStep(false)} | |
| /> | |
| ) : ( | |
| <TwoFactorSetupStep | |
| qrCodeSvg={qrCodeSvg} | |
| manualSetupKey={manualSetupKey} | |
| buttonText={modalConfig.buttonText} | |
| onNextStep={handleModalNextStep} | |
| errors={errors} | |
| /> | |
| )} | |
| </div> | |
| </DialogContent> | |
| </Dialog> | |
| ); | |
| } | |