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/interaction": "^6.1.15",
|
||||||
"@fullcalendar/react": "^6.1.15",
|
"@fullcalendar/react": "^6.1.15",
|
||||||
"@fullcalendar/timegrid": "^6.1.15",
|
"@fullcalendar/timegrid": "^6.1.15",
|
||||||
|
"@simplewebauthn/browser": "^10.0.0",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@ -1348,6 +1349,22 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.90.20",
|
"version": "5.90.20",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
"@fullcalendar/interaction": "^6.1.15",
|
"@fullcalendar/interaction": "^6.1.15",
|
||||||
"@fullcalendar/react": "^6.1.15",
|
"@fullcalendar/react": "^6.1.15",
|
||||||
"@fullcalendar/timegrid": "^6.1.15",
|
"@fullcalendar/timegrid": "^6.1.15",
|
||||||
|
"@simplewebauthn/browser": "^10.0.0",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
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 { useAuth } from '@/hooks/useAuth';
|
||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
import AmbientBackground from './AmbientBackground';
|
import AmbientBackground from './AmbientBackground';
|
||||||
import type { TotpSetupResponse } from '@/types';
|
import type { TotpSetupResponse } from '@/types';
|
||||||
|
|
||||||
@ -47,6 +48,8 @@ export default function LockScreen() {
|
|||||||
isRegisterPending,
|
isRegisterPending,
|
||||||
isSetupPending,
|
isSetupPending,
|
||||||
isTotpPending,
|
isTotpPending,
|
||||||
|
passkeyLogin,
|
||||||
|
isPasskeyLoginPending,
|
||||||
} = useAuth();
|
} = useAuth();
|
||||||
|
|
||||||
// ── Shared credential fields ──
|
// ── Shared credential fields ──
|
||||||
@ -83,6 +86,31 @@ export default function LockScreen() {
|
|||||||
const [forcedConfirmPassword, setForcedConfirmPassword] = useState('');
|
const [forcedConfirmPassword, setForcedConfirmPassword] = useState('');
|
||||||
const [isForcePwPending, setIsForcePwPending] = useState(false);
|
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)
|
// Redirect authenticated users (no pending MFA flows)
|
||||||
if (!isLoading && authStatus?.authenticated && !mfaSetupRequired && mode !== 'force_pw') {
|
if (!isLoading && authStatus?.authenticated && !mfaSetupRequired && mode !== 'force_pw') {
|
||||||
return <Navigate to="/dashboard" replace />;
|
return <Navigate to="/dashboard" replace />;
|
||||||
@ -561,6 +589,30 @@ export default function LockScreen() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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 */}
|
{/* Open registration link — only shown on login screen when enabled */}
|
||||||
{!isSetup && registrationOpen && (
|
{!isSetup && registrationOpen && (
|
||||||
<div className="mt-4 text-center">
|
<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 { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import TotpSetupSection from './TotpSetupSection';
|
import TotpSetupSection from './TotpSetupSection';
|
||||||
|
import PasskeySection from './PasskeySection';
|
||||||
import type { Settings } from '@/types';
|
import type { Settings } from '@/types';
|
||||||
|
|
||||||
interface SecurityTabProps {
|
interface SecurityTabProps {
|
||||||
@ -86,6 +87,9 @@ export default function SecurityTab({ settings, updateSettings, isUpdating }: Se
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Passkeys */}
|
||||||
|
<PasskeySection />
|
||||||
|
|
||||||
{/* Password + TOTP */}
|
{/* Password + TOTP */}
|
||||||
<TotpSetupSection bare />
|
<TotpSetupSection bare />
|
||||||
</div>
|
</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({
|
const logoutMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const { data } = await api.post('/auth/logout');
|
const { data } = await api.post('/auth/logout');
|
||||||
@ -125,5 +149,8 @@ export function useAuth() {
|
|||||||
isRegisterPending: registerMutation.isPending,
|
isRegisterPending: registerMutation.isPending,
|
||||||
isTotpPending: totpVerifyMutation.isPending,
|
isTotpPending: totpVerifyMutation.isPending,
|
||||||
isSetupPending: setupMutation.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) {
|
if (error.response?.status === 401) {
|
||||||
const url = error.config?.url || '';
|
const url = error.config?.url || '';
|
||||||
// Don't redirect on auth endpoints — they legitimately return 401
|
// 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))) {
|
if (!authEndpoints.some(ep => url.startsWith(ep))) {
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -243,6 +243,15 @@ export interface AuthStatus {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
registration_open: boolean;
|
registration_open: boolean;
|
||||||
is_locked: 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
|
// Login response discriminated union
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user