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 ( + <> + + + + + + Remove passkey + + Enter your password to remove "{credential.name}". + + +
+ + setPassword(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && password) handleSubmitDelete(); }} + autoFocus + /> +
+ + + + +
+
+ + ); +} + +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 */} + { + if (!open) { + setRegisterDialogOpen(false); + resetRegisterState(); + } + }} + > + + +
+
+ +
+ + {ceremonyState === 'password' && 'Add a passkey'} + {ceremonyState === 'waiting' && 'Creating passkey'} + {ceremonyState === 'naming' && 'Name your passkey'} + +
+
+ + {ceremonyState === 'password' && ( + <> + + Enter your password to add a passkey to your account. + +
+ + setRegisterPassword(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && registerPassword) handlePasswordSubmit(); }} + autoFocus + /> +
+ + + + + + )} + + {ceremonyState === 'waiting' && ( +
+ +

+ Follow your browser's prompt to create a passkey +

+
+ )} + + {ceremonyState === 'naming' && ( + <> +
+ + setPasskeyName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && passkeyName.trim()) handleSaveName(); }} + maxLength={100} + autoFocus + /> +

+ Give this passkey a name to help you identify it later. +

+
+ + + + + )} +
+
+
+
+ ); +} 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