diff --git a/frontend/src/components/mfa/BackupCodeTicket.tsx b/frontend/src/components/mfa/BackupCodeTicket.tsx new file mode 100644 index 00000000..106f4597 --- /dev/null +++ b/frontend/src/components/mfa/BackupCodeTicket.tsx @@ -0,0 +1,29 @@ +import { cn } from '@/lib/utils'; + +export function BackupCodeTicket({ codes }: { codes: string[] }) { + return ( +
+
+ Recovery codes + {codes.length} issued +
+
    + {codes.map((c, i) => ( +
  1. + + {String(i + 1).padStart(2, '0')} + + {c} +
  2. + ))} +
+
+ ); +} diff --git a/frontend/src/components/mfa/MfaBackupCodesDialog.tsx b/frontend/src/components/mfa/MfaBackupCodesDialog.tsx index 04ca78d1..886f0e44 100644 --- a/frontend/src/components/mfa/MfaBackupCodesDialog.tsx +++ b/frontend/src/components/mfa/MfaBackupCodesDialog.tsx @@ -1,21 +1,14 @@ import { useRef, useState } from 'react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from '@/components/ui/dialog'; +import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal'; import { Button } from '@/components/ui/button'; import { Check, Copy, Download } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { copyToClipboard } from '@/lib/clipboard'; -import { cn } from '@/lib/utils'; import { TOTP_LENGTH, normalizeTotpInput } from '@/lib/mfa'; import { OtpDigitField } from '@/components/auth/OtpDigitField'; import { ErrorRail } from '@/components/auth/ErrorRail'; +import { BackupCodeTicket } from './BackupCodeTicket'; interface MfaBackupCodesDialogProps { open: boolean; @@ -122,7 +115,8 @@ export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaB }; return ( - { if (!next) { @@ -132,108 +126,73 @@ export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaB onOpenChange(next); }} > - -
- + - -
- SENCHO · MFA + {step === 'confirm' && ( + <> + +

+ Enter a code from your authenticator to generate a new set. The previous codes stop working immediately. +

+ + {error && {error}} +
+ onOpenChange(false)} disabled={loading}> + Cancel + + } + primary={null} + /> + + )} + + {step === 'show' && ( + <> + + Previous codes have been invalidated. +

+ Each code can be used once. Store them safely. They will not be shown again. +

+ +
+ +
- - {step === 'confirm' ? 'Confirm identity' : 'New recovery codes'} - - - Replace your backup codes with a freshly generated set. The previous - set stops working immediately. - - - -
- {step === 'confirm' && ( -
-

- Enter a code from your authenticator to generate a new set. The previous codes stop working immediately. -

- - {error && {error}} - - - -
- )} - - {step === 'show' && ( -
- Previous codes have been invalidated. -

- Each code can be used once. Store them safely. They will not be shown again. -

- -
- - -
- - - -
- )} -
-
- -
- ); -} - -function BackupCodeTicket({ codes }: { codes: string[] }) { - return ( -
-
- Recovery codes - {codes.length} issued -
-
    - {codes.map((c, i) => ( -
  1. - - {String(i + 1).padStart(2, '0')} - - {c} -
  2. - ))} -
-
+ + + + Done + + } + /> + + )} + ); } @@ -247,4 +206,3 @@ function WarningRail({ children }: { children: React.ReactNode }) { ); } - diff --git a/frontend/src/components/mfa/MfaDisableDialog.tsx b/frontend/src/components/mfa/MfaDisableDialog.tsx index 0cf9271e..6516bba7 100644 --- a/frontend/src/components/mfa/MfaDisableDialog.tsx +++ b/frontend/src/components/mfa/MfaDisableDialog.tsx @@ -1,13 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { - AlertDialog, - AlertDialogContent, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogCancel, -} from '@/components/ui/alert-dialog'; +import { Modal, ModalDestructiveHeader, ModalBody, ModalFooter } from '@/components/ui/modal'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { toast } from '@/components/ui/toast-store'; @@ -127,76 +119,70 @@ export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableD }; return ( - - -
- - - -
- SENCHO · MFA · DISABLE -
- - Turn off two-factor - - - Disabling 2FA removes this login layer. Your backup codes become invalid. Confirm with a current code to proceed. - -
- -
- {useBackup ? ( -
- - Backup code · 10 chars - - handleBackupChange(e.target.value)} - placeholder="ABCDE-FGHIJ" - className="h-12 bg-background/60 border-card-border text-center font-mono text-lg tabular-nums tracking-[0.3em] shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.25)] focus-visible:border-brand/60 focus-visible:ring-2 focus-visible:ring-brand/40" - /> -
- ) : ( - - )} - - - - {error && {error}} + + + +

