From 9b898924ab2f7bedb1936e64e5de570dc3f66378 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:55:04 +0200 Subject: [PATCH] feat: add passkey management and auth policy sections to tenant settings Adds PasskeySection (list/rename/delete passkeys), AuthPolicySection (MFA mode + passkey enable/mode controls), and PasskeyNudgeBanner (dismissable nudge for users without a passkey enrolled). Co-Authored-By: Claude Sonnet 4.6 --- ui/package-lock.json | 7 + ui/package.json | 1 + ui/sign-in/package-lock.json | 7 + ui/sign-in/package.json | 1 + ui/src/pages/tenant/SettingsPage.tsx | 207 ++++++++++++++++++++++++++- 5 files changed, 222 insertions(+), 1 deletion(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 5a070b2..160b53e 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@cameleer/design-system": "^0.1.54", "@logto/react": "^4.0.13", + "@simplewebauthn/browser": "^13.3.0", "@tanstack/react-query": "^5.90.0", "lucide-react": "^1.7.0", "qrcode.react": "^4.2.0", @@ -1267,6 +1268,12 @@ "pnpm": "^10.0.0" } }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", diff --git a/ui/package.json b/ui/package.json index b0cd5bd..f33e885 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,6 +12,7 @@ "dependencies": { "@cameleer/design-system": "^0.1.54", "@logto/react": "^4.0.13", + "@simplewebauthn/browser": "^13.3.0", "@tanstack/react-query": "^5.90.0", "lucide-react": "^1.7.0", "qrcode.react": "^4.2.0", diff --git a/ui/sign-in/package-lock.json b/ui/sign-in/package-lock.json index 9c5b03a..f9b7c11 100644 --- a/ui/sign-in/package-lock.json +++ b/ui/sign-in/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@cameleer/design-system": "^0.1.54", + "@simplewebauthn/browser": "^13.3.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -1204,6 +1205,12 @@ "win32" ] }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", diff --git a/ui/sign-in/package.json b/ui/sign-in/package.json index 6b49435..f28d3bd 100644 --- a/ui/sign-in/package.json +++ b/ui/sign-in/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@cameleer/design-system": "^0.1.54", + "@simplewebauthn/browser": "^13.3.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/ui/src/pages/tenant/SettingsPage.tsx b/ui/src/pages/tenant/SettingsPage.tsx index 5f678f9..842881b 100644 --- a/ui/src/pages/tenant/SettingsPage.tsx +++ b/ui/src/pages/tenant/SettingsPage.tsx @@ -14,7 +14,8 @@ import { import { useTenantSettings, useChangeOwnPassword, useResetServerAdminPassword, useMfaStatus, useMfaSetup, useMfaVerify, useMfaBackupCodes, useMfaRemove, - useUpdateTenantSettings, + useUpdateTenantSettings, usePasskeyList, useRenamePasskey, useDeletePasskey, + useTenantAuthSettings, useUpdateTenantAuthSettings, } from '../../api/tenant-hooks'; import { useScopes } from '../../auth/useScopes'; import { tierColor } from '../../utils/tier'; @@ -340,6 +341,207 @@ function MfaEnforcementToggle() { ); } +function PasskeyNudgeBanner() { + const { data: status } = useMfaStatus(); + const [dismissed, setDismissed] = useState(false); + + const lastDismissed = localStorage.getItem('passkey_nudge_dismissed'); + const recentlyDismissed = lastDismissed && (Date.now() - Number(lastDismissed)) < 30 * 24 * 60 * 60 * 1000; + + if (dismissed || recentlyDismissed || !status || status.passkeyEnrolled) return null; + + function handleDismiss() { + localStorage.setItem('passkey_nudge_dismissed', String(Date.now())); + setDismissed(true); + } + + return ( + +

+ Use your fingerprint, face, or security key instead of typing a code every time. +

