UMBRA/frontend/src/components/settings/PasskeySection.tsx
Kyle Pope ed98924716 Action remaining QA suggestions + performance optimizations
S-02: Extract extract_credential_raw_id() helper in services/passkey.py
  — replaces 2 inline rawId parsing blocks in passkeys.py
S-03: Add PasskeyLoginResponse type, use in useAuth passkeyLoginMutation
S-04: Add Cancel button to disable-passwordless dialog
W-03: Invalidate auth queries on disable ceremony error/cancel

Perf-2: Session cap uses ID-only query + bulk UPDATE instead of loading
  full ORM objects and flipping booleans individually
Perf-3: Remove passkey_count from /auth/status hot path (polled every
  15s). Use EXISTS for has_passkeys boolean. Count derived from passkeys
  list query in PasskeySection (passkeys.length).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 02:34:00 +08:00

601 lines
22 KiB
TypeScript

import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Fingerprint, Loader2, Trash2, Cloud, ShieldOff } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Separator } from '@/components/ui/separator';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { useAuth } from '@/hooks/useAuth';
import type { PasskeyCredential } from '@/types';
function formatRelativeTime(dateStr: string | null): string {
if (!dateStr) return 'Never';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString(undefined, {
day: 'numeric', month: 'short', year: 'numeric',
});
}
function detectDeviceName(): string {
const ua = navigator.userAgent;
let browser = 'Browser';
if (ua.includes('Chrome') && !ua.includes('Edg')) browser = 'Chrome';
else if (ua.includes('Safari') && !ua.includes('Chrome')) browser = 'Safari';
else if (ua.includes('Firefox')) browser = 'Firefox';
else if (ua.includes('Edg')) browser = 'Edge';
let os = '';
if (ua.includes('Mac')) os = 'macOS';
else if (ua.includes('Windows')) os = 'Windows';
else if (ua.includes('Linux')) os = 'Linux';
else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
else if (ua.includes('Android')) os = 'Android';
return os ? `${os} \u2014 ${browser}` : browser;
}
interface DeleteConfirmProps {
credential: PasskeyCredential;
onDelete: (id: number, password: string) => void;
isDeleting: boolean;
}
function PasskeyDeleteButton({ credential, onDelete, isDeleting }: DeleteConfirmProps) {
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
const [password, setPassword] = useState('');
const { confirming, handleClick } = useConfirmAction(() => {
setShowPasswordDialog(true);
});
const handleSubmitDelete = () => {
onDelete(credential.id, password);
setPassword('');
setShowPasswordDialog(false);
};
return (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-400"
onClick={handleClick}
disabled={isDeleting}
aria-label={`Remove passkey ${credential.name}`}
>
{confirming ? (
<span className="text-xs font-medium text-red-400">Sure?</span>
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
<Dialog open={showPasswordDialog} onOpenChange={setShowPasswordDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove passkey</DialogTitle>
<DialogDescription>
Enter your password to remove "{credential.name}".
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="delete-passkey-password">Password</Label>
<Input
id="delete-passkey-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && password) handleSubmitDelete(); }}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowPasswordDialog(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleSubmitDelete}
disabled={!password || isDeleting}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Remove'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export default function PasskeySection() {
const queryClient = useQueryClient();
const { passwordlessEnabled, allowPasswordless } = useAuth();
// Registration state
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
const [ceremonyState, setCeremonyState] = useState<'password' | 'waiting' | 'naming'>('password');
const [registerPassword, setRegisterPassword] = useState('');
const [passkeyName, setPasskeyName] = useState('');
const [pendingCredential, setPendingCredential] = useState<{
credential: string;
challenge_token: string;
} | null>(null);
// Passwordless enable state
const [enableDialogOpen, setEnableDialogOpen] = useState(false);
const [enablePassword, setEnablePassword] = useState('');
// Passwordless disable state
const [disableDialogOpen, setDisableDialogOpen] = useState(false);
const passkeysQuery = useQuery({
queryKey: ['passkeys'],
queryFn: async () => {
const { data } = await api.get<PasskeyCredential[]>('/auth/passkeys');
return data;
},
});
const registerMutation = useMutation({
mutationFn: async ({ password }: { password: string }) => {
const { startRegistration } = await import('@simplewebauthn/browser');
// Step 1: Get registration options (requires password V-02)
const { data: beginResp } = await api.post('/auth/passkeys/register/begin', { password });
// Step 2: Browser WebAuthn ceremony
setCeremonyState('waiting');
const credential = await startRegistration(beginResp.options);
return {
credential: JSON.stringify(credential),
challenge_token: beginResp.challenge_token,
};
},
onSuccess: (data) => {
setPendingCredential(data);
setPasskeyName(detectDeviceName());
setCeremonyState('naming');
},
onError: (error: unknown) => {
if (error instanceof Error && error.name === 'NotAllowedError') {
toast.info('Passkey setup cancelled');
} else if (error instanceof Error && error.name === 'AbortError') {
toast.info('Cancelled');
} else {
toast.error(getErrorMessage(error, 'Failed to create passkey'));
}
setRegisterDialogOpen(false);
resetRegisterState();
},
});
const completeMutation = useMutation({
mutationFn: async ({ credential, challenge_token, name }: {
credential: string; challenge_token: string; name: string;
}) => {
const { data } = await api.post('/auth/passkeys/register/complete', {
credential, challenge_token, name,
});
return data;
},
onSuccess: () => {
toast.success('Passkey registered');
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
queryClient.invalidateQueries({ queryKey: ['auth'] });
setRegisterDialogOpen(false);
resetRegisterState();
},
onError: (error: unknown) => {
toast.error(getErrorMessage(error, 'Failed to save passkey'));
},
});
const deleteMutation = useMutation({
mutationFn: async ({ id, password }: { id: number; password: string }) => {
await api.delete(`/auth/passkeys/${id}`, { data: { password } });
},
onSuccess: () => {
toast.success('Passkey removed');
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
queryClient.invalidateQueries({ queryKey: ['auth'] });
},
onError: (error: unknown) => {
toast.error(getErrorMessage(error, 'Failed to remove passkey'));
},
});
const enablePasswordlessMutation = useMutation({
mutationFn: async ({ password }: { password: string }) => {
const { data } = await api.put('/auth/passkeys/passwordless/enable', { password });
return data;
},
onSuccess: () => {
toast.success('Passwordless login enabled');
queryClient.invalidateQueries({ queryKey: ['auth'] });
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
setEnableDialogOpen(false);
setEnablePassword('');
},
onError: (error: unknown) => {
toast.error(getErrorMessage(error, 'Failed to enable passwordless login'));
},
});
const disablePasswordlessMutation = useMutation({
mutationFn: async () => {
const { startAuthentication } = await import('@simplewebauthn/browser');
const { data: beginResp } = await api.post('/auth/passkeys/passwordless/disable/begin', {});
const credential = await startAuthentication(beginResp.options);
const { data } = await api.put('/auth/passkeys/passwordless/disable', {
credential: JSON.stringify(credential),
challenge_token: beginResp.challenge_token,
});
return data;
},
onSuccess: () => {
toast.success('Passwordless login disabled');
queryClient.invalidateQueries({ queryKey: ['auth'] });
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
setDisableDialogOpen(false);
},
onError: (error: unknown) => {
if (error instanceof Error && error.name === 'NotAllowedError') {
toast.error('Passkey not recognized');
} else if (error instanceof Error && error.name === 'AbortError') {
toast.info('Cancelled');
} else {
toast.error(getErrorMessage(error, 'Failed to disable passwordless login'));
}
setDisableDialogOpen(false);
// W-03: Invalidate to resync switch state after failed/cancelled ceremony
queryClient.invalidateQueries({ queryKey: ['auth'] });
},
});
const resetRegisterState = useCallback(() => {
setCeremonyState('password');
setRegisterPassword('');
setPasskeyName('');
setPendingCredential(null);
}, []);
const handleStartRegister = () => {
resetRegisterState();
setRegisterDialogOpen(true);
};
const handlePasswordSubmit = () => {
if (!registerPassword) return;
registerMutation.mutate({ password: registerPassword });
};
const handleSaveName = () => {
if (!pendingCredential || !passkeyName.trim()) return;
completeMutation.mutate({
...pendingCredential,
name: passkeyName.trim(),
});
};
const handleDelete = (id: number, password: string) => {
deleteMutation.mutate({ id, password });
};
const passkeys = passkeysQuery.data ?? [];
const hasPasskeys = passkeys.length > 0;
return (
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<Fingerprint className="h-4 w-4 text-accent" />
</div>
<div className="space-y-0.5">
<Label>Passkeys</Label>
<p className="text-xs text-muted-foreground">
Sign in with your fingerprint, face, or security key
</p>
</div>
</div>
{hasPasskeys && (
<span className="inline-flex items-center rounded-full bg-green-500/10 px-2.5 py-0.5 text-xs font-semibold text-green-400">
{passkeys.length} registered
</span>
)}
</div>
{hasPasskeys && (
<ul className="space-y-1" aria-live="polite">
{passkeys.map((pk) => (
<li
key={pk.id}
className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
>
<div className="p-1.5 rounded-md bg-accent/10">
<Fingerprint className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{pk.name}</span>
{pk.backed_up && (
<Cloud className="h-3 w-3 text-muted-foreground shrink-0" aria-label="Synced" />
)}
</div>
<span className="text-xs text-muted-foreground">
Added {formatDate(pk.created_at)} · Last used {formatRelativeTime(pk.last_used_at)}
</span>
</div>
<PasskeyDeleteButton
credential={pk}
onDelete={handleDelete}
isDeleting={deleteMutation.isPending && (deleteMutation.variables as { id: number } | undefined)?.id === pk.id}
/>
</li>
))}
</ul>
)}
<Separator />
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={handleStartRegister}
>
<Fingerprint className="h-4 w-4" />
{hasPasskeys ? 'Add another passkey' : 'Add a passkey'}
</Button>
{/* Passwordless login section — hidden when admin hasn't enabled the feature */}
{(allowPasswordless || passwordlessEnabled) && <Separator />}
{(allowPasswordless || passwordlessEnabled) && (
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Fingerprint className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium">Passwordless Login</Label>
</div>
<p className="text-xs text-muted-foreground">
Skip the password prompt and unlock the app using a passkey only.
</p>
{passkeys.length < 2 && !passwordlessEnabled && (
<p className="text-xs text-amber-400">
Requires at least 2 registered passkeys as a fallback.
</p>
)}
</div>
<Switch
checked={passwordlessEnabled}
onCheckedChange={(checked) => {
if (checked) {
setEnablePassword('');
setEnableDialogOpen(true);
} else {
setDisableDialogOpen(true);
disablePasswordlessMutation.mutate();
}
}}
disabled={(!passwordlessEnabled && passkeys.length < 2) || enablePasswordlessMutation.isPending || disablePasswordlessMutation.isPending}
aria-label="Toggle passwordless login"
/>
</div>
)}
{/* Enable passwordless dialog */}
<Dialog open={enableDialogOpen} onOpenChange={(open) => {
if (!open) { setEnableDialogOpen(false); setEnablePassword(''); }
}}>
<DialogContent>
<DialogHeader>
<div className="flex items-center gap-3 mb-1">
<div className="p-2 rounded-lg bg-accent/10">
<Fingerprint className="h-5 w-5 text-accent" />
</div>
<DialogTitle>Enable Passwordless Login</DialogTitle>
</div>
<DialogDescription>
Confirm your password to enable passkey-only login.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="enable-passwordless-password">Password</Label>
<Input
id="enable-passwordless-password"
type="password"
value={enablePassword}
onChange={(e) => setEnablePassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && enablePassword) {
enablePasswordlessMutation.mutate({ password: enablePassword });
}
}}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setEnableDialogOpen(false); setEnablePassword(''); }}>
Cancel
</Button>
<Button
onClick={() => enablePasswordlessMutation.mutate({ password: enablePassword })}
disabled={!enablePassword || enablePasswordlessMutation.isPending}
>
{enablePasswordlessMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Enable'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Disable passwordless dialog */}
<Dialog open={disableDialogOpen} onOpenChange={(open) => {
if (!open && !disablePasswordlessMutation.isPending) {
setDisableDialogOpen(false);
}
}}>
<DialogContent>
<DialogHeader>
<div className="flex items-center gap-3 mb-1">
<div className="p-2 rounded-lg bg-orange-500/10">
<ShieldOff className="h-5 w-5 text-orange-400" />
</div>
<DialogTitle>Disable Passwordless Login</DialogTitle>
</div>
<DialogDescription>
Verify with your passkey to disable passwordless login.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-4">
{disablePasswordlessMutation.isPending ? (
<>
<Loader2 className="h-8 w-8 animate-spin text-accent" />
<p className="text-sm text-muted-foreground text-center">
Follow your browser's prompt to verify your passkey
</p>
</>
) : (
<p className="text-sm text-muted-foreground text-center">
Ready to verify your passkey
</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDisableDialogOpen(false)}
disabled={disablePasswordlessMutation.isPending}
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Registration ceremony dialog */}
<Dialog
open={registerDialogOpen}
onOpenChange={(open) => {
if (!open) {
setRegisterDialogOpen(false);
resetRegisterState();
}
}}
>
<DialogContent>
<DialogHeader>
<div className="flex items-center gap-3 mb-1">
<div className="p-2 rounded-lg bg-accent/10">
<Fingerprint className="h-5 w-5 text-accent" />
</div>
<DialogTitle>
{ceremonyState === 'password' && 'Add a passkey'}
{ceremonyState === 'waiting' && 'Creating passkey'}
{ceremonyState === 'naming' && 'Name your passkey'}
</DialogTitle>
</div>
</DialogHeader>
{ceremonyState === 'password' && (
<>
<DialogDescription>
Enter your password to add a passkey to your account.
</DialogDescription>
<div className="space-y-2">
<Label htmlFor="register-passkey-password">Password</Label>
<Input
id="register-passkey-password"
type="password"
value={registerPassword}
onChange={(e) => setRegisterPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && registerPassword) handlePasswordSubmit(); }}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRegisterDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handlePasswordSubmit}
disabled={!registerPassword || registerMutation.isPending}
>
{registerMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Continue'}
</Button>
</DialogFooter>
</>
)}
{ceremonyState === 'waiting' && (
<div className="flex flex-col items-center gap-4 py-6">
<Loader2 className="h-8 w-8 animate-spin text-accent" />
<p className="text-sm text-muted-foreground text-center">
Follow your browser's prompt to create a passkey
</p>
</div>
)}
{ceremonyState === 'naming' && (
<>
<div className="space-y-2">
<Label htmlFor="passkey-name">Name</Label>
<Input
id="passkey-name"
value={passkeyName}
onChange={(e) => setPasskeyName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && passkeyName.trim()) handleSaveName(); }}
maxLength={100}
autoFocus
/>
<p className="text-xs text-muted-foreground">
Give this passkey a name to help you identify it later.
</p>
</div>
<DialogFooter>
<Button
onClick={handleSaveName}
disabled={!passkeyName.trim() || completeMutation.isPending}
>
{completeMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Save'}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
}