Replace 895-line monolith with 5 focused tab components (Profile, Appearance, Social, Security, Integrations) mirroring AdminPortal's tab pattern. URL deep linking via ?tab= search param. Conditional rendering prevents unmounted tabs from firing API calls. Reviewed by senior-code-reviewer, senior-ui-designer, and security-penetration-tester agents — all findings actioned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
559 lines
20 KiB
TypeScript
559 lines
20 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
AlertTriangle,
|
|
Check,
|
|
Copy,
|
|
Loader2,
|
|
ShieldCheck,
|
|
} from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogClose,
|
|
} from '@/components/ui/dialog';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import api, { getErrorMessage } from '@/lib/api';
|
|
import type { TotpSetupResponse } from '@/types';
|
|
|
|
type TotpSetupState = 'idle' | 'setup' | 'confirm' | 'backup_codes' | 'enabled';
|
|
|
|
interface TotpSetupSectionProps {
|
|
bare?: boolean;
|
|
}
|
|
|
|
export default function TotpSetupSection({ bare = false }: TotpSetupSectionProps) {
|
|
// ── Password change state ──
|
|
const [passwordForm, setPasswordForm] = useState({
|
|
oldPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: '',
|
|
});
|
|
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
|
|
|
// ── TOTP state ──
|
|
const [totpSetupState, setTotpSetupState] = useState<TotpSetupState>('idle');
|
|
const [qrCodeBase64, setQrCodeBase64] = useState('');
|
|
const [totpSecret, setTotpSecret] = useState('');
|
|
const [totpConfirmCode, setTotpConfirmCode] = useState('');
|
|
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
|
const [isTotpSetupPending, setIsTotpSetupPending] = useState(false);
|
|
const [isTotpConfirmPending, setIsTotpConfirmPending] = useState(false);
|
|
|
|
// ── Disable / Regenerate dialog state ──
|
|
const [disableDialogOpen, setDisableDialogOpen] = useState(false);
|
|
const [regenDialogOpen, setRegenDialogOpen] = useState(false);
|
|
const [dialogPassword, setDialogPassword] = useState('');
|
|
const [dialogCode, setDialogCode] = useState('');
|
|
const [isDialogPending, setIsDialogPending] = useState(false);
|
|
|
|
// On mount: check TOTP status to set initial state
|
|
useEffect(() => {
|
|
api
|
|
.get<{ enabled: boolean }>('/auth/totp/status')
|
|
.then(({ data }) => {
|
|
setTotpSetupState(data.enabled ? 'enabled' : 'idle');
|
|
})
|
|
.catch(() => {
|
|
// Endpoint not yet available — default to idle
|
|
setTotpSetupState('idle');
|
|
});
|
|
}, []);
|
|
|
|
// ── Password change ──
|
|
const handlePasswordChange = async () => {
|
|
const { oldPassword, newPassword, confirmPassword } = passwordForm;
|
|
if (!oldPassword || !newPassword || !confirmPassword) {
|
|
toast.error('All password fields are required');
|
|
return;
|
|
}
|
|
if (newPassword !== confirmPassword) {
|
|
toast.error('New passwords do not match');
|
|
return;
|
|
}
|
|
if (newPassword.length < 12) {
|
|
toast.error('Password must be at least 12 characters');
|
|
return;
|
|
}
|
|
const hasLetter = /[a-zA-Z]/.test(newPassword);
|
|
const hasNonLetter = /[^a-zA-Z]/.test(newPassword);
|
|
if (!hasLetter || !hasNonLetter) {
|
|
toast.error('Password must contain at least one letter and one non-letter character');
|
|
return;
|
|
}
|
|
setIsChangingPassword(true);
|
|
try {
|
|
await api.post('/auth/change-password', {
|
|
old_password: oldPassword,
|
|
new_password: newPassword,
|
|
});
|
|
setPasswordForm({ oldPassword: '', newPassword: '', confirmPassword: '' });
|
|
toast.success('Password changed successfully');
|
|
} catch (error) {
|
|
toast.error(getErrorMessage(error, 'Failed to change password'));
|
|
} finally {
|
|
setIsChangingPassword(false);
|
|
}
|
|
};
|
|
|
|
// ── TOTP setup ──
|
|
const handleBeginTotpSetup = async () => {
|
|
setIsTotpSetupPending(true);
|
|
try {
|
|
const { data } = await api.post<TotpSetupResponse>('/auth/totp/setup');
|
|
setQrCodeBase64(data.qr_code_base64);
|
|
setTotpSecret(data.secret);
|
|
setBackupCodes(data.backup_codes);
|
|
setTotpSetupState('setup');
|
|
} catch (error) {
|
|
toast.error(getErrorMessage(error, 'Failed to begin TOTP setup'));
|
|
} finally {
|
|
setIsTotpSetupPending(false);
|
|
}
|
|
};
|
|
|
|
const handleTotpConfirm = async () => {
|
|
if (!totpConfirmCode || totpConfirmCode.length !== 6) {
|
|
toast.error('Enter a 6-digit code');
|
|
return;
|
|
}
|
|
setIsTotpConfirmPending(true);
|
|
try {
|
|
const { data } = await api.post<{ backup_codes: string[] }>('/auth/totp/confirm', {
|
|
code: totpConfirmCode,
|
|
});
|
|
setBackupCodes(data.backup_codes);
|
|
setTotpConfirmCode('');
|
|
setTotpSetupState('backup_codes');
|
|
} catch (error) {
|
|
toast.error(getErrorMessage(error, 'Invalid code — try again'));
|
|
} finally {
|
|
setIsTotpConfirmPending(false);
|
|
}
|
|
};
|
|
|
|
const handleCopyBackupCodes = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(backupCodes.join('\n'));
|
|
toast.success('Backup codes copied');
|
|
} catch {
|
|
toast.error('Failed to copy codes');
|
|
}
|
|
};
|
|
|
|
const handleBackupCodesConfirmed = () => {
|
|
setBackupCodes([]);
|
|
setQrCodeBase64('');
|
|
setTotpSecret('');
|
|
setTotpSetupState('enabled');
|
|
};
|
|
|
|
// ── Disable TOTP ──
|
|
const handleDisableConfirm = async () => {
|
|
if (!dialogPassword || !dialogCode) {
|
|
toast.error('Password and code are required');
|
|
return;
|
|
}
|
|
setIsDialogPending(true);
|
|
try {
|
|
await api.post('/auth/totp/disable', { password: dialogPassword, code: dialogCode });
|
|
setDisableDialogOpen(false);
|
|
setDialogPassword('');
|
|
setDialogCode('');
|
|
setTotpSetupState('idle');
|
|
toast.success('Two-factor authentication disabled');
|
|
} catch (error) {
|
|
toast.error(getErrorMessage(error, 'Failed to disable TOTP'));
|
|
} finally {
|
|
setIsDialogPending(false);
|
|
}
|
|
};
|
|
|
|
// ── Regenerate backup codes ──
|
|
const handleRegenConfirm = async () => {
|
|
if (!dialogPassword || !dialogCode) {
|
|
toast.error('Password and code are required');
|
|
return;
|
|
}
|
|
setIsDialogPending(true);
|
|
try {
|
|
const { data } = await api.post<{ backup_codes: string[] }>(
|
|
'/auth/totp/backup-codes/regenerate',
|
|
{ password: dialogPassword, code: dialogCode }
|
|
);
|
|
setBackupCodes(data.backup_codes);
|
|
setRegenDialogOpen(false);
|
|
setDialogPassword('');
|
|
setDialogCode('');
|
|
setTotpSetupState('backup_codes');
|
|
toast.success('New backup codes generated');
|
|
} catch (error) {
|
|
toast.error(getErrorMessage(error, 'Failed to regenerate backup codes'));
|
|
} finally {
|
|
setIsDialogPending(false);
|
|
}
|
|
};
|
|
|
|
const closeDialog = () => {
|
|
setDisableDialogOpen(false);
|
|
setRegenDialogOpen(false);
|
|
setDialogPassword('');
|
|
setDialogCode('');
|
|
};
|
|
|
|
const content = (
|
|
<div className="space-y-6">
|
|
|
|
{/* Subsection A: Change Password */}
|
|
<div className="space-y-4">
|
|
<p className="text-sm font-medium">Change Password</p>
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="old_password">Current Password</Label>
|
|
<Input
|
|
id="old_password"
|
|
type="password"
|
|
value={passwordForm.oldPassword}
|
|
onChange={(e) => setPasswordForm({ ...passwordForm, oldPassword: e.target.value })}
|
|
autoComplete="current-password"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="new_password">New Password</Label>
|
|
<Input
|
|
id="new_password"
|
|
type="password"
|
|
value={passwordForm.newPassword}
|
|
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
|
|
autoComplete="new-password"
|
|
minLength={12}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="confirm_password">Confirm New Password</Label>
|
|
<Input
|
|
id="confirm_password"
|
|
type="password"
|
|
value={passwordForm.confirmPassword}
|
|
onChange={(e) =>
|
|
setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })
|
|
}
|
|
autoComplete="new-password"
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
onClick={handlePasswordChange}
|
|
disabled={isChangingPassword}
|
|
size="sm"
|
|
>
|
|
{isChangingPassword ? (
|
|
<><Loader2 className="h-4 w-4 animate-spin" />Saving</>
|
|
) : (
|
|
'Change Password'
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Subsection B: TOTP MFA Setup */}
|
|
<div className="space-y-4">
|
|
{totpSetupState === 'idle' && (
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium">Two-Factor Authentication</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
Add an extra layer of security with an authenticator app
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleBeginTotpSetup}
|
|
disabled={isTotpSetupPending}
|
|
>
|
|
{isTotpSetupPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Enable MFA'}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{totpSetupState === 'setup' && (
|
|
<div className="space-y-4">
|
|
<p className="text-sm font-medium">Scan with your authenticator app</p>
|
|
<div className="flex justify-center">
|
|
<img
|
|
src={`data:image/png;base64,${qrCodeBase64}`}
|
|
alt="TOTP QR code — scan with your authenticator app"
|
|
className="h-40 w-40 rounded-md border border-border"
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground text-center">
|
|
Can't scan? Enter this code manually:
|
|
</p>
|
|
<code className="block text-center text-xs font-mono bg-secondary px-3 py-2 rounded-md tracking-widest break-all">
|
|
{totpSecret}
|
|
</code>
|
|
<Button className="w-full" onClick={() => setTotpSetupState('confirm')}>
|
|
Next: Verify Code
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{totpSetupState === 'confirm' && (
|
|
<div className="space-y-4">
|
|
<p className="text-sm font-medium">Verify your authenticator app</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Enter the 6-digit code shown in your app to confirm setup.
|
|
</p>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="totp-confirm-code">Verification Code</Label>
|
|
<Input
|
|
id="totp-confirm-code"
|
|
type="text"
|
|
inputMode="numeric"
|
|
maxLength={6}
|
|
placeholder="000000"
|
|
value={totpConfirmCode}
|
|
onChange={(e) => setTotpConfirmCode(e.target.value.replace(/\D/g, ''))}
|
|
className="text-center tracking-widest text-lg"
|
|
autoFocus
|
|
autoComplete="one-time-code"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') handleTotpConfirm();
|
|
}}
|
|
/>
|
|
</div>
|
|
<Button
|
|
className="w-full"
|
|
onClick={handleTotpConfirm}
|
|
disabled={isTotpConfirmPending}
|
|
>
|
|
{isTotpConfirmPending ? (
|
|
<><Loader2 className="h-4 w-4 animate-spin" />Verifying</>
|
|
) : (
|
|
'Verify & Enable'
|
|
)}
|
|
</Button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setTotpSetupState('setup')}
|
|
className="w-full text-center text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
Back to QR code
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{totpSetupState === 'backup_codes' && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<AlertTriangle className="h-4 w-4 text-amber-400" aria-hidden="true" />
|
|
<p className="text-sm font-medium text-amber-400">Save these backup codes now</p>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
These {backupCodes.length} codes can each be used once if you lose access to your
|
|
authenticator app. Store them somewhere safe — they will not be shown again.
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-2 bg-secondary rounded-md p-3">
|
|
{backupCodes.map((code, i) => (
|
|
<code key={i} className="text-xs font-mono text-foreground text-center py-0.5">
|
|
{code}
|
|
</code>
|
|
))}
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleCopyBackupCodes}
|
|
className="w-full gap-2"
|
|
>
|
|
<Copy className="h-4 w-4" />
|
|
Copy All Codes
|
|
</Button>
|
|
<Button className="w-full" onClick={handleBackupCodesConfirmed}>
|
|
I've saved my backup codes
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{totpSetupState === 'enabled' && (
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-sm font-medium flex items-center gap-2 flex-wrap">
|
|
Two-Factor Authentication
|
|
<span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-400 font-medium uppercase tracking-wide">
|
|
<Check className="h-3 w-3" aria-hidden="true" />
|
|
Enabled
|
|
</span>
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
Your account is protected with an authenticator app
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2 shrink-0">
|
|
<Button variant="outline" size="sm" onClick={() => setRegenDialogOpen(true)}>
|
|
New backup codes
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => setDisableDialogOpen(true)}
|
|
className="bg-destructive/10 text-destructive hover:bg-destructive/20 border-destructive/30"
|
|
>
|
|
Disable
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{bare ? (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
{content}
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-1.5 rounded-md bg-red-500/10">
|
|
<ShieldCheck className="h-4 w-4 text-red-400" aria-hidden="true" />
|
|
</div>
|
|
<div>
|
|
<CardTitle>Authentication</CardTitle>
|
|
<CardDescription>Manage your password and two-factor authentication</CardDescription>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{content}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Disable TOTP Dialog */}
|
|
<Dialog open={disableDialogOpen} onOpenChange={closeDialog}>
|
|
<DialogContent>
|
|
<DialogClose onClick={closeDialog} />
|
|
<DialogHeader>
|
|
<DialogTitle>Disable Two-Factor Authentication</DialogTitle>
|
|
<DialogDescription>
|
|
Enter your password and a current authenticator code to disable MFA.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="disable-password">Password</Label>
|
|
<Input
|
|
id="disable-password"
|
|
type="password"
|
|
value={dialogPassword}
|
|
onChange={(e) => setDialogPassword(e.target.value)}
|
|
autoComplete="current-password"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="disable-code">Authenticator Code</Label>
|
|
<Input
|
|
id="disable-code"
|
|
type="text"
|
|
inputMode="numeric"
|
|
maxLength={6}
|
|
placeholder="000000"
|
|
value={dialogCode}
|
|
onChange={(e) => setDialogCode(e.target.value.replace(/\D/g, ''))}
|
|
className="text-center tracking-widest"
|
|
autoComplete="one-time-code"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" size="sm" onClick={closeDialog} disabled={isDialogPending}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={handleDisableConfirm}
|
|
disabled={isDialogPending}
|
|
className="gap-2"
|
|
>
|
|
{isDialogPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
|
Disable MFA
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Regenerate Backup Codes Dialog */}
|
|
<Dialog open={regenDialogOpen} onOpenChange={closeDialog}>
|
|
<DialogContent>
|
|
<DialogClose onClick={closeDialog} />
|
|
<DialogHeader>
|
|
<DialogTitle>Generate New Backup Codes</DialogTitle>
|
|
<DialogDescription>
|
|
Your existing backup codes will be invalidated. Enter your password and a current
|
|
authenticator code to continue.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="regen-password">Password</Label>
|
|
<Input
|
|
id="regen-password"
|
|
type="password"
|
|
value={dialogPassword}
|
|
onChange={(e) => setDialogPassword(e.target.value)}
|
|
autoComplete="current-password"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="regen-code">Authenticator Code</Label>
|
|
<Input
|
|
id="regen-code"
|
|
type="text"
|
|
inputMode="numeric"
|
|
maxLength={6}
|
|
placeholder="000000"
|
|
value={dialogCode}
|
|
onChange={(e) => setDialogCode(e.target.value.replace(/\D/g, ''))}
|
|
className="text-center tracking-widest"
|
|
autoComplete="one-time-code"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" size="sm" onClick={closeDialog} disabled={isDialogPending}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleRegenConfirm}
|
|
disabled={isDialogPending}
|
|
className="gap-2"
|
|
>
|
|
{isDialogPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
|
Generate New Codes
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|