Phase 2: Add passkey frontend UI
New files: - PasskeySection.tsx: Passkey management in Settings > Security with registration ceremony (password -> browser prompt -> name), credential list, two-click delete with password confirmation Changes: - types/index.ts: PasskeyCredential type, has_passkeys on AuthStatus - api.ts: 401 interceptor exclusions for passkey login endpoints - useAuth.ts: passkeyLoginMutation with dynamic import of @simplewebauthn/browser (~45KB saved from initial bundle) - LockScreen.tsx: "Sign in with a passkey" button (browser feature detection, not per-user), Fingerprint icon, error handling - SecurityTab.tsx: PasskeySection between Auto-lock and TOTP - package.json: Add @simplewebauthn/browser ^10.0.0 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e8e3f62ff8
commit
cc460df5d4
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@ -16,6 +16,7 @@
|
||||
"@fullcalendar/interaction": "^6.1.15",
|
||||
"@fullcalendar/react": "^6.1.15",
|
||||
"@fullcalendar/timegrid": "^6.1.15",
|
||||
"@simplewebauthn/browser": "^10.0.0",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"axios": "^1.7.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@ -1348,6 +1349,22 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-10.0.0.tgz",
|
||||
"integrity": "sha512-hG0JMZD+LiLUbpQcAjS4d+t4gbprE/dLYop/CkE01ugU/9sKXflxV5s0DRjdz3uNMFecatRfb4ZLG3XvF8m5zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/types": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simplewebauthn/types": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-10.0.0.tgz",
|
||||
"integrity": "sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.20",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
"@fullcalendar/interaction": "^6.1.15",
|
||||
"@fullcalendar/react": "^6.1.15",
|
||||
"@fullcalendar/timegrid": "^6.1.15",
|
||||
"@simplewebauthn/browser": "^10.0.0",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"axios": "^1.7.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { AlertTriangle, Copy, Lock, Loader2, ShieldCheck, UserPlus } from 'lucide-react';
|
||||
import { AlertTriangle, Copy, Fingerprint, Lock, Loader2, ShieldCheck, UserPlus } from 'lucide-react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@ -10,6 +10,7 @@ import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import AmbientBackground from './AmbientBackground';
|
||||
import type { TotpSetupResponse } from '@/types';
|
||||
|
||||
@ -47,6 +48,8 @@ export default function LockScreen() {
|
||||
isRegisterPending,
|
||||
isSetupPending,
|
||||
isTotpPending,
|
||||
passkeyLogin,
|
||||
isPasskeyLoginPending,
|
||||
} = useAuth();
|
||||
|
||||
// ── Shared credential fields ──
|
||||
@ -83,6 +86,31 @@ export default function LockScreen() {
|
||||
const [forcedConfirmPassword, setForcedConfirmPassword] = useState('');
|
||||
const [isForcePwPending, setIsForcePwPending] = useState(false);
|
||||
|
||||
// ── Passkey support (U-01: browser feature detection, not per-user) ──
|
||||
const [supportsWebAuthn] = useState(() => !!window.PublicKeyCredential);
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
setLoginError(null);
|
||||
try {
|
||||
const result = await passkeyLogin();
|
||||
if (result?.must_change_password) {
|
||||
setMode('force_pw');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'NotAllowedError') {
|
||||
toast.info('Passkey not recognized. Try your password.');
|
||||
} else if (error.name === 'AbortError') {
|
||||
// User cancelled — silent
|
||||
} else {
|
||||
toast.error(getErrorMessage(error, 'Passkey login failed. Try your password.'));
|
||||
}
|
||||
} else {
|
||||
toast.error(getErrorMessage(error, 'Passkey login failed. Try your password.'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Redirect authenticated users (no pending MFA flows)
|
||||
if (!isLoading && authStatus?.authenticated && !mfaSetupRequired && mode !== 'force_pw') {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
@ -561,6 +589,30 @@ export default function LockScreen() {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Passkey login — shown when browser supports WebAuthn (U-01) */}
|
||||
{!isSetup && supportsWebAuthn && (
|
||||
<>
|
||||
<div className="relative my-4">
|
||||
<Separator />
|
||||
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-2 text-xs text-muted-foreground">
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2"
|
||||
onClick={handlePasskeyLogin}
|
||||
disabled={isPasskeyLoginPending}
|
||||
aria-label="Sign in with a passkey"
|
||||
>
|
||||
{isPasskeyLoginPending
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <Fingerprint className="h-4 w-4" />}
|
||||
Sign in with a passkey
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Open registration link — only shown on login screen when enabled */}
|
||||
{!isSetup && registrationOpen && (
|
||||
<div className="mt-4 text-center">
|
||||
|
||||
415
frontend/src/components/settings/PasskeySection.tsx
Normal file
415
frontend/src/components/settings/PasskeySection.tsx
Normal file
@ -0,0 +1,415 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Fingerprint, Loader2, Trash2, Cloud } 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||
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-[10px] 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 [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);
|
||||
|
||||
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 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-emerald-500/10 px-2.5 py-0.5 text-xs font-semibold text-emerald-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-[11px] 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}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import TotpSetupSection from './TotpSetupSection';
|
||||
import PasskeySection from './PasskeySection';
|
||||
import type { Settings } from '@/types';
|
||||
|
||||
interface SecurityTabProps {
|
||||
@ -86,6 +87,9 @@ export default function SecurityTab({ settings, updateSettings, isUpdating }: Se
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Passkeys */}
|
||||
<PasskeySection />
|
||||
|
||||
{/* Password + TOTP */}
|
||||
<TotpSetupSection bare />
|
||||
</div>
|
||||
|
||||
@ -96,6 +96,30 @@ export function useAuth() {
|
||||
},
|
||||
});
|
||||
|
||||
const passkeyLoginMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { startAuthentication } = await import('@simplewebauthn/browser');
|
||||
const { data: beginResp } = await api.post('/auth/passkeys/login/begin', {});
|
||||
const credential = await startAuthentication(beginResp.options);
|
||||
const { data } = await api.post('/auth/passkeys/login/complete', {
|
||||
credential: JSON.stringify(credential),
|
||||
challenge_token: beginResp.challenge_token,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setMfaToken(null);
|
||||
setMfaSetupRequired(false);
|
||||
if (!data?.must_change_password) {
|
||||
queryClient.setQueryData(['auth'], (old: AuthStatus | undefined) => {
|
||||
if (!old) return old;
|
||||
return { ...old, authenticated: true };
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
||||
},
|
||||
});
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await api.post('/auth/logout');
|
||||
@ -125,5 +149,8 @@ export function useAuth() {
|
||||
isRegisterPending: registerMutation.isPending,
|
||||
isTotpPending: totpVerifyMutation.isPending,
|
||||
isSetupPending: setupMutation.isPending,
|
||||
passkeyLogin: passkeyLoginMutation.mutateAsync,
|
||||
isPasskeyLoginPending: passkeyLoginMutation.isPending,
|
||||
hasPasskeys: authQuery.data?.has_passkeys ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ api.interceptors.response.use(
|
||||
if (error.response?.status === 401) {
|
||||
const url = error.config?.url || '';
|
||||
// Don't redirect on auth endpoints — they legitimately return 401
|
||||
const authEndpoints = ['/auth/login', '/auth/register', '/auth/setup', '/auth/verify-password', '/auth/change-password'];
|
||||
const authEndpoints = ['/auth/login', '/auth/register', '/auth/setup', '/auth/verify-password', '/auth/change-password', '/auth/passkeys/login/begin', '/auth/passkeys/login/complete'];
|
||||
if (!authEndpoints.some(ep => url.startsWith(ep))) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
@ -243,6 +243,15 @@ export interface AuthStatus {
|
||||
username: string | null;
|
||||
registration_open: boolean;
|
||||
is_locked: boolean;
|
||||
has_passkeys: boolean;
|
||||
}
|
||||
|
||||
export interface PasskeyCredential {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: string | null;
|
||||
last_used_at: string | null;
|
||||
backed_up: boolean;
|
||||
}
|
||||
|
||||
// Login response discriminated union
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user