From 7471976c9a97cb130249be4204feee2aee5929b9 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 5 Jun 2026 18:46:03 +0200 Subject: [PATCH] feat(auth): passkey enrolment, login button + admin settings UI PasskeysSection in account settings (add/rename/remove with a current-password step-up), a 'Sign in with a passkey' button on the login page, the admin enable + RP-ID/origins controls, and a per-user admin reset action. --- client/package.json | 1 + client/src/api/client.ts | 19 ++ client/src/components/Settings/AccountTab.tsx | 4 + .../components/Settings/PasskeysSection.tsx | 271 ++++++++++++++++++ client/src/pages/LoginPage.tsx | 38 ++- client/src/pages/admin/AdminSettingsTab.tsx | 67 +++++ client/src/pages/admin/AdminUserModals.tsx | 21 +- client/src/pages/admin/useAdmin.ts | 30 ++ client/src/pages/login/useLogin.ts | 27 +- 9 files changed, 474 insertions(+), 4 deletions(-) create mode 100644 client/src/components/Settings/PasskeysSection.tsx diff --git a/client/package.json b/client/package.json index 61fcc5cd..8c95c504 100644 --- a/client/package.json +++ b/client/package.json @@ -26,6 +26,7 @@ "@fontsource/geist-sans": "^5.2.5", "@fontsource/poppins": "^5.2.7", "@react-pdf/renderer": "^4.5.1", + "@simplewebauthn/browser": "^13.1.2", "@trek/shared": "*", "axios": "^1.6.7", "dexie": "^4.4.2", diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 94f7677d..f31b80d0 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -261,6 +261,24 @@ export const authApi = { create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data), delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data), }, + passkey: { + registerOptions: (password: string) => apiClient.post('/auth/passkey/register/options', { password }).then(r => r.data), + registerVerify: (attestationResponse: unknown, name?: string) => apiClient.post('/auth/passkey/register/verify', { attestationResponse, name }).then(r => r.data), + loginOptions: () => apiClient.post('/auth/passkey/login/options', {}).then(r => r.data), + loginVerify: (assertionResponse: unknown) => apiClient.post('/auth/passkey/login/verify', { assertionResponse }).then(r => r.data as { token: string; user: Record }), + list: () => apiClient.get('/auth/passkey/credentials').then(r => r.data as { credentials: PasskeyCredential[] }), + rename: (id: number, name: string) => apiClient.patch(`/auth/passkey/credentials/${id}`, { name }).then(r => r.data), + delete: (id: number, password: string) => apiClient.delete(`/auth/passkey/credentials/${id}`, { data: { password } }).then(r => r.data), + }, +} + +export interface PasskeyCredential { + id: number + name: string | null + device_type: string | null + backed_up: boolean + created_at: string + last_used_at: string | null } export const oauthApi = { @@ -414,6 +432,7 @@ export const adminApi = { createUser: (data: Record) => apiClient.post('/admin/users', data).then(r => r.data), updateUser: (id: number, data: Record) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data), deleteUser: (id: number) => apiClient.delete(`/admin/users/${id}`).then(r => r.data), + resetUserPasskeys: (id: number) => apiClient.delete(`/admin/users/${id}/passkeys`).then(r => r.data), stats: () => apiClient.get('/admin/stats').then(r => r.data), saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data), getOidc: () => apiClient.get('/admin/oidc').then(r => r.data), diff --git a/client/src/components/Settings/AccountTab.tsx b/client/src/components/Settings/AccountTab.tsx index 35b55de2..27161915 100644 --- a/client/src/components/Settings/AccountTab.tsx +++ b/client/src/components/Settings/AccountTab.tsx @@ -8,6 +8,7 @@ import { authApi, adminApi } from '../../api/client' import { getApiErrorMessage } from '../../types' import type { UserWithOidc } from '../../types' import Section from './Section' +import PasskeysSection from './PasskeysSection' const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending' @@ -395,6 +396,9 @@ export default function AccountTab(): React.ReactElement { + {/* Passkeys */} + + {/* Avatar */}
diff --git a/client/src/components/Settings/PasskeysSection.tsx b/client/src/components/Settings/PasskeysSection.tsx new file mode 100644 index 00000000..a221ab6d --- /dev/null +++ b/client/src/components/Settings/PasskeysSection.tsx @@ -0,0 +1,271 @@ +import React, { useEffect, useState } from 'react' +import { Fingerprint, Plus, Trash2, Pencil, Check, X } from 'lucide-react' +import { startRegistration } from '@simplewebauthn/browser' +import { useTranslation } from '../../i18n' +import { useToast } from '../shared/Toast' +import { authApi, type PasskeyCredential } from '../../api/client' +import { getApiErrorMessage } from '../../types' + +/** Parse a SQLite UTC timestamp ("YYYY-MM-DD HH:MM:SS") into a local date string. */ +function fmtDate(ts: string | null): string | null { + if (!ts) return null + const iso = ts.includes('T') ? ts : ts.replace(' ', 'T') + const d = new Date(iso.endsWith('Z') ? iso : iso + 'Z') + return Number.isNaN(d.getTime()) ? null : d.toLocaleDateString() +} + +/** True when the browser cancellation / no-matching-credential DOMExceptions fire. */ +function isWebauthnAbort(err: unknown): boolean { + const name = (err as { name?: string })?.name + return name === 'NotAllowedError' || name === 'AbortError' +} + +/** + * Passkey enrolment + management. Mirrors the MFA block: list / add (with a + * password step-up + the WebAuthn ceremony) / rename / delete (password step-up). + * The "Add a passkey" action only appears when the instance toggle is on AND a + * usable RP ID resolves; the existing-credential list stays reachable even when + * the feature is later disabled so users can always clean up. + */ +export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): React.ReactElement | null { + const { t } = useTranslation() + const toast = useToast() + + const [enabled, setEnabled] = useState(false) + const [configured, setConfigured] = useState(false) + const [creds, setCreds] = useState([]) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + + const [addOpen, setAddOpen] = useState(false) + const [addPwd, setAddPwd] = useState('') + const [addName, setAddName] = useState('') + + const [renamingId, setRenamingId] = useState(null) + const [renameVal, setRenameVal] = useState('') + + const [deletingId, setDeletingId] = useState(null) + const [deletePwd, setDeletePwd] = useState('') + + const refresh = () => { + authApi.passkey.list() + .then(r => setCreds(r.credentials)) + .catch(() => {}) + .finally(() => setLoading(false)) + } + + useEffect(() => { + authApi.getAppConfig?.() + .then(c => { setEnabled(!!c?.passkey_login); setConfigured(!!c?.passkey_configured) }) + .catch(() => {}) + refresh() + }, []) + + const canAdd = enabled && configured + + const handleAdd = async () => { + if (!addPwd) { toast.error(t('settings.passkey.passwordRequired')); return } + setBusy(true) + try { + const options = await authApi.passkey.registerOptions(addPwd) + const attResp = await startRegistration({ optionsJSON: options }) + await authApi.passkey.registerVerify(attResp, addName.trim() || undefined) + toast.success(t('settings.passkey.addedToast')) + setAddOpen(false); setAddPwd(''); setAddName('') + refresh() + } catch (err: unknown) { + if (isWebauthnAbort(err)) toast.error(t('settings.passkey.cancelled')) + else toast.error(getApiErrorMessage(err, t('settings.passkey.addError'))) + } finally { + setBusy(false) + } + } + + const handleRename = async (id: number) => { + const name = renameVal.trim() + if (!name) { setRenamingId(null); return } + try { + await authApi.passkey.rename(id, name) + setRenamingId(null) + refresh() + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, t('common.error'))) + } + } + + const handleDelete = async (id: number) => { + if (!deletePwd) { toast.error(t('settings.passkey.passwordRequired')); return } + setBusy(true) + try { + await authApi.passkey.delete(id, deletePwd) + toast.success(t('settings.passkey.deleted')) + setDeletingId(null); setDeletePwd('') + refresh() + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, t('common.error'))) + } finally { + setBusy(false) + } + } + + if (demoMode) return null + // Nothing to show: feature off and the user has no credentials to manage. + if (!loading && !enabled && creds.length === 0) return null + + return ( +
+
+ +

