mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
feat: Passkey (WebAuthn) login (#1111)
* feat(auth): passkey (WebAuthn) login — server endpoints, schema + admin toggle Add @simplewebauthn/server registration and primary (discoverable) login ceremonies under /api/auth/passkey, a webauthn_credentials + single-use webauthn_challenges schema (migration), the instance-wide passkey_login toggle (default off) enforced before auth by a guard, and require_mfa satisfaction via a verified passkey. RP ID/origin come only from server config (webauthn_rp_id/origins -> APP_URL), never request headers. * feat(auth): passkey enrolment, login button + admin settings UI PasskeysSection in account settings (add/rename/remove with a current-password step-up), a 'Sign in with a passkey' button on the login page, the admin enable + RP-ID/origins controls, and a per-user admin reset action. * i18n(auth): passkey strings across all locales Add login/settings/admin passkey keys to en and all 19 translated locales.
This commit is contained in:
@@ -26,6 +26,7 @@
|
|||||||
"@fontsource/geist-sans": "^5.2.5",
|
"@fontsource/geist-sans": "^5.2.5",
|
||||||
"@fontsource/poppins": "^5.2.7",
|
"@fontsource/poppins": "^5.2.7",
|
||||||
"@react-pdf/renderer": "^4.5.1",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
|
"@simplewebauthn/browser": "^13.1.2",
|
||||||
"@trek/shared": "*",
|
"@trek/shared": "*",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"dexie": "^4.4.2",
|
"dexie": "^4.4.2",
|
||||||
|
|||||||
@@ -261,6 +261,24 @@ export const authApi = {
|
|||||||
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
|
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
|
||||||
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
|
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
|
||||||
},
|
},
|
||||||
|
passkey: {
|
||||||
|
registerOptions: (password: string) => apiClient.post('/auth/passkey/register/options', { password }).then(r => r.data),
|
||||||
|
registerVerify: (attestationResponse: unknown, name?: string) => apiClient.post('/auth/passkey/register/verify', { attestationResponse, name }).then(r => r.data),
|
||||||
|
loginOptions: () => apiClient.post('/auth/passkey/login/options', {}).then(r => r.data),
|
||||||
|
loginVerify: (assertionResponse: unknown) => apiClient.post('/auth/passkey/login/verify', { assertionResponse }).then(r => r.data as { token: string; user: Record<string, unknown> }),
|
||||||
|
list: () => apiClient.get('/auth/passkey/credentials').then(r => r.data as { credentials: PasskeyCredential[] }),
|
||||||
|
rename: (id: number, name: string) => apiClient.patch(`/auth/passkey/credentials/${id}`, { name }).then(r => r.data),
|
||||||
|
delete: (id: number, password: string) => apiClient.delete(`/auth/passkey/credentials/${id}`, { data: { password } }).then(r => r.data),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasskeyCredential {
|
||||||
|
id: number
|
||||||
|
name: string | null
|
||||||
|
device_type: string | null
|
||||||
|
backed_up: boolean
|
||||||
|
created_at: string
|
||||||
|
last_used_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const oauthApi = {
|
export const oauthApi = {
|
||||||
@@ -414,6 +432,7 @@ export const adminApi = {
|
|||||||
createUser: (data: Record<string, unknown>) => apiClient.post('/admin/users', data).then(r => r.data),
|
createUser: (data: Record<string, unknown>) => apiClient.post('/admin/users', data).then(r => r.data),
|
||||||
updateUser: (id: number, data: Record<string, unknown>) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data),
|
updateUser: (id: number, data: Record<string, unknown>) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data),
|
||||||
deleteUser: (id: number) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
|
deleteUser: (id: number) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
|
||||||
|
resetUserPasskeys: (id: number) => apiClient.delete(`/admin/users/${id}/passkeys`).then(r => r.data),
|
||||||
stats: () => apiClient.get('/admin/stats').then(r => r.data),
|
stats: () => apiClient.get('/admin/stats').then(r => r.data),
|
||||||
saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data),
|
saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data),
|
||||||
getOidc: () => apiClient.get('/admin/oidc').then(r => r.data),
|
getOidc: () => apiClient.get('/admin/oidc').then(r => r.data),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { authApi, adminApi } from '../../api/client'
|
|||||||
import { getApiErrorMessage } from '../../types'
|
import { getApiErrorMessage } from '../../types'
|
||||||
import type { UserWithOidc } from '../../types'
|
import type { UserWithOidc } from '../../types'
|
||||||
import Section from './Section'
|
import Section from './Section'
|
||||||
|
import PasskeysSection from './PasskeysSection'
|
||||||
|
|
||||||
const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending'
|
const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending'
|
||||||
|
|
||||||
@@ -395,6 +396,9 @@ export default function AccountTab(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Passkeys */}
|
||||||
|
<PasskeysSection demoMode={demoMode} />
|
||||||
|
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Fingerprint, Plus, Trash2, Pencil, Check, X } from 'lucide-react'
|
||||||
|
import { startRegistration } from '@simplewebauthn/browser'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { authApi, type PasskeyCredential } from '../../api/client'
|
||||||
|
import { getApiErrorMessage } from '../../types'
|
||||||
|
|
||||||
|
/** Parse a SQLite UTC timestamp ("YYYY-MM-DD HH:MM:SS") into a local date string. */
|
||||||
|
function fmtDate(ts: string | null): string | null {
|
||||||
|
if (!ts) return null
|
||||||
|
const iso = ts.includes('T') ? ts : ts.replace(' ', 'T')
|
||||||
|
const d = new Date(iso.endsWith('Z') ? iso : iso + 'Z')
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d.toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the browser cancellation / no-matching-credential DOMExceptions fire. */
|
||||||
|
function isWebauthnAbort(err: unknown): boolean {
|
||||||
|
const name = (err as { name?: string })?.name
|
||||||
|
return name === 'NotAllowedError' || name === 'AbortError'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passkey enrolment + management. Mirrors the MFA block: list / add (with a
|
||||||
|
* password step-up + the WebAuthn ceremony) / rename / delete (password step-up).
|
||||||
|
* The "Add a passkey" action only appears when the instance toggle is on AND a
|
||||||
|
* usable RP ID resolves; the existing-credential list stays reachable even when
|
||||||
|
* the feature is later disabled so users can always clean up.
|
||||||
|
*/
|
||||||
|
export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): React.ReactElement | null {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
const [configured, setConfigured] = useState(false)
|
||||||
|
const [creds, setCreds] = useState<PasskeyCredential[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
|
const [addPwd, setAddPwd] = useState('')
|
||||||
|
const [addName, setAddName] = useState('')
|
||||||
|
|
||||||
|
const [renamingId, setRenamingId] = useState<number | null>(null)
|
||||||
|
const [renameVal, setRenameVal] = useState('')
|
||||||
|
|
||||||
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
|
const [deletePwd, setDeletePwd] = useState('')
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
authApi.passkey.list()
|
||||||
|
.then(r => setCreds(r.credentials))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
authApi.getAppConfig?.()
|
||||||
|
.then(c => { setEnabled(!!c?.passkey_login); setConfigured(!!c?.passkey_configured) })
|
||||||
|
.catch(() => {})
|
||||||
|
refresh()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const canAdd = enabled && configured
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!addPwd) { toast.error(t('settings.passkey.passwordRequired')); return }
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const options = await authApi.passkey.registerOptions(addPwd)
|
||||||
|
const attResp = await startRegistration({ optionsJSON: options })
|
||||||
|
await authApi.passkey.registerVerify(attResp, addName.trim() || undefined)
|
||||||
|
toast.success(t('settings.passkey.addedToast'))
|
||||||
|
setAddOpen(false); setAddPwd(''); setAddName('')
|
||||||
|
refresh()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (isWebauthnAbort(err)) toast.error(t('settings.passkey.cancelled'))
|
||||||
|
else toast.error(getApiErrorMessage(err, t('settings.passkey.addError')))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRename = async (id: number) => {
|
||||||
|
const name = renameVal.trim()
|
||||||
|
if (!name) { setRenamingId(null); return }
|
||||||
|
try {
|
||||||
|
await authApi.passkey.rename(id, name)
|
||||||
|
setRenamingId(null)
|
||||||
|
refresh()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!deletePwd) { toast.error(t('settings.passkey.passwordRequired')); return }
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await authApi.passkey.delete(id, deletePwd)
|
||||||
|
toast.success(t('settings.passkey.deleted'))
|
||||||
|
setDeletingId(null); setDeletePwd('')
|
||||||
|
refresh()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (demoMode) return null
|
||||||
|
// Nothing to show: feature off and the user has no credentials to manage.
|
||||||
|
if (!loading && !enabled && creds.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-4 mt-4 border-t border-edge-secondary">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Fingerprint className="w-5 h-5 text-content-secondary" />
|
||||||
|
<h3 className="font-semibold text-base m-0 text-content">{t('settings.passkey.title')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm m-0 text-content-muted" style={{ lineHeight: 1.5 }}>{t('settings.passkey.description')}</p>
|
||||||
|
|
||||||
|
{enabled && !configured && (
|
||||||
|
<p className="text-sm m-0 text-amber-700">{t('settings.passkey.notConfigured')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{creds.length > 0 && (
|
||||||
|
<ul className="space-y-2 list-none p-0 m-0">
|
||||||
|
{creds.map(c => (
|
||||||
|
<li key={c.id} className="flex items-center gap-3 p-3 rounded-lg border border-edge bg-surface-card">
|
||||||
|
<Fingerprint className="w-4 h-4 flex-shrink-0 text-content-secondary" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{renamingId === c.id ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={renameVal}
|
||||||
|
onChange={e => setRenameVal(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleRename(c.id); if (e.key === 'Escape') setRenamingId(null) }}
|
||||||
|
className="flex-1 px-2 py-1 border border-slate-300 rounded text-sm"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => handleRename(c.id)} className="p-1 text-emerald-600" aria-label={t('common.save')}><Check size={16} /></button>
|
||||||
|
<button type="button" onClick={() => setRenamingId(null)} className="p-1 text-content-muted" aria-label={t('common.cancel')}><X size={16} /></button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-content truncate">{c.name || t('settings.passkey.defaultName')}</span>
|
||||||
|
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-surface-hover text-content-secondary">
|
||||||
|
{c.backed_up ? t('settings.passkey.synced') : t('settings.passkey.deviceBound')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs m-0 mt-0.5 text-content-faint">
|
||||||
|
{t('settings.passkey.added')}: {fmtDate(c.created_at) || '—'}
|
||||||
|
{' · '}
|
||||||
|
{c.last_used_at
|
||||||
|
? `${t('settings.passkey.lastUsed')}: ${fmtDate(c.last_used_at)}`
|
||||||
|
: t('settings.passkey.neverUsed')}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{renamingId !== c.id && (
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setRenamingId(c.id); setRenameVal(c.name || '') }}
|
||||||
|
className="p-1.5 rounded text-content-muted hover:text-content"
|
||||||
|
aria-label={t('settings.passkey.rename')}
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setDeletingId(c.id); setDeletePwd('') }}
|
||||||
|
className="p-1.5 rounded text-red-500 hover:bg-red-50"
|
||||||
|
aria-label={t('common.delete')}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation (password step-up) */}
|
||||||
|
{deletingId !== null && (
|
||||||
|
<div className="space-y-2 p-3 rounded-lg border border-red-200 bg-red-50/40">
|
||||||
|
<p className="text-sm font-medium m-0 text-content">{t('settings.passkey.deleteConfirm')}</p>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={deletePwd}
|
||||||
|
onChange={e => setDeletePwd(e.target.value)}
|
||||||
|
placeholder={t('settings.currentPassword')}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || !deletePwd}
|
||||||
|
onClick={() => handleDelete(deletingId)}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-medium text-red-600 border border-red-200 hover:bg-red-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('common.delete')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setDeletingId(null); setDeletePwd('') }}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add a passkey */}
|
||||||
|
{canAdd && (addOpen ? (
|
||||||
|
<div className="space-y-2 p-3 rounded-lg border border-edge bg-surface-hover">
|
||||||
|
<p className="text-sm font-medium m-0 text-content">{t('settings.passkey.addTitle')}</p>
|
||||||
|
<p className="text-xs m-0 text-content-muted">{t('settings.passkey.passwordPrompt')}</p>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={addPwd}
|
||||||
|
onChange={e => setAddPwd(e.target.value)}
|
||||||
|
placeholder={t('settings.currentPassword')}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addName}
|
||||||
|
onChange={e => setAddName(e.target.value)}
|
||||||
|
placeholder={t('settings.passkey.namePlaceholder')}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || !addPwd}
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : t('settings.passkey.add')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setAddOpen(false); setAddPwd(''); setAddName('') }}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAddOpen(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors border border-edge bg-surface-card text-content"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
{t('settings.passkey.add')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||||
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound, ChevronDown } from 'lucide-react'
|
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound, ChevronDown, Fingerprint } from 'lucide-react'
|
||||||
import { useLogin } from './login/useLogin'
|
import { useLogin } from './login/useLogin'
|
||||||
|
|
||||||
export default function LoginPage(): React.ReactElement {
|
export default function LoginPage(): React.ReactElement {
|
||||||
@@ -15,9 +15,13 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
||||||
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
|
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
|
||||||
noRedirect, showRegisterOption, oidcOnly,
|
noRedirect, showRegisterOption, oidcOnly,
|
||||||
handleDemoLogin, handleSubmit,
|
handleDemoLogin, handleSubmit, handlePasskeyLogin,
|
||||||
} = useLogin()
|
} = useLogin()
|
||||||
|
|
||||||
|
const oidcButtonShown = !!(appConfig?.oidc_configured && appConfig?.oidc_login && !oidcOnly)
|
||||||
|
const passkeyAvailable = !!(appConfig?.passkey_login && appConfig?.passkey_configured && !oidcOnly
|
||||||
|
&& mode === 'login' && !mfaStep && !passwordChangeStep)
|
||||||
|
|
||||||
const inputBase: React.CSSProperties = {
|
const inputBase: React.CSSProperties = {
|
||||||
width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb',
|
width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb',
|
||||||
borderRadius: 12, fontSize: 14, fontFamily: 'inherit', outline: 'none',
|
borderRadius: 12, fontSize: 14, fontFamily: 'inherit', outline: 'none',
|
||||||
@@ -636,6 +640,36 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Passkey login button (instance toggle on + a usable RP ID resolves) */}
|
||||||
|
{passkeyAvailable && (
|
||||||
|
<>
|
||||||
|
{!oidcButtonShown && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}>
|
||||||
|
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
||||||
|
<span style={{ fontSize: 12, color: '#9ca3af' }}>{t('common.or')}</span>
|
||||||
|
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={handlePasskeyLogin} disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
marginTop: 12, width: '100%', padding: '12px',
|
||||||
|
background: 'white', color: '#374151',
|
||||||
|
border: '1px solid #d1d5db', borderRadius: 12,
|
||||||
|
fontSize: 14, fontWeight: 600, cursor: isLoading ? 'default' : 'pointer',
|
||||||
|
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
|
opacity: isLoading ? 0.7 : 1,
|
||||||
|
transition: 'background 180ms cubic-bezier(0.23,1,0.32,1), border-color 180ms cubic-bezier(0.23,1,0.32,1)',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { if (!isLoading) { e.currentTarget.style.background = '#f9fafb'; e.currentTarget.style.borderColor = '#9ca3af' } }}
|
||||||
|
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.background = 'white'; e.currentTarget.style.borderColor = '#d1d5db' }}
|
||||||
|
>
|
||||||
|
<Fingerprint size={16} />
|
||||||
|
{t('login.passkey.signIn')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Demo login button */}
|
{/* Demo login button */}
|
||||||
{appConfig?.demo_mode && (
|
{appConfig?.demo_mode && (
|
||||||
<button onClick={handleDemoLogin} disabled={isLoading}
|
<button onClick={handleDemoLogin} disabled={isLoading}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
|||||||
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
|
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
|
||||||
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
|
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
|
||||||
envOverrideOidcOnly, oidcConfigured, requireMfa,
|
envOverrideOidcOnly, oidcConfigured, requireMfa,
|
||||||
|
passkeyLogin, setPasskeyLogin, passkeyConfigured,
|
||||||
|
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
|
||||||
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
|
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
|
||||||
mapsKey, setMapsKey, showKeys, savingKeys, validating, validation,
|
mapsKey, setMapsKey, showKeys, savingKeys, validating, validation,
|
||||||
setShowRotateJwtModal,
|
setShowRotateJwtModal,
|
||||||
@@ -119,6 +121,71 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Passkey (WebAuthn) login */}
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-slate-100">
|
||||||
|
<h2 className="font-semibold text-slate-900">{t('admin.passkey.title')}</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{t('admin.passkey.cardHint')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700">{t('admin.passkey.login')}</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">{t('admin.passkey.loginHint')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggleAuthSetting('passkey_login', !passkeyLogin, setPasskeyLogin)}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${passkeyLogin ? 'bg-content' : 'bg-edge'}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||||
|
style={{ transform: passkeyLogin ? 'translateX(20px)' : 'translateX(0)' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{passkeyLogin && !passkeyConfigured && (
|
||||||
|
<p className="flex items-start gap-2 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||||
|
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
|
||||||
|
{t('admin.passkey.notConfigured')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">{t('admin.passkey.rpId')}</label>
|
||||||
|
<p className="text-xs text-slate-400 mb-1.5">{t('admin.passkey.rpIdHint')}</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={webauthnRpId}
|
||||||
|
onChange={e => setWebauthnRpId(e.target.value)}
|
||||||
|
placeholder="trek.example.org"
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">{t('admin.passkey.origins')}</label>
|
||||||
|
<p className="text-xs text-slate-400 mb-1.5">{t('admin.passkey.originsHint')}</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={webauthnOrigins}
|
||||||
|
onChange={e => setWebauthnOrigins(e.target.value)}
|
||||||
|
placeholder="https://trek.example.org"
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveWebauthn}
|
||||||
|
disabled={savingWebauthn}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{savingWebauthn ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Require 2FA for all users */}
|
{/* Require 2FA for all users */}
|
||||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-slate-100">
|
<div className="px-6 py-4 border-b border-slate-100">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react'
|
|||||||
import { adminApi } from '../../api/client'
|
import { adminApi } from '../../api/client'
|
||||||
import Modal from '../../components/shared/Modal'
|
import Modal from '../../components/shared/Modal'
|
||||||
import CustomSelect from '../../components/shared/CustomSelect'
|
import CustomSelect from '../../components/shared/CustomSelect'
|
||||||
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle } from 'lucide-react'
|
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint } from 'lucide-react'
|
||||||
import type { TranslationFn } from '../../types'
|
import type { TranslationFn } from '../../types'
|
||||||
import type { useAdmin } from './useAdmin'
|
import type { useAdmin } from './useAdmin'
|
||||||
|
|
||||||
@@ -157,6 +157,25 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="pt-3 border-t border-slate-100">
|
||||||
|
<p className="text-xs text-slate-400 mb-2">{t('admin.passkey.resetHint')}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!editingUser) return
|
||||||
|
if (!confirm(t('admin.passkey.resetConfirm', { name: editingUser.username }))) return
|
||||||
|
try {
|
||||||
|
const r = await adminApi.resetUserPasskeys(editingUser.id)
|
||||||
|
toast.success(t('admin.passkey.resetDone', { count: r.deleted ?? 0 }))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('common.error'))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Fingerprint size={14} /> {t('admin.passkey.reset')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ export function useAdmin() {
|
|||||||
const [oidcConfigured, setOidcConfigured] = useState<boolean>(false)
|
const [oidcConfigured, setOidcConfigured] = useState<boolean>(false)
|
||||||
const [requireMfa, setRequireMfa] = useState<boolean>(false)
|
const [requireMfa, setRequireMfa] = useState<boolean>(false)
|
||||||
|
|
||||||
|
// Passkey (WebAuthn) login
|
||||||
|
const [passkeyLogin, setPasskeyLogin] = useState<boolean>(false)
|
||||||
|
const [passkeyConfigured, setPasskeyConfigured] = useState<boolean>(false)
|
||||||
|
const [webauthnRpId, setWebauthnRpId] = useState<string>('')
|
||||||
|
const [webauthnOrigins, setWebauthnOrigins] = useState<string>('')
|
||||||
|
const [savingWebauthn, setSavingWebauthn] = useState<boolean>(false)
|
||||||
|
|
||||||
// Invite links
|
// Invite links
|
||||||
const [invites, setInvites] = useState<any[]>([])
|
const [invites, setInvites] = useState<any[]>([])
|
||||||
const [showCreateInvite, setShowCreateInvite] = useState<boolean>(false)
|
const [showCreateInvite, setShowCreateInvite] = useState<boolean>(false)
|
||||||
@@ -80,6 +87,8 @@ export function useAdmin() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiClient.get('/auth/app-settings').then(r => {
|
apiClient.get('/auth/app-settings').then(r => {
|
||||||
setSmtpValues(r.data || {})
|
setSmtpValues(r.data || {})
|
||||||
|
if (r.data?.webauthn_rp_id) setWebauthnRpId(r.data.webauthn_rp_id)
|
||||||
|
if (r.data?.webauthn_origins) setWebauthnOrigins(r.data.webauthn_origins)
|
||||||
setSmtpLoaded(true)
|
setSmtpLoaded(true)
|
||||||
}).catch(() => setSmtpLoaded(true))
|
}).catch(() => setSmtpLoaded(true))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -141,6 +150,8 @@ export function useAdmin() {
|
|||||||
setEnvOverrideOidcOnly(config.env_override_oidc_only ?? false)
|
setEnvOverrideOidcOnly(config.env_override_oidc_only ?? false)
|
||||||
setOidcConfigured(config.oidc_configured ?? false)
|
setOidcConfigured(config.oidc_configured ?? false)
|
||||||
if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa)
|
if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa)
|
||||||
|
setPasskeyLogin(!!config.passkey_login)
|
||||||
|
setPasskeyConfigured(!!config.passkey_configured)
|
||||||
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -179,6 +190,23 @@ export function useAdmin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSaveWebauthn = async () => {
|
||||||
|
setSavingWebauthn(true)
|
||||||
|
try {
|
||||||
|
await authApi.updateAppSettings({
|
||||||
|
webauthn_rp_id: webauthnRpId.trim(),
|
||||||
|
webauthn_origins: webauthnOrigins.trim(),
|
||||||
|
})
|
||||||
|
// Re-read app-config so passkey_configured reflects the new RP ID.
|
||||||
|
await loadAppConfig()
|
||||||
|
toast.success(t('common.saved'))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||||
|
} finally {
|
||||||
|
setSavingWebauthn(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleKey = (key) => {
|
const toggleKey = (key) => {
|
||||||
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
|
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
|
||||||
}
|
}
|
||||||
@@ -341,6 +369,8 @@ export function useAdmin() {
|
|||||||
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
|
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
|
||||||
envOverrideOidcOnly, setEnvOverrideOidcOnly, oidcConfigured, setOidcConfigured,
|
envOverrideOidcOnly, setEnvOverrideOidcOnly, oidcConfigured, setOidcConfigured,
|
||||||
requireMfa, setRequireMfa,
|
requireMfa, setRequireMfa,
|
||||||
|
passkeyLogin, setPasskeyLogin, passkeyConfigured,
|
||||||
|
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
|
||||||
invites, setInvites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
|
invites, setInvites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
|
||||||
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
|
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
|
||||||
smtpValues, setSmtpValues, smtpLoaded,
|
smtpValues, setSmtpValues, smtpLoaded,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate, useLocation } from 'react-router-dom'
|
|||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useSettingsStore, hasStoredLanguage } from '../../store/settingsStore'
|
import { useSettingsStore, hasStoredLanguage } from '../../store/settingsStore'
|
||||||
import { useTranslation, detectBrowserLanguage } from '../../i18n'
|
import { useTranslation, detectBrowserLanguage } from '../../i18n'
|
||||||
|
import { startAuthentication } from '@simplewebauthn/browser'
|
||||||
import { authApi, configApi } from '../../api/client'
|
import { authApi, configApi } from '../../api/client'
|
||||||
import { getApiErrorMessage } from '../../types'
|
import { getApiErrorMessage } from '../../types'
|
||||||
|
|
||||||
@@ -18,6 +19,8 @@ interface AppConfig {
|
|||||||
password_registration: boolean
|
password_registration: boolean
|
||||||
oidc_login: boolean
|
oidc_login: boolean
|
||||||
oidc_registration: boolean
|
oidc_registration: boolean
|
||||||
|
passkey_login?: boolean
|
||||||
|
passkey_configured?: boolean
|
||||||
env_override_oidc_only: boolean
|
env_override_oidc_only: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +199,28 @@ export function useLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePasskeyLogin = async (): Promise<void> => {
|
||||||
|
setError('')
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const options = await authApi.passkey.loginOptions()
|
||||||
|
const assertion = await startAuthentication({ optionsJSON: options })
|
||||||
|
await authApi.passkey.loginVerify(assertion)
|
||||||
|
await loadUser({ silent: true })
|
||||||
|
setShowTakeoff(true)
|
||||||
|
setTimeout(() => navigate(redirectTarget), 2600)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// The user dismissing the native prompt isn't an error worth surfacing.
|
||||||
|
const name = (err as { name?: string })?.name
|
||||||
|
if (name === 'NotAllowedError' || name === 'AbortError') {
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(getApiErrorMessage(err, t('login.passkey.failed')))
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
@@ -270,6 +295,6 @@ export function useLogin() {
|
|||||||
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
||||||
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
|
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
|
||||||
noRedirect, showRegisterOption, oidcOnly,
|
noRedirect, showRegisterOption, oidcOnly,
|
||||||
handleDemoLogin, handleSubmit,
|
handleDemoLogin, handleSubmit, handlePasskeyLogin,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+257
@@ -29,6 +29,7 @@
|
|||||||
"@fontsource/geist-sans": "^5.2.5",
|
"@fontsource/geist-sans": "^5.2.5",
|
||||||
"@fontsource/poppins": "^5.2.7",
|
"@fontsource/poppins": "^5.2.7",
|
||||||
"@react-pdf/renderer": "^4.5.1",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
|
"@simplewebauthn/browser": "^13.1.2",
|
||||||
"@trek/shared": "*",
|
"@trek/shared": "*",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"dexie": "^4.4.2",
|
"dexie": "^4.4.2",
|
||||||
@@ -2525,6 +2526,12 @@
|
|||||||
"url": "https://github.com/sponsors/ayuhito"
|
"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": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.14",
|
"version": "1.19.14",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -3656,6 +3663,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@lukeed/csprng": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -4490,6 +4503,174 @@
|
|||||||
"@noble/hashes": "^1.1.5"
|
"@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": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -5179,6 +5360,31 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@swc/core": {
|
||||||
"version": "1.15.40",
|
"version": "1.15.40",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -6442,6 +6648,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -12765,6 +12985,24 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/qrcode": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -15445,6 +15683,24 @@
|
|||||||
"@esbuild/win32-x64": "0.28.0"
|
"@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": {
|
"node_modules/tunnel-agent": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
@@ -17346,6 +17602,7 @@
|
|||||||
"@nestjs/common": "^11.1.24",
|
"@nestjs/common": "^11.1.24",
|
||||||
"@nestjs/core": "^11.1.24",
|
"@nestjs/core": "^11.1.24",
|
||||||
"@nestjs/platform-express": "^11.1.24",
|
"@nestjs/platform-express": "^11.1.24",
|
||||||
|
"@simplewebauthn/server": "^13.1.2",
|
||||||
"@trek/shared": "*",
|
"@trek/shared": "*",
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"@nestjs/common": "^11.1.24",
|
"@nestjs/common": "^11.1.24",
|
||||||
"@nestjs/core": "^11.1.24",
|
"@nestjs/core": "^11.1.24",
|
||||||
"@nestjs/platform-express": "^11.1.24",
|
"@nestjs/platform-express": "^11.1.24",
|
||||||
|
"@simplewebauthn/server": "^13.1.2",
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
|
|||||||
@@ -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'",
|
"UPDATE addons SET name = 'Costs', description = 'Track and split trip expenses' WHERE id = 'budget' AND name = 'Budget Planner'",
|
||||||
).run();
|
).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) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -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_user ON password_reset_tokens(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash);
|
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 (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export function isPublicApiPath(method: string, pathNoQuery: string): boolean {
|
|||||||
if (method === 'POST' && pathNoQuery === '/api/auth/demo-login') return true;
|
if (method === 'POST' && pathNoQuery === '/api/auth/demo-login') return true;
|
||||||
if (method === 'GET' && pathNoQuery.startsWith('/api/auth/invite/')) return true;
|
if (method === 'GET' && pathNoQuery.startsWith('/api/auth/invite/')) return true;
|
||||||
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/verify-login') 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;
|
if (pathNoQuery.startsWith('/api/auth/oidc/')) return true;
|
||||||
return false;
|
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 === 'GET' && pathNoQuery === '/api/auth/me') return true;
|
||||||
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/setup') return true;
|
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/setup') return true;
|
||||||
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/enable') 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;
|
if ((method === 'GET' || method === 'PUT') && pathNoQuery === '/api/auth/app-settings') return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -81,8 +89,12 @@ export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFu
|
|||||||
return;
|
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;
|
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();
|
next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ export class AdminController {
|
|||||||
return { success: true };
|
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 ──
|
// ── Stats / permissions / audit ──
|
||||||
@Get('stats')
|
@Get('stats')
|
||||||
stats() { return this.admin.getStats(); }
|
stats() { return this.admin.getStats(); }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as svc from '../../services/adminService';
|
|||||||
import { getAdminUserDefaults, setAdminUserDefaults } from '../../services/settingsService';
|
import { getAdminUserDefaults, setAdminUserDefaults } from '../../services/settingsService';
|
||||||
import { invalidateMcpSessions } from '../../mcp';
|
import { invalidateMcpSessions } from '../../mcp';
|
||||||
import { getPreferencesMatrix, setAdminPreferences } from '../../services/notificationPreferencesService';
|
import { getPreferencesMatrix, setAdminPreferences } from '../../services/notificationPreferencesService';
|
||||||
|
import { adminResetPasskeys } from '../../services/passkeyService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin Nest wrapper around the existing admin service (+ the settings,
|
* 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<typeof svc.createUser>[0]); }
|
createUser(body: unknown) { return svc.createUser(body as Parameters<typeof svc.createUser>[0]); }
|
||||||
updateUser(id: string, body: unknown) { return svc.updateUser(id, body as Parameters<typeof svc.updateUser>[1]); }
|
updateUser(id: string, body: unknown) { return svc.updateUser(id, body as Parameters<typeof svc.updateUser>[1]); }
|
||||||
deleteUser(id: string, actingUserId: number) { return svc.deleteUser(id, actingUserId); }
|
deleteUser(id: string, actingUserId: number) { return svc.deleteUser(id, actingUserId); }
|
||||||
|
resetUserPasskeys(id: string) { return adminResetPasskeys(Number(id)); }
|
||||||
|
|
||||||
getStats() { return svc.getStats(); }
|
getStats() { return svc.getStats(); }
|
||||||
getPermissions() { return svc.getPermissions(); }
|
getPermissions() { return svc.getPermissions(); }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthPublicController } from './auth-public.controller';
|
import { AuthPublicController } from './auth-public.controller';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
|
import { PasskeyController } from './passkey.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RateLimitService } from './rate-limit.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.
|
* sub-paths explicitly rather than claiming all of /api/auth.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AuthPublicController, AuthController],
|
controllers: [AuthPublicController, AuthController, PasskeyController],
|
||||||
providers: [AuthService, RateLimitService],
|
providers: [AuthService, RateLimitService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof passkey.passkeyRegisterVerify>[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<typeof passkey.passkeyLoginVerify>[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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { verifyJwtAndLoadUser } from '../middleware/auth';
|
|||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
|
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
|
||||||
import { avatarUrl } from './avatarUrl';
|
import { avatarUrl } from './avatarUrl';
|
||||||
|
import { isPasskeyConfigured } from './webauthnConfig';
|
||||||
|
|
||||||
export { avatarUrl };
|
export { avatarUrl };
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ const ADMIN_SETTINGS_KEYS = [
|
|||||||
'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token',
|
'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token',
|
||||||
'notify_trip_reminder',
|
'notify_trip_reminder',
|
||||||
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
|
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
|
||||||
|
'passkey_login', 'webauthn_rp_id', 'webauthn_origins',
|
||||||
];
|
];
|
||||||
|
|
||||||
const avatarDir = path.join(__dirname, '../../uploads/avatars');
|
const avatarDir = path.join(__dirname, '../../uploads/avatars');
|
||||||
@@ -128,10 +130,17 @@ export function resolveAuthToggles(): {
|
|||||||
password_registration: boolean;
|
password_registration: boolean;
|
||||||
oidc_login: boolean;
|
oidc_login: boolean;
|
||||||
oidc_registration: boolean;
|
oidc_registration: boolean;
|
||||||
|
passkey_login: boolean;
|
||||||
} {
|
} {
|
||||||
const get = (key: string) =>
|
const get = (key: string) =>
|
||||||
(db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value ?? null;
|
(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']
|
const hasNewKeys = ['password_login', 'password_registration', 'oidc_login', 'oidc_registration']
|
||||||
.some(k => get(k) !== null);
|
.some(k => get(k) !== null);
|
||||||
|
|
||||||
@@ -141,6 +150,7 @@ export function resolveAuthToggles(): {
|
|||||||
password_registration: get('password_registration') !== 'false',
|
password_registration: get('password_registration') !== 'false',
|
||||||
oidc_login: get('oidc_login') !== 'false',
|
oidc_login: get('oidc_login') !== 'false',
|
||||||
oidc_registration: get('oidc_registration') !== 'false',
|
oidc_registration: get('oidc_registration') !== 'false',
|
||||||
|
passkey_login,
|
||||||
};
|
};
|
||||||
if (process.env.OIDC_ONLY?.toLowerCase() === 'true') {
|
if (process.env.OIDC_ONLY?.toLowerCase() === 'true') {
|
||||||
result.password_login = false;
|
result.password_login = false;
|
||||||
@@ -163,6 +173,7 @@ export function resolveAuthToggles(): {
|
|||||||
password_registration: !oidcOnly && allowReg,
|
password_registration: !oidcOnly && allowReg,
|
||||||
oidc_login: true,
|
oidc_login: true,
|
||||||
oidc_registration: allowReg,
|
oidc_registration: allowReg,
|
||||||
|
passkey_login,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +310,12 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
|||||||
password_registration: isDemo ? false : toggles.password_registration,
|
password_registration: isDemo ? false : toggles.password_registration,
|
||||||
oidc_login: toggles.oidc_login,
|
oidc_login: toggles.oidc_login,
|
||||||
oidc_registration: isDemo ? false : toggles.oidc_registration,
|
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',
|
env_override_oidc_only: process.env.OIDC_ONLY === 'true',
|
||||||
has_users: userCount > 0,
|
has_users: userCount > 0,
|
||||||
setup_complete: setupComplete,
|
setup_complete: setupComplete,
|
||||||
@@ -812,9 +829,12 @@ export function updateAppSettings(
|
|||||||
const { require_mfa } = body;
|
const { require_mfa } = body;
|
||||||
if (require_mfa === true || require_mfa === 'true') {
|
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;
|
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 {
|
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,
|
status: 400,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ReturnType<typeof generateRegistrationOptions>> }> {
|
||||||
|
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<typeof verifyRegistrationResponse>[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<string, unknown>;
|
||||||
|
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<ReturnType<typeof generateAuthenticationOptions>>;
|
||||||
|
}> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<typeof verifyAuthenticationResponse>[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<string, unknown>;
|
||||||
|
return { token, user: { ...userSafe, avatar_url: avatarUrl(user) }, auditUserId: Number(user.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Management (authenticated, owner-scoped)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function listPasskeys(userId: number): Array<Record<string, unknown>> {
|
||||||
|
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<string, unknown>>;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, string>(),
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -341,5 +341,24 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': 'Journey', // en-fallback
|
'admin.addons.catalog.journey.name': 'Journey', // en-fallback
|
||||||
'admin.addons.catalog.journey.description':
|
'admin.addons.catalog.journey.description':
|
||||||
'Trip tracking & travel journal with check-ins, photos, and daily stories', // en-fallback
|
'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;
|
export default admin;
|
||||||
|
|||||||
@@ -88,5 +88,7 @@ const login: TranslationStrings = {
|
|||||||
'هذا الرابط مفقود أو تالف. اطلب رابطًا جديدًا للمتابعة.',
|
'هذا الرابط مفقود أو تالف. اطلب رابطًا جديدًا للمتابعة.',
|
||||||
'login.resetPasswordFailed': 'فشلت إعادة التعيين. ربما انتهت صلاحية الرابط.',
|
'login.resetPasswordFailed': 'فشلت إعادة التعيين. ربما انتهت صلاحية الرابط.',
|
||||||
'login.emailPlaceholder': 'your@email.com', // en-fallback
|
'login.emailPlaceholder': 'your@email.com', // en-fallback
|
||||||
|
'login.passkey.signIn': 'تسجيل الدخول باستخدام مفتاح المرور',
|
||||||
|
'login.passkey.failed': 'فشل تسجيل الدخول بمفتاح المرور. يرجى المحاولة مرة أخرى.',
|
||||||
};
|
};
|
||||||
export default login;
|
export default login;
|
||||||
|
|||||||
@@ -290,6 +290,30 @@ const settings: TranslationStrings = {
|
|||||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate', // en-fallback
|
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate', // en-fallback
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this 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;
|
export default settings;
|
||||||
|
|||||||
@@ -363,5 +363,24 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': 'Jornada',
|
'admin.addons.catalog.journey.name': 'Jornada',
|
||||||
'admin.addons.catalog.journey.description':
|
'admin.addons.catalog.journey.description':
|
||||||
'Rastreamento de viagens e diário de viajante com check-ins, fotos e histórias diárias',
|
'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;
|
export default admin;
|
||||||
|
|||||||
@@ -91,5 +91,8 @@ const login: TranslationStrings = {
|
|||||||
'Este link está ausente ou corrompido. Solicite um novo para continuar.',
|
'Este link está ausente ou corrompido. Solicite um novo para continuar.',
|
||||||
'login.resetPasswordFailed':
|
'login.resetPasswordFailed':
|
||||||
'Falha na redefinição. O link pode ter expirado.',
|
'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;
|
export default login;
|
||||||
|
|||||||
@@ -296,6 +296,30 @@ const settings: TranslationStrings = {
|
|||||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this 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;
|
export default settings;
|
||||||
|
|||||||
@@ -358,5 +358,24 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': 'Cestovní deník',
|
'admin.addons.catalog.journey.name': 'Cestovní deník',
|
||||||
'admin.addons.catalog.journey.description':
|
'admin.addons.catalog.journey.description':
|
||||||
'Sledování cest a cestovní deník s odbaveními, fotkami a denními příběhy',
|
'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;
|
export default admin;
|
||||||
|
|||||||
@@ -91,5 +91,8 @@ const login: TranslationStrings = {
|
|||||||
'login.resetPasswordInvalidLinkBody':
|
'login.resetPasswordInvalidLinkBody':
|
||||||
'Odkaz chybí nebo je poškozený. Pro pokračování si vyžádej nový.',
|
'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.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;
|
export default login;
|
||||||
|
|||||||
@@ -297,6 +297,30 @@ const settings: TranslationStrings = {
|
|||||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this 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;
|
export default settings;
|
||||||
|
|||||||
@@ -361,5 +361,24 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': 'Journey',
|
'admin.addons.catalog.journey.name': 'Journey',
|
||||||
'admin.addons.catalog.journey.description':
|
'admin.addons.catalog.journey.description':
|
||||||
'Reise-Tracking & Tagebuch mit Check-ins, Fotos und Tagesberichten',
|
'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;
|
export default admin;
|
||||||
|
|||||||
@@ -94,5 +94,8 @@ const login: TranslationStrings = {
|
|||||||
'Dieser Link fehlt oder ist beschädigt. Fordere einen neuen an, um fortzufahren.',
|
'Dieser Link fehlt oder ist beschädigt. Fordere einen neuen an, um fortzufahren.',
|
||||||
'login.resetPasswordFailed':
|
'login.resetPasswordFailed':
|
||||||
'Zurücksetzen fehlgeschlagen. Der Link ist möglicherweise abgelaufen.',
|
'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;
|
export default login;
|
||||||
|
|||||||
@@ -300,6 +300,30 @@ const settings: TranslationStrings = {
|
|||||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
"settings.currency": "Währung",
|
"settings.currency": "Währung",
|
||||||
"settings.currencyHint": "Alle Beträge in Costs werden in diese Währung umgerechnet und angezeigt.",
|
"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;
|
export default settings;
|
||||||
|
|||||||
@@ -354,5 +354,23 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': 'Journey',
|
'admin.addons.catalog.journey.name': 'Journey',
|
||||||
'admin.addons.catalog.journey.description':
|
'admin.addons.catalog.journey.description':
|
||||||
'Trip tracking & travel journal with check-ins, photos, and daily stories',
|
'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;
|
export default admin;
|
||||||
|
|||||||
@@ -91,5 +91,7 @@ const login: TranslationStrings = {
|
|||||||
'login.resetPasswordInvalidLinkBody':
|
'login.resetPasswordInvalidLinkBody':
|
||||||
'This link is missing or broken. Request a new one to continue.',
|
'This link is missing or broken. Request a new one to continue.',
|
||||||
'login.resetPasswordFailed': 'Reset failed. The link may have expired.',
|
'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;
|
export default login;
|
||||||
|
|||||||
@@ -290,6 +290,28 @@ const settings: TranslationStrings = {
|
|||||||
'settings.mfa.demoBlocked': 'Not available in demo mode',
|
'settings.mfa.demoBlocked': 'Not available in demo mode',
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this 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;
|
export default settings;
|
||||||
|
|||||||
@@ -369,5 +369,24 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': 'Travesía',
|
'admin.addons.catalog.journey.name': 'Travesía',
|
||||||
'admin.addons.catalog.journey.description':
|
'admin.addons.catalog.journey.description':
|
||||||
'Seguimiento de viajes y diario de viajero con registros de ubicación, fotos e historias diarias',
|
'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;
|
export default admin;
|
||||||
|
|||||||
@@ -95,5 +95,8 @@ const login: TranslationStrings = {
|
|||||||
'La autenticación por contraseña está desactivada. Por favor, inicia sesión con tu proveedor SSO.',
|
'La autenticación por contraseña está desactivada. Por favor, inicia sesión con tu proveedor SSO.',
|
||||||
'login.oidcLoggedOut':
|
'login.oidcLoggedOut':
|
||||||
'Has cerrado sesión. Vuelve a iniciar sesión con tu proveedor SSO.',
|
'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;
|
export default login;
|
||||||
|
|||||||
@@ -297,6 +297,30 @@ const settings: TranslationStrings = {
|
|||||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this 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;
|
export default settings;
|
||||||
|
|||||||
@@ -369,5 +369,24 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': 'Journal de voyage',
|
'admin.addons.catalog.journey.name': 'Journal de voyage',
|
||||||
'admin.addons.catalog.journey.description':
|
'admin.addons.catalog.journey.description':
|
||||||
'Suivi de voyages et journal avec check-ins, photos et récits quotidiens',
|
'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;
|
export default admin;
|
||||||
|
|||||||
@@ -98,5 +98,8 @@ const login: TranslationStrings = {
|
|||||||
'login.oidcLoggedOut':
|
'login.oidcLoggedOut':
|
||||||
'Vous avez été déconnecté. Reconnectez-vous via votre fournisseur SSO.',
|
'Vous avez été déconnecté. Reconnectez-vous via votre fournisseur SSO.',
|
||||||
'login.demoHint': 'Essayez la démo — aucune inscription nécessaire',
|
'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;
|
export default login;
|
||||||
|
|||||||
@@ -301,6 +301,30 @@ const settings: TranslationStrings = {
|
|||||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this 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;
|
export default settings;
|
||||||
|
|||||||
@@ -370,5 +370,24 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': 'Ταξίδι',
|
'admin.addons.catalog.journey.name': 'Ταξίδι',
|
||||||
'admin.addons.catalog.journey.description':
|
'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;
|
export default admin;
|
||||||
|
|||||||
@@ -99,5 +99,8 @@ const login: TranslationStrings = {
|
|||||||
'Αυτός ο σύνδεσμος λείπει ή έχει χαλάσει. Ζητήστε έναν νέο για να συνεχίσετε.',
|
'Αυτός ο σύνδεσμος λείπει ή έχει χαλάσει. Ζητήστε έναν νέο για να συνεχίσετε.',
|
||||||
'login.resetPasswordFailed':
|
'login.resetPasswordFailed':
|
||||||
'Η επαναφορά απέτυχε. Ο σύνδεσμος μπορεί να έχει λήξει.',
|
'Η επαναφορά απέτυχε. Ο σύνδεσμος μπορεί να έχει λήξει.',
|
||||||
|
'login.passkey.signIn': 'Σύνδεση με passkey',
|
||||||
|
'login.passkey.failed':
|
||||||
|
'Η σύνδεση με passkey απέτυχε. Παρακαλώ δοκιμάστε ξανά.',
|
||||||
};
|
};
|
||||||
export default login;
|
export default login;
|
||||||
|
|||||||
@@ -303,6 +303,30 @@ const settings: TranslationStrings = {
|
|||||||
'settings.mfa.demoBlocked': 'Δεν είναι διαθέσιμο σε λειτουργία demo',
|
'settings.mfa.demoBlocked': 'Δεν είναι διαθέσιμο σε λειτουργία demo',
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this 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;
|
export default settings;
|
||||||
|
|||||||
@@ -362,5 +362,24 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': 'Útinaplók',
|
'admin.addons.catalog.journey.name': 'Útinaplók',
|
||||||
'admin.addons.catalog.journey.description':
|
'admin.addons.catalog.journey.description':
|
||||||
'Utazáskövetés és útinapló bejelentkezésekkel, fotókkal és napi történetekkel',
|
'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;
|
export default admin;
|
||||||
|
|||||||
@@ -98,5 +98,8 @@ const login: TranslationStrings = {
|
|||||||
'A link hiányzik vagy sérült. A folytatáshoz kérj egy újat.',
|
'A link hiányzik vagy sérült. A folytatáshoz kérj egy újat.',
|
||||||
'login.resetPasswordFailed':
|
'login.resetPasswordFailed':
|
||||||
'A visszaállítás nem sikerült. A link lehet, hogy lejárt.',
|
'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;
|
export default login;
|
||||||
|
|||||||
@@ -299,6 +299,30 @@ const settings: TranslationStrings = {
|
|||||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this 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;
|
export default settings;
|
||||||
|
|||||||
@@ -357,5 +357,24 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': 'Journey',
|
'admin.addons.catalog.journey.name': 'Journey',
|
||||||
'admin.addons.catalog.journey.description':
|
'admin.addons.catalog.journey.description':
|
||||||
'Pelacakan perjalanan & jurnal dengan check-in, foto, dan cerita harian',
|
'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;
|
export default admin;
|
||||||
|
|||||||
@@ -93,5 +93,7 @@ const login: TranslationStrings = {
|
|||||||
'login.resetPasswordInvalidLinkBody':
|
'login.resetPasswordInvalidLinkBody':
|
||||||
'Tautan hilang atau rusak. Minta tautan baru untuk melanjutkan.',
|
'Tautan hilang atau rusak. Minta tautan baru untuk melanjutkan.',
|
||||||
'login.resetPasswordFailed': 'Reset gagal. Tautan mungkin sudah kedaluwarsa.',
|
'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;
|
export default login;
|
||||||
|
|||||||
@@ -297,6 +297,30 @@ const settings: TranslationStrings = {
|
|||||||
'Menampilkan nama stasiun / bandara di peta. Jika mati, hanya ikon ditampilkan.',
|
'Menampilkan nama stasiun / bandara di peta. Jika mati, hanya ikon ditampilkan.',
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this 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;
|
export default settings;
|
||||||
|
|||||||
@@ -365,5 +365,24 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': 'Diario di viaggio',
|
'admin.addons.catalog.journey.name': 'Diario di viaggio',
|
||||||
'admin.addons.catalog.journey.description':
|
'admin.addons.catalog.journey.description':
|
||||||
'Tracciamento viaggi e diario con check-in, foto e storie quotidiane',
|
'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;
|
export default admin;
|
||||||
|
|||||||
@@ -92,5 +92,7 @@ const login: TranslationStrings = {
|
|||||||
'Il link è mancante o danneggiato. Richiedine uno nuovo per continuare.',
|
'Il link è mancante o danneggiato. Richiedine uno nuovo per continuare.',
|
||||||
'login.resetPasswordFailed':
|
'login.resetPasswordFailed':
|
||||||
'Reset non riuscito. Il link potrebbe essere scaduto.',
|
'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;
|
export default login;
|
||||||
|
|||||||
@@ -296,6 +296,30 @@ const settings: TranslationStrings = {
|
|||||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this 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;
|
export default settings;
|
||||||
|
|||||||
@@ -336,5 +336,24 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': '日記',
|
'admin.addons.catalog.journey.name': '日記',
|
||||||
'admin.addons.catalog.journey.description':
|
'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;
|
export default admin;
|
||||||
|
|||||||
@@ -91,5 +91,8 @@ const login: TranslationStrings = {
|
|||||||
'リンクが無効または破損しています。新しいリンクをリクエストしてください。',
|
'リンクが無効または破損しています。新しいリンクをリクエストしてください。',
|
||||||
'login.resetPasswordFailed':
|
'login.resetPasswordFailed':
|
||||||
'リセットに失敗しました。リンクの有効期限が切れている可能性があります。',
|
'リセットに失敗しました。リンクの有効期限が切れている可能性があります。',
|
||||||
|
'login.passkey.signIn': 'パスキーでサインイン',
|
||||||
|
'login.passkey.failed':
|
||||||
|
'パスキーでのサインインに失敗しました。もう一度お試しください。',
|
||||||
};
|
};
|
||||||
export default login;
|
export default login;
|
||||||
|
|||||||
@@ -276,6 +276,30 @@ const settings: TranslationStrings = {
|
|||||||
'settings.oauth.badge.machine': 'マシン',
|
'settings.oauth.badge.machine': 'マシン',
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this 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;
|
export default settings;
|
||||||
|
|||||||
@@ -349,5 +349,24 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': 'Journey',
|
'admin.addons.catalog.journey.name': 'Journey',
|
||||||
'admin.addons.catalog.journey.description':
|
'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;
|
export default admin;
|
||||||
|
|||||||
@@ -89,5 +89,7 @@ const login: TranslationStrings = {
|
|||||||
'login.resetPasswordInvalidLinkBody':
|
'login.resetPasswordInvalidLinkBody':
|
||||||
'이 링크가 없거나 손상되었습니다. 새 링크를 요청하세요.',
|
'이 링크가 없거나 손상되었습니다. 새 링크를 요청하세요.',
|
||||||
'login.resetPasswordFailed': '재설정 실패. 링크가 만료되었을 수 있습니다.',
|
'login.resetPasswordFailed': '재설정 실패. 링크가 만료되었을 수 있습니다.',
|
||||||
|
'login.passkey.signIn': '패스키로 로그인',
|
||||||
|
'login.passkey.failed': '패스키 로그인에 실패했습니다. 다시 시도하세요.',
|
||||||
};
|
};
|
||||||
export default login;
|
export default login;
|
||||||
|
|||||||
@@ -293,6 +293,30 @@ const settings: TranslationStrings = {
|
|||||||
'settings.oauth.badge.machine': '머신',
|
'settings.oauth.badge.machine': '머신',
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this 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;
|
export default settings;
|
||||||
|
|||||||
@@ -360,5 +360,24 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': 'Reisverslag',
|
'admin.addons.catalog.journey.name': 'Reisverslag',
|
||||||
'admin.addons.catalog.journey.description':
|
'admin.addons.catalog.journey.description':
|
||||||
"Reistracking & reisdagboek met check-ins, foto's en dagelijkse verhalen",
|
"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;
|
export default admin;
|
||||||
|
|||||||
@@ -94,5 +94,7 @@ const login: TranslationStrings = {
|
|||||||
'login.oidcLoggedOut':
|
'login.oidcLoggedOut':
|
||||||
'Je bent uitgelogd. Log opnieuw in via je SSO-provider.',
|
'Je bent uitgelogd. Log opnieuw in via je SSO-provider.',
|
||||||
'login.demoHint': 'Probeer de demo — geen registratie nodig',
|
'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;
|
export default login;
|
||||||
|
|||||||
@@ -296,6 +296,30 @@ const settings: TranslationStrings = {
|
|||||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this 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;
|
export default settings;
|
||||||
|
|||||||
@@ -362,5 +362,24 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': 'Dziennik podróży',
|
'admin.addons.catalog.journey.name': 'Dziennik podróży',
|
||||||
'admin.addons.catalog.journey.description':
|
'admin.addons.catalog.journey.description':
|
||||||
'Śledzenie podróży i dziennik z zameldowaniami, zdjęciami i codziennymi historiami',
|
'Ś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;
|
export default admin;
|
||||||
|
|||||||
@@ -94,5 +94,8 @@ const login: TranslationStrings = {
|
|||||||
'login.resetPasswordFailed': 'Reset nie powiódł się. Link mógł wygasnąć.',
|
'login.resetPasswordFailed': 'Reset nie powiódł się. Link mógł wygasnąć.',
|
||||||
'login.setNewPassword': 'Ustaw nowe hasło',
|
'login.setNewPassword': 'Ustaw nowe hasło',
|
||||||
'login.setNewPasswordHint': 'Musisz zmienić 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;
|
export default login;
|
||||||
|
|||||||
@@ -298,6 +298,30 @@ const settings: TranslationStrings = {
|
|||||||
'settings.mustChangePassword': 'Musisz zmienić hasło przed kontynuowaniem.',
|
'settings.mustChangePassword': 'Musisz zmienić hasło przed kontynuowaniem.',
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this 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;
|
export default settings;
|
||||||
|
|||||||
@@ -366,5 +366,24 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': 'Путешествие',
|
'admin.addons.catalog.journey.name': 'Путешествие',
|
||||||
'admin.addons.catalog.journey.description':
|
'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;
|
export default admin;
|
||||||
|
|||||||
@@ -93,5 +93,7 @@ const login: TranslationStrings = {
|
|||||||
'login.oidcLoggedOut':
|
'login.oidcLoggedOut':
|
||||||
'Вы вышли из системы. Войдите снова через вашего провайдера SSO.',
|
'Вы вышли из системы. Войдите снова через вашего провайдера SSO.',
|
||||||
'login.demoHint': 'Попробуйте демо — регистрация не требуется',
|
'login.demoHint': 'Попробуйте демо — регистрация не требуется',
|
||||||
|
'login.passkey.signIn': 'Войти с помощью passkey',
|
||||||
|
'login.passkey.failed': 'Не удалось войти с помощью passkey. Попробуйте ещё раз.',
|
||||||
};
|
};
|
||||||
export default login;
|
export default login;
|
||||||
|
|||||||
@@ -297,6 +297,29 @@ const settings: TranslationStrings = {
|
|||||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this 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;
|
export default settings;
|
||||||
|
|||||||
@@ -363,5 +363,24 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': 'Seyahat',
|
'admin.addons.catalog.journey.name': 'Seyahat',
|
||||||
'admin.addons.catalog.journey.description':
|
'admin.addons.catalog.journey.description':
|
||||||
'Check-in, fotoğraf ve günlük hikâyelerle seyahat takibi ve seyahat günlüğü',
|
'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;
|
export default admin;
|
||||||
|
|||||||
@@ -96,5 +96,8 @@ const login: TranslationStrings = {
|
|||||||
'Bu bağlantı eksik veya bozuk. Devam etmek için yeni bir tane isteyin.',
|
'Bu bağlantı eksik veya bozuk. Devam etmek için yeni bir tane isteyin.',
|
||||||
'login.resetPasswordFailed':
|
'login.resetPasswordFailed':
|
||||||
'Sıfırlama başarısız oldu. Bağlantının süresi dolmuş olabilir.',
|
'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;
|
export default login;
|
||||||
|
|||||||
@@ -297,6 +297,30 @@ const settings: TranslationStrings = {
|
|||||||
'settings.oauth.badge.machine': 'makine',
|
'settings.oauth.badge.machine': 'makine',
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this 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;
|
export default settings;
|
||||||
|
|||||||
@@ -370,5 +370,24 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': 'Journey',
|
'admin.addons.catalog.journey.name': 'Journey',
|
||||||
'admin.addons.catalog.journey.description':
|
'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;
|
export default admin;
|
||||||
|
|||||||
@@ -94,5 +94,7 @@ const login: TranslationStrings = {
|
|||||||
'login.oidcLoggedOut':
|
'login.oidcLoggedOut':
|
||||||
'Ви вийшли з системи. Увійдіть знову через вашого SSO-провайдера.',
|
'Ви вийшли з системи. Увійдіть знову через вашого SSO-провайдера.',
|
||||||
'login.demoHint': 'Спробуйте демо — реєстрація не потрібна',
|
'login.demoHint': 'Спробуйте демо — реєстрація не потрібна',
|
||||||
|
'login.passkey.signIn': 'Увійти за допомогою passkey',
|
||||||
|
'login.passkey.failed': 'Не вдалося увійти за допомогою passkey. Спробуйте ще раз.',
|
||||||
};
|
};
|
||||||
export default login;
|
export default login;
|
||||||
|
|||||||
@@ -295,6 +295,30 @@ const settings: TranslationStrings = {
|
|||||||
'settings.oauth.badge.machine': 'машина',
|
'settings.oauth.badge.machine': 'машина',
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this 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;
|
export default settings;
|
||||||
|
|||||||
@@ -327,5 +327,23 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': '旅程',
|
'admin.addons.catalog.journey.name': '旅程',
|
||||||
'admin.addons.catalog.journey.description':
|
'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;
|
export default admin;
|
||||||
|
|||||||
@@ -84,5 +84,7 @@ const login: TranslationStrings = {
|
|||||||
'login.oidcOnly': '密碼登入已關閉。請透過 SSO 提供商登入。',
|
'login.oidcOnly': '密碼登入已關閉。請透過 SSO 提供商登入。',
|
||||||
'login.oidcLoggedOut': '您已登出。請重新透過 SSO 提供商登入。',
|
'login.oidcLoggedOut': '您已登出。請重新透過 SSO 提供商登入。',
|
||||||
'login.demoHint': '試用演示——無需註冊',
|
'login.demoHint': '試用演示——無需註冊',
|
||||||
|
'login.passkey.signIn': '使用 Passkey 登入',
|
||||||
|
'login.passkey.failed': 'Passkey 登入失敗,請重試。',
|
||||||
};
|
};
|
||||||
export default login;
|
export default login;
|
||||||
|
|||||||
@@ -284,6 +284,28 @@ const settings: TranslationStrings = {
|
|||||||
'在地圖上顯示車站 / 機場名稱。關閉時僅顯示圖示。',
|
'在地圖上顯示車站 / 機場名稱。關閉時僅顯示圖示。',
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this 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;
|
export default settings;
|
||||||
|
|||||||
@@ -326,5 +326,23 @@ const admin: TranslationStrings = {
|
|||||||
'admin.addons.catalog.journey.name': '旅程',
|
'admin.addons.catalog.journey.name': '旅程',
|
||||||
'admin.addons.catalog.journey.description':
|
'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;
|
export default admin;
|
||||||
|
|||||||
@@ -83,5 +83,7 @@ const login: TranslationStrings = {
|
|||||||
'login.oidcOnly': '密码登录已关闭。请通过 SSO 提供商登录。',
|
'login.oidcOnly': '密码登录已关闭。请通过 SSO 提供商登录。',
|
||||||
'login.oidcLoggedOut': '您已退出登录。请重新通过 SSO 提供商登录。',
|
'login.oidcLoggedOut': '您已退出登录。请重新通过 SSO 提供商登录。',
|
||||||
'login.demoHint': '试用演示——无需注册',
|
'login.demoHint': '试用演示——无需注册',
|
||||||
|
'login.passkey.signIn': '使用通行密钥登录',
|
||||||
|
'login.passkey.failed': '通行密钥登录失败,请重试。',
|
||||||
};
|
};
|
||||||
export default login;
|
export default login;
|
||||||
|
|||||||
@@ -283,6 +283,28 @@ const settings: TranslationStrings = {
|
|||||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this 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;
|
export default settings;
|
||||||
|
|||||||
Reference in New Issue
Block a user