mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +00:00
refactor(frontend): migrate MFA dialogs to Modal chrome system (D-3) (#907)
Replace raw Dialog/AlertDialog scaffolding in MfaEnrollDialog, MfaDisableDialog, and MfaBackupCodesDialog with the shared Modal* primitives introduced in D-1. Extract the duplicated BackupCodeTicket component into a shared file consumed by both enroll and regen flows. Fix a CSS grid auto-column blowout in Modal: the dialog's implicit auto column was sizing to the step rail's min-content (492px), overflowing the 448px max-w-md constraint and clipping right-side content. Adding grid-cols-1 to the DialogContent override forces the column to use minmax(0, 1fr), preventing any grid item from expanding the track past available space. Also add min-w-0 to the TOTP secret code element so its long monospaced string can truncate rather than drive column sizing. Kicker prefixes updated to SECURITY · per the tracker convention.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function BackupCodeTicket({ codes }: { codes: string[] }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border border-card-border bg-background/60 shadow-[inset_0_2px_6px_0_oklch(0_0_0/0.35)]">
|
||||
<div className="flex items-center justify-between border-b border-card-border/60 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
<span>Recovery codes</span>
|
||||
<span className="tabular-nums">{codes.length} issued</span>
|
||||
</div>
|
||||
<ol className="grid grid-cols-1 sm:grid-cols-2">
|
||||
{codes.map((c, i) => (
|
||||
<li
|
||||
key={c}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2 font-mono text-sm tabular-nums tracking-[0.15em] text-stat-value',
|
||||
'border-t border-card-border/40',
|
||||
i < 2 && 'sm:border-t-0',
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<span>{c}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Dialog
|
||||
<Modal
|
||||
size="md"
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
@@ -132,108 +126,73 @@ export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaB
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md overflow-hidden p-0">
|
||||
<div className="relative">
|
||||
<span aria-hidden className="absolute inset-y-0 left-0 w-[3px] bg-brand/70" />
|
||||
<ModalHeader
|
||||
kicker="SECURITY · BACKUP CODES"
|
||||
title={step === 'confirm' ? 'Confirm identity' : 'New recovery codes'}
|
||||
description="Replace your backup codes with a freshly generated set. The previous set stops working immediately."
|
||||
/>
|
||||
|
||||
<DialogHeader className="border-b border-card-border/60 px-6 pt-6 pb-4 text-left">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
SENCHO · MFA
|
||||
{step === 'confirm' && (
|
||||
<>
|
||||
<ModalBody>
|
||||
<p className="text-sm leading-snug text-stat-subtitle">
|
||||
Enter a code from your authenticator to generate a new set. The previous codes stop working immediately.
|
||||
</p>
|
||||
<OtpDigitField
|
||||
id="mfa-regen-code"
|
||||
value={code}
|
||||
onChange={handleCodeChange}
|
||||
state={confirmState}
|
||||
disabled={loading || confirmState === 'success'}
|
||||
autoFocus
|
||||
/>
|
||||
{error && <ErrorRail>{error}</ErrorRail>}
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
secondary={
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
primary={null}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'show' && (
|
||||
<>
|
||||
<ModalBody>
|
||||
<WarningRail>Previous codes have been invalidated.</WarningRail>
|
||||
<p className="text-sm leading-snug text-stat-subtitle">
|
||||
Each code can be used once. Store them safely. They will not be shown again.
|
||||
</p>
|
||||
<BackupCodeTicket codes={backupCodes} />
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" className="flex-1" onClick={handleCopy}>
|
||||
<Copy className="h-4 w-4" strokeWidth={1.5} />
|
||||
Copy all
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="flex-1" onClick={handleDownload}>
|
||||
<Download className="h-4 w-4" strokeWidth={1.5} />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
<DialogTitle className="mt-1 font-display text-[1.75rem] italic leading-tight text-stat-value">
|
||||
{step === 'confirm' ? 'Confirm identity' : 'New recovery codes'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Replace your backup codes with a freshly generated set. The previous
|
||||
set stops working immediately.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 py-5">
|
||||
{step === 'confirm' && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm leading-snug text-stat-subtitle">
|
||||
Enter a code from your authenticator to generate a new set. The previous codes stop working immediately.
|
||||
</p>
|
||||
<OtpDigitField
|
||||
id="mfa-regen-code"
|
||||
value={code}
|
||||
onChange={handleCodeChange}
|
||||
state={confirmState}
|
||||
disabled={loading || confirmState === 'success'}
|
||||
autoFocus
|
||||
/>
|
||||
{error && <ErrorRail>{error}</ErrorRail>}
|
||||
<DialogFooter className="mt-2 gap-2 sm:gap-2">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'show' && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<WarningRail>Previous codes have been invalidated.</WarningRail>
|
||||
<p className="text-sm leading-snug text-stat-subtitle">
|
||||
Each code can be used once. Store them safely. They will not be shown again.
|
||||
</p>
|
||||
<BackupCodeTicket codes={backupCodes} />
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" className="flex-1" onClick={handleCopy}>
|
||||
<Copy className="h-4 w-4" strokeWidth={1.5} />
|
||||
Copy all
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="flex-1" onClick={handleDownload}>
|
||||
<Download className="h-4 w-4" strokeWidth={1.5} />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter className="mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleFinish}
|
||||
className="bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
|
||||
>
|
||||
<Check strokeWidth={1.5} />
|
||||
Done
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupCodeTicket({ codes }: { codes: string[] }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border border-card-border bg-background/60 shadow-[inset_0_2px_6px_0_oklch(0_0_0/0.35)]">
|
||||
<div className="flex items-center justify-between border-b border-card-border/60 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
<span>Recovery codes</span>
|
||||
<span className="tabular-nums">{codes.length} issued</span>
|
||||
</div>
|
||||
<ol className="grid grid-cols-1 sm:grid-cols-2">
|
||||
{codes.map((c, i) => (
|
||||
<li
|
||||
key={c}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2 font-mono text-sm tabular-nums tracking-[0.15em] text-stat-value',
|
||||
'border-t border-card-border/40',
|
||||
i === 0 && 'sm:border-t-0',
|
||||
i === 1 && 'sm:border-t-0',
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<span>{c}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
primary={
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleFinish}
|
||||
className="bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
|
||||
>
|
||||
<Check strokeWidth={1.5} />
|
||||
Done
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -247,4 +206,3 @@ function WarningRail({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="max-w-md overflow-hidden p-0">
|
||||
<div className="relative">
|
||||
<span aria-hidden className="absolute inset-y-0 left-0 w-[3px] bg-destructive/70" />
|
||||
|
||||
<AlertDialogHeader className="border-b border-card-border/60 px-6 pt-6 pb-4 text-left">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-destructive">
|
||||
SENCHO · MFA · DISABLE
|
||||
</div>
|
||||
<AlertDialogTitle className="mt-1 font-display text-[1.75rem] italic leading-tight text-stat-value">
|
||||
Turn off two-factor
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="mt-2 text-sm leading-snug text-stat-subtitle">
|
||||
Disabling 2FA removes this login layer. Your backup codes become invalid. Confirm with a current code to proceed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 px-6 py-5">
|
||||
{useBackup ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Backup code · 10 chars
|
||||
</span>
|
||||
<Input
|
||||
id="mfa-disable-backup"
|
||||
type="text"
|
||||
inputMode="text"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={BACKUP_CODE_DISPLAY_LENGTH}
|
||||
value={display}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<OtpDigitField
|
||||
id="mfa-disable-code"
|
||||
value={display}
|
||||
onChange={handleOtpChange}
|
||||
state={otpState}
|
||||
disabled={loading || otpState === 'success'}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleBackup}
|
||||
className="self-start font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle transition-colors hover:text-brand"
|
||||
>
|
||||
{useBackup ? '[ Use authenticator ]' : '[ Use backup code ]'}
|
||||
</button>
|
||||
|
||||
{error && <ErrorRail>{error}</ErrorRail>}
|
||||
<Modal size="md" open={open} onOpenChange={onOpenChange}>
|
||||
<ModalDestructiveHeader
|
||||
kicker="SECURITY · MFA · DISABLE"
|
||||
title="Turn off two-factor"
|
||||
description="Disabling 2FA removes this login layer. Your backup codes become invalid."
|
||||
/>
|
||||
<ModalBody>
|
||||
<p className="text-sm leading-snug text-stat-subtitle">
|
||||
Disabling 2FA removes this login layer. Your backup codes become invalid.
|
||||
Confirm with a current code to proceed.
|
||||
</p>
|
||||
{useBackup ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Backup code · 10 chars
|
||||
</span>
|
||||
<Input
|
||||
id="mfa-disable-backup"
|
||||
type="text"
|
||||
inputMode="text"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={BACKUP_CODE_DISPLAY_LENGTH}
|
||||
value={display}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter className="border-t border-card-border/60 px-6 py-4">
|
||||
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={loading || raw.length !== expectedLength}
|
||||
onClick={handleDisableClick}
|
||||
>
|
||||
{loading ? 'Disabling...' : 'Disable'}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : (
|
||||
<OtpDigitField
|
||||
id="mfa-disable-code"
|
||||
value={display}
|
||||
onChange={handleOtpChange}
|
||||
state={otpState}
|
||||
disabled={loading || otpState === 'success'}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleBackup}
|
||||
className="self-start font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle transition-colors hover:text-brand"
|
||||
>
|
||||
{useBackup ? '[ Use authenticator ]' : '[ Use backup code ]'}
|
||||
</button>
|
||||
{error && <ErrorRail>{error}</ErrorRail>}
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
secondary={
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
primary={
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={loading || raw.length !== expectedLength}
|
||||
onClick={handleDisableClick}
|
||||
>
|
||||
{loading ? 'Disabling...' : 'Disable'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Dialog
|
||||
<Modal
|
||||
size="md"
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next && step === 'backup') onEnrolled();
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md overflow-hidden p-0">
|
||||
<div className="relative">
|
||||
<span aria-hidden className="absolute inset-y-0 left-0 w-[3px] bg-brand/70" />
|
||||
<ModalHeader
|
||||
kicker="SECURITY · MFA"
|
||||
title={title}
|
||||
description="Enrol a time-based one-time password (TOTP) authenticator and save single-use backup codes."
|
||||
/>
|
||||
<StepRail step={step} />
|
||||
|
||||
<DialogHeader className="border-b border-card-border/60 px-6 pt-6 pb-4 text-left">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
SENCHO · MFA
|
||||
{step === 'qr' && (
|
||||
<>
|
||||
<ModalBody>
|
||||
<p className="text-sm leading-snug text-stat-subtitle">
|
||||
Scan the code with 1Password, Bitwarden, Google Authenticator, or any TOTP app.
|
||||
</p>
|
||||
<div className="flex justify-center rounded-md bg-background p-5 shadow-[inset_0_2px_6px_0_oklch(0_0_0/0.45)]">
|
||||
{otpauthUri ? (
|
||||
<QRCodeSVG value={otpauthUri} size={180} />
|
||||
) : (
|
||||
<div className="flex h-[180px] w-[180px] items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-stat-subtitle" strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogTitle className="mt-1 font-display text-[1.75rem] italic leading-tight text-stat-value">
|
||||
{step === 'qr' && 'Pair your authenticator'}
|
||||
{step === 'confirm' && 'Confirm the pairing'}
|
||||
{step === 'backup' && 'Save your recovery codes'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Enrol a time-based one-time password (TOTP) authenticator and save
|
||||
single-use backup codes.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<StepRail step={step} />
|
||||
|
||||
<div className="px-6 py-5">
|
||||
{step === 'qr' && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm leading-snug text-stat-subtitle">
|
||||
Scan the code with 1Password, Bitwarden, Google Authenticator, or any TOTP app.
|
||||
</p>
|
||||
<div className="flex justify-center rounded-md bg-background p-5 shadow-[inset_0_2px_6px_0_oklch(0_0_0/0.45)]">
|
||||
{otpauthUri ? (
|
||||
<QRCodeSVG value={otpauthUri} size={180} />
|
||||
) : (
|
||||
<div className="flex h-[180px] w-[180px] items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-stat-subtitle" strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Secret · manual entry
|
||||
</span>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<code className="flex-1 truncate rounded-md border border-card-border bg-background/60 px-3 py-2 font-mono text-xs tabular-nums tracking-[0.2em] text-stat-value shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.25)]">
|
||||
{formatSecret(secret) || '...'}
|
||||
</code>
|
||||
<Button type="button" size="icon" variant="outline" onClick={handleCopySecret} disabled={!secret}>
|
||||
<Copy className="h-4 w-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="mt-2 gap-2 sm:gap-2">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setStep('confirm')}
|
||||
disabled={!otpauthUri || loading}
|
||||
className="bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight strokeWidth={1.5} />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Secret · manual entry
|
||||
</span>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded-md border border-card-border bg-background/60 px-3 py-2 font-mono text-xs tabular-nums tracking-[0.2em] text-stat-value shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.25)]">
|
||||
{formatSecret(secret) || '...'}
|
||||
</code>
|
||||
<Button type="button" size="icon" variant="outline" onClick={handleCopySecret} disabled={!secret}>
|
||||
<Copy className="h-4 w-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
secondary={
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
primary={
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setStep('confirm')}
|
||||
disabled={!otpauthUri || loading}
|
||||
className="bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight strokeWidth={1.5} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm leading-snug text-stat-subtitle">
|
||||
Enter the 6-digit code shown in your authenticator to confirm the pairing.
|
||||
</p>
|
||||
<OtpDigitField
|
||||
id="mfa-confirm-code"
|
||||
value={code}
|
||||
onChange={handleCodeChange}
|
||||
state={confirmState}
|
||||
disabled={loading || confirmState === 'success'}
|
||||
autoFocus
|
||||
/>
|
||||
{error && <ErrorRail>{error}</ErrorRail>}
|
||||
<DialogFooter className="mt-2 gap-2 sm:gap-2">
|
||||
<Button type="button" variant="ghost" onClick={() => setStep('qr')} disabled={loading}>
|
||||
Back
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
{step === 'confirm' && (
|
||||
<>
|
||||
<ModalBody>
|
||||
<p className="text-sm leading-snug text-stat-subtitle">
|
||||
Enter the 6-digit code shown in your authenticator to confirm the pairing.
|
||||
</p>
|
||||
<OtpDigitField
|
||||
id="mfa-confirm-code"
|
||||
value={code}
|
||||
onChange={handleCodeChange}
|
||||
state={confirmState}
|
||||
disabled={loading || confirmState === 'success'}
|
||||
autoFocus
|
||||
/>
|
||||
{error && <ErrorRail>{error}</ErrorRail>}
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
secondary={
|
||||
<Button type="button" variant="ghost" onClick={() => setStep('qr')} disabled={loading}>
|
||||
Back
|
||||
</Button>
|
||||
}
|
||||
primary={null}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'backup' && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm leading-snug text-stat-subtitle">
|
||||
Each code unlocks your account once if your authenticator is unavailable. Store them safely. They will not be shown again.
|
||||
</p>
|
||||
<BackupCodeTicket codes={backupCodes} />
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" className="flex-1" onClick={handleCopyBackupCodes}>
|
||||
<Copy className="h-4 w-4" strokeWidth={1.5} />
|
||||
Copy all
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="flex-1" onClick={handleDownloadBackupCodes}>
|
||||
<Download className="h-4 w-4" strokeWidth={1.5} />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter className="mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleFinish}
|
||||
className="bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
|
||||
>
|
||||
<Check strokeWidth={1.5} />
|
||||
Done
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{step === 'backup' && (
|
||||
<>
|
||||
<ModalBody>
|
||||
<p className="text-sm leading-snug text-stat-subtitle">
|
||||
Each code unlocks your account once if your authenticator is unavailable. Store them safely. They will not be shown again.
|
||||
</p>
|
||||
<BackupCodeTicket codes={backupCodes} />
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" className="flex-1" onClick={handleCopyBackupCodes}>
|
||||
<Copy className="h-4 w-4" strokeWidth={1.5} />
|
||||
Copy all
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="flex-1" onClick={handleDownloadBackupCodes}>
|
||||
<Download className="h-4 w-4" strokeWidth={1.5} />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
primary={
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleFinish}
|
||||
className="bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
|
||||
>
|
||||
<Check strokeWidth={1.5} />
|
||||
Done
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -326,33 +323,3 @@ function StepRail({ step }: { step: Step }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupCodeTicket({ codes }: { codes: string[] }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border border-card-border bg-background/60 shadow-[inset_0_2px_6px_0_oklch(0_0_0/0.35)]">
|
||||
<div className="flex items-center justify-between border-b border-card-border/60 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
<span>Recovery codes</span>
|
||||
<span className="tabular-nums">{codes.length} issued</span>
|
||||
</div>
|
||||
<ol className="grid grid-cols-1 sm:grid-cols-2">
|
||||
{codes.map((c, i) => (
|
||||
<li
|
||||
key={c}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2 font-mono text-sm tabular-nums tracking-[0.15em] text-stat-value',
|
||||
'border-t border-card-border/40',
|
||||
i === 0 && 'sm:border-t-0',
|
||||
i === 1 && 'sm:border-t-0',
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<span>{c}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export function Modal({ open, onOpenChange, children, size = 'md', className }:
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'p-0 gap-0 overflow-hidden',
|
||||
'p-0 gap-0 overflow-hidden grid-cols-1',
|
||||
SIZE_CLASS[size],
|
||||
className,
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user