+ Disabling 2FA removes this login layer. Your backup codes become invalid. + Confirm with a current code to proceed. +

+ {useBackup ? ( +
+ + Backup code · 10 chars + + handleBackupChange(e.target.value)} + placeholder="ABCDE-FGHIJ" + className="h-12 bg-background/60 border-card-border text-center font-mono text-lg tabular-nums tracking-[0.3em] shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.25)] focus-visible:border-brand/60 focus-visible:ring-2 focus-visible:ring-brand/40" + />
- - - Cancel - - -
- - + ) : ( + + )} + + {error && {error}} + + onOpenChange(false)} disabled={loading}> + Cancel + + } + primary={ + + } + /> + ); } diff --git a/frontend/src/components/mfa/MfaEnrollDialog.tsx b/frontend/src/components/mfa/MfaEnrollDialog.tsx index 91a5cc08..36ef3ae6 100644 --- a/frontend/src/components/mfa/MfaEnrollDialog.tsx +++ b/frontend/src/components/mfa/MfaEnrollDialog.tsx @@ -1,13 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { QRCodeSVG } from 'qrcode.react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from '@/components/ui/dialog'; +import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal'; import { Button } from '@/components/ui/button'; import { ArrowRight, Check, Copy, Download, Loader2 } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; @@ -17,6 +10,7 @@ import { cn } from '@/lib/utils'; import { TOTP_LENGTH, normalizeTotpInput } from '@/lib/mfa'; import { OtpDigitField } from '@/components/auth/OtpDigitField'; import { ErrorRail } from '@/components/auth/ErrorRail'; +import { BackupCodeTicket } from './BackupCodeTicket'; interface MfaEnrollDialogProps { open: boolean; @@ -158,134 +152,137 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia onEnrolled(); }; + const title = + step === 'qr' ? 'Pair your authenticator' : + step === 'confirm' ? 'Confirm the pairing' : + 'Save your recovery codes'; + return ( - { if (!next && step === 'backup') onEnrolled(); onOpenChange(next); }} > - -
- + + - -
- SENCHO · MFA + {step === 'qr' && ( + <> + +

+ Scan the code with 1Password, Bitwarden, Google Authenticator, or any TOTP app. +

+
+ {otpauthUri ? ( + + ) : ( +
+ +
+ )}
- - {step === 'qr' && 'Pair your authenticator'} - {step === 'confirm' && 'Confirm the pairing'} - {step === 'backup' && 'Save your recovery codes'} - - - Enrol a time-based one-time password (TOTP) authenticator and save - single-use backup codes. - - - - - -
- {step === 'qr' && ( -
-

- Scan the code with 1Password, Bitwarden, Google Authenticator, or any TOTP app. -

-
- {otpauthUri ? ( - - ) : ( -
- -
- )} -
-
- - Secret · manual entry - -
- - {formatSecret(secret) || '...'} - - -
-
- - - - +
+ + Secret · manual entry + +
+ + {formatSecret(secret) || '...'} + +
- )} +
+ + onOpenChange(false)}> + Cancel + + } + primary={ + + } + /> + + )} - {step === 'confirm' && ( -
-

- Enter the 6-digit code shown in your authenticator to confirm the pairing. -

- - {error && {error}} - - - -
- )} + {step === 'confirm' && ( + <> + +

+ Enter the 6-digit code shown in your authenticator to confirm the pairing. +

+ + {error && {error}} +
+ setStep('qr')} disabled={loading}> + Back + + } + primary={null} + /> + + )} - {step === 'backup' && ( -
-

- Each code unlocks your account once if your authenticator is unavailable. Store them safely. They will not be shown again. -

- -
- - -
- - - -
- )} -
-
- -
+ {step === 'backup' && ( + <> + +

+ Each code unlocks your account once if your authenticator is unavailable. Store them safely. They will not be shown again. +

+ +
+ + +
+
+ + + Done + + } + /> + + )} + ); } @@ -326,33 +323,3 @@ function StepRail({ step }: { step: Step }) {
); } - -function BackupCodeTicket({ codes }: { codes: string[] }) { - return ( -
-
- Recovery codes - {codes.length} issued -
-
    - {codes.map((c, i) => ( -
  1. - - {String(i + 1).padStart(2, '0')} - - {c} -
  2. - ))} -
-
- ); -} - diff --git a/frontend/src/components/ui/modal.tsx b/frontend/src/components/ui/modal.tsx index c0d50a68..6ccce8ac 100644 --- a/frontend/src/components/ui/modal.tsx +++ b/frontend/src/components/ui/modal.tsx @@ -40,7 +40,7 @@ export function Modal({ open, onOpenChange, children, size = 'md', className }: