diff --git a/frontend/src/components/admin/IAMPage.tsx b/frontend/src/components/admin/IAMPage.tsx index 2c83957..03024f5 100644 --- a/frontend/src/components/admin/IAMPage.tsx +++ b/frontend/src/components/admin/IAMPage.tsx @@ -81,7 +81,7 @@ export default function IAMPage() { ); }, [users, searchQuery]); - const handleConfigToggle = async (key: 'allow_registration' | 'enforce_mfa_new_users', value: boolean) => { + const handleConfigToggle = async (key: 'allow_registration' | 'enforce_mfa_new_users' | 'allow_passwordless', value: boolean) => { try { await updateConfig.mutateAsync({ [key]: value }); toast.success('System settings updated'); @@ -320,6 +320,20 @@ export default function IAMPage() { disabled={updateConfig.isPending} /> + +
+
+ +

+ Allow users to enable passkey-only login, skipping the password prompt entirely. +

+
+ handleConfigToggle('allow_passwordless', v)} + disabled={updateConfig.isPending} + /> +
)} diff --git a/frontend/src/components/admin/UserActionsMenu.tsx b/frontend/src/components/admin/UserActionsMenu.tsx index 056e5a7..60c99d5 100644 --- a/frontend/src/components/admin/UserActionsMenu.tsx +++ b/frontend/src/components/admin/UserActionsMenu.tsx @@ -11,6 +11,7 @@ import { ChevronRight, Loader2, Trash2, + ShieldOff, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useConfirmAction } from '@/hooks/useConfirmAction'; @@ -23,6 +24,7 @@ import { useToggleUserActive, useRevokeSessions, useDeleteUser, + useDisablePasswordless, getErrorMessage, } from '@/hooks/useAdmin'; import type { AdminUserDetail, UserRole } from '@/types'; @@ -53,6 +55,7 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe const toggleActive = useToggleUserActive(); const revokeSessions = useRevokeSessions(); const deleteUser = useDeleteUser(); + const disablePasswordless = useDisablePasswordless(); // Close on outside click useEffect(() => { @@ -102,6 +105,10 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe } }); + const disablePasswordlessConfirm = useConfirmAction(() => { + handleAction(() => disablePasswordless.mutateAsync(user.id), 'Passwordless login disabled'); + }); + const isLoading = updateRole.isPending || resetPassword.isPending || @@ -110,7 +117,8 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe removeMfaEnforcement.isPending || toggleActive.isPending || revokeSessions.isPending || - deleteUser.isPending; + deleteUser.isPending || + disablePasswordless.isPending; return (
@@ -258,6 +266,21 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe )} + {user.passwordless_enabled && ( + + )} +
{/* Disable / Enable Account */} diff --git a/frontend/src/components/admin/UserDetailSection.tsx b/frontend/src/components/admin/UserDetailSection.tsx index f5b8e09..49d7de8 100644 --- a/frontend/src/components/admin/UserDetailSection.tsx +++ b/frontend/src/components/admin/UserDetailSection.tsx @@ -193,6 +193,18 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection /> } /> + + Enabled + + ) : ( + Off + ) + } + /> !!window.PublicKeyCredential); const inputRef = useRef(null); - // Focus password input when lock activates + // Derive from auth query — has_passkeys covers both owners and any registered passkey + const userHasPasskeys = authStatus?.has_passkeys ?? hasPasskeys; + const showPasskeyButton = userHasPasskeys && supportsWebAuthn; + // When passwordless is enabled: passkey is the primary unlock method + // When passwordless is disabled: show password form, optionally with passkey secondary + const showPasswordForm = !passwordlessEnabled; + + // Focus password input when lock activates (only when password form is visible) useEffect(() => { - if (isLocked) { + if (isLocked && showPasswordForm) { setPassword(''); - // Small delay to let the overlay render const t = setTimeout(() => inputRef.current?.focus(), 100); return () => clearTimeout(t); } - }, [isLocked]); + }, [isLocked, showPasswordForm]); + + // Auto-trigger passkey unlock when passwordless mode is enabled + useEffect(() => { + if (isLocked && passwordlessEnabled && supportsWebAuthn && !isPasskeyUnlocking) { + handlePasskeyUnlock(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLocked, passwordlessEnabled]); if (!isLocked) return null; @@ -50,6 +67,29 @@ export default function LockOverlay() { } }; + const handlePasskeyUnlock = async () => { + setIsPasskeyUnlocking(true); + try { + const { startAuthentication } = await import('@simplewebauthn/browser'); + const { data: beginResp } = await api.post('/auth/passkeys/login/begin', {}); + const credential = await startAuthentication(beginResp.options); + await api.post('/auth/passkeys/login/complete', { + credential: JSON.stringify(credential), + challenge_token: beginResp.challenge_token, + unlock: true, + }); + unlockWithPasskey(); + } catch (error) { + if (error instanceof Error && error.name === 'NotAllowedError') { + toast.error('Passkey not recognized'); + } else if (error instanceof Error && error.name !== 'AbortError') { + toast.error(getErrorMessage(error, 'Unlock failed')); + } + } finally { + setIsPasskeyUnlocking(false); + } + }; + const handleSwitchAccount = async () => { await logout(); navigate('/login'); @@ -75,29 +115,87 @@ export default function LockOverlay() { )}
- {/* Password form */} -
- setPassword(e.target.value)} - placeholder="Enter password to unlock" - autoComplete="current-password" - className="text-center" - /> - -
+ )} + + {/* Password form — shown when passwordless is off */} + {showPasswordForm && ( + <> +
+ setPassword(e.target.value)} + placeholder="Enter password to unlock" + autoComplete="current-password" + className="text-center" + /> + +
+ + {/* Passkey secondary option */} + {showPasskeyButton && ( +
+
+ + or + +
+ +
+ )} + + )} {/* Switch account link */} + {/* Passwordless login section */} + + +
+
+
+ + +
+