+ +
+ ); +} + +function PasskeySection() { + const { toast } = useToast(); + const { data: passkeys, isLoading } = usePasskeyList(); + const renamePasskey = useRenamePasskey(); + const deletePasskey = useDeletePasskey(); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + + function parseAgent(agent: string | null): string { + if (!agent) return 'Unknown device'; + if (agent.includes('Chrome')) return agent.includes('Windows') ? 'Chrome on Windows' : agent.includes('Mac') ? 'Chrome on macOS' : agent.includes('Android') ? 'Chrome on Android' : 'Chrome'; + if (agent.includes('Safari') && !agent.includes('Chrome')) return agent.includes('iPhone') ? 'Safari on iPhone' : 'Safari on macOS'; + if (agent.includes('Firefox')) return 'Firefox'; + if (agent.includes('Edge')) return 'Edge'; + return 'Browser'; + } + + function startRename(id: string, currentName: string | null) { + setEditingId(id); + setEditName(currentName ?? ''); + } + + async function handleRename(id: string) { + try { + await renamePasskey.mutateAsync({ id, name: editName }); + setEditingId(null); + toast({ title: 'Passkey renamed', variant: 'success' }); + } catch (err) { + toast({ title: 'Failed to rename passkey', description: errorMessage(err), variant: 'error' }); + } + } + + async function handleDelete(id: string) { + try { + await deletePasskey.mutateAsync(id); + setConfirmDeleteId(null); + toast({ title: 'Passkey removed', variant: 'success' }); + } catch (err) { + toast({ title: 'Failed to remove passkey', description: errorMessage(err), variant: 'error' }); + } + } + + if (isLoading) return null; + const credentials = passkeys ?? []; + + return ( + +

+ Use your fingerprint, face, or security key to sign in faster. +

+ {credentials.length === 0 ? ( +

+ No passkeys registered. Passkeys can be registered during sign-in when prompted. +

+ ) : ( +
+ {credentials.map((pk) => ( +
+
+ {editingId === pk.id ? ( +
+ setEditName(e.target.value)} placeholder="Passkey name" style={{ maxWidth: 200 }} /> + + +
+ ) : ( + <> +
{pk.name || 'Unnamed passkey'}
+
+ {parseAgent(pk.agent)} · Added {pk.createdAt ? new Date(pk.createdAt).toLocaleDateString() : 'unknown'} +
+ + )} +
+ {editingId !== pk.id && ( +
+ + {confirmDeleteId === pk.id ? ( + <> + + + + ) : ( + + )} +
+ )} +
+ ))} +
+ )} +
+ ); +} + +function AuthPolicySection() { + const scopes = useScopes(); + const { toast } = useToast(); + const { data: authSettings } = useTenantAuthSettings(); + const updateAuth = useUpdateTenantAuthSettings(); + + if (!scopes.has('tenant:manage') || !authSettings) return null; + + async function handleMfaModeChange(mode: string) { + try { + await updateAuth.mutateAsync({ mfaMode: mode }); + toast({ title: `MFA mode set to ${mode}`, variant: 'success' }); + } catch (err) { + toast({ title: 'Failed to update', description: errorMessage(err), variant: 'error' }); + } + } + + async function handlePasskeyToggle() { + if (!authSettings) return; + try { + await updateAuth.mutateAsync({ passkeyEnabled: !authSettings.passkeyEnabled }); + toast({ title: authSettings.passkeyEnabled ? 'Passkeys disabled' : 'Passkeys enabled', variant: 'success' }); + } catch (err) { + toast({ title: 'Failed to update', description: errorMessage(err), variant: 'error' }); + } + } + + async function handlePasskeyModeChange(mode: string) { + try { + await updateAuth.mutateAsync({ passkeyMode: mode }); + toast({ title: `Passkey mode set to ${mode}`, variant: 'success' }); + } catch (err) { + toast({ title: 'Failed to update', description: errorMessage(err), variant: 'error' }); + } + } + + return ( + +

+ Configure MFA and passkey requirements for your organization's users. +

+
+ MFA Mode + +
+
+ {['off', 'optional', 'required'].map((mode) => ( + + ))} +
+
+ Passkeys + +
+ + {authSettings.passkeyEnabled && ( +
+
+ Passkey Mode + +
+
+ {['optional', 'preferred', 'required'].map((mode) => ( + + ))} +
+
+ )} +
+ ); +} + export function SettingsPage() { const { data, isLoading, isError } = useTenantSettings(); const changePassword = useChangeOwnPassword(); @@ -391,6 +593,7 @@ export function SettingsPage() { return (

Settings

+
@@ -502,6 +705,8 @@ export function SettingsPage() { + +
); }