Fix: hide passwordless toggle when disabled, remove lock auto-trigger

1. Passwordless toggle in Settings is now hidden when admin hasn't
   enabled allow_passwordless in system config (or when user already
   has it enabled — so they can still disable it). Backend exposes
   allow_passwordless in /auth/status response.

2. Remove auto-trigger passkey ceremony on lock screen — previously
   fired immediately when session locked for passwordless users.
   Now waits for user to click "Unlock with passkey" button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-18 00:32:03 +08:00
parent 42d73526f5
commit 1b868ba503
5 changed files with 36 additions and 39 deletions

View File

@ -553,6 +553,7 @@ async def auth_status(
"has_passkeys": has_passkeys, "has_passkeys": has_passkeys,
"passkey_count": passkey_count, "passkey_count": passkey_count,
"passwordless_enabled": passwordless_enabled, "passwordless_enabled": passwordless_enabled,
"allow_passwordless": config.allow_passwordless if config else False,
} }

View File

@ -39,14 +39,6 @@ export default function LockOverlay() {
} }
}, [isLocked, showPasswordForm]); }, [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;
const preferredName = settings?.preferred_name; const preferredName = settings?.preferred_name;

View File

@ -137,7 +137,7 @@ function PasskeyDeleteButton({ credential, onDelete, isDeleting }: DeleteConfirm
export default function PasskeySection() { export default function PasskeySection() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { passwordlessEnabled, passkeyCount } = useAuth(); const { passwordlessEnabled, passkeyCount, allowPasswordless } = useAuth();
// Registration state // Registration state
const [registerDialogOpen, setRegisterDialogOpen] = useState(false); const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
@ -376,39 +376,41 @@ export default function PasskeySection() {
{hasPasskeys ? 'Add another passkey' : 'Add a passkey'} {hasPasskeys ? 'Add another passkey' : 'Add a passkey'}
</Button> </Button>
{/* Passwordless login section */} {/* Passwordless login section — hidden when admin hasn't enabled the feature */}
<Separator /> {(allowPasswordless || passwordlessEnabled) && <Separator />}
<div className="flex items-start justify-between gap-4"> {(allowPasswordless || passwordlessEnabled) && (
<div className="space-y-1"> <div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-2"> <div className="space-y-1">
<Fingerprint className="h-4 w-4 text-muted-foreground" /> <div className="flex items-center gap-2">
<Label className="text-sm font-medium">Passwordless Login</Label> <Fingerprint className="h-4 w-4 text-muted-foreground" />
</div> <Label className="text-sm font-medium">Passwordless Login</Label>
<p className="text-xs text-muted-foreground"> </div>
Skip the password prompt and unlock the app using a passkey only. <p className="text-xs text-muted-foreground">
</p> Skip the password prompt and unlock the app using a passkey only.
{passkeyCount < 2 && (
<p className="text-xs text-amber-400">
Requires at least 2 registered passkeys as a fallback.
</p> </p>
)} {passkeyCount < 2 && !passwordlessEnabled && (
<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> </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 */} {/* Enable passwordless dialog */}
<Dialog open={enableDialogOpen} onOpenChange={(open) => { <Dialog open={enableDialogOpen} onOpenChange={(open) => {

View File

@ -154,5 +154,6 @@ export function useAuth() {
hasPasskeys: authQuery.data?.has_passkeys ?? false, hasPasskeys: authQuery.data?.has_passkeys ?? false,
passkeyCount: authQuery.data?.passkey_count ?? 0, passkeyCount: authQuery.data?.passkey_count ?? 0,
passwordlessEnabled: authQuery.data?.passwordless_enabled ?? false, passwordlessEnabled: authQuery.data?.passwordless_enabled ?? false,
allowPasswordless: authQuery.data?.allow_passwordless ?? false,
}; };
} }

View File

@ -246,6 +246,7 @@ export interface AuthStatus {
has_passkeys: boolean; has_passkeys: boolean;
passkey_count: number; passkey_count: number;
passwordless_enabled: boolean; passwordless_enabled: boolean;
allow_passwordless: boolean;
} }
export interface PasskeyCredential { export interface PasskeyCredential {