mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
feat: Passkey (WebAuthn) login (#1111)
* feat(auth): passkey (WebAuthn) login — server endpoints, schema + admin toggle Add @simplewebauthn/server registration and primary (discoverable) login ceremonies under /api/auth/passkey, a webauthn_credentials + single-use webauthn_challenges schema (migration), the instance-wide passkey_login toggle (default off) enforced before auth by a guard, and require_mfa satisfaction via a verified passkey. RP ID/origin come only from server config (webauthn_rp_id/origins -> APP_URL), never request headers. * 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. * i18n(auth): passkey strings across all locales Add login/settings/admin passkey keys to en and all 19 translated locales.
This commit is contained in:
@@ -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 && (
|
||||
<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 */}
|
||||
{appConfig?.demo_mode && (
|
||||
<button onClick={handleDemoLogin} disabled={isLoading}
|
||||
|
||||
@@ -23,6 +23,8 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
|
||||
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
|
||||
envOverrideOidcOnly, oidcConfigured, requireMfa,
|
||||
passkeyLogin, setPasskeyLogin, passkeyConfigured,
|
||||
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
|
||||
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
|
||||
mapsKey, setMapsKey, showKeys, savingKeys, validating, validation,
|
||||
setShowRotateJwtModal,
|
||||
@@ -119,6 +121,71 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
</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 */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
|
||||
@@ -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
|
||||
]}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -65,6 +65,13 @@ export function useAdmin() {
|
||||
const [oidcConfigured, setOidcConfigured] = 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
|
||||
const [invites, setInvites] = useState<any[]>([])
|
||||
const [showCreateInvite, setShowCreateInvite] = useState<boolean>(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,
|
||||
|
||||
@@ -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<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> => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user