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:
parent
bcfebbc9ae
commit
42d73526f5
@ -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}
|
||||
/>
|
||||
</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>
|
||||
|
||||
@ -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 (
|
||||
<div ref={menuRef} className="relative">
|
||||
@ -258,6 +266,21 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
|
||||
</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" />
|
||||
|
||||
{/* Disable / Enable Account */}
|
||||
|
||||
@ -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
|
||||
label="Must Change Pwd"
|
||||
value={user.must_change_password ? 'Yes' : 'No'}
|
||||
|
||||
@ -1,34 +1,51 @@
|
||||
import { useState, FormEvent, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { Lock, Loader2 } from 'lucide-react';
|
||||
import { Lock, Loader2, Fingerprint } from 'lucide-react';
|
||||
import { useLock } from '@/hooks/useLock';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { getErrorMessage } from '@/lib/api';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import AmbientBackground from '@/components/auth/AmbientBackground';
|
||||
|
||||
export default function LockOverlay() {
|
||||
const { isLocked, unlock } = useLock();
|
||||
const { logout } = useAuth();
|
||||
const { isLocked, unlock, unlockWithPasskey } = useLock();
|
||||
const { logout, passwordlessEnabled, hasPasskeys, authStatus } = useAuth();
|
||||
const { settings } = useSettings();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [isUnlocking, setIsUnlocking] = useState(false);
|
||||
const [isPasskeyUnlocking, setIsPasskeyUnlocking] = useState(false);
|
||||
const [supportsWebAuthn] = useState(() => !!window.PublicKeyCredential);
|
||||
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(() => {
|
||||
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,7 +115,32 @@ export default function LockOverlay() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password form */}
|
||||
{/* Passwordless-primary mode: passkey button only */}
|
||||
{passwordlessEnabled && showPasskeyButton && (
|
||||
<Button
|
||||
type="button"
|
||||
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" />
|
||||
Unlock with passkey
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Password form — shown when passwordless is off */}
|
||||
{showPasswordForm && (
|
||||
<>
|
||||
<form onSubmit={handleUnlock} className="w-full space-y-4">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
@ -99,6 +164,39 @@ export default function LockOverlay() {
|
||||
</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 */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
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 { Fingerprint, Loader2, Trash2, Cloud, ShieldOff } 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 { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -17,6 +18,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import type { PasskeyCredential } from '@/types';
|
||||
|
||||
function formatRelativeTime(dateStr: string | null): string {
|
||||
@ -135,6 +137,9 @@ function PasskeyDeleteButton({ credential, onDelete, isDeleting }: DeleteConfirm
|
||||
|
||||
export default function PasskeySection() {
|
||||
const queryClient = useQueryClient();
|
||||
const { passwordlessEnabled, passkeyCount } = useAuth();
|
||||
|
||||
// Registration state
|
||||
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
||||
const [ceremonyState, setCeremonyState] = useState<'password' | 'waiting' | 'naming'>('password');
|
||||
const [registerPassword, setRegisterPassword] = useState('');
|
||||
@ -144,6 +149,13 @@ export default function PasskeySection() {
|
||||
challenge_token: string;
|
||||
} | 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({
|
||||
queryKey: ['passkeys'],
|
||||
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(() => {
|
||||
setCeremonyState('password');
|
||||
setRegisterPassword('');
|
||||
@ -318,6 +376,112 @@ export default function PasskeySection() {
|
||||
{hasPasskeys ? 'Add another passkey' : 'Add a passkey'}
|
||||
</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 */}
|
||||
<Dialog
|
||||
open={registerDialogOpen}
|
||||
|
||||
@ -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
|
||||
export { getErrorMessage };
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ interface LockContextValue {
|
||||
isLockResolved: boolean;
|
||||
lock: () => Promise<void>;
|
||||
unlock: (password: string) => Promise<void>;
|
||||
unlockWithPasskey: () => void;
|
||||
}
|
||||
|
||||
const LockContext = createContext<LockContextValue | null>(null);
|
||||
@ -94,6 +95,14 @@ export function LockProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [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
|
||||
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 (
|
||||
<LockContext.Provider value={{ isLocked, isLockResolved, lock, unlock }}>
|
||||
<LockContext.Provider value={{ isLocked, isLockResolved, lock, unlock, unlockWithPasskey }}>
|
||||
{children}
|
||||
</LockContext.Provider>
|
||||
);
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user