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 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-27 08:55:04 +02:00
parent 8de16019b7
commit 9b898924ab
5 changed files with 222 additions and 1 deletions

7
ui/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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 (
<Alert variant="info" title="Sign in faster with a passkey">
<p style={{ margin: '4px 0 12px' }}>
Use your fingerprint, face, or security key instead of typing a code every time.
</p>
<Button size="sm" variant="secondary" onClick={handleDismiss}>Not now</Button>
</Alert>
);
}
function PasskeySection() {
const { toast } = useToast();
const { data: passkeys, isLoading } = usePasskeyList();
const renamePasskey = useRenamePasskey();
const deletePasskey = useDeletePasskey();
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(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 (
<Card title="Passkeys">
<p className={styles.description} style={{ marginTop: 0 }}>
Use your fingerprint, face, or security key to sign in faster.
</p>
{credentials.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
No passkeys registered. Passkeys can be registered during sign-in when prompted.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{credentials.map((pk) => (
<div key={pk.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
<div style={{ flex: 1 }}>
{editingId === pk.id ? (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Input value={editName} onChange={(e) => setEditName(e.target.value)} placeholder="Passkey name" style={{ maxWidth: 200 }} />
<Button size="sm" variant="primary" onClick={() => handleRename(pk.id)} loading={renamePasskey.isPending}>Save</Button>
<Button size="sm" variant="secondary" onClick={() => setEditingId(null)}>Cancel</Button>
</div>
) : (
<>
<div style={{ fontWeight: 500 }}>{pk.name || 'Unnamed passkey'}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{parseAgent(pk.agent)} &middot; Added {pk.createdAt ? new Date(pk.createdAt).toLocaleDateString() : 'unknown'}
</div>
</>
)}
</div>
{editingId !== pk.id && (
<div style={{ display: 'flex', gap: 8 }}>
<Button size="sm" variant="secondary" onClick={() => startRename(pk.id, pk.name)}>Rename</Button>
{confirmDeleteId === pk.id ? (
<>
<Button size="sm" variant="danger" onClick={() => handleDelete(pk.id)} loading={deletePasskey.isPending}>Confirm</Button>
<Button size="sm" variant="secondary" onClick={() => setConfirmDeleteId(null)}>Cancel</Button>
</>
) : (
<Button size="sm" variant="danger" onClick={() => setConfirmDeleteId(pk.id)}>Remove</Button>
)}
</div>
)}
</div>
))}
</div>
)}
</Card>
);
}
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 (
<Card title="Authentication Policy">
<p className={styles.description} style={{ marginTop: 0 }}>
Configure MFA and passkey requirements for your organization's users.
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<span style={{ fontSize: '0.875rem' }}>MFA Mode</span>
<Badge label={authSettings.mfaMode} color={authSettings.mfaMode === 'required' ? 'success' : 'auto'} />
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
{['off', 'optional', 'required'].map((mode) => (
<Button key={mode} variant={authSettings.mfaMode === mode ? 'primary' : 'secondary'}
onClick={() => handleMfaModeChange(mode)} loading={updateAuth.isPending} size="sm">
{mode.charAt(0).toUpperCase() + mode.slice(1)}
</Button>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<span style={{ fontSize: '0.875rem' }}>Passkeys</span>
<Badge label={authSettings.passkeyEnabled ? 'Enabled' : 'Disabled'} color={authSettings.passkeyEnabled ? 'success' : 'auto'} />
</div>
<Button variant={authSettings.passkeyEnabled ? 'danger' : 'primary'}
onClick={handlePasskeyToggle} loading={updateAuth.isPending} size="sm">
{authSettings.passkeyEnabled ? 'Disable passkeys' : 'Enable passkeys'}
</Button>
{authSettings.passkeyEnabled && (
<div style={{ marginTop: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<span style={{ fontSize: '0.875rem' }}>Passkey Mode</span>
<Badge label={authSettings.passkeyMode} color={authSettings.passkeyMode === 'required' ? 'success' : 'auto'} />
</div>
<div style={{ display: 'flex', gap: 8 }}>
{['optional', 'preferred', 'required'].map((mode) => (
<Button key={mode} variant={authSettings.passkeyMode === mode ? 'primary' : 'secondary'}
onClick={() => handlePasskeyModeChange(mode)} loading={updateAuth.isPending} size="sm">
{mode.charAt(0).toUpperCase() + mode.slice(1)}
</Button>
))}
</div>
</div>
)}
</Card>
);
}
export function SettingsPage() {
const { data, isLoading, isError } = useTenantSettings();
const changePassword = useChangeOwnPassword();
@@ -391,6 +593,7 @@ export function SettingsPage() {
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Settings</h1>
<PasskeyNudgeBanner />
<Card title="Tenant Details">
<div className={styles.dividerList}>
@@ -502,6 +705,8 @@ export function SettingsPage() {
<MfaSection />
<MfaEnforcementToggle />
<PasskeySection />
<AuthPolicySection />
</div>
);
}