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]);
|
}, [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>
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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'}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user