+ Skip the password prompt and unlock the app using a passkey only. +

+ {passkeyCount < 2 && ( +

+ Requires at least 2 registered passkeys as a fallback. +

+ )} +
+ { + if (checked) { + setEnablePassword(''); + setEnableDialogOpen(true); + } else { + setDisableDialogOpen(true); + disablePasswordlessMutation.mutate(); + } + }} + disabled={(!passwordlessEnabled && passkeyCount < 2) || enablePasswordlessMutation.isPending || disablePasswordlessMutation.isPending} + aria-label="Toggle passwordless login" + /> +
+ + {/* Enable passwordless dialog */} + { + if (!open) { setEnableDialogOpen(false); setEnablePassword(''); } + }}> + + +
+
+ +
+ Enable Passwordless Login +
+ + Confirm your password to enable passkey-only login. + +
+
+ + setEnablePassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && enablePassword) { + enablePasswordlessMutation.mutate({ password: enablePassword }); + } + }} + autoFocus + /> +
+ + + + +
+
+ + {/* Disable passwordless dialog */} + { + if (!open && !disablePasswordlessMutation.isPending) { + setDisableDialogOpen(false); + } + }}> + + +
+
+ +
+ Disable Passwordless Login +
+ + Verify with your passkey to disable passwordless login. + +
+
+ +

+ Follow your browser's prompt to verify your passkey +

+
+
+
+ {/* Registration ceremony dialog */} { + const { data } = await api.put(`/admin/users/${userId}/passwordless`, { enabled: false }); + return data; + }); +} + // Re-export getErrorMessage for convenience in admin components export { getErrorMessage }; diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 6cc8665..a8b6cb5 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -152,5 +152,7 @@ export function useAuth() { passkeyLogin: passkeyLoginMutation.mutateAsync, isPasskeyLoginPending: passkeyLoginMutation.isPending, hasPasskeys: authQuery.data?.has_passkeys ?? false, + passkeyCount: authQuery.data?.passkey_count ?? 0, + passwordlessEnabled: authQuery.data?.passwordless_enabled ?? false, }; } diff --git a/frontend/src/hooks/useLock.tsx b/frontend/src/hooks/useLock.tsx index cdb2e1e..bb91a2b 100644 --- a/frontend/src/hooks/useLock.tsx +++ b/frontend/src/hooks/useLock.tsx @@ -17,6 +17,7 @@ interface LockContextValue { isLockResolved: boolean; lock: () => Promise; unlock: (password: string) => Promise; + unlockWithPasskey: () => void; } const LockContext = createContext(null); @@ -94,6 +95,14 @@ export function LockProvider({ children }: { children: ReactNode }) { } }, [queryClient]); + const unlockWithPasskey = useCallback(() => { + setIsLocked(false); + lastActivityRef.current = Date.now(); + queryClient.setQueryData(['auth'], (old) => + old ? { ...old, is_locked: false } : old + ); + }, [queryClient]); + // Auto-lock idle timer useEffect(() => { const enabled = settings?.auto_lock_enabled ?? false; @@ -147,7 +156,7 @@ export function LockProvider({ children }: { children: ReactNode }) { }, [settings?.auto_lock_enabled, settings?.auto_lock_minutes, isLocked, lock]); return ( - + {children} ); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ea9023c..f7b2f02 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -244,6 +244,8 @@ export interface AuthStatus { registration_open: boolean; is_locked: boolean; has_passkeys: boolean; + passkey_count: number; + passwordless_enabled: boolean; } export interface PasskeyCredential { @@ -288,6 +290,7 @@ export interface AdminUser { last_password_change_at: string | null; totp_enabled: boolean; mfa_enforce_pending: boolean; + passwordless_enabled: boolean; created_at: string; } @@ -302,6 +305,7 @@ export interface AdminUserDetail extends AdminUser { export interface SystemConfig { allow_registration: boolean; enforce_mfa_new_users: boolean; + allow_passwordless: boolean; } export interface AuditLogEntry {