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 */}
-
+ )}
+
+ {/* Password form — shown when passwordless is off */}
+ {showPasswordForm && (
+ <>
+
+
+ {/* 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 */}
+
+
+ {/* Disable passwordless dialog */}
+
+
{/* Registration ceremony dialog */}