{t('settings.passkey.title')}

+
+
+

{t('settings.passkey.description')}

+ + {enabled && !configured && ( +

{t('settings.passkey.notConfigured')}

+ )} + + {creds.length > 0 && ( +
    + {creds.map(c => ( +
  • + +
    + {renamingId === c.id ? ( +
    + setRenameVal(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleRename(c.id); if (e.key === 'Escape') setRenamingId(null) }} + className="flex-1 px-2 py-1 border border-slate-300 rounded text-sm" + /> + + +
    + ) : ( + <> +
    + {c.name || t('settings.passkey.defaultName')} + + {c.backed_up ? t('settings.passkey.synced') : t('settings.passkey.deviceBound')} + +
    +

    + {t('settings.passkey.added')}: {fmtDate(c.created_at) || '—'} + {' · '} + {c.last_used_at + ? `${t('settings.passkey.lastUsed')}: ${fmtDate(c.last_used_at)}` + : t('settings.passkey.neverUsed')} +

    + + )} +
    + {renamingId !== c.id && ( +
    + + +
    + )} +
  • + ))} +
+ )} + + {/* Delete confirmation (password step-up) */} + {deletingId !== null && ( +
+

{t('settings.passkey.deleteConfirm')}

+ setDeletePwd(e.target.value)} + placeholder={t('settings.currentPassword')} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" + /> +
+ + +
+
+ )} + + {/* Add a passkey */} + {canAdd && (addOpen ? ( +
+

{t('settings.passkey.addTitle')}

+

{t('settings.passkey.passwordPrompt')}

+ setAddPwd(e.target.value)} + placeholder={t('settings.currentPassword')} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" + /> + setAddName(e.target.value)} + placeholder={t('settings.passkey.namePlaceholder')} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" + /> +
+ + +
+
+ ) : ( + + ))} +
+
+ ) +} diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index a80312dc..6359024d 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -1,6 +1,6 @@ import React from 'react' import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n' -import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound, ChevronDown } from 'lucide-react' +import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound, ChevronDown, Fingerprint } from 'lucide-react' import { useLogin } from './login/useLogin' export default function LoginPage(): React.ReactElement { @@ -15,9 +15,13 @@ export default function LoginPage(): React.ReactElement { showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode, passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword, noRedirect, showRegisterOption, oidcOnly, - handleDemoLogin, handleSubmit, + handleDemoLogin, handleSubmit, handlePasskeyLogin, } = useLogin() + const oidcButtonShown = !!(appConfig?.oidc_configured && appConfig?.oidc_login && !oidcOnly) + const passkeyAvailable = !!(appConfig?.passkey_login && appConfig?.passkey_configured && !oidcOnly + && mode === 'login' && !mfaStep && !passwordChangeStep) + const inputBase: React.CSSProperties = { width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb', borderRadius: 12, fontSize: 14, fontFamily: 'inherit', outline: 'none', @@ -636,6 +640,36 @@ export default function LoginPage(): React.ReactElement { )} + {/* Passkey login button (instance toggle on + a usable RP ID resolves) */} + {passkeyAvailable && ( + <> + {!oidcButtonShown && ( +
+
+ {t('common.or')} +
+
+ )} + + + )} + {/* Demo login button */} {appConfig?.demo_mode && (
+ {/* Passkey (WebAuthn) login */} +
+
+

{t('admin.passkey.title')}

+

{t('admin.passkey.cardHint')}

+
+
+
+
+

{t('admin.passkey.login')}

+

{t('admin.passkey.loginHint')}

+
+ +
+ + {passkeyLogin && !passkeyConfigured && ( +

+ + {t('admin.passkey.notConfigured')} +

+ )} + +
+ +

{t('admin.passkey.rpIdHint')}

+ setWebauthnRpId(e.target.value)} + placeholder="trek.example.org" + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +
+
+ +

{t('admin.passkey.originsHint')}

+ setWebauthnOrigins(e.target.value)} + placeholder="https://trek.example.org" + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +
+ +
+
+ {/* Require 2FA for all users */}
diff --git a/client/src/pages/admin/AdminUserModals.tsx b/client/src/pages/admin/AdminUserModals.tsx index 9b3fc754..a8e15e57 100644 --- a/client/src/pages/admin/AdminUserModals.tsx +++ b/client/src/pages/admin/AdminUserModals.tsx @@ -2,7 +2,7 @@ import React from 'react' import { adminApi } from '../../api/client' import Modal from '../../components/shared/Modal' import CustomSelect from '../../components/shared/CustomSelect' -import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle } from 'lucide-react' +import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint } from 'lucide-react' import type { TranslationFn } from '../../types' import type { useAdmin } from './useAdmin' @@ -157,6 +157,25 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea ]} />
+
+

