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.
This commit is contained in:
Maurice
2026-06-05 18:46:03 +02:00
parent 5b8c61d215
commit 7471976c9a
9 changed files with 474 additions and 4 deletions
+1
View File
@@ -26,6 +26,7 @@
"@fontsource/geist-sans": "^5.2.5", "@fontsource/geist-sans": "^5.2.5",
"@fontsource/poppins": "^5.2.7", "@fontsource/poppins": "^5.2.7",
"@react-pdf/renderer": "^4.5.1", "@react-pdf/renderer": "^4.5.1",
"@simplewebauthn/browser": "^13.1.2",
"@trek/shared": "*", "@trek/shared": "*",
"axios": "^1.6.7", "axios": "^1.6.7",
"dexie": "^4.4.2", "dexie": "^4.4.2",
+19
View File
@@ -261,6 +261,24 @@ export const authApi = {
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data), 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), 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<string, unknown> }),
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 = { export const oauthApi = {
@@ -414,6 +432,7 @@ export const adminApi = {
createUser: (data: Record<string, unknown>) => apiClient.post('/admin/users', data).then(r => r.data), createUser: (data: Record<string, unknown>) => apiClient.post('/admin/users', data).then(r => r.data),
updateUser: (id: number, data: Record<string, unknown>) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data), updateUser: (id: number, data: Record<string, unknown>) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data),
deleteUser: (id: number) => apiClient.delete(`/admin/users/${id}`).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), stats: () => apiClient.get('/admin/stats').then(r => r.data),
saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data), saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data),
getOidc: () => apiClient.get('/admin/oidc').then(r => r.data), getOidc: () => apiClient.get('/admin/oidc').then(r => r.data),
@@ -8,6 +8,7 @@ import { authApi, adminApi } from '../../api/client'
import { getApiErrorMessage } from '../../types' import { getApiErrorMessage } from '../../types'
import type { UserWithOidc } from '../../types' import type { UserWithOidc } from '../../types'
import Section from './Section' import Section from './Section'
import PasskeysSection from './PasskeysSection'
const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending' const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending'
@@ -395,6 +396,9 @@ export default function AccountTab(): React.ReactElement {
</div> </div>
</div> </div>
{/* Passkeys */}
<PasskeysSection demoMode={demoMode} />
{/* Avatar */} {/* Avatar */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div style={{ position: 'relative', flexShrink: 0 }}> <div style={{ position: 'relative', flexShrink: 0 }}>
@@ -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<PasskeyCredential[]>([])
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<number | null>(null)
const [renameVal, setRenameVal] = useState('')
const [deletingId, setDeletingId] = useState<number | null>(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 (
<div className="pt-4 mt-4 border-t border-edge-secondary">
<div className="flex items-center gap-2 mb-3">
<Fingerprint className="w-5 h-5 text-content-secondary" />
<h3 className="font-semibold text-base m-0 text-content">{t('settings.passkey.title')}</h3>
</div>
<div className="space-y-3">
<p className="text-sm m-0 text-content-muted" style={{ lineHeight: 1.5 }}>{t('settings.passkey.description')}</p>
{enabled && !configured && (
<p className="text-sm m-0 text-amber-700">{t('settings.passkey.notConfigured')}</p>
)}
{creds.length > 0 && (
<ul className="space-y-2 list-none p-0 m-0">
{creds.map(c => (
<li key={c.id} className="flex items-center gap-3 p-3 rounded-lg border border-edge bg-surface-card">
<Fingerprint className="w-4 h-4 flex-shrink-0 text-content-secondary" />
<div className="flex-1 min-w-0">
{renamingId === c.id ? (
<div className="flex items-center gap-2">
<input
autoFocus
type="text"
value={renameVal}
onChange={e => 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"
/>
<button type="button" onClick={() => handleRename(c.id)} className="p-1 text-emerald-600" aria-label={t('common.save')}><Check size={16} /></button>
<button type="button" onClick={() => setRenamingId(null)} className="p-1 text-content-muted" aria-label={t('common.cancel')}><X size={16} /></button>
</div>
) : (
<>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-content truncate">{c.name || t('settings.passkey.defaultName')}</span>
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-surface-hover text-content-secondary">
{c.backed_up ? t('settings.passkey.synced') : t('settings.passkey.deviceBound')}
</span>
</div>
<p className="text-xs m-0 mt-0.5 text-content-faint">
{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')}
</p>
</>
)}
</div>
{renamingId !== c.id && (
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
onClick={() => { setRenamingId(c.id); setRenameVal(c.name || '') }}
className="p-1.5 rounded text-content-muted hover:text-content"
aria-label={t('settings.passkey.rename')}
>
<Pencil size={14} />
</button>
<button
type="button"
onClick={() => { setDeletingId(c.id); setDeletePwd('') }}
className="p-1.5 rounded text-red-500 hover:bg-red-50"
aria-label={t('common.delete')}
>
<Trash2 size={14} />
</button>
</div>
)}
</li>
))}
</ul>
)}
{/* Delete confirmation (password step-up) */}
{deletingId !== null && (
<div className="space-y-2 p-3 rounded-lg border border-red-200 bg-red-50/40">
<p className="text-sm font-medium m-0 text-content">{t('settings.passkey.deleteConfirm')}</p>
<input
type="password"
value={deletePwd}
onChange={e => setDeletePwd(e.target.value)}
placeholder={t('settings.currentPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
<div className="flex gap-2">
<button
type="button"
disabled={busy || !deletePwd}
onClick={() => handleDelete(deletingId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-red-600 border border-red-200 hover:bg-red-50 disabled:opacity-50"
>
{t('common.delete')}
</button>
<button
type="button"
onClick={() => { setDeletingId(null); setDeletePwd('') }}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary"
>
{t('common.cancel')}
</button>
</div>
</div>
)}
{/* Add a passkey */}
{canAdd && (addOpen ? (
<div className="space-y-2 p-3 rounded-lg border border-edge bg-surface-hover">
<p className="text-sm font-medium m-0 text-content">{t('settings.passkey.addTitle')}</p>
<p className="text-xs m-0 text-content-muted">{t('settings.passkey.passwordPrompt')}</p>
<input
type="password"
value={addPwd}
onChange={e => setAddPwd(e.target.value)}
placeholder={t('settings.currentPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
<input
type="text"
value={addName}
onChange={e => setAddName(e.target.value)}
placeholder={t('settings.passkey.namePlaceholder')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
<div className="flex gap-2">
<button
type="button"
disabled={busy || !addPwd}
onClick={handleAdd}
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
>
{busy ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : t('settings.passkey.add')}
</button>
<button
type="button"
onClick={() => { setAddOpen(false); setAddPwd(''); setAddName('') }}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary"
>
{t('common.cancel')}
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setAddOpen(true)}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors border border-edge bg-surface-card text-content"
>
<Plus size={14} />
{t('settings.passkey.add')}
</button>
))}
</div>
</div>
)
}
+36 -2
View File
@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n' 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' import { useLogin } from './login/useLogin'
export default function LoginPage(): React.ReactElement { export default function LoginPage(): React.ReactElement {
@@ -15,9 +15,13 @@ export default function LoginPage(): React.ReactElement {
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode, showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword, passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
noRedirect, showRegisterOption, oidcOnly, noRedirect, showRegisterOption, oidcOnly,
handleDemoLogin, handleSubmit, handleDemoLogin, handleSubmit, handlePasskeyLogin,
} = useLogin() } = 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 = { const inputBase: React.CSSProperties = {
width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb', width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb',
borderRadius: 12, fontSize: 14, fontFamily: 'inherit', outline: 'none', 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 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}>
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
<span style={{ fontSize: 12, color: '#9ca3af' }}>{t('common.or')}</span>
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
</div>
)}
<button type="button" onClick={handlePasskeyLogin} disabled={isLoading}
style={{
marginTop: 12, width: '100%', padding: '12px',
background: 'white', color: '#374151',
border: '1px solid #d1d5db', borderRadius: 12,
fontSize: 14, fontWeight: 600, cursor: isLoading ? 'default' : 'pointer',
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
opacity: isLoading ? 0.7 : 1,
transition: 'background 180ms cubic-bezier(0.23,1,0.32,1), border-color 180ms cubic-bezier(0.23,1,0.32,1)',
boxSizing: 'border-box',
}}
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { if (!isLoading) { e.currentTarget.style.background = '#f9fafb'; e.currentTarget.style.borderColor = '#9ca3af' } }}
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.background = 'white'; e.currentTarget.style.borderColor = '#d1d5db' }}
>
<Fingerprint size={16} />
{t('login.passkey.signIn')}
</button>
</>
)}
{/* Demo login button */} {/* Demo login button */}
{appConfig?.demo_mode && ( {appConfig?.demo_mode && (
<button onClick={handleDemoLogin} disabled={isLoading} <button onClick={handleDemoLogin} disabled={isLoading}
@@ -23,6 +23,8 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration, passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration, oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
envOverrideOidcOnly, oidcConfigured, requireMfa, envOverrideOidcOnly, oidcConfigured, requireMfa,
passkeyLogin, setPasskeyLogin, passkeyConfigured,
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes, allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
mapsKey, setMapsKey, showKeys, savingKeys, validating, validation, mapsKey, setMapsKey, showKeys, savingKeys, validating, validation,
setShowRotateJwtModal, setShowRotateJwtModal,
@@ -119,6 +121,71 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</div> </div>
</div> </div>
{/* Passkey (WebAuthn) login */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">{t('admin.passkey.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.passkey.cardHint')}</p>
</div>
<div className="p-6 space-y-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.passkey.login')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.passkey.loginHint')}</p>
</div>
<button
type="button"
onClick={() => handleToggleAuthSetting('passkey_login', !passkeyLogin, setPasskeyLogin)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${passkeyLogin ? 'bg-content' : 'bg-edge'}`}
>
<span
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: passkeyLogin ? 'translateX(20px)' : 'translateX(0)' }}
/>
</button>
</div>
{passkeyLogin && !passkeyConfigured && (
<p className="flex items-start gap-2 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
{t('admin.passkey.notConfigured')}
</p>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">{t('admin.passkey.rpId')}</label>
<p className="text-xs text-slate-400 mb-1.5">{t('admin.passkey.rpIdHint')}</p>
<input
type="text"
value={webauthnRpId}
onChange={e => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">{t('admin.passkey.origins')}</label>
<p className="text-xs text-slate-400 mb-1.5">{t('admin.passkey.originsHint')}</p>
<input
type="text"
value={webauthnOrigins}
onChange={e => 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"
/>
</div>
<button
type="button"
onClick={handleSaveWebauthn}
disabled={savingWebauthn}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
>
{savingWebauthn ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{t('common.save')}
</button>
</div>
</div>
{/* Require 2FA for all users */} {/* Require 2FA for all users */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden"> <div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100"> <div className="px-6 py-4 border-b border-slate-100">
+20 -1
View File
@@ -2,7 +2,7 @@ import React from 'react'
import { adminApi } from '../../api/client' import { adminApi } from '../../api/client'
import Modal from '../../components/shared/Modal' import Modal from '../../components/shared/Modal'
import CustomSelect from '../../components/shared/CustomSelect' 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 { TranslationFn } from '../../types'
import type { useAdmin } from './useAdmin' import type { useAdmin } from './useAdmin'
@@ -157,6 +157,25 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
]} ]}
/> />
</div> </div>
<div className="pt-3 border-t border-slate-100">
<p className="text-xs text-slate-400 mb-2">{t('admin.passkey.resetHint')}</p>
<button
type="button"
onClick={async () => {
if (!editingUser) return
if (!confirm(t('admin.passkey.resetConfirm', { name: editingUser.username }))) return
try {
const r = await adminApi.resetUserPasskeys(editingUser.id)
toast.success(t('admin.passkey.resetDone', { count: r.deleted ?? 0 }))
} catch {
toast.error(t('common.error'))
}
}}
className="flex items-center gap-2 px-3 py-2 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
>
<Fingerprint size={14} /> {t('admin.passkey.reset')}
</button>
</div>
</div> </div>
)} )}
</Modal> </Modal>
+30
View File
@@ -65,6 +65,13 @@ export function useAdmin() {
const [oidcConfigured, setOidcConfigured] = useState<boolean>(false) const [oidcConfigured, setOidcConfigured] = useState<boolean>(false)
const [requireMfa, setRequireMfa] = useState<boolean>(false) const [requireMfa, setRequireMfa] = useState<boolean>(false)
// Passkey (WebAuthn) login
const [passkeyLogin, setPasskeyLogin] = useState<boolean>(false)
const [passkeyConfigured, setPasskeyConfigured] = useState<boolean>(false)
const [webauthnRpId, setWebauthnRpId] = useState<string>('')
const [webauthnOrigins, setWebauthnOrigins] = useState<string>('')
const [savingWebauthn, setSavingWebauthn] = useState<boolean>(false)
// Invite links // Invite links
const [invites, setInvites] = useState<any[]>([]) const [invites, setInvites] = useState<any[]>([])
const [showCreateInvite, setShowCreateInvite] = useState<boolean>(false) const [showCreateInvite, setShowCreateInvite] = useState<boolean>(false)
@@ -80,6 +87,8 @@ export function useAdmin() {
useEffect(() => { useEffect(() => {
apiClient.get('/auth/app-settings').then(r => { apiClient.get('/auth/app-settings').then(r => {
setSmtpValues(r.data || {}) 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) setSmtpLoaded(true)
}).catch(() => setSmtpLoaded(true)) }).catch(() => setSmtpLoaded(true))
}, []) }, [])
@@ -141,6 +150,8 @@ export function useAdmin() {
setEnvOverrideOidcOnly(config.env_override_oidc_only ?? false) setEnvOverrideOidcOnly(config.env_override_oidc_only ?? false)
setOidcConfigured(config.oidc_configured ?? false) setOidcConfigured(config.oidc_configured ?? false)
if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa) 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) if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
} catch (err: unknown) { } catch (err: unknown) {
// ignore // 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) => { const toggleKey = (key) => {
setShowKeys(prev => ({ ...prev, [key]: !prev[key] })) setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
} }
@@ -341,6 +369,8 @@ export function useAdmin() {
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration, oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
envOverrideOidcOnly, setEnvOverrideOidcOnly, oidcConfigured, setOidcConfigured, envOverrideOidcOnly, setEnvOverrideOidcOnly, oidcConfigured, setOidcConfigured,
requireMfa, setRequireMfa, requireMfa, setRequireMfa,
passkeyLogin, setPasskeyLogin, passkeyConfigured,
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
invites, setInvites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm, invites, setInvites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes, allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
smtpValues, setSmtpValues, smtpLoaded, smtpValues, setSmtpValues, smtpLoaded,
+26 -1
View File
@@ -3,6 +3,7 @@ import { useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { useSettingsStore, hasStoredLanguage } from '../../store/settingsStore' import { useSettingsStore, hasStoredLanguage } from '../../store/settingsStore'
import { useTranslation, detectBrowserLanguage } from '../../i18n' import { useTranslation, detectBrowserLanguage } from '../../i18n'
import { startAuthentication } from '@simplewebauthn/browser'
import { authApi, configApi } from '../../api/client' import { authApi, configApi } from '../../api/client'
import { getApiErrorMessage } from '../../types' import { getApiErrorMessage } from '../../types'
@@ -18,6 +19,8 @@ interface AppConfig {
password_registration: boolean password_registration: boolean
oidc_login: boolean oidc_login: boolean
oidc_registration: boolean oidc_registration: boolean
passkey_login?: boolean
passkey_configured?: boolean
env_override_oidc_only: boolean env_override_oidc_only: boolean
} }
@@ -196,6 +199,28 @@ export function useLogin() {
} }
} }
const handlePasskeyLogin = async (): Promise<void> => {
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<HTMLFormElement>): Promise<void> => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault() e.preventDefault()
setError('') setError('')
@@ -270,6 +295,6 @@ export function useLogin() {
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode, showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword, passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
noRedirect, showRegisterOption, oidcOnly, noRedirect, showRegisterOption, oidcOnly,
handleDemoLogin, handleSubmit, handleDemoLogin, handleSubmit, handlePasskeyLogin,
} }
} }