feat(passkeys): implement passwordless login frontend (Phase 2)

- types/index.ts: add passkey_count, passwordless_enabled to AuthStatus; add allow_passwordless to SystemConfig; add passwordless_enabled to AdminUser
- useAuth: expose passwordlessEnabled and passkeyCount from auth query
- useLock: add unlockWithPasskey() — clears lock state without password verification
- LockOverlay: passkey unlock support with three modes: passwordless-primary (passkey only, auto-triggers), hybrid (password + "or use a passkey"), password-only (existing behaviour)
- PasskeySection: passwordless toggle below passkey list — enable via password dialog, disable via WebAuthn ceremony dialog; requires 2+ passkeys
- useAdmin: add useDisablePasswordless mutation (PUT /admin/users/{id}/passwordless)
- IAMPage: add allow_passwordless system config toggle
- UserActionsMenu: add "Disable Passwordless" two-click confirm item (shown when user.passwordless_enabled)
- UserDetailSection: add Passwordless badge in Security & Permissions card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-18 00:16:48 +08:00
parent bcfebbc9ae
commit 42d73526f5
9 changed files with 362 additions and 29 deletions

View File

@ -81,7 +81,7 @@ export default function IAMPage() {
); );
}, [users, searchQuery]); }, [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 { try {
await updateConfig.mutateAsync({ [key]: value }); await updateConfig.mutateAsync({ [key]: value });
toast.success('System settings updated'); toast.success('System settings updated');
@ -320,6 +320,20 @@ export default function IAMPage() {
disabled={updateConfig.isPending} disabled={updateConfig.isPending}
/> />
</div> </div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label className="text-sm font-medium">Allow Passwordless Login</Label>
<p className="text-xs text-muted-foreground">
Allow users to enable passkey-only login, skipping the password prompt entirely.
</p>
</div>
<Switch
checked={config?.allow_passwordless ?? false}
onCheckedChange={(v) => handleConfigToggle('allow_passwordless', v)}
disabled={updateConfig.isPending}
/>
</div>
</> </>
)} )}
</CardContent> </CardContent>

View File

@ -11,6 +11,7 @@ import {
ChevronRight, ChevronRight,
Loader2, Loader2,
Trash2, Trash2,
ShieldOff,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useConfirmAction } from '@/hooks/useConfirmAction'; import { useConfirmAction } from '@/hooks/useConfirmAction';
@ -23,6 +24,7 @@ import {
useToggleUserActive, useToggleUserActive,
useRevokeSessions, useRevokeSessions,
useDeleteUser, useDeleteUser,
useDisablePasswordless,
getErrorMessage, getErrorMessage,
} from '@/hooks/useAdmin'; } from '@/hooks/useAdmin';
import type { AdminUserDetail, UserRole } from '@/types'; import type { AdminUserDetail, UserRole } from '@/types';
@ -53,6 +55,7 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
const toggleActive = useToggleUserActive(); const toggleActive = useToggleUserActive();
const revokeSessions = useRevokeSessions(); const revokeSessions = useRevokeSessions();
const deleteUser = useDeleteUser(); const deleteUser = useDeleteUser();
const disablePasswordless = useDisablePasswordless();
// Close on outside click // Close on outside click
useEffect(() => { 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 = const isLoading =
updateRole.isPending || updateRole.isPending ||
resetPassword.isPending || resetPassword.isPending ||
@ -110,7 +117,8 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
removeMfaEnforcement.isPending || removeMfaEnforcement.isPending ||
toggleActive.isPending || toggleActive.isPending ||
revokeSessions.isPending || revokeSessions.isPending ||
deleteUser.isPending; deleteUser.isPending ||
disablePasswordless.isPending;
return ( return (
<div ref={menuRef} className="relative"> <div ref={menuRef} className="relative">
@ -258,6 +266,21 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
</button> </button>
)} )}
{user.passwordless_enabled && (
<button
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
disablePasswordlessConfirm.confirming
? 'text-orange-400 bg-orange-500/10 hover:bg-orange-500/15'
: 'hover:bg-card-elevated'
)}
onClick={disablePasswordlessConfirm.handleClick}
>
<ShieldOff className="h-4 w-4" />
{disablePasswordlessConfirm.confirming ? 'Sure? Click to confirm' : 'Disable Passwordless'}
</button>
)}
<div className="my-1 border-t border-border" /> <div className="my-1 border-t border-border" />
{/* Disable / Enable Account */} {/* Disable / Enable Account */}

View File

@ -193,6 +193,18 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
/> />
} }
/> />
<DetailRow
label="Passwordless"
value={
user.passwordless_enabled ? (
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-green-500/15 text-green-400">
Enabled
</span>
) : (
<span className="text-xs text-muted-foreground">Off</span>
)
}
/>
<DetailRow <DetailRow
label="Must Change Pwd" label="Must Change Pwd"
value={user.must_change_password ? 'Yes' : 'No'} value={user.must_change_password ? 'Yes' : 'No'}

