mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #371 from MBombeck/release/v1.23.0
v1.23.0 — Account security and data sovereignty
This commit is contained in:
224 files changed
+16508
-4492
No files matched your search
@@ -2,6 +2,35 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.23.0] — 2026-06-27 — Account security and data sovereignty
|
||||
|
||||
A security release. It adds two-factor authentication, brings every sign-in and sensitive action under a stronger check, encrypts free-text health notes at rest, lets you download an encrypted copy of your record, and gathers export, deletion, and privacy controls into one place. Seven additive migrations (`0199`–`0205`) apply automatically on start; `0205` removes the retired bug-report tables.
|
||||
|
||||
### Added
|
||||
|
||||
- Two-factor authentication: an authenticator app (TOTP) or a hardware security key as a second factor, with one-time recovery codes — set up under Settings → Security.
|
||||
- Sign-in asks for the second factor when it is enabled; you can trust a device for 30 days to skip it, while the password is always still required.
|
||||
- Passkeys now show when they were last used and can be renamed, with a gentle prompt to add one.
|
||||
- An active-session list with sign-out-everywhere, and an alert when a new device signs in.
|
||||
- A security activity log of recent sign-ins, second-factor changes, and exports.
|
||||
- A passphrase-encrypted export, so a downloaded copy of your record is never plain text — keep the passphrase safe, there is no recovery.
|
||||
- A Data & Privacy page that gathers export, deletion, retention, encryption status, sessions, and activity in one place.
|
||||
- A check that warns when a chosen password has appeared in a known breach.
|
||||
- Admin controls to require a second factor and to review encryption coverage and key-rotation progress.
|
||||
|
||||
### Changed
|
||||
|
||||
- Free-text notes on measurements and mood entries are now encrypted at rest.
|
||||
- Sensitive actions — account deletion, data reset, encrypted export, disabling the second factor, and key rotation — require a fresh second-factor check.
|
||||
|
||||
### Removed
|
||||
|
||||
- The bug-report integration has been removed.
|
||||
|
||||
### Security
|
||||
|
||||
- A second-factor session is bound to the credential it was issued for; an API token can never satisfy a fresh-factor check or reach the admin surface.
|
||||
|
||||
## [1.22.1] — 2026-06-27 — Mobile and Coach polish
|
||||
|
||||
A reliability and quality-of-life patch focused on the mobile interface and the Coach.
|
||||
|
||||
+2
-2
@@ -41,8 +41,8 @@ intentional:
|
||||
- **`tags`** — domain groupings (`Auth`, `Measurements`, `Medications`,
|
||||
`Mood`, `Dashboard`, `Insights`, `Achievements`, `DoctorReport`,
|
||||
`Integrations`, `User`, `Tokens`, `Notifications`, `Onboarding`,
|
||||
`Health`, `Settings`, `Analytics`, `Export`, `AuditLog`, `Feedback`,
|
||||
`BugReport`, `Monitoring`, `Webhooks`, `Ingest`, `Admin`).
|
||||
`Health`, `Settings`, `Analytics`, `Export`, `AuditLog`,
|
||||
`Monitoring`, `Webhooks`, `Ingest`, `Admin`).
|
||||
- **`securitySchemes`**:
|
||||
- `bearerAuth` — long-lived `hlk_*` API tokens issued via `POST /api/tokens`.
|
||||
Required on `/api/ingest/medication`.
|
||||
|
||||
+1219
-5
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,34 @@
|
||||
# Require a second factor (operator)
|
||||
|
||||
The admin settings expose a **Require a second factor** policy — instance-wide
|
||||
under General settings, and as a per-user override on the user detail page. The
|
||||
effective requirement for an account is the OR of the per-user override and the
|
||||
instance-wide policy.
|
||||
|
||||
## What enforcement does — and does not — do
|
||||
|
||||
Enforcement is a **guidance nudge for web sessions, not a hard API wall.**
|
||||
|
||||
- When the policy is on, any account that signs in **on the web** without an
|
||||
active second factor is redirected to enrollment after authentication. It
|
||||
cannot reach the rest of the app until it enrolls.
|
||||
- Enforcement does **not** block direct API or native (Bearer-token) access for
|
||||
an account that has not yet enrolled. A token-authenticated client keeps
|
||||
working until the user enrolls a factor of their own accord.
|
||||
|
||||
This is deliberate. A hard API wall on a not-yet-enrolled account would lock a
|
||||
native client (or an automation using a long-lived token) out of its own data
|
||||
with no in-band way to enrol, and there is no server-side path to mint a second
|
||||
factor on the user's behalf. The step-up gates that protect destructive actions
|
||||
(MFA disable, recovery-code regeneration, account deletion, encrypted export)
|
||||
remain cookie-only and are unaffected by this policy — they apply to accounts
|
||||
that have actually enrolled a factor.
|
||||
|
||||
## Operator guidance
|
||||
|
||||
- Treat the policy as a strong prompt that drives adoption, not as a control
|
||||
that retroactively secures every transport.
|
||||
- To hard-gate a specific account's API access, the lever is the account
|
||||
itself (disable it, or rotate/revoke its tokens), not the MFA-required flag.
|
||||
- The redirect clears the moment the user completes enrollment; no operator
|
||||
action is needed afterwards.
|
||||
+239
-92
@@ -32,10 +32,10 @@
|
||||
"copy": "Kopieren",
|
||||
"copyDetails": "Details kopieren",
|
||||
"retry": "Erneut versuchen",
|
||||
"reportIssue": "Fehler melden",
|
||||
"showPassword": "Passwort anzeigen",
|
||||
"hidePassword": "Passwort verbergen",
|
||||
"learnMore": "Mehr erfahren"
|
||||
"learnMore": "Mehr erfahren",
|
||||
"dismiss": "Schließen"
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Gesundheitsbericht",
|
||||
@@ -204,7 +204,6 @@
|
||||
"insights": "Insights",
|
||||
"achievements": "Erfolge",
|
||||
"settings": "Einstellungen",
|
||||
"bugreport": "Bugreport",
|
||||
"admin": "Admin-Konsole",
|
||||
"logout": "Abmelden",
|
||||
"login": "Anmelden",
|
||||
@@ -263,7 +262,28 @@
|
||||
"passkeySetupLater": "Passkeys kannst du jederzeit in den Einstellungen einrichten.",
|
||||
"passwordMinLength": "Min. 12 Zeichen",
|
||||
"passwordTooShort": "Passwort muss mindestens {minLength} Zeichen lang sein.",
|
||||
"privacyPolicy": "Datenschutzerklärung"
|
||||
"privacyPolicy": "Datenschutzerklärung",
|
||||
"passwordBreached": "Dieses Passwort taucht in bekannten Datenlecks auf. Bitte wähle ein anderes.",
|
||||
"mfa": {
|
||||
"title": "Zwei-Faktor-Authentifizierung",
|
||||
"totpHint": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
|
||||
"recoveryHint": "Gib einen deiner gespeicherten Wiederherstellungscodes ein.",
|
||||
"securityKeyHint": "Verwende deinen registrierten Sicherheitsschlüssel, um fortzufahren.",
|
||||
"codeLabel": "Authentifizierungscode",
|
||||
"recoveryLabel": "Wiederherstellungscode",
|
||||
"verify": "Bestätigen",
|
||||
"verifyFailed": "Bestätigung fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"useSecurityKey": "Sicherheitsschlüssel verwenden",
|
||||
"useRecoveryCode": "Wiederherstellungscode verwenden",
|
||||
"useAuthenticator": "Authenticator-App verwenden",
|
||||
"backToLogin": "Zurück zur Anmeldung",
|
||||
"rememberDevice": "Diesem Gerät 30 Tage vertrauen"
|
||||
},
|
||||
"enrollMfa": {
|
||||
"title": "Zweiten Faktor einrichten",
|
||||
"body": "Dein Administrator verlangt einen zweiten Faktor für dieses Konto. Füge eine Authenticator-App oder einen Sicherheitsschlüssel hinzu, um fortzufahren.",
|
||||
"signOut": "Abmelden"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -4057,7 +4077,12 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Voll-Backup",
|
||||
"description": "Eine einzige JSON-Datei mit allen Daten — gleiches Format wie das wöchentliche Auto-Backup. Praktisch zum Selbst-Wiederherstellen via Admin-Upload."
|
||||
"description": "Eine einzige JSON-Datei mit allen Daten — gleiches Format wie das wöchentliche Auto-Backup. Praktisch zum Selbst-Wiederherstellen via Admin-Upload.",
|
||||
"encryptToggle": "Mit einer Passphrase verschlüsseln",
|
||||
"passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)",
|
||||
"passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.",
|
||||
"noRecoveryWarning": "Es gibt keine Wiederherstellung — geht diese Passphrase verloren, lässt sich das Archiv nicht mehr öffnen. Bewahre sie sicher auf.",
|
||||
"stepUpNeeded": "Bestätige deinen zweiten Faktor und versuche es erneut."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Zyklus",
|
||||
@@ -4197,6 +4222,15 @@
|
||||
"title": "Kanäle",
|
||||
"description": "Zustellung über Telegram, ntfy, Web-Push, Webhook und E-Mail.",
|
||||
"subtitle": "Wohin Erinnerungen und Hinweise zugestellt werden."
|
||||
},
|
||||
"security": {
|
||||
"title": "Sicherheit",
|
||||
"subtitle": "Zwei-Faktor-Authentifizierung, Wiederherstellungscodes und Passkeys."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Daten & Datenschutz",
|
||||
"subtitle": "Deine Daten, Aufbewahrung, Verschlüsselung und Kontosicherheit an einem Ort.",
|
||||
"description": "Export, Löschung, Aufbewahrung, Verschlüsselungsstatus, Sitzungen und Sicherheitsaktivität."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -4968,7 +5002,153 @@
|
||||
"polarDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
|
||||
"ouraDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
|
||||
"nightscoutDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
|
||||
"integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben."
|
||||
"integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben.",
|
||||
"security": {
|
||||
"sessionsTitle": "Aktive Sitzungen",
|
||||
"sessionsDescription": "Geräte und Browser, die aktuell bei deinem Konto angemeldet sind.",
|
||||
"sessionsLoadError": "Aktive Sitzungen konnten nicht geladen werden.",
|
||||
"sessionsEmpty": "Keine aktiven Sitzungen.",
|
||||
"currentSession": "Dieses Gerät",
|
||||
"lastActive": "Zuletzt aktiv {time}",
|
||||
"unknownLocation": "Unbekannter Ort",
|
||||
"revokeSession": "Abmelden",
|
||||
"revokeError": "Anfrage konnte nicht abgeschlossen werden. Bitte erneut versuchen.",
|
||||
"signOutEverywhere": "Überall sonst abmelden",
|
||||
"signOutEverywhereDone": "{count} andere Sitzung(en) abgemeldet.",
|
||||
"activityTitle": "Sicherheitsaktivität",
|
||||
"activityDescription": "Letzte Anmeldungen, Sicherheitsänderungen und Datenexporte deines Kontos.",
|
||||
"activityEmpty": "Noch keine Sicherheitsaktivität.",
|
||||
"activityLoadError": "Sicherheitsaktivität konnte nicht geladen werden.",
|
||||
"action": {
|
||||
"newDevice": "Anmeldung von neuem Gerät",
|
||||
"loginFailed": "Fehlgeschlagene Anmeldung",
|
||||
"login": "Anmeldung",
|
||||
"passwordChange": "Passwort geändert",
|
||||
"mfa": "Zwei-Faktor-Änderung",
|
||||
"sessionRevoke": "Sitzung abgemeldet",
|
||||
"token": "API-Token-Änderung",
|
||||
"accountDelete": "Konto gelöscht",
|
||||
"dataClear": "Daten zurückgesetzt",
|
||||
"export": "Datenexport",
|
||||
"other": "Sicherheitsereignis"
|
||||
},
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv",
|
||||
"stepUpRequired": "Melde dich aus Sicherheitsgründen erneut an, um deinen zweiten Faktor zu ändern.",
|
||||
"useRecoveryCode": "Stattdessen Wiederherstellungscode verwenden",
|
||||
"useAuthenticator": "Stattdessen Authenticator-App verwenden",
|
||||
"totp": {
|
||||
"title": "Authenticator-App",
|
||||
"description": "Verwende einen zeitbasierten Code aus einer Authenticator-App als zweiten Faktor.",
|
||||
"setUp": "Einrichten",
|
||||
"step1": "Scanne den QR-Code mit deiner Authenticator-App oder gib das Geheimnis manuell ein.",
|
||||
"step2": "Gib zur Bestätigung den 6-stelligen Code aus der App ein.",
|
||||
"qrAlt": "QR-Code zur Einrichtung der Authenticator-App",
|
||||
"qrUnavailable": "QR-Code nicht verfügbar",
|
||||
"manualEntry": "Oder gib dieses Geheimnis manuell ein",
|
||||
"enterCode": "Gib den 6-stelligen Code ein",
|
||||
"verify": "Bestätigen",
|
||||
"setupFailed": "Einrichtung konnte nicht gestartet werden. Bitte versuche es erneut.",
|
||||
"invalidCode": "Dieser Code ist ungültig. Bitte versuche es erneut.",
|
||||
"disable": "Deaktivieren",
|
||||
"disableTitle": "Zwei-Faktor-Authentifizierung deaktivieren",
|
||||
"disableConfirm": "Gib einen aktuellen Code ein, um deinen zweiten Faktor abzuschalten. Damit werden auch deine Wiederherstellungscodes gelöscht.",
|
||||
"currentCode": "Aktueller Code",
|
||||
"disableFailed": "Der zweite Faktor konnte nicht deaktiviert werden."
|
||||
},
|
||||
"recovery": {
|
||||
"saveNow": "Speichere diese Wiederherstellungscodes jetzt",
|
||||
"saveNowHint": "Jeder Code funktioniert einmal. Sie werden nur dieses eine Mal angezeigt — bewahre sie sicher auf. Wenn du deine Authenticator-App verlierst, ist ein Wiederherstellungscode der einzige Weg zurück.",
|
||||
"download": "Herunterladen",
|
||||
"remainingOne": "{count} Wiederherstellungscode übrig.",
|
||||
"remainingOther": "{count} Wiederherstellungscodes übrig.",
|
||||
"regenerate": "Codes neu erzeugen",
|
||||
"regenFailed": "Wiederherstellungscodes konnten nicht neu erzeugt werden.",
|
||||
"savedIt": "Ich habe sie gespeichert",
|
||||
"regenerateTitle": "Wiederherstellungscodes neu erzeugen?",
|
||||
"regenerateConfirm": "Damit werden deine aktuellen Wiederherstellungscodes ersetzt. Deine bisherigen Codes funktionieren sofort nicht mehr, und der neue Satz wird nur einmal angezeigt."
|
||||
},
|
||||
"keys": {
|
||||
"title": "Sicherheitsschlüssel",
|
||||
"description": "Registriere einen Hardware- oder Plattform-Sicherheitsschlüssel als zweiten Faktor.",
|
||||
"empty": "Noch keine Sicherheitsschlüssel registriert.",
|
||||
"add": "Sicherheitsschlüssel hinzufügen",
|
||||
"addFailed": "Der Sicherheitsschlüssel konnte nicht registriert werden.",
|
||||
"rename": "Umbenennen",
|
||||
"renameFailed": "Der Sicherheitsschlüssel konnte nicht umbenannt werden.",
|
||||
"remove": "Sicherheitsschlüssel entfernen",
|
||||
"removeFailed": "Der Sicherheitsschlüssel konnte nicht entfernt werden.",
|
||||
"removeConfirm": "Dieser Sicherheitsschlüssel funktioniert dann nicht mehr als zweiter Faktor.",
|
||||
"lastUsed": "Zuletzt verwendet {date}",
|
||||
"neverUsed": "Noch nie verwendet",
|
||||
"added": "hinzugefügt {date}",
|
||||
"nameLabel": "Name des Sicherheitsschlüssels"
|
||||
},
|
||||
"passkeyNudge": {
|
||||
"title": "Füge einen Passkey für schnellere Anmeldung hinzu",
|
||||
"body": "Mit Passkeys meldest du dich per Fingerabdruck, Gesicht oder Geräte-PIN an — ohne Passwort zum Merken."
|
||||
},
|
||||
"trustedDevices": {
|
||||
"title": "Vertrauenswürdige Geräte",
|
||||
"description": "Browser, denen du vertraust, überspringen den zweiten Faktor für 30 Tage. Das Passwort wird weiterhin verlangt. Entziehe das Vertrauen, wenn du ein Gerät nicht kennst.",
|
||||
"loadError": "Vertrauenswürdige Geräte konnten nicht geladen werden.",
|
||||
"revokeError": "Gerät konnte nicht entfernt werden. Bitte erneut versuchen.",
|
||||
"revoke": "Entfernen",
|
||||
"revokeAll": "Alle Geräte vergessen",
|
||||
"revokeAllDone": "{count} vertrauenswürdige(s) Gerät(e) entfernt.",
|
||||
"unnamed": "Unbekanntes Gerät",
|
||||
"current": "Dieses Gerät",
|
||||
"expires": "Vertraut bis {date}"
|
||||
}
|
||||
},
|
||||
"privacy": {
|
||||
"intro": {
|
||||
"title": "Deine Daten bleiben deine",
|
||||
"body": "HealthLog wird selbst gehostet. Diese Seite fasst zusammen, wie deine Daten geschützt werden, wie lange sie gespeichert bleiben, wie du sie exportierst und wie du sie löschst."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Verschlüsselt gespeichert",
|
||||
"description": "Sensible Felder werden in der Datenbank verschlüsselt.",
|
||||
"summary": "Deine sensiblen Daten werden mit {algorithm} verschlüsselt gespeichert – über {columns} Felder in {models} Datensatztypen.",
|
||||
"statement": "Deine sensiblen Daten werden mit AES-256-GCM verschlüsselt gespeichert."
|
||||
},
|
||||
"stored": {
|
||||
"title": "Was gespeichert wird",
|
||||
"description": "Die Datenkategorien, die dieses Konto enthält.",
|
||||
"measurements": "Messwerte (Gewicht, Blutdruck, Puls, Glukose, Schlaf, Körperzusammensetzung)",
|
||||
"medications": "Medikamente und Einnahmeverlauf",
|
||||
"moodLabs": "Stimmungseinträge und Laborwerte",
|
||||
"coach": "Coach-Unterhaltungen",
|
||||
"integrations": "Integrations-Tokens (verschlüsselt)",
|
||||
"security": "Konto- und Sicherheitsaktivität (Sitzungen, Anmeldeprotokoll)"
|
||||
},
|
||||
"retention": {
|
||||
"title": "Aufbewahrung",
|
||||
"description": "Wie lange zeitgebundene Datensätze vor der automatischen Bereinigung gespeichert bleiben.",
|
||||
"coach": "Coach-Unterhaltungen: {days} Tage",
|
||||
"audit": "Sicherheitsprotokoll: {days} Tage",
|
||||
"delivery": "Benachrichtigungsprotokoll: {days} Tage",
|
||||
"unavailable": "Aufbewahrungsdetails sind derzeit nicht verfügbar."
|
||||
},
|
||||
"export": {
|
||||
"title": "Meine Daten herunterladen",
|
||||
"description": "Exportiere deine Datensätze als CSV oder JSON, optional passphrasen-verschlüsselt, oder erstelle eine vollständige Gesundheitsakte.",
|
||||
"openExport": "Export & Backup",
|
||||
"openHealthRecord": "Gesundheitsakte"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Löschen oder zurücksetzen",
|
||||
"description": "Lösche dein Konto vollständig oder lösche deine Gesundheitsdaten und behalte das Konto.",
|
||||
"backupLag": "Die Löschung entfernt deine Daten sofort. Verschlüsselte Off-Host-Backups enthalten sie weiterhin, bis sie aus dem Aufbewahrungsfenster des Betreibers herausfallen.",
|
||||
"openAdvanced": "Löschen oder zurücksetzen"
|
||||
},
|
||||
"posture": {
|
||||
"title": "Datenschutz-Einstellung",
|
||||
"description": "Steuere, wie viele deiner Daten an KI-Funktionen und die freiwillige Forschung weitergegeben werden.",
|
||||
"openAi": "KI-Datenschutzmodus",
|
||||
"openResearch": "Forschungsmodus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -5064,7 +5244,6 @@
|
||||
"webPushGlobal": "Browser-Push-Benachrichtigungen erlauben.",
|
||||
"moodLogGlobal": "moodLog-Erinnerungen erlauben.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug-Reports",
|
||||
"offlineGeoLabel": "Geodaten",
|
||||
"offlineGeoEnabled": "Offline (GeoLite2)",
|
||||
"offlineGeoFallback": "Fallback (ipwho.is) — MAXMIND_LICENSE_KEY in CI setzen",
|
||||
@@ -5101,12 +5280,6 @@
|
||||
"configured": "Konfiguriert",
|
||||
"monitoringTestSuccess": "Testevent wurde gesendet",
|
||||
"monitoringTestFailed": "Monitoring-Test fehlgeschlagen",
|
||||
"bugReportGithub": "Bugreport (GitHub Issues)",
|
||||
"bugReportGithubDescription": "Hinterlege GitHub-Issue-Token und Repository für eingehende Bugreports.",
|
||||
"bugReportEnabledLabel": "Bug-Reports aktiviert",
|
||||
"bugReportEnabledDescription": "Wenn deaktiviert, ist der Report-Button im Frontend nicht mehr sichtbar — Token und Repository bleiben gespeichert.",
|
||||
"bugReportDeprecatedBadge": "Veraltet",
|
||||
"bugReportDeprecatedNote": "Diese Funktion ist veraltet und wird in einer künftigen Version entfernt.",
|
||||
"assistant": {
|
||||
"title": "Assistenz-Oberflächen",
|
||||
"description": "Jeder Toggle versteckt eine modellgestützte Oberfläche app-weit, ohne die Provider-Konfiguration zu entfernen. Der Hauptschalter erzwingt jede Unter-Option aus.",
|
||||
@@ -5140,10 +5313,6 @@
|
||||
"description": "Schalte ein Modul für den gesamten Server ab. Ein hier deaktiviertes Modul ist für jedes Konto aus – unabhängig von den persönlichen Einstellungen. Kerndaten (Gewicht, Blutdruck, Puls, Medikamente) sind immer aktiv und lassen sich nicht abschalten.",
|
||||
"saveError": "Modulverfügbarkeit konnte nicht aktualisiert werden."
|
||||
},
|
||||
"bugReportRepo": "Repository",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Issue-Token",
|
||||
"bugReportTokenPlaceholder": "ghp_...",
|
||||
"invites": {
|
||||
"title": "Einladungen",
|
||||
"description": "Einladungslinks für die Registrierung — funktionieren auch bei geschlossener Registrierung. Der Link wird nur einmal angezeigt.",
|
||||
@@ -5297,47 +5466,6 @@
|
||||
"reminderCheckNotifOff": "Benachrichtigungen aus",
|
||||
"reminderCheckEventsToday": "Heutige Events",
|
||||
"reminderCheckNotifSent": "Gesendet",
|
||||
"feedback": {
|
||||
"tabOpen": "Offen",
|
||||
"tabAcknowledged": "Bestätigt",
|
||||
"tabResolved": "Erledigt",
|
||||
"tabArchived": "Archiviert",
|
||||
"subject": "Betreff",
|
||||
"category": "Kategorie",
|
||||
"user": "Benutzer",
|
||||
"createdAt": "Eingegangen",
|
||||
"actions": "Aktionen",
|
||||
"details": "Details",
|
||||
"emptyTitle": "Alles erledigt",
|
||||
"emptyDescription": "Kein Nutzer-Feedback in diesem Status. Bug-Reports, Fragen und Feature-Wünsche erscheinen hier.",
|
||||
"loading": "Lade Feedback...",
|
||||
"anonymous": "anonym",
|
||||
"categoryBug": "Bug",
|
||||
"categoryFeature": "Feature",
|
||||
"categoryQuestion": "Frage",
|
||||
"categoryOther": "Sonstiges",
|
||||
"metadataHeading": "Metadaten",
|
||||
"metaUrl": "URL",
|
||||
"metaLocale": "Sprache",
|
||||
"metaUserAgent": "User-Agent",
|
||||
"metaAppVersion": "App-Version",
|
||||
"screenshotHeading": "Screenshot",
|
||||
"adminNote": "Admin-Notiz",
|
||||
"adminNotePlaceholder": "Interne Notiz (nicht für Benutzer sichtbar)",
|
||||
"saveNote": "Notiz speichern",
|
||||
"noteSaved": "Notiz gespeichert",
|
||||
"actionAcknowledge": "Bestätigen",
|
||||
"actionResolve": "Erledigen",
|
||||
"actionArchive": "Archivieren",
|
||||
"actionPublishGithub": "Auf GitHub veröffentlichen",
|
||||
"publishedToGithub": "Auf GitHub veröffentlicht",
|
||||
"viewIssue": "Issue ansehen",
|
||||
"publishSuccess": "Issue erstellt",
|
||||
"publishFailed": "GitHub-Veröffentlichung fehlgeschlagen",
|
||||
"updateFailed": "Aktualisierung fehlgeschlagen",
|
||||
"archiveConfirm": "Diesen Feedback-Eintrag archivieren?",
|
||||
"submittedBy": "Eingereicht von"
|
||||
},
|
||||
"shell": {
|
||||
"overview": "Übersicht",
|
||||
"sectionsNav": "Admin-Bereiche"
|
||||
@@ -5390,10 +5518,6 @@
|
||||
"title": "Coach",
|
||||
"subtitle": "Assistenz-Oberflächen, Server-Schlüssel sowie die Feedback-Qualitäts-Tabellen für Coach und Auswertungen an einem Ort."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Feedback",
|
||||
"subtitle": "Feedback, Bug-Reports und Feature-Wünsche der Nutzer sichten."
|
||||
},
|
||||
"reminders": {
|
||||
"title": "Erinnerungen",
|
||||
"subtitle": "Schwellwerte für Medikamentenerinnerungen anpassen."
|
||||
@@ -5521,10 +5645,55 @@
|
||||
"module-availability": {
|
||||
"title": "Module",
|
||||
"subtitle": "Module für jedes Konto auf diesem Server aktivieren oder deaktivieren."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Verschlüsselung",
|
||||
"subtitle": "Verschlüsselungsabdeckung und Schlüsselrotations-Status für ruhende Daten.",
|
||||
"loading": "Verschlüsselungsstatus wird geladen...",
|
||||
"loadError": "Verschlüsselungsstatus konnte nicht geladen werden.",
|
||||
"coverageTitle": "Abdeckung",
|
||||
"coverageDescription": "Wie viele verschlüsselte Zeilen unter welcher Schlüssel-ID liegen. Es werden nur Schlüssel-IDs und Anzahlen gezeigt — niemals Schlüsselmaterial.",
|
||||
"activeKeyId": "Aktive Schlüssel-ID",
|
||||
"configuredKeys": "Konfigurierte Schlüssel",
|
||||
"totalRows": "Verschlüsselte Zeilen",
|
||||
"staleRows": "Zeilen nicht auf dem aktiven Schlüssel",
|
||||
"safeToDropLegacy": "Alter Schlüssel kann entfernt werden",
|
||||
"rotationIncomplete": "{count} Zeilen brauchen noch eine Rotation",
|
||||
"rotationTitle": "Schlüsselrotation",
|
||||
"rotationDescription": "Jede verschlüsselte Spalte auf den aktiven Schlüssel neu verschlüsseln. Das Kommandozeilen-Werkzeug bleibt der kanonische Weg; dieser Auslöser ist eine Bequemlichkeit.",
|
||||
"rotationState": "Status",
|
||||
"lastRun": "Letzter Lauf: {scanned} geprüft, {rotated} rotiert, {errors} Fehler.",
|
||||
"rotateNow": "Jetzt rotieren",
|
||||
"rotateConfirmTitle": "Den gesamten Datensatz neu verschlüsseln?",
|
||||
"rotateConfirmDescription": "Verschlüsselt jede verschlüsselte Spalte auf den aktiven Schlüssel neu. Es wird nie ein Schlüssel hinzugefügt oder entfernt, und ein erneuter Lauf ist sicher. Bei großen Konten kann das eine Weile dauern.",
|
||||
"cancel": "Abbrechen",
|
||||
"rotateConfirm": "Rotation starten",
|
||||
"rotateEnqueued": "Rotation gestartet.",
|
||||
"rotateFailed": "Rotation konnte nicht gestartet werden.",
|
||||
"stepUpNeeded": "Ein frischer zweiter Faktor ist erforderlich. Bestätige deinen zweiten Faktor oder nutze das Kommandozeilen-Werkzeug, falls kein zweiter Faktor eingerichtet ist.",
|
||||
"cliNote": "Für diesen Knopf ist ein zweiter Faktor nötig. Betreiber ohne zweiten Faktor rotieren mit scripts/rotate-encryption-key.ts.",
|
||||
"columnsTitle": "Abdeckung pro Spalte",
|
||||
"columnsDescription": "Jede verschlüsselte Spalte mit ihren Zeilenzahlen auf dem aktiven Schlüssel gegenüber älteren Schlüsseln.",
|
||||
"colColumn": "Spalte",
|
||||
"colTotal": "Gesamt",
|
||||
"colActive": "Auf aktivem Schlüssel",
|
||||
"colStale": "Veraltet",
|
||||
"state": {
|
||||
"idle": "Bereit",
|
||||
"running": "Läuft",
|
||||
"completed": "Abgeschlossen",
|
||||
"failed": "Fehlgeschlagen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"carrier": "Anbieter",
|
||||
"carrierUnavailableGeoFallback": "{location} — Anbieter nicht verfügbar"
|
||||
"carrierUnavailableGeoFallback": "{location} — Anbieter nicht verfügbar",
|
||||
"mfaRequired": "Zweiten Faktor verlangen",
|
||||
"mfaRequiredDescription": "Jedes Konto ohne aktiven zweiten Faktor wird nach der Anmeldung zur Einrichtung geleitet. Das fordert Web-Nutzer zur Einrichtung auf – es blockiert jedoch keinen direkten API- oder nativen (Bearer-)Zugriff für noch nicht eingerichtete Konten.",
|
||||
"mfaEnforcedOn": "MFA an",
|
||||
"mfaEnforcedOff": "MFA aus",
|
||||
"mfaEnforcedOnHint": "Für diesen Nutzer ist ein zweiter Faktor erforderlich. Klicken, um die Anforderung aufzuheben.",
|
||||
"mfaEnforcedOffHint": "Einen zweiten Faktor für diesen Nutzer verlangen."
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Erfolge",
|
||||
@@ -5583,10 +5752,6 @@
|
||||
"title": "Oldschool",
|
||||
"description": "Melde dich mindestens einmal mit Username und Passwort an."
|
||||
},
|
||||
"bugReport1": {
|
||||
"title": "Fehlerjäger",
|
||||
"description": "Reiche einen Bugreport ein."
|
||||
},
|
||||
"loginStreak7": {
|
||||
"title": "Login-Serie 7T",
|
||||
"description": "Logge dich an 7 Tagen in Folge ein."
|
||||
@@ -5775,10 +5940,6 @@
|
||||
"title": "Polyglott",
|
||||
"description": "Du hast die App-Sprache mindestens einmal gewechselt."
|
||||
},
|
||||
"hiddenBugBuddy": {
|
||||
"title": "Bug-Kumpel",
|
||||
"description": "Mindestens 5 Bugreports eingereicht — das Projekt liebt dich."
|
||||
},
|
||||
"missFree7": {
|
||||
"title": "Lückenlos · 7T",
|
||||
"description": "7 Tage in Folge ohne eine verpasste Dosis."
|
||||
@@ -5831,26 +5992,6 @@
|
||||
"empty": "Noch keine Erfolge — trage weiter ein, um deinen ersten freizuschalten."
|
||||
}
|
||||
},
|
||||
"bugreport": {
|
||||
"title": "Bugreport",
|
||||
"subtitle": "Sende eine Fehlermeldung, einen Feature-Wunsch oder eine Frage. Der Server-Administrator sieht deine Einreichung.",
|
||||
"loginRequired": "Bitte anmelden, um einen Bug zu melden.",
|
||||
"bugTitle": "Titel",
|
||||
"description": "Beschreibung",
|
||||
"submit": "Senden",
|
||||
"success": "Vielen Dank! Dein Bug Report wurde erfolgreich übermittelt.",
|
||||
"errorCreating": "Fehler beim Erstellen",
|
||||
"category": "Art",
|
||||
"categoryBug": "Fehlermeldung",
|
||||
"categoryFeature": "Feature-Wunsch",
|
||||
"categoryQuestion": "Frage",
|
||||
"categoryOther": "Sonstiges Feedback",
|
||||
"bugTitlePlaceholder": "Kurze Zusammenfassung (optional)",
|
||||
"githubEscalationNote": "Dein Feedback wird lokal gespeichert. Wenn der Administrator die GitHub-Integration aktiviert, kann es auf Wunsch als öffentliches Issue veröffentlicht werden.",
|
||||
"internalOnlyNote": "Dein Feedback wird lokal gespeichert und ist nur für den Administrator dieses Servers sichtbar.",
|
||||
"disabledTitle": "Bug-Reports sind deaktiviert",
|
||||
"disabledBody": "Der Administrator hat Bug-Reports auf diesem Server deaktiviert."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"eventColumn": "Ereignis",
|
||||
@@ -5955,7 +6096,13 @@
|
||||
"eventMeasurementReminder": "Mess-Erinnerung",
|
||||
"eventMeasurementReminderDesc": "Wird gesendet, wenn eine erfasste Messung fällig ist.",
|
||||
"eventMedicationIntakeSync": "Einnahme-Synchronisierung",
|
||||
"eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird."
|
||||
"eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird.",
|
||||
"security": {
|
||||
"newDeviceTitle": "Neue Anmeldung bei deinem Konto",
|
||||
"newDeviceBody": "Eine neue Anmeldung von {device} ({location}). Falls du das nicht warst, ändere sofort dein Passwort.",
|
||||
"unknownLocation": "unbekannter Ort"
|
||||
},
|
||||
"loginRequired": "Bitte melde dich an, um Benachrichtigungen zu verwalten."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
|
||||
+239
-92
@@ -32,10 +32,10 @@
|
||||
"copy": "Copy",
|
||||
"copyDetails": "Copy details",
|
||||
"retry": "Retry",
|
||||
"reportIssue": "Report issue",
|
||||
"showPassword": "Show password",
|
||||
"hidePassword": "Hide password",
|
||||
"learnMore": "Learn more"
|
||||
"learnMore": "Learn more",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Health Report",
|
||||
@@ -204,7 +204,6 @@
|
||||
"insights": "Insights",
|
||||
"achievements": "Achievements",
|
||||
"settings": "Settings",
|
||||
"bugreport": "Bug Report",
|
||||
"admin": "Admin Console",
|
||||
"logout": "Sign out",
|
||||
"login": "Sign in",
|
||||
@@ -263,7 +262,28 @@
|
||||
"passkeySetupLater": "You can set up passkeys at any time in Settings.",
|
||||
"passwordMinLength": "Min. 12 characters",
|
||||
"passwordTooShort": "Password must be at least {minLength} characters long.",
|
||||
"privacyPolicy": "Privacy policy"
|
||||
"privacyPolicy": "Privacy policy",
|
||||
"passwordBreached": "This password has appeared in known data breaches. Please choose a different one.",
|
||||
"mfa": {
|
||||
"title": "Two-factor authentication",
|
||||
"totpHint": "Enter the 6-digit code from your authenticator app.",
|
||||
"recoveryHint": "Enter one of your saved recovery codes.",
|
||||
"securityKeyHint": "Use your registered security key to continue.",
|
||||
"codeLabel": "Authentication code",
|
||||
"recoveryLabel": "Recovery code",
|
||||
"verify": "Verify",
|
||||
"verifyFailed": "Verification failed. Please try again.",
|
||||
"useSecurityKey": "Use a security key",
|
||||
"useRecoveryCode": "Use a recovery code",
|
||||
"useAuthenticator": "Use your authenticator app",
|
||||
"backToLogin": "Back to login",
|
||||
"rememberDevice": "Trust this device for 30 days"
|
||||
},
|
||||
"enrollMfa": {
|
||||
"title": "Set up a second factor",
|
||||
"body": "Your administrator requires a second factor on this account. Add an authenticator app or a security key to continue.",
|
||||
"signOut": "Sign out"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -4057,7 +4077,12 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Full backup",
|
||||
"description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload."
|
||||
"description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload.",
|
||||
"encryptToggle": "Encrypt with a passphrase",
|
||||
"passphrasePlaceholder": "Passphrase (min. 12 characters)",
|
||||
"passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.",
|
||||
"noRecoveryWarning": "There is no recovery — if you lose this passphrase, the archive cannot be opened. Keep it somewhere safe.",
|
||||
"stepUpNeeded": "Confirm your second factor, then try again."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cycle",
|
||||
@@ -4197,6 +4222,15 @@
|
||||
"title": "Channels",
|
||||
"description": "Telegram, ntfy, Web Push, Webhook, and Email delivery.",
|
||||
"subtitle": "Where reminders and alerts are delivered."
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"subtitle": "Two-factor authentication, recovery codes, and passkeys."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Data & Privacy",
|
||||
"subtitle": "Your data, retention, encryption, and account security in one place.",
|
||||
"description": "Export, deletion, retention, encryption status, sessions, and security activity."
|
||||
}
|
||||
},
|
||||
"profile": "Profile",
|
||||
@@ -4968,7 +5002,153 @@
|
||||
"polarDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
|
||||
"ouraDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
|
||||
"nightscoutDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
|
||||
"integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above."
|
||||
"integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above.",
|
||||
"security": {
|
||||
"sessionsTitle": "Active sessions",
|
||||
"sessionsDescription": "Devices and browsers currently signed in to your account.",
|
||||
"sessionsLoadError": "Active sessions could not be loaded.",
|
||||
"sessionsEmpty": "No active sessions.",
|
||||
"currentSession": "This device",
|
||||
"lastActive": "Last active {time}",
|
||||
"unknownLocation": "Unknown location",
|
||||
"revokeSession": "Sign out",
|
||||
"revokeError": "Could not complete the request. Please try again.",
|
||||
"signOutEverywhere": "Sign out everywhere else",
|
||||
"signOutEverywhereDone": "Signed out {count} other session(s).",
|
||||
"activityTitle": "Security activity",
|
||||
"activityDescription": "Recent sign-ins, security changes, and data exports on your account.",
|
||||
"activityEmpty": "No security activity yet.",
|
||||
"activityLoadError": "Security activity could not be loaded.",
|
||||
"action": {
|
||||
"newDevice": "New-device sign-in",
|
||||
"loginFailed": "Failed sign-in",
|
||||
"login": "Sign-in",
|
||||
"passwordChange": "Password changed",
|
||||
"mfa": "Two-factor change",
|
||||
"sessionRevoke": "Session signed out",
|
||||
"token": "API token change",
|
||||
"accountDelete": "Account deleted",
|
||||
"dataClear": "Data reset",
|
||||
"export": "Data export",
|
||||
"other": "Security event"
|
||||
},
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"stepUpRequired": "For your security, sign in again to change your second factor.",
|
||||
"useRecoveryCode": "Use a recovery code instead",
|
||||
"useAuthenticator": "Use your authenticator app instead",
|
||||
"totp": {
|
||||
"title": "Authenticator app",
|
||||
"description": "Use a time-based code from an authenticator app as your second factor.",
|
||||
"setUp": "Set up",
|
||||
"step1": "Scan the QR code with your authenticator app, or enter the secret manually.",
|
||||
"step2": "Enter the 6-digit code the app shows to confirm.",
|
||||
"qrAlt": "QR code for authenticator app setup",
|
||||
"qrUnavailable": "QR code unavailable",
|
||||
"manualEntry": "Or enter this secret manually",
|
||||
"enterCode": "Enter the 6-digit code",
|
||||
"verify": "Verify",
|
||||
"setupFailed": "Could not start setup. Please try again.",
|
||||
"invalidCode": "That code is not valid. Please try again.",
|
||||
"disable": "Disable",
|
||||
"disableTitle": "Disable two-factor authentication",
|
||||
"disableConfirm": "Enter a current code to turn off your second factor. This also deletes your recovery codes.",
|
||||
"currentCode": "Current code",
|
||||
"disableFailed": "Could not disable the second factor."
|
||||
},
|
||||
"recovery": {
|
||||
"saveNow": "Save these recovery codes now",
|
||||
"saveNowHint": "Each code works once. They are shown only this time — store them somewhere safe. If you lose your authenticator, a recovery code is the only way back in.",
|
||||
"download": "Download",
|
||||
"remainingOne": "{count} recovery code remaining.",
|
||||
"remainingOther": "{count} recovery codes remaining.",
|
||||
"regenerate": "Regenerate codes",
|
||||
"regenFailed": "Could not regenerate recovery codes.",
|
||||
"savedIt": "I've saved them",
|
||||
"regenerateTitle": "Regenerate recovery codes?",
|
||||
"regenerateConfirm": "This replaces your current recovery codes. Your existing codes will stop working immediately, and a new set is shown only once."
|
||||
},
|
||||
"keys": {
|
||||
"title": "Security keys",
|
||||
"description": "Register a hardware or platform security key as a second factor.",
|
||||
"empty": "No security keys registered yet.",
|
||||
"add": "Add security key",
|
||||
"addFailed": "Could not register the security key.",
|
||||
"rename": "Rename",
|
||||
"renameFailed": "Could not rename the security key.",
|
||||
"remove": "Remove security key",
|
||||
"removeFailed": "Could not remove the security key.",
|
||||
"removeConfirm": "This security key will no longer work as a second factor.",
|
||||
"lastUsed": "Last used {date}",
|
||||
"neverUsed": "Never used",
|
||||
"added": "added {date}",
|
||||
"nameLabel": "Security key name"
|
||||
},
|
||||
"passkeyNudge": {
|
||||
"title": "Add a passkey for faster sign-in",
|
||||
"body": "Passkeys let you sign in with your fingerprint, face, or device PIN — no password to remember."
|
||||
},
|
||||
"trustedDevices": {
|
||||
"title": "Trusted devices",
|
||||
"description": "Browsers you chose to trust skip the second factor for 30 days. The password is still required. Revoke any you don't recognize.",
|
||||
"loadError": "Could not load trusted devices.",
|
||||
"revokeError": "Could not revoke the device. Please try again.",
|
||||
"revoke": "Revoke",
|
||||
"revokeAll": "Forget all devices",
|
||||
"revokeAllDone": "Forgot {count} trusted device(s).",
|
||||
"unnamed": "Unknown device",
|
||||
"current": "This device",
|
||||
"expires": "Trusted until {date}"
|
||||
}
|
||||
},
|
||||
"privacy": {
|
||||
"intro": {
|
||||
"title": "Your data stays yours",
|
||||
"body": "HealthLog is self-hosted. This page brings together how your data is protected, how long it is kept, how to get it out, and how to delete it."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Encrypted at rest",
|
||||
"description": "Sensitive fields are encrypted in the database.",
|
||||
"summary": "Your sensitive data is encrypted at rest with {algorithm} across {columns} fields in {models} record types.",
|
||||
"statement": "Your sensitive data is encrypted at rest with AES-256-GCM."
|
||||
},
|
||||
"stored": {
|
||||
"title": "What's stored",
|
||||
"description": "The categories of data this account holds.",
|
||||
"measurements": "Measurements (weight, blood pressure, pulse, glucose, sleep, body composition)",
|
||||
"medications": "Medications and intake history",
|
||||
"moodLabs": "Mood entries and lab results",
|
||||
"coach": "Coach conversations",
|
||||
"integrations": "Integration tokens (encrypted)",
|
||||
"security": "Account and security activity (sessions, sign-in log)"
|
||||
},
|
||||
"retention": {
|
||||
"title": "Retention",
|
||||
"description": "How long time-bound records are kept before automatic cleanup.",
|
||||
"coach": "Coach conversations: kept {days} days",
|
||||
"audit": "Security activity log: kept {days} days",
|
||||
"delivery": "Notification delivery log: kept {days} days",
|
||||
"unavailable": "Retention details are unavailable right now."
|
||||
},
|
||||
"export": {
|
||||
"title": "Download my data",
|
||||
"description": "Export your records as CSV or JSON, optionally passphrase-encrypted, or generate a full health record.",
|
||||
"openExport": "Export & backup",
|
||||
"openHealthRecord": "Health record"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete or reset",
|
||||
"description": "Remove your account entirely, or wipe your health records while keeping the account.",
|
||||
"backupLag": "Deletion removes your data immediately. Encrypted off-host backups still contain it until they roll off the operator's backup retention window.",
|
||||
"openAdvanced": "Delete or reset"
|
||||
},
|
||||
"posture": {
|
||||
"title": "Privacy posture",
|
||||
"description": "Control how much your data is shared with AI features and opt-in research.",
|
||||
"openAi": "AI privacy mode",
|
||||
"openResearch": "Research mode"
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -5064,7 +5244,6 @@
|
||||
"webPushGlobal": "Allow browser push notifications.",
|
||||
"moodLogGlobal": "Allow mood-log reminders.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug Report",
|
||||
"offlineGeoLabel": "Geo data",
|
||||
"offlineGeoEnabled": "Offline (GeoLite2)",
|
||||
"offlineGeoFallback": "Fallback (ipwho.is) — set MAXMIND_LICENSE_KEY in CI",
|
||||
@@ -5101,12 +5280,6 @@
|
||||
"configured": "Configured",
|
||||
"monitoringTestSuccess": "Test event sent",
|
||||
"monitoringTestFailed": "Monitoring test failed",
|
||||
"bugReportGithub": "Bug report (GitHub Issues)",
|
||||
"bugReportGithubDescription": "Configure GitHub issue token and repository for incoming bug reports.",
|
||||
"bugReportEnabledLabel": "Bug reports enabled",
|
||||
"bugReportEnabledDescription": "When off, the in-app report button hides — token and repository stay configured.",
|
||||
"bugReportDeprecatedBadge": "Deprecated",
|
||||
"bugReportDeprecatedNote": "This feature is deprecated and will be removed in a future version.",
|
||||
"assistant": {
|
||||
"title": "Assistant Surfaces",
|
||||
"description": "Each toggle hides a model-driven surface app-wide without removing the provider configuration. The master kills every sub-flag at once.",
|
||||
@@ -5140,10 +5313,6 @@
|
||||
"description": "Turn a module off for the whole server. A module disabled here is off for every account regardless of personal preference. Core domains (weight, blood pressure, pulse, medications) are always on and cannot be disabled.",
|
||||
"saveError": "Could not update module availability."
|
||||
},
|
||||
"bugReportRepo": "Repository",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Issue token",
|
||||
"bugReportTokenPlaceholder": "ghp_...",
|
||||
"invites": {
|
||||
"title": "Invites",
|
||||
"description": "Registration invite links — they work even while registration is closed. The link is shown only once.",
|
||||
@@ -5297,47 +5466,6 @@
|
||||
"reminderCheckNotifOff": "Notifications off",
|
||||
"reminderCheckEventsToday": "Events today",
|
||||
"reminderCheckNotifSent": "Sent",
|
||||
"feedback": {
|
||||
"tabOpen": "Open",
|
||||
"tabAcknowledged": "Acknowledged",
|
||||
"tabResolved": "Resolved",
|
||||
"tabArchived": "Archived",
|
||||
"subject": "Subject",
|
||||
"category": "Category",
|
||||
"user": "User",
|
||||
"createdAt": "Submitted",
|
||||
"actions": "Actions",
|
||||
"details": "Details",
|
||||
"emptyTitle": "All caught up",
|
||||
"emptyDescription": "No user feedback in this status. Submitted bugs, questions, and feature requests will appear here.",
|
||||
"loading": "Loading feedback...",
|
||||
"anonymous": "anonymous",
|
||||
"categoryBug": "Bug",
|
||||
"categoryFeature": "Feature",
|
||||
"categoryQuestion": "Question",
|
||||
"categoryOther": "Other",
|
||||
"metadataHeading": "Metadata",
|
||||
"metaUrl": "URL",
|
||||
"metaLocale": "Locale",
|
||||
"metaUserAgent": "User agent",
|
||||
"metaAppVersion": "App version",
|
||||
"screenshotHeading": "Screenshot",
|
||||
"adminNote": "Admin note",
|
||||
"adminNotePlaceholder": "Internal note (not visible to user)",
|
||||
"saveNote": "Save note",
|
||||
"noteSaved": "Note saved",
|
||||
"actionAcknowledge": "Acknowledge",
|
||||
"actionResolve": "Resolve",
|
||||
"actionArchive": "Archive",
|
||||
"actionPublishGithub": "Publish to GitHub",
|
||||
"publishedToGithub": "Published to GitHub",
|
||||
"viewIssue": "View issue",
|
||||
"publishSuccess": "Issue created",
|
||||
"publishFailed": "GitHub publish failed",
|
||||
"updateFailed": "Update failed",
|
||||
"archiveConfirm": "Archive this feedback entry?",
|
||||
"submittedBy": "Submitted by"
|
||||
},
|
||||
"shell": {
|
||||
"overview": "Overview",
|
||||
"sectionsNav": "Admin sections"
|
||||
@@ -5390,10 +5518,6 @@
|
||||
"title": "Coach",
|
||||
"subtitle": "Assistant surfaces, server key, and the Coach and Insights feedback-quality tables in one place."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Feedback",
|
||||
"subtitle": "Triage user feedback, bug reports, and feature requests."
|
||||
},
|
||||
"reminders": {
|
||||
"title": "Reminders",
|
||||
"subtitle": "Tune medication reminder thresholds and run a manual check."
|
||||
@@ -5521,10 +5645,55 @@
|
||||
"module-availability": {
|
||||
"title": "Modules",
|
||||
"subtitle": "Turn modules on or off for every account on this server."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Encryption",
|
||||
"subtitle": "Encryption coverage and key-rotation status for data at rest.",
|
||||
"loading": "Loading encryption status...",
|
||||
"loadError": "Failed to load encryption status.",
|
||||
"coverageTitle": "Coverage",
|
||||
"coverageDescription": "How many encrypted rows sit under each key id. Only key ids and counts are shown — never key material.",
|
||||
"activeKeyId": "Active key id",
|
||||
"configuredKeys": "Configured keys",
|
||||
"totalRows": "Encrypted rows",
|
||||
"staleRows": "Rows not on the active key",
|
||||
"safeToDropLegacy": "Safe to drop the legacy key",
|
||||
"rotationIncomplete": "{count} rows still need rotation",
|
||||
"rotationTitle": "Key rotation",
|
||||
"rotationDescription": "Re-encrypt every encrypted column to the active key. The command-line tool stays the canonical path; this trigger is a convenience.",
|
||||
"rotationState": "State",
|
||||
"lastRun": "Last run: scanned {scanned}, rotated {rotated}, errors {errors}.",
|
||||
"rotateNow": "Rotate now",
|
||||
"rotateConfirmTitle": "Re-encrypt the whole record?",
|
||||
"rotateConfirmDescription": "Re-encrypts every encrypted column to the active key. It never adds or drops a key and is safe to re-run. This can take a while on large accounts.",
|
||||
"cancel": "Cancel",
|
||||
"rotateConfirm": "Start rotation",
|
||||
"rotateEnqueued": "Rotation started.",
|
||||
"rotateFailed": "Could not start the rotation.",
|
||||
"stepUpNeeded": "A fresh second factor is required. Confirm your second factor, or use the command-line tool if you have no second factor enrolled.",
|
||||
"cliNote": "A second factor is required to use this button. Operators without one rotate with scripts/rotate-encryption-key.ts.",
|
||||
"columnsTitle": "Per-column coverage",
|
||||
"columnsDescription": "Each encrypted column with its row counts on the active key versus older keys.",
|
||||
"colColumn": "Column",
|
||||
"colTotal": "Total",
|
||||
"colActive": "On active key",
|
||||
"colStale": "Stale",
|
||||
"state": {
|
||||
"idle": "Idle",
|
||||
"running": "Running",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"carrier": "Carrier",
|
||||
"carrierUnavailableGeoFallback": "{location} — carrier unavailable"
|
||||
"carrierUnavailableGeoFallback": "{location} — carrier unavailable",
|
||||
"mfaRequired": "Require a second factor",
|
||||
"mfaRequiredDescription": "Every account without an active second factor is sent to enrollment after sign-in. This prompts web users to enroll — it does not block direct API or native (Bearer) access for accounts not yet enrolled.",
|
||||
"mfaEnforcedOn": "MFA on",
|
||||
"mfaEnforcedOff": "MFA off",
|
||||
"mfaEnforcedOnHint": "A second factor is required for this user. Click to stop requiring it.",
|
||||
"mfaEnforcedOffHint": "Require a second factor for this user."
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Achievements",
|
||||
@@ -5583,10 +5752,6 @@
|
||||
"title": "Old school",
|
||||
"description": "Sign in at least once with username and password."
|
||||
},
|
||||
"bugReport1": {
|
||||
"title": "Bug hunter",
|
||||
"description": "Submit a bug report."
|
||||
},
|
||||
"loginStreak7": {
|
||||
"title": "Login streak 7d",
|
||||
"description": "Sign in on 7 consecutive days."
|
||||
@@ -5775,10 +5940,6 @@
|
||||
"title": "Polyglot",
|
||||
"description": "Switched the app language at least once."
|
||||
},
|
||||
"hiddenBugBuddy": {
|
||||
"title": "Bug buddy",
|
||||
"description": "Submitted at least 5 bug reports — the project loves you."
|
||||
},
|
||||
"missFree7": {
|
||||
"title": "No dose missed · 7d",
|
||||
"description": "7 consecutive days without a missed dose."
|
||||
@@ -5831,26 +5992,6 @@
|
||||
"empty": "No achievements yet — keep logging to unlock your first."
|
||||
}
|
||||
},
|
||||
"bugreport": {
|
||||
"title": "Bug Report",
|
||||
"subtitle": "Send a bug report, feature request, or question. The server administrator will see your submission.",
|
||||
"loginRequired": "Please sign in to report a bug.",
|
||||
"bugTitle": "Title",
|
||||
"description": "Description",
|
||||
"submit": "Send",
|
||||
"success": "Thank you! Your bug report has been submitted successfully.",
|
||||
"errorCreating": "Error creating report",
|
||||
"category": "Type",
|
||||
"categoryBug": "Bug report",
|
||||
"categoryFeature": "Feature request",
|
||||
"categoryQuestion": "Question",
|
||||
"categoryOther": "Other feedback",
|
||||
"bugTitlePlaceholder": "Short summary (optional)",
|
||||
"githubEscalationNote": "Your feedback is stored locally. If the admin enables GitHub integration, it can be promoted to a public issue on request.",
|
||||
"internalOnlyNote": "Your feedback is stored locally and only visible to the administrator of this server.",
|
||||
"disabledTitle": "Bug reports are turned off",
|
||||
"disabledBody": "The administrator has disabled bug reports on this server."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"eventColumn": "Event",
|
||||
@@ -5955,7 +6096,13 @@
|
||||
"eventMeasurementReminder": "Measurement reminder",
|
||||
"eventMeasurementReminderDesc": "Sent when a tracked measurement is due.",
|
||||
"eventMedicationIntakeSync": "Medication intake sync",
|
||||
"eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped."
|
||||
"eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped.",
|
||||
"security": {
|
||||
"newDeviceTitle": "New sign-in to your account",
|
||||
"newDeviceBody": "A new sign-in from {device} ({location}). If this wasn't you, change your password immediately.",
|
||||
"unknownLocation": "unknown location"
|
||||
},
|
||||
"loginRequired": "Please sign in to manage notifications."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
|
||||
+239
-92
@@ -32,10 +32,10 @@
|
||||
"copy": "Copiar",
|
||||
"copyDetails": "Copiar detalles",
|
||||
"retry": "Reintentar",
|
||||
"reportIssue": "Reportar problema",
|
||||
"showPassword": "Mostrar contraseña",
|
||||
"hidePassword": "Ocultar contraseña",
|
||||
"learnMore": "Más información"
|
||||
"learnMore": "Más información",
|
||||
"dismiss": "Descartar"
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Informe de salud",
|
||||
@@ -204,7 +204,6 @@
|
||||
"insights": "Insights",
|
||||
"achievements": "Logros",
|
||||
"settings": "Ajustes",
|
||||
"bugreport": "Reporte de bug",
|
||||
"admin": "Consola de administración",
|
||||
"logout": "Cerrar sesión",
|
||||
"login": "Iniciar sesión",
|
||||
@@ -263,7 +262,28 @@
|
||||
"passkeySetupLater": "Puedes configurar passkeys más tarde en los ajustes.",
|
||||
"passwordMinLength": "Mín. 12 caracteres",
|
||||
"passwordTooShort": "La contraseña debe tener al menos {minLength} caracteres.",
|
||||
"privacyPolicy": "Política de privacidad"
|
||||
"privacyPolicy": "Política de privacidad",
|
||||
"passwordBreached": "Esta contraseña aparece en filtraciones de datos conocidas. Elige otra.",
|
||||
"mfa": {
|
||||
"title": "Autenticación de dos factores",
|
||||
"totpHint": "Introduce el código de 6 dígitos de tu aplicación de autenticación.",
|
||||
"recoveryHint": "Introduce uno de tus códigos de recuperación guardados.",
|
||||
"securityKeyHint": "Usa tu llave de seguridad registrada para continuar.",
|
||||
"codeLabel": "Código de autenticación",
|
||||
"recoveryLabel": "Código de recuperación",
|
||||
"verify": "Verificar",
|
||||
"verifyFailed": "La verificación falló. Inténtalo de nuevo.",
|
||||
"useSecurityKey": "Usar una llave de seguridad",
|
||||
"useRecoveryCode": "Usar un código de recuperación",
|
||||
"useAuthenticator": "Usar tu aplicación de autenticación",
|
||||
"backToLogin": "Volver al inicio de sesión",
|
||||
"rememberDevice": "Confiar en este dispositivo durante 30 días"
|
||||
},
|
||||
"enrollMfa": {
|
||||
"title": "Configurar un segundo factor",
|
||||
"body": "Tu administrador exige un segundo factor en esta cuenta. Añade una app de autenticación o una llave de seguridad para continuar.",
|
||||
"signOut": "Cerrar sesión"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel",
|
||||
@@ -4057,7 +4077,12 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Copia completa",
|
||||
"description": "Un único archivo JSON con todo — el mismo formato que la copia automática semanal. Útil para restaurar tú mismo mediante la carga de administrador."
|
||||
"description": "Un único archivo JSON con todo — el mismo formato que la copia automática semanal. Útil para restaurar tú mismo mediante la carga de administrador.",
|
||||
"encryptToggle": "Cifrar con una contraseña",
|
||||
"passphrasePlaceholder": "Contraseña (mín. 12 caracteres)",
|
||||
"passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.",
|
||||
"noRecoveryWarning": "No hay recuperación: si pierdes esta contraseña, el archivo no se podrá abrir. Guárdala en un lugar seguro.",
|
||||
"stepUpNeeded": "Confirma tu segundo factor y vuelve a intentarlo."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Ciclo",
|
||||
@@ -4197,6 +4222,15 @@
|
||||
"title": "Canales",
|
||||
"description": "Envío por Telegram, ntfy, Web Push, Webhook y correo electrónico.",
|
||||
"subtitle": "Dónde se entregan los recordatorios y avisos."
|
||||
},
|
||||
"security": {
|
||||
"title": "Seguridad",
|
||||
"subtitle": "Autenticación de dos factores, códigos de recuperación y claves de acceso."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Datos y privacidad",
|
||||
"subtitle": "Tus datos, su retención, el cifrado y la seguridad de la cuenta en un solo lugar.",
|
||||
"description": "Exportación, eliminación, retención, estado de cifrado, sesiones y actividad de seguridad."
|
||||
}
|
||||
},
|
||||
"profile": "Perfil",
|
||||
@@ -4968,7 +5002,153 @@
|
||||
"polarDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
|
||||
"ouraDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
|
||||
"nightscoutDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
|
||||
"integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba."
|
||||
"integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba.",
|
||||
"security": {
|
||||
"sessionsTitle": "Sesiones activas",
|
||||
"sessionsDescription": "Dispositivos y navegadores con sesión iniciada en tu cuenta.",
|
||||
"sessionsLoadError": "No se pudieron cargar las sesiones activas.",
|
||||
"sessionsEmpty": "No hay sesiones activas.",
|
||||
"currentSession": "Este dispositivo",
|
||||
"lastActive": "Última actividad {time}",
|
||||
"unknownLocation": "Ubicación desconocida",
|
||||
"revokeSession": "Cerrar sesión",
|
||||
"revokeError": "No se pudo completar la solicitud. Inténtalo de nuevo.",
|
||||
"signOutEverywhere": "Cerrar sesión en los demás dispositivos",
|
||||
"signOutEverywhereDone": "Se cerraron {count} sesión(es).",
|
||||
"activityTitle": "Actividad de seguridad",
|
||||
"activityDescription": "Inicios de sesión, cambios de seguridad y exportaciones recientes de tu cuenta.",
|
||||
"activityEmpty": "Aún no hay actividad de seguridad.",
|
||||
"activityLoadError": "No se pudo cargar la actividad de seguridad.",
|
||||
"action": {
|
||||
"newDevice": "Inicio desde nuevo dispositivo",
|
||||
"loginFailed": "Inicio de sesión fallido",
|
||||
"login": "Inicio de sesión",
|
||||
"passwordChange": "Contraseña cambiada",
|
||||
"mfa": "Cambio de doble factor",
|
||||
"sessionRevoke": "Sesión cerrada",
|
||||
"token": "Cambio de token de API",
|
||||
"accountDelete": "Cuenta eliminada",
|
||||
"dataClear": "Datos restablecidos",
|
||||
"export": "Exportación de datos",
|
||||
"other": "Evento de seguridad"
|
||||
},
|
||||
"active": "Activo",
|
||||
"inactive": "Inactivo",
|
||||
"stepUpRequired": "Por tu seguridad, vuelve a iniciar sesión para cambiar tu segundo factor.",
|
||||
"useRecoveryCode": "Usar un código de recuperación en su lugar",
|
||||
"useAuthenticator": "Usar tu aplicación de autenticación en su lugar",
|
||||
"totp": {
|
||||
"title": "Aplicación de autenticación",
|
||||
"description": "Usa un código temporal de una aplicación de autenticación como segundo factor.",
|
||||
"setUp": "Configurar",
|
||||
"step1": "Escanea el código QR con tu aplicación de autenticación o introduce el secreto manualmente.",
|
||||
"step2": "Introduce el código de 6 dígitos que muestra la aplicación para confirmar.",
|
||||
"qrAlt": "Código QR para configurar la aplicación de autenticación",
|
||||
"qrUnavailable": "Código QR no disponible",
|
||||
"manualEntry": "O introduce este secreto manualmente",
|
||||
"enterCode": "Introduce el código de 6 dígitos",
|
||||
"verify": "Verificar",
|
||||
"setupFailed": "No se pudo iniciar la configuración. Inténtalo de nuevo.",
|
||||
"invalidCode": "Ese código no es válido. Inténtalo de nuevo.",
|
||||
"disable": "Desactivar",
|
||||
"disableTitle": "Desactivar la autenticación de dos factores",
|
||||
"disableConfirm": "Introduce un código actual para desactivar tu segundo factor. Esto también elimina tus códigos de recuperación.",
|
||||
"currentCode": "Código actual",
|
||||
"disableFailed": "No se pudo desactivar el segundo factor."
|
||||
},
|
||||
"recovery": {
|
||||
"saveNow": "Guarda ahora estos códigos de recuperación",
|
||||
"saveNowHint": "Cada código funciona una sola vez. Solo se muestran esta vez: guárdalos en un lugar seguro. Si pierdes tu autenticador, un código de recuperación es la única forma de volver a entrar.",
|
||||
"download": "Descargar",
|
||||
"remainingOne": "Queda {count} código de recuperación.",
|
||||
"remainingOther": "Quedan {count} códigos de recuperación.",
|
||||
"regenerate": "Regenerar códigos",
|
||||
"regenFailed": "No se pudieron regenerar los códigos de recuperación.",
|
||||
"savedIt": "Los he guardado",
|
||||
"regenerateTitle": "¿Regenerar los códigos de recuperación?",
|
||||
"regenerateConfirm": "Esto reemplaza tus códigos de recuperación actuales. Tus códigos existentes dejarán de funcionar de inmediato y el nuevo conjunto se muestra solo una vez."
|
||||
},
|
||||
"keys": {
|
||||
"title": "Llaves de seguridad",
|
||||
"description": "Registra una llave de seguridad de hardware o de plataforma como segundo factor.",
|
||||
"empty": "Aún no hay llaves de seguridad registradas.",
|
||||
"add": "Añadir llave de seguridad",
|
||||
"addFailed": "No se pudo registrar la llave de seguridad.",
|
||||
"rename": "Cambiar nombre",
|
||||
"renameFailed": "No se pudo cambiar el nombre de la llave de seguridad.",
|
||||
"remove": "Quitar llave de seguridad",
|
||||
"removeFailed": "No se pudo quitar la llave de seguridad.",
|
||||
"removeConfirm": "Esta llave de seguridad dejará de funcionar como segundo factor.",
|
||||
"lastUsed": "Usada por última vez el {date}",
|
||||
"neverUsed": "Nunca usada",
|
||||
"added": "añadida el {date}",
|
||||
"nameLabel": "Nombre de la llave de seguridad"
|
||||
},
|
||||
"passkeyNudge": {
|
||||
"title": "Añade una clave de acceso para iniciar sesión más rápido",
|
||||
"body": "Las claves de acceso te permiten iniciar sesión con tu huella, tu cara o el PIN del dispositivo, sin contraseñas que recordar."
|
||||
},
|
||||
"trustedDevices": {
|
||||
"title": "Dispositivos de confianza",
|
||||
"description": "Los navegadores en los que confías omiten el segundo factor durante 30 días. La contraseña sigue siendo obligatoria. Revoca los que no reconozcas.",
|
||||
"loadError": "No se pudieron cargar los dispositivos de confianza.",
|
||||
"revokeError": "No se pudo revocar el dispositivo. Inténtalo de nuevo.",
|
||||
"revoke": "Revocar",
|
||||
"revokeAll": "Olvidar todos los dispositivos",
|
||||
"revokeAllDone": "Se olvidaron {count} dispositivo(s) de confianza.",
|
||||
"unnamed": "Dispositivo desconocido",
|
||||
"current": "Este dispositivo",
|
||||
"expires": "De confianza hasta el {date}"
|
||||
}
|
||||
},
|
||||
"privacy": {
|
||||
"intro": {
|
||||
"title": "Tus datos siguen siendo tuyos",
|
||||
"body": "HealthLog es autoalojado. Esta página reúne cómo se protegen tus datos, cuánto tiempo se conservan, cómo exportarlos y cómo eliminarlos."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Cifrado en reposo",
|
||||
"description": "Los campos sensibles se cifran en la base de datos.",
|
||||
"summary": "Tus datos sensibles se cifran en reposo con {algorithm} en {columns} campos de {models} tipos de registro.",
|
||||
"statement": "Tus datos sensibles se cifran en reposo con AES-256-GCM."
|
||||
},
|
||||
"stored": {
|
||||
"title": "Qué se almacena",
|
||||
"description": "Las categorías de datos que contiene esta cuenta.",
|
||||
"measurements": "Mediciones (peso, presión arterial, pulso, glucosa, sueño, composición corporal)",
|
||||
"medications": "Medicamentos e historial de tomas",
|
||||
"moodLabs": "Entradas de estado de ánimo y resultados de laboratorio",
|
||||
"coach": "Conversaciones con el asistente",
|
||||
"integrations": "Tokens de integración (cifrados)",
|
||||
"security": "Actividad de la cuenta y de seguridad (sesiones, registro de inicio de sesión)"
|
||||
},
|
||||
"retention": {
|
||||
"title": "Retención",
|
||||
"description": "Cuánto tiempo se conservan los registros con límite temporal antes de su limpieza automática.",
|
||||
"coach": "Conversaciones con el asistente: {days} días",
|
||||
"audit": "Registro de seguridad: {days} días",
|
||||
"delivery": "Registro de envío de notificaciones: {days} días",
|
||||
"unavailable": "Los detalles de retención no están disponibles en este momento."
|
||||
},
|
||||
"export": {
|
||||
"title": "Descargar mis datos",
|
||||
"description": "Exporta tus registros como CSV o JSON, opcionalmente cifrados con frase de contraseña, o genera un historial médico completo.",
|
||||
"openExport": "Exportar y copia de seguridad",
|
||||
"openHealthRecord": "Historial médico"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Eliminar o restablecer",
|
||||
"description": "Elimina tu cuenta por completo o borra tus registros de salud conservando la cuenta.",
|
||||
"backupLag": "La eliminación borra tus datos de inmediato. Las copias de seguridad cifradas externas aún los contienen hasta que caduquen según la ventana de retención del operador.",
|
||||
"openAdvanced": "Eliminar o restablecer"
|
||||
},
|
||||
"posture": {
|
||||
"title": "Configuración de privacidad",
|
||||
"description": "Controla cuántos de tus datos se comparten con las funciones de IA y la investigación opcional.",
|
||||
"openAi": "Modo de privacidad de IA",
|
||||
"openResearch": "Modo investigación"
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -5064,7 +5244,6 @@
|
||||
"webPushGlobal": "Permitir notificaciones push del navegador.",
|
||||
"moodLogGlobal": "Permitir recordatorios del registro de ánimo.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Informe de errores",
|
||||
"offlineGeoLabel": "Datos geográficos",
|
||||
"offlineGeoEnabled": "Sin conexión (GeoLite2)",
|
||||
"offlineGeoFallback": "Respaldo (ipwho.is) — configura MAXMIND_LICENSE_KEY en CI",
|
||||
@@ -5101,12 +5280,6 @@
|
||||
"configured": "Configurado",
|
||||
"monitoringTestSuccess": "Evento de prueba enviado",
|
||||
"monitoringTestFailed": "La prueba de monitorización falló",
|
||||
"bugReportGithub": "Informe de errores (GitHub Issues)",
|
||||
"bugReportGithubDescription": "Configura el token de issues de GitHub y el repositorio para los informes de errores entrantes.",
|
||||
"bugReportEnabledLabel": "Informes de errores activados",
|
||||
"bugReportEnabledDescription": "Cuando está desactivado, el botón de informe en la app se oculta; el token y el repositorio permanecen configurados.",
|
||||
"bugReportDeprecatedBadge": "Obsoleto",
|
||||
"bugReportDeprecatedNote": "Esta función está obsoleta y se eliminará en una versión futura.",
|
||||
"assistant": {
|
||||
"title": "Superficies del asistente",
|
||||
"description": "Cada conmutador oculta una superficie impulsada por el modelo en toda la aplicación sin retirar la configuración del proveedor. El interruptor maestro fuerza cada opción secundaria a off.",
|
||||
@@ -5140,10 +5313,6 @@
|
||||
"description": "Desactiva un módulo para todo el servidor. Un módulo desactivado aquí queda apagado para todas las cuentas, sin importar la preferencia personal. Los dominios principales (peso, presión arterial, pulso, medicación) están siempre activos y no se pueden desactivar.",
|
||||
"saveError": "No se pudo actualizar la disponibilidad de módulos."
|
||||
},
|
||||
"bugReportRepo": "Repositorio",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Token de issues",
|
||||
"bugReportTokenPlaceholder": "ghp_...",
|
||||
"invites": {
|
||||
"title": "Invitaciones",
|
||||
"description": "Enlaces de invitación para el registro: funcionan incluso con el registro cerrado. El enlace se muestra solo una vez.",
|
||||
@@ -5297,47 +5466,6 @@
|
||||
"reminderCheckNotifOff": "Notificaciones desactivadas",
|
||||
"reminderCheckEventsToday": "Eventos de hoy",
|
||||
"reminderCheckNotifSent": "Enviadas",
|
||||
"feedback": {
|
||||
"tabOpen": "Abierto",
|
||||
"tabAcknowledged": "Confirmado",
|
||||
"tabResolved": "Resuelto",
|
||||
"tabArchived": "Archivado",
|
||||
"subject": "Asunto",
|
||||
"category": "Categoría",
|
||||
"user": "Usuario",
|
||||
"createdAt": "Enviado",
|
||||
"actions": "Acciones",
|
||||
"details": "Detalles",
|
||||
"emptyTitle": "Todo al día",
|
||||
"emptyDescription": "No hay comentarios de usuarios en este estado. Los errores, preguntas y solicitudes de funciones enviados aparecerán aquí.",
|
||||
"loading": "Cargando comentarios...",
|
||||
"anonymous": "anónimo",
|
||||
"categoryBug": "Error",
|
||||
"categoryFeature": "Función",
|
||||
"categoryQuestion": "Pregunta",
|
||||
"categoryOther": "Otro",
|
||||
"metadataHeading": "Metadatos",
|
||||
"metaUrl": "URL",
|
||||
"metaLocale": "Idioma",
|
||||
"metaUserAgent": "Agente de usuario",
|
||||
"metaAppVersion": "Versión de la app",
|
||||
"screenshotHeading": "Screenshot",
|
||||
"adminNote": "Nota de administración",
|
||||
"adminNotePlaceholder": "Nota interna (no visible para el usuario)",
|
||||
"saveNote": "Guardar nota",
|
||||
"noteSaved": "Nota guardada",
|
||||
"actionAcknowledge": "Confirmar",
|
||||
"actionResolve": "Resolver",
|
||||
"actionArchive": "Archivar",
|
||||
"actionPublishGithub": "Publicar en GitHub",
|
||||
"publishedToGithub": "Publicado en GitHub",
|
||||
"viewIssue": "Ver issue",
|
||||
"publishSuccess": "Issue creada",
|
||||
"publishFailed": "La publicación en GitHub falló",
|
||||
"updateFailed": "La actualización falló",
|
||||
"archiveConfirm": "¿Archivar esta entrada de comentarios?",
|
||||
"submittedBy": "Enviado por"
|
||||
},
|
||||
"shell": {
|
||||
"overview": "Resumen",
|
||||
"sectionsNav": "Secciones de administración"
|
||||
@@ -5390,10 +5518,6 @@
|
||||
"title": "Coach",
|
||||
"subtitle": "Superficies del asistente, clave del servidor y las tablas de calidad de comentarios del Coach y de los análisis, en un solo lugar."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Comentarios",
|
||||
"subtitle": "Clasifica los comentarios de usuarios, los informes de errores y las solicitudes de funciones."
|
||||
},
|
||||
"reminders": {
|
||||
"title": "Recordatorios",
|
||||
"subtitle": "Ajusta los umbrales de los recordatorios de medicación y ejecuta una comprobación manual."
|
||||
@@ -5521,10 +5645,55 @@
|
||||
"module-availability": {
|
||||
"title": "Módulos",
|
||||
"subtitle": "Activa o desactiva módulos para todas las cuentas de este servidor."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Cifrado",
|
||||
"subtitle": "Cobertura de cifrado y estado de rotación de claves para los datos en reposo.",
|
||||
"loading": "Cargando el estado de cifrado...",
|
||||
"loadError": "No se pudo cargar el estado de cifrado.",
|
||||
"coverageTitle": "Cobertura",
|
||||
"coverageDescription": "Cuántas filas cifradas hay bajo cada id de clave. Solo se muestran los id de clave y los recuentos, nunca el material de la clave.",
|
||||
"activeKeyId": "Id de clave activa",
|
||||
"configuredKeys": "Claves configuradas",
|
||||
"totalRows": "Filas cifradas",
|
||||
"staleRows": "Filas que no usan la clave activa",
|
||||
"safeToDropLegacy": "Es seguro eliminar la clave antigua",
|
||||
"rotationIncomplete": "Aún quedan {count} filas por rotar",
|
||||
"rotationTitle": "Rotación de claves",
|
||||
"rotationDescription": "Vuelve a cifrar cada columna cifrada con la clave activa. La herramienta de línea de comandos sigue siendo la vía canónica; este disparador es una comodidad.",
|
||||
"rotationState": "Estado",
|
||||
"lastRun": "Última ejecución: {scanned} revisadas, {rotated} rotadas, {errors} errores.",
|
||||
"rotateNow": "Rotar ahora",
|
||||
"rotateConfirmTitle": "¿Volver a cifrar todo el registro?",
|
||||
"rotateConfirmDescription": "Vuelve a cifrar cada columna cifrada con la clave activa. Nunca añade ni elimina una clave y se puede repetir con seguridad. En cuentas grandes puede tardar un rato.",
|
||||
"cancel": "Cancelar",
|
||||
"rotateConfirm": "Iniciar rotación",
|
||||
"rotateEnqueued": "Rotación iniciada.",
|
||||
"rotateFailed": "No se pudo iniciar la rotación.",
|
||||
"stepUpNeeded": "Se requiere un segundo factor reciente. Confirma tu segundo factor o usa la herramienta de línea de comandos si no tienes ninguno configurado.",
|
||||
"cliNote": "Se necesita un segundo factor para usar este botón. Los operadores sin él rotan con scripts/rotate-encryption-key.ts.",
|
||||
"columnsTitle": "Cobertura por columna",
|
||||
"columnsDescription": "Cada columna cifrada con sus recuentos de filas en la clave activa frente a claves anteriores.",
|
||||
"colColumn": "Columna",
|
||||
"colTotal": "Total",
|
||||
"colActive": "En clave activa",
|
||||
"colStale": "Obsoletas",
|
||||
"state": {
|
||||
"idle": "Inactivo",
|
||||
"running": "En curso",
|
||||
"completed": "Completado",
|
||||
"failed": "Fallido"
|
||||
}
|
||||
}
|
||||
},
|
||||
"carrier": "Operador",
|
||||
"carrierUnavailableGeoFallback": "{location} — operador no disponible"
|
||||
"carrierUnavailableGeoFallback": "{location} — operador no disponible",
|
||||
"mfaRequired": "Exigir un segundo factor",
|
||||
"mfaRequiredDescription": "Cada cuenta sin un segundo factor activo se dirige a la configuración tras iniciar sesión. Esto pide a los usuarios web que lo configuren, pero no bloquea el acceso directo por API o nativo (Bearer) de las cuentas que aún no lo tienen.",
|
||||
"mfaEnforcedOn": "MFA activado",
|
||||
"mfaEnforcedOff": "MFA desactivado",
|
||||
"mfaEnforcedOnHint": "Este usuario debe usar un segundo factor. Haz clic para dejar de exigirlo.",
|
||||
"mfaEnforcedOffHint": "Exigir un segundo factor a este usuario."
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Logros",
|
||||
@@ -5583,10 +5752,6 @@
|
||||
"title": "A la antigua",
|
||||
"description": "Inicia sesión al menos una vez con usuario y contraseña."
|
||||
},
|
||||
"bugReport1": {
|
||||
"title": "Cazador de errores",
|
||||
"description": "Envía un informe de error."
|
||||
},
|
||||
"loginStreak7": {
|
||||
"title": "Racha de inicios 7d",
|
||||
"description": "Inicia sesión 7 días seguidos."
|
||||
@@ -5775,10 +5940,6 @@
|
||||
"title": "Políglota",
|
||||
"description": "Cambiaste el idioma de la app al menos una vez."
|
||||
},
|
||||
"hiddenBugBuddy": {
|
||||
"title": "Colega de errores",
|
||||
"description": "Enviaste al menos 5 informes de error — el proyecto te quiere."
|
||||
},
|
||||
"missFree7": {
|
||||
"title": "Sin dosis perdidas · 7d",
|
||||
"description": "7 días consecutivos sin una dosis perdida."
|
||||
@@ -5831,26 +5992,6 @@
|
||||
"empty": "Aún no hay logros — sigue registrando para desbloquear el primero."
|
||||
}
|
||||
},
|
||||
"bugreport": {
|
||||
"title": "Reporte de bug",
|
||||
"subtitle": "Envía un fallo, una sugerencia o una pregunta. El administrador del servidor verá tu envío.",
|
||||
"loginRequired": "Inicia sesión para reportar un bug.",
|
||||
"bugTitle": "Título",
|
||||
"description": "Descripción",
|
||||
"submit": "Enviar",
|
||||
"success": "¡Gracias! Tu reporte se ha enviado correctamente.",
|
||||
"errorCreating": "Error al crear",
|
||||
"category": "Tipo",
|
||||
"categoryBug": "Fallo",
|
||||
"categoryFeature": "Sugerencia",
|
||||
"categoryQuestion": "Pregunta",
|
||||
"categoryOther": "Otro feedback",
|
||||
"bugTitlePlaceholder": "Resumen breve (opcional)",
|
||||
"githubEscalationNote": "Tu feedback se guarda localmente. Si el administrador activa la integración con GitHub, puede publicarse como issue público a petición.",
|
||||
"internalOnlyNote": "Tu feedback se guarda localmente y solo es visible para el administrador de este servidor.",
|
||||
"disabledTitle": "Los informes de errores están desactivados",
|
||||
"disabledBody": "El administrador ha desactivado los informes de errores en este servidor."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notificaciones",
|
||||
"eventColumn": "Evento",
|
||||
@@ -5955,7 +6096,13 @@
|
||||
"eventMeasurementReminder": "Recordatorio de medición",
|
||||
"eventMeasurementReminderDesc": "Se envía cuando una medición registrada está pendiente.",
|
||||
"eventMedicationIntakeSync": "Sincronización de tomas",
|
||||
"eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis."
|
||||
"eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis.",
|
||||
"security": {
|
||||
"newDeviceTitle": "Nuevo inicio de sesión en tu cuenta",
|
||||
"newDeviceBody": "Un nuevo inicio de sesión desde {device} ({location}). Si no fuiste tú, cambia tu contraseña de inmediato.",
|
||||
"unknownLocation": "ubicación desconocida"
|
||||
},
|
||||
"loginRequired": "Inicia sesión para gestionar las notificaciones."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
|
||||
+239
-92
@@ -32,10 +32,10 @@
|
||||
"copy": "Copier",
|
||||
"copyDetails": "Copier les détails",
|
||||
"retry": "Réessayer",
|
||||
"reportIssue": "Signaler un problème",
|
||||
"showPassword": "Afficher le mot de passe",
|
||||
"hidePassword": "Masquer le mot de passe",
|
||||
"learnMore": "En savoir plus"
|
||||
"learnMore": "En savoir plus",
|
||||
"dismiss": "Ignorer"
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Rapport de santé",
|
||||
@@ -204,7 +204,6 @@
|
||||
"insights": "Insights",
|
||||
"achievements": "Succès",
|
||||
"settings": "Paramètres",
|
||||
"bugreport": "Rapport de bug",
|
||||
"admin": "Console d’administration",
|
||||
"logout": "Se déconnecter",
|
||||
"login": "Se connecter",
|
||||
@@ -263,7 +262,28 @@
|
||||
"passkeySetupLater": "Tu peux configurer les passkeys plus tard dans les paramètres.",
|
||||
"passwordMinLength": "Min. 12 caractères",
|
||||
"passwordTooShort": "Le mot de passe doit comporter au moins {minLength} caractères.",
|
||||
"privacyPolicy": "Politique de confidentialité"
|
||||
"privacyPolicy": "Politique de confidentialité",
|
||||
"passwordBreached": "Ce mot de passe figure dans des fuites de données connues. Choisissez-en un autre.",
|
||||
"mfa": {
|
||||
"title": "Authentification à deux facteurs",
|
||||
"totpHint": "Saisissez le code à 6 chiffres de votre application d'authentification.",
|
||||
"recoveryHint": "Saisissez l'un de vos codes de récupération enregistrés.",
|
||||
"securityKeyHint": "Utilisez votre clé de sécurité enregistrée pour continuer.",
|
||||
"codeLabel": "Code d'authentification",
|
||||
"recoveryLabel": "Code de récupération",
|
||||
"verify": "Vérifier",
|
||||
"verifyFailed": "Échec de la vérification. Veuillez réessayer.",
|
||||
"useSecurityKey": "Utiliser une clé de sécurité",
|
||||
"useRecoveryCode": "Utiliser un code de récupération",
|
||||
"useAuthenticator": "Utiliser votre application d'authentification",
|
||||
"backToLogin": "Retour à la connexion",
|
||||
"rememberDevice": "Faire confiance à cet appareil pendant 30 jours"
|
||||
},
|
||||
"enrollMfa": {
|
||||
"title": "Configurer un second facteur",
|
||||
"body": "Votre administrateur exige un second facteur sur ce compte. Ajoutez une application d'authentification ou une clé de sécurité pour continuer.",
|
||||
"signOut": "Se déconnecter"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -4057,7 +4077,12 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Sauvegarde complète",
|
||||
"description": "Un seul fichier JSON contenant tout — même format que la sauvegarde automatique hebdomadaire. Utile pour une restauration personnelle via le téléversement administrateur."
|
||||
"description": "Un seul fichier JSON contenant tout — même format que la sauvegarde automatique hebdomadaire. Utile pour une restauration personnelle via le téléversement administrateur.",
|
||||
"encryptToggle": "Chiffrer avec une phrase secrète",
|
||||
"passphrasePlaceholder": "Phrase secrète (12 caractères min.)",
|
||||
"passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.",
|
||||
"noRecoveryWarning": "Aucune récupération possible : si vous perdez cette phrase secrète, l'archive ne pourra pas être ouverte. Conservez-la en lieu sûr.",
|
||||
"stepUpNeeded": "Confirmez votre second facteur, puis réessayez."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cycle",
|
||||
@@ -4197,6 +4222,15 @@
|
||||
"title": "Canaux",
|
||||
"description": "Distribution via Telegram, ntfy, Web Push, Webhook et e-mail.",
|
||||
"subtitle": "Où sont envoyés les rappels et alertes."
|
||||
},
|
||||
"security": {
|
||||
"title": "Sécurité",
|
||||
"subtitle": "Authentification à deux facteurs, codes de récupération et clés d'accès."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Données et confidentialité",
|
||||
"subtitle": "Vos données, leur conservation, le chiffrement et la sécurité du compte au même endroit.",
|
||||
"description": "Export, suppression, conservation, état du chiffrement, sessions et activité de sécurité."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -4968,7 +5002,153 @@
|
||||
"polarDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
|
||||
"ouraDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
|
||||
"nightscoutDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
|
||||
"integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus."
|
||||
"integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus.",
|
||||
"security": {
|
||||
"sessionsTitle": "Sessions actives",
|
||||
"sessionsDescription": "Appareils et navigateurs actuellement connectés à votre compte.",
|
||||
"sessionsLoadError": "Impossible de charger les sessions actives.",
|
||||
"sessionsEmpty": "Aucune session active.",
|
||||
"currentSession": "Cet appareil",
|
||||
"lastActive": "Dernière activité {time}",
|
||||
"unknownLocation": "Lieu inconnu",
|
||||
"revokeSession": "Déconnecter",
|
||||
"revokeError": "Impossible de traiter la demande. Veuillez réessayer.",
|
||||
"signOutEverywhere": "Se déconnecter partout ailleurs",
|
||||
"signOutEverywhereDone": "{count} autre(s) session(s) déconnectée(s).",
|
||||
"activityTitle": "Activité de sécurité",
|
||||
"activityDescription": "Connexions récentes, modifications de sécurité et exports de données de votre compte.",
|
||||
"activityEmpty": "Aucune activité de sécurité pour l'instant.",
|
||||
"activityLoadError": "Impossible de charger l'activité de sécurité.",
|
||||
"action": {
|
||||
"newDevice": "Connexion depuis un nouvel appareil",
|
||||
"loginFailed": "Échec de connexion",
|
||||
"login": "Connexion",
|
||||
"passwordChange": "Mot de passe modifié",
|
||||
"mfa": "Modification de la double authentification",
|
||||
"sessionRevoke": "Session déconnectée",
|
||||
"token": "Modification de jeton API",
|
||||
"accountDelete": "Compte supprimé",
|
||||
"dataClear": "Données réinitialisées",
|
||||
"export": "Export de données",
|
||||
"other": "Événement de sécurité"
|
||||
},
|
||||
"active": "Actif",
|
||||
"inactive": "Inactif",
|
||||
"stepUpRequired": "Pour votre sécurité, reconnectez-vous pour modifier votre second facteur.",
|
||||
"useRecoveryCode": "Utiliser plutôt un code de récupération",
|
||||
"useAuthenticator": "Utiliser plutôt votre application d'authentification",
|
||||
"totp": {
|
||||
"title": "Application d'authentification",
|
||||
"description": "Utilisez un code temporaire d'une application d'authentification comme second facteur.",
|
||||
"setUp": "Configurer",
|
||||
"step1": "Scannez le code QR avec votre application d'authentification, ou saisissez le secret manuellement.",
|
||||
"step2": "Saisissez le code à 6 chiffres affiché par l'application pour confirmer.",
|
||||
"qrAlt": "Code QR pour configurer l'application d'authentification",
|
||||
"qrUnavailable": "Code QR indisponible",
|
||||
"manualEntry": "Ou saisissez ce secret manuellement",
|
||||
"enterCode": "Saisissez le code à 6 chiffres",
|
||||
"verify": "Vérifier",
|
||||
"setupFailed": "Impossible de démarrer la configuration. Veuillez réessayer.",
|
||||
"invalidCode": "Ce code n'est pas valide. Veuillez réessayer.",
|
||||
"disable": "Désactiver",
|
||||
"disableTitle": "Désactiver l'authentification à deux facteurs",
|
||||
"disableConfirm": "Saisissez un code actuel pour désactiver votre second facteur. Cela supprime aussi vos codes de récupération.",
|
||||
"currentCode": "Code actuel",
|
||||
"disableFailed": "Impossible de désactiver le second facteur."
|
||||
},
|
||||
"recovery": {
|
||||
"saveNow": "Enregistrez ces codes de récupération maintenant",
|
||||
"saveNowHint": "Chaque code ne fonctionne qu'une fois. Ils ne sont affichés que cette fois — conservez-les en lieu sûr. Si vous perdez votre authentificateur, un code de récupération est le seul moyen de revenir.",
|
||||
"download": "Télécharger",
|
||||
"remainingOne": "{count} code de récupération restant.",
|
||||
"remainingOther": "{count} codes de récupération restants.",
|
||||
"regenerate": "Régénérer les codes",
|
||||
"regenFailed": "Impossible de régénérer les codes de récupération.",
|
||||
"savedIt": "Je les ai enregistrés",
|
||||
"regenerateTitle": "Régénérer les codes de récupération ?",
|
||||
"regenerateConfirm": "Cela remplace vos codes de récupération actuels. Vos codes existants cesseront de fonctionner immédiatement et le nouveau jeu n'est affiché qu'une seule fois."
|
||||
},
|
||||
"keys": {
|
||||
"title": "Clés de sécurité",
|
||||
"description": "Enregistrez une clé de sécurité matérielle ou de plateforme comme second facteur.",
|
||||
"empty": "Aucune clé de sécurité enregistrée pour le moment.",
|
||||
"add": "Ajouter une clé de sécurité",
|
||||
"addFailed": "Impossible d'enregistrer la clé de sécurité.",
|
||||
"rename": "Renommer",
|
||||
"renameFailed": "Impossible de renommer la clé de sécurité.",
|
||||
"remove": "Supprimer la clé de sécurité",
|
||||
"removeFailed": "Impossible de supprimer la clé de sécurité.",
|
||||
"removeConfirm": "Cette clé de sécurité ne fonctionnera plus comme second facteur.",
|
||||
"lastUsed": "Dernière utilisation le {date}",
|
||||
"neverUsed": "Jamais utilisée",
|
||||
"added": "ajoutée le {date}",
|
||||
"nameLabel": "Nom de la clé de sécurité"
|
||||
},
|
||||
"passkeyNudge": {
|
||||
"title": "Ajoutez une clé d'accès pour une connexion plus rapide",
|
||||
"body": "Les clés d'accès vous permettent de vous connecter avec votre empreinte, votre visage ou le code de votre appareil, sans mot de passe à retenir."
|
||||
},
|
||||
"trustedDevices": {
|
||||
"title": "Appareils de confiance",
|
||||
"description": "Les navigateurs auxquels vous faites confiance ignorent le second facteur pendant 30 jours. Le mot de passe reste requis. Révoquez ceux que vous ne reconnaissez pas.",
|
||||
"loadError": "Impossible de charger les appareils de confiance.",
|
||||
"revokeError": "Impossible de révoquer l'appareil. Veuillez réessayer.",
|
||||
"revoke": "Révoquer",
|
||||
"revokeAll": "Oublier tous les appareils",
|
||||
"revokeAllDone": "{count} appareil(s) de confiance oublié(s).",
|
||||
"unnamed": "Appareil inconnu",
|
||||
"current": "Cet appareil",
|
||||
"expires": "De confiance jusqu'au {date}"
|
||||
}
|
||||
},
|
||||
"privacy": {
|
||||
"intro": {
|
||||
"title": "Vos données vous appartiennent",
|
||||
"body": "HealthLog est auto-hébergé. Cette page réunit la façon dont vos données sont protégées, leur durée de conservation, comment les exporter et comment les supprimer."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Chiffré au repos",
|
||||
"description": "Les champs sensibles sont chiffrés dans la base de données.",
|
||||
"summary": "Vos données sensibles sont chiffrées au repos avec {algorithm} sur {columns} champs dans {models} types d'enregistrement.",
|
||||
"statement": "Vos données sensibles sont chiffrées au repos avec AES-256-GCM."
|
||||
},
|
||||
"stored": {
|
||||
"title": "Ce qui est stocké",
|
||||
"description": "Les catégories de données que contient ce compte.",
|
||||
"measurements": "Mesures (poids, tension, pouls, glycémie, sommeil, composition corporelle)",
|
||||
"medications": "Médicaments et historique des prises",
|
||||
"moodLabs": "Entrées d'humeur et résultats de laboratoire",
|
||||
"coach": "Conversations avec le coach",
|
||||
"integrations": "Jetons d'intégration (chiffrés)",
|
||||
"security": "Activité du compte et de sécurité (sessions, journal de connexion)"
|
||||
},
|
||||
"retention": {
|
||||
"title": "Conservation",
|
||||
"description": "Durée de conservation des enregistrements limités dans le temps avant le nettoyage automatique.",
|
||||
"coach": "Conversations avec le coach : {days} jours",
|
||||
"audit": "Journal de sécurité : {days} jours",
|
||||
"delivery": "Journal d'envoi des notifications : {days} jours",
|
||||
"unavailable": "Les détails de conservation sont indisponibles pour le moment."
|
||||
},
|
||||
"export": {
|
||||
"title": "Télécharger mes données",
|
||||
"description": "Exportez vos enregistrements en CSV ou JSON, éventuellement chiffrés par phrase secrète, ou générez un dossier médical complet.",
|
||||
"openExport": "Export et sauvegarde",
|
||||
"openHealthRecord": "Dossier médical"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Supprimer ou réinitialiser",
|
||||
"description": "Supprimez complètement votre compte, ou effacez vos données de santé tout en conservant le compte.",
|
||||
"backupLag": "La suppression efface vos données immédiatement. Les sauvegardes chiffrées hors site les contiennent encore jusqu'à leur expiration selon la fenêtre de conservation de l'opérateur.",
|
||||
"openAdvanced": "Supprimer ou réinitialiser"
|
||||
},
|
||||
"posture": {
|
||||
"title": "Posture de confidentialité",
|
||||
"description": "Contrôlez la quantité de données partagées avec les fonctions d'IA et la recherche optionnelle.",
|
||||
"openAi": "Mode confidentialité IA",
|
||||
"openResearch": "Mode recherche"
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -5064,7 +5244,6 @@
|
||||
"webPushGlobal": "Autoriser les notifications push du navigateur.",
|
||||
"moodLogGlobal": "Autoriser les rappels du journal d'humeur.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Rapport de bug",
|
||||
"offlineGeoLabel": "Données géographiques",
|
||||
"offlineGeoEnabled": "Hors ligne (GeoLite2)",
|
||||
"offlineGeoFallback": "Fallback (ipwho.is) — définissez MAXMIND_LICENSE_KEY dans la CI",
|
||||
@@ -5101,12 +5280,6 @@
|
||||
"configured": "Configuré",
|
||||
"monitoringTestSuccess": "Événement de test envoyé",
|
||||
"monitoringTestFailed": "Échec du test de surveillance",
|
||||
"bugReportGithub": "Rapport de bug (GitHub Issues)",
|
||||
"bugReportGithubDescription": "Configurez le jeton d'issues GitHub et le dépôt pour les rapports de bug entrants.",
|
||||
"bugReportEnabledLabel": "Rapports de bug activés",
|
||||
"bugReportEnabledDescription": "Lorsque cette option est désactivée, le bouton de signalement dans l'app est masqué — le jeton et le dépôt restent configurés.",
|
||||
"bugReportDeprecatedBadge": "Obsolète",
|
||||
"bugReportDeprecatedNote": "Cette fonctionnalité est obsolète et sera supprimée dans une version future.",
|
||||
"assistant": {
|
||||
"title": "Surfaces d'assistance",
|
||||
"description": "Chaque bascule masque une surface pilotée par le modèle dans toute l'application sans retirer la configuration du fournisseur. Le commutateur principal force chaque option secondaire à off.",
|
||||
@@ -5140,10 +5313,6 @@
|
||||
"description": "Désactivez un module pour tout le serveur. Un module désactivé ici est coupé pour chaque compte, quelle que soit la préférence personnelle. Les domaines essentiels (poids, tension, pouls, médicaments) restent toujours actifs et ne peuvent pas être désactivés.",
|
||||
"saveError": "Impossible de mettre à jour la disponibilité des modules."
|
||||
},
|
||||
"bugReportRepo": "Dépôt",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Jeton d'issue",
|
||||
"bugReportTokenPlaceholder": "ghp_...",
|
||||
"invites": {
|
||||
"title": "Invitations",
|
||||
"description": "Liens d'invitation pour l'inscription — ils fonctionnent même lorsque les inscriptions sont fermées. Le lien n'est affiché qu'une seule fois.",
|
||||
@@ -5297,47 +5466,6 @@
|
||||
"reminderCheckNotifOff": "Notifications désactivées",
|
||||
"reminderCheckEventsToday": "Événements aujourd'hui",
|
||||
"reminderCheckNotifSent": "Envoyées",
|
||||
"feedback": {
|
||||
"tabOpen": "Ouvert",
|
||||
"tabAcknowledged": "Pris en compte",
|
||||
"tabResolved": "Résolu",
|
||||
"tabArchived": "Archivé",
|
||||
"subject": "Sujet",
|
||||
"category": "Catégorie",
|
||||
"user": "Utilisateur",
|
||||
"createdAt": "Soumis",
|
||||
"actions": "Actions",
|
||||
"details": "Détails",
|
||||
"emptyTitle": "Tout est à jour",
|
||||
"emptyDescription": "Aucun retour utilisateur dans ce statut. Les bugs, questions et demandes de fonctionnalités soumis apparaîtront ici.",
|
||||
"loading": "Chargement des retours...",
|
||||
"anonymous": "anonyme",
|
||||
"categoryBug": "Bug",
|
||||
"categoryFeature": "Fonctionnalité",
|
||||
"categoryQuestion": "Question",
|
||||
"categoryOther": "Autre",
|
||||
"metadataHeading": "Métadonnées",
|
||||
"metaUrl": "URL",
|
||||
"metaLocale": "Langue",
|
||||
"metaUserAgent": "Agent utilisateur",
|
||||
"metaAppVersion": "Version de l'app",
|
||||
"screenshotHeading": "Screenshot",
|
||||
"adminNote": "Note d'administration",
|
||||
"adminNotePlaceholder": "Note interne (non visible par l'utilisateur)",
|
||||
"saveNote": "Enregistrer la note",
|
||||
"noteSaved": "Note enregistrée",
|
||||
"actionAcknowledge": "Prendre en compte",
|
||||
"actionResolve": "Résoudre",
|
||||
"actionArchive": "Archiver",
|
||||
"actionPublishGithub": "Publier sur GitHub",
|
||||
"publishedToGithub": "Publié sur GitHub",
|
||||
"viewIssue": "Voir l'issue",
|
||||
"publishSuccess": "Issue créée",
|
||||
"publishFailed": "Échec de la publication sur GitHub",
|
||||
"updateFailed": "Échec de la mise à jour",
|
||||
"archiveConfirm": "Archiver cette entrée de retour ?",
|
||||
"submittedBy": "Soumis par"
|
||||
},
|
||||
"shell": {
|
||||
"overview": "Vue d'ensemble",
|
||||
"sectionsNav": "Sections d'administration"
|
||||
@@ -5390,10 +5518,6 @@
|
||||
"title": "Coach",
|
||||
"subtitle": "Surfaces d'assistance, clé du serveur et les tableaux de qualité des retours du Coach et des analyses, au même endroit."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Retours",
|
||||
"subtitle": "Triez les retours utilisateurs, les rapports de bug et les demandes de fonctionnalités."
|
||||
},
|
||||
"reminders": {
|
||||
"title": "Rappels",
|
||||
"subtitle": "Réglez les seuils des rappels de médicaments et lancez une vérification manuelle."
|
||||
@@ -5521,10 +5645,55 @@
|
||||
"module-availability": {
|
||||
"title": "Modules",
|
||||
"subtitle": "Activez ou désactivez les modules pour tous les comptes de ce serveur."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Chiffrement",
|
||||
"subtitle": "Couverture du chiffrement et état de la rotation des clés pour les données au repos.",
|
||||
"loading": "Chargement de l'état du chiffrement...",
|
||||
"loadError": "Échec du chargement de l'état du chiffrement.",
|
||||
"coverageTitle": "Couverture",
|
||||
"coverageDescription": "Combien de lignes chiffrées relèvent de chaque id de clé. Seuls les id de clé et les décomptes sont affichés, jamais le matériel de clé.",
|
||||
"activeKeyId": "Id de clé active",
|
||||
"configuredKeys": "Clés configurées",
|
||||
"totalRows": "Lignes chiffrées",
|
||||
"staleRows": "Lignes hors de la clé active",
|
||||
"safeToDropLegacy": "La clé ancienne peut être retirée",
|
||||
"rotationIncomplete": "{count} lignes restent à faire tourner",
|
||||
"rotationTitle": "Rotation des clés",
|
||||
"rotationDescription": "Re-chiffre chaque colonne chiffrée avec la clé active. L'outil en ligne de commande reste la voie canonique ; ce déclencheur est un confort.",
|
||||
"rotationState": "État",
|
||||
"lastRun": "Dernière exécution : {scanned} analysées, {rotated} tournées, {errors} erreurs.",
|
||||
"rotateNow": "Faire tourner maintenant",
|
||||
"rotateConfirmTitle": "Re-chiffrer tout le dossier ?",
|
||||
"rotateConfirmDescription": "Re-chiffre chaque colonne chiffrée avec la clé active. Cela n'ajoute ni ne retire jamais de clé et peut être relancé sans risque. Cela peut prendre du temps sur les gros comptes.",
|
||||
"cancel": "Annuler",
|
||||
"rotateConfirm": "Démarrer la rotation",
|
||||
"rotateEnqueued": "Rotation démarrée.",
|
||||
"rotateFailed": "Impossible de démarrer la rotation.",
|
||||
"stepUpNeeded": "Un second facteur récent est requis. Confirmez votre second facteur, ou utilisez l'outil en ligne de commande si aucun n'est configuré.",
|
||||
"cliNote": "Un second facteur est requis pour ce bouton. Les opérateurs sans second facteur utilisent scripts/rotate-encryption-key.ts.",
|
||||
"columnsTitle": "Couverture par colonne",
|
||||
"columnsDescription": "Chaque colonne chiffrée avec ses décomptes de lignes sur la clé active par rapport aux clés plus anciennes.",
|
||||
"colColumn": "Colonne",
|
||||
"colTotal": "Total",
|
||||
"colActive": "Sur clé active",
|
||||
"colStale": "Obsolètes",
|
||||
"state": {
|
||||
"idle": "Au repos",
|
||||
"running": "En cours",
|
||||
"completed": "Terminé",
|
||||
"failed": "Échoué"
|
||||
}
|
||||
}
|
||||
},
|
||||
"carrier": "Opérateur",
|
||||
"carrierUnavailableGeoFallback": "{location} — opérateur indisponible"
|
||||
"carrierUnavailableGeoFallback": "{location} — opérateur indisponible",
|
||||
"mfaRequired": "Exiger un second facteur",
|
||||
"mfaRequiredDescription": "Chaque compte sans deuxième facteur actif est dirigé vers la configuration après la connexion. Cela invite les utilisateurs web à l'activer, mais ne bloque pas l'accès direct par API ou natif (Bearer) pour les comptes pas encore configurés.",
|
||||
"mfaEnforcedOn": "MFA activé",
|
||||
"mfaEnforcedOff": "MFA désactivé",
|
||||
"mfaEnforcedOnHint": "Un second facteur est requis pour cet utilisateur. Cliquez pour ne plus l'exiger.",
|
||||
"mfaEnforcedOffHint": "Exiger un second facteur pour cet utilisateur."
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Réussites",
|
||||
@@ -5583,10 +5752,6 @@
|
||||
"title": "À l’ancienne",
|
||||
"description": "Connectez-vous au moins une fois avec un identifiant et un mot de passe."
|
||||
},
|
||||
"bugReport1": {
|
||||
"title": "Chasseur de bugs",
|
||||
"description": "Soumettez un rapport de bug."
|
||||
},
|
||||
"loginStreak7": {
|
||||
"title": "Série de connexions 7j",
|
||||
"description": "Connectez-vous 7 jours d’affilée."
|
||||
@@ -5775,10 +5940,6 @@
|
||||
"title": "Polyglotte",
|
||||
"description": "Vous avez changé la langue de l’application au moins une fois."
|
||||
},
|
||||
"hiddenBugBuddy": {
|
||||
"title": "Compagnon des bugs",
|
||||
"description": "Vous avez soumis au moins 5 rapports de bug — le projet vous adore."
|
||||
},
|
||||
"missFree7": {
|
||||
"title": "Aucune dose oubliée · 7j",
|
||||
"description": "7 jours consécutifs sans dose oubliée."
|
||||
@@ -5831,26 +5992,6 @@
|
||||
"empty": "Pas encore de réussites — continuez à suivre pour débloquer la première."
|
||||
}
|
||||
},
|
||||
"bugreport": {
|
||||
"title": "Rapport de bug",
|
||||
"subtitle": "Envoie un bug, une suggestion ou une question. L’administrateur du serveur verra ton envoi.",
|
||||
"loginRequired": "Connecte-toi pour signaler un bug.",
|
||||
"bugTitle": "Titre",
|
||||
"description": "Description",
|
||||
"submit": "Envoyer",
|
||||
"success": "Merci ! Ton rapport a bien été envoyé.",
|
||||
"errorCreating": "Erreur lors de la création",
|
||||
"category": "Type",
|
||||
"categoryBug": "Bug",
|
||||
"categoryFeature": "Suggestion",
|
||||
"categoryQuestion": "Question",
|
||||
"categoryOther": "Autre retour",
|
||||
"bugTitlePlaceholder": "Résumé court (optionnel)",
|
||||
"githubEscalationNote": "Ton retour est stocké localement. Si l’administrateur active l’intégration GitHub, il pourra être publié comme issue public à la demande.",
|
||||
"internalOnlyNote": "Ton retour est stocké localement et n’est visible que par l’administrateur de ce serveur.",
|
||||
"disabledTitle": "Les rapports de bug sont désactivés",
|
||||
"disabledBody": "L'administrateur a désactivé les rapports de bug sur ce serveur."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"eventColumn": "Événement",
|
||||
@@ -5955,7 +6096,13 @@
|
||||
"eventMeasurementReminder": "Rappel de mesure",
|
||||
"eventMeasurementReminderDesc": "Envoyé lorsqu'une mesure suivie est due.",
|
||||
"eventMedicationIntakeSync": "Synchronisation des prises",
|
||||
"eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée."
|
||||
"eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée.",
|
||||
"security": {
|
||||
"newDeviceTitle": "Nouvelle connexion à votre compte",
|
||||
"newDeviceBody": "Une nouvelle connexion depuis {device} ({location}). Si ce n'était pas vous, changez votre mot de passe immédiatement.",
|
||||
"unknownLocation": "lieu inconnu"
|
||||
},
|
||||
"loginRequired": "Connecte-toi pour gérer les notifications."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
|
||||
+239
-92
@@ -32,10 +32,10 @@
|
||||
"copy": "Copia",
|
||||
"copyDetails": "Copia dettagli",
|
||||
"retry": "Riprova",
|
||||
"reportIssue": "Segnala un problema",
|
||||
"showPassword": "Mostra password",
|
||||
"hidePassword": "Nascondi password",
|
||||
"learnMore": "Scopri di più"
|
||||
"learnMore": "Scopri di più",
|
||||
"dismiss": "Ignora"
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Rapporto sanitario",
|
||||
@@ -204,7 +204,6 @@
|
||||
"insights": "Insights",
|
||||
"achievements": "Traguardi",
|
||||
"settings": "Impostazioni",
|
||||
"bugreport": "Segnalazione bug",
|
||||
"admin": "Console di amministrazione",
|
||||
"logout": "Esci",
|
||||
"login": "Accedi",
|
||||
@@ -263,7 +262,28 @@
|
||||
"passkeySetupLater": "Puoi configurare le passkey più tardi dalle impostazioni.",
|
||||
"passwordMinLength": "Min. 12 caratteri",
|
||||
"passwordTooShort": "La password deve avere almeno {minLength} caratteri.",
|
||||
"privacyPolicy": "Informativa sulla privacy"
|
||||
"privacyPolicy": "Informativa sulla privacy",
|
||||
"passwordBreached": "Questa password compare in violazioni di dati note. Scegline un'altra.",
|
||||
"mfa": {
|
||||
"title": "Autenticazione a due fattori",
|
||||
"totpHint": "Inserisci il codice a 6 cifre della tua app di autenticazione.",
|
||||
"recoveryHint": "Inserisci uno dei tuoi codici di recupero salvati.",
|
||||
"securityKeyHint": "Usa la tua chiave di sicurezza registrata per continuare.",
|
||||
"codeLabel": "Codice di autenticazione",
|
||||
"recoveryLabel": "Codice di recupero",
|
||||
"verify": "Verifica",
|
||||
"verifyFailed": "Verifica non riuscita. Riprova.",
|
||||
"useSecurityKey": "Usa una chiave di sicurezza",
|
||||
"useRecoveryCode": "Usa un codice di recupero",
|
||||
"useAuthenticator": "Usa la tua app di autenticazione",
|
||||
"backToLogin": "Torna all'accesso",
|
||||
"rememberDevice": "Considera attendibile questo dispositivo per 30 giorni"
|
||||
},
|
||||
"enrollMfa": {
|
||||
"title": "Configura un secondo fattore",
|
||||
"body": "Il tuo amministratore richiede un secondo fattore su questo account. Aggiungi un'app di autenticazione o una chiave di sicurezza per continuare.",
|
||||
"signOut": "Esci"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Cruscotto",
|
||||
@@ -4057,7 +4077,12 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Backup completo",
|
||||
"description": "Un unico file JSON con tutto — stesso formato del backup automatico settimanale. Utile per il ripristino autonomo tramite il caricamento da amministratore."
|
||||
"description": "Un unico file JSON con tutto — stesso formato del backup automatico settimanale. Utile per il ripristino autonomo tramite il caricamento da amministratore.",
|
||||
"encryptToggle": "Cifra con una passphrase",
|
||||
"passphrasePlaceholder": "Passphrase (min. 12 caratteri)",
|
||||
"passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.",
|
||||
"noRecoveryWarning": "Non c'è recupero: se perdi questa passphrase, l'archivio non potrà essere aperto. Conservala in un luogo sicuro.",
|
||||
"stepUpNeeded": "Conferma il secondo fattore e riprova."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Ciclo",
|
||||
@@ -4197,6 +4222,15 @@
|
||||
"title": "Canali",
|
||||
"description": "Recapito tramite Telegram, ntfy, Web Push, Webhook ed e-mail.",
|
||||
"subtitle": "Dove vengono recapitati promemoria e avvisi."
|
||||
},
|
||||
"security": {
|
||||
"title": "Sicurezza",
|
||||
"subtitle": "Autenticazione a due fattori, codici di recupero e passkey."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Dati e privacy",
|
||||
"subtitle": "I tuoi dati, la conservazione, la cifratura e la sicurezza dell'account in un unico posto.",
|
||||
"description": "Esportazione, eliminazione, conservazione, stato della cifratura, sessioni e attività di sicurezza."
|
||||
}
|
||||
},
|
||||
"profile": "Profilo",
|
||||
@@ -4968,7 +5002,153 @@
|
||||
"polarDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
|
||||
"ouraDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
|
||||
"nightscoutDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
|
||||
"integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra."
|
||||
"integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra.",
|
||||
"security": {
|
||||
"sessionsTitle": "Sessioni attive",
|
||||
"sessionsDescription": "Dispositivi e browser attualmente connessi al tuo account.",
|
||||
"sessionsLoadError": "Impossibile caricare le sessioni attive.",
|
||||
"sessionsEmpty": "Nessuna sessione attiva.",
|
||||
"currentSession": "Questo dispositivo",
|
||||
"lastActive": "Ultima attività {time}",
|
||||
"unknownLocation": "Posizione sconosciuta",
|
||||
"revokeSession": "Disconnetti",
|
||||
"revokeError": "Impossibile completare la richiesta. Riprova.",
|
||||
"signOutEverywhere": "Disconnetti ovunque",
|
||||
"signOutEverywhereDone": "Disconnesse {count} altra/e sessione/i.",
|
||||
"activityTitle": "Attività di sicurezza",
|
||||
"activityDescription": "Accessi recenti, modifiche di sicurezza ed esportazioni di dati del tuo account.",
|
||||
"activityEmpty": "Nessuna attività di sicurezza.",
|
||||
"activityLoadError": "Impossibile caricare l'attività di sicurezza.",
|
||||
"action": {
|
||||
"newDevice": "Accesso da nuovo dispositivo",
|
||||
"loginFailed": "Accesso non riuscito",
|
||||
"login": "Accesso",
|
||||
"passwordChange": "Password modificata",
|
||||
"mfa": "Modifica autenticazione a due fattori",
|
||||
"sessionRevoke": "Sessione disconnessa",
|
||||
"token": "Modifica token API",
|
||||
"accountDelete": "Account eliminato",
|
||||
"dataClear": "Dati azzerati",
|
||||
"export": "Esportazione dati",
|
||||
"other": "Evento di sicurezza"
|
||||
},
|
||||
"active": "Attivo",
|
||||
"inactive": "Inattivo",
|
||||
"stepUpRequired": "Per la tua sicurezza, accedi di nuovo per modificare il secondo fattore.",
|
||||
"useRecoveryCode": "Usa invece un codice di recupero",
|
||||
"useAuthenticator": "Usa invece la tua app di autenticazione",
|
||||
"totp": {
|
||||
"title": "App di autenticazione",
|
||||
"description": "Usa un codice temporaneo di un'app di autenticazione come secondo fattore.",
|
||||
"setUp": "Configura",
|
||||
"step1": "Scansiona il codice QR con la tua app di autenticazione oppure inserisci il segreto manualmente.",
|
||||
"step2": "Inserisci il codice a 6 cifre mostrato dall'app per confermare.",
|
||||
"qrAlt": "Codice QR per la configurazione dell'app di autenticazione",
|
||||
"qrUnavailable": "Codice QR non disponibile",
|
||||
"manualEntry": "Oppure inserisci questo segreto manualmente",
|
||||
"enterCode": "Inserisci il codice a 6 cifre",
|
||||
"verify": "Verifica",
|
||||
"setupFailed": "Impossibile avviare la configurazione. Riprova.",
|
||||
"invalidCode": "Questo codice non è valido. Riprova.",
|
||||
"disable": "Disattiva",
|
||||
"disableTitle": "Disattiva l'autenticazione a due fattori",
|
||||
"disableConfirm": "Inserisci un codice attuale per disattivare il secondo fattore. Questo elimina anche i tuoi codici di recupero.",
|
||||
"currentCode": "Codice attuale",
|
||||
"disableFailed": "Impossibile disattivare il secondo fattore."
|
||||
},
|
||||
"recovery": {
|
||||
"saveNow": "Salva subito questi codici di recupero",
|
||||
"saveNowHint": "Ogni codice funziona una sola volta. Vengono mostrati solo questa volta: conservali in un luogo sicuro. Se perdi l'autenticatore, un codice di recupero è l'unico modo per rientrare.",
|
||||
"download": "Scarica",
|
||||
"remainingOne": "{count} codice di recupero rimanente.",
|
||||
"remainingOther": "{count} codici di recupero rimanenti.",
|
||||
"regenerate": "Rigenera i codici",
|
||||
"regenFailed": "Impossibile rigenerare i codici di recupero.",
|
||||
"savedIt": "Li ho salvati",
|
||||
"regenerateTitle": "Rigenerare i codici di recupero?",
|
||||
"regenerateConfirm": "Questa operazione sostituisce i codici di recupero attuali. I codici esistenti smetteranno di funzionare immediatamente e il nuovo set viene mostrato una sola volta."
|
||||
},
|
||||
"keys": {
|
||||
"title": "Chiavi di sicurezza",
|
||||
"description": "Registra una chiave di sicurezza hardware o di piattaforma come secondo fattore.",
|
||||
"empty": "Nessuna chiave di sicurezza ancora registrata.",
|
||||
"add": "Aggiungi chiave di sicurezza",
|
||||
"addFailed": "Impossibile registrare la chiave di sicurezza.",
|
||||
"rename": "Rinomina",
|
||||
"renameFailed": "Impossibile rinominare la chiave di sicurezza.",
|
||||
"remove": "Rimuovi chiave di sicurezza",
|
||||
"removeFailed": "Impossibile rimuovere la chiave di sicurezza.",
|
||||
"removeConfirm": "Questa chiave di sicurezza non funzionerà più come secondo fattore.",
|
||||
"lastUsed": "Usata l'ultima volta il {date}",
|
||||
"neverUsed": "Mai usata",
|
||||
"added": "aggiunta il {date}",
|
||||
"nameLabel": "Nome della chiave di sicurezza"
|
||||
},
|
||||
"passkeyNudge": {
|
||||
"title": "Aggiungi una passkey per un accesso più rapido",
|
||||
"body": "Le passkey ti permettono di accedere con impronta, volto o PIN del dispositivo, senza password da ricordare."
|
||||
},
|
||||
"trustedDevices": {
|
||||
"title": "Dispositivi attendibili",
|
||||
"description": "I browser che hai scelto di considerare attendibili saltano il secondo fattore per 30 giorni. La password è comunque richiesta. Revoca quelli che non riconosci.",
|
||||
"loadError": "Impossibile caricare i dispositivi attendibili.",
|
||||
"revokeError": "Impossibile revocare il dispositivo. Riprova.",
|
||||
"revoke": "Revoca",
|
||||
"revokeAll": "Dimentica tutti i dispositivi",
|
||||
"revokeAllDone": "Dimenticati {count} dispositivo/i attendibile/i.",
|
||||
"unnamed": "Dispositivo sconosciuto",
|
||||
"current": "Questo dispositivo",
|
||||
"expires": "Attendibile fino al {date}"
|
||||
}
|
||||
},
|
||||
"privacy": {
|
||||
"intro": {
|
||||
"title": "I tuoi dati restano tuoi",
|
||||
"body": "HealthLog è self-hosted. Questa pagina riunisce come sono protetti i tuoi dati, per quanto tempo vengono conservati, come esportarli e come eliminarli."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Cifrati a riposo",
|
||||
"description": "I campi sensibili sono cifrati nel database.",
|
||||
"summary": "I tuoi dati sensibili sono cifrati a riposo con {algorithm} su {columns} campi in {models} tipi di record.",
|
||||
"statement": "I tuoi dati sensibili sono cifrati a riposo con AES-256-GCM."
|
||||
},
|
||||
"stored": {
|
||||
"title": "Cosa viene memorizzato",
|
||||
"description": "Le categorie di dati contenute in questo account.",
|
||||
"measurements": "Misurazioni (peso, pressione, polso, glicemia, sonno, composizione corporea)",
|
||||
"medications": "Farmaci e cronologia delle assunzioni",
|
||||
"moodLabs": "Voci dell'umore e referti di laboratorio",
|
||||
"coach": "Conversazioni con il coach",
|
||||
"integrations": "Token di integrazione (cifrati)",
|
||||
"security": "Attività dell'account e di sicurezza (sessioni, registro degli accessi)"
|
||||
},
|
||||
"retention": {
|
||||
"title": "Conservazione",
|
||||
"description": "Per quanto tempo i record a tempo vengono conservati prima della pulizia automatica.",
|
||||
"coach": "Conversazioni con il coach: {days} giorni",
|
||||
"audit": "Registro di sicurezza: {days} giorni",
|
||||
"delivery": "Registro di invio delle notifiche: {days} giorni",
|
||||
"unavailable": "I dettagli di conservazione non sono disponibili al momento."
|
||||
},
|
||||
"export": {
|
||||
"title": "Scarica i miei dati",
|
||||
"description": "Esporta i tuoi record come CSV o JSON, eventualmente cifrati con passphrase, oppure genera un fascicolo sanitario completo.",
|
||||
"openExport": "Esportazione e backup",
|
||||
"openHealthRecord": "Fascicolo sanitario"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Elimina o reimposta",
|
||||
"description": "Elimina completamente il tuo account oppure cancella i tuoi dati sanitari mantenendo l'account.",
|
||||
"backupLag": "L'eliminazione rimuove subito i tuoi dati. I backup cifrati esterni li contengono ancora finché non scadono secondo la finestra di conservazione dell'operatore.",
|
||||
"openAdvanced": "Elimina o reimposta"
|
||||
},
|
||||
"posture": {
|
||||
"title": "Impostazione privacy",
|
||||
"description": "Controlla quanti dei tuoi dati vengono condivisi con le funzioni di IA e la ricerca facoltativa.",
|
||||
"openAi": "Modalità privacy IA",
|
||||
"openResearch": "Modalità ricerca"
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -5064,7 +5244,6 @@
|
||||
"webPushGlobal": "Consentire le notifiche push del browser.",
|
||||
"moodLogGlobal": "Consentire i promemoria del diario dell'umore.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Segnalazione di bug",
|
||||
"offlineGeoLabel": "Dati geografici",
|
||||
"offlineGeoEnabled": "Offline (GeoLite2)",
|
||||
"offlineGeoFallback": "Fallback (ipwho.is) — imposta MAXMIND_LICENSE_KEY in CI",
|
||||
@@ -5101,12 +5280,6 @@
|
||||
"configured": "Configurato",
|
||||
"monitoringTestSuccess": "Evento di prova inviato",
|
||||
"monitoringTestFailed": "Test di monitoraggio non riuscito",
|
||||
"bugReportGithub": "Segnalazione di bug (GitHub Issues)",
|
||||
"bugReportGithubDescription": "Configura il token delle issue di GitHub e il repository per le segnalazioni di bug in arrivo.",
|
||||
"bugReportEnabledLabel": "Segnalazioni di bug attive",
|
||||
"bugReportEnabledDescription": "Se disattivato, il pulsante di segnalazione nell'app viene nascosto: token e repository restano configurati.",
|
||||
"bugReportDeprecatedBadge": "Obsoleto",
|
||||
"bugReportDeprecatedNote": "Questa funzione è obsoleta e verrà rimossa in una versione futura.",
|
||||
"assistant": {
|
||||
"title": "Superfici dell'assistente",
|
||||
"description": "Ogni interruttore nasconde una superficie guidata dal modello in tutta l'app senza rimuovere la configurazione del provider. Il master forza ogni opzione secondaria su off.",
|
||||
@@ -5140,10 +5313,6 @@
|
||||
"description": "Disattiva un modulo per l'intero server. Un modulo disattivato qui è spento per ogni account, indipendentemente dalle preferenze personali. I domini principali (peso, pressione, polso, farmaci) sono sempre attivi e non possono essere disattivati.",
|
||||
"saveError": "Impossibile aggiornare la disponibilità dei moduli."
|
||||
},
|
||||
"bugReportRepo": "Repository",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Token delle issue",
|
||||
"bugReportTokenPlaceholder": "ghp_...",
|
||||
"invites": {
|
||||
"title": "Inviti",
|
||||
"description": "Link di invito per la registrazione: funzionano anche quando la registrazione è chiusa. Il link viene mostrato una sola volta.",
|
||||
@@ -5297,47 +5466,6 @@
|
||||
"reminderCheckNotifOff": "Notifiche disattivate",
|
||||
"reminderCheckEventsToday": "Eventi di oggi",
|
||||
"reminderCheckNotifSent": "Inviate",
|
||||
"feedback": {
|
||||
"tabOpen": "Aperto",
|
||||
"tabAcknowledged": "Preso in carico",
|
||||
"tabResolved": "Risolto",
|
||||
"tabArchived": "Archiviato",
|
||||
"subject": "Oggetto",
|
||||
"category": "Categoria",
|
||||
"user": "Utente",
|
||||
"createdAt": "Inviato",
|
||||
"actions": "Azioni",
|
||||
"details": "Dettagli",
|
||||
"emptyTitle": "Tutto in ordine",
|
||||
"emptyDescription": "Nessun feedback degli utenti in questo stato. Bug, domande e richieste di funzionalità inviati compariranno qui.",
|
||||
"loading": "Caricamento feedback...",
|
||||
"anonymous": "anonimo",
|
||||
"categoryBug": "Bug",
|
||||
"categoryFeature": "Funzionalità",
|
||||
"categoryQuestion": "Domanda",
|
||||
"categoryOther": "Altro",
|
||||
"metadataHeading": "Metadati",
|
||||
"metaUrl": "URL",
|
||||
"metaLocale": "Lingua",
|
||||
"metaUserAgent": "User agent",
|
||||
"metaAppVersion": "Versione dell'app",
|
||||
"screenshotHeading": "Screenshot",
|
||||
"adminNote": "Nota amministrativa",
|
||||
"adminNotePlaceholder": "Nota interna (non visibile all'utente)",
|
||||
"saveNote": "Salva nota",
|
||||
"noteSaved": "Nota salvata",
|
||||
"actionAcknowledge": "Prendi in carico",
|
||||
"actionResolve": "Risolvi",
|
||||
"actionArchive": "Archivia",
|
||||
"actionPublishGithub": "Pubblica su GitHub",
|
||||
"publishedToGithub": "Pubblicato su GitHub",
|
||||
"viewIssue": "Vedi issue",
|
||||
"publishSuccess": "Issue creata",
|
||||
"publishFailed": "Pubblicazione su GitHub non riuscita",
|
||||
"updateFailed": "Aggiornamento non riuscito",
|
||||
"archiveConfirm": "Archiviare questa voce di feedback?",
|
||||
"submittedBy": "Inviato da"
|
||||
},
|
||||
"shell": {
|
||||
"overview": "Panoramica",
|
||||
"sectionsNav": "Sezioni di amministrazione"
|
||||
@@ -5390,10 +5518,6 @@
|
||||
"title": "Coach",
|
||||
"subtitle": "Superfici dell'assistente, chiave del server e le tabelle di qualità del feedback di Coach e analisi, in un unico posto."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Feedback",
|
||||
"subtitle": "Smista i feedback degli utenti, le segnalazioni di bug e le richieste di funzionalità."
|
||||
},
|
||||
"reminders": {
|
||||
"title": "Promemoria",
|
||||
"subtitle": "Regola le soglie dei promemoria dei farmaci ed esegui un controllo manuale."
|
||||
@@ -5521,10 +5645,55 @@
|
||||
"module-availability": {
|
||||
"title": "Moduli",
|
||||
"subtitle": "Attiva o disattiva i moduli per tutti gli account su questo server."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Crittografia",
|
||||
"subtitle": "Copertura della crittografia e stato della rotazione delle chiavi per i dati a riposo.",
|
||||
"loading": "Caricamento dello stato della crittografia...",
|
||||
"loadError": "Impossibile caricare lo stato della crittografia.",
|
||||
"coverageTitle": "Copertura",
|
||||
"coverageDescription": "Quante righe cifrate stanno sotto ogni id di chiave. Vengono mostrati solo gli id di chiave e i conteggi, mai il materiale della chiave.",
|
||||
"activeKeyId": "Id chiave attiva",
|
||||
"configuredKeys": "Chiavi configurate",
|
||||
"totalRows": "Righe cifrate",
|
||||
"staleRows": "Righe non sulla chiave attiva",
|
||||
"safeToDropLegacy": "È sicuro rimuovere la vecchia chiave",
|
||||
"rotationIncomplete": "Restano {count} righe da ruotare",
|
||||
"rotationTitle": "Rotazione delle chiavi",
|
||||
"rotationDescription": "Ricifra ogni colonna cifrata con la chiave attiva. Lo strumento da riga di comando resta la via canonica; questo comando è una comodità.",
|
||||
"rotationState": "Stato",
|
||||
"lastRun": "Ultima esecuzione: {scanned} esaminate, {rotated} ruotate, {errors} errori.",
|
||||
"rotateNow": "Ruota ora",
|
||||
"rotateConfirmTitle": "Ricifrare l'intero archivio?",
|
||||
"rotateConfirmDescription": "Ricifra ogni colonna cifrata con la chiave attiva. Non aggiunge né rimuove mai una chiave ed è sicuro rilanciarlo. Su account grandi può richiedere tempo.",
|
||||
"cancel": "Annulla",
|
||||
"rotateConfirm": "Avvia rotazione",
|
||||
"rotateEnqueued": "Rotazione avviata.",
|
||||
"rotateFailed": "Impossibile avviare la rotazione.",
|
||||
"stepUpNeeded": "È richiesto un secondo fattore recente. Conferma il secondo fattore oppure usa lo strumento da riga di comando se non ne hai configurato uno.",
|
||||
"cliNote": "Per questo pulsante serve un secondo fattore. Gli operatori senza secondo fattore ruotano con scripts/rotate-encryption-key.ts.",
|
||||
"columnsTitle": "Copertura per colonna",
|
||||
"columnsDescription": "Ogni colonna cifrata con i conteggi di righe sulla chiave attiva rispetto alle chiavi più vecchie.",
|
||||
"colColumn": "Colonna",
|
||||
"colTotal": "Totale",
|
||||
"colActive": "Su chiave attiva",
|
||||
"colStale": "Obsolete",
|
||||
"state": {
|
||||
"idle": "Inattivo",
|
||||
"running": "In corso",
|
||||
"completed": "Completato",
|
||||
"failed": "Fallito"
|
||||
}
|
||||
}
|
||||
},
|
||||
"carrier": "Operatore",
|
||||
"carrierUnavailableGeoFallback": "{location} — operatore non disponibile"
|
||||
"carrierUnavailableGeoFallback": "{location} — operatore non disponibile",
|
||||
"mfaRequired": "Richiedi un secondo fattore",
|
||||
"mfaRequiredDescription": "Ogni account senza un secondo fattore attivo viene indirizzato alla configurazione dopo l'accesso. Questo invita gli utenti web ad attivarlo, ma non blocca l'accesso diretto via API o nativo (Bearer) per gli account non ancora configurati.",
|
||||
"mfaEnforcedOn": "MFA attivo",
|
||||
"mfaEnforcedOff": "MFA disattivato",
|
||||
"mfaEnforcedOnHint": "Per questo utente è richiesto un secondo fattore. Clicca per non richiederlo più.",
|
||||
"mfaEnforcedOffHint": "Richiedi un secondo fattore per questo utente."
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Obiettivi",
|
||||
@@ -5583,10 +5752,6 @@
|
||||
"title": "Vecchia scuola",
|
||||
"description": "Accedi almeno una volta con nome utente e password."
|
||||
},
|
||||
"bugReport1": {
|
||||
"title": "Cacciatore di bug",
|
||||
"description": "Invia una segnalazione di bug."
|
||||
},
|
||||
"loginStreak7": {
|
||||
"title": "Serie di accessi 7g",
|
||||
"description": "Accedi per 7 giorni consecutivi."
|
||||
@@ -5775,10 +5940,6 @@
|
||||
"title": "Poliglotta",
|
||||
"description": "Hai cambiato la lingua dell’app almeno una volta."
|
||||
},
|
||||
"hiddenBugBuddy": {
|
||||
"title": "Amico dei bug",
|
||||
"description": "Hai inviato almeno 5 segnalazioni di bug — il progetto ti ama."
|
||||
},
|
||||
"missFree7": {
|
||||
"title": "Nessuna dose persa · 7g",
|
||||
"description": "7 giorni consecutivi senza una dose persa."
|
||||
@@ -5831,26 +5992,6 @@
|
||||
"empty": "Ancora nessun obiettivo — continua a registrare per sbloccare il primo."
|
||||
}
|
||||
},
|
||||
"bugreport": {
|
||||
"title": "Segnalazione bug",
|
||||
"subtitle": "Invia un bug, un’idea o una domanda. L’amministratore del server vedrà la tua segnalazione.",
|
||||
"loginRequired": "Accedi per segnalare un bug.",
|
||||
"bugTitle": "Titolo",
|
||||
"description": "Descrizione",
|
||||
"submit": "Invia",
|
||||
"success": "Grazie! La tua segnalazione è stata inviata.",
|
||||
"errorCreating": "Errore durante la creazione",
|
||||
"category": "Tipo",
|
||||
"categoryBug": "Bug",
|
||||
"categoryFeature": "Suggerimento",
|
||||
"categoryQuestion": "Domanda",
|
||||
"categoryOther": "Altro feedback",
|
||||
"bugTitlePlaceholder": "Riassunto breve (opzionale)",
|
||||
"githubEscalationNote": "Il feedback viene salvato localmente. Se l’amministratore attiva l’integrazione GitHub, può essere pubblicato come issue pubblico su richiesta.",
|
||||
"internalOnlyNote": "Il feedback viene salvato localmente ed è visibile solo all’amministratore di questo server.",
|
||||
"disabledTitle": "Le segnalazioni di bug sono disattivate",
|
||||
"disabledBody": "L'amministratore ha disattivato le segnalazioni di bug su questo server."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifiche",
|
||||
"eventColumn": "Evento",
|
||||
@@ -5955,7 +6096,13 @@
|
||||
"eventMeasurementReminder": "Promemoria misurazione",
|
||||
"eventMeasurementReminderDesc": "Inviato quando una misurazione tracciata è in scadenza.",
|
||||
"eventMedicationIntakeSync": "Sincronizzazione assunzioni",
|
||||
"eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata."
|
||||
"eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata.",
|
||||
"security": {
|
||||
"newDeviceTitle": "Nuovo accesso al tuo account",
|
||||
"newDeviceBody": "Un nuovo accesso da {device} ({location}). Se non sei stato tu, cambia subito la password.",
|
||||
"unknownLocation": "posizione sconosciuta"
|
||||
},
|
||||
"loginRequired": "Accedi per gestire le notifiche."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
|
||||
+239
-92
@@ -32,10 +32,10 @@
|
||||
"copy": "Kopiuj",
|
||||
"copyDetails": "Kopiuj szczegóły",
|
||||
"retry": "Spróbuj ponownie",
|
||||
"reportIssue": "Zgłoś problem",
|
||||
"showPassword": "Pokaż hasło",
|
||||
"hidePassword": "Ukryj hasło",
|
||||
"learnMore": "Dowiedz się więcej"
|
||||
"learnMore": "Dowiedz się więcej",
|
||||
"dismiss": "Odrzuć"
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Raport zdrowotny",
|
||||
@@ -204,7 +204,6 @@
|
||||
"insights": "Insights",
|
||||
"achievements": "Osiągnięcia",
|
||||
"settings": "Ustawienia",
|
||||
"bugreport": "Zgłoszenie błędu",
|
||||
"admin": "Konsola administratora",
|
||||
"logout": "Wyloguj",
|
||||
"login": "Zaloguj",
|
||||
@@ -263,7 +262,28 @@
|
||||
"passkeySetupLater": "Passkey możesz skonfigurować później w ustawieniach.",
|
||||
"passwordMinLength": "Min. 12 znaków",
|
||||
"passwordTooShort": "Hasło musi mieć co najmniej {minLength} znaków.",
|
||||
"privacyPolicy": "Polityka prywatności"
|
||||
"privacyPolicy": "Polityka prywatności",
|
||||
"passwordBreached": "To hasło pojawia się w znanych wyciekach danych. Wybierz inne.",
|
||||
"mfa": {
|
||||
"title": "Uwierzytelnianie dwuskładnikowe",
|
||||
"totpHint": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej.",
|
||||
"recoveryHint": "Wprowadź jeden z zapisanych kodów odzyskiwania.",
|
||||
"securityKeyHint": "Użyj zarejestrowanego klucza bezpieczeństwa, aby kontynuować.",
|
||||
"codeLabel": "Kod uwierzytelniający",
|
||||
"recoveryLabel": "Kod odzyskiwania",
|
||||
"verify": "Zweryfikuj",
|
||||
"verifyFailed": "Weryfikacja nie powiodła się. Spróbuj ponownie.",
|
||||
"useSecurityKey": "Użyj klucza bezpieczeństwa",
|
||||
"useRecoveryCode": "Użyj kodu odzyskiwania",
|
||||
"useAuthenticator": "Użyj aplikacji uwierzytelniającej",
|
||||
"backToLogin": "Powrót do logowania",
|
||||
"rememberDevice": "Zaufaj temu urządzeniu na 30 dni"
|
||||
},
|
||||
"enrollMfa": {
|
||||
"title": "Skonfiguruj drugi składnik",
|
||||
"body": "Administrator wymaga drugiego składnika na tym koncie. Dodaj aplikację uwierzytelniającą lub klucz bezpieczeństwa, aby kontynuować.",
|
||||
"signOut": "Wyloguj się"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Pulpit",
|
||||
@@ -4057,7 +4077,12 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Pełna kopia zapasowa",
|
||||
"description": "Pojedynczy plik JSON ze wszystkim — taki sam format jak cotygodniowa automatyczna kopia. Przydatny do samodzielnego przywracania przez przesłanie w panelu administratora."
|
||||
"description": "Pojedynczy plik JSON ze wszystkim — taki sam format jak cotygodniowa automatyczna kopia. Przydatny do samodzielnego przywracania przez przesłanie w panelu administratora.",
|
||||
"encryptToggle": "Zaszyfruj hasłem-frazą",
|
||||
"passphrasePlaceholder": "Hasło-fraza (min. 12 znaków)",
|
||||
"passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.",
|
||||
"noRecoveryWarning": "Nie ma odzyskiwania — jeśli zgubisz tę frazę, archiwum nie da się otworzyć. Przechowuj ją bezpiecznie.",
|
||||
"stepUpNeeded": "Potwierdź drugi składnik i spróbuj ponownie."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cykl",
|
||||
@@ -4197,6 +4222,15 @@
|
||||
"title": "Kanały",
|
||||
"description": "Dostarczanie przez Telegram, ntfy, Web Push, Webhook i e-mail.",
|
||||
"subtitle": "Gdzie dostarczane są przypomnienia i powiadomienia."
|
||||
},
|
||||
"security": {
|
||||
"title": "Bezpieczeństwo",
|
||||
"subtitle": "Uwierzytelnianie dwuskładnikowe, kody odzyskiwania i klucze dostępu."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Dane i prywatność",
|
||||
"subtitle": "Twoje dane, retencja, szyfrowanie i bezpieczeństwo konta w jednym miejscu.",
|
||||
"description": "Eksport, usuwanie, retencja, stan szyfrowania, sesje i aktywność zabezpieczeń."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -4968,7 +5002,153 @@
|
||||
"polarDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
|
||||
"ouraDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
|
||||
"nightscoutDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
|
||||
"integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej."
|
||||
"integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej.",
|
||||
"security": {
|
||||
"sessionsTitle": "Aktywne sesje",
|
||||
"sessionsDescription": "Urządzenia i przeglądarki obecnie zalogowane na Twoje konto.",
|
||||
"sessionsLoadError": "Nie udało się załadować aktywnych sesji.",
|
||||
"sessionsEmpty": "Brak aktywnych sesji.",
|
||||
"currentSession": "To urządzenie",
|
||||
"lastActive": "Ostatnia aktywność {time}",
|
||||
"unknownLocation": "Nieznana lokalizacja",
|
||||
"revokeSession": "Wyloguj",
|
||||
"revokeError": "Nie udało się zrealizować żądania. Spróbuj ponownie.",
|
||||
"signOutEverywhere": "Wyloguj na pozostałych urządzeniach",
|
||||
"signOutEverywhereDone": "Wylogowano {count} inną/e sesję/e.",
|
||||
"activityTitle": "Aktywność zabezpieczeń",
|
||||
"activityDescription": "Ostatnie logowania, zmiany zabezpieczeń i eksporty danych Twojego konta.",
|
||||
"activityEmpty": "Brak aktywności zabezpieczeń.",
|
||||
"activityLoadError": "Nie udało się załadować aktywności zabezpieczeń.",
|
||||
"action": {
|
||||
"newDevice": "Logowanie z nowego urządzenia",
|
||||
"loginFailed": "Nieudane logowanie",
|
||||
"login": "Logowanie",
|
||||
"passwordChange": "Zmieniono hasło",
|
||||
"mfa": "Zmiana uwierzytelniania dwuskładnikowego",
|
||||
"sessionRevoke": "Wylogowano sesję",
|
||||
"token": "Zmiana tokenu API",
|
||||
"accountDelete": "Konto usunięte",
|
||||
"dataClear": "Zresetowano dane",
|
||||
"export": "Eksport danych",
|
||||
"other": "Zdarzenie zabezpieczeń"
|
||||
},
|
||||
"active": "Aktywne",
|
||||
"inactive": "Nieaktywne",
|
||||
"stepUpRequired": "Ze względów bezpieczeństwa zaloguj się ponownie, aby zmienić drugi składnik.",
|
||||
"useRecoveryCode": "Użyj zamiast tego kodu odzyskiwania",
|
||||
"useAuthenticator": "Użyj zamiast tego aplikacji uwierzytelniającej",
|
||||
"totp": {
|
||||
"title": "Aplikacja uwierzytelniająca",
|
||||
"description": "Użyj kodu czasowego z aplikacji uwierzytelniającej jako drugiego składnika.",
|
||||
"setUp": "Skonfiguruj",
|
||||
"step1": "Zeskanuj kod QR aplikacją uwierzytelniającą lub wprowadź sekret ręcznie.",
|
||||
"step2": "Wprowadź 6-cyfrowy kod wyświetlany przez aplikację, aby potwierdzić.",
|
||||
"qrAlt": "Kod QR do konfiguracji aplikacji uwierzytelniającej",
|
||||
"qrUnavailable": "Kod QR niedostępny",
|
||||
"manualEntry": "Lub wprowadź ten sekret ręcznie",
|
||||
"enterCode": "Wprowadź 6-cyfrowy kod",
|
||||
"verify": "Zweryfikuj",
|
||||
"setupFailed": "Nie udało się rozpocząć konfiguracji. Spróbuj ponownie.",
|
||||
"invalidCode": "Ten kod jest nieprawidłowy. Spróbuj ponownie.",
|
||||
"disable": "Wyłącz",
|
||||
"disableTitle": "Wyłącz uwierzytelnianie dwuskładnikowe",
|
||||
"disableConfirm": "Wprowadź bieżący kod, aby wyłączyć drugi składnik. Spowoduje to również usunięcie kodów odzyskiwania.",
|
||||
"currentCode": "Bieżący kod",
|
||||
"disableFailed": "Nie udało się wyłączyć drugiego składnika."
|
||||
},
|
||||
"recovery": {
|
||||
"saveNow": "Zapisz teraz te kody odzyskiwania",
|
||||
"saveNowHint": "Każdy kod działa raz. Są wyświetlane tylko teraz — przechowuj je w bezpiecznym miejscu. Jeśli stracisz aplikację uwierzytelniającą, kod odzyskiwania będzie jedyną drogą powrotu.",
|
||||
"download": "Pobierz",
|
||||
"remainingOne": "Pozostał {count} kod odzyskiwania.",
|
||||
"remainingOther": "Pozostało {count} kodów odzyskiwania.",
|
||||
"regenerate": "Wygeneruj kody ponownie",
|
||||
"regenFailed": "Nie udało się ponownie wygenerować kodów odzyskiwania.",
|
||||
"savedIt": "Zapisałem je",
|
||||
"regenerateTitle": "Wygenerować nowe kody odzyskiwania?",
|
||||
"regenerateConfirm": "Spowoduje to zastąpienie obecnych kodów odzyskiwania. Dotychczasowe kody natychmiast przestaną działać, a nowy zestaw zostanie pokazany tylko raz."
|
||||
},
|
||||
"keys": {
|
||||
"title": "Klucze bezpieczeństwa",
|
||||
"description": "Zarejestruj sprzętowy lub systemowy klucz bezpieczeństwa jako drugi składnik.",
|
||||
"empty": "Nie zarejestrowano jeszcze żadnych kluczy bezpieczeństwa.",
|
||||
"add": "Dodaj klucz bezpieczeństwa",
|
||||
"addFailed": "Nie udało się zarejestrować klucza bezpieczeństwa.",
|
||||
"rename": "Zmień nazwę",
|
||||
"renameFailed": "Nie udało się zmienić nazwy klucza bezpieczeństwa.",
|
||||
"remove": "Usuń klucz bezpieczeństwa",
|
||||
"removeFailed": "Nie udało się usunąć klucza bezpieczeństwa.",
|
||||
"removeConfirm": "Ten klucz bezpieczeństwa nie będzie już działać jako drugi składnik.",
|
||||
"lastUsed": "Ostatnio użyty {date}",
|
||||
"neverUsed": "Nigdy nieużywany",
|
||||
"added": "dodany {date}",
|
||||
"nameLabel": "Nazwa klucza bezpieczeństwa"
|
||||
},
|
||||
"passkeyNudge": {
|
||||
"title": "Dodaj klucz dostępu, aby logować się szybciej",
|
||||
"body": "Klucze dostępu pozwalają logować się odciskiem palca, twarzą lub kodem PIN urządzenia — bez hasła do zapamiętania."
|
||||
},
|
||||
"trustedDevices": {
|
||||
"title": "Zaufane urządzenia",
|
||||
"description": "Przeglądarki, którym zaufałeś, pomijają drugi składnik przez 30 dni. Hasło jest nadal wymagane. Cofnij zaufanie tym, których nie rozpoznajesz.",
|
||||
"loadError": "Nie udało się załadować zaufanych urządzeń.",
|
||||
"revokeError": "Nie udało się cofnąć urządzenia. Spróbuj ponownie.",
|
||||
"revoke": "Cofnij",
|
||||
"revokeAll": "Zapomnij wszystkie urządzenia",
|
||||
"revokeAllDone": "Zapomniano {count} zaufane urządzenie/a.",
|
||||
"unnamed": "Nieznane urządzenie",
|
||||
"current": "To urządzenie",
|
||||
"expires": "Zaufane do {date}"
|
||||
}
|
||||
},
|
||||
"privacy": {
|
||||
"intro": {
|
||||
"title": "Twoje dane pozostają Twoje",
|
||||
"body": "HealthLog jest hostowany samodzielnie. Ta strona zbiera w jednym miejscu informacje o tym, jak chronione są Twoje dane, jak długo są przechowywane, jak je wyeksportować i jak je usunąć."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Szyfrowane w spoczynku",
|
||||
"description": "Wrażliwe pola są szyfrowane w bazie danych.",
|
||||
"summary": "Twoje wrażliwe dane są szyfrowane w spoczynku algorytmem {algorithm} w {columns} polach w {models} typach rekordów.",
|
||||
"statement": "Twoje wrażliwe dane są szyfrowane w spoczynku algorytmem AES-256-GCM."
|
||||
},
|
||||
"stored": {
|
||||
"title": "Co jest przechowywane",
|
||||
"description": "Kategorie danych przechowywanych na tym koncie.",
|
||||
"measurements": "Pomiary (waga, ciśnienie, tętno, glukoza, sen, skład ciała)",
|
||||
"medications": "Leki i historia przyjmowania",
|
||||
"moodLabs": "Wpisy nastroju i wyniki badań laboratoryjnych",
|
||||
"coach": "Rozmowy z asystentem",
|
||||
"integrations": "Tokeny integracji (szyfrowane)",
|
||||
"security": "Aktywność konta i zabezpieczeń (sesje, dziennik logowań)"
|
||||
},
|
||||
"retention": {
|
||||
"title": "Retencja",
|
||||
"description": "Jak długo rekordy ograniczone czasowo są przechowywane przed automatycznym czyszczeniem.",
|
||||
"coach": "Rozmowy z asystentem: {days} dni",
|
||||
"audit": "Dziennik zabezpieczeń: {days} dni",
|
||||
"delivery": "Dziennik wysyłki powiadomień: {days} dni",
|
||||
"unavailable": "Szczegóły przechowywania są obecnie niedostępne."
|
||||
},
|
||||
"export": {
|
||||
"title": "Pobierz moje dane",
|
||||
"description": "Wyeksportuj swoje rekordy jako CSV lub JSON, opcjonalnie zaszyfrowane hasłem, albo wygeneruj pełną dokumentację medyczną.",
|
||||
"openExport": "Eksport i kopia zapasowa",
|
||||
"openHealthRecord": "Dokumentacja medyczna"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Usuń lub zresetuj",
|
||||
"description": "Usuń całe konto albo wyczyść dane zdrowotne, zachowując konto.",
|
||||
"backupLag": "Usunięcie natychmiast usuwa Twoje dane. Zaszyfrowane kopie zapasowe poza serwerem nadal je zawierają, dopóki nie wygasną zgodnie z oknem retencji operatora.",
|
||||
"openAdvanced": "Usuń lub zresetuj"
|
||||
},
|
||||
"posture": {
|
||||
"title": "Ustawienia prywatności",
|
||||
"description": "Kontroluj, ile Twoich danych jest udostępnianych funkcjom AI i opcjonalnym badaniom.",
|
||||
"openAi": "Tryb prywatności AI",
|
||||
"openResearch": "Tryb badawczy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administracja",
|
||||
@@ -5064,7 +5244,6 @@
|
||||
"webPushGlobal": "Zezwól na powiadomienia push przeglądarki.",
|
||||
"moodLogGlobal": "Zezwól na przypomnienia dziennika nastroju.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Zgłoszenie błędu",
|
||||
"offlineGeoLabel": "Dane geograficzne",
|
||||
"offlineGeoEnabled": "Offline (GeoLite2)",
|
||||
"offlineGeoFallback": "Awaryjne (ipwho.is) — ustaw MAXMIND_LICENSE_KEY w CI",
|
||||
@@ -5101,12 +5280,6 @@
|
||||
"configured": "Skonfigurowano",
|
||||
"monitoringTestSuccess": "Wysłano zdarzenie testowe",
|
||||
"monitoringTestFailed": "Test monitorowania nie powiódł się",
|
||||
"bugReportGithub": "Zgłoszenie błędu (GitHub Issues)",
|
||||
"bugReportGithubDescription": "Skonfiguruj token zgłoszeń GitHub i repozytorium dla przychodzących zgłoszeń błędów.",
|
||||
"bugReportEnabledLabel": "Zgłaszanie błędów włączone",
|
||||
"bugReportEnabledDescription": "Gdy wyłączone, przycisk zgłaszania w aplikacji jest ukryty — token i repozytorium pozostają skonfigurowane.",
|
||||
"bugReportDeprecatedBadge": "Wycofane",
|
||||
"bugReportDeprecatedNote": "Ta funkcja jest wycofywana i zostanie usunięta w przyszłej wersji.",
|
||||
"assistant": {
|
||||
"title": "Powierzchnie asystenta",
|
||||
"description": "Każdy przełącznik ukrywa powierzchnię opartą na modelu w całej aplikacji bez usuwania konfiguracji dostawcy. Główny wyłącznik wymusza wyłączenie każdej opcji podrzędnej.",
|
||||
@@ -5140,10 +5313,6 @@
|
||||
"description": "Wyłącz moduł dla całego serwera. Moduł wyłączony tutaj jest nieaktywny dla każdego konta, niezależnie od preferencji użytkownika. Domeny podstawowe (waga, ciśnienie, tętno, leki) są zawsze włączone i nie można ich wyłączyć.",
|
||||
"saveError": "Nie udało się zaktualizować dostępności modułów."
|
||||
},
|
||||
"bugReportRepo": "Repozytorium",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Token zgłoszeń",
|
||||
"bugReportTokenPlaceholder": "ghp_...",
|
||||
"invites": {
|
||||
"title": "Zaproszenia",
|
||||
"description": "Linki z zaproszeniem do rejestracji — działają nawet przy zamkniętej rejestracji. Link jest wyświetlany tylko raz.",
|
||||
@@ -5297,47 +5466,6 @@
|
||||
"reminderCheckNotifOff": "Powiadomienia wyłączone",
|
||||
"reminderCheckEventsToday": "Zdarzenia dzisiaj",
|
||||
"reminderCheckNotifSent": "Wysłane",
|
||||
"feedback": {
|
||||
"tabOpen": "Otwarte",
|
||||
"tabAcknowledged": "Potwierdzone",
|
||||
"tabResolved": "Rozwiązane",
|
||||
"tabArchived": "Zarchiwizowane",
|
||||
"subject": "Temat",
|
||||
"category": "Kategoria",
|
||||
"user": "Użytkownik",
|
||||
"createdAt": "Przesłano",
|
||||
"actions": "Akcje",
|
||||
"details": "Szczegóły",
|
||||
"emptyTitle": "Wszystko na bieżąco",
|
||||
"emptyDescription": "Brak opinii użytkowników w tym statusie. Przesłane błędy, pytania i prośby o funkcje pojawią się tutaj.",
|
||||
"loading": "Ładowanie opinii...",
|
||||
"anonymous": "anonimowy",
|
||||
"categoryBug": "Błąd",
|
||||
"categoryFeature": "Funkcja",
|
||||
"categoryQuestion": "Pytanie",
|
||||
"categoryOther": "Inne",
|
||||
"metadataHeading": "Metadane",
|
||||
"metaUrl": "URL",
|
||||
"metaLocale": "Język",
|
||||
"metaUserAgent": "Agent użytkownika",
|
||||
"metaAppVersion": "Wersja aplikacji",
|
||||
"screenshotHeading": "Screenshot",
|
||||
"adminNote": "Notatka administratora",
|
||||
"adminNotePlaceholder": "Notatka wewnętrzna (niewidoczna dla użytkownika)",
|
||||
"saveNote": "Zapisz notatkę",
|
||||
"noteSaved": "Notatka zapisana",
|
||||
"actionAcknowledge": "Potwierdź",
|
||||
"actionResolve": "Rozwiąż",
|
||||
"actionArchive": "Archiwizuj",
|
||||
"actionPublishGithub": "Opublikuj na GitHub",
|
||||
"publishedToGithub": "Opublikowano na GitHub",
|
||||
"viewIssue": "Zobacz zgłoszenie",
|
||||
"publishSuccess": "Utworzono zgłoszenie",
|
||||
"publishFailed": "Publikacja na GitHub nie powiodła się",
|
||||
"updateFailed": "Aktualizacja nie powiodła się",
|
||||
"archiveConfirm": "Zarchiwizować ten wpis opinii?",
|
||||
"submittedBy": "Przesłane przez"
|
||||
},
|
||||
"shell": {
|
||||
"overview": "Przegląd",
|
||||
"sectionsNav": "Sekcje administracyjne"
|
||||
@@ -5390,10 +5518,6 @@
|
||||
"title": "Coach",
|
||||
"subtitle": "Powierzchnie asystenta, klucz serwera oraz tabele jakości opinii Coacha i analiz w jednym miejscu."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Opinie",
|
||||
"subtitle": "Segreguj opinie użytkowników, zgłoszenia błędów i prośby o funkcje."
|
||||
},
|
||||
"reminders": {
|
||||
"title": "Przypomnienia",
|
||||
"subtitle": "Dostrój progi przypomnień o lekach i uruchom ręczne sprawdzenie."
|
||||
@@ -5521,10 +5645,55 @@
|
||||
"module-availability": {
|
||||
"title": "Moduły",
|
||||
"subtitle": "Włącz lub wyłącz moduły dla wszystkich kont na tym serwerze."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Szyfrowanie",
|
||||
"subtitle": "Pokrycie szyfrowaniem i status rotacji kluczy dla danych w spoczynku.",
|
||||
"loading": "Ładowanie statusu szyfrowania...",
|
||||
"loadError": "Nie udało się wczytać statusu szyfrowania.",
|
||||
"coverageTitle": "Pokrycie",
|
||||
"coverageDescription": "Ile zaszyfrowanych wierszy przypada na każdy identyfikator klucza. Pokazywane są tylko identyfikatory kluczy i liczby — nigdy materiał klucza.",
|
||||
"activeKeyId": "Identyfikator aktywnego klucza",
|
||||
"configuredKeys": "Skonfigurowane klucze",
|
||||
"totalRows": "Zaszyfrowane wiersze",
|
||||
"staleRows": "Wiersze poza aktywnym kluczem",
|
||||
"safeToDropLegacy": "Można bezpiecznie usunąć stary klucz",
|
||||
"rotationIncomplete": "Pozostało {count} wierszy do rotacji",
|
||||
"rotationTitle": "Rotacja kluczy",
|
||||
"rotationDescription": "Ponownie szyfruje każdą zaszyfrowaną kolumnę aktywnym kluczem. Narzędzie wiersza poleceń pozostaje kanoniczną drogą; ten przycisk to udogodnienie.",
|
||||
"rotationState": "Status",
|
||||
"lastRun": "Ostatni przebieg: sprawdzono {scanned}, zrotowano {rotated}, błędy {errors}.",
|
||||
"rotateNow": "Rotuj teraz",
|
||||
"rotateConfirmTitle": "Ponownie zaszyfrować cały rekord?",
|
||||
"rotateConfirmDescription": "Ponownie szyfruje każdą zaszyfrowaną kolumnę aktywnym kluczem. Nigdy nie dodaje ani nie usuwa klucza i można go bezpiecznie uruchomić ponownie. Na dużych kontach może to chwilę potrwać.",
|
||||
"cancel": "Anuluj",
|
||||
"rotateConfirm": "Rozpocznij rotację",
|
||||
"rotateEnqueued": "Rotacja rozpoczęta.",
|
||||
"rotateFailed": "Nie udało się rozpocząć rotacji.",
|
||||
"stepUpNeeded": "Wymagany jest świeży drugi składnik. Potwierdź drugi składnik lub użyj narzędzia wiersza poleceń, jeśli nie masz skonfigurowanego żadnego.",
|
||||
"cliNote": "Do tego przycisku wymagany jest drugi składnik. Operatorzy bez niego rotują przez scripts/rotate-encryption-key.ts.",
|
||||
"columnsTitle": "Pokrycie wg kolumn",
|
||||
"columnsDescription": "Każda zaszyfrowana kolumna z liczbą wierszy na aktywnym kluczu względem starszych kluczy.",
|
||||
"colColumn": "Kolumna",
|
||||
"colTotal": "Razem",
|
||||
"colActive": "Na aktywnym kluczu",
|
||||
"colStale": "Przestarzałe",
|
||||
"state": {
|
||||
"idle": "Bezczynny",
|
||||
"running": "W toku",
|
||||
"completed": "Zakończono",
|
||||
"failed": "Niepowodzenie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"carrier": "Operator",
|
||||
"carrierUnavailableGeoFallback": "{location} — operator niedostępny"
|
||||
"carrierUnavailableGeoFallback": "{location} — operator niedostępny",
|
||||
"mfaRequired": "Wymagaj drugiego składnika",
|
||||
"mfaRequiredDescription": "Każde konto bez aktywnego drugiego składnika jest po zalogowaniu kierowane do konfiguracji. To zachęca użytkowników sieci do włączenia go, ale nie blokuje bezpośredniego dostępu przez API ani natywnego (Bearer) dla kont jeszcze nieskonfigurowanych.",
|
||||
"mfaEnforcedOn": "MFA wł.",
|
||||
"mfaEnforcedOff": "MFA wył.",
|
||||
"mfaEnforcedOnHint": "Ten użytkownik musi używać drugiego składnika. Kliknij, aby przestać go wymagać.",
|
||||
"mfaEnforcedOffHint": "Wymagaj drugiego składnika dla tego użytkownika."
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Osiągnięcia",
|
||||
@@ -5583,10 +5752,6 @@
|
||||
"title": "Stara szkoła",
|
||||
"description": "Zaloguj się co najmniej raz nazwą użytkownika i hasłem."
|
||||
},
|
||||
"bugReport1": {
|
||||
"title": "Łowca błędów",
|
||||
"description": "Wyślij zgłoszenie błędu."
|
||||
},
|
||||
"loginStreak7": {
|
||||
"title": "Seria logowań 7d",
|
||||
"description": "Zaloguj się przez 7 kolejnych dni."
|
||||
@@ -5775,10 +5940,6 @@
|
||||
"title": "Poliglota",
|
||||
"description": "Zmieniłeś język aplikacji co najmniej raz."
|
||||
},
|
||||
"hiddenBugBuddy": {
|
||||
"title": "Towarzysz błędów",
|
||||
"description": "Wysłałeś co najmniej 5 zgłoszeń błędów — projekt Cię kocha."
|
||||
},
|
||||
"missFree7": {
|
||||
"title": "Bez pominiętej dawki · 7 dni",
|
||||
"description": "7 kolejnych dni bez pominiętej dawki."
|
||||
@@ -5831,26 +5992,6 @@
|
||||
"empty": "Brak osiągnięć — zapisuj dalej, aby odblokować pierwsze."
|
||||
}
|
||||
},
|
||||
"bugreport": {
|
||||
"title": "Zgłoszenie błędu",
|
||||
"subtitle": "Wyślij zgłoszenie błędu, sugestię funkcji lub pytanie. Administrator serwera zobaczy twoje zgłoszenie.",
|
||||
"loginRequired": "Zaloguj się, aby zgłosić błąd.",
|
||||
"bugTitle": "Tytuł",
|
||||
"description": "Opis",
|
||||
"submit": "Wyślij",
|
||||
"success": "Dziękujemy! Twoje zgłoszenie zostało wysłane.",
|
||||
"errorCreating": "Błąd tworzenia",
|
||||
"category": "Rodzaj",
|
||||
"categoryBug": "Błąd",
|
||||
"categoryFeature": "Propozycja funkcji",
|
||||
"categoryQuestion": "Pytanie",
|
||||
"categoryOther": "Inna opinia",
|
||||
"bugTitlePlaceholder": "Krótkie podsumowanie (opcjonalnie)",
|
||||
"githubEscalationNote": "Twoja opinia zapisuje się lokalnie. Jeśli administrator włączy integrację z GitHubem, może zostać opublikowana jako publiczne issue na życzenie.",
|
||||
"internalOnlyNote": "Twoja opinia zapisuje się lokalnie i jest widoczna tylko dla administratora tego serwera.",
|
||||
"disabledTitle": "Zgłoszenia błędów są wyłączone",
|
||||
"disabledBody": "Administrator wyłączył zgłoszenia błędów na tym serwerze."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Powiadomienia",
|
||||
"eventColumn": "Zdarzenie",
|
||||
@@ -5955,7 +6096,13 @@
|
||||
"eventMeasurementReminder": "Przypomnienie o pomiarze",
|
||||
"eventMeasurementReminderDesc": "Wysyłane, gdy śledzony pomiar jest wymagany.",
|
||||
"eventMedicationIntakeSync": "Synchronizacja przyjęć leku",
|
||||
"eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta."
|
||||
"eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta.",
|
||||
"security": {
|
||||
"newDeviceTitle": "Nowe logowanie do Twojego konta",
|
||||
"newDeviceBody": "Nowe logowanie z {device} ({location}). Jeśli to nie Ty, natychmiast zmień hasło.",
|
||||
"unknownLocation": "nieznana lokalizacja"
|
||||
},
|
||||
"loginRequired": "Zaloguj się, aby zarządzać powiadomieniami."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.22.1",
|
||||
"version": "1.23.0",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
@@ -74,6 +74,7 @@
|
||||
"next": "16.2.9",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^9.0.1",
|
||||
"otpauth": "^9.5.1",
|
||||
"p-limit": "^7.3.0",
|
||||
"pg-boss": "^12.20.0",
|
||||
"prisma": "^7.8.0",
|
||||
|
||||
Generated
+16
@@ -84,6 +84,9 @@ importers:
|
||||
nodemailer:
|
||||
specifier: ^9.0.1
|
||||
version: 9.0.1
|
||||
otpauth:
|
||||
specifier: ^9.5.1
|
||||
version: 9.5.1
|
||||
p-limit:
|
||||
specifier: ^7.3.0
|
||||
version: 7.3.0
|
||||
@@ -1290,6 +1293,10 @@ packages:
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@noble/hashes@2.2.0':
|
||||
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@nodable/entities@2.1.0':
|
||||
resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==}
|
||||
|
||||
@@ -5396,6 +5403,9 @@ packages:
|
||||
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
otpauth@9.5.1:
|
||||
resolution: {integrity: sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==}
|
||||
|
||||
outvariant@1.4.3:
|
||||
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
|
||||
|
||||
@@ -7978,6 +7988,8 @@ snapshots:
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@noble/hashes@2.2.0': {}
|
||||
|
||||
'@nodable/entities@2.1.0': {}
|
||||
|
||||
'@node-rs/argon2-android-arm-eabi@2.0.2':
|
||||
@@ -12157,6 +12169,10 @@ snapshots:
|
||||
string-width: 7.2.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
otpauth@9.5.1:
|
||||
dependencies:
|
||||
'@noble/hashes': 2.2.0
|
||||
|
||||
outvariant@1.4.3: {}
|
||||
|
||||
own-keys@1.0.1:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- v1.23 — account-security columns (second-factor substrate).
|
||||
--
|
||||
-- Additive, nullable / defaulted columns only — existing rows need no
|
||||
-- backfill. The TOTP shared secret (`totp_secret_encrypted`) is AES-256-GCM
|
||||
-- ciphertext like every other `*_encrypted` column (registered in the
|
||||
-- encrypted-columns registry + the rotation script). `totp_confirmed_at`
|
||||
-- marks the pending->active promotion; `mfa_enforced` /
|
||||
-- `passkey_upgrade_nudge_dismissed` are per-account flags later waves read.
|
||||
--
|
||||
-- `sessions.mfa_verified_at` is the step-up freshness stamp; `last_active_at`
|
||||
-- backs the user-facing active-session list. `passkeys.last_used_at` is set on
|
||||
-- each successful assertion so the management UI can surface "last used".
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "totp_secret_encrypted" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "totp_confirmed_at" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "mfa_enforced" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS "passkey_upgrade_nudge_dismissed" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE "sessions"
|
||||
ADD COLUMN IF NOT EXISTS "mfa_verified_at" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "last_active_at" TIMESTAMP(3);
|
||||
|
||||
ALTER TABLE "passkeys"
|
||||
ADD COLUMN IF NOT EXISTS "last_used_at" TIMESTAMP(3);
|
||||
@@ -0,0 +1,69 @@
|
||||
-- v1.23 — second-factor (MFA) tables.
|
||||
--
|
||||
-- Three new stores backing the TOTP / WebAuthn-second-factor / recovery-code
|
||||
-- flows later waves build on. Phase F lands the empty tables only — no
|
||||
-- behaviour yet.
|
||||
--
|
||||
-- mfa_challenges single-use step-up ticket minted after a valid
|
||||
-- password when the account has a second factor.
|
||||
-- The "password OK, awaiting factor 2" state lives
|
||||
-- here, never in a half-built session, so no
|
||||
-- session/token is issued before factor 2 passes.
|
||||
-- Only the ticket HASH is stored (ticket_hash).
|
||||
-- mfa_recovery_codes one row per single-use recovery code, Argon2id-
|
||||
-- hashed at rest; used_at burns it.
|
||||
-- webauthn_mfa_credentials second-factor security keys, kept SEPARATE from
|
||||
-- `passkeys` (the passwordless-primary store) —
|
||||
-- different semantics, ceremony, management UI.
|
||||
-- Mirrors the passkeys field encoding.
|
||||
|
||||
CREATE TABLE "mfa_challenges" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"ticket_hash" TEXT NOT NULL,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"consumed_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "mfa_challenges_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "mfa_challenges_ticket_hash_key" ON "mfa_challenges"("ticket_hash");
|
||||
CREATE INDEX "mfa_challenges_user_id_idx" ON "mfa_challenges"("user_id");
|
||||
-- The verify path and the expiry sweep both filter on expires_at.
|
||||
CREATE INDEX "mfa_challenges_expires_at_idx" ON "mfa_challenges"("expires_at");
|
||||
|
||||
CREATE TABLE "mfa_recovery_codes" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"code_hash" TEXT NOT NULL,
|
||||
"used_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "mfa_recovery_codes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "mfa_recovery_codes_user_id_idx" ON "mfa_recovery_codes"("user_id");
|
||||
|
||||
CREATE TABLE "webauthn_mfa_credentials" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL DEFAULT 'Security key',
|
||||
"credential_id" TEXT NOT NULL,
|
||||
"credential_public_key" BYTEA NOT NULL,
|
||||
"counter" BIGINT NOT NULL DEFAULT 0,
|
||||
"transports" TEXT[],
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"last_used_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "webauthn_mfa_credentials_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "webauthn_mfa_credentials_credential_id_key" ON "webauthn_mfa_credentials"("credential_id");
|
||||
CREATE INDEX "webauthn_mfa_credentials_user_id_idx" ON "webauthn_mfa_credentials"("user_id");
|
||||
|
||||
ALTER TABLE "mfa_challenges" ADD CONSTRAINT "mfa_challenges_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "mfa_recovery_codes" ADD CONSTRAINT "mfa_recovery_codes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "webauthn_mfa_credentials" ADD CONSTRAINT "webauthn_mfa_credentials_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- v1.23 — encrypt free-text health notes at rest.
|
||||
--
|
||||
-- Adds the AES-256-GCM ciphertext columns for the two free-text health-note
|
||||
-- fields that were still plaintext (`mood_entries.note`, `measurements.notes`).
|
||||
--
|
||||
-- STORAGE CONVENTION: Bytes (`bytea`), matching the dominant free-text
|
||||
-- encrypted-note convention already in the schema — illness_day_logs.note_
|
||||
-- encrypted, cycle_day_logs.notes_encrypted (string-kind), labs.note_encrypted,
|
||||
-- coach_*.{...}_encrypted are all the shared-codec shape (the `encrypt()`
|
||||
-- ciphertext string stored UTF-8 as `bytea`). `bytea` is chosen here because
|
||||
-- the comparable note columns (IllnessDayLog / LabResult) use it and the
|
||||
-- rotation script's `rotateBytesColumn` already covers that shape with least
|
||||
-- friction. Both columns are registered in ENCRYPTED_COLUMNS + the rotation
|
||||
-- script in the same change.
|
||||
--
|
||||
-- This migration is ADDITIVE ONLY. The legacy plaintext columns
|
||||
-- (`mood_entries.note`, `measurements.notes`) are intentionally NOT dropped
|
||||
-- here: an idempotent, transactional, fail-closed boot-time job encrypts the
|
||||
-- existing rows (plaintext -> ciphertext, then nulls the plaintext) so the
|
||||
-- read path can fall back to plaintext for not-yet-migrated rows. Dropping the
|
||||
-- plaintext columns is a deliberate FOLLOW-UP release, once the backfill
|
||||
-- reports zero remaining plaintext-but-no-ciphertext rows — the same boundary
|
||||
-- the encryption-key-rotation playbook uses before dropping a legacy key.
|
||||
ALTER TABLE "mood_entries"
|
||||
ADD COLUMN IF NOT EXISTS "note_encrypted" BYTEA;
|
||||
|
||||
ALTER TABLE "measurements"
|
||||
ADD COLUMN IF NOT EXISTS "notes_encrypted" BYTEA;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- v1.23 — TOTP replay guard.
|
||||
--
|
||||
-- Persists the last accepted time-step counter per account so a verified
|
||||
-- code cannot be replayed within its 30-second validity window. The verify
|
||||
-- path resolves the submitted code to its RFC 6238 step (`floor(now /
|
||||
-- period)` adjusted by the ±1 drift delta) and rejects any step that is not
|
||||
-- strictly greater than this column. Additive, nullable — existing rows
|
||||
-- (no TOTP enrolled) need no backfill, and a freshly confirmed factor starts
|
||||
-- with NULL (the first verify simply records its step).
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "totp_last_step" BIGINT;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- v1.23 — new-device sign-in alert dedupe ledger.
|
||||
--
|
||||
-- One row per (user, coarse device fingerprint). The fingerprint
|
||||
-- (`device_hash`) is a one-way SHA-256 hash of
|
||||
-- `userId | normalised-User-Agent | coarse-location-or-asn`. The raw
|
||||
-- User-Agent and IP are NEVER stored — the table holds only the salted hash
|
||||
-- (the userId is part of the digest input, so the same browser on two
|
||||
-- accounts produces two unrelated hashes) plus an optional coarse,
|
||||
-- human-readable `label` for the security-activity surface (e.g.
|
||||
-- "Firefox on macOS — Berlin, DE"; the label omits the IP).
|
||||
--
|
||||
-- A successful sign-in upserts on (user_id, device_hash): the first sighting
|
||||
-- of a hash inserts the row and fires one new-device notification through the
|
||||
-- dispatcher; subsequent sightings only bump `last_seen_at` and stay silent.
|
||||
|
||||
CREATE TABLE "user_known_devices" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"device_hash" TEXT NOT NULL,
|
||||
"label" TEXT,
|
||||
"first_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_known_devices_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Dedupe is per-user: a coarse fingerprint can never be inserted twice for
|
||||
-- one account, so "first sighting → alert" is a single unique-constrained
|
||||
-- upsert with no race window.
|
||||
CREATE UNIQUE INDEX "user_known_devices_user_id_device_hash_key" ON "user_known_devices"("user_id", "device_hash");
|
||||
CREATE INDEX "user_known_devices_user_id_idx" ON "user_known_devices"("user_id");
|
||||
|
||||
ALTER TABLE "user_known_devices" ADD CONSTRAINT "user_known_devices_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- v1.23 — admin-enforced MFA policy + "remember this device" trusted devices.
|
||||
--
|
||||
-- 1. `app_settings.mfa_required` — the instance-wide "require a second factor"
|
||||
-- policy. When true, every account without an active second factor is sent
|
||||
-- through a forced-enrollment interstitial after sign-in. The effective
|
||||
-- per-user requirement is `mfa_required OR users.mfa_enforced`.
|
||||
--
|
||||
-- 2. `trusted_devices` — the opt-in "remember this device" store. Only the HMAC
|
||||
-- hash of the cookie token is persisted (keyed by API_TOKEN_HMAC_KEY); the
|
||||
-- raw token lives only in the user's httpOnly+Secure cookie. A trusted
|
||||
-- device skips factor 2 within the 30-day window but never satisfies step-up
|
||||
-- and never replaces the password. Revoked when the second factor is removed.
|
||||
|
||||
ALTER TABLE "app_settings" ADD COLUMN "mfa_required" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE TABLE "trusted_devices" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"label" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"last_used_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "trusted_devices_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- The token hash is the lookup key on every trusted-device sign-in, and a
|
||||
-- token is single-purpose per device, so the hash is globally unique.
|
||||
CREATE UNIQUE INDEX "trusted_devices_token_hash_key" ON "trusted_devices"("token_hash");
|
||||
CREATE INDEX "trusted_devices_user_id_idx" ON "trusted_devices"("user_id");
|
||||
-- The expired-trusted-device reaper sweeps on `expires_at`.
|
||||
CREATE INDEX "trusted_devices_expires_at_idx" ON "trusted_devices"("expires_at");
|
||||
|
||||
ALTER TABLE "trusted_devices" ADD CONSTRAINT "trusted_devices_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- v1.23 — remove the bug-report integration.
|
||||
--
|
||||
-- The in-app bug-report / feedback surface (user form, admin inbox, optional
|
||||
-- GitHub-issue escalation) is retired. This migration drops the dedicated
|
||||
-- feedback table, its enums, and the three AppSettings columns that held the
|
||||
-- GitHub-issue credentials and the enable toggle.
|
||||
--
|
||||
-- Destructive by design: the feature is gone, so its stored data goes with it.
|
||||
DROP TABLE IF EXISTS "feedback";
|
||||
|
||||
DROP TYPE IF EXISTS "feedback_status";
|
||||
DROP TYPE IF EXISTS "feedback_category";
|
||||
|
||||
ALTER TABLE "app_settings"
|
||||
DROP COLUMN IF EXISTS "github_issue_token_encrypted",
|
||||
DROP COLUMN IF EXISTS "github_issue_repo",
|
||||
DROP COLUMN IF EXISTS "bug_report_enabled";
|
||||
+503
-380
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.22.0";
|
||||
/* @sw-version-fallback */ "v1.22.1";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* scripts/decrypt-export.ts <archive.hlx> [output.json]
|
||||
*
|
||||
* Standalone decryptor for a passphrase-encrypted HealthLog export (`HLX1`
|
||||
* wire format — see src/lib/export/passphrase-archive.ts). A user runs this
|
||||
* with their passphrase to recover the plaintext JSON backup WITHOUT the app:
|
||||
*
|
||||
* pnpm dlx tsx scripts/decrypt-export.ts healthlog-backup-....hlx
|
||||
*
|
||||
* The passphrase is read interactively with echo disabled, or from the
|
||||
* `EXPORT_PASSPHRASE` environment variable for non-interactive use. It is
|
||||
* never written to disk or logged. There is no server-side recovery: a wrong
|
||||
* or forgotten passphrase cannot be worked around — the archive is opaque.
|
||||
*
|
||||
* If no output path is given, the decrypted JSON is written next to the input
|
||||
* with a `.json` extension; pass `-` to stream to stdout instead.
|
||||
*
|
||||
* This script depends ONLY on the archive codec (Argon2id + node:crypto) — no
|
||||
* database, no Prisma, no app boot — so it stays runnable from a checkout with
|
||||
* just `pnpm install`.
|
||||
*/
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { createInterface } from "node:readline";
|
||||
import { decryptArchive } from "@/lib/export/passphrase-archive";
|
||||
|
||||
function usage(): never {
|
||||
console.error(
|
||||
"Usage: pnpm dlx tsx scripts/decrypt-export.ts <archive.hlx> [output.json|-]",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/** Read a line from the TTY with local echo disabled. */
|
||||
async function promptPassphrase(): Promise<string> {
|
||||
const fromEnv = process.env.EXPORT_PASSPHRASE;
|
||||
if (fromEnv && fromEnv.length > 0) return fromEnv;
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
console.error(
|
||||
"No TTY available and EXPORT_PASSPHRASE is unset — cannot read passphrase.",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
// Mute echo: intercept the output stream while the prompt is active.
|
||||
const out = process.stdout;
|
||||
let muted = false;
|
||||
const realWrite = out.write.bind(out);
|
||||
(out as unknown as { write: typeof out.write }).write = ((
|
||||
chunk: string | Uint8Array,
|
||||
...rest: unknown[]
|
||||
) => {
|
||||
if (muted) return true;
|
||||
// @ts-expect-error variadic passthrough
|
||||
return realWrite(chunk, ...rest);
|
||||
}) as typeof out.write;
|
||||
|
||||
process.stdout.write("Passphrase: ");
|
||||
muted = true;
|
||||
try {
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.question("", resolve);
|
||||
});
|
||||
return answer;
|
||||
} finally {
|
||||
muted = false;
|
||||
(out as unknown as { write: typeof out.write }).write = realWrite;
|
||||
process.stdout.write("\n");
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const input = process.argv[2];
|
||||
if (!input) usage();
|
||||
const output = process.argv[3] ?? input.replace(/\.hlx$/i, "") + ".json";
|
||||
|
||||
const buf = await readFile(input);
|
||||
const passphrase = await promptPassphrase();
|
||||
|
||||
let plaintext: string;
|
||||
try {
|
||||
plaintext = await decryptArchive(buf, passphrase);
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (output === "-") {
|
||||
process.stdout.write(plaintext);
|
||||
if (!plaintext.endsWith("\n")) process.stdout.write("\n");
|
||||
} else {
|
||||
await writeFile(output, plaintext, "utf8");
|
||||
console.error(`Decrypted -> ${output}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* scripts/disable-mfa.ts <email-or-username>
|
||||
*
|
||||
* Operator escape hatch: clear the second factor for an account whose owner
|
||||
* has locked themselves out (lost authenticator AND recovery codes). Mirrors
|
||||
* the password-reset CLI — it runs out-of-band against the database, so it is
|
||||
* the recovery path of last resort for a self-hoster.
|
||||
*
|
||||
* It clears the TOTP secret, the confirmed-at stamp and the replay counter,
|
||||
* and deletes every recovery code, then writes an `auth.mfa.disabled` audit
|
||||
* row (with `details.source = "cli"`) so the action is traceable.
|
||||
*
|
||||
* Run via the bundled tsx (the production standalone image strips tsx):
|
||||
* pnpm dlx tsx scripts/disable-mfa.ts user@example.com
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("DATABASE_URL must be set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const identifier = process.argv[2];
|
||||
if (!identifier) {
|
||||
console.error(
|
||||
"Usage: pnpm dlx tsx scripts/disable-mfa.ts <email-or-username>",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: DATABASE_URL }),
|
||||
});
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ email: { equals: identifier, mode: "insensitive" } },
|
||||
{ username: { equals: identifier, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
select: { id: true, username: true, totpConfirmedAt: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.error(`No account matches '${identifier}'.`);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
if (!user.totpConfirmedAt) {
|
||||
console.log(
|
||||
`Account '${user.username}' has no active second factor — nothing to do.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totpSecretEncrypted: null,
|
||||
totpConfirmedAt: null,
|
||||
totpLastStep: null,
|
||||
},
|
||||
});
|
||||
await tx.mfaRecoveryCode.deleteMany({ where: { userId: user.id } });
|
||||
// v1.23 (M-review L3) — removing the second factor is a security-state
|
||||
// change. Out-of-band there is no "current" session to keep, so drop every
|
||||
// session, every native refresh token, and every "remember this device"
|
||||
// trusted device for the account: a stolen session or trusted-device cookie
|
||||
// must not survive the lockout-recovery reset.
|
||||
await tx.session.deleteMany({ where: { userId: user.id } });
|
||||
await tx.refreshToken.updateMany({
|
||||
where: { userId: user.id, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
await tx.trustedDevice.deleteMany({ where: { userId: user.id } });
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
action: "auth.mfa.disabled",
|
||||
userId: user.id,
|
||||
details: JSON.stringify({ source: "cli", factor: "totp" }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`Second factor cleared for account '${user.username}'.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => {
|
||||
void prisma.$disconnect();
|
||||
});
|
||||
@@ -193,6 +193,8 @@ async function main() {
|
||||
"aiOpenaiKeyEncrypted",
|
||||
"aiOcrKeyEncrypted",
|
||||
"insuranceNumberEncrypted",
|
||||
// v1.23 — TOTP shared secret (second factor).
|
||||
"totpSecretEncrypted",
|
||||
];
|
||||
for (const field of userFields) {
|
||||
results.push(await rotateStringColumn("User", field, prisma.user));
|
||||
@@ -225,11 +227,9 @@ async function main() {
|
||||
|
||||
// ───── AppSettings — operator credentials (String) ─────
|
||||
// Columns: "adminAiKeyEncrypted" "webPushVapidPrivateKeyEncrypted"
|
||||
// "githubIssueTokenEncrypted"
|
||||
for (const field of [
|
||||
"adminAiKeyEncrypted",
|
||||
"webPushVapidPrivateKeyEncrypted",
|
||||
"githubIssueTokenEncrypted",
|
||||
]) {
|
||||
results.push(
|
||||
await rotateStringColumn("AppSettings", field, prisma.appSettings),
|
||||
@@ -405,6 +405,22 @@ async function main() {
|
||||
),
|
||||
);
|
||||
|
||||
// ───── v1.23 free-text health notes (Bytes columns) ─────
|
||||
// "noteEncrypted" (MoodEntry) + "notesEncrypted" (Measurement). Same shared-
|
||||
// codec shape as the clinical-spine note columns. NULL on rows whose note is
|
||||
// still in the legacy plaintext column (pre-backfill) — `rotateBytesColumn`
|
||||
// skips those.
|
||||
results.push(
|
||||
await rotateBytesColumn("MoodEntry", "noteEncrypted", prisma.moodEntry),
|
||||
);
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"Measurement",
|
||||
"notesEncrypted",
|
||||
prisma.measurement,
|
||||
),
|
||||
);
|
||||
|
||||
console.log("\n=== Rotation summary ===");
|
||||
let totalRotated = 0;
|
||||
let totalErrors = 0;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/auth/mfa-enrollment", () => ({
|
||||
setMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
|
||||
syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
verifyPassword: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "h") }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: Date.now() + 1e6,
|
||||
ip: "203.0.113.7",
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/auth/login-response", () => ({
|
||||
finishLogin: vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ data: { user: { id: "user-1" } }, error: null }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
),
|
||||
}));
|
||||
vi.mock("@/lib/auth/mfa/challenge", () => ({
|
||||
createMfaChallenge: vi.fn().mockResolvedValue({
|
||||
ticket: "opaque-ticket-value",
|
||||
expiresAt: new Date(Date.now() + 5 * 60_000),
|
||||
}),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "@/app/api/auth/login/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
|
||||
|
||||
function loginRequest() {
|
||||
return new Request("http://localhost/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: "user@example.com",
|
||||
password: "correct-horse-battery",
|
||||
}),
|
||||
}) as unknown as Parameters<typeof POST>[0];
|
||||
}
|
||||
|
||||
const BASE_USER = {
|
||||
id: "user-1",
|
||||
username: "u",
|
||||
email: "user@example.com",
|
||||
passwordHash: "argon2-hash",
|
||||
onboardingCompletedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default: no registered security key. Individual tests override as needed.
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
});
|
||||
|
||||
describe("login MFA branch", () => {
|
||||
it("MFA user → returns mfaRequired + ticket and issues NO session", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue({
|
||||
...BASE_USER,
|
||||
totpConfirmedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const res = await POST(loginRequest());
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.meta.mfaRequired).toBe(true);
|
||||
expect(body.meta.mfaTicket).toBe("opaque-ticket-value");
|
||||
expect(body.meta.methods).toEqual(["totp", "recovery"]);
|
||||
|
||||
expect(createMfaChallenge).toHaveBeenCalledWith("user-1", "login");
|
||||
// The session/token tail is never reached.
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("WebAuthn-only user (no TOTP) → returns mfaRequired with webauthn method", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue({
|
||||
...BASE_USER,
|
||||
totpConfirmedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
|
||||
|
||||
const res = await POST(loginRequest());
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.meta.mfaRequired).toBe(true);
|
||||
expect(body.meta.methods).toEqual(["webauthn"]);
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("non-MFA user → issues the session via finishLogin, no challenge", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue({
|
||||
...BASE_USER,
|
||||
totpConfirmedAt: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(loginRequest());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(finishLogin).toHaveBeenCalledTimes(1);
|
||||
expect(createMfaChallenge).not.toHaveBeenCalled();
|
||||
const arg = vi.mocked(finishLogin).mock.calls[0][0];
|
||||
expect(arg.source).toBe("login.password");
|
||||
expect(arg.mfaVerified).toBeUndefined();
|
||||
});
|
||||
|
||||
it("wrong password never reaches the MFA branch", async () => {
|
||||
const { verifyPassword } = await import("@/lib/auth/password");
|
||||
vi.mocked(verifyPassword).mockResolvedValueOnce(false);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue({
|
||||
...BASE_USER,
|
||||
totpConfirmedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const res = await POST(loginRequest());
|
||||
expect(res.status).toBe(401);
|
||||
expect(createMfaChallenge).not.toHaveBeenCalled();
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 1e6,
|
||||
ip: "203.0.113.9",
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/auth/login-response", () => ({
|
||||
finishLogin: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ data: { user: { id: "user-1" } }, error: null }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
),
|
||||
}));
|
||||
vi.mock("@/lib/auth/mfa/challenge", () => ({
|
||||
loadActiveChallenge: vi.fn(),
|
||||
recordChallengeFailure: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ exhausted: false, attempts: 1 }),
|
||||
claimChallenge: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/mfa/verify-factor", () => ({
|
||||
verifyMfaFactor: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "@/app/api/auth/mfa/verify/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import {
|
||||
loadActiveChallenge,
|
||||
recordChallengeFailure,
|
||||
claimChallenge,
|
||||
} from "@/lib/auth/mfa/challenge";
|
||||
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
|
||||
|
||||
function verifyRequest(body: unknown) {
|
||||
return new Request("http://localhost/api/auth/mfa/verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}) as unknown as Parameters<typeof POST>[0];
|
||||
}
|
||||
|
||||
const GOOD_BODY = { mfaTicket: "tkt", method: "totp", code: "123456" };
|
||||
const ACTIVE_CHALLENGE = {
|
||||
id: "ch-1",
|
||||
userId: "user-1",
|
||||
kind: "login",
|
||||
attempts: 0,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
};
|
||||
const MFA_USER = {
|
||||
id: "user-1",
|
||||
username: "u",
|
||||
email: "user@example.com",
|
||||
onboardingCompletedAt: new Date(),
|
||||
totpConfirmedAt: new Date(),
|
||||
totpSecretEncrypted: "v2.cipher",
|
||||
totpLastStep: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("POST /api/auth/mfa/verify", () => {
|
||||
it("happy path: valid factor → claims ticket and issues the bundle", async () => {
|
||||
vi.mocked(loadActiveChallenge).mockResolvedValue(ACTIVE_CHALLENGE as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(MFA_USER as never);
|
||||
vi.mocked(verifyMfaFactor).mockResolvedValue({ ok: true, replay: false });
|
||||
vi.mocked(claimChallenge).mockResolvedValue(true);
|
||||
|
||||
const res = await POST(verifyRequest(GOOD_BODY));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(claimChallenge).toHaveBeenCalledWith("ch-1");
|
||||
expect(finishLogin).toHaveBeenCalledTimes(1);
|
||||
const arg = vi.mocked(finishLogin).mock.calls[0][0];
|
||||
expect(arg.mfaVerified).toBe(true);
|
||||
expect(arg.source).toBe("mfa.verify");
|
||||
});
|
||||
|
||||
it("invalid ticket → 401, no session", async () => {
|
||||
vi.mocked(loadActiveChallenge).mockResolvedValue(null);
|
||||
const res = await POST(verifyRequest(GOOD_BODY));
|
||||
expect(res.status).toBe(401);
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("wrong code → records failure, 401, no session", async () => {
|
||||
vi.mocked(loadActiveChallenge).mockResolvedValue(ACTIVE_CHALLENGE as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(MFA_USER as never);
|
||||
vi.mocked(verifyMfaFactor).mockResolvedValue({ ok: false, replay: false });
|
||||
|
||||
const res = await POST(verifyRequest(GOOD_BODY));
|
||||
expect(res.status).toBe(401);
|
||||
expect(recordChallengeFailure).toHaveBeenCalledWith("ch-1");
|
||||
expect(claimChallenge).not.toHaveBeenCalled();
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("factor valid but claim lost (race) → 401, no session", async () => {
|
||||
vi.mocked(loadActiveChallenge).mockResolvedValue(ACTIVE_CHALLENGE as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(MFA_USER as never);
|
||||
vi.mocked(verifyMfaFactor).mockResolvedValue({ ok: true, replay: false });
|
||||
vi.mocked(claimChallenge).mockResolvedValue(false);
|
||||
|
||||
const res = await POST(verifyRequest(GOOD_BODY));
|
||||
expect(res.status).toBe(401);
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("factor disabled between password and verify → failure, 401", async () => {
|
||||
vi.mocked(loadActiveChallenge).mockResolvedValue(ACTIVE_CHALLENGE as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
...MFA_USER,
|
||||
totpConfirmedAt: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(verifyRequest(GOOD_BODY));
|
||||
expect(res.status).toBe(401);
|
||||
expect(recordChallengeFailure).toHaveBeenCalledWith("ch-1");
|
||||
expect(verifyMfaFactor).not.toHaveBeenCalled();
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("malformed body → 422", async () => {
|
||||
const res = await POST(verifyRequest({ mfaTicket: "", code: "x" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -137,7 +137,6 @@ const mockData = {
|
||||
passkeyLoginCount: 0,
|
||||
passwordLoginCount: 0,
|
||||
loginDayStreak: 0,
|
||||
bugReportCount: 0,
|
||||
moodEntryCount: 0,
|
||||
moodDayStreak: 0,
|
||||
moodImprovementHit: 0,
|
||||
|
||||
@@ -25,7 +25,7 @@ import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-s
|
||||
import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section";
|
||||
import { BackupsSection } from "@/components/admin/backups-section";
|
||||
import { DangerZoneSection } from "@/components/admin/danger-zone-section";
|
||||
import { FeedbackInboxSection } from "@/components/admin/feedback-inbox-section";
|
||||
import { EncryptionSection } from "@/components/admin/encryption-section";
|
||||
import { GeneralSettingsSection } from "@/components/admin/general-settings-section";
|
||||
import { IntegrationsGroupSection } from "@/components/admin/integrations-group-section";
|
||||
import { LoginOverviewSection } from "@/components/admin/login-overview-section";
|
||||
@@ -120,15 +120,6 @@ export function AdminSectionRenderer({
|
||||
<ModuleAvailabilitySection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "feedback":
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.feedback.title")}
|
||||
subtitle={t("admin.section.feedback.subtitle")}
|
||||
>
|
||||
<FeedbackInboxSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "reminders":
|
||||
return (
|
||||
<SectionFrame
|
||||
@@ -195,6 +186,15 @@ export function AdminSectionRenderer({
|
||||
<BackupsSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "encryption":
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.encryption.title")}
|
||||
subtitle={t("admin.section.encryption.subtitle")}
|
||||
>
|
||||
<EncryptionSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "danger-zone":
|
||||
return (
|
||||
<SectionFrame
|
||||
|
||||
@@ -36,6 +36,7 @@ import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { defaultUserIdResolver, withIdempotency } from "@/lib/idempotency";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
@@ -330,7 +331,10 @@ const handler = apiHandler(
|
||||
unit: m.unit,
|
||||
source: (m.source ?? "MANUAL") as never,
|
||||
measuredAt: new Date(m.measuredAt),
|
||||
notes: m.notes ?? null,
|
||||
// v1.23 — re-encrypt the note from the (decrypted) backup payload
|
||||
// into the at-rest column; legacy plaintext column nulled.
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
})),
|
||||
// Backups can replay the same data — the unique
|
||||
// (userId, type, measuredAt, source) constraint catches
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* POST /api/admin/encryption/rotate
|
||||
*
|
||||
* v1.23 — admin-triggered encryption-key rotation. Enqueues the
|
||||
* `encryption-key-rotate` pg-boss job, which re-encrypts the whole encrypted-
|
||||
* column corpus to the configured ACTIVE key id. The CLI
|
||||
* (`scripts/rotate-encryption-key.ts`) stays the canonical path; this is a
|
||||
* convenience that reuses the same column registry and is safe by construction:
|
||||
*
|
||||
* - ACTIVE-KEY-ONLY + NEVER ADDS / DROPS A KEY: the job only writes the active
|
||||
* key and never touches `ENCRYPTION_KEYS`. Dropping a key remains an env +
|
||||
* redeploy act.
|
||||
* - IDEMPOTENT: rows already on the active key are skipped; the singleton key
|
||||
* coalesces duplicate triggers into one queued run.
|
||||
*
|
||||
* Auth: cookie-only `requireAdmin` (Bearer can never reach admin) PLUS
|
||||
* `requireFreshMfa` step-up — re-encrypting the whole sensitive corpus is a
|
||||
* high-blast-radius action. An admin without a second factor cannot use the
|
||||
* button and falls back to the CLI (this is intentional: step-up cannot be
|
||||
* softened, and the CLI is the documented canonical path).
|
||||
*/
|
||||
import {
|
||||
apiHandler,
|
||||
requireAdmin,
|
||||
requireFreshMfa,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
ENCRYPTION_KEY_ROTATE_SINGLETON,
|
||||
} from "@/lib/jobs/encryption-key-rotate";
|
||||
import { getActiveKeyId } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Cookie-only admin, then a fresh second factor. Both resolution paths are
|
||||
// cookie-only, so a Bearer token cannot reach either.
|
||||
await requireAdmin();
|
||||
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
annotate({ action: { name: "admin.encryption.rotate.requested" } });
|
||||
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
return apiError("Background worker is not available", 503);
|
||||
}
|
||||
|
||||
const jobId = await boss.send(
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
{ requestedByUserId: user.id, enqueuedAt: new Date().toISOString() },
|
||||
{
|
||||
// Coalesce duplicate triggers: at most one queued run at a time.
|
||||
singletonKey: ENCRYPTION_KEY_ROTATE_SINGLETON,
|
||||
retryLimit: 2,
|
||||
retryDelay: 60,
|
||||
},
|
||||
);
|
||||
|
||||
await auditLog("admin.encryption.rotate.requested", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { activeKeyId: getActiveKeyId(), enqueued: Boolean(jobId) },
|
||||
});
|
||||
|
||||
annotate({ meta: { rotate_enqueued: Boolean(jobId) } });
|
||||
|
||||
// A null jobId means the singleton coalesced this trigger onto an already-
|
||||
// queued run — still "accepted" from the operator's point of view.
|
||||
return apiSuccess({ accepted: true, alreadyQueued: !jobId });
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* GET /api/admin/encryption/status
|
||||
*
|
||||
* v1.23 — read-only admin view of encryption coverage + rotation progress.
|
||||
* Buckets every registered encrypted column's rows by key id (the same scan the
|
||||
* rotation tooling does), so an operator can SEE whether a rotation finished
|
||||
* before dropping a legacy key, instead of guessing.
|
||||
*
|
||||
* Returns ONLY key IDS (operator-chosen labels like `v1` / `v2`) and ROW
|
||||
* COUNTS — never key material. The configured-key set is surfaced as a count.
|
||||
*
|
||||
* Auth: cookie-only `requireAdmin` (a Bearer token can never reach admin). The
|
||||
* scan is on-demand with a short in-process cache so repeated panel renders
|
||||
* don't re-scan the corpus each time; the rotation run state is read from the
|
||||
* audit trail (DB-backed, so it is correct across the web/worker processes).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getConfiguredKeyIds } from "@/lib/crypto";
|
||||
import {
|
||||
scanCorpus,
|
||||
type CorpusClient,
|
||||
type CorpusScan,
|
||||
} from "@/lib/crypto/encryption-corpus";
|
||||
|
||||
const SCAN_CACHE_TTL_MS = 15_000;
|
||||
let scanCache: { at: number; scan: CorpusScan } | null = null;
|
||||
|
||||
interface RotationState {
|
||||
state: "idle" | "running" | "completed" | "failed";
|
||||
lastRequestedAt: string | null;
|
||||
lastCompletedAt: string | null;
|
||||
lastResult: { scanned: number; rotated: number; errors: number } | null;
|
||||
}
|
||||
|
||||
async function readRotationState(): Promise<RotationState> {
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
action: {
|
||||
in: [
|
||||
"admin.encryption.rotate.requested",
|
||||
"admin.encryption.rotate.completed",
|
||||
"admin.encryption.rotate.failed",
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
select: { action: true, createdAt: true, details: true },
|
||||
});
|
||||
|
||||
const requested = rows.find(
|
||||
(r) => r.action === "admin.encryption.rotate.requested",
|
||||
);
|
||||
const finished = rows.find(
|
||||
(r) =>
|
||||
r.action === "admin.encryption.rotate.completed" ||
|
||||
r.action === "admin.encryption.rotate.failed",
|
||||
);
|
||||
|
||||
let lastResult: RotationState["lastResult"] = null;
|
||||
if (
|
||||
finished?.action === "admin.encryption.rotate.completed" &&
|
||||
finished.details
|
||||
) {
|
||||
try {
|
||||
const d = JSON.parse(finished.details) as {
|
||||
scanned?: number;
|
||||
rotated?: number;
|
||||
errors?: number;
|
||||
};
|
||||
lastResult = {
|
||||
scanned: d.scanned ?? 0,
|
||||
rotated: d.rotated ?? 0,
|
||||
errors: d.errors ?? 0,
|
||||
};
|
||||
} catch {
|
||||
lastResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
// "running" iff the most recent request is newer than the most recent
|
||||
// completion/failure.
|
||||
let state: RotationState["state"] = "idle";
|
||||
if (requested && (!finished || requested.createdAt > finished.createdAt)) {
|
||||
state = "running";
|
||||
} else if (finished?.action === "admin.encryption.rotate.completed") {
|
||||
state = "completed";
|
||||
} else if (finished?.action === "admin.encryption.rotate.failed") {
|
||||
state = "failed";
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
lastRequestedAt: requested?.createdAt.toISOString() ?? null,
|
||||
lastCompletedAt: finished?.createdAt.toISOString() ?? null,
|
||||
lastResult,
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.status" } });
|
||||
|
||||
const now = Date.now();
|
||||
let scan: CorpusScan;
|
||||
if (scanCache && now - scanCache.at < SCAN_CACHE_TTL_MS) {
|
||||
scan = scanCache.scan;
|
||||
} else {
|
||||
scan = await scanCorpus(prisma as unknown as CorpusClient);
|
||||
scanCache = { at: now, scan };
|
||||
}
|
||||
|
||||
const rotation = await readRotationState();
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
encryption_total_rows: scan.totalRows,
|
||||
encryption_stale_rows: scan.staleRows,
|
||||
encryption_rotation_complete: scan.rotationComplete,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
activeKeyId: scan.activeKeyId,
|
||||
// COUNT only — never the key material.
|
||||
configuredKeyCount: getConfiguredKeyIds().length,
|
||||
rotationComplete: scan.rotationComplete,
|
||||
totalRows: scan.totalRows,
|
||||
activeRows: scan.activeRows,
|
||||
staleRows: scan.staleRows,
|
||||
columns: scan.columns,
|
||||
rotation,
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on PATCH /api/admin/feedback/[id].
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
feedback: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PATCH } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_ADMIN = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "admin-1", username: "admin", role: "ADMIN" as const },
|
||||
};
|
||||
|
||||
function patchReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/admin/feedback/f1", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const ROUTE_CTX = { params: Promise.resolve({ id: "f1" }) };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_ADMIN as never);
|
||||
});
|
||||
|
||||
describe("PATCH /api/admin/feedback/[id] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Bad `status` enum + adminNote too long.
|
||||
const res = await PATCH(
|
||||
patchReq({ status: "junk", adminNote: "x".repeat(10000) }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces multiple simultaneous validation errors (≥ 2)", async () => {
|
||||
// The schema has only two knobs (status + adminNote), so the strict
|
||||
// 3-issue ceiling isn't reachable. Multi-issue contract is pinned at
|
||||
// ≥ 2 by the case above.
|
||||
const res = await PATCH(
|
||||
patchReq({ status: 999, adminNote: 123 }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* POST /api/admin/feedback/[id]/github — promote feedback to a GitHub Issue.
|
||||
*
|
||||
* Reuses publishFeedbackToGithub() so the issue body matches the legacy
|
||||
* /api/bugreport flow. Idempotent: if gitHubIssueUrl is already set we return
|
||||
* the existing URL instead of creating a duplicate.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
publishFeedbackToGithub,
|
||||
GithubPublishError,
|
||||
} from "@/lib/feedback/publish-github";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const feedback = await prisma.feedback.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
category: true,
|
||||
subject: true,
|
||||
description: true,
|
||||
gitHubIssueUrl: true,
|
||||
screenshotBase64: true,
|
||||
metadata: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
if (!feedback) return apiError("Feedback not found", 404);
|
||||
|
||||
if (feedback.gitHubIssueUrl) {
|
||||
return apiError(`Already published: ${feedback.gitHubIssueUrl}`, 409);
|
||||
}
|
||||
|
||||
// Atomic claim to prevent concurrent admin clicks from creating two
|
||||
// duplicate GitHub issues. Only the first caller whose updateMany
|
||||
// matches (gitHubIssueUrl still null) proceeds. We write a PENDING
|
||||
// sentinel now and replace it with the real URL after the API call.
|
||||
const PENDING = "pending://creating";
|
||||
const claim = await prisma.feedback.updateMany({
|
||||
where: { id, gitHubIssueUrl: null },
|
||||
data: { gitHubIssueUrl: PENDING },
|
||||
});
|
||||
if (claim.count !== 1) {
|
||||
return apiError("Already being published by another request", 409);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await publishFeedbackToGithub({
|
||||
category: feedback.category,
|
||||
subject: feedback.subject,
|
||||
description: feedback.description,
|
||||
username: feedback.user?.username ?? "anonymous",
|
||||
metadata: feedback.metadata,
|
||||
});
|
||||
|
||||
await prisma.feedback.update({
|
||||
where: { id },
|
||||
data: { gitHubIssueUrl: result.issueUrl },
|
||||
});
|
||||
|
||||
await auditLog("admin.feedback.github_publish", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { feedbackId: id, issueNumber: result.issueNumber },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.feedback.github_publish" },
|
||||
meta: { feedback_id: id, issue_number: result.issueNumber },
|
||||
});
|
||||
|
||||
return apiSuccess({ issueUrl: result.issueUrl });
|
||||
} catch (err) {
|
||||
// Release the PENDING claim so a retry can succeed.
|
||||
await prisma.feedback
|
||||
.updateMany({
|
||||
where: { id, gitHubIssueUrl: PENDING },
|
||||
data: { gitHubIssueUrl: null },
|
||||
})
|
||||
.catch(() => undefined);
|
||||
if (err instanceof GithubPublishError) {
|
||||
return apiError(err.message, err.status);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* PATCH /api/admin/feedback/[id] — change status or admin note.
|
||||
* DELETE /api/admin/feedback/[id] — archive (soft: sets status to ARCHIVED).
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
getClientIp,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { updateFeedbackSchema } from "@/lib/validations/feedback";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user } = await requireAdmin();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = updateFeedbackSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const before = await prisma.feedback.findUnique({ where: { id } });
|
||||
if (!before) return apiError("Feedback not found", 404);
|
||||
|
||||
const updated = await prisma.feedback.update({
|
||||
where: { id },
|
||||
data: parsed.data,
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
adminNote: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("admin.feedback.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
feedbackId: id,
|
||||
before: { status: before.status, adminNote: before.adminNote },
|
||||
after: parsed.data,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.feedback.update" },
|
||||
meta: { feedback_id: id },
|
||||
});
|
||||
|
||||
return apiSuccess(updated);
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user } = await requireAdmin();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const updated = await prisma.feedback.update({
|
||||
where: { id },
|
||||
data: { status: "ARCHIVED" },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
|
||||
await auditLog("admin.feedback.archive", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { feedbackId: id },
|
||||
});
|
||||
|
||||
return apiSuccess(updated);
|
||||
},
|
||||
);
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Admin feedback inbox. Lists all feedback with status filter.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { NextRequest } from "next/server";
|
||||
import type { FeedbackStatus } from "@/generated/prisma/client";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
await requireAdmin();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const status = url.searchParams.get("status") as FeedbackStatus | null;
|
||||
const limit = Math.min(
|
||||
100,
|
||||
Math.max(1, Number(url.searchParams.get("limit") ?? 50)),
|
||||
);
|
||||
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0));
|
||||
|
||||
const where = status ? { status } : {};
|
||||
|
||||
const [items, total, counts] = await Promise.all([
|
||||
prisma.feedback.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
email: true,
|
||||
category: true,
|
||||
subject: true,
|
||||
description: true,
|
||||
status: true,
|
||||
adminNote: true,
|
||||
gitHubIssueUrl: true,
|
||||
metadata: true,
|
||||
screenshotBase64: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
}),
|
||||
prisma.feedback.count({ where }),
|
||||
prisma.feedback.groupBy({
|
||||
by: ["status"],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.feedback.list" },
|
||||
meta: { total, filtered_status: status },
|
||||
});
|
||||
|
||||
const countsByStatus = Object.fromEntries(
|
||||
counts.map((c) => [c.status, c._count._all]),
|
||||
) as Record<FeedbackStatus, number>;
|
||||
|
||||
return apiSuccess({
|
||||
items,
|
||||
meta: { total, limit, offset, countsByStatus },
|
||||
});
|
||||
});
|
||||
@@ -98,7 +98,6 @@ describe("GET /api/admin/settings", () => {
|
||||
expect(body.data.registrationEnabled).toBe(true);
|
||||
expect(body.data.defaultLocale).toBe("de");
|
||||
expect(body.data.webPushVapidConfigured).toBe(false);
|
||||
expect(body.data.bugReportConfigured).toBe(false);
|
||||
expect(body.data.glitchtipEnabled).toBe(false);
|
||||
});
|
||||
|
||||
@@ -120,8 +119,6 @@ describe("GET /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: "production",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "token-blob",
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
@@ -129,15 +126,12 @@ describe("GET /api/admin/settings", () => {
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
// The two configured-flags must be true …
|
||||
// The configured flag must be true …
|
||||
expect(body.data.webPushVapidConfigured).toBe(true);
|
||||
expect(body.data.bugReportConfigured).toBe(true);
|
||||
// … but raw encrypted blobs must never reach the client.
|
||||
const serialized = JSON.stringify(body.data);
|
||||
expect(serialized).not.toContain("secret-blob");
|
||||
expect(serialized).not.toContain("token-blob");
|
||||
expect(body.data).not.toHaveProperty("webPushVapidPrivateKeyEncrypted");
|
||||
expect(body.data).not.toHaveProperty("githubIssueTokenEncrypted");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,8 +179,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: "production",
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
@@ -227,8 +219,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
@@ -257,8 +247,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
@@ -292,8 +280,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: true,
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "enc(gh-token)",
|
||||
reminderLateMinutes: 30,
|
||||
reminderMissedMinutes: 90,
|
||||
moodLogGlobal: false,
|
||||
@@ -317,8 +303,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
umamiWebsiteId: "site-uuid",
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
bugReportRepo: "owner/repo",
|
||||
bugReportToken: "gh-token",
|
||||
reminderLateMinutes: 30,
|
||||
reminderMissedMinutes: 90,
|
||||
}),
|
||||
@@ -336,8 +320,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
umamiWebsiteId: "site-uuid",
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "enc(gh-token)",
|
||||
webPushVapidPublicKey: "vapid-pub",
|
||||
webPushVapidSubject: "mailto:ops@healthlog.dev",
|
||||
webPushVapidPrivateKeyEncrypted: "enc(secret)",
|
||||
@@ -345,7 +327,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
reminderMissedMinutes: 90,
|
||||
});
|
||||
expect(encrypt).toHaveBeenCalledWith("secret");
|
||||
expect(encrypt).toHaveBeenCalledWith("gh-token");
|
||||
});
|
||||
|
||||
it("clears nullable string fields when an empty string is submitted", async () => {
|
||||
@@ -366,8 +347,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
@@ -381,7 +360,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
umamiWebsiteId: "",
|
||||
glitchtipDsn: "",
|
||||
glitchtipEnvironment: "",
|
||||
bugReportRepo: "",
|
||||
}),
|
||||
);
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
@@ -392,39 +370,9 @@ describe("PUT /api/admin/settings", () => {
|
||||
umamiWebsiteId: null,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the encrypted bug-report token when clear flag is set", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
await PUT(jsonReq({ clearBugReportToken: true }));
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsert?.update).toEqual({ githubIssueTokenEncrypted: null });
|
||||
});
|
||||
|
||||
it("returns 415 when content-type is not JSON", async () => {
|
||||
const r = new NextRequest("http://localhost/api/admin/settings", {
|
||||
method: "PUT",
|
||||
@@ -454,9 +402,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
bugReportEnabled: true,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
@@ -486,9 +431,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
bugReportEnabled: true,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
invalidateServerDefaultTimezone,
|
||||
isValidTimezone,
|
||||
} from "@/lib/tz/resolver";
|
||||
import { invalidateAppSettings } from "@/lib/cache/invalidate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -30,6 +29,7 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
return apiSuccess({
|
||||
registrationEnabled: settings?.registrationEnabled ?? true,
|
||||
mfaRequired: settings?.mfaRequired ?? false,
|
||||
defaultLocale: settings?.defaultLocale ?? "de",
|
||||
telegramGlobal: settings?.telegramGlobal ?? true,
|
||||
ntfyGlobal: settings?.ntfyGlobal ?? true,
|
||||
@@ -48,11 +48,6 @@ export const GET = apiHandler(async () => {
|
||||
glitchtipEnabled: settings?.glitchtipEnabled ?? false,
|
||||
glitchtipDsn: settings?.glitchtipDsn ?? null,
|
||||
glitchtipEnvironment: settings?.glitchtipEnvironment ?? "production",
|
||||
bugReportRepo: settings?.githubIssueRepo ?? null,
|
||||
bugReportConfigured: Boolean(
|
||||
settings?.githubIssueRepo && settings?.githubIssueTokenEncrypted,
|
||||
),
|
||||
bugReportEnabled: settings?.bugReportEnabled ?? true,
|
||||
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
|
||||
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
|
||||
moodLogGlobal: settings?.moodLogGlobal ?? true,
|
||||
@@ -85,6 +80,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
// Boolean fields — direct mapping
|
||||
const booleanFields = [
|
||||
"registrationEnabled",
|
||||
"mfaRequired",
|
||||
"telegramGlobal",
|
||||
"ntfyGlobal",
|
||||
"webPushGlobal",
|
||||
@@ -92,7 +88,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
"umamiEnabled",
|
||||
"glitchtipEnabled",
|
||||
"moodLogGlobal",
|
||||
"bugReportEnabled",
|
||||
] as const;
|
||||
for (const field of booleanFields) {
|
||||
if (data[field] !== undefined) {
|
||||
@@ -171,24 +166,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
auditDetails.glitchtipEnvironment = value || null;
|
||||
}
|
||||
|
||||
// Bug report
|
||||
if (data.bugReportRepo !== undefined) {
|
||||
const repo = data.bugReportRepo.trim();
|
||||
updates.githubIssueRepo = repo || null;
|
||||
auditDetails.bugReportRepo = repo || null;
|
||||
}
|
||||
if (data.bugReportToken !== undefined) {
|
||||
const token = data.bugReportToken.trim();
|
||||
if (token) {
|
||||
updates.githubIssueTokenEncrypted = encrypt(token);
|
||||
auditDetails.bugReportTokenUpdated = true;
|
||||
}
|
||||
}
|
||||
if (data.clearBugReportToken === true) {
|
||||
updates.githubIssueTokenEncrypted = null;
|
||||
auditDetails.bugReportTokenUpdated = false;
|
||||
}
|
||||
|
||||
// Numeric thresholds
|
||||
if (data.reminderLateMinutes !== undefined) {
|
||||
updates.reminderLateMinutes = data.reminderLateMinutes;
|
||||
@@ -233,11 +210,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
invalidateServerDefaultTimezone();
|
||||
}
|
||||
|
||||
// v1.4.34 IW-G — bust the bug-report status cache (global singleton)
|
||||
// so the next read reflects the new GitHub-token / bug-report-enabled
|
||||
// shape. Cheap call — the cache holds at most 10 entries.
|
||||
invalidateAppSettings();
|
||||
|
||||
await auditLog("admin.settings.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
@@ -246,6 +218,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
return apiSuccess({
|
||||
registrationEnabled: settings.registrationEnabled,
|
||||
mfaRequired: settings.mfaRequired,
|
||||
defaultLocale: settings.defaultLocale,
|
||||
telegramGlobal: settings.telegramGlobal,
|
||||
ntfyGlobal: settings.ntfyGlobal,
|
||||
@@ -264,11 +237,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
glitchtipEnabled: settings.glitchtipEnabled,
|
||||
glitchtipDsn: settings.glitchtipDsn,
|
||||
glitchtipEnvironment: settings.glitchtipEnvironment ?? "production",
|
||||
bugReportRepo: settings.githubIssueRepo,
|
||||
bugReportConfigured: Boolean(
|
||||
settings.githubIssueRepo && settings.githubIssueTokenEncrypted,
|
||||
),
|
||||
bugReportEnabled: settings.bugReportEnabled,
|
||||
reminderLateMinutes: settings.reminderLateMinutes,
|
||||
reminderMissedMinutes: settings.reminderMissedMinutes,
|
||||
moodLogGlobal: settings.moodLogGlobal,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { invalidateAppSettings } from "@/lib/cache/invalidate";
|
||||
import { z } from "zod/v4";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -114,8 +113,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
invalidateAppSettings();
|
||||
|
||||
await auditLog("admin.web_push_vapid.generate", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -89,11 +89,10 @@ describe("GET /api/admin/status (integration health summary)", () => {
|
||||
umami: null,
|
||||
glitchtip: null,
|
||||
webPush: null,
|
||||
bugReport: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("aggregates configured + enabled flags for umami / glitchtip / web-push / bugReport", async () => {
|
||||
it("aggregates configured + enabled flags for umami / glitchtip / web-push", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
umamiScriptUrl: "https://x/script.js",
|
||||
umamiWebsiteId: "site-1",
|
||||
@@ -103,8 +102,6 @@ describe("GET /api/admin/status (integration health summary)", () => {
|
||||
webPushVapidPublicKey: "pub",
|
||||
webPushVapidPrivateKeyEncrypted: "secret-blob",
|
||||
webPushVapidSubject: "mailto:a@b.com",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "token-blob",
|
||||
} as never);
|
||||
|
||||
const res = await GET();
|
||||
@@ -121,7 +118,6 @@ describe("GET /api/admin/status (integration health summary)", () => {
|
||||
enabled: false,
|
||||
});
|
||||
expect(body.data.integrations.webPush).toEqual({ configured: true });
|
||||
expect(body.data.integrations.bugReport).toEqual({ configured: true });
|
||||
|
||||
// Regression: encrypted blobs MUST never appear in the response envelope.
|
||||
const serialized = JSON.stringify(body);
|
||||
|
||||
@@ -56,10 +56,6 @@ export const GET = apiHandler(async () => {
|
||||
appSettings?.webPushVapidPrivateKeyEncrypted &&
|
||||
appSettings?.webPushVapidSubject,
|
||||
);
|
||||
const bugReportConfigured = Boolean(
|
||||
appSettings?.githubIssueRepo && appSettings?.githubIssueTokenEncrypted,
|
||||
);
|
||||
|
||||
const workerStatus = getWorkerStatus();
|
||||
|
||||
return apiSuccess({
|
||||
@@ -89,7 +85,6 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
: null,
|
||||
webPush: webPushConfigured ? { configured: true } : null,
|
||||
bugReport: bugReportConfigured ? { configured: true } : null,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,8 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { destroyAllSessions } from "@/lib/auth/session";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { checkPasswordBreach } from "@/lib/auth/hibp";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (
|
||||
@@ -65,6 +67,16 @@ export const POST = apiHandler(
|
||||
);
|
||||
}
|
||||
|
||||
// v1.23 — reject an admin-set password found in a known breach corpus
|
||||
// (HIBP k-anonymity). Fail-open on an unreachable HIBP.
|
||||
const breach = await checkPasswordBreach(password);
|
||||
if (breach?.breached) {
|
||||
return apiError(
|
||||
getServerTranslator(locale).t("auth.passwordBreached"),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
|
||||
@@ -16,6 +16,9 @@ const updateUserSchema = z.object({
|
||||
username: z.string().min(3).max(30).optional(),
|
||||
email: z.string().email().nullable().optional(),
|
||||
role: z.enum(["ADMIN", "USER"]).optional(),
|
||||
// v1.23 — per-user "require a second factor" override. Effective requirement
|
||||
// is the OR of this and the instance-wide AppSettings.mfaRequired policy.
|
||||
mfaEnforced: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(
|
||||
@@ -64,6 +67,9 @@ export const PUT = apiHandler(
|
||||
}),
|
||||
...(parsed.data.email !== undefined && { email: parsed.data.email }),
|
||||
...(parsed.data.role !== undefined && { role: parsed.data.role }),
|
||||
...(parsed.data.mfaEnforced !== undefined && {
|
||||
mfaEnforced: parsed.data.mfaEnforced,
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -71,6 +77,7 @@ export const PUT = apiHandler(
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
mfaEnforced: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ describe("GET /api/admin/users", () => {
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
mfaEnforced: true,
|
||||
_count: { select: { passkeys: true } },
|
||||
});
|
||||
expect(args.select).not.toHaveProperty("passwordHash");
|
||||
|
||||
@@ -16,6 +16,7 @@ export const GET = apiHandler(async () => {
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
mfaEnforced: true,
|
||||
_count: { select: { passkeys: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
@@ -28,6 +29,9 @@ export const GET = apiHandler(async () => {
|
||||
email: u.email,
|
||||
role: u.role,
|
||||
createdAt: u.createdAt,
|
||||
// v1.23 — per-user "require a second factor" override. The effective
|
||||
// requirement is the OR of this and the instance-wide policy.
|
||||
mfaEnforced: u.mfaEnforced,
|
||||
passkeyCount: u._count.passkeys,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -8,6 +8,8 @@ vi.mock("@/lib/db", () => ({
|
||||
user: { findFirst: vi.fn() },
|
||||
apiToken: { create: vi.fn() },
|
||||
refreshToken: { create: vi.fn() },
|
||||
// v1.23 — login checks for a registered second-factor security key.
|
||||
webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -15,6 +17,14 @@ vi.mock("@/lib/auth/password", () => ({
|
||||
verifyPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/mfa-enrollment", () => ({
|
||||
syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/login-alert", () => ({
|
||||
recordSignInDevice: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn().mockResolvedValue("session-id"),
|
||||
setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -97,6 +107,7 @@ function makeRequest(headers: Record<string, string> = {}): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(FAKE_USER as never);
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(verifyPassword).mockResolvedValue(true);
|
||||
vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never);
|
||||
vi.mocked(prisma.refreshToken.create).mockResolvedValue({
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/password", () => ({ verifyPassword: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/login-response", () => ({
|
||||
finishLogin: vi.fn(async () => new Response(null, { status: 200 })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/mfa/challenge", () => ({
|
||||
createMfaChallenge: vi.fn(async () => ({
|
||||
ticket: "tkt",
|
||||
expiresAt: new Date(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/trusted-device", () => ({
|
||||
consumeTrustedDevice: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/mfa-enrollment", () => ({
|
||||
syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
checkAuthSurfaceRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, ip: "1.2.3.4" }),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "h") }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
|
||||
import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
|
||||
|
||||
const MFA_USER = {
|
||||
id: "u1",
|
||||
username: "u",
|
||||
passwordHash: "hash",
|
||||
onboardingCompletedAt: null,
|
||||
totpConfirmedAt: new Date(),
|
||||
mfaEnforced: false,
|
||||
};
|
||||
|
||||
function req() {
|
||||
return new NextRequest("http://localhost/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email: "u@example.com", password: "pw-correct" }),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(MFA_USER as never);
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/login — trusted device", () => {
|
||||
it("skips the second factor when the device is trusted (no challenge minted)", async () => {
|
||||
vi.mocked(verifyPassword).mockResolvedValue(true);
|
||||
vi.mocked(consumeTrustedDevice).mockResolvedValue(true);
|
||||
|
||||
const res = await POST(req());
|
||||
expect(res.status).toBe(200);
|
||||
expect(createMfaChallenge).not.toHaveBeenCalled();
|
||||
expect(finishLogin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ source: "login.password.trusted_device" }),
|
||||
);
|
||||
// A trusted-device login is NOT step-up fresh — mfaVerified must be absent.
|
||||
const arg = vi.mocked(finishLogin).mock.calls[0][0];
|
||||
expect(arg.mfaVerified).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to the MFA challenge when the device is not trusted", async () => {
|
||||
vi.mocked(verifyPassword).mockResolvedValue(true);
|
||||
vi.mocked(consumeTrustedDevice).mockResolvedValue(false);
|
||||
|
||||
const res = await POST(req());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { meta?: { mfaRequired?: boolean } };
|
||||
expect(body.meta?.mfaRequired).toBe(true);
|
||||
expect(createMfaChallenge).toHaveBeenCalled();
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still requires the password — a wrong password never consults the trusted device", async () => {
|
||||
vi.mocked(verifyPassword).mockResolvedValue(false);
|
||||
|
||||
const res = await POST(req());
|
||||
expect(res.status).toBe(401);
|
||||
expect(consumeTrustedDevice).not.toHaveBeenCalled();
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,18 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { loginPasswordSchema } from "@/lib/validations/auth";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { apiError, safeJson } from "@/lib/api-response";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token";
|
||||
import {
|
||||
resolveTokenPolicy,
|
||||
shouldIssueBearerToken,
|
||||
isCookielessNativeCaller,
|
||||
} from "@/lib/auth/native-client";
|
||||
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
|
||||
import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// v1.4.43 W13 M-4 — `checkAuthSurfaceRateLimit` swaps to a tighter
|
||||
@@ -95,93 +91,107 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const ua = request.headers.get("user-agent");
|
||||
// v1.4.22 W5 reconcile (Sr-H1) — `createSession` now anchors the
|
||||
// `hl_onboarding` cookie itself; pass the user's onboarding state
|
||||
// through so the proxy can short-circuit the redirect before the
|
||||
// page hydrates.
|
||||
await createSession(user.id, user.onboardingCompletedAt == null, ip, ua);
|
||||
|
||||
await auditLog("auth.login.password", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.login.password" } });
|
||||
// v1.23 — second-factor gate. The password is correct, but an account with
|
||||
// a confirmed second factor (a TOTP secret and/or a registered WebAuthn
|
||||
// security key) is NOT yet authenticated: no `Session` row and no token
|
||||
// bundle is minted here. Instead a single-use, ~5-minute MFA ticket is
|
||||
// returned (the partial state lives in the ticket, never in a half-built
|
||||
// session) and the client completes the login at `/api/auth/mfa/verify`
|
||||
// (TOTP / recovery) or `/api/auth/mfa/webauthn/verify` (security key).
|
||||
//
|
||||
// Enumeration note: `meta.mfaRequired` is only ever returned AFTER a valid
|
||||
// password, and the invalid-credentials response above is identical
|
||||
// regardless of MFA state — an attacker without the password cannot learn
|
||||
// whether an account has MFA.
|
||||
const hasTotp = Boolean(user.totpConfirmedAt);
|
||||
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
const hasWebauthn = webauthnKeyCount > 0;
|
||||
|
||||
// v1.4 G4: Native callers (iOS, n8n, Health-Connect, unrecognised UAs)
|
||||
// get a 24h access token + 60d rotating refresh token. Web UAs keep the
|
||||
// legacy 90d Bearer (issued only when X-Client-Type: native is set —
|
||||
// browser flows continue to use the session cookie).
|
||||
if (
|
||||
shouldIssueBearerToken(request.headers) ||
|
||||
isNativeClientRequest(request.headers)
|
||||
) {
|
||||
const policy = resolveTokenPolicy(request.headers);
|
||||
const deviceId = request.headers.get("x-device-id");
|
||||
|
||||
// M-3 hardening: a 60-day refresh token is only ever delivered to a
|
||||
// genuinely cookie-less native caller. A browser spoofing
|
||||
// `X-Client-Type: native` (Mozilla UA or an inbound session cookie)
|
||||
// falls through to the plain access-token path below — never handed a
|
||||
// long-lived secret into a DOM/XSS-reachable context.
|
||||
if (
|
||||
policy.refreshTokenDays !== null &&
|
||||
isCookielessNativeCaller(request.headers)
|
||||
) {
|
||||
const bundle = await issueAccessAndRefresh({
|
||||
userId: user.id,
|
||||
policy,
|
||||
deviceId,
|
||||
userAgent: ua,
|
||||
ipAddress: ip,
|
||||
source: "login.password",
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
if (hasTotp || hasWebauthn) {
|
||||
// v1.23 — "remember this device". A browser the user previously trusted
|
||||
// skips factor 2 within the 30-day window. The password (factor 1) has
|
||||
// already been verified above, so a trusted device only ever drops the
|
||||
// second challenge — it never replaces the password. The minted session is
|
||||
// deliberately NOT stamped `mfaVerifiedAt` (mfaVerified omitted below), so a
|
||||
// trusted-device login can never satisfy step-up (`requireFreshMfa`): a
|
||||
// stolen device cookie cannot reach a destructive action.
|
||||
if (await consumeTrustedDevice(user.id)) {
|
||||
await auditLog("auth.mfa.trusted_device", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { source: "login.password", policy: "native" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "native" },
|
||||
action: { name: "auth.mfa.trusted_device" },
|
||||
meta: { mfa_skipped: true },
|
||||
});
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: bundle.accessToken,
|
||||
tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(),
|
||||
refreshToken: bundle.refreshToken,
|
||||
refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(),
|
||||
// The account has a second factor, so the enforcement gate is satisfied;
|
||||
// keep the hint cookie honest.
|
||||
await syncMfaEnrollCookie(user.id, {
|
||||
totpConfirmedAt: user.totpConfirmedAt,
|
||||
mfaEnforced: user.mfaEnforced,
|
||||
});
|
||||
return finishLogin({
|
||||
user,
|
||||
request,
|
||||
ip,
|
||||
userAgent: ua,
|
||||
source: "login.password.trusted_device",
|
||||
});
|
||||
}
|
||||
|
||||
// Web policy with explicit X-Client-Type:native (legacy iOS auto-login)
|
||||
const issued = await issueApiToken({
|
||||
userId: user.id,
|
||||
name: `web auto-login ${new Date().toISOString()}`,
|
||||
permissions: ["*"],
|
||||
expiresInDays: policy.accessTokenDays,
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
const challenge = await createMfaChallenge(user.id, "login");
|
||||
// Recovery codes are only ever issued alongside TOTP enrollment, so the
|
||||
// recovery method is offered exactly when TOTP is active.
|
||||
const methods: ("totp" | "recovery" | "webauthn")[] = [];
|
||||
if (hasTotp) methods.push("totp", "recovery");
|
||||
if (hasWebauthn) methods.push("webauthn");
|
||||
await auditLog("auth.mfa.challenge", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: {
|
||||
tokenId: issued.tokenId,
|
||||
source: "login.password",
|
||||
policy: "web",
|
||||
},
|
||||
details: { source: "login.password" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "web" },
|
||||
});
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: issued.token,
|
||||
tokenExpiresAt: issued.expiresAt.toISOString(),
|
||||
action: { name: "auth.mfa.challenge" },
|
||||
meta: { mfa_required: true },
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: null,
|
||||
meta: {
|
||||
mfaRequired: true,
|
||||
mfaTicket: challenge.ticket,
|
||||
methods,
|
||||
},
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
annotate({ action: { name: "auth.login.password" } });
|
||||
|
||||
// v1.23 — admin-enforced MFA policy. A single-factor account under the policy
|
||||
// is sent to forced enrollment after sign-in; the proxy reads this hint
|
||||
// cookie without a DB round-trip. Cheap (short-circuits when not enforced).
|
||||
await syncMfaEnrollCookie(user.id, {
|
||||
totpConfirmedAt: user.totpConfirmedAt,
|
||||
mfaEnforced: user.mfaEnforced,
|
||||
});
|
||||
|
||||
// No second factor — issue the session/token exactly as before.
|
||||
return finishLogin({
|
||||
user,
|
||||
request,
|
||||
ip,
|
||||
userAgent: ua,
|
||||
source: "login.password",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
requireFreshMfa: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$transaction: vi.fn(),
|
||||
user: { update: vi.fn() },
|
||||
mfaRecoveryCode: { deleteMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
destroyOtherSessions: vi.fn().mockResolvedValue({ sessionsRevoked: 2 }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/mfa/verify-factor", () => ({
|
||||
verifyMfaFactor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { requireFreshMfa } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { destroyOtherSessions } from "@/lib/auth/session";
|
||||
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
|
||||
|
||||
function req() {
|
||||
return new Request("http://localhost/api/auth/me/mfa/disable", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ method: "totp", code: "123456" }),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireFreshMfa).mockResolvedValue({
|
||||
user: { id: "u1" },
|
||||
session: { id: "sess-current" },
|
||||
} as never);
|
||||
vi.mocked(verifyMfaFactor).mockResolvedValue({ ok: true } as never);
|
||||
vi.mocked(prisma.$transaction).mockImplementation(async (cb: unknown) => {
|
||||
if (typeof cb === "function") {
|
||||
return (cb as (tx: unknown) => unknown)({
|
||||
user: { update: vi.fn() },
|
||||
mfaRecoveryCode: { deleteMany: vi.fn() },
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/me/mfa/disable", () => {
|
||||
it("revokes other sessions + trusted devices (via destroyOtherSessions), keeping the current one", async () => {
|
||||
const res = await POST(req());
|
||||
expect(res.status).toBe(200);
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith("u1", "sess-current");
|
||||
});
|
||||
|
||||
it("rejects a wrong current factor without revoking anything", async () => {
|
||||
vi.mocked(verifyMfaFactor).mockResolvedValue({
|
||||
ok: false,
|
||||
replay: false,
|
||||
} as never);
|
||||
const res = await POST(req());
|
||||
expect(res.status).toBe(401);
|
||||
expect(destroyOtherSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* POST /api/auth/me/mfa/disable
|
||||
*
|
||||
* Tear down the second factor. Double-gated:
|
||||
* 1. `requireFreshMfa` — a cookie session that completed a second factor
|
||||
* within the step-up window (Bearer can never satisfy this).
|
||||
* 2. A current TOTP or recovery code in the body — proves live possession at
|
||||
* the moment of the destructive action, not just a stale fresh-session.
|
||||
*
|
||||
* On success it clears `totpSecretEncrypted` / `totpConfirmedAt` /
|
||||
* `totpLastStep` and deletes every recovery code in one transaction.
|
||||
*/
|
||||
import {
|
||||
apiHandler,
|
||||
requireFreshMfa,
|
||||
HttpError,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { destroyOtherSessions } from "@/lib/auth/session";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
|
||||
import { mfaDisableSchema } from "@/lib/validations/mfa";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const DISABLE_RATE_LIMIT = 5;
|
||||
const DISABLE_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
// Step-up gate first — throws StepUpRequiredError (401 + errorCode) if the
|
||||
// session is not freshly second-factor-verified.
|
||||
const { user, session } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mfa:disable:${user.id}`,
|
||||
DISABLE_RATE_LIMIT,
|
||||
DISABLE_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const res = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
res.headers.set(k, v);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 4096,
|
||||
});
|
||||
if (jsonError) {
|
||||
throw new HttpError(400, "Invalid JSON body");
|
||||
}
|
||||
const parsed = mfaDisableSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
|
||||
const factor = await verifyMfaFactor(
|
||||
user,
|
||||
parsed.data.method,
|
||||
parsed.data.code,
|
||||
);
|
||||
if (!factor.ok) {
|
||||
await auditLog("auth.mfa.failed", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
stage: "disable",
|
||||
method: parsed.data.method,
|
||||
replay: factor.replay,
|
||||
},
|
||||
});
|
||||
annotate({ action: { name: "auth.mfa.disable.invalid_factor" } });
|
||||
throw new HttpError(401, "Invalid code");
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totpSecretEncrypted: null,
|
||||
totpConfirmedAt: null,
|
||||
totpLastStep: null,
|
||||
},
|
||||
});
|
||||
await tx.mfaRecoveryCode.deleteMany({ where: { userId: user.id } });
|
||||
});
|
||||
|
||||
// v1.23 (M-review L3) — removing the second factor is a security-state
|
||||
// change: drop every OTHER web session, every native refresh token, and
|
||||
// every "remember this device" trusted device (all handled by
|
||||
// destroyOtherSessions), keeping only the caller's current session. A stale
|
||||
// session or a trusted-device cookie must not survive the factor's removal.
|
||||
await destroyOtherSessions(user.id, session.id);
|
||||
|
||||
await auditLog("auth.mfa.disabled", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { factor: parsed.data.method },
|
||||
});
|
||||
annotate({ action: { name: "auth.mfa.disabled" } });
|
||||
|
||||
return apiSuccess({ enabled: false });
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* POST /api/auth/me/mfa/recovery-codes/regenerate
|
||||
*
|
||||
* Issue a fresh recovery-code batch, invalidating the entire prior set in one
|
||||
* transaction. Step-up gated (`requireFreshMfa`) — a recovery-code rotation is
|
||||
* a sensitive action; Bearer can never satisfy the gate. The new codes are
|
||||
* returned **once**.
|
||||
*/
|
||||
import {
|
||||
apiHandler,
|
||||
requireFreshMfa,
|
||||
HttpError,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { regenerateRecoveryCodes } from "@/lib/auth/mfa/recovery-codes";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const REGEN_RATE_LIMIT = 5;
|
||||
const REGEN_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
|
||||
// Defence in depth — regeneration only makes sense for an active factor.
|
||||
if (!user.totpConfirmedAt) {
|
||||
throw new HttpError(409, "No second factor is active");
|
||||
}
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mfa:recovery-regen:${user.id}`,
|
||||
REGEN_RATE_LIMIT,
|
||||
REGEN_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const res = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
res.headers.set(k, v);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
const recoveryCodes = await regenerateRecoveryCodes(user.id);
|
||||
|
||||
await auditLog("auth.mfa.recovery_regenerated", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { count: recoveryCodes.length },
|
||||
});
|
||||
annotate({ action: { name: "auth.mfa.recovery_regenerated" } });
|
||||
|
||||
return apiSuccess({
|
||||
recoveryCodes,
|
||||
recoveryCodesRemaining: recoveryCodes.length,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* GET /api/auth/me/mfa
|
||||
*
|
||||
* Second-factor status for the security settings hub: whether TOTP is active,
|
||||
* how many recovery codes remain, and the registered WebAuthn security keys.
|
||||
* Cookie-only (`requireCookieAuth`) — managing MFA is never a Bearer surface.
|
||||
*
|
||||
* Returns only metadata: no secret, no recovery-code plaintext, no public key.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { countRemainingRecoveryCodes } from "@/lib/auth/mfa/recovery-codes";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireCookieAuth();
|
||||
|
||||
const [recoveryCodesRemaining, webauthn] = await Promise.all([
|
||||
countRemainingRecoveryCodes(user.id),
|
||||
prisma.webauthnMfaCredential.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
annotate({ action: { name: "auth.mfa.status" } });
|
||||
|
||||
return apiSuccess({
|
||||
totp: { enabled: Boolean(user.totpConfirmedAt) },
|
||||
recoveryCodesRemaining,
|
||||
webauthn,
|
||||
// The security hub also drives the passkey-upgrade nudge from this one
|
||||
// status read, so it knows whether the user has already dismissed it.
|
||||
passkeyNudgeDismissed: Boolean(user.passkeyUpgradeNudgeDismissed),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* POST /api/auth/me/mfa/totp/confirm
|
||||
*
|
||||
* Finish TOTP enrollment. Verifies a code against the pending secret; on
|
||||
* success it promotes pending → active (`totpConfirmedAt = now()`), records
|
||||
* the accepted time-step (replay guard), generates + persists the Argon2id-
|
||||
* hashed recovery codes, and returns the codes **once** for the user to save.
|
||||
*
|
||||
* The completing session is also stamped `mfaVerifiedAt = now()` so the user
|
||||
* can immediately manage the freshly-enabled factor (disable / regenerate
|
||||
* codes) without an extra step-up round-trip.
|
||||
*
|
||||
* Cookie-only (`requireCookieAuth`): an API token can never enrol MFA.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { verifyTotp } from "@/lib/auth/mfa/totp";
|
||||
import {
|
||||
generateRecoveryCodes,
|
||||
persistRecoveryCodes,
|
||||
} from "@/lib/auth/mfa/recovery-codes";
|
||||
import { totpConfirmSchema } from "@/lib/validations/mfa";
|
||||
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CONFIRM_RATE_LIMIT = 10;
|
||||
const CONFIRM_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user, session } = await requireCookieAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mfa:confirm:${user.id}`,
|
||||
CONFIRM_RATE_LIMIT,
|
||||
CONFIRM_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const res = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
res.headers.set(k, v);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
if (user.totpConfirmedAt) {
|
||||
throw new HttpError(409, "A second factor is already active");
|
||||
}
|
||||
if (!user.totpSecretEncrypted) {
|
||||
throw new HttpError(409, "Start enrollment before confirming");
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 4096,
|
||||
});
|
||||
if (jsonError) {
|
||||
throw new HttpError(400, "Invalid JSON body");
|
||||
}
|
||||
const parsed = totpConfirmSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid code", 422);
|
||||
}
|
||||
|
||||
// Decrypt is fail-closed — a missing/rotated key throws rather than
|
||||
// confirming against junk.
|
||||
const secret = decrypt(user.totpSecretEncrypted);
|
||||
const result = verifyTotp(
|
||||
secret,
|
||||
parsed.data.code,
|
||||
user.totpLastStep === null ? null : Number(user.totpLastStep),
|
||||
);
|
||||
|
||||
if (!result.valid) {
|
||||
await auditLog("auth.mfa.failed", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { stage: "confirm", replay: result.replay },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.mfa.totp.confirm.invalid" },
|
||||
meta: { replay: result.replay },
|
||||
});
|
||||
throw new HttpError(401, "Invalid code");
|
||||
}
|
||||
|
||||
const recoveryCodes = generateRecoveryCodes();
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totpConfirmedAt: new Date(),
|
||||
totpLastStep: BigInt(result.step as number),
|
||||
},
|
||||
});
|
||||
// A fresh enrollment starts from a clean recovery set.
|
||||
await tx.mfaRecoveryCode.deleteMany({ where: { userId: user.id } });
|
||||
await persistRecoveryCodes(tx, user.id, recoveryCodes);
|
||||
// Stamp the completing session so the user can immediately manage the
|
||||
// factor under step-up without re-verifying.
|
||||
await tx.session.updateMany({
|
||||
where: { id: session.id },
|
||||
data: { mfaVerifiedAt: new Date() },
|
||||
});
|
||||
});
|
||||
|
||||
// v1.23 — the account now has an active second factor, so any
|
||||
// admin-enforced forced-enrollment redirect must clear immediately.
|
||||
await setMfaEnrollCookie(false);
|
||||
|
||||
await auditLog("auth.mfa.enabled", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { factor: "totp" },
|
||||
});
|
||||
annotate({ action: { name: "auth.mfa.enabled" } });
|
||||
|
||||
// `recoveryCodes` is redaction-denylisted (`/recovery.?code/i`).
|
||||
return apiSuccess({
|
||||
enabled: true,
|
||||
recoveryCodes,
|
||||
recoveryCodesRemaining: recoveryCodes.length,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* POST /api/auth/me/mfa/totp/setup
|
||||
*
|
||||
* Begin TOTP enrollment. Generates a 160-bit secret, stores it **encrypted**
|
||||
* (AES-256-GCM, the same at-rest envelope as every other credential), and
|
||||
* returns the `otpauth://` URI + the raw Base32 secret so the client can
|
||||
* render the QR and offer manual entry. The secret is **pending** — MFA is
|
||||
* not active until `/confirm` verifies a code, so `totpConfirmedAt` stays
|
||||
* null here.
|
||||
*
|
||||
* Cookie-only (`requireCookieAuth`): an API token can never enrol MFA. The
|
||||
* recovery-code batch is issued at `/confirm` (after the factor is proven),
|
||||
* not here, so an abandoned setup never persists codes.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { generateTotpSecret, buildOtpauthUri } from "@/lib/auth/mfa/totp";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const SETUP_RATE_LIMIT = 5;
|
||||
const SETUP_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mfa:setup:${user.id}`,
|
||||
SETUP_RATE_LIMIT,
|
||||
SETUP_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const res = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
res.headers.set(k, v);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// An already-active factor must be disabled (step-up gated) before a new
|
||||
// secret can be enrolled — re-running setup must not silently rotate the
|
||||
// live secret out from under the user's authenticator.
|
||||
if (user.totpConfirmedAt) {
|
||||
annotate({ action: { name: "auth.mfa.totp.setup.already_active" } });
|
||||
throw new HttpError(409, "A second factor is already active");
|
||||
}
|
||||
|
||||
const secret = generateTotpSecret();
|
||||
const account = user.email ?? user.username;
|
||||
const otpauthUri = buildOtpauthUri(secret, account);
|
||||
|
||||
// Store the pending secret encrypted; leave `totpConfirmedAt` null.
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totpSecretEncrypted: encrypt(secret),
|
||||
// A re-setup resets the replay counter — the new secret starts fresh.
|
||||
totpLastStep: null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("auth.mfa.totp.setup", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
});
|
||||
annotate({ action: { name: "auth.mfa.totp.setup" } });
|
||||
|
||||
// `totpSecret` / `otpauthUri` are redaction-denylisted (`/totp/i`,
|
||||
// `/otp/i`, `/secret/i`) so they never surface in a wide-event excerpt.
|
||||
return apiSuccess({ otpauthUri, totpSecret: secret });
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* PATCH /api/auth/me/mfa/webauthn/[id] — rename a registered security key.
|
||||
* DELETE /api/auth/me/mfa/webauthn/[id] — remove one (step-up gated).
|
||||
*
|
||||
* Rename is cookie-authenticated (non-destructive). Removal is a sensitive
|
||||
* action: it is gated on a fresh second-factor step-up (`requireFreshMfa`,
|
||||
* cookie-only — a Bearer token can never satisfy it).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
apiHandler,
|
||||
requireCookieAuth,
|
||||
requireFreshMfa,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { mfaWebauthnRenameSchema } from "@/lib/validations/mfa";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = mfaWebauthnRenameSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
|
||||
const existing = await prisma.webauthnMfaCredential.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Security key not found", 404);
|
||||
}
|
||||
|
||||
const updated = await prisma.webauthnMfaCredential.update({
|
||||
where: { id },
|
||||
data: { name: parsed.data.name },
|
||||
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.mfa.webauthn.rename" } });
|
||||
return apiSuccess(updated);
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
// Step-up gate first — throws StepUpRequiredError (401 + errorCode) if the
|
||||
// session is not freshly second-factor-verified.
|
||||
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.webauthnMfaCredential.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true, name: true },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Security key not found", 404);
|
||||
}
|
||||
|
||||
await prisma.webauthnMfaCredential.delete({ where: { id } });
|
||||
|
||||
await auditLog("auth.mfa.webauthn.remove", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { credentialId: id, name: existing.name },
|
||||
});
|
||||
annotate({ action: { name: "auth.mfa.webauthn.remove" } });
|
||||
|
||||
return apiSuccess({ success: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/auth/mfa-enrollment", () => ({
|
||||
setMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
|
||||
syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
requireCookieAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { webauthnMfaCredential: { create: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/mfa/webauthn", () => ({
|
||||
createMfaRegistrationOptions: vi.fn(),
|
||||
verifyMfaRegistration: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
import { POST as REGISTER_OPTIONS } from "../options/route";
|
||||
import { POST as REGISTER_VERIFY } from "../verify/route";
|
||||
import { requireCookieAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
createMfaRegistrationOptions,
|
||||
verifyMfaRegistration,
|
||||
} from "@/lib/auth/mfa/webauthn";
|
||||
|
||||
const USER = { id: "user-1", username: "u", email: "u@example.com" };
|
||||
|
||||
function verifyRequest(body: Record<string, unknown>): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/auth/me/mfa/webauthn/register/verify",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(requireCookieAuth).mockResolvedValue({ user: USER } as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/me/mfa/webauthn/register/options", () => {
|
||||
it("returns ceremony options + challenge id", async () => {
|
||||
vi.mocked(createMfaRegistrationOptions).mockResolvedValue({
|
||||
options: { challenge: "abc" },
|
||||
challengeId: "ch-1",
|
||||
} as never);
|
||||
|
||||
const res = await REGISTER_OPTIONS();
|
||||
const body = (await res.json()) as {
|
||||
data: { options: unknown; challengeId: string };
|
||||
};
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.challengeId).toBe("ch-1");
|
||||
expect(createMfaRegistrationOptions).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"u@example.com",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/me/mfa/webauthn/register/verify", () => {
|
||||
const credential = {
|
||||
id: "cred-1",
|
||||
rawId: "cred-1",
|
||||
type: "public-key",
|
||||
response: { transports: ["usb"] },
|
||||
};
|
||||
|
||||
it("stores the credential when verification succeeds", async () => {
|
||||
vi.mocked(verifyMfaRegistration).mockResolvedValue({
|
||||
verified: true,
|
||||
registrationInfo: {
|
||||
credential: {
|
||||
id: "cred-1",
|
||||
publicKey: new Uint8Array([1, 2, 3]),
|
||||
counter: 0,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(prisma.webauthnMfaCredential.create).mockResolvedValue({
|
||||
id: "row-1",
|
||||
name: "My key",
|
||||
createdAt: new Date(),
|
||||
lastUsedAt: null,
|
||||
} as never);
|
||||
|
||||
const res = await REGISTER_VERIFY(
|
||||
verifyRequest({ challengeId: "ch-1", credential, name: "My key" }),
|
||||
);
|
||||
const body = (await res.json()) as { data: { id: string; name: string } };
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.id).toBe("row-1");
|
||||
expect(verifyMfaRegistration).toHaveBeenCalledWith(
|
||||
"ch-1",
|
||||
"user-1",
|
||||
credential,
|
||||
);
|
||||
const createArgs = vi.mocked(prisma.webauthnMfaCredential.create).mock
|
||||
.calls[0][0];
|
||||
expect(createArgs.data.userId).toBe("user-1");
|
||||
expect(createArgs.data.credentialId).toBe("cred-1");
|
||||
expect(createArgs.data.name).toBe("My key");
|
||||
});
|
||||
|
||||
it("returns 400 when verification fails", async () => {
|
||||
vi.mocked(verifyMfaRegistration).mockResolvedValue({
|
||||
verified: false,
|
||||
} as never);
|
||||
|
||||
const res = await REGISTER_VERIFY(
|
||||
verifyRequest({ challengeId: "ch-1", credential }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(prisma.webauthnMfaCredential.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 for a malformed body", async () => {
|
||||
const res = await REGISTER_VERIFY(verifyRequest({ challengeId: "ch-1" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* POST /api/auth/me/mfa/webauthn/register/options
|
||||
*
|
||||
* Begin registering a WebAuthn security key as a second factor. Cookie-only —
|
||||
* an API token can never enrol MFA. Returns the SimpleWebAuthn creation
|
||||
* options + the server-issued challenge id to present back at /register/verify.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireCookieAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mfa:webauthn:register:${user.id}`,
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const res = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
res.headers.set(k, v);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
const { options, challengeId } = await createMfaRegistrationOptions(
|
||||
user.id,
|
||||
user.email ?? user.username,
|
||||
);
|
||||
|
||||
annotate({ action: { name: "auth.mfa.webauthn.register-options" } });
|
||||
|
||||
return apiSuccess({ options, challengeId });
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* POST /api/auth/me/mfa/webauthn/register/verify
|
||||
*
|
||||
* Finish registering a WebAuthn security key as a second factor. Cookie-only.
|
||||
* Verifies the attestation against the user-bound challenge and stores the
|
||||
* credential in `WebauthnMfaCredential` (kept separate from primary passkeys).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyMfaRegistration } from "@/lib/auth/mfa/webauthn";
|
||||
import { mfaWebauthnRegisterVerifySchema } from "@/lib/validations/mfa";
|
||||
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = mfaWebauthnRegisterVerifySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
const { challengeId, credential, name } = parsed.data;
|
||||
|
||||
const verification = await verifyMfaRegistration(
|
||||
challengeId,
|
||||
user.id,
|
||||
credential,
|
||||
);
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
return apiError("Security key verification failed", 400);
|
||||
}
|
||||
|
||||
const { registrationInfo } = verification;
|
||||
const created = await prisma.webauthnMfaCredential.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: name ?? "Security key",
|
||||
credentialId: registrationInfo.credential.id,
|
||||
credentialPublicKey: Buffer.from(registrationInfo.credential.publicKey),
|
||||
counter: BigInt(registrationInfo.credential.counter),
|
||||
transports:
|
||||
((credential.response as Record<string, unknown> | undefined)
|
||||
?.transports as string[]) ?? [],
|
||||
},
|
||||
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
|
||||
});
|
||||
|
||||
// v1.23 — a registered security key satisfies an admin-enforced MFA policy,
|
||||
// so clear any forced-enrollment redirect immediately.
|
||||
await setMfaEnrollCookie(false);
|
||||
|
||||
await auditLog("auth.mfa.webauthn.register", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
});
|
||||
annotate({ action: { name: "auth.mfa.webauthn.register" } });
|
||||
|
||||
return apiSuccess(created);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* POST /api/auth/me/passkey-nudge/dismiss
|
||||
*
|
||||
* Permanently dismiss the "add a passkey" upgrade nudge for the calling user.
|
||||
* Cookie-only; idempotent. Sets `User.passkeyUpgradeNudgeDismissed = true`.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireCookieAuth();
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passkeyUpgradeNudgeDismissed: true },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.passkey.nudge.dismiss" } });
|
||||
return apiSuccess({ dismissed: true });
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { setOnboardingPendingCookie } from "@/lib/auth/session";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { buildAvatarUrl } from "@/lib/avatar";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -26,8 +27,16 @@ export const GET = apiHandler(async () => {
|
||||
// cycle-tracking gate; no row is forced, a NULL toggle derives from
|
||||
// gender) is a Postgres round-trip. Running them sequentially added
|
||||
// the cookie hop to every /me — and /me sits on every app boot path.
|
||||
const [, cycleProfile, modules, moduleAvailability] = await Promise.all([
|
||||
const [, , cycleProfile, modules, moduleAvailability] = await Promise.all([
|
||||
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
|
||||
// v1.23 — keep the admin-enforced-MFA hint cookie honest on every app
|
||||
// boot, mirroring the onboarding-cookie resync. A locally edited cookie is
|
||||
// corrected here; an account that enrols (or has the policy lifted) loses
|
||||
// the redirect on the next /me read.
|
||||
syncMfaEnrollCookie(user.id, {
|
||||
totpConfirmedAt: user.totpConfirmedAt,
|
||||
mfaEnforced: user.mfaEnforced,
|
||||
}),
|
||||
prisma.cycleProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { cycleTrackingEnabled: true },
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* GET /api/auth/me/security-activity
|
||||
*
|
||||
* v1.23 — the SHARED user-facing security-activity feed. Returns the caller's
|
||||
* recent account-security audit events (logins, MFA, password change, token /
|
||||
* session revocations, exports, deletions) with timestamp + resolved location
|
||||
* + MASKED IP. The IP is never returned in full and no event detail body is
|
||||
* surfaced — only the action name, time, and coarse geo. Consumed by the
|
||||
* account-security surface and the privacy dashboard.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { securityActivityWhere } from "@/lib/auth/security-activity";
|
||||
import { maskIp } from "@/lib/auth/device-fingerprint";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 100;
|
||||
|
||||
export interface SecurityActivityDTO {
|
||||
action: string;
|
||||
createdAt: string;
|
||||
location: string | null;
|
||||
ipMasked: string | null;
|
||||
carrier: string | null;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const limitParam = Number(
|
||||
request.nextUrl.searchParams.get("limit") ?? DEFAULT_LIMIT,
|
||||
);
|
||||
const limit =
|
||||
Number.isFinite(limitParam) && limitParam > 0
|
||||
? Math.min(Math.floor(limitParam), MAX_LIMIT)
|
||||
: DEFAULT_LIMIT;
|
||||
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where: securityActivityWhere(user.id),
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
select: {
|
||||
action: true,
|
||||
createdAt: true,
|
||||
location: true,
|
||||
ipAddress: true,
|
||||
carrier: true,
|
||||
},
|
||||
});
|
||||
|
||||
const events: SecurityActivityDTO[] = rows.map((r) => ({
|
||||
action: r.action,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
location: r.location ?? null,
|
||||
ipMasked: maskIp(r.ipAddress),
|
||||
carrier: r.carrier ?? null,
|
||||
}));
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.security_activity.list" },
|
||||
meta: { event_count: events.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ events });
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* DELETE /api/auth/me/sessions/[id] — revoke a single active web session.
|
||||
*
|
||||
* v1.23 — the deletion is scoped to the authenticated user (the Prisma `where`
|
||||
* narrows on both id AND userId), so a caller can never revoke another user's
|
||||
* session by guessing an id. A 404 is returned for a non-existent or
|
||||
* not-owned id so the surface cannot probe foreign session ids.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { destroySessionById } from "@/lib/auth/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user, session } = await requireAuth();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const removed = await destroySessionById(user.id, id);
|
||||
if (!removed) {
|
||||
return apiError("Session not found", 404);
|
||||
}
|
||||
|
||||
await auditLog("auth.session.revoke", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { self: id === session.id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.session.revoke" },
|
||||
meta: { self: id === session.id },
|
||||
});
|
||||
|
||||
return apiSuccess({ revoked: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { session: { findMany: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
destroyOtherSessions: vi.fn(),
|
||||
destroySessionById: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/geo", () => ({
|
||||
lookupIpLocation: vi.fn(async () => "Berlin, DE"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, DELETE } from "../route";
|
||||
import { DELETE as DELETE_ONE } from "../[id]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
getSession,
|
||||
destroyOtherSessions,
|
||||
destroySessionById,
|
||||
} from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-current", expiresAt: new Date(Date.now() + 3.6e6) },
|
||||
user: { id: "user-1", username: "u", role: "USER" as const },
|
||||
};
|
||||
|
||||
function del(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/me/sessions", {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/sessions", () => {
|
||||
it("401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("lists sessions with masked IP, location, and the current marker", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.session.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "sess-current",
|
||||
ipAddress: "203.0.113.7",
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0",
|
||||
lastActiveAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "sess-other",
|
||||
ipAddress: "198.51.100.4",
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Windows NT 10.0) Chrome/119.0.0.0 Safari/537.36",
|
||||
lastActiveAt: null,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
const list = body.data.sessions;
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0]).toMatchObject({
|
||||
id: "sess-current",
|
||||
device: "Firefox on macOS",
|
||||
ipMasked: "203.0.x.x",
|
||||
location: "Berlin, DE",
|
||||
isCurrent: true,
|
||||
});
|
||||
expect(list[1].isCurrent).toBe(false);
|
||||
// The full IP never appears in the response body.
|
||||
expect(JSON.stringify(body)).not.toContain("203.0.113.7");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
|
||||
it("revokes other sessions and reports the count", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(destroyOtherSessions).mockResolvedValue({ sessionsRevoked: 3 });
|
||||
|
||||
const res = await DELETE(del());
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.sessionsRevoked).toBe(3);
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", "sess-current");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/auth/me/sessions/[id]", () => {
|
||||
function ctx(id: string) {
|
||||
return { params: Promise.resolve({ id }) };
|
||||
}
|
||||
function delReq(id: string): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/auth/me/sessions/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
it("revokes a single owned session", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(destroySessionById).mockResolvedValue(true);
|
||||
const res = await DELETE_ONE(delReq("sess-other"), ctx("sess-other"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(destroySessionById).toHaveBeenCalledWith("user-1", "sess-other");
|
||||
});
|
||||
|
||||
it("404 for a non-existent or not-owned id", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(destroySessionById).mockResolvedValue(false);
|
||||
const res = await DELETE_ONE(delReq("nope"), ctx("nope"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* GET /api/auth/me/sessions — list the user's active web sessions.
|
||||
* DELETE /api/auth/me/sessions — "sign out everywhere": revoke every OTHER
|
||||
* session (and native refresh tokens), keeping
|
||||
* the caller's current session. Closes #64.
|
||||
*
|
||||
* v1.23 — the user-facing session/device-management surface. Distinct from
|
||||
* `/api/auth/me/devices`, which lists APNs / Web-Push notification devices;
|
||||
* this lists the authenticated `Session` rows (one per browser login). The list
|
||||
* carries the masked IP + resolved coarse location + a coarse device label +
|
||||
* the sliding `lastActiveAt` — never the full IP, never a credential. The
|
||||
* caller's own row is flagged `isCurrent` so the UI can mark "this device".
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getClientIp } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { destroyOtherSessions } from "@/lib/auth/session";
|
||||
import { lookupIpLocation } from "@/lib/geo";
|
||||
import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export interface SessionDTO {
|
||||
id: string;
|
||||
device: string;
|
||||
ipMasked: string | null;
|
||||
location: string | null;
|
||||
lastActiveAt: string | null;
|
||||
createdAt: string;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user, session } = await requireAuth();
|
||||
|
||||
const sessions = await prisma.session.findMany({
|
||||
where: { userId: user.id, expiresAt: { gt: new Date() } },
|
||||
orderBy: { lastActiveAt: { sort: "desc", nulls: "last" } },
|
||||
select: {
|
||||
id: true,
|
||||
ipAddress: true,
|
||||
userAgent: true,
|
||||
lastActiveAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Resolve location once per distinct IP (the resolver caches internally, but
|
||||
// de-duping keeps the await count to the number of unique networks).
|
||||
const uniqueIps = Array.from(
|
||||
new Set(
|
||||
sessions.map((s) => s.ipAddress).filter((ip): ip is string => !!ip),
|
||||
),
|
||||
);
|
||||
const locationByIp = new Map<string, string | null>();
|
||||
await Promise.all(
|
||||
uniqueIps.map(async (ip) => {
|
||||
try {
|
||||
locationByIp.set(ip, await lookupIpLocation(ip));
|
||||
} catch {
|
||||
locationByIp.set(ip, null);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const result: SessionDTO[] = sessions.map((s) => ({
|
||||
id: s.id,
|
||||
device: coarseDeviceLabel(s.userAgent),
|
||||
ipMasked: maskIp(s.ipAddress),
|
||||
location: s.ipAddress ? (locationByIp.get(s.ipAddress) ?? null) : null,
|
||||
lastActiveAt: s.lastActiveAt ? s.lastActiveAt.toISOString() : null,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
isCurrent: s.id === session.id,
|
||||
}));
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.session.list" },
|
||||
meta: { session_count: result.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ sessions: result });
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user, session } = await requireAuth();
|
||||
|
||||
const { sessionsRevoked } = await destroyOtherSessions(user.id, session.id);
|
||||
|
||||
await auditLog("auth.session.revoke_others", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { sessionsRevoked },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.session.revoke_others" },
|
||||
meta: { sessions_revoked: sessionsRevoked },
|
||||
});
|
||||
|
||||
return apiSuccess({ sessionsRevoked });
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* DELETE /api/auth/me/trusted-devices/[id]
|
||||
*
|
||||
* Revoke a single trusted device, scoped to the authenticated user (a foreign
|
||||
* id returns 404, never another user's row). When the revoked device is the
|
||||
* caller's own, the trusted-device cookie is cleared too. Cookie-only.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { revokeTrustedDevice } from "@/lib/auth/trusted-device";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const revoked = await revokeTrustedDevice(user.id, id);
|
||||
if (!revoked) {
|
||||
annotate({ action: { name: "auth.trusted_device.revoke.not_found" } });
|
||||
return apiError("Trusted device not found", 404);
|
||||
}
|
||||
|
||||
await auditLog("auth.trusted_device.revoke", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
});
|
||||
annotate({ action: { name: "auth.trusted_device.revoke" } });
|
||||
|
||||
return apiSuccess({ revoked: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* GET /api/auth/me/trusted-devices — list the caller's trusted devices.
|
||||
* DELETE /api/auth/me/trusted-devices — revoke ALL of them ("forget every
|
||||
* device"); also clears the caller's own trusted-device cookie.
|
||||
*
|
||||
* Cookie-only (`requireCookieAuth`) — a trusted device is a browser concept and
|
||||
* a Bearer client never manages one. Returns only metadata: a coarse, IP-free
|
||||
* device label and the lifecycle timestamps; never the token or its hash.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getClientIp } from "@/lib/api-response";
|
||||
import {
|
||||
listTrustedDevices,
|
||||
revokeAllTrustedDevices,
|
||||
clearTrustedDeviceCookie,
|
||||
} from "@/lib/auth/trusted-device";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireCookieAuth();
|
||||
annotate({ action: { name: "auth.trusted_device.list" } });
|
||||
|
||||
const devices = await listTrustedDevices(user.id);
|
||||
return apiSuccess({
|
||||
devices: devices.map((d) => ({
|
||||
id: d.id,
|
||||
label: d.label,
|
||||
createdAt: d.createdAt.toISOString(),
|
||||
lastUsedAt: d.lastUsedAt.toISOString(),
|
||||
expiresAt: d.expiresAt.toISOString(),
|
||||
isCurrent: d.isCurrent,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
|
||||
const revoked = await revokeAllTrustedDevices(user.id);
|
||||
await clearTrustedDeviceCookie();
|
||||
|
||||
await auditLog("auth.trusted_device.revoke_all", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { revoked },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.trusted_device.revoke_all" },
|
||||
meta: { revoked },
|
||||
});
|
||||
|
||||
return apiSuccess({ revoked });
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* POST /api/auth/mfa/verify
|
||||
*
|
||||
* Complete a second-factor login challenge. The caller presents the opaque
|
||||
* `mfaTicket` from the `meta.mfaRequired` login response plus a TOTP or
|
||||
* recovery code. On success the **same** session/token bundle the password
|
||||
* path issues is returned (web → session cookie; native → access+refresh),
|
||||
* with the session stamped `mfaVerifiedAt`.
|
||||
*
|
||||
* Security-critical invariants:
|
||||
* - No session or token exists until the factor passes — the partial state
|
||||
* lived in the ticket, never in a half-built session.
|
||||
* - The ticket is **claimed once, atomically**, only AFTER the factor
|
||||
* verifies; two concurrent valid submissions can mint at most one session.
|
||||
* - Wrong factors increment the ticket attempt counter and burn it at the
|
||||
* cap (NIST throttle, not an account lock). Anonymous surface is rate-
|
||||
* limited per IP on top of the per-ticket cap.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiError, safeJson } from "@/lib/api-response";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import {
|
||||
loadActiveChallenge,
|
||||
recordChallengeFailure,
|
||||
claimChallenge,
|
||||
} from "@/lib/auth/mfa/challenge";
|
||||
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
|
||||
import { mfaVerifySchema } from "@/lib/validations/mfa";
|
||||
import { mintTrustedDevice } from "@/lib/auth/trusted-device";
|
||||
import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint";
|
||||
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:mfa-verify",
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts. Please try again later.", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
await ensureDbCompatibility();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = mfaVerifySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
const { mfaTicket, method, code, rememberDevice } = parsed.data;
|
||||
|
||||
// Resolve the ticket. A single generic 401 covers unknown / expired /
|
||||
// consumed / over-cap so a ticket-guesser learns nothing.
|
||||
const challenge = await loadActiveChallenge(mfaTicket);
|
||||
if (!challenge) {
|
||||
annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } });
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
// Only a login-issued challenge may be redeemed for a session here. A future
|
||||
// step-up or webauthn challenge kind must never mint a login session through
|
||||
// this endpoint, so reject anything but "login" with the same generic 401.
|
||||
if (challenge.kind !== "login") {
|
||||
annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } });
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: challenge.userId },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
onboardingCompletedAt: true,
|
||||
totpConfirmedAt: true,
|
||||
totpSecretEncrypted: true,
|
||||
totpLastStep: true,
|
||||
},
|
||||
});
|
||||
|
||||
// The factor must still be active (it could have been disabled between the
|
||||
// password step and this call). Treat anything else as a failed attempt.
|
||||
if (!user || !user.totpConfirmedAt) {
|
||||
await recordChallengeFailure(challenge.id);
|
||||
annotate({ action: { name: "auth.mfa.verify.no_active_factor" } });
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
const factor = await verifyMfaFactor(user, method, code);
|
||||
if (!factor.ok) {
|
||||
const { exhausted } = await recordChallengeFailure(challenge.id);
|
||||
await auditLog("auth.mfa.failed", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { stage: "login", method, replay: factor.replay, exhausted },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.mfa.failed" },
|
||||
meta: { method, replay: factor.replay, exhausted },
|
||||
});
|
||||
return apiError("Invalid code", 401);
|
||||
}
|
||||
|
||||
// Factor verified — claim the ticket atomically. Only the single winning
|
||||
// caller proceeds to mint a session; a concurrent claim loses here.
|
||||
const claimed = await claimChallenge(challenge.id);
|
||||
if (!claimed) {
|
||||
annotate({ action: { name: "auth.mfa.verify.claim_lost" } });
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
await auditLog("auth.mfa.verified", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { method },
|
||||
});
|
||||
if (method === "recovery") {
|
||||
await auditLog("auth.mfa.recovery_used", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
}
|
||||
annotate({ action: { name: "auth.mfa.verified" }, meta: { method } });
|
||||
|
||||
const ua = request.headers.get("user-agent");
|
||||
|
||||
// The user just completed a second factor, so any forced-enrollment redirect
|
||||
// must clear immediately.
|
||||
await setMfaEnrollCookie(false);
|
||||
|
||||
// v1.23 — "remember this device". Mint the trusted-device token + cookie
|
||||
// (only its hash is stored) so subsequent logins from this browser skip the
|
||||
// second factor for 30 days. Set BEFORE finishLogin so both cookies land on
|
||||
// the same response. A recovery-code login is excluded: it signals the user
|
||||
// lost their device, so trusting the current browser would be unsafe.
|
||||
if (rememberDevice && method !== "recovery") {
|
||||
await mintTrustedDevice(user.id, coarseDeviceLabel(ua));
|
||||
}
|
||||
|
||||
return finishLogin({
|
||||
user,
|
||||
request,
|
||||
ip,
|
||||
userAgent: ua,
|
||||
source: "mfa.verify",
|
||||
mfaVerified: true,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/auth/mfa-enrollment", () => ({
|
||||
setMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
|
||||
syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/mfa/challenge", () => ({
|
||||
loadActiveChallenge: vi.fn(),
|
||||
recordChallengeFailure: vi.fn().mockResolvedValue({ exhausted: false }),
|
||||
claimChallenge: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/mfa/webauthn", () => ({
|
||||
verifyMfaAuthentication: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/login-response", () => ({
|
||||
finishLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
loadActiveChallenge,
|
||||
claimChallenge,
|
||||
recordChallengeFailure,
|
||||
} from "@/lib/auth/mfa/challenge";
|
||||
import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
|
||||
function makeRequest(body: Record<string, unknown>): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/mfa/webauthn/verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const VALID_BODY = {
|
||||
mfaTicket: "ticket-1",
|
||||
challengeId: "ch-1",
|
||||
credential: {
|
||||
id: "cred-1",
|
||||
rawId: "cred-1",
|
||||
type: "public-key",
|
||||
response: {},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
ip: "203.0.113.1",
|
||||
} as never);
|
||||
vi.mocked(loadActiveChallenge).mockResolvedValue({
|
||||
id: "mfa-1",
|
||||
userId: "user-1",
|
||||
kind: "login",
|
||||
attempts: 0,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "u",
|
||||
onboardingCompletedAt: new Date(),
|
||||
} as never);
|
||||
vi.mocked(recordChallengeFailure).mockResolvedValue({
|
||||
exhausted: false,
|
||||
attempts: 1,
|
||||
} as never);
|
||||
vi.mocked(finishLogin).mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: { user: { id: "user-1" } } }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/mfa/webauthn/verify", () => {
|
||||
it("finishes login when the assertion verifies and the ticket is claimed", async () => {
|
||||
vi.mocked(verifyMfaAuthentication).mockResolvedValue(true as never);
|
||||
vi.mocked(claimChallenge).mockResolvedValue(true as never);
|
||||
|
||||
const res = await POST(makeRequest(VALID_BODY));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(verifyMfaAuthentication).toHaveBeenCalledWith(
|
||||
"ch-1",
|
||||
"user-1",
|
||||
VALID_BODY.credential,
|
||||
);
|
||||
expect(claimChallenge).toHaveBeenCalledWith("mfa-1");
|
||||
expect(finishLogin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mfaVerified: true,
|
||||
source: "mfa.webauthn.verify",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects with 401 and burns an attempt when the assertion fails", async () => {
|
||||
vi.mocked(verifyMfaAuthentication).mockResolvedValue(false as never);
|
||||
|
||||
const res = await POST(makeRequest(VALID_BODY));
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(recordChallengeFailure).toHaveBeenCalledWith("mfa-1");
|
||||
expect(claimChallenge).not.toHaveBeenCalled();
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects with 401 for an unknown / expired ticket", async () => {
|
||||
vi.mocked(loadActiveChallenge).mockResolvedValue(null as never);
|
||||
|
||||
const res = await POST(makeRequest(VALID_BODY));
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(verifyMfaAuthentication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not mint a session if the ticket claim is lost (concurrent)", async () => {
|
||||
vi.mocked(verifyMfaAuthentication).mockResolvedValue(true as never);
|
||||
vi.mocked(claimChallenge).mockResolvedValue(false as never);
|
||||
|
||||
const res = await POST(makeRequest(VALID_BODY));
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when rate limited", async () => {
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
ip: "203.0.113.1",
|
||||
} as never);
|
||||
|
||||
const res = await POST(makeRequest(VALID_BODY));
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* POST /api/auth/mfa/webauthn/verify/options
|
||||
*
|
||||
* Begin a mid-login second-factor security-key assertion. The caller presents
|
||||
* the opaque `mfaTicket` from the `meta.mfaRequired` login response; the server
|
||||
* resolves the password-identified user and returns assertion options scoped to
|
||||
* that user's registered MFA security keys (never an empty allow-list — a
|
||||
* non-resident credential is not discoverable).
|
||||
*
|
||||
* Anonymous surface (no session yet), rate-limited per IP. A single generic
|
||||
* 401 covers every unknown / expired / consumed ticket so a guesser learns
|
||||
* nothing.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { loadActiveChallenge } from "@/lib/auth/mfa/challenge";
|
||||
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
import { mfaWebauthnLoginOptionsSchema } from "@/lib/validations/mfa";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:mfa-verify",
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts. Please try again later.", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
await ensureDbCompatibility();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = mfaWebauthnLoginOptionsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
|
||||
const challenge = await loadActiveChallenge(parsed.data.mfaTicket);
|
||||
if (!challenge) {
|
||||
annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } });
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
// Only a login-issued challenge may begin a login assertion here. Reject any
|
||||
// other challenge kind with the same generic 401.
|
||||
if (challenge.kind !== "login") {
|
||||
annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } });
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
const result = await createMfaAuthenticationOptions(challenge.userId);
|
||||
if (!result) {
|
||||
// No registered security key for this account — the client should fall
|
||||
// back to a TOTP / recovery code.
|
||||
annotate({ action: { name: "auth.mfa.webauthn.no_key" } });
|
||||
return apiError("No security key registered", 409);
|
||||
}
|
||||
|
||||
annotate({ action: { name: "auth.mfa.webauthn.verify-options" } });
|
||||
return apiSuccess({
|
||||
options: result.options,
|
||||
challengeId: result.challengeId,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* POST /api/auth/mfa/webauthn/verify
|
||||
*
|
||||
* Complete a mid-login second-factor login challenge with a WebAuthn security
|
||||
* key. The caller presents the login `mfaTicket` plus the assertion from
|
||||
* /verify/options. On success the SAME session/token bundle the password path
|
||||
* issues is returned (web → cookie; native → access+refresh), with the session
|
||||
* stamped `mfaVerifiedAt`.
|
||||
*
|
||||
* Security invariants mirror /api/auth/mfa/verify (the TOTP path):
|
||||
* - No session/token exists until the assertion verifies — the partial state
|
||||
* lives in the ticket, never in a half-built session.
|
||||
* - The ticket is claimed once, atomically, only AFTER the assertion verifies.
|
||||
* - A failed assertion increments the ticket attempt counter and burns it at
|
||||
* the cap. Anonymous surface is rate-limited per IP on top of the cap.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { apiError, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import {
|
||||
loadActiveChallenge,
|
||||
recordChallengeFailure,
|
||||
claimChallenge,
|
||||
} from "@/lib/auth/mfa/challenge";
|
||||
import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn";
|
||||
import { mfaWebauthnLoginVerifySchema } from "@/lib/validations/mfa";
|
||||
import { mintTrustedDevice } from "@/lib/auth/trusted-device";
|
||||
import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint";
|
||||
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:mfa-verify",
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts. Please try again later.", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
await ensureDbCompatibility();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = mfaWebauthnLoginVerifySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
const { mfaTicket, challengeId, credential, rememberDevice } = parsed.data;
|
||||
|
||||
const challenge = await loadActiveChallenge(mfaTicket);
|
||||
if (!challenge) {
|
||||
annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } });
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
// Only a login-issued challenge may be redeemed for a session here. A future
|
||||
// step-up or non-login challenge kind must never mint a login session through
|
||||
// this endpoint, so reject anything but "login" with the same generic 401.
|
||||
if (challenge.kind !== "login") {
|
||||
annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } });
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: challenge.userId },
|
||||
select: { id: true, username: true, onboardingCompletedAt: true },
|
||||
});
|
||||
if (!user) {
|
||||
await recordChallengeFailure(challenge.id);
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
let verified = false;
|
||||
try {
|
||||
verified = await verifyMfaAuthentication(challengeId, user.id, credential);
|
||||
} catch {
|
||||
// A malformed / expired WebAuthn challenge is a failed attempt, not a 500.
|
||||
verified = false;
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
const { exhausted } = await recordChallengeFailure(challenge.id);
|
||||
await auditLog("auth.mfa.failed", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { stage: "login", method: "webauthn", exhausted },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.mfa.failed" },
|
||||
meta: { method: "webauthn", exhausted },
|
||||
});
|
||||
return apiError("Security key verification failed", 401);
|
||||
}
|
||||
|
||||
// Assertion verified — claim the ticket atomically. Only the single winning
|
||||
// caller proceeds to mint a session.
|
||||
const claimed = await claimChallenge(challenge.id);
|
||||
if (!claimed) {
|
||||
annotate({ action: { name: "auth.mfa.verify.claim_lost" } });
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
await auditLog("auth.mfa.verified", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { method: "webauthn" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.mfa.verified" },
|
||||
meta: { method: "webauthn" },
|
||||
});
|
||||
|
||||
const ua = request.headers.get("user-agent");
|
||||
|
||||
// The user just completed a second factor — clear any forced-enrollment hint.
|
||||
await setMfaEnrollCookie(false);
|
||||
|
||||
// v1.23 — "remember this device": skip the second factor on this browser for
|
||||
// 30 days. Set before finishLogin so both cookies share the response.
|
||||
if (rememberDevice) {
|
||||
await mintTrustedDevice(user.id, coarseDeviceLabel(ua));
|
||||
}
|
||||
|
||||
return finishLogin({
|
||||
user,
|
||||
request,
|
||||
ip,
|
||||
userAgent: ua,
|
||||
source: "mfa.webauthn.verify",
|
||||
mfaVerified: true,
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,14 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/mfa-enrollment", () => ({
|
||||
syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/login-alert", () => ({
|
||||
recordSignInDevice: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
// v1.4.43 W13 M-4 — passkey/login-verify now uses the auth-surface
|
||||
@@ -53,6 +61,7 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const FAKE_USER = {
|
||||
@@ -133,4 +142,14 @@ describe("POST /api/auth/passkey/login-verify — native token issuance", () =>
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.apiToken.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// v1.23 (M-review M1) — a primary passkey login is a satisfied second factor.
|
||||
it("stamps mfaVerifiedAt on the session so the passkey satisfies step-up", async () => {
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(200);
|
||||
// createSession(userId, onboardingPending, ip, ua, mfaVerifiedAt) — the
|
||||
// 5th argument must be a Date for a primary passkey sign-in.
|
||||
const args = vi.mocked(createSession).mock.calls[0];
|
||||
expect(args[4]).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -68,14 +70,37 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// `hl_onboarding` cookie itself; pass the user's onboarding state
|
||||
// through so the proxy short-circuits the redirect before
|
||||
// hydration.
|
||||
//
|
||||
// v1.23 (M-review M1) — stamp `mfaVerifiedAt` on a primary passkey login. A
|
||||
// discoverable passkey is a phishing-resistant possession factor that already
|
||||
// cleared user verification (`userVerification: 'required'` on the ceremony),
|
||||
// so a passwordless passkey sign-in is itself a satisfied second factor: it
|
||||
// meets an MFA-enforcement policy AND passes `requireFreshMfa` step-up,
|
||||
// exactly like a completed password+TOTP login. (The password+TOTP path is
|
||||
// unchanged — it stamps the session at `/api/auth/mfa/verify`.)
|
||||
const ua = request.headers.get("user-agent");
|
||||
await createSession(user.id, user.onboardingCompletedAt == null, ip, ua);
|
||||
await createSession(
|
||||
user.id,
|
||||
user.onboardingCompletedAt == null,
|
||||
ip,
|
||||
ua,
|
||||
new Date(),
|
||||
);
|
||||
|
||||
// v1.23 — sync the admin-enforced-MFA forced-enrollment hint cookie.
|
||||
await syncMfaEnrollCookie(user.id, {
|
||||
totpConfirmedAt: user.totpConfirmedAt,
|
||||
mfaEnforced: user.mfaEnforced,
|
||||
});
|
||||
|
||||
await auditLog("auth.login.passkey", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
|
||||
// v1.23 — new-device / new-location alert, fire-and-forget (see finishLogin).
|
||||
void recordSignInDevice({ userId: user.id, ip, userAgent: ua });
|
||||
|
||||
annotate({ action: { name: "auth.login.passkey" } });
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,9 +1,62 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { webauthnKeyNameSchema } from "@/lib/validations/mfa";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const passkeyRenameSchema = z.object({ name: webauthnKeyNameSchema });
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = passkeyRenameSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
|
||||
const passkey = await prisma.passkey.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!passkey || passkey.userId !== user.id) {
|
||||
return apiError("Passkey not found", 404);
|
||||
}
|
||||
|
||||
const updated = await prisma.passkey.update({
|
||||
where: { id },
|
||||
data: { name: parsed.data.name },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
credentialDeviceType: true,
|
||||
credentialBackedUp: true,
|
||||
createdAt: true,
|
||||
lastUsedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.passkey.rename" } });
|
||||
return apiSuccess(updated);
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (
|
||||
|
||||
@@ -16,6 +16,7 @@ export const GET = apiHandler(async () => {
|
||||
credentialDeviceType: true,
|
||||
credentialBackedUp: true,
|
||||
createdAt: true,
|
||||
lastUsedAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { destroyAllSessions, createSession } from "@/lib/auth/session";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { checkPasswordBreach } from "@/lib/auth/hibp";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -73,6 +75,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
// v1.23 — reject a newly chosen password that appears in a known breach
|
||||
// corpus (HIBP k-anonymity). Fail-open: a null result (HIBP unreachable)
|
||||
// never blocks the change. Only the user's chosen NEW password is checked —
|
||||
// existing credentials are never retroactively blocked at login.
|
||||
const breach = await checkPasswordBreach(newPassword);
|
||||
if (breach?.breached) {
|
||||
return apiError(
|
||||
getServerTranslator(locale).t("auth.passwordBreached"),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const newHash = await hashPassword(newPassword);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { registerSchema } from "@/lib/validations/auth";
|
||||
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
@@ -19,6 +20,8 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { checkPasswordBreach } from "@/lib/auth/hibp";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import {
|
||||
isValidTimezone,
|
||||
resolveServerDefaultTimezone,
|
||||
@@ -121,6 +124,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
// v1.23 — reject a registration password found in a known breach corpus
|
||||
// (HIBP k-anonymity). Fail-open on an unreachable HIBP.
|
||||
const breach = await checkPasswordBreach(password);
|
||||
if (breach?.breached) {
|
||||
return apiError(
|
||||
getServerTranslator(locale).t("auth.passwordBreached"),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// v1.15.20 — consume the invite LAST, after every other validation,
|
||||
@@ -176,6 +190,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ua = request.headers.get("user-agent");
|
||||
await createSession(user.id, true, ip, ua);
|
||||
|
||||
// v1.23 — record the registering device silently so the account's first
|
||||
// login from this same device does not immediately fire a "new device"
|
||||
// alert. A genuinely new device later still alerts on its first sighting.
|
||||
void recordSignInDevice({
|
||||
userId: user.id,
|
||||
ip,
|
||||
userAgent: ua,
|
||||
alertOnNew: false,
|
||||
});
|
||||
|
||||
// Stamp the consumer onto the invite (informational, best-effort —
|
||||
// the use itself was already counted atomically above).
|
||||
if (inviteId) {
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/bugreport.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: (s: string) => s.replace(/^enc-/, ""),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/bugreport", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
githubIssueTokenEncrypted: "enc-token",
|
||||
githubIssueRepo: "owner/repo",
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/bugreport — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// description too short + screenshot doesn't match data-URL regex.
|
||||
const res = await POST(
|
||||
postReq({ description: "tiny", screenshot: "not-a-data-url" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces multiple simultaneous validation errors (≥ 2)", async () => {
|
||||
// Schema has only two knobs (description, screenshot). Pin contract
|
||||
// on ≥ 2.
|
||||
const res = await POST(postReq({ description: 123, screenshot: 999 }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// Strict image/* data URL (no SVG — it can carry inline JS).
|
||||
const SCREENSHOT_DATA_URL =
|
||||
/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/;
|
||||
|
||||
const bugReportSchema = z.object({
|
||||
description: z.string().min(10, "Description too short").max(5000),
|
||||
screenshot: z
|
||||
.string()
|
||||
.max(7_000_000, "Screenshot too large (max 5 MB)")
|
||||
.regex(
|
||||
SCREENSHOT_DATA_URL,
|
||||
"Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL",
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`bugreport:${user.id}`, 3, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 3 bug reports per hour", 429);
|
||||
}
|
||||
|
||||
annotate({ action: { name: "bugreport.submit" } });
|
||||
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
githubIssueTokenEncrypted: true,
|
||||
githubIssueRepo: true,
|
||||
bugReportEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.3: explicit toggle. When the admin disables bug reports we
|
||||
// return 503 immediately — the client already renders a "not
|
||||
// configured" UI for that status, so the report button gracefully
|
||||
// disappears without needing additional client-side wiring.
|
||||
if (appSettings && appSettings.bugReportEnabled === false) {
|
||||
return apiError("Bug reports are disabled by the administrator", 503);
|
||||
}
|
||||
|
||||
let configuredToken: string | null = null;
|
||||
if (appSettings?.githubIssueTokenEncrypted) {
|
||||
try {
|
||||
configuredToken = decrypt(appSettings.githubIssueTokenEncrypted);
|
||||
} catch {
|
||||
getEvent()?.addWarning("Failed to decrypt GitHub issue token");
|
||||
}
|
||||
}
|
||||
|
||||
const ghToken = configuredToken || process.env.GITHUB_ISSUE_TOKEN;
|
||||
const ghRepo = appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO; // e.g. "owner/repo"
|
||||
|
||||
if (!ghToken || !ghRepo) {
|
||||
// 503 instead of 500: the client keys off status to render the "not
|
||||
// configured" screen. The user also sees a clear message rather than
|
||||
// "Internal server error".
|
||||
return apiError(
|
||||
"Bug report not configured (GitHub issue token/repository missing in admin settings)",
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 8 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = bugReportSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { description, screenshot } = parsed.data;
|
||||
|
||||
// GitHub issues are maintainer-facing and the repo's primary language is English.
|
||||
// Use an ISO-8601 timestamp so titles are unambiguous and sortable.
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
const title = `Bug Report – ${dateStr}`;
|
||||
|
||||
// Sanitize user-provided content for GitHub markdown. Strip HTML tags
|
||||
// entirely (prevents `<img onerror=>` etc.) and neutralise markdown link
|
||||
// targets that start with dangerous schemes like javascript: or data:.
|
||||
const safeUsername = user.username.replace(/[`*_~<>\[\]|]/g, "");
|
||||
const safeDescription = description
|
||||
.replace(/<[^>]*>/g, "") // strip HTML tags
|
||||
.replace(/\]\(\s*(javascript|data|vbscript|file):/gi, "](about:blank#") // neutralise dangerous markdown link schemes
|
||||
.replace(/```/g, "\\`\\`\\`")
|
||||
.slice(0, 5000);
|
||||
|
||||
// Wrap the user's description in a fenced code block so any residual
|
||||
// markdown renders literally rather than as executable markup.
|
||||
let issueBody = `**Reported by:** ${safeUsername}\n`;
|
||||
issueBody += `**Date:** ${dateStr}\n\n`;
|
||||
issueBody += `## Description\n\n\`\`\`\n${safeDescription}\n\`\`\`\n`;
|
||||
issueBody += `\n---\n*Created via HealthLog bug report*`;
|
||||
|
||||
// If screenshot provided, upload to GitHub as a gist or include as base64
|
||||
// For simplicity, we'll note there's a screenshot but GitHub issues don't support inline base64
|
||||
if (screenshot) {
|
||||
issueBody += `\n\n**Screenshot:** A screenshot was attached (see comment).`;
|
||||
}
|
||||
|
||||
// Create the issue
|
||||
const issueRes = await safeFetch(
|
||||
`https://api.github.com/repos/${ghRepo}/issues`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${ghToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: `[Bug] ${title}`,
|
||||
body: issueBody,
|
||||
labels: ["bug", "user-reported"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!issueRes.ok) {
|
||||
const errBody = await issueRes.text();
|
||||
getEvent()?.addWarning("GitHub issue creation failed: " + errBody);
|
||||
return apiError("Failed to create issue", 500);
|
||||
}
|
||||
|
||||
const issue = (await issueRes.json()) as {
|
||||
number: number;
|
||||
html_url: string;
|
||||
};
|
||||
|
||||
// If screenshot, upload as a comment
|
||||
if (screenshot) {
|
||||
// Extract just the base64 part
|
||||
const base64Data = screenshot.replace(/^data:image\/\w+;base64,/, "");
|
||||
const ext = screenshot.startsWith("data:image/png") ? "png" : "jpg";
|
||||
|
||||
// Upload screenshot as issue comment with embedded image
|
||||
// GitHub doesn't support base64 in comments, so we note the limitation
|
||||
const commentBody = `### Screenshot\n\n}...)\n\n*Note: The full screenshot was truncated due to size limits. Please contact the user directly.*`;
|
||||
|
||||
await safeFetch(
|
||||
`https://api.github.com/repos/${ghRepo}/issues/${issue.number}/comments`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${ghToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
body: JSON.stringify({ body: commentBody }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await auditLog("bugreport.submit", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
issueNumber: issue.number,
|
||||
hasScreenshot: Boolean(screenshot),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({ meta: { issue_number: issue.number } });
|
||||
|
||||
return apiSuccess({
|
||||
issueNumber: issue.number,
|
||||
issueUrl: issue.html_url,
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Returns whether bug report submission is configured (GitHub token + repo).
|
||||
*
|
||||
* The bug report page uses this to gate its UI so users don't hit a silent
|
||||
* 500 when the admin hasn't set up the GitHub integration. Only two booleans
|
||||
* escape: a single `configured` flag and `isAdmin` so the page can render
|
||||
* the admin-specific copy + admin link. Token/repo granularity is kept
|
||||
* server-side (log annotation only) so we don't hand regular users a probe.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
|
||||
interface BugreportStatusCacheValue {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
hasToken: boolean;
|
||||
hasRepo: boolean;
|
||||
}
|
||||
|
||||
async function buildBugreportStatusValue(): Promise<BugreportStatusCacheValue> {
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
githubIssueTokenEncrypted: true,
|
||||
githubIssueRepo: true,
|
||||
bugReportEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const hasToken = Boolean(
|
||||
appSettings?.githubIssueTokenEncrypted || process.env.GITHUB_ISSUE_TOKEN,
|
||||
);
|
||||
const hasRepo = Boolean(
|
||||
appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO,
|
||||
);
|
||||
const configured = hasToken && hasRepo;
|
||||
// Default ON when the column has never been written.
|
||||
const enabled = appSettings?.bugReportEnabled !== false;
|
||||
|
||||
return { configured, enabled, hasToken, hasRepo };
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// No per-request rate limit: the payload rides the singleton server
|
||||
// cache below, so a cache hit costs zero Postgres round-trips — while
|
||||
// the former rate-limit bucket was an unconditional Postgres UPSERT
|
||||
// per request, i.e. the limiter WAS the per-request DB load it claimed
|
||||
// to prevent. The route stays authenticated; the only uncached work is
|
||||
// one `appSettings` read per 10 min (the bucket's TTL) process-wide.
|
||||
|
||||
// Cache the global app-settings shape on a singleton key (per blueprint §3).
|
||||
// `isAdmin` lives outside the cache because it varies per request. The
|
||||
// `invalidateAppSettings()` helper called from
|
||||
// `PATCH /api/admin/app-settings` evicts this entry when bug-report
|
||||
// configuration changes.
|
||||
const status = await cached(
|
||||
caches.bugreportStatus as ServerCache<BugreportStatusCacheValue>,
|
||||
"singleton",
|
||||
() => buildBugreportStatusValue(),
|
||||
annotate,
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "bugreport.status" },
|
||||
meta: {
|
||||
configured: status.configured,
|
||||
enabled: status.enabled,
|
||||
has_token: status.hasToken,
|
||||
has_repo: status.hasRepo,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
configured: status.configured,
|
||||
enabled: status.enabled,
|
||||
isAdmin: user.role === "ADMIN",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* v1.23 — POST /api/export/encrypted unit coverage.
|
||||
*
|
||||
* Mock-based. Pins the step-up gating contract (single-factor accounts export
|
||||
* without step-up; MFA accounts need a fresh second factor) and that the
|
||||
* passphrase produces a decryptable HLX1 archive — never a plaintext body.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medication: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
session: { findUnique: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/geo", () => ({
|
||||
lookupIpLocation: vi.fn(),
|
||||
lookupIpAsn: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() }));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { POST } from "../route";
|
||||
import { decryptArchive } from "@/lib/export/passphrase-archive";
|
||||
|
||||
const PASSPHRASE = "a-strong-passphrase-123";
|
||||
|
||||
function mkReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/export/encrypted", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
reset: Date.now() + 3600_000,
|
||||
} as never);
|
||||
// Default: no registered security key. Tests that exercise the
|
||||
// WebAuthn-only cohort override this.
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
});
|
||||
|
||||
describe("POST /api/export/encrypted", () => {
|
||||
it("returns a decryptable HLX1 archive for a single-factor account", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: null },
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
|
||||
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
|
||||
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
expect(buf.subarray(0, 4).toString("ascii")).toBe("HLX1");
|
||||
const plaintext = await decryptArchive(buf, PASSPHRASE);
|
||||
const payload = JSON.parse(plaintext);
|
||||
expect(payload).toHaveProperty("schemaVersion");
|
||||
expect(payload).toHaveProperty("measurements");
|
||||
});
|
||||
|
||||
it("blocks an MFA account without a fresh second factor (401 step-up)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: new Date() },
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
// No fresh mfaVerifiedAt on the session row.
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("allows an MFA account with a fresh second factor", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: new Date() },
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
|
||||
expect(res.status).toBe(200);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
expect(await decryptArchive(buf, PASSPHRASE)).toContain("schemaVersion");
|
||||
});
|
||||
|
||||
it("blocks a WebAuthn-only account (no TOTP) without a fresh second factor (401 step-up)", async () => {
|
||||
// No confirmed TOTP, but a registered security key — the account is still
|
||||
// MFA-enrolled and must clear step-up before exporting the whole record.
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: null },
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
|
||||
// No fresh mfaVerifiedAt on the session row.
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("allows a WebAuthn-only account with a fresh second factor", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: null },
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
|
||||
expect(res.status).toBe(200);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
expect(await decryptArchive(buf, PASSPHRASE)).toContain("schemaVersion");
|
||||
});
|
||||
|
||||
it("rejects a too-short passphrase with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: null },
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
|
||||
const res = await POST(mkReq({ passphrase: "short" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* POST /api/export/encrypted
|
||||
*
|
||||
* v1.23 — passphrase-encrypted variant of the full-backup export. Returns the
|
||||
* same restore-compatible payload as `GET /api/export/full-backup`, but sealed
|
||||
* into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under a passphrase
|
||||
* the caller supplies in the request body. The binary archive is returned as
|
||||
* `application/octet-stream`.
|
||||
*
|
||||
* SECURITY:
|
||||
* - Step-up: when the account has any second factor enrolled (TOTP OR a
|
||||
* security key), this is gated by `requireFreshMfaIfEnrolled` — exporting the
|
||||
* whole record is a sensitive action. Single-factor accounts fall back to a
|
||||
* normal authenticated session.
|
||||
* - The passphrase NEVER hits a log or wide-event: it is read off the parsed
|
||||
* body, passed straight into the KDF, and never `annotate()`d. The egress
|
||||
* redaction denylist already scrubs `/passphrase/i` (key-name) and the
|
||||
* `passphrase=` query-string form as defence in depth.
|
||||
* - There is NO server-side recovery — the passphrase is not stored. A
|
||||
* forgotten passphrase means the archive is unrecoverable; the UI says so.
|
||||
*
|
||||
* Auth: cookie session OR Bearer for single-factor accounts; cookie + fresh
|
||||
* second factor for MFA accounts (Bearer can never satisfy step-up).
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h) — same bucket as the
|
||||
* plaintext export so the encrypted variant cannot be used to bypass the cap.
|
||||
* Audit: `user.export.encrypted` with the row counts (never the passphrase).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
apiHandler,
|
||||
requireFreshMfaIfEnrolled,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import {
|
||||
encryptArchive,
|
||||
MIN_EXPORT_PASSPHRASE_LENGTH,
|
||||
} from "@/lib/export/passphrase-archive";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const encryptedExportSchema = z
|
||||
.object({
|
||||
passphrase: z
|
||||
.string()
|
||||
.min(MIN_EXPORT_PASSPHRASE_LENGTH)
|
||||
// A generous upper bound — a passphrase, not a file. Keeps the KDF input
|
||||
// bounded.
|
||||
.max(1024),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Resolve the session (cookie or Bearer), then escalate to a fresh second
|
||||
// factor when the account has one enrolled. `requireFreshMfaIfEnrolled`
|
||||
// covers BOTH cohorts — a confirmed TOTP secret AND a registered WebAuthn
|
||||
// security key — so a security-key-only account is gated too. A single-factor
|
||||
// account cannot produce a fresh-MFA proof, so it passes straight through
|
||||
// rather than being locked out of its own encrypted export.
|
||||
const auth = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
const user = auth.user;
|
||||
annotate({ action: { name: "user.export.encrypted" } });
|
||||
|
||||
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
// The body is a single passphrase string — small and bounded. 8 KB is far
|
||||
// above any legitimate passphrase while rejecting a large body before parse.
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 8 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = encryptedExportSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error);
|
||||
}
|
||||
const { passphrase } = parsed.data;
|
||||
|
||||
const { payload, counts } = await buildFullBackupPayload(prisma, user.id);
|
||||
|
||||
// Encrypt the JSON bytes. The passphrase goes no further than the KDF.
|
||||
const archive = await encryptArchive(JSON.stringify(payload), passphrase);
|
||||
|
||||
await auditLog("user.export.encrypted", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { counts, format: "HLX1" },
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
export_measurements_count: counts.measurements,
|
||||
export_medications_count: counts.medications,
|
||||
export_intake_count: counts.intakeEvents,
|
||||
export_mood_count: counts.moodEntries,
|
||||
export_cycle_count: counts.cycles,
|
||||
export_cycle_day_log_count: counts.cycleDayLogs,
|
||||
export_archive_bytes: archive.byteLength,
|
||||
},
|
||||
});
|
||||
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
// Return the raw binary archive (NOT the apiSuccess envelope). The file is a
|
||||
// self-contained `.hlx` archive openable with the user's passphrase via
|
||||
// scripts/decrypt-export.ts.
|
||||
return new NextResponse(new Uint8Array(archive), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="healthlog-backup-${user.id}-${stamp}.hlx"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -21,8 +21,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup";
|
||||
import { buildCycleBackupSection } from "@/lib/cycle/backup";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
@@ -34,123 +33,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
const [measurements, medications, intakeEvents, moodEntries, cycle] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
// v1.4.41 W-DELETED-2 — soft-deleted rows are excluded from the
|
||||
// user-initiated backup bundle so the round-trip via admin
|
||||
// restore does not resurrect deleted measurements.
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
}),
|
||||
// Explicit `select` on the schedule columns — see the matching
|
||||
// comment in `medications/route.ts` for the rationale (the
|
||||
// integration testbed has an unmigrated `days_of_week` column on
|
||||
// the schema branch we ship alongside).
|
||||
prisma.medication.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
name: true,
|
||||
dose: true,
|
||||
active: true,
|
||||
schedules: {
|
||||
select: {
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
label: true,
|
||||
dose: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows (mirrors the measurement
|
||||
// `deletedAt: null` filter above on this user-facing export).
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
include: { medication: { select: { name: true } } },
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows.
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
}),
|
||||
// v1.15.0 — cycle tables (profile + observed spans + day-logs). The
|
||||
// shared helper carries `notesEncrypted` verbatim (never decrypted).
|
||||
buildCycleBackupSection(prisma, user.id),
|
||||
]);
|
||||
|
||||
// Shape mirrors the pg-boss `data-backup` worker exactly so the same
|
||||
// `parseBackupPayload()` validator round-trips both blobs. Keep these
|
||||
// two writers in sync — `src/lib/jobs/reminder-worker.ts` is the
|
||||
// canonical reference.
|
||||
const payload = {
|
||||
schemaVersion: BACKUP_SCHEMA_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
userId: user.id,
|
||||
measurements: measurements.map((m) => ({
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt.toISOString(),
|
||||
source: m.source,
|
||||
notes: m.notes,
|
||||
})),
|
||||
medications: medications.map((m) => ({
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
active: m.active,
|
||||
schedules: m.schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
label: s.label,
|
||||
dose: s.dose,
|
||||
})),
|
||||
})),
|
||||
intakeEvents: intakeEvents.map((e) => ({
|
||||
medication: e.medication.name,
|
||||
scheduledFor: e.scheduledFor.toISOString(),
|
||||
takenAt: e.takenAt?.toISOString() ?? null,
|
||||
skipped: e.skipped,
|
||||
source: e.source,
|
||||
})),
|
||||
moodEntries: moodEntries.map((e) => ({
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
tags: e.tags,
|
||||
source: e.source,
|
||||
loggedAt: e.moodLoggedAt.toISOString(),
|
||||
})),
|
||||
// v1.15.0 — cycle slice (profile + observed spans + day-logs).
|
||||
cycleProfile: cycle.cycleProfile,
|
||||
cycles: cycle.cycles,
|
||||
cycleDayLogs: cycle.cycleDayLogs,
|
||||
};
|
||||
// v1.23 — the payload builder is shared with the passphrase-encrypted
|
||||
// export route so both emit the byte-for-byte same restore-compatible shape.
|
||||
const { payload, counts } = await buildFullBackupPayload(prisma, user.id);
|
||||
|
||||
await auditLog("user.export.full-backup", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
counts: {
|
||||
measurements: measurements.length,
|
||||
medications: medications.length,
|
||||
intakeEvents: intakeEvents.length,
|
||||
moodEntries: moodEntries.length,
|
||||
cycles: cycle.cycles.length,
|
||||
cycleDayLogs: cycle.cycleDayLogs.length,
|
||||
},
|
||||
},
|
||||
details: { counts },
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
export_measurements_count: measurements.length,
|
||||
export_medications_count: medications.length,
|
||||
export_intake_count: intakeEvents.length,
|
||||
export_mood_count: moodEntries.length,
|
||||
export_cycle_count: cycle.cycles.length,
|
||||
export_cycle_day_log_count: cycle.cycleDayLogs.length,
|
||||
export_measurements_count: counts.measurements,
|
||||
export_medications_count: counts.medications,
|
||||
export_intake_count: counts.intakeEvents,
|
||||
export_mood_count: counts.moodEntries,
|
||||
export_cycle_count: counts.cycles,
|
||||
export_cycle_day_log_count: counts.cycleDayLogs,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { toCSV, formatMeasurementsForExport } from "@/lib/export";
|
||||
import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { resolveGlucoseUnit } from "@/lib/glucose";
|
||||
@@ -65,12 +66,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
|
||||
|
||||
const csv = toCSV(
|
||||
formatMeasurementsForExport(measurements, userTz, {
|
||||
granularity,
|
||||
sleepTz: userTz,
|
||||
sourcePriorityJson,
|
||||
glucoseUnit,
|
||||
}),
|
||||
formatMeasurementsForExport(
|
||||
measurements.map(shapeMeasurementNotes),
|
||||
userTz,
|
||||
{
|
||||
granularity,
|
||||
sleepTz: userTz,
|
||||
sourcePriorityJson,
|
||||
glucoseUnit,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await auditLog("user.export.measurements", {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { toCSV, formatMoodEntriesForExport } from "@/lib/export";
|
||||
import { shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
@@ -62,7 +63,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
resolveUserTimezone(user.id),
|
||||
]);
|
||||
|
||||
const csv = toCSV(formatMoodEntriesForExport(entries, userTz));
|
||||
const csv = toCSV(
|
||||
formatMoodEntriesForExport(entries.map(shapeMoodNote), userTz),
|
||||
);
|
||||
|
||||
await auditLog("user.export.mood", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
formatIntakeEventsForExport,
|
||||
formatMoodEntriesForExport,
|
||||
} from "@/lib/export";
|
||||
import { shapeMeasurementNotes, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
@@ -70,7 +71,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// Thread the user's source-priority so a multi-source night dedups to
|
||||
// the same canonical source the UI shows.
|
||||
const measurementRecords = formatMeasurementsForExport(
|
||||
measurements,
|
||||
// v1.23 — decrypt notes before formatting; strip the ciphertext column.
|
||||
measurements.map(shapeMeasurementNotes),
|
||||
userTz,
|
||||
{ sleepTz: userTz, sourcePriorityJson },
|
||||
);
|
||||
@@ -110,10 +112,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
});
|
||||
const decryptedMood = moodEntries.map(shapeMoodNote);
|
||||
data.moodEntries =
|
||||
format === "csv"
|
||||
? toCSV(formatMoodEntriesForExport(moodEntries, userTz))
|
||||
: formatMoodEntriesForExport(moodEntries, userTz);
|
||||
? toCSV(formatMoodEntriesForExport(decryptedMood, userTz))
|
||||
: formatMoodEntriesForExport(decryptedMood, userTz);
|
||||
}
|
||||
|
||||
await auditLog("export.download", {
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1", email: "test@example.com", role: "USER" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
feedback: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
function jsonRequest(body: unknown): Request {
|
||||
return new Request("http://localhost/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/feedback bug-report toggle gate", () => {
|
||||
it("returns 503 when bugReportEnabled is false", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: false,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "test",
|
||||
description: "Anything at least ten characters long.",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/disabled/i);
|
||||
expect(prisma.feedback.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates feedback when bugReportEnabled is true", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.feedback.create).mockResolvedValue({
|
||||
id: "fb-1",
|
||||
createdAt: new Date(),
|
||||
category: "BUG",
|
||||
status: "NEW",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "test",
|
||||
description: "Anything at least ten characters long.",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.feedback.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("creates feedback when bugReportEnabled has never been written (default ON)", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.feedback.create).mockResolvedValue({
|
||||
id: "fb-2",
|
||||
createdAt: new Date(),
|
||||
category: "BUG",
|
||||
status: "NEW",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "test",
|
||||
description: "Anything at least ten characters long.",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.feedback.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/feedback — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
// subject too short + description too short.
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "x",
|
||||
description: "short",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
// bad subject + bad description + bad screenshot.
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "x",
|
||||
description: "short",
|
||||
screenshot: "not-a-data-url",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* POST /api/feedback — user submits feedback. Always works, no GitHub needed.
|
||||
* GET /api/feedback — user's own feedback history (paginated).
|
||||
*
|
||||
* Rate-limited at 5 per hour per user. Screenshots are stored as base64 in
|
||||
* the database (≤5 MB each).
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
getClientIp,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createFeedbackSchema } from "@/lib/validations/feedback";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`feedback:${user.id}`, 5, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many submissions — please try again later", 429);
|
||||
}
|
||||
|
||||
// The admin "Bug reports" toggle gates *every* feedback submission, not
|
||||
// just the GitHub-promotion path. Previously the gate only lived in the
|
||||
// legacy `/api/bugreport` route which the form does not even call —
|
||||
// so flipping the toggle off had no effect on user-visible behaviour.
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { bugReportEnabled: true },
|
||||
});
|
||||
if (settings && settings.bugReportEnabled === false) {
|
||||
return apiError("Bug reports are disabled by the administrator", 503);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = createFeedbackSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const feedback = await prisma.feedback.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
email: user.email ?? null,
|
||||
category: parsed.data.category,
|
||||
subject: parsed.data.subject,
|
||||
description: parsed.data.description,
|
||||
screenshotBase64: parsed.data.screenshot ?? null,
|
||||
metadata:
|
||||
parsed.data.metadata !== undefined
|
||||
? (parsed.data.metadata as Prisma.InputJsonValue)
|
||||
: Prisma.JsonNull,
|
||||
},
|
||||
select: { id: true, createdAt: true, category: true, status: true },
|
||||
});
|
||||
|
||||
await auditLog("feedback.submit", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
feedbackId: feedback.id,
|
||||
category: feedback.category,
|
||||
hasScreenshot: Boolean(parsed.data.screenshot),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "feedback.submit" },
|
||||
meta: {
|
||||
feedback_id: feedback.id,
|
||||
category: feedback.category,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(feedback, 201);
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = Math.min(
|
||||
50,
|
||||
Math.max(1, Number(url.searchParams.get("limit") ?? 25)),
|
||||
);
|
||||
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0));
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.feedback.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
category: true,
|
||||
subject: true,
|
||||
status: true,
|
||||
adminNote: true,
|
||||
gitHubIssueUrl: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.feedback.count({ where: { userId: user.id } }),
|
||||
]);
|
||||
|
||||
return apiSuccess({ items, meta: { total, limit, offset } });
|
||||
});
|
||||
@@ -190,7 +190,7 @@ describe("GET /api/gamification/achievements — hidden achievement redaction",
|
||||
for (const entry of hiddenLocked) {
|
||||
// Trigger semantics scrubbed
|
||||
expect(entry.metric ?? "").not.toMatch(
|
||||
/(nightOwl|earlyBird|leapDay|doctorPdf|localeFlip|bugReport)Count/,
|
||||
/(nightOwl|earlyBird|leapDay|doctorPdf|localeFlip)Count/,
|
||||
);
|
||||
// i18n keys scrubbed
|
||||
expect(entry.titleKey ?? "").not.toContain("hiddenNightOwl");
|
||||
|
||||
@@ -18,6 +18,15 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
// v1.23 — deterministic note-cipher so the route test stays isolated from the
|
||||
// encryption-key env. `enc:<text>` stands in for the AES-256-GCM ciphertext.
|
||||
vi.mock("@/lib/crypto/note-cipher", () => ({
|
||||
encryptNote: (s: string | null | undefined) =>
|
||||
s === null || s === undefined || s.length === 0
|
||||
? null
|
||||
: new Uint8Array(Buffer.from(`enc:${s}`, "utf8")),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -124,7 +125,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt,
|
||||
notes: m.notes ?? null,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
},
|
||||
@@ -136,7 +138,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
source: "IMPORT",
|
||||
externalId: m.externalId,
|
||||
measuredAt: m.measuredAt,
|
||||
notes: m.notes ?? null,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
},
|
||||
@@ -160,7 +163,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
unit: m.unit,
|
||||
source: "IMPORT",
|
||||
measuredAt: m.measuredAt,
|
||||
notes: m.notes ?? null,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
glucoseContextEnum,
|
||||
} from "@/lib/validations/measurement";
|
||||
import { validateEntryInstant } from "@/lib/validations/entry-instant";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
|
||||
import {
|
||||
collapseToTypeDayKeys,
|
||||
@@ -144,7 +145,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt,
|
||||
notes: m.notes || null,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes || null),
|
||||
glucoseContext: m.glucoseContext ?? null,
|
||||
},
|
||||
create: {
|
||||
@@ -155,7 +157,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
source: "IMPORT",
|
||||
externalId: m.externalId,
|
||||
measuredAt,
|
||||
notes: m.notes || null,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes || null),
|
||||
glucoseContext: m.glucoseContext ?? null,
|
||||
},
|
||||
});
|
||||
@@ -168,7 +171,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
unit: m.unit,
|
||||
source: "IMPORT",
|
||||
measuredAt,
|
||||
notes: m.notes || null,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes || null),
|
||||
glucoseContext: m.glucoseContext ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { updateMeasurementSchema } from "@/lib/validations/measurement";
|
||||
import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
|
||||
@@ -45,7 +46,7 @@ export const GET = apiHandler(
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(measurement);
|
||||
return apiSuccess(shapeMeasurementNotes(measurement));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -110,7 +111,12 @@ export const PUT = apiHandler(
|
||||
data: {
|
||||
...(data.value !== undefined && { value: data.value }),
|
||||
...(data.measuredAt !== undefined && { measuredAt: data.measuredAt }),
|
||||
...(data.notes !== undefined && { notes: data.notes }),
|
||||
// v1.23 — write the note to the encrypted column; null the legacy
|
||||
// plaintext column. An explicit `null` clears the note.
|
||||
...(data.notes !== undefined && {
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(data.notes),
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -188,7 +194,7 @@ export const PUT = apiHandler(
|
||||
console.warn("[measurements] status-insight invalidate failed", err);
|
||||
});
|
||||
|
||||
return apiSuccess(measurement);
|
||||
return apiSuccess(shapeMeasurementNotes(measurement));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
localDayWindow,
|
||||
} from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
@@ -491,7 +492,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "measurement.list" }, meta: { total, type } });
|
||||
|
||||
return apiSuccess({
|
||||
measurements,
|
||||
// v1.23 — decrypt `notesEncrypted` onto `notes` and strip the ciphertext
|
||||
// before it leaves the server (iOS MeasurementWireDTO contract).
|
||||
measurements: measurements.map(shapeMeasurementNotes),
|
||||
meta: { total, limit, offset },
|
||||
});
|
||||
});
|
||||
@@ -738,7 +741,10 @@ async function postMeasurement(request: NextRequest) {
|
||||
unit: getUnitForType(m.type),
|
||||
source: (m.source ?? "MANUAL") as MeasurementSource,
|
||||
measuredAt: m.measuredAt,
|
||||
notes: m.notes ?? null,
|
||||
// v1.23 — encrypt the note at rest; the legacy plaintext column is
|
||||
// written null for new rows.
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
// v1.4.25 W10 reconcile (code-review M4): mirror the
|
||||
@@ -750,6 +756,8 @@ async function postMeasurement(request: NextRequest) {
|
||||
),
|
||||
);
|
||||
|
||||
const shapedResults = results.map(shapeMeasurementNotes);
|
||||
|
||||
await auditLog("measurement.create.batch", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
@@ -823,7 +831,7 @@ async function postMeasurement(request: NextRequest) {
|
||||
console.warn("[measurements] rollup recompute failed", err);
|
||||
}
|
||||
|
||||
return apiSuccess(results, 201);
|
||||
return apiSuccess(shapedResults, 201);
|
||||
}
|
||||
|
||||
// Single mode (existing behavior)
|
||||
@@ -878,7 +886,9 @@ async function postMeasurement(request: NextRequest) {
|
||||
unit: getUnitForType(type),
|
||||
source: (source ?? "MANUAL") as MeasurementSource,
|
||||
measuredAt,
|
||||
notes: notes ?? null,
|
||||
// v1.23 — encrypt the note at rest; legacy plaintext column nulled.
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(notes ?? null),
|
||||
glucoseContext: (glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
// v1.4.25 W10 reconcile (code-review M4): the deviceType column
|
||||
// already accepts client metadata from the batch route. Mirror
|
||||
@@ -963,5 +973,5 @@ async function postMeasurement(request: NextRequest) {
|
||||
console.warn("[measurements] status-insight invalidate failed", err);
|
||||
});
|
||||
|
||||
return apiSuccess(measurement, 201);
|
||||
return apiSuccess(shapeMeasurementNotes(measurement), 201);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import { replaceTagLinks } from "@/lib/mood/tag-links";
|
||||
@@ -53,7 +54,7 @@ export const GET = apiHandler(
|
||||
meta: { moodEntryId: id },
|
||||
});
|
||||
|
||||
return apiSuccess({ ...entry, tags: parseTags(entry.tags) });
|
||||
return apiSuccess({ ...shapeMoodNote(entry), tags: parseTags(entry.tags) });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -128,7 +129,10 @@ export const PUT = apiHandler(
|
||||
updateData.tags = data.tags ? JSON.stringify(data.tags) : null;
|
||||
}
|
||||
if (data.note !== undefined) {
|
||||
updateData.note = data.note;
|
||||
// v1.23 — write to the encrypted column; null the legacy plaintext. An
|
||||
// explicit `null` clears the note.
|
||||
updateData.note = null;
|
||||
updateData.noteEncrypted = encryptNote(data.note);
|
||||
}
|
||||
|
||||
// v1.7.0 sync — mood is last-writer-wins by syncVersion; bump it on
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
moodSourceEnum,
|
||||
} from "@/lib/validations/moodlog";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
|
||||
@@ -238,7 +239,8 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
mood: entry.mood,
|
||||
score,
|
||||
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
||||
note: entry.note ?? null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(entry.note ?? null),
|
||||
source: resolvedSource,
|
||||
externalId: entry.externalId ?? null,
|
||||
moodLoggedAt: entry.moodLoggedAt,
|
||||
@@ -253,7 +255,8 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
mood: entry.mood,
|
||||
score,
|
||||
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
||||
note: entry.note ?? null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(entry.note ?? null),
|
||||
...(entry.externalId
|
||||
? { date, moodLoggedAt: entry.moodLoggedAt }
|
||||
: {}),
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptNote, readNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
@@ -119,7 +120,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({
|
||||
...e,
|
||||
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
|
||||
...shapeMoodNote(e),
|
||||
tags: parseTags(e.tags),
|
||||
// v1.8.5 — flat list of binary structured-tag keys attached to the
|
||||
// entry (rated factors are surfaced separately below).
|
||||
@@ -234,7 +236,8 @@ async function postMoodEntry(request: NextRequest) {
|
||||
mood,
|
||||
score,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: note ?? null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note ?? null),
|
||||
source: resolvedSource,
|
||||
externalId,
|
||||
moodLoggedAt,
|
||||
@@ -245,7 +248,8 @@ async function postMoodEntry(request: NextRequest) {
|
||||
mood,
|
||||
score,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: note ?? null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note ?? null),
|
||||
moodLoggedAt,
|
||||
},
|
||||
})
|
||||
@@ -257,7 +261,8 @@ async function postMoodEntry(request: NextRequest) {
|
||||
mood,
|
||||
score,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: note ?? null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note ?? null),
|
||||
source: resolvedSource,
|
||||
moodLoggedAt,
|
||||
},
|
||||
@@ -347,7 +352,7 @@ async function postMoodEntry(request: NextRequest) {
|
||||
date: entry.date,
|
||||
moodLoggedAt: entry.moodLoggedAt,
|
||||
mood: entry.mood,
|
||||
note: entry.note ?? null,
|
||||
note: readNote(entry.noteEncrypted, entry.note),
|
||||
tags: entry.tags,
|
||||
source: entry.source,
|
||||
},
|
||||
@@ -362,7 +367,8 @@ async function postMoodEntry(request: NextRequest) {
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
...entry,
|
||||
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
|
||||
...shapeMoodNote(entry),
|
||||
tags: parseTags(entry.tags),
|
||||
// v1.8.5 — surface the persisted structured-tag keys so a client
|
||||
// hydrating from the create response renders the tag set without a
|
||||
|
||||
@@ -3,7 +3,11 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { destroyAllSessions } from "@/lib/auth/session";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
requireFreshMfaIfEnrolled,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -11,9 +15,14 @@ export const dynamic = "force-dynamic";
|
||||
/**
|
||||
* Permanently delete the user account and ALL associated data.
|
||||
* Cascading deletes in the schema handle all related records.
|
||||
*
|
||||
* v1.23 — for an account with a second factor active this requires a fresh
|
||||
* step-up (within MFA_STEP_UP_MAX_AGE_SECONDS) in addition to the typed
|
||||
* confirmation, so a hijacked live session cannot erase the record. Accounts
|
||||
* without MFA are unaffected and keep the typed-confirmation-only contract.
|
||||
*/
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
|
||||
let confirm = "";
|
||||
try {
|
||||
@@ -57,12 +66,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
// Destroy all sessions first
|
||||
await destroyAllSessions(userId);
|
||||
|
||||
// Audit V3 NEW-V3-2 / GDPR Art. 17 fix: Feedback and AuditLog rows have
|
||||
// `onDelete: SetNull` in the schema, which keeps PII (free-text symptom
|
||||
// descriptions, IP addresses, login city geo) attached to the record after
|
||||
// the user is deleted. We explicitly purge them inside the same logical
|
||||
// operation so account deletion is genuinely complete erasure.
|
||||
await prisma.feedback.deleteMany({ where: { userId } });
|
||||
// Audit V3 NEW-V3-2 / GDPR Art. 17 fix: AuditLog rows have
|
||||
// `onDelete: SetNull` in the schema, which keeps PII (IP addresses, login
|
||||
// city geo) attached to the record after the user is deleted. We explicitly
|
||||
// purge them inside the same logical operation so account deletion is
|
||||
// genuinely complete erasure.
|
||||
await prisma.auditLog.deleteMany({ where: { userId } });
|
||||
|
||||
// Delete user — all other related data is removed via onDelete: Cascade
|
||||
|
||||
@@ -2,7 +2,11 @@ import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
requireFreshMfaIfEnrolled,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { invalidateUserData } from "@/lib/cache/invalidate";
|
||||
|
||||
@@ -10,9 +14,13 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Delete all user-owned health/integration data while keeping the account.
|
||||
*
|
||||
* v1.23 — gated by a fresh step-up for MFA-enrolled accounts (see the account
|
||||
* deletion route); single-factor accounts keep the typed-confirmation-only
|
||||
* contract.
|
||||
*/
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
|
||||
let confirm = "";
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* GET /api/settings/privacy-summary
|
||||
*
|
||||
* Server-authoritative facts for the user-facing "Data & Privacy" dashboard:
|
||||
* the configured retention windows and a truthful encryption-at-rest summary
|
||||
* driven from the canonical `ENCRYPTED_COLUMNS` registry. No secrets, no key
|
||||
* material, no per-row data — just the numbers the dashboard renders read-only.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { ENCRYPTED_COLUMNS } from "@/lib/crypto/encrypted-columns";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Retention windows. These mirror the defaults + env names the cleanup jobs
|
||||
* read (`coach-message-cleanup.ts`, `audit-log-cleanup.ts`, the reminder
|
||||
* cleanup handlers); read here directly so the dashboard never imports a
|
||||
* pg-boss job module. Off-host backup retention is operator infrastructure
|
||||
* (not an app setting), so it is disclosed as prose, not a number.
|
||||
*/
|
||||
function resolveRetention() {
|
||||
const intEnv = (name: string, fallback: number, min: number): number => {
|
||||
const raw = process.env[name];
|
||||
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(parsed) && parsed >= min ? parsed : fallback;
|
||||
};
|
||||
return {
|
||||
coachMessagesDays: intEnv("COACH_MESSAGE_RETENTION_DAYS", 365, 30),
|
||||
auditLogDays: intEnv("AUDIT_LOG_RETENTION_DAYS", 365, 7),
|
||||
// The push-attempt + mood-dispatch ledgers are a fixed 90-day window.
|
||||
deliveryLogDays: 90,
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAuth();
|
||||
annotate({ action: { name: "settings.privacy.summary" } });
|
||||
|
||||
const encryptedModels = new Set(ENCRYPTED_COLUMNS.map((c) => c.model));
|
||||
|
||||
return apiSuccess({
|
||||
retention: resolveRetention(),
|
||||
encryption: {
|
||||
algorithm: "AES-256-GCM",
|
||||
columnCount: ENCRYPTED_COLUMNS.length,
|
||||
modelCount: encryptedModels.size,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client";
|
||||
import {
|
||||
decodeCursor,
|
||||
@@ -249,6 +250,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
notes: true,
|
||||
notesEncrypted: true,
|
||||
syncVersion: true,
|
||||
deletedAt: true,
|
||||
updatedAt: true,
|
||||
@@ -265,6 +267,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
score: true,
|
||||
tags: true,
|
||||
note: true,
|
||||
noteEncrypted: true,
|
||||
moodLoggedAt: true,
|
||||
source: true,
|
||||
syncVersion: true,
|
||||
@@ -336,7 +339,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
unit: row.unit,
|
||||
measuredAt: row.measuredAt.toISOString(),
|
||||
source: row.source,
|
||||
notes: row.notes,
|
||||
notes: readNote(row.notesEncrypted, row.notes),
|
||||
syncVersion: row.syncVersion,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
@@ -365,7 +368,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
mood: row.mood,
|
||||
score: row.score,
|
||||
tags: row.tags,
|
||||
note: row.note,
|
||||
note: readNote(row.noteEncrypted, row.note),
|
||||
moodLoggedAt: row.moodLoggedAt.toISOString(),
|
||||
source: row.source,
|
||||
syncVersion: row.syncVersion,
|
||||
|
||||
+214
-113
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Lock, Loader2 } from "lucide-react";
|
||||
@@ -13,7 +13,13 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
ApiError,
|
||||
apiFetchEnvelope,
|
||||
apiGet,
|
||||
apiPost,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { MfaLoginStep, type MfaMethod } from "@/components/auth/mfa-login-step";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
|
||||
@@ -27,6 +33,15 @@ export default function LoginPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// v1.23 — second step of login when the password response carries
|
||||
// `meta.mfaRequired`. Holds the opaque ticket + the factors the account can
|
||||
// complete the challenge with.
|
||||
const [mfaChallenge, setMfaChallenge] = useState<{
|
||||
ticket: string;
|
||||
methods: MfaMethod[];
|
||||
} | null>(null);
|
||||
// Guards the conditional-UI passkey autofill so it starts at most once.
|
||||
const autofillStarted = useRef(false);
|
||||
// v1.4.27 MB3 — explicit error-region id so the email + password
|
||||
// inputs reference the banner via `aria-describedby`. Screen readers
|
||||
// pair the error with the field instead of announcing it as a
|
||||
@@ -112,7 +127,28 @@ export default function LoginPage() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await apiPost("/api/auth/login", { email, password });
|
||||
// Read the envelope `meta` directly: an MFA account gets HTTP 200 with
|
||||
// `data: null` + `meta.mfaRequired`, not an error and not a session.
|
||||
const { meta } = await apiFetchEnvelope<
|
||||
unknown,
|
||||
{
|
||||
mfaRequired?: boolean;
|
||||
mfaTicket?: string;
|
||||
methods?: MfaMethod[];
|
||||
}
|
||||
>("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (meta?.mfaRequired && meta.mfaTicket) {
|
||||
setMfaChallenge({
|
||||
ticket: meta.mfaTicket,
|
||||
methods: meta.methods ?? ["totp", "recovery"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
|
||||
navigateAfterLogin();
|
||||
@@ -127,6 +163,50 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// v1.23 — passkey conditional-UI autofill. When the browser supports it,
|
||||
// arm a discoverable-credential assertion bound to the username field so a
|
||||
// returning user can pick their passkey straight from the autofill prompt.
|
||||
// Best-effort: any abort (the user types a password instead) is swallowed.
|
||||
useEffect(() => {
|
||||
if (autofillStarted.current) return;
|
||||
autofillStarted.current = true;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { browserSupportsWebAuthnAutofill, startAuthentication } =
|
||||
await import("@simplewebauthn/browser");
|
||||
if (!(await browserSupportsWebAuthnAutofill())) return;
|
||||
|
||||
const { options, challengeId } = await apiPost<{
|
||||
options: Parameters<typeof startAuthentication>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/passkey/login-options");
|
||||
|
||||
const credential = await startAuthentication({
|
||||
optionsJSON: options,
|
||||
useBrowserAutofill: true,
|
||||
});
|
||||
if (cancelled) return;
|
||||
|
||||
await apiPost("/api/auth/passkey/login-verify", {
|
||||
challengeId,
|
||||
credential,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
|
||||
navigateAfterLogin();
|
||||
} catch {
|
||||
// Autofill aborts (no passkey, user dismisses, password path taken)
|
||||
// are normal — stay silent and let the explicit buttons drive login.
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-4">
|
||||
{/* v1.4.27 MB7 / CF-61 — drop `p-8` to `p-6 sm:p-8` so the
|
||||
@@ -143,131 +223,152 @@ export default function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
{/* Phase A5 / B-mobile: bumped from default size (h-9, 36px)
|
||||
{mfaChallenge ? (
|
||||
<div className="mt-8">
|
||||
<MfaLoginStep
|
||||
mfaTicket={mfaChallenge.ticket}
|
||||
methods={mfaChallenge.methods}
|
||||
onSuccess={async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.auth(),
|
||||
});
|
||||
navigateAfterLogin();
|
||||
}}
|
||||
onCancel={() => {
|
||||
setMfaChallenge(null);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8 space-y-4">
|
||||
{/* Phase A5 / B-mobile: bumped from default size (h-9, 36px)
|
||||
to size="lg" so the login CTAs meet WCAG 2.5.5 (44px
|
||||
minimum) on mobile. Login is the most-tapped flow on a
|
||||
fresh install. */}
|
||||
<Button
|
||||
onClick={handlePasskeyLogin}
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && mode === "passkey" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<KeyRound className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.loginWithPasskey")}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("common.or")}
|
||||
</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
|
||||
{mode === "passkey" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePasskeyLogin}
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
onClick={() => setMode("password")}
|
||||
disabled={loading}
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
{t("auth.loginWithPassword")}
|
||||
{loading && mode === "passkey" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<KeyRound className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.loginWithPasskey")}
|
||||
</Button>
|
||||
) : (
|
||||
<form onSubmit={handlePasswordLogin} className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t("auth.emailOrUsername")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
inputMode="email"
|
||||
enterKeyHint="next"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
aria-required="true"
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
placeholder={t("auth.emailOrUsernamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
enterKeyHint="go"
|
||||
aria-required="true"
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
placeholder="********"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("common.or")}
|
||||
</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
|
||||
{mode === "passkey" ? (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
onClick={() => setMode("password")}
|
||||
>
|
||||
{loading && mode === "password" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.login")}
|
||||
<Lock className="h-4 w-4" />
|
||||
{t("auth.loginWithPassword")}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("passkey")}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
|
||||
>
|
||||
{t("auth.backToPasskey")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
) : (
|
||||
<form onSubmit={handlePasswordLogin} className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t("auth.emailOrUsername")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
// `webauthn` token arms the conditional-UI passkey
|
||||
// autofill prompt on this field (see the mount effect).
|
||||
autoComplete="username webauthn"
|
||||
inputMode="email"
|
||||
enterKeyHint="next"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
aria-required="true"
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
placeholder={t("auth.emailOrUsernamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
enterKeyHint="go"
|
||||
aria-required="true"
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
placeholder="********"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && mode === "password" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.login")}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("passkey")}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
|
||||
>
|
||||
{t("auth.backToPasskey")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
id={errorId}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{registrationEnabled === true && (
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
{t("auth.noAccount")}{" "}
|
||||
<Link
|
||||
href="/auth/register"
|
||||
// axe-core `link-in-text-block` requires a visible
|
||||
// distinction beyond colour alone (WCAG 1.4.1). Forcing
|
||||
// the underline always-on instead of `hover:underline`
|
||||
// satisfies the rule without changing the visual after
|
||||
// hover.
|
||||
className="text-primary underline"
|
||||
{error && (
|
||||
<div
|
||||
id={errorId}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
|
||||
>
|
||||
{t("auth.register")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{registrationEnabled === true && (
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
{t("auth.noAccount")}{" "}
|
||||
<Link
|
||||
href="/auth/register"
|
||||
// axe-core `link-in-text-block` requires a visible
|
||||
// distinction beyond colour alone (WCAG 1.4.1). Forcing
|
||||
// the underline always-on instead of `hover:underline`
|
||||
// satisfies the rule without changing the visual after
|
||||
// hover.
|
||||
className="text-primary underline"
|
||||
>
|
||||
{t("auth.register")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* v1.4.26 — discoverable privacy link below the login card.
|
||||
App Store reviewers and first-time visitors need a one-click
|
||||
|
||||
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user