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, } } diff --git a/package-lock.json b/package-lock.json index 1037c39b..822a53f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,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", @@ -2525,6 +2526,12 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@hono/node-server": { "version": "1.19.14", "license": "MIT", @@ -3656,6 +3663,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@lukeed/csprng": { "version": "1.1.0", "license": "MIT", @@ -4490,6 +4503,174 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.7.0.tgz", + "integrity": "sha512-iD3VskhVQnM4nE3PN9cBdPTR7JrqZy3FYk+uD2CeG6DUqKoANqaEfx0f7izPmW+Qm5JBM35ek+viLCmjy18ByQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz", + "integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz", + "integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz", + "integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz", + "integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.7.0", + "@peculiar/asn1-pkcs8": "^2.7.0", + "@peculiar/asn1-rsa": "^2.7.0", + "@peculiar/asn1-schema": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz", + "integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz", + "integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.7.0", + "@peculiar/asn1-pfx": "^2.7.0", + "@peculiar/asn1-pkcs8": "^2.7.0", + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz", + "integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz", + "integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz", + "integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz", + "integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "dev": true, @@ -5179,6 +5360,31 @@ "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/@simplewebauthn/server": { + "version": "13.3.1", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.1.tgz", + "integrity": "sha512-GV/oM/qeycWn8p42JZIMJBsXWQcNFg+nJFzeQTnMA4gN8mXg0+HZFWJerHg8ZN/zlveMS3iV1wzuFpOVWS/46w==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@swc/core": { "version": "1.15.40", "dev": true, @@ -6442,6 +6648,20 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "dev": true, @@ -12765,6 +12985,24 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qrcode": { "version": "1.5.4", "license": "MIT", @@ -15445,6 +15683,24 @@ "@esbuild/win32-x64": "0.28.0" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/tunnel-agent": { "version": "0.6.0", "license": "Apache-2.0", @@ -17346,6 +17602,7 @@ "@nestjs/common": "^11.1.24", "@nestjs/core": "^11.1.24", "@nestjs/platform-express": "^11.1.24", + "@simplewebauthn/server": "^13.1.2", "@trek/shared": "*", "archiver": "^6.0.1", "bcryptjs": "^2.4.3", diff --git a/server/package.json b/server/package.json index a2b403a9..f5663c74 100644 --- a/server/package.json +++ b/server/package.json @@ -27,6 +27,7 @@ "@nestjs/common": "^11.1.24", "@nestjs/core": "^11.1.24", "@nestjs/platform-express": "^11.1.24", + "@simplewebauthn/server": "^13.1.2", "archiver": "^6.0.1", "bcryptjs": "^2.4.3", "better-sqlite3": "^12.8.0", diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 67563076..234fbfb1 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -2340,6 +2340,35 @@ function runMigrations(db: Database.Database): void { "UPDATE addons SET name = 'Costs', description = 'Track and split trip expenses' WHERE id = 'budget' AND name = 'Budget Planner'", ).run(); }, + // WebAuthn / passkey support: per-user credentials + single-use login + // challenges. Additive (CREATE TABLE IF NOT EXISTS) so existing installs are + // untouched; both tables also live in schema.ts for fresh installs. + () => db.exec(` + CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, + public_key BLOB NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + transports TEXT, + device_type TEXT, + backed_up INTEGER NOT NULL DEFAULT 0, + name TEXT, + aaguid TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME + ); + CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id); + CREATE TABLE IF NOT EXISTS webauthn_challenges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + challenge TEXT NOT NULL UNIQUE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at); + `), ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 310b869d..71b3ad73 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -42,6 +42,32 @@ function createTables(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash); + CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, + public_key BLOB NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + transports TEXT, + device_type TEXT, + backed_up INTEGER NOT NULL DEFAULT 0, + name TEXT, + aaguid TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME + ); + CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id); + + CREATE TABLE IF NOT EXISTS webauthn_challenges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + challenge TEXT NOT NULL UNIQUE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at); + CREATE TABLE IF NOT EXISTS settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/server/src/middleware/mfaPolicy.ts b/server/src/middleware/mfaPolicy.ts index 70b00896..e1538419 100644 --- a/server/src/middleware/mfaPolicy.ts +++ b/server/src/middleware/mfaPolicy.ts @@ -12,6 +12,9 @@ export function isPublicApiPath(method: string, pathNoQuery: string): boolean { if (method === 'POST' && pathNoQuery === '/api/auth/demo-login') return true; if (method === 'GET' && pathNoQuery.startsWith('/api/auth/invite/')) return true; if (method === 'POST' && pathNoQuery === '/api/auth/mfa/verify-login') return true; + // Unauthenticated passkey (primary) login ceremony. + if (method === 'POST' && pathNoQuery === '/api/auth/passkey/login/options') return true; + if (method === 'POST' && pathNoQuery === '/api/auth/passkey/login/verify') return true; if (pathNoQuery.startsWith('/api/auth/oidc/')) return true; return false; } @@ -21,6 +24,11 @@ export function isMfaSetupExemptPath(method: string, pathNoQuery: string): boole if (method === 'GET' && pathNoQuery === '/api/auth/me') return true; if (method === 'POST' && pathNoQuery === '/api/auth/mfa/setup') return true; if (method === 'POST' && pathNoQuery === '/api/auth/mfa/enable') return true; + // Allow enrolling a passkey as the second factor (a user-verified passkey + // satisfies require_mfa), so a fresh user under the policy isn't stuck. + if (method === 'POST' && pathNoQuery === '/api/auth/passkey/register/options') return true; + if (method === 'POST' && pathNoQuery === '/api/auth/passkey/register/verify') return true; + if (method === 'GET' && pathNoQuery === '/api/auth/passkey/credentials') return true; if ((method === 'GET' || method === 'PUT') && pathNoQuery === '/api/auth/app-settings') return true; return false; } @@ -81,8 +89,12 @@ export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFu return; } + // A user-verified passkey is phishing-resistant and inherently two-factor, so + // owning at least one satisfies the require_mfa policy exactly like TOTP does. + // (All stored passkeys were registered with userVerification required.) const mfaOk = row.mfa_enabled === 1 || row.mfa_enabled === true; - if (mfaOk) { + const passkeyOk = !!db.prepare('SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1').get(userId); + if (mfaOk || passkeyOk) { next(); return; } diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index 9cca9026..340e165d 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -60,6 +60,13 @@ export class AdminController { return { success: true }; } + @Delete('users/:id/passkeys') + resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { + const result = ok(this.admin.resetUserPasskeys(id)); + writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } }); + return { success: true, deleted: result.deleted }; + } + // ── Stats / permissions / audit ── @Get('stats') stats() { return this.admin.getStats(); } diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index 754e0e6c..27340ccb 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -3,6 +3,7 @@ import * as svc from '../../services/adminService'; import { getAdminUserDefaults, setAdminUserDefaults } from '../../services/settingsService'; import { invalidateMcpSessions } from '../../mcp'; import { getPreferencesMatrix, setAdminPreferences } from '../../services/notificationPreferencesService'; +import { adminResetPasskeys } from '../../services/passkeyService'; /** * Thin Nest wrapper around the existing admin service (+ the settings, @@ -17,6 +18,7 @@ export class AdminService { createUser(body: unknown) { return svc.createUser(body as Parameters[0]); } updateUser(id: string, body: unknown) { return svc.updateUser(id, body as Parameters[1]); } deleteUser(id: string, actingUserId: number) { return svc.deleteUser(id, actingUserId); } + resetUserPasskeys(id: string) { return adminResetPasskeys(Number(id)); } getStats() { return svc.getStats(); } getPermissions() { return svc.getPermissions(); } diff --git a/server/src/nest/auth/auth.module.ts b/server/src/nest/auth/auth.module.ts index 3f8ae266..7c649537 100644 --- a/server/src/nest/auth/auth.module.ts +++ b/server/src/nest/auth/auth.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { AuthPublicController } from './auth-public.controller'; import { AuthController } from './auth.controller'; +import { PasskeyController } from './passkey.controller'; import { AuthService } from './auth.service'; import { RateLimitService } from './rate-limit.service'; @@ -11,7 +12,7 @@ import { RateLimitService } from './rate-limit.service'; * sub-paths explicitly rather than claiming all of /api/auth. */ @Module({ - controllers: [AuthPublicController, AuthController], + controllers: [AuthPublicController, AuthController, PasskeyController], providers: [AuthService, RateLimitService], }) export class AuthModule {} diff --git a/server/src/nest/auth/passkey-enabled.guard.ts b/server/src/nest/auth/passkey-enabled.guard.ts new file mode 100644 index 00000000..26954162 --- /dev/null +++ b/server/src/nest/auth/passkey-enabled.guard.ts @@ -0,0 +1,22 @@ +import { CanActivate, HttpException, Injectable } from '@nestjs/common'; +import { resolveAuthToggles } from '../../services/authService'; + +/** + * Server-side enforcement of the instance-wide `passkey_login` toggle. Placed + * BEFORE the auth guard on every passkey ceremony route so a disabled feature + * returns 404 (not "auth required") and cannot be driven by direct API calls — + * hiding the button in the UI is not enough. Mirrors JourneyAddonGuard. + * + * The credential-management routes (list/rename/delete) are deliberately NOT + * gated by this guard so users can still clean up their passkeys after an admin + * turns the feature off. + */ +@Injectable() +export class PasskeyEnabledGuard implements CanActivate { + canActivate(): boolean { + if (!resolveAuthToggles().passkey_login) { + throw new HttpException({ error: 'Passkey login is not enabled' }, 404); + } + return true; + } +} diff --git a/server/src/nest/auth/passkey.controller.ts b/server/src/nest/auth/passkey.controller.ts new file mode 100644 index 00000000..e4f70749 --- /dev/null +++ b/server/src/nest/auth/passkey.controller.ts @@ -0,0 +1,114 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Patch, Post, Req, Res, UseGuards } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { RateLimitService } from './rate-limit.service'; +import { JwtAuthGuard } from './jwt-auth.guard'; +import { PasskeyEnabledGuard } from './passkey-enabled.guard'; +import { CurrentUser } from './current-user.decorator'; +import { setAuthCookie } from '../../services/cookie'; +import { writeAudit, getClientIp } from '../../services/auditLog'; +import * as passkey from '../../services/passkeyService'; +import type { User } from '../../types'; + +const WINDOW = 15 * 60 * 1000; +const LOGIN_MIN_LATENCY_MS = 350; +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** + * /api/auth/passkey — WebAuthn (passkey) registration, primary login and + * credential management. + * + * - register/* : authenticated, gated by the admin toggle + password re-auth. + * - login/* : UNauthenticated discoverable-credential login, gated by the + * admin toggle; mints the SAME session cookie as password login. + * - credentials : owner-scoped management — intentionally NOT toggle-gated so a + * user can always view/remove their passkeys. + * + * PasskeyEnabledGuard is listed first so a disabled feature 404s before auth. + */ +@Controller('api/auth/passkey') +export class PasskeyController { + constructor(private readonly rl: RateLimitService) {} + + private limit(bucket: string, req: Request, max: number): void { + if (!this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now())) { + throw new HttpException({ error: 'Too many attempts. Please try again later.' }, 429); + } + } + + // ── Registration (authenticated) ── + @Post('register/options') + @HttpCode(200) + @UseGuards(PasskeyEnabledGuard, JwtAuthGuard) + async registerOptions(@CurrentUser() user: User, @Body() body: { password?: string }, @Req() req: Request) { + this.limit('mfa', req, 5); + const result = await passkey.passkeyRegisterOptions(user.id, body?.password); + if (result.error) throw new HttpException({ error: result.error }, result.status!); + return result.options; + } + + @Post('register/verify') + @HttpCode(200) + @UseGuards(PasskeyEnabledGuard, JwtAuthGuard) + async registerVerify(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) { + const result = await passkey.passkeyRegisterVerify(user.id, body as Parameters[1]); + if (result.error) throw new HttpException({ error: result.error }, result.status!); + writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) }); + return { success: true, credential: result.credential }; + } + + // ── Authentication (public — primary login) ── + @Post('login/options') + @HttpCode(200) + @UseGuards(PasskeyEnabledGuard) + async loginOptions(@Req() req: Request) { + this.limit('login', req, 10); + const result = await passkey.passkeyLoginOptions(); + if (result.error) throw new HttpException({ error: result.error }, result.status!); + return result.options; + } + + @Post('login/verify') + @HttpCode(200) + @UseGuards(PasskeyEnabledGuard) + async loginVerify(@Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response) { + this.limit('login', req, 10); + const started = Date.now(); + const result = await passkey.passkeyLoginVerify(body as Parameters[0]); + if (result.auditAction) { + writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req) }); + } + // Pad to the same floor as password login so timing can't distinguish a + // known credential from an unknown one. + const elapsed = Date.now() - started; + if (elapsed < LOGIN_MIN_LATENCY_MS) await delay(LOGIN_MIN_LATENCY_MS - elapsed); + if (result.error) throw new HttpException({ error: result.error }, result.status!); + writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { method: 'passkey' } }); + setAuthCookie(res, result.token!, req); + return { token: result.token, user: result.user }; + } + + // ── Management (authenticated, owner-scoped — NOT toggle-gated) ── + @Get('credentials') + @UseGuards(JwtAuthGuard) + list(@CurrentUser() user: User) { + return { credentials: passkey.listPasskeys(user.id) }; + } + + @Patch('credentials/:id') + @UseGuards(JwtAuthGuard) + rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { name?: unknown }) { + const result = passkey.renamePasskey(user.id, id, body?.name); + if (result.error) throw new HttpException({ error: result.error }, result.status!); + return { success: true }; + } + + @Delete('credentials/:id') + @UseGuards(JwtAuthGuard) + remove(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { password?: string }, @Req() req: Request) { + this.limit('login', req, 5); + const result = passkey.deletePasskey(user.id, id, body?.password); + if (result.error) throw new HttpException({ error: result.error }, result.status!); + writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) }); + return { success: true }; + } +} diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index 61b6193d..e7d86b84 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -21,6 +21,7 @@ import { verifyJwtAndLoadUser } from '../middleware/auth'; import { User } from '../types'; import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo'; import { avatarUrl } from './avatarUrl'; +import { isPasskeyConfigured } from './webauthnConfig'; export { avatarUrl }; @@ -51,6 +52,7 @@ const ADMIN_SETTINGS_KEYS = [ 'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token', 'notify_trip_reminder', 'password_login', 'password_registration', 'oidc_login', 'oidc_registration', + 'passkey_login', 'webauthn_rp_id', 'webauthn_origins', ]; const avatarDir = path.join(__dirname, '../../uploads/avatars'); @@ -128,10 +130,17 @@ export function resolveAuthToggles(): { password_registration: boolean; oidc_login: boolean; oidc_registration: boolean; + passkey_login: boolean; } { const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value ?? null; + // Passkey login is independent of the password/OIDC "new keys" probe, so it + // must be resolved OUTSIDE the branch below — otherwise on a fresh install + // that never touched the password/OIDC toggles it would silently read false + // even after an admin enabled it. Default OFF (opt-in). + const passkey_login = get('passkey_login') === 'true'; + const hasNewKeys = ['password_login', 'password_registration', 'oidc_login', 'oidc_registration'] .some(k => get(k) !== null); @@ -141,6 +150,7 @@ export function resolveAuthToggles(): { password_registration: get('password_registration') !== 'false', oidc_login: get('oidc_login') !== 'false', oidc_registration: get('oidc_registration') !== 'false', + passkey_login, }; if (process.env.OIDC_ONLY?.toLowerCase() === 'true') { result.password_login = false; @@ -163,6 +173,7 @@ export function resolveAuthToggles(): { password_registration: !oidcOnly && allowReg, oidc_login: true, oidc_registration: allowReg, + passkey_login, }; } @@ -299,6 +310,12 @@ export function getAppConfig(authenticatedUser: { id: number } | null) { password_registration: isDemo ? false : toggles.password_registration, oidc_login: toggles.oidc_login, oidc_registration: isDemo ? false : toggles.oidc_registration, + // Passkey login: the instance toggle + whether a usable RP ID resolves for + // this deployment. The login page shows the passkey button only when both + // are true. `passkey_configured` stays a pure boolean — it never leaks the + // resolved RP ID / origin / APP_URL on this unauthenticated endpoint. + passkey_login: toggles.passkey_login, + passkey_configured: isPasskeyConfigured(), env_override_oidc_only: process.env.OIDC_ONLY === 'true', has_users: userCount > 0, setup_complete: setupComplete, @@ -812,9 +829,12 @@ export function updateAppSettings( const { require_mfa } = body; if (require_mfa === true || require_mfa === 'true') { const adminMfa = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as { mfa_enabled: number } | undefined; - if (!(adminMfa?.mfa_enabled === 1)) { + // A user-verified passkey satisfies the MFA policy, so an admin who secured + // their own account with a passkey may enable it too (not only TOTP). + const adminHasPasskey = !!db.prepare('SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1').get(userId); + if (!(adminMfa?.mfa_enabled === 1) && !adminHasPasskey) { return { - error: 'Enable two-factor authentication on your own account before requiring it for all users.', + error: 'Secure your own account with two-factor authentication or a passkey before requiring it for all users.', status: 400, }; } diff --git a/server/src/services/passkeyService.ts b/server/src/services/passkeyService.ts new file mode 100644 index 00000000..7c8c2548 --- /dev/null +++ b/server/src/services/passkeyService.ts @@ -0,0 +1,364 @@ +import bcrypt from 'bcryptjs'; +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, + type AuthenticatorTransportFuture, +} from '@simplewebauthn/server'; +import { db } from '../db/database'; +import { resolveWebauthnConfig } from './webauthnConfig'; +import { generateToken, stripUserForClient, avatarUrl } from './authService'; +import type { User } from '../types'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +// Short single-use challenge lifetime — a ceremony is a few seconds of user +// interaction. Kept tight so a stray row can't be replayed and the table can't +// accumulate. Mirrors the spirit of the OIDC state TTL. +const CHALLENGE_TTL_MS = 5 * 60 * 1000; + +// Pinned COSE algorithms: EdDSA (-8), ES256 (-7), RS256 (-257). We never want a +// future library default to silently widen what we accept. +const SUPPORTED_ALGORITHM_IDS = [-8, -7, -257]; + +const NOT_CONFIGURED = { error: 'Passkey login is not configured for this server.', status: 400 } as const; +// One generic message for every authentication failure so the endpoint can't be +// used to tell "no such credential" apart from "bad signature" (CWE-203). +const AUTH_FAILED = { error: 'Authentication failed', status: 401 } as const; + +interface CredentialRow { + id: number; + user_id: number; + credential_id: string; + public_key: Buffer; + counter: number; + transports: string | null; + device_type: string | null; + backed_up: number; + name: string | null; + aaguid: string | null; + created_at: string; + last_used_at: string | null; +} + +// --------------------------------------------------------------------------- +// Challenge store (DB-backed, single-use, TTL'd) +// --------------------------------------------------------------------------- + +function purgeExpiredChallenges(now: number): void { + db.prepare('DELETE FROM webauthn_challenges WHERE expires_at < ?').run(now); +} + +function storeChallenge(challenge: string, userId: number | null, type: 'registration' | 'authentication', now: number): void { + db.prepare('INSERT INTO webauthn_challenges (challenge, user_id, type, expires_at) VALUES (?, ?, ?, ?)') + .run(challenge, userId, type, now + CHALLENGE_TTL_MS); +} + +/** + * Atomically claim a challenge by its EXACT bytes + type. This is a single + * DELETE ... RETURNING statement that runs BEFORE any async verification, so a + * concurrent double-submit of the same assertion can never spend one challenge + * twice (the replay window a SELECT→await→DELETE ordering would open). + */ +function claimChallenge(challenge: string, type: 'registration' | 'authentication', now: number): { user_id: number | null } | null { + const row = db.prepare( + 'DELETE FROM webauthn_challenges WHERE challenge = ? AND type = ? AND expires_at > ? RETURNING user_id', + ).get(challenge, type, now) as { user_id: number | null } | undefined; + return row ?? null; +} + +/** Decode the challenge the authenticator echoed back inside clientDataJSON. */ +function challengeFromResponse(resp: unknown): string | null { + try { + const cdj = (resp as { response?: { clientDataJSON?: unknown } })?.response?.clientDataJSON; + if (typeof cdj !== 'string') return null; + const parsed = JSON.parse(Buffer.from(cdj, 'base64url').toString('utf8')) as { challenge?: unknown }; + return typeof parsed.challenge === 'string' ? parsed.challenge : null; + } catch { + return null; + } +} + +function parseTransports(raw: string | null): AuthenticatorTransportFuture[] | undefined { + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as AuthenticatorTransportFuture[]) : undefined; + } catch { + return undefined; + } +} + +function sanitizeName(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + const trimmed = raw.trim().slice(0, 60); + return trimmed || null; +} + +function defaultCredentialName(deviceType: string | undefined): string { + return deviceType === 'multiDevice' ? 'Passkey (synced)' : 'Passkey'; +} + +// --------------------------------------------------------------------------- +// Registration (authenticated — from Settings, password re-auth required) +// --------------------------------------------------------------------------- + +export async function passkeyRegisterOptions( + userId: number, + password: string | undefined, +): Promise<{ error?: string; status?: number; options?: Awaited> }> { + const cfg = resolveWebauthnConfig(); + if (!cfg) return { ...NOT_CONFIGURED }; + + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as User | undefined; + if (!user) return { error: 'User not found', status: 404 }; + + // Re-authentication: a hijacked session must not be able to silently plant an + // attacker-controlled passkey. Require the current password (parity with the + // change-password / disable-MFA step-up). + if (!password || !user.password_hash || !bcrypt.compareSync(password, user.password_hash)) { + return { error: 'Incorrect password', status: 401 }; + } + + const existing = db.prepare('SELECT credential_id, transports FROM webauthn_credentials WHERE user_id = ?') + .all(userId) as { credential_id: string; transports: string | null }[]; + + const now = Date.now(); + purgeExpiredChallenges(now); + + const options = await generateRegistrationOptions({ + rpName: cfg.rpName, + rpID: cfg.rpID, + userName: user.email, + userDisplayName: user.username, + userID: new TextEncoder().encode(String(user.id)), + attestationType: 'none', + // Stop the same authenticator from enrolling twice on this account. + excludeCredentials: existing.map((c) => ({ id: c.credential_id, transports: parseTransports(c.transports) })), + authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' }, + supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS, + }); + + storeChallenge(options.challenge, userId, 'registration', now); + return { options }; +} + +export async function passkeyRegisterVerify( + userId: number, + body: { attestationResponse?: unknown; name?: unknown }, +): Promise<{ error?: string; status?: number; success?: boolean; credential?: unknown }> { + const cfg = resolveWebauthnConfig(); + if (!cfg) return { ...NOT_CONFIGURED }; + + const resp = body?.attestationResponse; + if (!resp) return { error: 'Invalid registration response', status: 400 }; + + const challenge = challengeFromResponse(resp); + if (!challenge) return { error: 'Invalid registration response', status: 400 }; + + const now = Date.now(); + const claimed = claimChallenge(challenge, 'registration', now); + if (!claimed || claimed.user_id !== userId) { + return { error: 'Registration challenge expired. Please try again.', status: 400 }; + } + + let verification; + try { + verification = await verifyRegistrationResponse({ + response: resp as Parameters[0]['response'], + expectedChallenge: challenge, + expectedOrigin: cfg.origins, + expectedRPID: cfg.rpID, + requireUserVerification: true, + }); + } catch { + return { error: 'Could not register this passkey.', status: 400 }; + } + + if (!verification.verified || !verification.registrationInfo) { + return { error: 'Could not register this passkey.', status: 400 }; + } + + // Persist ONLY the values the verifier vouches for — never anything parsed + // from the raw client payload. + const { credential, credentialDeviceType, credentialBackedUp, aaguid } = verification.registrationInfo; + + if (db.prepare('SELECT id FROM webauthn_credentials WHERE credential_id = ?').get(credential.id)) { + return { error: 'This passkey is already registered.', status: 409 }; + } + + const name = sanitizeName(body?.name) || defaultCredentialName(credentialDeviceType); + try { + db.prepare( + `INSERT INTO webauthn_credentials + (user_id, credential_id, public_key, counter, transports, device_type, backed_up, name, aaguid, last_used_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`, + ).run( + userId, + credential.id, + Buffer.from(credential.publicKey), + credential.counter ?? 0, + credential.transports ? JSON.stringify(credential.transports) : null, + credentialDeviceType ?? null, + credentialBackedUp ? 1 : 0, + name, + aaguid ?? null, + ); + } catch { + return { error: 'Could not register this passkey.', status: 400 }; + } + + const created = db.prepare( + 'SELECT id, name, device_type, backed_up, created_at, last_used_at FROM webauthn_credentials WHERE credential_id = ?', + ).get(credential.id) as { backed_up: number } & Record; + return { success: true, credential: { ...created, backed_up: created.backed_up === 1 } }; +} + +// --------------------------------------------------------------------------- +// Authentication (public — primary, discoverable-credential login) +// --------------------------------------------------------------------------- + +export async function passkeyLoginOptions(): Promise<{ + error?: string; + status?: number; + options?: Awaited>; +}> { + const cfg = resolveWebauthnConfig(); + if (!cfg) return { ...NOT_CONFIGURED }; + + const now = Date.now(); + purgeExpiredChallenges(now); + + const options = await generateAuthenticationOptions({ + rpID: cfg.rpID, + userVerification: 'required', + // Empty allowCredentials → discoverable flow. The server never echoes which + // accounts have passkeys, so the endpoint can't be used to enumerate users. + }); + + storeChallenge(options.challenge, null, 'authentication', now); + return { options }; +} + +export async function passkeyLoginVerify(body: { assertionResponse?: unknown }): Promise<{ + error?: string; + status?: number; + token?: string; + user?: Record; + auditUserId?: number | null; + auditAction?: string; +}> { + const cfg = resolveWebauthnConfig(); + if (!cfg) return { ...NOT_CONFIGURED }; + + const resp = body?.assertionResponse; + if (!resp) return { ...AUTH_FAILED }; + + const challenge = challengeFromResponse(resp); + if (!challenge) return { ...AUTH_FAILED }; + + // Claim the challenge (single-use) BEFORE looking anything up or verifying. + const now = Date.now(); + if (!claimChallenge(challenge, 'authentication', now)) return { ...AUTH_FAILED }; + + const credId = (resp as { id?: unknown; rawId?: unknown }).id ?? (resp as { rawId?: unknown }).rawId; + if (typeof credId !== 'string') return { ...AUTH_FAILED }; + + const cred = db.prepare('SELECT * FROM webauthn_credentials WHERE credential_id = ?').get(credId) as CredentialRow | undefined; + if (!cred) return { ...AUTH_FAILED }; + + let verification; + try { + verification = await verifyAuthenticationResponse({ + response: resp as Parameters[0]['response'], + expectedChallenge: challenge, + expectedOrigin: cfg.origins, + expectedRPID: cfg.rpID, + requireUserVerification: true, + credential: { + id: cred.credential_id, + publicKey: new Uint8Array(cred.public_key), + counter: cred.counter, + transports: parseTransports(cred.transports), + }, + }); + } catch { + return { ...AUTH_FAILED }; + } + + if (!verification.verified) return { ...AUTH_FAILED }; + + const { newCounter } = verification.authenticationInfo; + // Clone detection only makes sense for authenticators that actually increment. + // Synced passkeys legitimately report a counter that stays 0 — never treat + // that as a clone. A regression from a previously NON-ZERO counter rejects + // THIS assertion (and is audited) but does not disable the credential. + if (cred.counter > 0 && newCounter <= cred.counter) { + return { ...AUTH_FAILED, auditUserId: cred.user_id, auditAction: 'user.passkey_clone_suspected' }; + } + + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(cred.user_id) as User | undefined; + if (!user) return { ...AUTH_FAILED }; + + // Persist the new counter + last-used and bump login bookkeeping atomically. + db.transaction(() => { + db.prepare('UPDATE webauthn_credentials SET counter = ?, last_used_at = CURRENT_TIMESTAMP WHERE id = ?').run(newCounter, cred.id); + db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id); + })(); + + // A user-verified passkey is phishing-resistant and inherently two-factor + // (device possession + biometric/PIN), so it mints the real session directly + // — the SAME path as password and OIDC login (no new token shape). + const token = generateToken(user); + const userSafe = stripUserForClient(user) as Record; + return { token, user: { ...userSafe, avatar_url: avatarUrl(user) }, auditUserId: Number(user.id) }; +} + +// --------------------------------------------------------------------------- +// Management (authenticated, owner-scoped) +// --------------------------------------------------------------------------- + +export function listPasskeys(userId: number): Array> { + const rows = db.prepare( + 'SELECT id, name, device_type, backed_up, created_at, last_used_at FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC', + ).all(userId) as Array<{ backed_up: number } & Record>; + return rows.map((r) => ({ ...r, backed_up: r.backed_up === 1 })); +} + +export function renamePasskey(userId: number, id: string, name: unknown): { error?: string; status?: number; success?: boolean } { + const cleanName = sanitizeName(name); + if (!cleanName) return { error: 'Name is required', status: 400 }; + // Ownership enforced in SQL (404 on miss, never a 403 that leaks existence). + const result = db.prepare('UPDATE webauthn_credentials SET name = ? WHERE id = ? AND user_id = ?').run(cleanName, Number(id), userId); + if (result.changes === 0) return { error: 'Passkey not found', status: 404 }; + return { success: true }; +} + +export function deletePasskey( + userId: number, + id: string, + password: string | undefined, +): { error?: string; status?: number; success?: boolean } { + // Re-auth before removing a credential (a hijacked session must not be able to + // strip the victim's passkeys). Deleting is always allowed because every + // account keeps a usable password as recovery fallback — losing all passkeys + // can never lock anyone out. + const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(userId) as { password_hash: string } | undefined; + if (!user || !user.password_hash || !password || !bcrypt.compareSync(password, user.password_hash)) { + return { error: 'Incorrect password', status: 401 }; + } + const result = db.prepare('DELETE FROM webauthn_credentials WHERE id = ? AND user_id = ?').run(Number(id), userId); + if (result.changes === 0) return { error: 'Passkey not found', status: 404 }; + return { success: true }; +} + +/** Admin: clear all of a user's passkeys (e.g. on suspected compromise). */ +export function adminResetPasskeys(targetUserId: number): { error?: string; status?: number; success?: boolean; deleted?: number; email?: string } { + const target = db.prepare('SELECT id, email FROM users WHERE id = ?').get(targetUserId) as { id: number; email: string } | undefined; + if (!target) return { error: 'User not found', status: 404 }; + const result = db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(targetUserId); + return { success: true, deleted: result.changes, email: target.email }; +} diff --git a/server/src/services/webauthnConfig.ts b/server/src/services/webauthnConfig.ts new file mode 100644 index 00000000..d6fcf924 --- /dev/null +++ b/server/src/services/webauthnConfig.ts @@ -0,0 +1,85 @@ +import { db } from '../db/database'; +import { getAppUrl } from './notifications'; + +/** + * Resolves the WebAuthn Relying Party ID + allowed origins for this deployment. + * + * SECURITY: the RP ID and the allowed origins are derived ONLY from server-side + * configuration — the `webauthn_rp_id` / `webauthn_origins` admin settings (or + * the matching env vars), falling back to APP_URL. They are NEVER taken from the + * request `Host` / `X-Forwarded-Host` header: a forged forwarded host would + * otherwise let an attacker bind credentials to a domain they control, or brick + * every enrolled user. This mirrors how OIDC derives its redirect URI from + * APP_URL (oidc.controller.ts) rather than from request input. + * + * Returns null when no usable RP ID can be resolved (bare IP host, or nothing + * configured) — the feature then reports itself as "not configured" and stays + * disabled so nobody can enrol a credential bound to the wrong origin. + */ + +function getSetting(key: string): string | null { + const raw = (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value; + const trimmed = raw?.trim(); + return trimmed ? trimmed : null; +} + +function hostOf(url: string): string | null { + try { + return new URL(url).hostname || null; + } catch { + return null; + } +} + +/** WebAuthn RP IDs must be registrable domains — never bare IP literals. */ +function isIpHost(host: string): boolean { + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true; // IPv4 + if (host.includes(':')) return true; // IPv6 (hostname keeps the colons) + return false; +} + +export interface WebauthnConfig { + rpID: string; + rpName: string; + /** Exact allowed origins (scheme + host + port). One in prod; localhost dev adds the Vite/API ports. */ + origins: string[]; +} + +export function resolveWebauthnConfig(): WebauthnConfig | null { + // 1. Explicit operator config always wins. + const explicitRpId = (process.env.WEBAUTHN_RP_ID || getSetting('webauthn_rp_id'))?.trim() || null; + const explicitOrigins = (process.env.WEBAUTHN_ORIGINS || getSetting('webauthn_origins') || '') + .split(',') + .map((o) => o.trim().replace(/\/+$/, '')) + .filter(Boolean); + + const appUrl = getAppUrl(); + const appHost = hostOf(appUrl); + + // 2. Derive the RP ID from APP_URL when not explicitly set. + let rpID = explicitRpId; + if (!rpID && appHost && !isIpHost(appHost)) { + rpID = appHost; // a real domain, or "localhost" + } + if (!rpID) return null; // bare IP / unresolved → WebAuthn cannot be used here + + // 3. Resolve the allowed origins. Explicit list wins verbatim (operator's + // responsibility). Otherwise derive a SINGLE origin from APP_URL — we never + // silently union dev localhost origins into a production allow-list. + let origins = explicitOrigins; + if (origins.length === 0) { + if (appHost) origins = [appUrl.replace(/\/+$/, '')]; + if (rpID === 'localhost') { + // Dev: the browser origin is the Vite dev server (:5173), not the API port. + origins = Array.from(new Set([...origins, 'http://localhost:5173', 'http://localhost:3001'])); + } + } + if (origins.length === 0) return null; + + return { rpID, rpName: 'TREK', origins }; +} + +/** True when a usable RP ID resolves for this deployment (exposed as a pure boolean on app-config). */ +export function isPasskeyConfigured(): boolean { + return resolveWebauthnConfig() !== null; +} diff --git a/server/tests/unit/services/webauthnConfig.test.ts b/server/tests/unit/services/webauthnConfig.test.ts new file mode 100644 index 00000000..157645a4 --- /dev/null +++ b/server/tests/unit/services/webauthnConfig.test.ts @@ -0,0 +1,103 @@ +/** + * webauthnConfig.test.ts + * + * The RP-ID / allowed-origin resolver is the single highest-risk piece of the + * passkey feature: a wrong RP ID permanently bricks every enrolled credential. + * These tests pin the security-relevant rules — config wins over APP_URL, bare + * IPs are rejected, localhost dev uses the browser (Vite) origin, and the + * resolver NEVER reads request headers. + */ + +const { settingsStore, appUrlRef } = vi.hoisted(() => ({ + settingsStore: new Map(), + appUrlRef: { value: '' }, +})); + +vi.mock('../../../src/db/database', () => ({ + db: { + prepare: (_sql: string) => ({ + get: (key: string) => { + const v = settingsStore.get(key); + return v === undefined ? undefined : { value: v }; + }, + }), + }, +})); + +vi.mock('../../../src/services/notifications', () => ({ + getAppUrl: () => appUrlRef.value, +})); + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { resolveWebauthnConfig, isPasskeyConfigured } from '../../../src/services/webauthnConfig'; + +beforeEach(() => { + settingsStore.clear(); + appUrlRef.value = ''; +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('resolveWebauthnConfig', () => { + it('WAC-001: derives the RP ID and single origin from a real APP_URL domain', () => { + appUrlRef.value = 'https://trek.example.org'; + const cfg = resolveWebauthnConfig(); + expect(cfg).not.toBeNull(); + expect(cfg!.rpID).toBe('trek.example.org'); + expect(cfg!.origins).toEqual(['https://trek.example.org']); + expect(isPasskeyConfigured()).toBe(true); + }); + + it('WAC-002: returns null for a bare-IP host (IPs are not valid RP IDs)', () => { + appUrlRef.value = 'http://192.168.1.50:3001'; + expect(resolveWebauthnConfig()).toBeNull(); + expect(isPasskeyConfigured()).toBe(false); + }); + + it('WAC-003: returns null when nothing is configured', () => { + expect(resolveWebauthnConfig()).toBeNull(); + expect(isPasskeyConfigured()).toBe(false); + }); + + it('WAC-004: localhost dev uses the browser (Vite :5173) origin, not just the API port', () => { + appUrlRef.value = 'http://localhost:3001'; + const cfg = resolveWebauthnConfig(); + expect(cfg!.rpID).toBe('localhost'); + expect(cfg!.origins).toContain('http://localhost:5173'); + expect(cfg!.origins).toContain('http://localhost:3001'); + }); + + it('WAC-005: an explicit webauthn_rp_id app-setting overrides APP_URL', () => { + appUrlRef.value = 'https://internal.example.org'; + settingsStore.set('webauthn_rp_id', 'public.example.org'); + settingsStore.set('webauthn_origins', 'https://public.example.org'); + const cfg = resolveWebauthnConfig(); + expect(cfg!.rpID).toBe('public.example.org'); + expect(cfg!.origins).toEqual(['https://public.example.org']); + }); + + it('WAC-006: webauthn_origins is parsed as a comma-separated, trimmed list', () => { + settingsStore.set('webauthn_rp_id', 'example.org'); + settingsStore.set('webauthn_origins', 'https://a.example.org , https://b.example.org/'); + const cfg = resolveWebauthnConfig(); + expect(cfg!.origins).toEqual(['https://a.example.org', 'https://b.example.org']); + }); + + it('WAC-007: the WEBAUTHN_RP_ID env var takes priority', () => { + vi.stubEnv('WEBAUTHN_RP_ID', 'env.example.org'); + vi.stubEnv('WEBAUTHN_ORIGINS', 'https://env.example.org'); + appUrlRef.value = 'https://ignored.example.org'; + const cfg = resolveWebauthnConfig(); + expect(cfg!.rpID).toBe('env.example.org'); + expect(cfg!.origins).toEqual(['https://env.example.org']); + }); + + it('WAC-008: a configured RP ID with no origins falls back to the APP_URL origin', () => { + appUrlRef.value = 'https://trek.example.org'; + settingsStore.set('webauthn_rp_id', 'trek.example.org'); + const cfg = resolveWebauthnConfig(); + expect(cfg!.origins).toEqual(['https://trek.example.org']); + }); +}); diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index 7e8f30d2..3582c446 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -341,5 +341,24 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': 'Journey', // en-fallback 'admin.addons.catalog.journey.description': 'Trip tracking & travel journal with check-ins, photos, and daily stories', // en-fallback + 'admin.passkey.title': 'تسجيل الدخول بمفتاح المرور', + 'admin.passkey.cardHint': + 'اسمح للمستخدمين بتسجيل الدخول باستخدام مفاتيح المرور (WebAuthn). معطّل افتراضيًا.', + 'admin.passkey.login': 'تفعيل تسجيل الدخول بمفتاح المرور', + 'admin.passkey.loginHint': + 'إظهار خيار "تسجيل الدخول باستخدام مفتاح المرور" والسماح للمستخدمين بتسجيل مفاتيح المرور في إعداداتهم.', + 'admin.passkey.notConfigured': + 'لا يوجد نطاق WebAuthn صالح لهذا التثبيت بعد. عيّن APP_URL أو Relying Party ID أدناه — تبقى مفاتيح المرور مخفية حتى ذلك الحين.', + 'admin.passkey.rpId': 'Relying Party ID (النطاق)', + 'admin.passkey.rpIdHint': + 'النطاق المجرّد الذي تُربط به مفاتيح المرور، مثل trek.example.org. اتركه فارغًا لاشتقاقه من APP_URL. تغييره لاحقًا يُبطل مفاتيح المرور الموجودة.', + 'admin.passkey.origins': 'الأصول المسموح بها', + 'admin.passkey.originsHint': + 'أصول كاملة مفصولة بفواصل، مثل https://trek.example.org. اتركه فارغًا لاستخدام APP_URL.', + 'admin.passkey.reset': 'إعادة تعيين مفاتيح المرور', + 'admin.passkey.resetHint': + 'إزالة جميع مفاتيح المرور لهذا المستخدم (مثلًا عند فقدان جهاز). سيظل بإمكانه تسجيل الدخول بكلمة المرور.', + 'admin.passkey.resetConfirm': 'إزالة جميع مفاتيح المرور لـ {name}؟', + 'admin.passkey.resetDone': 'تمت إزالة {count} من مفاتيح المرور', }; export default admin; diff --git a/shared/src/i18n/ar/login.ts b/shared/src/i18n/ar/login.ts index 603d9600..aea31fe6 100644 --- a/shared/src/i18n/ar/login.ts +++ b/shared/src/i18n/ar/login.ts @@ -88,5 +88,7 @@ const login: TranslationStrings = { 'هذا الرابط مفقود أو تالف. اطلب رابطًا جديدًا للمتابعة.', 'login.resetPasswordFailed': 'فشلت إعادة التعيين. ربما انتهت صلاحية الرابط.', 'login.emailPlaceholder': 'your@email.com', // en-fallback + 'login.passkey.signIn': 'تسجيل الدخول باستخدام مفتاح المرور', + 'login.passkey.failed': 'فشل تسجيل الدخول بمفتاح المرور. يرجى المحاولة مرة أخرى.', }; export default login; diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index 45415afb..1f9d0814 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -290,6 +290,30 @@ const settings: TranslationStrings = { 'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate', // en-fallback "settings.currency": "Currency", "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", + 'settings.passkey.title': 'مفاتيح المرور', + 'settings.passkey.description': + 'سجّل الدخول بشكل أسرع وأكثر مقاومة للتصيّد باستخدام مفتاح مرور — ببصمة إصبعك أو وجهك أو رمز PIN أو مفتاح أمان مادي. تبقى كلمة المرور كنسخة احتياطية.', + 'settings.passkey.notConfigured': + 'مفاتيح المرور مفعّلة لكنها لم تُهيّأ بالكامل على هذا الخادم بعد. اطلب من المسؤول تعيين نطاق WebAuthn.', + 'settings.passkey.add': 'إضافة مفتاح مرور', + 'settings.passkey.addTitle': 'إضافة مفتاح مرور', + 'settings.passkey.passwordPrompt': + 'أكّد كلمة المرور الحالية، ثم اتبع التعليمات على جهازك.', + 'settings.passkey.passwordRequired': 'كلمة المرور الحالية مطلوبة.', + 'settings.passkey.namePlaceholder': 'الاسم (اختياري، مثل "iPhone")', + 'settings.passkey.addedToast': 'تمت إضافة مفتاح المرور', + 'settings.passkey.added': 'تمت الإضافة', + 'settings.passkey.addError': 'تعذّرت إضافة مفتاح المرور', + 'settings.passkey.cancelled': 'تم إلغاء إعداد مفتاح المرور', + 'settings.passkey.deleted': 'تمت إزالة مفتاح المرور', + 'settings.passkey.deleteConfirm': + 'إزالة مفتاح المرور هذا؟ أكّد بكلمة المرور الخاصة بك.', + 'settings.passkey.rename': 'إعادة التسمية', + 'settings.passkey.defaultName': 'مفتاح المرور', + 'settings.passkey.synced': 'متزامن', + 'settings.passkey.deviceBound': 'هذا الجهاز', + 'settings.passkey.lastUsed': 'آخر استخدام', + 'settings.passkey.neverUsed': 'لم يُستخدم قط', }; export default settings; diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index efff6cc2..4e69fdc3 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -363,5 +363,24 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': 'Jornada', 'admin.addons.catalog.journey.description': 'Rastreamento de viagens e diário de viajante com check-ins, fotos e histórias diárias', + 'admin.passkey.title': 'Login com passkey', + 'admin.passkey.cardHint': + 'Permite que os usuários entrem com passkeys (WebAuthn). Desativado por padrão.', + 'admin.passkey.login': 'Ativar login com passkey', + 'admin.passkey.loginHint': + 'Mostra a opção "Entrar com uma passkey" e permite que os usuários cadastrem passkeys nas configurações.', + 'admin.passkey.notConfigured': + 'Nenhum domínio WebAuthn é resolvido para esta instalação ainda. Defina APP_URL ou o Relying Party ID abaixo — as passkeys ficam ocultas até lá.', + 'admin.passkey.rpId': 'Relying Party ID (domínio)', + 'admin.passkey.rpIdHint': + 'O domínio puro ao qual as passkeys ficam vinculadas, ex.: trek.example.org. Deixe vazio para derivá-lo de APP_URL. Alterá-lo depois invalida as passkeys existentes.', + 'admin.passkey.origins': 'Origens permitidas', + 'admin.passkey.originsHint': + 'Origens completas separadas por vírgula, ex.: https://trek.example.org. Deixe vazio para usar APP_URL.', + 'admin.passkey.reset': 'Redefinir passkeys', + 'admin.passkey.resetHint': + 'Remove todas as passkeys deste usuário (ex.: em caso de perda do dispositivo). Ele ainda poderá entrar com a senha.', + 'admin.passkey.resetConfirm': 'Remover todas as passkeys de {name}?', + 'admin.passkey.resetDone': 'Removida(s) {count} passkey(s)', }; export default admin; diff --git a/shared/src/i18n/br/login.ts b/shared/src/i18n/br/login.ts index 3e4dde2f..a9ed02d2 100644 --- a/shared/src/i18n/br/login.ts +++ b/shared/src/i18n/br/login.ts @@ -91,5 +91,8 @@ const login: TranslationStrings = { 'Este link está ausente ou corrompido. Solicite um novo para continuar.', 'login.resetPasswordFailed': 'Falha na redefinição. O link pode ter expirado.', + 'login.passkey.signIn': 'Entrar com uma passkey', + 'login.passkey.failed': + 'Falha ao entrar com passkey. Tente novamente.', }; export default login; diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index 6248906b..93d999cf 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -296,6 +296,30 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.ntfy': 'Ntfy', "settings.currency": "Currency", "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", + 'settings.passkey.title': 'Passkeys', + 'settings.passkey.description': + 'Entre mais rápido e com proteção contra phishing usando uma passkey — sua impressão digital, rosto, PIN ou uma chave de segurança física. Sua senha continua disponível como reserva.', + 'settings.passkey.notConfigured': + 'As passkeys estão ativadas, mas ainda não foram totalmente configuradas neste servidor. Peça ao administrador para definir o domínio WebAuthn.', + 'settings.passkey.add': 'Adicionar uma passkey', + 'settings.passkey.addTitle': 'Adicionar uma passkey', + 'settings.passkey.passwordPrompt': + 'Confirme sua senha atual e depois siga as instruções do seu dispositivo.', + 'settings.passkey.passwordRequired': 'A senha atual é obrigatória.', + 'settings.passkey.namePlaceholder': 'Nome (opcional, ex.: "iPhone")', + 'settings.passkey.addedToast': 'Passkey adicionada', + 'settings.passkey.added': 'Adicionada', + 'settings.passkey.addError': 'Não foi possível adicionar a passkey', + 'settings.passkey.cancelled': 'Configuração da passkey cancelada', + 'settings.passkey.deleted': 'Passkey removida', + 'settings.passkey.deleteConfirm': + 'Remover esta passkey? Confirme com sua senha.', + 'settings.passkey.rename': 'Renomear', + 'settings.passkey.defaultName': 'Passkey', + 'settings.passkey.synced': 'Sincronizada', + 'settings.passkey.deviceBound': 'Este dispositivo', + 'settings.passkey.lastUsed': 'Último uso', + 'settings.passkey.neverUsed': 'Nunca usada', }; export default settings; diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index c7fa9742..8221658c 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -358,5 +358,24 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': 'Cestovní deník', 'admin.addons.catalog.journey.description': 'Sledování cest a cestovní deník s odbaveními, fotkami a denními příběhy', + 'admin.passkey.title': 'Přihlášení přístupovým klíčem', + 'admin.passkey.cardHint': + 'Umožněte uživatelům přihlašovat se pomocí přístupových klíčů (WebAuthn). Ve výchozím nastavení vypnuto.', + 'admin.passkey.login': 'Povolit přihlášení přístupovým klíčem', + 'admin.passkey.loginHint': + 'Zobrazí možnost „Přihlásit se pomocí přístupového klíče“ a umožní uživatelům zaregistrovat přístupové klíče v nastavení.', + 'admin.passkey.notConfigured': + 'Pro toto nasazení zatím nelze určit žádnou doménu WebAuthn. Nastavte níže APP_URL nebo Relying Party ID — do té doby zůstanou přístupové klíče skryté.', + 'admin.passkey.rpId': 'Relying Party ID (doména)', + 'admin.passkey.rpIdHint': + 'Holá doména, ke které jsou přístupové klíče vázány, např. trek.example.org. Ponechte prázdné, aby se odvodila z APP_URL. Pozdější změna zneplatní stávající přístupové klíče.', + 'admin.passkey.origins': 'Povolené origins', + 'admin.passkey.originsHint': + 'Úplné origins oddělené čárkou, např. https://trek.example.org. Ponechte prázdné pro použití APP_URL.', + 'admin.passkey.reset': 'Resetovat přístupové klíče', + 'admin.passkey.resetHint': + 'Odebere všechny přístupové klíče tohoto uživatele (např. při ztrátě zařízení). Stále se může přihlásit svým heslem.', + 'admin.passkey.resetConfirm': 'Odebrat všechny přístupové klíče uživatele {name}?', + 'admin.passkey.resetDone': 'Odebráno {count} přístupových klíčů', }; export default admin; diff --git a/shared/src/i18n/cs/login.ts b/shared/src/i18n/cs/login.ts index 1855273b..bc23b462 100644 --- a/shared/src/i18n/cs/login.ts +++ b/shared/src/i18n/cs/login.ts @@ -91,5 +91,8 @@ const login: TranslationStrings = { 'login.resetPasswordInvalidLinkBody': 'Odkaz chybí nebo je poškozený. Pro pokračování si vyžádej nový.', 'login.resetPasswordFailed': 'Obnovení se nezdařilo. Odkaz mohl vypršet.', + 'login.passkey.signIn': 'Přihlásit se pomocí přístupového klíče', + 'login.passkey.failed': + 'Přihlášení přístupovým klíčem se nezdařilo. Zkuste to prosím znovu.', }; export default login; diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index ee7502e8..790a7709 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -297,6 +297,30 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.ntfy': 'Ntfy', "settings.currency": "Currency", "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", + 'settings.passkey.title': 'Přístupové klíče', + 'settings.passkey.description': + 'Přihlašujte se rychleji a s ochranou proti phishingu pomocí přístupového klíče — otiskem prstu, obličejem, PINem nebo hardwarovým klíčem. Vaše heslo zůstává jako záloha.', + 'settings.passkey.notConfigured': + 'Přístupové klíče jsou povoleny, ale na tomto serveru zatím nejsou plně nastaveny. Požádejte správce o nastavení domény WebAuthn.', + 'settings.passkey.add': 'Přidat přístupový klíč', + 'settings.passkey.addTitle': 'Přidat přístupový klíč', + 'settings.passkey.passwordPrompt': + 'Potvrďte své současné heslo a poté postupujte podle pokynů svého zařízení.', + 'settings.passkey.passwordRequired': 'Vaše současné heslo je vyžadováno.', + 'settings.passkey.namePlaceholder': 'Název (volitelné, např. „iPhone“)', + 'settings.passkey.addedToast': 'Přístupový klíč přidán', + 'settings.passkey.added': 'Přidáno', + 'settings.passkey.addError': 'Přístupový klíč se nepodařilo přidat', + 'settings.passkey.cancelled': 'Nastavení přístupového klíče zrušeno', + 'settings.passkey.deleted': 'Přístupový klíč odebrán', + 'settings.passkey.deleteConfirm': + 'Odebrat tento přístupový klíč? Potvrďte svým heslem.', + 'settings.passkey.rename': 'Přejmenovat', + 'settings.passkey.defaultName': 'Přístupový klíč', + 'settings.passkey.synced': 'Synchronizováno', + 'settings.passkey.deviceBound': 'Toto zařízení', + 'settings.passkey.lastUsed': 'Naposledy použito', + 'settings.passkey.neverUsed': 'Nikdy nepoužito', }; export default settings; diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index f56921aa..073b1537 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -361,5 +361,24 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': 'Journey', 'admin.addons.catalog.journey.description': 'Reise-Tracking & Tagebuch mit Check-ins, Fotos und Tagesberichten', + 'admin.passkey.title': 'Passkey-Anmeldung', + 'admin.passkey.cardHint': + 'Erlaube Benutzern die Anmeldung mit Passkeys (WebAuthn). Standardmäßig deaktiviert.', + 'admin.passkey.login': 'Passkey-Anmeldung aktivieren', + 'admin.passkey.loginHint': + 'Zeigt die Option "Mit Passkey anmelden" und erlaubt Benutzern, in ihren Einstellungen Passkeys zu registrieren.', + 'admin.passkey.notConfigured': + 'Für diese Installation ist noch keine WebAuthn-Domain auflösbar. Lege unten APP_URL oder die Relying Party ID fest — bis dahin bleiben Passkeys ausgeblendet.', + 'admin.passkey.rpId': 'Relying Party ID (Domain)', + 'admin.passkey.rpIdHint': + 'Die reine Domain, an die Passkeys gebunden sind, z. B. trek.example.org. Leer lassen, um sie aus APP_URL abzuleiten. Eine spätere Änderung macht bestehende Passkeys ungültig.', + 'admin.passkey.origins': 'Erlaubte Origins', + 'admin.passkey.originsHint': + 'Vollständige Origins, durch Komma getrennt, z. B. https://trek.example.org. Leer lassen, um APP_URL zu verwenden.', + 'admin.passkey.reset': 'Passkeys zurücksetzen', + 'admin.passkey.resetHint': + 'Entfernt alle Passkeys dieses Benutzers (z. B. bei einem verlorenen Gerät). Die Anmeldung mit Passwort bleibt weiterhin möglich.', + 'admin.passkey.resetConfirm': 'Alle Passkeys von {name} entfernen?', + 'admin.passkey.resetDone': '{count} Passkey(s) entfernt', }; export default admin; diff --git a/shared/src/i18n/de/login.ts b/shared/src/i18n/de/login.ts index 3e701bf0..de57564d 100644 --- a/shared/src/i18n/de/login.ts +++ b/shared/src/i18n/de/login.ts @@ -94,5 +94,8 @@ const login: TranslationStrings = { 'Dieser Link fehlt oder ist beschädigt. Fordere einen neuen an, um fortzufahren.', 'login.resetPasswordFailed': 'Zurücksetzen fehlgeschlagen. Der Link ist möglicherweise abgelaufen.', + 'login.passkey.signIn': 'Mit Passkey anmelden', + 'login.passkey.failed': + 'Anmeldung mit Passkey fehlgeschlagen. Bitte erneut versuchen.', }; export default login; diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index a1d0df6b..e1b433e2 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -300,6 +300,30 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.ntfy': 'Ntfy', "settings.currency": "Währung", "settings.currencyHint": "Alle Beträge in Costs werden in diese Währung umgerechnet und angezeigt.", + 'settings.passkey.title': 'Passkeys', + 'settings.passkey.description': + 'Melde dich schneller und phishing-resistent mit einem Passkey an — per Fingerabdruck, Gesicht, PIN oder Hardware-Schlüssel. Dein Passwort bleibt als Backup erhalten.', + 'settings.passkey.notConfigured': + 'Passkeys sind aktiviert, aber auf diesem Server noch nicht vollständig eingerichtet. Bitte deinen Administrator, die WebAuthn-Domain festzulegen.', + 'settings.passkey.add': 'Passkey hinzufügen', + 'settings.passkey.addTitle': 'Passkey hinzufügen', + 'settings.passkey.passwordPrompt': + 'Bestätige dein aktuelles Passwort und folge dann der Aufforderung deines Geräts.', + 'settings.passkey.passwordRequired': 'Dein aktuelles Passwort wird benötigt.', + 'settings.passkey.namePlaceholder': 'Name (optional, z. B. "iPhone")', + 'settings.passkey.addedToast': 'Passkey hinzugefügt', + 'settings.passkey.added': 'Hinzugefügt', + 'settings.passkey.addError': 'Passkey konnte nicht hinzugefügt werden', + 'settings.passkey.cancelled': 'Passkey-Einrichtung abgebrochen', + 'settings.passkey.deleted': 'Passkey entfernt', + 'settings.passkey.deleteConfirm': + 'Diesen Passkey entfernen? Bestätige mit deinem Passwort.', + 'settings.passkey.rename': 'Umbenennen', + 'settings.passkey.defaultName': 'Passkey', + 'settings.passkey.synced': 'Synchronisiert', + 'settings.passkey.deviceBound': 'Dieses Gerät', + 'settings.passkey.lastUsed': 'Zuletzt verwendet', + 'settings.passkey.neverUsed': 'Noch nie verwendet', }; export default settings; diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 8b9c408d..796f74c2 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -354,5 +354,23 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': 'Journey', 'admin.addons.catalog.journey.description': 'Trip tracking & travel journal with check-ins, photos, and daily stories', + 'admin.passkey.title': 'Passkey login', + 'admin.passkey.cardHint': 'Let users sign in with passkeys (WebAuthn). Off by default.', + 'admin.passkey.login': 'Enable passkey login', + 'admin.passkey.loginHint': + 'Show a "Sign in with a passkey" option and let users enrol passkeys in their settings.', + 'admin.passkey.notConfigured': + 'No WebAuthn domain resolves for this deployment yet. Set APP_URL or the Relying Party ID below — passkeys stay hidden until then.', + 'admin.passkey.rpId': 'Relying Party ID (domain)', + 'admin.passkey.rpIdHint': + 'The bare domain passkeys are bound to, e.g. trek.example.org. Leave empty to derive it from APP_URL. Changing it later invalidates existing passkeys.', + 'admin.passkey.origins': 'Allowed origins', + 'admin.passkey.originsHint': + 'Comma-separated full origins, e.g. https://trek.example.org. Leave empty to use APP_URL.', + 'admin.passkey.reset': 'Reset passkeys', + 'admin.passkey.resetHint': + "Remove all of this user's passkeys (e.g. on a lost device). They can still sign in with their password.", + 'admin.passkey.resetConfirm': 'Remove all passkeys for {name}?', + 'admin.passkey.resetDone': 'Removed {count} passkey(s)', }; export default admin; diff --git a/shared/src/i18n/en/login.ts b/shared/src/i18n/en/login.ts index 26f1263e..58532b61 100644 --- a/shared/src/i18n/en/login.ts +++ b/shared/src/i18n/en/login.ts @@ -91,5 +91,7 @@ const login: TranslationStrings = { 'login.resetPasswordInvalidLinkBody': 'This link is missing or broken. Request a new one to continue.', 'login.resetPasswordFailed': 'Reset failed. The link may have expired.', + 'login.passkey.signIn': 'Sign in with a passkey', + 'login.passkey.failed': 'Passkey sign-in failed. Please try again.', }; export default login; diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index 7eaabb50..ae60ffad 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -290,6 +290,28 @@ const settings: TranslationStrings = { 'settings.mfa.demoBlocked': 'Not available in demo mode', "settings.currency": "Currency", "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", + 'settings.passkey.title': 'Passkeys', + 'settings.passkey.description': + 'Sign in faster and phishing-resistant with a passkey — your fingerprint, face, PIN, or a hardware key. Your password stays as a backup.', + 'settings.passkey.notConfigured': + 'Passkeys are enabled but not fully configured on this server yet. Ask your administrator to set the WebAuthn domain.', + 'settings.passkey.add': 'Add a passkey', + 'settings.passkey.addTitle': 'Add a passkey', + 'settings.passkey.passwordPrompt': 'Confirm your current password, then follow your device prompt.', + 'settings.passkey.passwordRequired': 'Your current password is required.', + 'settings.passkey.namePlaceholder': 'Name (optional, e.g. "iPhone")', + 'settings.passkey.addedToast': 'Passkey added', + 'settings.passkey.added': 'Added', + 'settings.passkey.addError': 'Could not add passkey', + 'settings.passkey.cancelled': 'Passkey setup cancelled', + 'settings.passkey.deleted': 'Passkey removed', + 'settings.passkey.deleteConfirm': 'Remove this passkey? Confirm with your password.', + 'settings.passkey.rename': 'Rename', + 'settings.passkey.defaultName': 'Passkey', + 'settings.passkey.synced': 'Synced', + 'settings.passkey.deviceBound': 'This device', + 'settings.passkey.lastUsed': 'Last used', + 'settings.passkey.neverUsed': 'Never used', }; export default settings; diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index e76b44d1..7a1a993b 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -369,5 +369,24 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': 'Travesía', 'admin.addons.catalog.journey.description': 'Seguimiento de viajes y diario de viajero con registros de ubicación, fotos e historias diarias', + 'admin.passkey.title': 'Inicio de sesión con passkey', + 'admin.passkey.cardHint': + 'Permite que los usuarios inicien sesión con passkeys (WebAuthn). Desactivado de forma predeterminada.', + 'admin.passkey.login': 'Activar inicio de sesión con passkey', + 'admin.passkey.loginHint': + 'Muestra una opción "Iniciar sesión con una passkey" y permite a los usuarios registrar passkeys en sus ajustes.', + 'admin.passkey.notConfigured': + 'Aún no se resuelve ningún dominio de WebAuthn para esta instalación. Define APP_URL o el Relying Party ID a continuación: las passkeys permanecerán ocultas hasta entonces.', + 'admin.passkey.rpId': 'Relying Party ID (dominio)', + 'admin.passkey.rpIdHint': + 'El dominio puro al que se vinculan las passkeys, p. ej. trek.example.org. Déjalo vacío para derivarlo de APP_URL. Cambiarlo más adelante invalida las passkeys existentes.', + 'admin.passkey.origins': 'Orígenes permitidos', + 'admin.passkey.originsHint': + 'Orígenes completos separados por comas, p. ej. https://trek.example.org. Déjalo vacío para usar APP_URL.', + 'admin.passkey.reset': 'Restablecer passkeys', + 'admin.passkey.resetHint': + 'Elimina todas las passkeys de este usuario (p. ej. tras perder un dispositivo). Aún podrá iniciar sesión con su contraseña.', + 'admin.passkey.resetConfirm': '¿Eliminar todas las passkeys de {name}?', + 'admin.passkey.resetDone': 'Se eliminaron {count} passkey(s)', }; export default admin; diff --git a/shared/src/i18n/es/login.ts b/shared/src/i18n/es/login.ts index e73bed86..f4d91eb4 100644 --- a/shared/src/i18n/es/login.ts +++ b/shared/src/i18n/es/login.ts @@ -95,5 +95,8 @@ const login: TranslationStrings = { 'La autenticación por contraseña está desactivada. Por favor, inicia sesión con tu proveedor SSO.', 'login.oidcLoggedOut': 'Has cerrado sesión. Vuelve a iniciar sesión con tu proveedor SSO.', + 'login.passkey.signIn': 'Iniciar sesión con una passkey', + 'login.passkey.failed': + 'Error al iniciar sesión con la passkey. Inténtalo de nuevo.', }; export default login; diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index cfb3edd5..dfb68248 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -297,6 +297,30 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.ntfy': 'Ntfy', "settings.currency": "Currency", "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", + 'settings.passkey.title': 'Passkeys', + 'settings.passkey.description': + 'Inicia sesión más rápido y con protección frente al phishing usando una passkey: tu huella, tu cara, tu PIN o una llave de seguridad física. Tu contraseña sigue disponible como respaldo.', + 'settings.passkey.notConfigured': + 'Las passkeys están habilitadas, pero aún no están del todo configuradas en este servidor. Pide a tu administrador que defina el dominio de WebAuthn.', + 'settings.passkey.add': 'Añadir una passkey', + 'settings.passkey.addTitle': 'Añadir una passkey', + 'settings.passkey.passwordPrompt': + 'Confirma tu contraseña actual y luego sigue las indicaciones de tu dispositivo.', + 'settings.passkey.passwordRequired': 'Se requiere tu contraseña actual.', + 'settings.passkey.namePlaceholder': 'Nombre (opcional, p. ej. "iPhone")', + 'settings.passkey.addedToast': 'Passkey añadida', + 'settings.passkey.added': 'Añadida', + 'settings.passkey.addError': 'No se pudo añadir la passkey', + 'settings.passkey.cancelled': 'Configuración de la passkey cancelada', + 'settings.passkey.deleted': 'Passkey eliminada', + 'settings.passkey.deleteConfirm': + '¿Eliminar esta passkey? Confírmalo con tu contraseña.', + 'settings.passkey.rename': 'Renombrar', + 'settings.passkey.defaultName': 'Passkey', + 'settings.passkey.synced': 'Sincronizada', + 'settings.passkey.deviceBound': 'Este dispositivo', + 'settings.passkey.lastUsed': 'Último uso', + 'settings.passkey.neverUsed': 'Nunca usada', }; export default settings; diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 616327a8..dd9a8dd6 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -369,5 +369,24 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': 'Journal de voyage', 'admin.addons.catalog.journey.description': 'Suivi de voyages et journal avec check-ins, photos et récits quotidiens', + 'admin.passkey.title': 'Connexion par passkey', + 'admin.passkey.cardHint': + 'Permettez aux utilisateurs de se connecter avec des passkeys (WebAuthn). Désactivé par défaut.', + 'admin.passkey.login': 'Activer la connexion par passkey', + 'admin.passkey.loginHint': + 'Affiche une option « Se connecter avec une passkey » et permet aux utilisateurs d\'enregistrer des passkeys dans leurs paramètres.', + 'admin.passkey.notConfigured': + "Aucun domaine WebAuthn ne peut encore être résolu pour ce déploiement. Définissez APP_URL ou le Relying Party ID ci-dessous — les passkeys restent masquées jusque-là.", + 'admin.passkey.rpId': 'Relying Party ID (domaine)', + 'admin.passkey.rpIdHint': + "Le domaine nu auquel les passkeys sont liées, ex. trek.example.org. Laissez vide pour le déduire d'APP_URL. Le modifier ultérieurement invalide les passkeys existantes.", + 'admin.passkey.origins': 'Origines autorisées', + 'admin.passkey.originsHint': + "Origines complètes séparées par des virgules, ex. https://trek.example.org. Laissez vide pour utiliser APP_URL.", + 'admin.passkey.reset': 'Réinitialiser les passkeys', + 'admin.passkey.resetHint': + "Supprime toutes les passkeys de cet utilisateur (ex. en cas d'appareil perdu). Il pourra toujours se connecter avec son mot de passe.", + 'admin.passkey.resetConfirm': 'Supprimer toutes les passkeys de {name} ?', + 'admin.passkey.resetDone': '{count} passkey(s) supprimée(s)', }; export default admin; diff --git a/shared/src/i18n/fr/login.ts b/shared/src/i18n/fr/login.ts index e6e9d87e..921e3a6e 100644 --- a/shared/src/i18n/fr/login.ts +++ b/shared/src/i18n/fr/login.ts @@ -98,5 +98,8 @@ const login: TranslationStrings = { 'login.oidcLoggedOut': 'Vous avez été déconnecté. Reconnectez-vous via votre fournisseur SSO.', 'login.demoHint': 'Essayez la démo — aucune inscription nécessaire', + 'login.passkey.signIn': 'Se connecter avec une passkey', + 'login.passkey.failed': + 'Échec de la connexion par passkey. Veuillez réessayer.', }; export default login; diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index d4f2dfdc..fa55f294 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -301,6 +301,30 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.ntfy': 'Ntfy', "settings.currency": "Currency", "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", + 'settings.passkey.title': 'Passkeys', + 'settings.passkey.description': + "Connectez-vous plus rapidement et de façon résistante au phishing avec une passkey — votre empreinte digitale, votre visage, votre code PIN ou une clé matérielle. Votre mot de passe reste disponible en secours.", + 'settings.passkey.notConfigured': + "Les passkeys sont activées mais ne sont pas encore entièrement configurées sur ce serveur. Demandez à votre administrateur de définir le domaine WebAuthn.", + 'settings.passkey.add': 'Ajouter une passkey', + 'settings.passkey.addTitle': 'Ajouter une passkey', + 'settings.passkey.passwordPrompt': + "Confirmez votre mot de passe actuel, puis suivez les instructions de votre appareil.", + 'settings.passkey.passwordRequired': 'Votre mot de passe actuel est requis.', + 'settings.passkey.namePlaceholder': 'Nom (facultatif, ex. "iPhone")', + 'settings.passkey.addedToast': 'Passkey ajoutée', + 'settings.passkey.added': 'Ajoutée', + 'settings.passkey.addError': "Impossible d'ajouter la passkey", + 'settings.passkey.cancelled': 'Configuration de la passkey annulée', + 'settings.passkey.deleted': 'Passkey supprimée', + 'settings.passkey.deleteConfirm': + 'Supprimer cette passkey ? Confirmez avec votre mot de passe.', + 'settings.passkey.rename': 'Renommer', + 'settings.passkey.defaultName': 'Passkey', + 'settings.passkey.synced': 'Synchronisée', + 'settings.passkey.deviceBound': 'Cet appareil', + 'settings.passkey.lastUsed': 'Dernière utilisation', + 'settings.passkey.neverUsed': 'Jamais utilisée', }; export default settings; diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index d0b43fac..bf94846a 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -370,5 +370,24 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': 'Ταξίδι', 'admin.addons.catalog.journey.description': 'Παρακολούθηση ταξιδιών & ημερολόγιο ταξιδιών με αφίξεις, φωτογραφίες και καθημερινές ιστορίες', + 'admin.passkey.title': 'Σύνδεση με passkey', + 'admin.passkey.cardHint': + 'Επιτρέψτε στους χρήστες να συνδέονται με passkeys (WebAuthn). Απενεργοποιημένο από προεπιλογή.', + 'admin.passkey.login': 'Ενεργοποίηση σύνδεσης με passkey', + 'admin.passkey.loginHint': + 'Εμφανίστε μια επιλογή "Σύνδεση με passkey" και επιτρέψτε στους χρήστες να καταχωρούν passkeys στις ρυθμίσεις τους.', + 'admin.passkey.notConfigured': + 'Δεν αναλύεται ακόμη κανένας τομέας WebAuthn για αυτή την εγκατάσταση. Ορίστε το APP_URL ή το Relying Party ID παρακάτω — τα passkeys παραμένουν κρυφά μέχρι τότε.', + 'admin.passkey.rpId': 'Relying Party ID (τομέας)', + 'admin.passkey.rpIdHint': + 'Ο σκέτος τομέας στον οποίο δεσμεύονται τα passkeys, π.χ. trek.example.org. Αφήστε το κενό για να παραχθεί από το APP_URL. Η μεταγενέστερη αλλαγή του ακυρώνει τα υπάρχοντα passkeys.', + 'admin.passkey.origins': 'Επιτρεπόμενες προελεύσεις', + 'admin.passkey.originsHint': + 'Πλήρεις προελεύσεις χωρισμένες με κόμμα, π.χ. https://trek.example.org. Αφήστε το κενό για χρήση του APP_URL.', + 'admin.passkey.reset': 'Επαναφορά passkeys', + 'admin.passkey.resetHint': + 'Αφαιρέστε όλα τα passkeys αυτού του χρήστη (π.χ. σε περίπτωση χαμένης συσκευής). Μπορούν ακόμη να συνδεθούν με τον κωδικό τους.', + 'admin.passkey.resetConfirm': 'Αφαίρεση όλων των passkeys για τον/την {name};', + 'admin.passkey.resetDone': 'Αφαιρέθηκαν {count} passkey(s)', }; export default admin; diff --git a/shared/src/i18n/gr/login.ts b/shared/src/i18n/gr/login.ts index 9c3e4684..1f7b626c 100644 --- a/shared/src/i18n/gr/login.ts +++ b/shared/src/i18n/gr/login.ts @@ -99,5 +99,8 @@ const login: TranslationStrings = { 'Αυτός ο σύνδεσμος λείπει ή έχει χαλάσει. Ζητήστε έναν νέο για να συνεχίσετε.', 'login.resetPasswordFailed': 'Η επαναφορά απέτυχε. Ο σύνδεσμος μπορεί να έχει λήξει.', + 'login.passkey.signIn': 'Σύνδεση με passkey', + 'login.passkey.failed': + 'Η σύνδεση με passkey απέτυχε. Παρακαλώ δοκιμάστε ξανά.', }; export default login; diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index ea9dd1a5..9a51c338 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -303,6 +303,30 @@ const settings: TranslationStrings = { 'settings.mfa.demoBlocked': 'Δεν είναι διαθέσιμο σε λειτουργία demo', "settings.currency": "Currency", "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", + 'settings.passkey.title': 'Passkeys', + 'settings.passkey.description': + 'Συνδεθείτε πιο γρήγορα και με προστασία από phishing χρησιμοποιώντας ένα passkey — το δαχτυλικό σας αποτύπωμα, το πρόσωπό σας, ένα PIN ή ένα κλειδί υλικού. Ο κωδικός σας παραμένει ως εφεδρεία.', + 'settings.passkey.notConfigured': + 'Τα passkeys είναι ενεργοποιημένα αλλά δεν έχουν διαμορφωθεί πλήρως σε αυτόν τον server ακόμη. Ζητήστε από τον διαχειριστή σας να ορίσει τον τομέα WebAuthn.', + 'settings.passkey.add': 'Προσθήκη passkey', + 'settings.passkey.addTitle': 'Προσθήκη passkey', + 'settings.passkey.passwordPrompt': + 'Επιβεβαιώστε τον τρέχοντα κωδικό σας και έπειτα ακολουθήστε τις οδηγίες της συσκευής σας.', + 'settings.passkey.passwordRequired': 'Ο τρέχων κωδικός σας είναι υποχρεωτικός.', + 'settings.passkey.namePlaceholder': 'Όνομα (προαιρετικό, π.χ. "iPhone")', + 'settings.passkey.addedToast': 'Το passkey προστέθηκε', + 'settings.passkey.added': 'Προστέθηκε', + 'settings.passkey.addError': 'Δεν ήταν δυνατή η προσθήκη του passkey', + 'settings.passkey.cancelled': 'Η ρύθμιση του passkey ακυρώθηκε', + 'settings.passkey.deleted': 'Το passkey αφαιρέθηκε', + 'settings.passkey.deleteConfirm': + 'Αφαίρεση αυτού του passkey; Επιβεβαιώστε με τον κωδικό σας.', + 'settings.passkey.rename': 'Μετονομασία', + 'settings.passkey.defaultName': 'Passkey', + 'settings.passkey.synced': 'Συγχρονισμένο', + 'settings.passkey.deviceBound': 'Αυτή η συσκευή', + 'settings.passkey.lastUsed': 'Τελευταία χρήση', + 'settings.passkey.neverUsed': 'Δεν χρησιμοποιήθηκε ποτέ', }; export default settings; diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index c246fe3b..6bb6a732 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -362,5 +362,24 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': 'Útinaplók', 'admin.addons.catalog.journey.description': 'Utazáskövetés és útinapló bejelentkezésekkel, fotókkal és napi történetekkel', + 'admin.passkey.title': 'Passkey-bejelentkezés', + 'admin.passkey.cardHint': + 'Engedélyezd a felhasználóknak a bejelentkezést passkey-vel (WebAuthn). Alapból kikapcsolva.', + 'admin.passkey.login': 'Passkey-bejelentkezés engedélyezése', + 'admin.passkey.loginHint': + 'Jeleníts meg egy „Bejelentkezés passkey-jel" lehetőséget, és engedd, hogy a felhasználók passkey-ket regisztráljanak a beállításaikban.', + 'admin.passkey.notConfigured': + 'Ehhez a telepítéshez még nem oldódik fel WebAuthn-domain. Állítsd be az APP_URL-t vagy a lenti Relying Party ID-t — addig a passkey-k rejtve maradnak.', + 'admin.passkey.rpId': 'Relying Party ID (domain)', + 'admin.passkey.rpIdHint': + 'A csupasz domain, amelyhez a passkey-k kötődnek, pl. trek.example.org. Hagyd üresen, hogy az APP_URL-ből legyen levezetve. Későbbi módosítása érvényteleníti a meglévő passkey-ket.', + 'admin.passkey.origins': 'Engedélyezett origók', + 'admin.passkey.originsHint': + 'Vesszővel elválasztott teljes origók, pl. https://trek.example.org. Hagyd üresen az APP_URL használatához.', + 'admin.passkey.reset': 'Passkey-k visszaállítása', + 'admin.passkey.resetHint': + 'Eltávolítja a felhasználó összes passkey-jét (pl. elveszett eszköz esetén). A jelszavukkal továbbra is be tudnak jelentkezni.', + 'admin.passkey.resetConfirm': 'Eltávolítod {name} összes passkey-jét?', + 'admin.passkey.resetDone': '{count} passkey eltávolítva', }; export default admin; diff --git a/shared/src/i18n/hu/login.ts b/shared/src/i18n/hu/login.ts index add396e7..5663c166 100644 --- a/shared/src/i18n/hu/login.ts +++ b/shared/src/i18n/hu/login.ts @@ -98,5 +98,8 @@ const login: TranslationStrings = { 'A link hiányzik vagy sérült. A folytatáshoz kérj egy újat.', 'login.resetPasswordFailed': 'A visszaállítás nem sikerült. A link lehet, hogy lejárt.', + 'login.passkey.signIn': 'Bejelentkezés passkey-jel', + 'login.passkey.failed': + 'A passkey-bejelentkezés sikertelen. Kérjük, próbáld újra.', }; export default login; diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index 4ee9c8a3..6d82e28d 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -299,6 +299,30 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.ntfy': 'Ntfy', "settings.currency": "Currency", "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", + 'settings.passkey.title': 'Passkey-k', + 'settings.passkey.description': + 'Jelentkezz be gyorsabban és adathalászat-állóan egy passkey-jel — ujjlenyomattal, arccal, PIN-kóddal vagy hardveres kulccsal. A jelszavad tartalékként megmarad.', + 'settings.passkey.notConfigured': + 'A passkey-k engedélyezve vannak, de ezen a szerveren még nincsenek teljesen beállítva. Kérd meg a rendszergazdát, hogy állítsa be a WebAuthn-domaint.', + 'settings.passkey.add': 'Passkey hozzáadása', + 'settings.passkey.addTitle': 'Passkey hozzáadása', + 'settings.passkey.passwordPrompt': + 'Erősítsd meg a jelenlegi jelszavad, majd kövesd az eszközöd útmutatását.', + 'settings.passkey.passwordRequired': 'A jelenlegi jelszó megadása kötelező.', + 'settings.passkey.namePlaceholder': 'Név (opcionális, pl. "iPhone")', + 'settings.passkey.addedToast': 'Passkey hozzáadva', + 'settings.passkey.added': 'Hozzáadva', + 'settings.passkey.addError': 'Nem sikerült hozzáadni a passkey-t', + 'settings.passkey.cancelled': 'Passkey beállítása megszakítva', + 'settings.passkey.deleted': 'Passkey eltávolítva', + 'settings.passkey.deleteConfirm': + 'Eltávolítod ezt a passkey-t? Erősítsd meg a jelszavaddal.', + 'settings.passkey.rename': 'Átnevezés', + 'settings.passkey.defaultName': 'Passkey', + 'settings.passkey.synced': 'Szinkronizálva', + 'settings.passkey.deviceBound': 'Ez az eszköz', + 'settings.passkey.lastUsed': 'Utoljára használva', + 'settings.passkey.neverUsed': 'Még nem használt', }; export default settings; diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index 181ba0ca..b651fc85 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -357,5 +357,24 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': 'Journey', 'admin.addons.catalog.journey.description': 'Pelacakan perjalanan & jurnal dengan check-in, foto, dan cerita harian', + 'admin.passkey.title': 'Login dengan passkey', + 'admin.passkey.cardHint': + 'Izinkan pengguna masuk dengan passkey (WebAuthn). Nonaktif secara default.', + 'admin.passkey.login': 'Aktifkan login dengan passkey', + 'admin.passkey.loginHint': + 'Tampilkan opsi "Masuk dengan passkey" dan izinkan pengguna mendaftarkan passkey di pengaturan mereka.', + 'admin.passkey.notConfigured': + 'Belum ada domain WebAuthn yang terdeteksi untuk deployment ini. Atur APP_URL atau Relying Party ID di bawah — passkey tetap tersembunyi sampai itu dilakukan.', + 'admin.passkey.rpId': 'Relying Party ID (domain)', + 'admin.passkey.rpIdHint': + 'Domain murni tempat passkey diikat, mis. trek.example.org. Kosongkan untuk mengambilnya dari APP_URL. Mengubahnya nanti akan membatalkan passkey yang sudah ada.', + 'admin.passkey.origins': 'Origin yang diizinkan', + 'admin.passkey.originsHint': + 'Origin lengkap dipisahkan koma, mis. https://trek.example.org. Kosongkan untuk menggunakan APP_URL.', + 'admin.passkey.reset': 'Reset passkey', + 'admin.passkey.resetHint': + 'Hapus semua passkey pengguna ini (mis. saat perangkat hilang). Mereka tetap bisa masuk dengan kata sandi mereka.', + 'admin.passkey.resetConfirm': 'Hapus semua passkey untuk {name}?', + 'admin.passkey.resetDone': 'Menghapus {count} passkey', }; export default admin; diff --git a/shared/src/i18n/id/login.ts b/shared/src/i18n/id/login.ts index 948382b6..014590f0 100644 --- a/shared/src/i18n/id/login.ts +++ b/shared/src/i18n/id/login.ts @@ -93,5 +93,7 @@ const login: TranslationStrings = { 'login.resetPasswordInvalidLinkBody': 'Tautan hilang atau rusak. Minta tautan baru untuk melanjutkan.', 'login.resetPasswordFailed': 'Reset gagal. Tautan mungkin sudah kedaluwarsa.', + 'login.passkey.signIn': 'Masuk dengan passkey', + 'login.passkey.failed': 'Masuk dengan passkey gagal. Silakan coba lagi.', }; export default login; diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index a97a27c8..6d953907 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -297,6 +297,30 @@ const settings: TranslationStrings = { 'Menampilkan nama stasiun / bandara di peta. Jika mati, hanya ikon ditampilkan.', "settings.currency": "Currency", "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", + 'settings.passkey.title': 'Passkey', + 'settings.passkey.description': + 'Masuk lebih cepat dan tahan terhadap phishing dengan passkey — sidik jari, wajah, PIN, atau kunci keamanan fisik kamu. Kata sandimu tetap tersedia sebagai cadangan.', + 'settings.passkey.notConfigured': + 'Passkey diaktifkan tetapi belum sepenuhnya dikonfigurasi di server ini. Minta administratormu untuk mengatur domain WebAuthn.', + 'settings.passkey.add': 'Tambah passkey', + 'settings.passkey.addTitle': 'Tambah passkey', + 'settings.passkey.passwordPrompt': + 'Konfirmasi kata sandimu saat ini, lalu ikuti petunjuk di perangkatmu.', + 'settings.passkey.passwordRequired': 'Kata sandimu saat ini wajib diisi.', + 'settings.passkey.namePlaceholder': 'Nama (opsional, mis. "iPhone")', + 'settings.passkey.addedToast': 'Passkey ditambahkan', + 'settings.passkey.added': 'Ditambahkan', + 'settings.passkey.addError': 'Gagal menambahkan passkey', + 'settings.passkey.cancelled': 'Penyiapan passkey dibatalkan', + 'settings.passkey.deleted': 'Passkey dihapus', + 'settings.passkey.deleteConfirm': + 'Hapus passkey ini? Konfirmasi dengan kata sandimu.', + 'settings.passkey.rename': 'Ganti nama', + 'settings.passkey.defaultName': 'Passkey', + 'settings.passkey.synced': 'Tersinkron', + 'settings.passkey.deviceBound': 'Perangkat ini', + 'settings.passkey.lastUsed': 'Terakhir digunakan', + 'settings.passkey.neverUsed': 'Belum pernah digunakan', }; export default settings; diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index f804ed30..337be072 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -365,5 +365,24 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': 'Diario di viaggio', 'admin.addons.catalog.journey.description': 'Tracciamento viaggi e diario con check-in, foto e storie quotidiane', + 'admin.passkey.title': 'Accesso con passkey', + 'admin.passkey.cardHint': + 'Consenti agli utenti di accedere con le passkey (WebAuthn). Disattivato per impostazione predefinita.', + 'admin.passkey.login': 'Abilita accesso con passkey', + 'admin.passkey.loginHint': + 'Mostra un\'opzione "Accedi con una passkey" e consenti agli utenti di registrare le passkey nelle loro impostazioni.', + 'admin.passkey.notConfigured': + 'Nessun dominio WebAuthn è ancora risolto per questa installazione. Imposta APP_URL o il Relying Party ID qui sotto — le passkey restano nascoste fino ad allora.', + 'admin.passkey.rpId': 'Relying Party ID (dominio)', + 'admin.passkey.rpIdHint': + 'Il dominio puro a cui le passkey sono associate, es. trek.example.org. Lascia vuoto per derivarlo da APP_URL. Modificarlo in seguito invalida le passkey esistenti.', + 'admin.passkey.origins': 'Origini consentite', + 'admin.passkey.originsHint': + 'Origini complete separate da virgola, es. https://trek.example.org. Lascia vuoto per usare APP_URL.', + 'admin.passkey.reset': 'Reimposta passkey', + 'admin.passkey.resetHint': + "Rimuovi tutte le passkey di questo utente (es. in caso di dispositivo smarrito). Potrà comunque accedere con la sua password.", + 'admin.passkey.resetConfirm': 'Rimuovere tutte le passkey di {name}?', + 'admin.passkey.resetDone': 'Rimosse {count} passkey', }; export default admin; diff --git a/shared/src/i18n/it/login.ts b/shared/src/i18n/it/login.ts index 47b8a35b..281a091e 100644 --- a/shared/src/i18n/it/login.ts +++ b/shared/src/i18n/it/login.ts @@ -92,5 +92,7 @@ const login: TranslationStrings = { 'Il link è mancante o danneggiato. Richiedine uno nuovo per continuare.', 'login.resetPasswordFailed': 'Reset non riuscito. Il link potrebbe essere scaduto.', + 'login.passkey.signIn': 'Accedi con una passkey', + 'login.passkey.failed': 'Accesso con passkey non riuscito. Riprova.', }; export default login; diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index 42d4d4b9..a1619c6d 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -296,6 +296,30 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.ntfy': 'Ntfy', "settings.currency": "Currency", "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", + 'settings.passkey.title': 'Passkey', + 'settings.passkey.description': + 'Accedi più velocemente e in modo resistente al phishing con una passkey — la tua impronta digitale, il volto, il PIN o una chiave hardware. La tua password resta come riserva.', + 'settings.passkey.notConfigured': + 'Le passkey sono abilitate ma non ancora completamente configurate su questo server. Chiedi al tuo amministratore di impostare il dominio WebAuthn.', + 'settings.passkey.add': 'Aggiungi una passkey', + 'settings.passkey.addTitle': 'Aggiungi una passkey', + 'settings.passkey.passwordPrompt': + 'Conferma la tua password attuale, poi segui le istruzioni del tuo dispositivo.', + 'settings.passkey.passwordRequired': 'La tua password attuale è obbligatoria.', + 'settings.passkey.namePlaceholder': 'Nome (facoltativo, es. "iPhone")', + 'settings.passkey.addedToast': 'Passkey aggiunta', + 'settings.passkey.added': 'Aggiunta', + 'settings.passkey.addError': 'Impossibile aggiungere la passkey', + 'settings.passkey.cancelled': 'Configurazione della passkey annullata', + 'settings.passkey.deleted': 'Passkey rimossa', + 'settings.passkey.deleteConfirm': + 'Rimuovere questa passkey? Conferma con la tua password.', + 'settings.passkey.rename': 'Rinomina', + 'settings.passkey.defaultName': 'Passkey', + 'settings.passkey.synced': 'Sincronizzata', + 'settings.passkey.deviceBound': 'Questo dispositivo', + 'settings.passkey.lastUsed': 'Ultimo utilizzo', + 'settings.passkey.neverUsed': 'Mai usata', }; export default settings; diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index a2986b62..bfbec288 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -336,5 +336,24 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': '日記', 'admin.addons.catalog.journey.description': 'チェックイン、写真、日ごとのストーリーで旅を記録', + 'admin.passkey.title': 'パスキーログイン', + 'admin.passkey.cardHint': + 'ユーザーがパスキー(WebAuthn)でサインインできるようにします。既定では無効です。', + 'admin.passkey.login': 'パスキーログインを有効化', + 'admin.passkey.loginHint': + '「パスキーでサインイン」オプションを表示し、ユーザーが設定でパスキーを登録できるようにします。', + 'admin.passkey.notConfigured': + 'このデプロイにはまだ有効な WebAuthn ドメインがありません。下の APP_URL または Relying Party ID を設定してください。それまでパスキーは表示されません。', + 'admin.passkey.rpId': 'Relying Party ID(ドメイン)', + 'admin.passkey.rpIdHint': + 'パスキーが紐づくドメイン名のみ(例:trek.example.org)。空欄の場合は APP_URL から導出されます。後で変更すると既存のパスキーは無効になります。', + 'admin.passkey.origins': '許可するオリジン', + 'admin.passkey.originsHint': + 'カンマ区切りの完全なオリジン(例:https://trek.example.org)。空欄の場合は APP_URL を使用します。', + 'admin.passkey.reset': 'パスキーをリセット', + 'admin.passkey.resetHint': + 'このユーザーのパスキーをすべて削除します(例:デバイスを紛失した場合)。パスワードでのサインインは引き続き可能です。', + 'admin.passkey.resetConfirm': '{name} のパスキーをすべて削除しますか?', + 'admin.passkey.resetDone': '{count} 件のパスキーを削除しました', }; export default admin; diff --git a/shared/src/i18n/ja/login.ts b/shared/src/i18n/ja/login.ts index f7621567..1cb73fdd 100644 --- a/shared/src/i18n/ja/login.ts +++ b/shared/src/i18n/ja/login.ts @@ -91,5 +91,8 @@ const login: TranslationStrings = { 'リンクが無効または破損しています。新しいリンクをリクエストしてください。', 'login.resetPasswordFailed': 'リセットに失敗しました。リンクの有効期限が切れている可能性があります。', + 'login.passkey.signIn': 'パスキーでサインイン', + 'login.passkey.failed': + 'パスキーでのサインインに失敗しました。もう一度お試しください。', }; export default login; diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index 50209a75..4b88316a 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -276,6 +276,30 @@ const settings: TranslationStrings = { 'settings.oauth.badge.machine': 'マシン', "settings.currency": "Currency", "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", + 'settings.passkey.title': 'パスキー', + 'settings.passkey.description': + '指紋、顔認証、PIN、またはハードウェアキーを使うパスキーで、より速く、フィッシングに強いサインインができます。パスワードはバックアップとして残ります。', + 'settings.passkey.notConfigured': + 'パスキーは有効ですが、このサーバーではまだ完全には設定されていません。管理者に WebAuthn ドメインの設定を依頼してください。', + 'settings.passkey.add': 'パスキーを追加', + 'settings.passkey.addTitle': 'パスキーを追加', + 'settings.passkey.passwordPrompt': + '現在のパスワードを確認し、デバイスの指示に従ってください。', + 'settings.passkey.passwordRequired': '現在のパスワードが必要です。', + 'settings.passkey.namePlaceholder': '名前(任意、例:"iPhone")', + 'settings.passkey.addedToast': 'パスキーを追加しました', + 'settings.passkey.added': '追加済み', + 'settings.passkey.addError': 'パスキーを追加できませんでした', + 'settings.passkey.cancelled': 'パスキーの設定をキャンセルしました', + 'settings.passkey.deleted': 'パスキーを削除しました', + 'settings.passkey.deleteConfirm': + 'このパスキーを削除しますか?パスワードで確認してください。', + 'settings.passkey.rename': '名前を変更', + 'settings.passkey.defaultName': 'パスキー', + 'settings.passkey.synced': '同期済み', + 'settings.passkey.deviceBound': 'このデバイス', + 'settings.passkey.lastUsed': '最終使用', + 'settings.passkey.neverUsed': '未使用', }; export default settings; diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index bc1743b6..3838a59e 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -349,5 +349,24 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': 'Journey', 'admin.addons.catalog.journey.description': '체크인, 사진, 일별 이야기가 있는 여행 기록 및 여행 일지', + 'admin.passkey.title': '패스키 로그인', + 'admin.passkey.cardHint': + '사용자가 패스키(WebAuthn)로 로그인할 수 있게 합니다. 기본값은 꺼짐입니다.', + 'admin.passkey.login': '패스키 로그인 활성화', + 'admin.passkey.loginHint': + '"패스키로 로그인" 옵션을 표시하고 사용자가 설정에서 패스키를 등록할 수 있게 합니다.', + 'admin.passkey.notConfigured': + '이 배포에 아직 WebAuthn 도메인이 확인되지 않습니다. 아래에서 APP_URL 또는 Relying Party ID를 설정하세요 — 그 전까지 패스키는 숨겨진 상태로 유지됩니다.', + 'admin.passkey.rpId': 'Relying Party ID (도메인)', + 'admin.passkey.rpIdHint': + '패스키가 바인딩되는 순수 도메인입니다. 예: trek.example.org. 비워두면 APP_URL에서 자동으로 가져옵니다. 나중에 변경하면 기존 패스키가 무효화됩니다.', + 'admin.passkey.origins': '허용된 오리진', + 'admin.passkey.originsHint': + '쉼표로 구분된 전체 오리진입니다. 예: https://trek.example.org. 비워두면 APP_URL을 사용합니다.', + 'admin.passkey.reset': '패스키 초기화', + 'admin.passkey.resetHint': + '이 사용자의 모든 패스키를 삭제합니다 (예: 기기 분실 시). 사용자는 비밀번호로 계속 로그인할 수 있습니다.', + 'admin.passkey.resetConfirm': '{name}의 모든 패스키를 삭제할까요?', + 'admin.passkey.resetDone': '패스키 {count}개를 삭제했습니다', }; export default admin; diff --git a/shared/src/i18n/ko/login.ts b/shared/src/i18n/ko/login.ts index 39436fcc..5f453661 100644 --- a/shared/src/i18n/ko/login.ts +++ b/shared/src/i18n/ko/login.ts @@ -89,5 +89,7 @@ const login: TranslationStrings = { 'login.resetPasswordInvalidLinkBody': '이 링크가 없거나 손상되었습니다. 새 링크를 요청하세요.', 'login.resetPasswordFailed': '재설정 실패. 링크가 만료되었을 수 있습니다.', + 'login.passkey.signIn': '패스키로 로그인', + 'login.passkey.failed': '패스키 로그인에 실패했습니다. 다시 시도하세요.', }; export default login; diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index e7bd2ca0..705e1e04 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -293,6 +293,30 @@ const settings: TranslationStrings = { 'settings.oauth.badge.machine': '머신', "settings.currency": "Currency", "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", + 'settings.passkey.title': '패스키', + 'settings.passkey.description': + '지문, 얼굴, PIN 또는 하드웨어 키 같은 패스키로 더 빠르고 피싱에 강하게 로그인하세요. 비밀번호는 백업으로 그대로 유지됩니다.', + 'settings.passkey.notConfigured': + '패스키가 활성화되어 있지만 이 서버에 아직 완전히 설정되지 않았습니다. 관리자에게 WebAuthn 도메인 설정을 요청하세요.', + 'settings.passkey.add': '패스키 추가', + 'settings.passkey.addTitle': '패스키 추가', + 'settings.passkey.passwordPrompt': + '현재 비밀번호를 확인한 뒤 기기 안내에 따라 진행하세요.', + 'settings.passkey.passwordRequired': '현재 비밀번호가 필요합니다.', + 'settings.passkey.namePlaceholder': '이름 (선택, 예: "iPhone")', + 'settings.passkey.addedToast': '패스키가 추가되었습니다', + 'settings.passkey.added': '추가됨', + 'settings.passkey.addError': '패스키를 추가할 수 없습니다', + 'settings.passkey.cancelled': '패스키 설정이 취소되었습니다', + 'settings.passkey.deleted': '패스키가 삭제되었습니다', + 'settings.passkey.deleteConfirm': + '이 패스키를 삭제할까요? 비밀번호로 확인하세요.', + 'settings.passkey.rename': '이름 변경', + 'settings.passkey.defaultName': '패스키', + 'settings.passkey.synced': '동기화됨', + 'settings.passkey.deviceBound': '이 기기', + 'settings.passkey.lastUsed': '마지막 사용', + 'settings.passkey.neverUsed': '사용한 적 없음', }; export default settings; diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index 4b63e430..6aa54294 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -360,5 +360,24 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': 'Reisverslag', 'admin.addons.catalog.journey.description': "Reistracking & reisdagboek met check-ins, foto's en dagelijkse verhalen", + 'admin.passkey.title': 'Inloggen met passkey', + 'admin.passkey.cardHint': + 'Laat gebruikers inloggen met passkeys (WebAuthn). Standaard uit.', + 'admin.passkey.login': 'Inloggen met passkey inschakelen', + 'admin.passkey.loginHint': + 'Toon een optie "Inloggen met een passkey" en laat gebruikers passkeys registreren in hun instellingen.', + 'admin.passkey.notConfigured': + 'Voor deze installatie wordt nog geen WebAuthn-domein herleid. Stel APP_URL of de Relying Party ID hieronder in — tot dan blijven passkeys verborgen.', + 'admin.passkey.rpId': 'Relying Party ID (domein)', + 'admin.passkey.rpIdHint': + 'Het kale domein waaraan passkeys zijn gebonden, bijv. trek.example.org. Laat leeg om het af te leiden uit APP_URL. Als je dit later wijzigt, worden bestaande passkeys ongeldig.', + 'admin.passkey.origins': 'Toegestane origins', + 'admin.passkey.originsHint': + 'Volledige origins, gescheiden door komma\'s, bijv. https://trek.example.org. Laat leeg om APP_URL te gebruiken.', + 'admin.passkey.reset': 'Passkeys resetten', + 'admin.passkey.resetHint': + 'Verwijder alle passkeys van deze gebruiker (bijv. bij een verloren apparaat). Ze kunnen nog steeds inloggen met hun wachtwoord.', + 'admin.passkey.resetConfirm': 'Alle passkeys voor {name} verwijderen?', + 'admin.passkey.resetDone': '{count} passkey(s) verwijderd', }; export default admin; diff --git a/shared/src/i18n/nl/login.ts b/shared/src/i18n/nl/login.ts index b931b2a6..8388ec3e 100644 --- a/shared/src/i18n/nl/login.ts +++ b/shared/src/i18n/nl/login.ts @@ -94,5 +94,7 @@ const login: TranslationStrings = { 'login.oidcLoggedOut': 'Je bent uitgelogd. Log opnieuw in via je SSO-provider.', 'login.demoHint': 'Probeer de demo — geen registratie nodig', + 'login.passkey.signIn': 'Inloggen met een passkey', + 'login.passkey.failed': 'Inloggen met passkey mislukt. Probeer het opnieuw.', }; export default login; diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index 2e9572b8..33340e8b 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -296,6 +296,30 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.ntfy': 'Ntfy', "settings.currency": "Currency", "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", + 'settings.passkey.title': 'Passkeys', + 'settings.passkey.description': + 'Log sneller en phishingbestendig in met een passkey — je vingerafdruk, gezicht, pincode of een hardwaresleutel. Je wachtwoord blijft als back-up bestaan.', + 'settings.passkey.notConfigured': + 'Passkeys zijn ingeschakeld maar nog niet volledig geconfigureerd op deze server. Vraag je beheerder om het WebAuthn-domein in te stellen.', + 'settings.passkey.add': 'Een passkey toevoegen', + 'settings.passkey.addTitle': 'Een passkey toevoegen', + 'settings.passkey.passwordPrompt': + 'Bevestig je huidige wachtwoord en volg daarna de aanwijzingen van je apparaat.', + 'settings.passkey.passwordRequired': 'Je huidige wachtwoord is vereist.', + 'settings.passkey.namePlaceholder': 'Naam (optioneel, bijv. "iPhone")', + 'settings.passkey.addedToast': 'Passkey toegevoegd', + 'settings.passkey.added': 'Toegevoegd', + 'settings.passkey.addError': 'Passkey kon niet worden toegevoegd', + 'settings.passkey.cancelled': 'Passkey instellen geannuleerd', + 'settings.passkey.deleted': 'Passkey verwijderd', + 'settings.passkey.deleteConfirm': + 'Deze passkey verwijderen? Bevestig met je wachtwoord.', + 'settings.passkey.rename': 'Naam wijzigen', + 'settings.passkey.defaultName': 'Passkey', + 'settings.passkey.synced': 'Gesynchroniseerd', + 'settings.passkey.deviceBound': 'Dit apparaat', + 'settings.passkey.lastUsed': 'Laatst gebruikt', + 'settings.passkey.neverUsed': 'Nooit gebruikt', }; export default settings; diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index 3beb1c54..516b7021 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -362,5 +362,24 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': 'Dziennik podróży', 'admin.addons.catalog.journey.description': 'Śledzenie podróży i dziennik z zameldowaniami, zdjęciami i codziennymi historiami', + 'admin.passkey.title': 'Logowanie kluczem dostępu', + 'admin.passkey.cardHint': + 'Pozwól użytkownikom logować się kluczami dostępu (WebAuthn). Domyślnie wyłączone.', + 'admin.passkey.login': 'Włącz logowanie kluczem dostępu', + 'admin.passkey.loginHint': + 'Pokaż opcję „Zaloguj się kluczem dostępu” i pozwól użytkownikom rejestrować klucze dostępu w swoich ustawieniach.', + 'admin.passkey.notConfigured': + 'Dla tego wdrożenia nie ustalono jeszcze żadnej domeny WebAuthn. Ustaw APP_URL lub Relying Party ID poniżej — do tego czasu klucze dostępu pozostaną ukryte.', + 'admin.passkey.rpId': 'Relying Party ID (domena)', + 'admin.passkey.rpIdHint': + 'Sama domena, do której przypisane są klucze dostępu, np. trek.example.org. Pozostaw puste, aby wyprowadzić ją z APP_URL. Późniejsza zmiana unieważnia istniejące klucze dostępu.', + 'admin.passkey.origins': 'Dozwolone origins', + 'admin.passkey.originsHint': + 'Pełne origins oddzielone przecinkami, np. https://trek.example.org. Pozostaw puste, aby użyć APP_URL.', + 'admin.passkey.reset': 'Zresetuj klucze dostępu', + 'admin.passkey.resetHint': + 'Usuń wszystkie klucze dostępu tego użytkownika (np. po utracie urządzenia). Nadal będzie mógł logować się hasłem.', + 'admin.passkey.resetConfirm': 'Usunąć wszystkie klucze dostępu dla {name}?', + 'admin.passkey.resetDone': 'Usunięto {count} kluczy dostępu', }; export default admin; diff --git a/shared/src/i18n/pl/login.ts b/shared/src/i18n/pl/login.ts index 45186fb4..8bab8eca 100644 --- a/shared/src/i18n/pl/login.ts +++ b/shared/src/i18n/pl/login.ts @@ -94,5 +94,8 @@ const login: TranslationStrings = { 'login.resetPasswordFailed': 'Reset nie powiódł się. Link mógł wygasnąć.', 'login.setNewPassword': 'Ustaw nowe hasło', 'login.setNewPasswordHint': 'Musisz zmienić hasło.', + 'login.passkey.signIn': 'Zaloguj się kluczem dostępu', + 'login.passkey.failed': + 'Logowanie kluczem dostępu nie powiodło się. Spróbuj ponownie.', }; export default login; diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index 8daf8eb7..e55c15c5 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -298,6 +298,30 @@ const settings: TranslationStrings = { 'settings.mustChangePassword': 'Musisz zmienić hasło przed kontynuowaniem.', "settings.currency": "Currency", "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", + 'settings.passkey.title': 'Klucze dostępu', + 'settings.passkey.description': + 'Loguj się szybciej i z odpornością na phishing za pomocą klucza dostępu — odcisku palca, twarzy, kodu PIN lub klucza sprzętowego. Twoje hasło pozostaje jako zapasowa opcja.', + 'settings.passkey.notConfigured': + 'Klucze dostępu są włączone, ale nie zostały jeszcze w pełni skonfigurowane na tym serwerze. Poproś administratora o ustawienie domeny WebAuthn.', + 'settings.passkey.add': 'Dodaj klucz dostępu', + 'settings.passkey.addTitle': 'Dodaj klucz dostępu', + 'settings.passkey.passwordPrompt': + 'Potwierdź swoje aktualne hasło, a następnie postępuj zgodnie z komunikatem na urządzeniu.', + 'settings.passkey.passwordRequired': 'Twoje aktualne hasło jest wymagane.', + 'settings.passkey.namePlaceholder': 'Nazwa (opcjonalnie, np. "iPhone")', + 'settings.passkey.addedToast': 'Klucz dostępu został dodany', + 'settings.passkey.added': 'Dodano', + 'settings.passkey.addError': 'Nie udało się dodać klucza dostępu', + 'settings.passkey.cancelled': 'Konfiguracja klucza dostępu anulowana', + 'settings.passkey.deleted': 'Klucz dostępu został usunięty', + 'settings.passkey.deleteConfirm': + 'Usunąć ten klucz dostępu? Potwierdź swoim hasłem.', + 'settings.passkey.rename': 'Zmień nazwę', + 'settings.passkey.defaultName': 'Klucz dostępu', + 'settings.passkey.synced': 'Zsynchronizowany', + 'settings.passkey.deviceBound': 'To urządzenie', + 'settings.passkey.lastUsed': 'Ostatnio użyty', + 'settings.passkey.neverUsed': 'Nigdy nieużywany', }; export default settings; diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index 6bab660f..0ceffdef 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -366,5 +366,24 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': 'Путешествие', 'admin.addons.catalog.journey.description': 'Отслеживание поездок и дневник путешествий с отметками, фото и ежедневными историями', + 'admin.passkey.title': 'Вход по passkey', + 'admin.passkey.cardHint': + 'Разрешите пользователям входить с помощью passkeys (WebAuthn). По умолчанию выключено.', + 'admin.passkey.login': 'Включить вход по passkey', + 'admin.passkey.loginHint': + 'Показывать вариант «Войти с помощью passkey» и разрешить пользователям регистрировать passkeys в своих настройках.', + 'admin.passkey.notConfigured': + 'Для этого развёртывания пока не определён домен WebAuthn. Задайте APP_URL или Relying Party ID ниже — до этого passkeys остаются скрытыми.', + 'admin.passkey.rpId': 'Relying Party ID (домен)', + 'admin.passkey.rpIdHint': + 'Голый домен, к которому привязаны passkeys, напр. trek.example.org. Оставьте пустым, чтобы определить его из APP_URL. Последующее изменение делает существующие passkeys недействительными.', + 'admin.passkey.origins': 'Разрешённые источники', + 'admin.passkey.originsHint': + 'Полные источники через запятую, напр. https://trek.example.org. Оставьте пустым, чтобы использовать APP_URL.', + 'admin.passkey.reset': 'Сбросить passkeys', + 'admin.passkey.resetHint': + 'Удалить все passkeys этого пользователя (напр. при потере устройства). Он по-прежнему сможет войти по паролю.', + 'admin.passkey.resetConfirm': 'Удалить все passkeys пользователя {name}?', + 'admin.passkey.resetDone': 'Удалено passkeys: {count}', }; export default admin; diff --git a/shared/src/i18n/ru/login.ts b/shared/src/i18n/ru/login.ts index 7e17c63d..c987f33d 100644 --- a/shared/src/i18n/ru/login.ts +++ b/shared/src/i18n/ru/login.ts @@ -93,5 +93,7 @@ const login: TranslationStrings = { 'login.oidcLoggedOut': 'Вы вышли из системы. Войдите снова через вашего провайдера SSO.', 'login.demoHint': 'Попробуйте демо — регистрация не требуется', + 'login.passkey.signIn': 'Войти с помощью passkey', + 'login.passkey.failed': 'Не удалось войти с помощью passkey. Попробуйте ещё раз.', }; export default login; diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index 0f59d8c1..7359ddcc 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -297,6 +297,29 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.ntfy': 'Ntfy', "settings.currency": "Currency", "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", + 'settings.passkey.title': 'Passkeys', + 'settings.passkey.description': + 'Входите быстрее и с защитой от фишинга с помощью passkey — отпечатка пальца, лица, PIN-кода или аппаратного ключа. Ваш пароль остаётся как резервный способ.', + 'settings.passkey.notConfigured': + 'Passkeys включены, но ещё не полностью настроены на этом сервере. Попросите администратора задать домен WebAuthn.', + 'settings.passkey.add': 'Добавить passkey', + 'settings.passkey.addTitle': 'Добавить passkey', + 'settings.passkey.passwordPrompt': + 'Подтвердите текущий пароль, затем следуйте подсказке на устройстве.', + 'settings.passkey.passwordRequired': 'Требуется ваш текущий пароль.', + 'settings.passkey.namePlaceholder': 'Название (необязательно, напр. "iPhone")', + 'settings.passkey.addedToast': 'Passkey добавлен', + 'settings.passkey.added': 'Добавлен', + 'settings.passkey.addError': 'Не удалось добавить passkey', + 'settings.passkey.cancelled': 'Настройка passkey отменена', + 'settings.passkey.deleted': 'Passkey удалён', + 'settings.passkey.deleteConfirm': 'Удалить этот passkey? Подтвердите паролем.', + 'settings.passkey.rename': 'Переименовать', + 'settings.passkey.defaultName': 'Passkey', + 'settings.passkey.synced': 'Синхронизирован', + 'settings.passkey.deviceBound': 'Это устройство', + 'settings.passkey.lastUsed': 'Последнее использование', + 'settings.passkey.neverUsed': 'Не использовался', }; export default settings; diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index 23accdf3..6129ca30 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -363,5 +363,24 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': 'Seyahat', 'admin.addons.catalog.journey.description': 'Check-in, fotoğraf ve günlük hikâyelerle seyahat takibi ve seyahat günlüğü', + 'admin.passkey.title': 'Passkey ile oturum açma', + 'admin.passkey.cardHint': + 'Kullanıcıların passkey (WebAuthn) ile oturum açmasına izin verin. Varsayılan olarak kapalı.', + 'admin.passkey.login': 'Passkey ile oturum açmayı etkinleştir', + 'admin.passkey.loginHint': + 'Bir "Passkey ile oturum açın" seçeneği gösterin ve kullanıcıların ayarlarında passkey kaydetmesine izin verin.', + 'admin.passkey.notConfigured': + 'Bu dağıtım için henüz çözümlenen bir WebAuthn alan adı yok. Aşağıdan APP_URL veya Relying Party ID değerini ayarlayın — o ana kadar passkey’ler gizli kalır.', + 'admin.passkey.rpId': 'Relying Party ID (alan adı)', + 'admin.passkey.rpIdHint': + 'Passkey’lerin bağlı olduğu yalın alan adı, ör. trek.example.org. APP_URL’den türetmek için boş bırakın. Daha sonra değiştirmek mevcut passkey’leri geçersiz kılar.', + 'admin.passkey.origins': 'İzin verilen kaynaklar', + 'admin.passkey.originsHint': + 'Virgülle ayrılmış tam kaynaklar, ör. https://trek.example.org. APP_URL kullanmak için boş bırakın.', + 'admin.passkey.reset': 'Passkey’leri sıfırla', + 'admin.passkey.resetHint': + 'Bu kullanıcının tüm passkey’lerini kaldırın (ör. kaybolan bir cihazda). Yine de şifreleriyle oturum açabilirler.', + 'admin.passkey.resetConfirm': '{name} için tüm passkey’ler kaldırılsın mı?', + 'admin.passkey.resetDone': '{count} passkey kaldırıldı', }; export default admin; diff --git a/shared/src/i18n/tr/login.ts b/shared/src/i18n/tr/login.ts index c2c595ca..debad23c 100644 --- a/shared/src/i18n/tr/login.ts +++ b/shared/src/i18n/tr/login.ts @@ -96,5 +96,8 @@ const login: TranslationStrings = { 'Bu bağlantı eksik veya bozuk. Devam etmek için yeni bir tane isteyin.', 'login.resetPasswordFailed': 'Sıfırlama başarısız oldu. Bağlantının süresi dolmuş olabilir.', + 'login.passkey.signIn': 'Passkey ile oturum açın', + 'login.passkey.failed': + 'Passkey ile oturum açma başarısız oldu. Lütfen tekrar deneyin.', }; export default login; diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index 7f2d6673..f5d0a9d9 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -297,6 +297,30 @@ const settings: TranslationStrings = { 'settings.oauth.badge.machine': 'makine', "settings.currency": "Currency", "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", + 'settings.passkey.title': 'Passkey’ler', + 'settings.passkey.description': + 'Passkey ile daha hızlı ve kimlik avına dayanıklı şekilde oturum açın — parmak iziniz, yüzünüz, PIN’iniz veya bir donanım anahtarı. Şifreniz yedek olarak kalır.', + 'settings.passkey.notConfigured': + 'Passkey’ler etkin ancak bu sunucuda henüz tam olarak yapılandırılmadı. WebAuthn alan adını ayarlaması için yöneticinize başvurun.', + 'settings.passkey.add': 'Passkey ekle', + 'settings.passkey.addTitle': 'Passkey ekle', + 'settings.passkey.passwordPrompt': + 'Mevcut şifrenizi onaylayın, ardından cihazınızın istemini izleyin.', + 'settings.passkey.passwordRequired': 'Mevcut şifreniz gerekli.', + 'settings.passkey.namePlaceholder': 'Ad (isteğe bağlı, ör. "iPhone")', + 'settings.passkey.addedToast': 'Passkey eklendi', + 'settings.passkey.added': 'Eklendi', + 'settings.passkey.addError': 'Passkey eklenemedi', + 'settings.passkey.cancelled': 'Passkey kurulumu iptal edildi', + 'settings.passkey.deleted': 'Passkey kaldırıldı', + 'settings.passkey.deleteConfirm': + 'Bu passkey kaldırılsın mı? Şifrenizle onaylayın.', + 'settings.passkey.rename': 'Yeniden adlandır', + 'settings.passkey.defaultName': 'Passkey', + 'settings.passkey.synced': 'Senkronize edildi', + 'settings.passkey.deviceBound': 'Bu cihaz', + 'settings.passkey.lastUsed': 'Son kullanım', + 'settings.passkey.neverUsed': 'Hiç kullanılmadı', }; export default settings; diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index 472d515e..63a792bf 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -370,5 +370,24 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': 'Journey', 'admin.addons.catalog.journey.description': 'Відстеження поїздок і щоденник подорожей з позначками, фото та щоденними історіями', + 'admin.passkey.title': 'Вхід за допомогою passkey', + 'admin.passkey.cardHint': + 'Дозволити користувачам входити за допомогою passkey (WebAuthn). За замовчуванням вимкнено.', + 'admin.passkey.login': 'Увімкнути вхід за допомогою passkey', + 'admin.passkey.loginHint': + 'Показувати опцію «Увійти за допомогою passkey» та дозволити користувачам реєструвати passkeys у своїх налаштуваннях.', + 'admin.passkey.notConfigured': + 'Для цього розгортання поки не визначено жодного домену WebAuthn. Вкажіть APP_URL або Relying Party ID нижче — до того часу passkeys залишатимуться прихованими.', + 'admin.passkey.rpId': 'Relying Party ID (домен)', + 'admin.passkey.rpIdHint': + 'Чистий домен, до якого прив’язані passkeys, напр. trek.example.org. Залиште порожнім, щоб визначити його з APP_URL. Подальша зміна робить наявні passkeys недійсними.', + 'admin.passkey.origins': 'Дозволені джерела (origins)', + 'admin.passkey.originsHint': + 'Повні джерела через кому, напр. https://trek.example.org. Залиште порожнім, щоб використати APP_URL.', + 'admin.passkey.reset': 'Скинути passkeys', + 'admin.passkey.resetHint': + 'Видалити всі passkeys цього користувача (напр. у разі втрати пристрою). Він зможе входити за допомогою свого пароля.', + 'admin.passkey.resetConfirm': 'Видалити всі passkeys для {name}?', + 'admin.passkey.resetDone': 'Видалено passkeys: {count}', }; export default admin; diff --git a/shared/src/i18n/uk/login.ts b/shared/src/i18n/uk/login.ts index 4138d964..2af617c5 100644 --- a/shared/src/i18n/uk/login.ts +++ b/shared/src/i18n/uk/login.ts @@ -94,5 +94,7 @@ const login: TranslationStrings = { 'login.oidcLoggedOut': 'Ви вийшли з системи. Увійдіть знову через вашого SSO-провайдера.', 'login.demoHint': 'Спробуйте демо — реєстрація не потрібна', + 'login.passkey.signIn': 'Увійти за допомогою passkey', + 'login.passkey.failed': 'Не вдалося увійти за допомогою passkey. Спробуйте ще раз.', }; export default login; diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index 360adf5d..33b660fa 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -295,6 +295,30 @@ const settings: TranslationStrings = { 'settings.oauth.badge.machine': 'машина', "settings.currency": "Currency", "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", + 'settings.passkey.title': 'Passkeys', + 'settings.passkey.description': + 'Входьте швидше та з захистом від фішингу за допомогою passkey — відбитка пальця, обличчя, PIN-коду або апаратного ключа. Ваш пароль залишається як резервний варіант.', + 'settings.passkey.notConfigured': + 'Passkeys увімкнено, але цей сервер ще не повністю налаштовано. Попросіть адміністратора вказати домен WebAuthn.', + 'settings.passkey.add': 'Додати passkey', + 'settings.passkey.addTitle': 'Додати passkey', + 'settings.passkey.passwordPrompt': + 'Підтвердіть поточний пароль, а потім дотримуйтесь підказок на вашому пристрої.', + 'settings.passkey.passwordRequired': 'Потрібен ваш поточний пароль.', + 'settings.passkey.namePlaceholder': 'Назва (необов’язково, напр. "iPhone")', + 'settings.passkey.addedToast': 'Passkey додано', + 'settings.passkey.added': 'Додано', + 'settings.passkey.addError': 'Не вдалося додати passkey', + 'settings.passkey.cancelled': 'Налаштування passkey скасовано', + 'settings.passkey.deleted': 'Passkey видалено', + 'settings.passkey.deleteConfirm': + 'Видалити цей passkey? Підтвердіть паролем.', + 'settings.passkey.rename': 'Перейменувати', + 'settings.passkey.defaultName': 'Passkey', + 'settings.passkey.synced': 'Синхронізовано', + 'settings.passkey.deviceBound': 'Цей пристрій', + 'settings.passkey.lastUsed': 'Останнє використання', + 'settings.passkey.neverUsed': 'Не використовувався', }; export default settings; diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index 17194cc2..6dd290cf 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -327,5 +327,23 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': '旅程', 'admin.addons.catalog.journey.description': '旅行追蹤與旅行日誌,包含打卡、照片和每日故事', + 'admin.passkey.title': 'Passkey 登入', + 'admin.passkey.cardHint': '讓使用者使用 Passkey(WebAuthn)登入。預設為關閉。', + 'admin.passkey.login': '啟用 Passkey 登入', + 'admin.passkey.loginHint': + '顯示「使用 Passkey 登入」選項,並讓使用者在設定中註冊 Passkey。', + 'admin.passkey.notConfigured': + '此部署尚未解析出任何 WebAuthn 網域。請設定下方的 APP_URL 或 Relying Party ID——在此之前 Passkey 將保持隱藏。', + 'admin.passkey.rpId': 'Relying Party ID(網域)', + 'admin.passkey.rpIdHint': + 'Passkey 綁定的純網域,例如 trek.example.org。留空則從 APP_URL 推導。日後變更將使現有 Passkey 失效。', + 'admin.passkey.origins': '允許的來源', + 'admin.passkey.originsHint': + '以逗號分隔的完整來源,例如 https://trek.example.org。留空則使用 APP_URL。', + 'admin.passkey.reset': '重設 Passkey', + 'admin.passkey.resetHint': + '移除此使用者的所有 Passkey(例如裝置遺失時)。他們仍可使用密碼登入。', + 'admin.passkey.resetConfirm': '要移除 {name} 的所有 Passkey 嗎?', + 'admin.passkey.resetDone': '已移除 {count} 個 Passkey', }; export default admin; diff --git a/shared/src/i18n/zh-TW/login.ts b/shared/src/i18n/zh-TW/login.ts index 78ddd040..06f66169 100644 --- a/shared/src/i18n/zh-TW/login.ts +++ b/shared/src/i18n/zh-TW/login.ts @@ -84,5 +84,7 @@ const login: TranslationStrings = { 'login.oidcOnly': '密碼登入已關閉。請透過 SSO 提供商登入。', 'login.oidcLoggedOut': '您已登出。請重新透過 SSO 提供商登入。', 'login.demoHint': '試用演示——無需註冊', + 'login.passkey.signIn': '使用 Passkey 登入', + 'login.passkey.failed': 'Passkey 登入失敗,請重試。', }; export default login; diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index fa9dd650..e0ee4895 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -284,6 +284,28 @@ const settings: TranslationStrings = { '在地圖上顯示車站 / 機場名稱。關閉時僅顯示圖示。', "settings.currency": "Currency", "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", + 'settings.passkey.title': 'Passkey', + 'settings.passkey.description': + '使用 Passkey 更快登入,並可抵禦網路釣魚——透過你的指紋、臉部、PIN 碼或硬體金鑰。你的密碼仍會保留作為備援。', + 'settings.passkey.notConfigured': + 'Passkey 已啟用,但此伺服器尚未完成設定。請聯絡管理員設定 WebAuthn 網域。', + 'settings.passkey.add': '新增 Passkey', + 'settings.passkey.addTitle': '新增 Passkey', + 'settings.passkey.passwordPrompt': '請確認你目前的密碼,然後依照裝置提示操作。', + 'settings.passkey.passwordRequired': '請輸入你目前的密碼。', + 'settings.passkey.namePlaceholder': '名稱(選填,例如 "iPhone")', + 'settings.passkey.addedToast': 'Passkey 已新增', + 'settings.passkey.added': '已新增', + 'settings.passkey.addError': '無法新增 Passkey', + 'settings.passkey.cancelled': 'Passkey 設定已取消', + 'settings.passkey.deleted': 'Passkey 已移除', + 'settings.passkey.deleteConfirm': '要移除此 Passkey 嗎?請以密碼確認。', + 'settings.passkey.rename': '重新命名', + 'settings.passkey.defaultName': 'Passkey', + 'settings.passkey.synced': '已同步', + 'settings.passkey.deviceBound': '此裝置', + 'settings.passkey.lastUsed': '上次使用', + 'settings.passkey.neverUsed': '從未使用', }; export default settings; diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index 0e5191b3..674653f3 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -326,5 +326,23 @@ const admin: TranslationStrings = { 'admin.addons.catalog.journey.name': '旅程', 'admin.addons.catalog.journey.description': '旅行追踪与旅行日志,包含签到、照片和每日故事', + 'admin.passkey.title': '通行密钥登录', + 'admin.passkey.cardHint': '允许用户使用通行密钥(WebAuthn)登录。默认关闭。', + 'admin.passkey.login': '启用通行密钥登录', + 'admin.passkey.loginHint': + '显示"使用通行密钥登录"选项,并允许用户在其设置中注册通行密钥。', + 'admin.passkey.notConfigured': + '此部署尚未解析出有效的 WebAuthn 域名。请设置 APP_URL 或下方的 Relying Party ID——在此之前通行密钥将保持隐藏。', + 'admin.passkey.rpId': 'Relying Party ID(域名)', + 'admin.passkey.rpIdHint': + '通行密钥所绑定的纯域名,如 trek.example.org。留空则从 APP_URL 推导。之后更改将使现有通行密钥失效。', + 'admin.passkey.origins': '允许的来源', + 'admin.passkey.originsHint': + '以逗号分隔的完整来源,如 https://trek.example.org。留空则使用 APP_URL。', + 'admin.passkey.reset': '重置通行密钥', + 'admin.passkey.resetHint': + '移除该用户的所有通行密钥(如设备丢失时)。他们仍可使用密码登录。', + 'admin.passkey.resetConfirm': '移除 {name} 的所有通行密钥?', + 'admin.passkey.resetDone': '已移除 {count} 个通行密钥', }; export default admin; diff --git a/shared/src/i18n/zh/login.ts b/shared/src/i18n/zh/login.ts index e22ff67b..94616658 100644 --- a/shared/src/i18n/zh/login.ts +++ b/shared/src/i18n/zh/login.ts @@ -83,5 +83,7 @@ const login: TranslationStrings = { 'login.oidcOnly': '密码登录已关闭。请通过 SSO 提供商登录。', 'login.oidcLoggedOut': '您已退出登录。请重新通过 SSO 提供商登录。', 'login.demoHint': '试用演示——无需注册', + 'login.passkey.signIn': '使用通行密钥登录', + 'login.passkey.failed': '通行密钥登录失败,请重试。', }; export default login; diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index 8b45fc57..82374cd1 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -283,6 +283,28 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.ntfy': 'Ntfy', "settings.currency": "Currency", "settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.", + 'settings.passkey.title': '通行密钥', + 'settings.passkey.description': + '使用通行密钥更快登录,并能抵御钓鱼攻击——通过指纹、面容、PIN 或硬件密钥验证。你的密码仍可作为备用方式。', + 'settings.passkey.notConfigured': + '通行密钥已启用,但此服务器尚未完成完整配置。请联系管理员设置 WebAuthn 域名。', + 'settings.passkey.add': '添加通行密钥', + 'settings.passkey.addTitle': '添加通行密钥', + 'settings.passkey.passwordPrompt': '确认你的当前密码,然后按照设备提示操作。', + 'settings.passkey.passwordRequired': '需要输入你的当前密码。', + 'settings.passkey.namePlaceholder': '名称(可选,如 "iPhone")', + 'settings.passkey.addedToast': '通行密钥已添加', + 'settings.passkey.added': '已添加', + 'settings.passkey.addError': '无法添加通行密钥', + 'settings.passkey.cancelled': '已取消通行密钥设置', + 'settings.passkey.deleted': '通行密钥已移除', + 'settings.passkey.deleteConfirm': '移除此通行密钥?请输入密码确认。', + 'settings.passkey.rename': '重命名', + 'settings.passkey.defaultName': '通行密钥', + 'settings.passkey.synced': '已同步', + 'settings.passkey.deviceBound': '此设备', + 'settings.passkey.lastUsed': '上次使用', + 'settings.passkey.neverUsed': '从未使用', }; export default settings;