{t('admin.passkey.resetHint')}

+ +
)} diff --git a/client/src/pages/admin/useAdmin.ts b/client/src/pages/admin/useAdmin.ts index b924583d..a5ac3ebe 100644 --- a/client/src/pages/admin/useAdmin.ts +++ b/client/src/pages/admin/useAdmin.ts @@ -65,6 +65,13 @@ export function useAdmin() { const [oidcConfigured, setOidcConfigured] = useState(false) const [requireMfa, setRequireMfa] = useState(false) + // Passkey (WebAuthn) login + const [passkeyLogin, setPasskeyLogin] = useState(false) + const [passkeyConfigured, setPasskeyConfigured] = useState(false) + const [webauthnRpId, setWebauthnRpId] = useState('') + const [webauthnOrigins, setWebauthnOrigins] = useState('') + const [savingWebauthn, setSavingWebauthn] = useState(false) + // Invite links const [invites, setInvites] = useState([]) const [showCreateInvite, setShowCreateInvite] = useState(false) @@ -80,6 +87,8 @@ export function useAdmin() { useEffect(() => { apiClient.get('/auth/app-settings').then(r => { setSmtpValues(r.data || {}) + if (r.data?.webauthn_rp_id) setWebauthnRpId(r.data.webauthn_rp_id) + if (r.data?.webauthn_origins) setWebauthnOrigins(r.data.webauthn_origins) setSmtpLoaded(true) }).catch(() => setSmtpLoaded(true)) }, []) @@ -141,6 +150,8 @@ export function useAdmin() { setEnvOverrideOidcOnly(config.env_override_oidc_only ?? false) setOidcConfigured(config.oidc_configured ?? false) if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa) + setPasskeyLogin(!!config.passkey_login) + setPasskeyConfigured(!!config.passkey_configured) if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types) } catch (err: unknown) { // ignore @@ -179,6 +190,23 @@ export function useAdmin() { } } + const handleSaveWebauthn = async () => { + setSavingWebauthn(true) + try { + await authApi.updateAppSettings({ + webauthn_rp_id: webauthnRpId.trim(), + webauthn_origins: webauthnOrigins.trim(), + }) + // Re-read app-config so passkey_configured reflects the new RP ID. + await loadAppConfig() + toast.success(t('common.saved')) + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, t('common.error'))) + } finally { + setSavingWebauthn(false) + } + } + const toggleKey = (key) => { setShowKeys(prev => ({ ...prev, [key]: !prev[key] })) } @@ -341,6 +369,8 @@ export function useAdmin() { oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration, envOverrideOidcOnly, setEnvOverrideOidcOnly, oidcConfigured, setOidcConfigured, requireMfa, setRequireMfa, + passkeyLogin, setPasskeyLogin, passkeyConfigured, + webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn, invites, setInvites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm, allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes, smtpValues, setSmtpValues, smtpLoaded, diff --git a/client/src/pages/login/useLogin.ts b/client/src/pages/login/useLogin.ts index 6d0e62d8..4a9d80af 100644 --- a/client/src/pages/login/useLogin.ts +++ b/client/src/pages/login/useLogin.ts @@ -3,6 +3,7 @@ import { useNavigate, useLocation } from 'react-router-dom' import { useAuthStore } from '../../store/authStore' import { useSettingsStore, hasStoredLanguage } from '../../store/settingsStore' import { useTranslation, detectBrowserLanguage } from '../../i18n' +import { startAuthentication } from '@simplewebauthn/browser' import { authApi, configApi } from '../../api/client' import { getApiErrorMessage } from '../../types' @@ -18,6 +19,8 @@ interface AppConfig { password_registration: boolean oidc_login: boolean oidc_registration: boolean + passkey_login?: boolean + passkey_configured?: boolean env_override_oidc_only: boolean } @@ -196,6 +199,28 @@ export function useLogin() { } } + const handlePasskeyLogin = async (): Promise => { + setError('') + setIsLoading(true) + try { + const options = await authApi.passkey.loginOptions() + const assertion = await startAuthentication({ optionsJSON: options }) + await authApi.passkey.loginVerify(assertion) + await loadUser({ silent: true }) + setShowTakeoff(true) + setTimeout(() => navigate(redirectTarget), 2600) + } catch (err: unknown) { + // The user dismissing the native prompt isn't an error worth surfacing. + const name = (err as { name?: string })?.name + if (name === 'NotAllowedError' || name === 'AbortError') { + setIsLoading(false) + return + } + setError(getApiErrorMessage(err, t('login.passkey.failed'))) + setIsLoading(false) + } + } + const handleSubmit = async (e: React.FormEvent): Promise => { e.preventDefault() setError('') @@ -270,6 +295,6 @@ export function useLogin() { showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode, passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword, noRedirect, showRegisterOption, oidcOnly, - handleDemoLogin, handleSubmit, + handleDemoLogin, handleSubmit, handlePasskeyLogin, } }