View File

@ -1,34 +1,51 @@
import { useState, FormEvent, useEffect, useRef } from 'react'; import { useState, FormEvent, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Lock, Loader2 } from 'lucide-react'; import { Lock, Loader2, Fingerprint } from 'lucide-react';
import { useLock } from '@/hooks/useLock'; import { useLock } from '@/hooks/useLock';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import AmbientBackground from '@/components/auth/AmbientBackground'; import AmbientBackground from '@/components/auth/AmbientBackground';
export default function LockOverlay() { export default function LockOverlay() {
const { isLocked, unlock } = useLock(); const { isLocked, unlock, unlockWithPasskey } = useLock();
const { logout } = useAuth(); const { logout, passwordlessEnabled, hasPasskeys, authStatus } = useAuth();
const { settings } = useSettings(); const { settings } = useSettings();
const navigate = useNavigate(); const navigate = useNavigate();
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [isUnlocking, setIsUnlocking] = useState(false); const [isUnlocking, setIsUnlocking] = useState(false);
const [isPasskeyUnlocking, setIsPasskeyUnlocking] = useState(false);
const [supportsWebAuthn] = useState(() => !!window.PublicKeyCredential);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(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(() => { useEffect(() => {
if (isLocked) { if (isLocked && showPasswordForm) {
setPassword(''); setPassword('');
// Small delay to let the overlay render
const t = setTimeout(() => inputRef.current?.focus(), 100); const t = setTimeout(() => inputRef.current?.focus(), 100);
return () => clearTimeout(t); 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; 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 () => { const handleSwitchAccount = async () => {
await logout(); await logout();
navigate('/login'); navigate('/login');
@ -75,29 +115,87 @@ export default function LockOverlay() {
)} )}
</div> </div>
{/* Password form */} {/* Passwordless-primary mode: passkey button only */}
<form onSubmit={handleUnlock} className="w-full space-y-4"> {passwordlessEnabled && showPasskeyButton && (
<Input <Button
ref={inputRef} type="button"
type="password" className="w-full gap-2"
aria-label="Password" onClick={handlePasskeyUnlock}
value={password} disabled={isPasskeyUnlocking}
onChange={(e) => setPassword(e.target.value)} aria-label="Unlock with passkey"
placeholder="Enter password to unlock" >
autoComplete="current-password" {isPasskeyUnlocking ? (
className="text-center"
/>
<Button type="submit" className="w-full" disabled={isUnlocking}>
{isUnlocking ? (
<> <>
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Unlocking Verifying passkey
</> </>
) : ( ) : (
'Unlock' <>
<Fingerprint className="h-4 w-4" />
Unlock with passkey
</>
)} )}
</Button> </Button>
</form> )}
{/* Password form — shown when passwordless is off */}
{showPasswordForm && (
<>
<form onSubmit={handleUnlock} className="w-full space-y-4">
<Input
ref={inputRef}
type="password"
aria-label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password to unlock"
autoComplete="current-password"
className="text-center"
/>
<Button type="submit" className="w-full" disabled={isUnlocking}>
{isUnlocking ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Unlocking
</>
) : (
'Unlock'
)}
</Button>
</form>
{/* Passkey secondary option */}
{showPasskeyButton && (
<div className="w-full flex flex-col items-center gap-4">
<div className="w-full flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-xs text-muted-foreground shrink-0">or</span>
<Separator className="flex-1" />
</div>
<Button
type="button"
variant="outline"
className="w-full gap-2"
onClick={handlePasskeyUnlock}
disabled={isPasskeyUnlocking}
aria-label="Unlock with passkey"
>
{isPasskeyUnlocking ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Verifying passkey
</>
) : (
<>
<Fingerprint className="h-4 w-4" />
Use a passkey
</>
)}
</Button>
</div>
)}
</>
)}
{/* Switch account link */} {/* Switch account link */}
<button <button

View File

@ -1,12 +1,13 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Fingerprint, Loader2, Trash2, Cloud } from 'lucide-react'; import { Fingerprint, Loader2, Trash2, Cloud, ShieldOff } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -17,6 +18,7 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { useConfirmAction } from '@/hooks/useConfirmAction'; import { useConfirmAction } from '@/hooks/useConfirmAction';
import { useAuth } from '@/hooks/useAuth';
import type { PasskeyCredential } from '@/types'; import type { PasskeyCredential } from '@/types';
function formatRelativeTime(dateStr: string | null): string { function formatRelativeTime(dateStr: string | null): string {
@ -135,6 +137,9 @@ function PasskeyDeleteButton({ credential, onDelete, isDeleting }: DeleteConfirm
export default function PasskeySection() { export default function PasskeySection() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { passwordlessEnabled, passkeyCount } = useAuth();
// Registration state
const [registerDialogOpen, setRegisterDialogOpen] = useState(false); const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
const [ceremonyState, setCeremonyState] = useState<'password' | 'waiting' | 'naming'>('password'); const [ceremonyState, setCeremonyState] = useState<'password' | 'waiting' | 'naming'>('password');
const [registerPassword, setRegisterPassword] = useState(''); const [registerPassword, setRegisterPassword] = useState('');
@ -144,6 +149,13 @@ export default function PasskeySection() {
challenge_token: string; challenge_token: string;
} | null>(null); } | null>(null);
// Passwordless enable state
const [enableDialogOpen, setEnableDialogOpen] = useState(false);
const [enablePassword, setEnablePassword] = useState('');
// Passwordless disable state
const [disableDialogOpen, setDisableDialogOpen] = useState(false);
const passkeysQuery = useQuery({ const passkeysQuery = useQuery({
queryKey: ['passkeys'], queryKey: ['passkeys'],
queryFn: async () => { queryFn: async () => {
@ -221,6 +233,52 @@ export default function PasskeySection() {
}, },
}); });
const enablePasswordlessMutation = useMutation({
mutationFn: async ({ password }: { password: string }) => {
const { data } = await api.put('/auth/passkeys/passwordless/enable', { password });
return data;
},
onSuccess: () => {
toast.success('Passwordless login enabled');
queryClient.invalidateQueries({ queryKey: ['auth'] });
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
setEnableDialogOpen(false);
setEnablePassword('');
},
onError: (error: unknown) => {
toast.error(getErrorMessage(error, 'Failed to enable passwordless login'));
},
});
const disablePasswordlessMutation = useMutation({
mutationFn: async () => {
const { startAuthentication } = await import('@simplewebauthn/browser');
const { data: beginResp } = await api.post('/auth/passkeys/passwordless/disable/begin', {});
const credential = await startAuthentication(beginResp.options);
const { data } = await api.put('/auth/passkeys/passwordless/disable', {
credential: JSON.stringify(credential),
challenge_token: beginResp.challenge_token,
});
return data;
},
onSuccess: () => {
toast.success('Passwordless login disabled');
queryClient.invalidateQueries({ queryKey: ['auth'] });
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
setDisableDialogOpen(false);
},
onError: (error: unknown) => {
if (error instanceof Error && error.name === 'NotAllowedError') {
toast.error('Passkey not recognized');
} else if (error instanceof Error && error.name === 'AbortError') {
toast.info('Cancelled');
} else {
toast.error(getErrorMessage(error, 'Failed to disable passwordless login'));
}
setDisableDialogOpen(false);
},
});
const resetRegisterState = useCallback(() => { const resetRegisterState = useCallback(() => {
setCeremonyState('password'); setCeremonyState('password');
setRegisterPassword(''); setRegisterPassword('');
@ -318,6 +376,112 @@ export default function PasskeySection() {
{hasPasskeys ? 'Add another passkey' : 'Add a passkey'} {hasPasskeys ? 'Add another passkey' : 'Add a passkey'}
</Button> </Button>
{/* Passwordless login section */}
<Separator />
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Fingerprint className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium">Passwordless Login</Label>
</div>
<p className="text-xs text-muted-foreground">
Skip the password prompt and unlock the app using a passkey only.
</p>
{passkeyCount < 2 && (
<p className="text-xs text-amber-400">
Requires at least 2 registered passkeys as a fallback.
</p>
)}
</div>
<Switch
checked={passwordlessEnabled}
onCheckedChange={(checked) => {
if (checked) {
setEnablePassword('');
setEnableDialogOpen(true);
} else {
setDisableDialogOpen(true);
disablePasswordlessMutation.mutate();
}
}}
disabled={(!passwordlessEnabled && passkeyCount < 2) || enablePasswordlessMutation.isPending || disablePasswordlessMutation.isPending}
aria-label="Toggle passwordless login"
/>
</div>
{/* Enable passwordless dialog */}
<Dialog open={enableDialogOpen} onOpenChange={(open) => {
if (!open) { setEnableDialogOpen(false); setEnablePassword(''); }
}}>
<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>Enable Passwordless Login</DialogTitle>
</div>
<DialogDescription>
Confirm your password to enable passkey-only login.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="enable-passwordless-password">Password</Label>
<Input
id="enable-passwordless-password"
type="password"
value={enablePassword}
onChange={(e) => setEnablePassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && enablePassword) {
enablePasswordlessMutation.mutate({ password: enablePassword });
}
}}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setEnableDialogOpen(false); setEnablePassword(''); }}>
Cancel
</Button>
<Button
onClick={() => enablePasswordlessMutation.mutate({ password: enablePassword })}
disabled={!enablePassword || enablePasswordlessMutation.isPending}
>
{enablePasswordlessMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Enable'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Disable passwordless dialog */}
<Dialog open={disableDialogOpen} onOpenChange={(open) => {
if (!open && !disablePasswordlessMutation.isPending) {
setDisableDialogOpen(false);
}
}}>
<DialogContent>
<DialogHeader>
<div className="flex items-center gap-3 mb-1">
<div className="p-2 rounded-lg bg-orange-500/10">
<ShieldOff className="h-5 w-5 text-orange-400" />
</div>
<DialogTitle>Disable Passwordless Login</DialogTitle>
</div>
<DialogDescription>
Verify with your passkey to disable passwordless login.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-4">
<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 verify your passkey
</p>
</div>
</DialogContent>
</Dialog>
{/* Registration ceremony dialog */} {/* Registration ceremony dialog */}
<Dialog <Dialog
open={registerDialogOpen} open={registerDialogOpen}

View File

@ -203,5 +203,12 @@ export function useUpdateConfig() {
}); });
} }
export function useDisablePasswordless() {
return useAdminMutation(async (userId: number) => {
const { data } = await api.put(`/admin/users/${userId}/passwordless`, { enabled: false });
return data;
});
}
// Re-export getErrorMessage for convenience in admin components // Re-export getErrorMessage for convenience in admin components
export { getErrorMessage }; export { getErrorMessage };

View File

@ -152,5 +152,7 @@ export function useAuth() {
passkeyLogin: passkeyLoginMutation.mutateAsync, passkeyLogin: passkeyLoginMutation.mutateAsync,
isPasskeyLoginPending: passkeyLoginMutation.isPending, isPasskeyLoginPending: passkeyLoginMutation.isPending,
hasPasskeys: authQuery.data?.has_passkeys ?? false, hasPasskeys: authQuery.data?.has_passkeys ?? false,
passkeyCount: authQuery.data?.passkey_count ?? 0,
passwordlessEnabled: authQuery.data?.passwordless_enabled ?? false,
}; };
} }

View File

@ -17,6 +17,7 @@ interface LockContextValue {
isLockResolved: boolean; isLockResolved: boolean;
lock: () => Promise<void>; lock: () => Promise<void>;
unlock: (password: string) => Promise<void>; unlock: (password: string) => Promise<void>;
unlockWithPasskey: () => void;
} }
const LockContext = createContext<LockContextValue | null>(null); const LockContext = createContext<LockContextValue | null>(null);
@ -94,6 +95,14 @@ export function LockProvider({ children }: { children: ReactNode }) {
} }
}, [queryClient]); }, [queryClient]);
const unlockWithPasskey = useCallback(() => {
setIsLocked(false);
lastActivityRef.current = Date.now();
queryClient.setQueryData<AuthStatus>(['auth'], (old) =>
old ? { ...old, is_locked: false } : old
);
}, [queryClient]);
// Auto-lock idle timer // Auto-lock idle timer
useEffect(() => { useEffect(() => {
const enabled = settings?.auto_lock_enabled ?? false; 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]); }, [settings?.auto_lock_enabled, settings?.auto_lock_minutes, isLocked, lock]);
return ( return (
<LockContext.Provider value={{ isLocked, isLockResolved, lock, unlock }}> <LockContext.Provider value={{ isLocked, isLockResolved, lock, unlock, unlockWithPasskey }}>
{children} {children}
</LockContext.Provider> </LockContext.Provider>
); );

View File

@ -244,6 +244,8 @@ export interface AuthStatus {
registration_open: boolean; registration_open: boolean;
is_locked: boolean; is_locked: boolean;
has_passkeys: boolean; has_passkeys: boolean;
passkey_count: number;
passwordless_enabled: boolean;
} }
export interface PasskeyCredential { export interface PasskeyCredential {
@ -288,6 +290,7 @@ export interface AdminUser {
last_password_change_at: string | null; last_password_change_at: string | null;
totp_enabled: boolean; totp_enabled: boolean;
mfa_enforce_pending: boolean; mfa_enforce_pending: boolean;
passwordless_enabled: boolean;
created_at: string; created_at: string;
} }
@ -302,6 +305,7 @@ export interface AdminUserDetail extends AdminUser {
export interface SystemConfig { export interface SystemConfig {
allow_registration: boolean; allow_registration: boolean;
enforce_mfa_new_users: boolean; enforce_mfa_new_users: boolean;
allow_passwordless: boolean;
} }
export interface AuditLogEntry { export interface AuditLogEntry {