diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 83aa677..a31f85e 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -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",
diff --git a/frontend/package.json b/frontend/package.json
index 4e34964..1a4a5f8 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",
diff --git a/frontend/src/components/auth/LockScreen.tsx b/frontend/src/components/auth/LockScreen.tsx
index 0a5bd03..24b5b9e 100644
--- a/frontend/src/components/auth/LockScreen.tsx
+++ b/frontend/src/components/auth/LockScreen.tsx
@@ -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 ;
@@ -561,6 +589,30 @@ export default function LockScreen() {
+ {/* Passkey login — shown when browser supports WebAuthn (U-01) */}
+ {!isSetup && supportsWebAuthn && (
+ <>
+
+
+
+ or
+
+
+
+ >
+ )}
+
{/* Open registration link — only shown on login screen when enabled */}
{!isSetup && registrationOpen && (
diff --git a/frontend/src/components/settings/PasskeySection.tsx b/frontend/src/components/settings/PasskeySection.tsx
new file mode 100644
index 0000000..935606d
--- /dev/null
+++ b/frontend/src/components/settings/PasskeySection.tsx
@@ -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 (
+ <>
+
+
+
+ >
+ );
+}
+
+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
('/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 (
+
+
+
+
+
+
+
+
+
+
+ Sign in with your fingerprint, face, or security key
+
+
+
+ {hasPasskeys && (
+
+ {passkeys.length} registered
+
+ )}
+
+
+ {hasPasskeys && (
+
+ {passkeys.map((pk) => (
+ -
+
+
+
+
+
+ {pk.name}
+ {pk.backed_up && (
+
+ )}
+
+
+ Added {formatDate(pk.created_at)} · Last used {formatRelativeTime(pk.last_used_at)}
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ {/* Registration ceremony dialog */}
+
+
+
+ );
+}
diff --git a/frontend/src/components/settings/SecurityTab.tsx b/frontend/src/components/settings/SecurityTab.tsx
index 22c9627..f8ccca3 100644
--- a/frontend/src/components/settings/SecurityTab.tsx
+++ b/frontend/src/components/settings/SecurityTab.tsx
@@ -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
+ {/* Passkeys */}
+
+
{/* Password + TOTP */}
diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts
index a7acc78..6cc8665 100644
--- a/frontend/src/hooks/useAuth.ts
+++ b/frontend/src/hooks/useAuth.ts
@@ -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,
};
}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 60d29c8..c43ecc4 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -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';
}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 949a6ce..ea9023c 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -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