mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
merge: integrate the v1.23 security settings and MFA wave
# Conflicts: # messages/de.json # messages/en.json # messages/es.json # messages/fr.json # messages/it.json # messages/pl.json # src/lib/openapi/routes/auth.ts # src/lib/query-keys/auth.ts
This commit is contained in:
commit
c458d955e4
45 files changed
+4211
-551
No files matched your search
+444
-1
@@ -1502,6 +1502,145 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/me/mfa:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Second-factor status (cookie session only)
|
||||
description: Whether TOTP is active, how many recovery codes remain, and the registered WebAuthn security keys. Metadata
|
||||
only — no secret, code, or public key. Cookie-only.
|
||||
responses:
|
||||
"200":
|
||||
description: Second-factor status.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MfaStatusEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/me/mfa/webauthn/register/options:
|
||||
post:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Begin registering a security key as a second factor
|
||||
description: Returns SimpleWebAuthn creation options + a challenge id. Cookie-only — a Bearer token cannot enrol MFA.
|
||||
responses:
|
||||
"200":
|
||||
description: Registration options issued.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MfaWebauthnRegisterOptionsEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/me/mfa/webauthn/register/verify:
|
||||
post:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Finish registering a security key as a second factor
|
||||
description: Verifies the attestation against the user-bound challenge and stores the credential in the second-factor
|
||||
store (separate from primary passkeys). Cookie-only.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MfaWebauthnRegisterVerifyRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Security key registered.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MfaWebauthnRegisterVerifyEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/me/mfa/webauthn/{id}:
|
||||
patch:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Rename a registered security key
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MfaWebauthnRenameRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Security key renamed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MfaWebauthnRenameEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
delete:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Remove a registered security key (step-up gated)
|
||||
description: Requires a fresh second-factor step-up (cookie session). Bearer can never satisfy the gate.
|
||||
responses:
|
||||
"200":
|
||||
description: Security key removed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MfaWebauthnRemoveEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/mfa/webauthn/verify/options:
|
||||
post:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Begin a mid-login security-key assertion
|
||||
description: Presents the login `mfaTicket` and returns assertion options scoped to the password-identified user's
|
||||
registered security keys. Anonymous surface; rate-limited.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MfaWebauthnLoginOptionsRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Assertion options issued.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MfaWebauthnLoginOptionsEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/mfa/webauthn/verify:
|
||||
post:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Complete a security-key second-factor login challenge
|
||||
description: Presents the login `mfaTicket` plus the assertion. On success returns the SAME token bundle / session the
|
||||
password path issues, with the session marked second-factor-verified. The ticket is single-use; failures are
|
||||
throttled and the ticket is burned at the attempt cap.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MfaWebauthnLoginVerifyRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Second factor verified — session + optional bearer issued.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MfaWebauthnVerifyResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/passkey/login-verify:
|
||||
post:
|
||||
tags:
|
||||
@@ -7724,6 +7863,115 @@ components:
|
||||
required:
|
||||
- code
|
||||
description: Disable the second factor. Requires fresh step-up plus a current TOTP or recovery code.
|
||||
MfaWebauthnRegisterVerifyRequest:
|
||||
type: object
|
||||
properties:
|
||||
challengeId:
|
||||
type: string
|
||||
minLength: 1
|
||||
credential:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
rawId:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
const: public-key
|
||||
response:
|
||||
type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties: {}
|
||||
authenticatorAttachment:
|
||||
type: string
|
||||
enum:
|
||||
- platform
|
||||
- cross-platform
|
||||
clientExtensionResults:
|
||||
type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties: {}
|
||||
required:
|
||||
- id
|
||||
- rawId
|
||||
- type
|
||||
- response
|
||||
description: SimpleWebAuthn ceremony response payload.
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
required:
|
||||
- challengeId
|
||||
- credential
|
||||
description: Finish registering a WebAuthn security key as a second factor.
|
||||
MfaWebauthnRenameRequest:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
required:
|
||||
- name
|
||||
MfaWebauthnLoginOptionsRequest:
|
||||
type: object
|
||||
properties:
|
||||
mfaTicket:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: The opaque single-use ticket from the login response.
|
||||
required:
|
||||
- mfaTicket
|
||||
MfaWebauthnLoginVerifyRequest:
|
||||
type: object
|
||||
properties:
|
||||
mfaTicket:
|
||||
type: string
|
||||
minLength: 1
|
||||
challengeId:
|
||||
type: string
|
||||
minLength: 1
|
||||
credential:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
rawId:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
const: public-key
|
||||
response:
|
||||
type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties: {}
|
||||
authenticatorAttachment:
|
||||
type: string
|
||||
enum:
|
||||
- platform
|
||||
- cross-platform
|
||||
clientExtensionResults:
|
||||
type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties: {}
|
||||
required:
|
||||
- id
|
||||
- rawId
|
||||
- type
|
||||
- response
|
||||
description: SimpleWebAuthn ceremony response payload.
|
||||
required:
|
||||
- mfaTicket
|
||||
- challengeId
|
||||
- credential
|
||||
description: Complete a second-factor login challenge with a WebAuthn security key and receive the same token bundle the
|
||||
password path issues.
|
||||
PasskeyLoginVerifyRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -10915,7 +11163,9 @@ components:
|
||||
enum:
|
||||
- totp
|
||||
- recovery
|
||||
description: Second factors the account can complete the challenge with.
|
||||
- webauthn
|
||||
description: Second factors the account can complete the challenge with. `webauthn` is completed via
|
||||
/api/auth/mfa/webauthn/verify; the rest via /api/auth/mfa/verify.
|
||||
required:
|
||||
- mfaRequired
|
||||
- mfaTicket
|
||||
@@ -11052,6 +11302,199 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MfaStatusEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MfaStatusResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MfaStatusResponse:
|
||||
type: object
|
||||
properties:
|
||||
totp:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
required:
|
||||
- enabled
|
||||
additionalProperties: false
|
||||
recoveryCodesRemaining:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
webauthn:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/WebauthnMfaCredentialInfo"
|
||||
passkeyNudgeDismissed:
|
||||
type: boolean
|
||||
required:
|
||||
- totp
|
||||
- recoveryCodesRemaining
|
||||
- webauthn
|
||||
- passkeyNudgeDismissed
|
||||
additionalProperties: false
|
||||
WebauthnMfaCredentialInfo:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
lastUsedAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- createdAt
|
||||
- lastUsedAt
|
||||
additionalProperties: false
|
||||
MfaWebauthnRegisterOptionsEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/WebauthnOptionsResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
WebauthnOptionsResponse:
|
||||
type: object
|
||||
properties:
|
||||
options:
|
||||
type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties: {}
|
||||
description: SimpleWebAuthn options to pass to the browser ceremony.
|
||||
challengeId:
|
||||
type: string
|
||||
description: Server-issued challenge id.
|
||||
required:
|
||||
- options
|
||||
- challengeId
|
||||
additionalProperties: false
|
||||
MfaWebauthnRegisterVerifyEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/WebauthnMfaCredentialInfo"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MfaWebauthnRenameEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/WebauthnMfaCredentialInfo"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MfaWebauthnRemoveEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/AuthSuccessFlagResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
AuthSuccessFlagResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- success
|
||||
additionalProperties: false
|
||||
MfaWebauthnLoginOptionsEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/WebauthnOptionsResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MfaWebauthnVerifyResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/AccessRefreshBundle"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
PasskeyLoginVerifyResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+75
-2
@@ -35,7 +35,8 @@
|
||||
"reportIssue": "Fehler melden",
|
||||
"showPassword": "Passwort anzeigen",
|
||||
"hidePassword": "Passwort verbergen",
|
||||
"learnMore": "Mehr erfahren"
|
||||
"learnMore": "Mehr erfahren",
|
||||
"dismiss": "Schließen"
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Gesundheitsbericht",
|
||||
@@ -264,7 +265,21 @@
|
||||
"passwordMinLength": "Min. 12 Zeichen",
|
||||
"passwordTooShort": "Passwort muss mindestens {minLength} Zeichen lang sein.",
|
||||
"privacyPolicy": "Datenschutzerklärung",
|
||||
"passwordBreached": "Dieses Passwort taucht in bekannten Datenlecks auf. Bitte wähle ein anderes."
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -4198,6 +4213,10 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -4998,6 +5017,60 @@
|
||||
"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"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+75
-2
@@ -35,7 +35,8 @@
|
||||
"reportIssue": "Report issue",
|
||||
"showPassword": "Show password",
|
||||
"hidePassword": "Hide password",
|
||||
"learnMore": "Learn more"
|
||||
"learnMore": "Learn more",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Health Report",
|
||||
@@ -264,7 +265,21 @@
|
||||
"passwordMinLength": "Min. 12 characters",
|
||||
"passwordTooShort": "Password must be at least {minLength} characters long.",
|
||||
"privacyPolicy": "Privacy policy",
|
||||
"passwordBreached": "This password has appeared in known data breaches. Please choose a different one."
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -4198,6 +4213,10 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"profile": "Profile",
|
||||
@@ -4998,6 +5017,60 @@
|
||||
"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"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+75
-2
@@ -35,7 +35,8 @@
|
||||
"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",
|
||||
@@ -264,7 +265,21 @@
|
||||
"passwordMinLength": "Mín. 12 caracteres",
|
||||
"passwordTooShort": "La contraseña debe tener al menos {minLength} caracteres.",
|
||||
"privacyPolicy": "Política de privacidad",
|
||||
"passwordBreached": "Esta contraseña aparece en filtraciones de datos conocidas. Elige otra."
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel",
|
||||
@@ -4198,6 +4213,10 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"profile": "Perfil",
|
||||
@@ -4998,6 +5017,60 @@
|
||||
"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"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+75
-2
@@ -35,7 +35,8 @@
|
||||
"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é",
|
||||
@@ -264,7 +265,21 @@
|
||||
"passwordMinLength": "Min. 12 caractères",
|
||||
"passwordTooShort": "Le mot de passe doit comporter au moins {minLength} caractères.",
|
||||
"privacyPolicy": "Politique de confidentialité",
|
||||
"passwordBreached": "Ce mot de passe figure dans des fuites de données connues. Choisissez-en un autre."
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -4198,6 +4213,10 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -4998,6 +5017,60 @@
|
||||
"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"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+75
-2
@@ -35,7 +35,8 @@
|
||||
"reportIssue": "Segnala un problema",
|
||||
"showPassword": "Mostra password",
|
||||
"hidePassword": "Nascondi password",
|
||||
"learnMore": "Scopri di più"
|
||||
"learnMore": "Scopri di più",
|
||||
"dismiss": "Ignora"
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Rapporto sanitario",
|
||||
@@ -264,7 +265,21 @@
|
||||
"passwordMinLength": "Min. 12 caratteri",
|
||||
"passwordTooShort": "La password deve avere almeno {minLength} caratteri.",
|
||||
"privacyPolicy": "Informativa sulla privacy",
|
||||
"passwordBreached": "Questa password compare in violazioni di dati note. Scegline un'altra."
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Cruscotto",
|
||||
@@ -4198,6 +4213,10 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"profile": "Profilo",
|
||||
@@ -4998,6 +5017,60 @@
|
||||
"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"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+75
-2
@@ -35,7 +35,8 @@
|
||||
"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",
|
||||
@@ -264,7 +265,21 @@
|
||||
"passwordMinLength": "Min. 12 znaków",
|
||||
"passwordTooShort": "Hasło musi mieć co najmniej {minLength} znaków.",
|
||||
"privacyPolicy": "Polityka prywatności",
|
||||
"passwordBreached": "To hasło pojawia się w znanych wyciekach danych. Wybierz inne."
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Pulpit",
|
||||
@@ -4198,6 +4213,10 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -4998,6 +5017,60 @@
|
||||
"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"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findFirst: vi.fn() } },
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
verifyPassword: vi.fn().mockResolvedValue(true),
|
||||
@@ -75,6 +78,8 @@ const BASE_USER = {
|
||||
|
||||
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", () => {
|
||||
@@ -99,6 +104,22 @@ describe("login MFA branch", () => {
|
||||
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,
|
||||
|
||||
@@ -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) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -97,6 +99,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({
|
||||
|
||||
@@ -96,17 +96,30 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
// v1.23 — second-factor gate. The password is correct, but an account with
|
||||
// a confirmed TOTP factor is NOT yet authenticated: no `Session` row and no
|
||||
// token bundle is minted here. Instead a single-use, ~5-minute MFA ticket is
|
||||
// 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`.
|
||||
// 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.
|
||||
if (user.totpConfirmedAt) {
|
||||
const hasTotp = Boolean(user.totpConfirmedAt);
|
||||
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
const hasWebauthn = webauthnKeyCount > 0;
|
||||
|
||||
if (hasTotp || hasWebauthn) {
|
||||
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,
|
||||
@@ -123,7 +136,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
meta: {
|
||||
mfaRequired: true,
|
||||
mfaTicket: challenge.ticket,
|
||||
methods: ["totp", "recovery"],
|
||||
methods,
|
||||
},
|
||||
},
|
||||
{ status: 200 },
|
||||
|
||||
@@ -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,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,141 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
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,69 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
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,70 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
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,127 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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 } = parsed.data;
|
||||
|
||||
const challenge = await loadActiveChallenge(mfaTicket);
|
||||
if (!challenge) {
|
||||
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");
|
||||
return finishLogin({
|
||||
user,
|
||||
request,
|
||||
ip,
|
||||
userAgent: ua,
|
||||
source: "mfa.webauthn.verify",
|
||||
mfaVerified: true,
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
+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
|
||||
|
||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { AccountSection } from "@/components/settings/account-section";
|
||||
import { SecuritySection } from "@/components/settings/security-section";
|
||||
import { AboutSection } from "@/components/settings/about-section";
|
||||
import { AdvancedSection } from "@/components/settings/advanced-section";
|
||||
import { AiSection } from "@/components/settings/ai-section";
|
||||
@@ -55,6 +56,7 @@ const SECTION_COMPONENTS: Record<
|
||||
() => JSX.Element | null
|
||||
> = {
|
||||
account: AccountSection,
|
||||
security: SecuritySection,
|
||||
modules: ModulesSection,
|
||||
about: AboutSection,
|
||||
ai: AiSection,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* MfaLoginStep rendering — the second step of login shown when the password
|
||||
* response carried `meta.mfaRequired`. Pins which controls render for each
|
||||
* factor mix (TOTP+recovery, TOTP+webauthn, webauthn-only).
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { MfaLoginStep } from "../mfa-login-step";
|
||||
|
||||
function render(methods: ("totp" | "recovery" | "webauthn")[]) {
|
||||
return renderToStaticMarkup(
|
||||
<MfaLoginStep
|
||||
mfaTicket="ticket-1"
|
||||
methods={methods}
|
||||
onSuccess={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MfaLoginStep", () => {
|
||||
it("renders the code entry + recovery toggle for a TOTP account", () => {
|
||||
const html = render(["totp", "recovery"]);
|
||||
expect(html).toContain("mfa-login-step");
|
||||
expect(html).toContain("auth.mfa.codeLabel");
|
||||
expect(html).toContain("auth.mfa.verify");
|
||||
expect(html).toContain("auth.mfa.useRecoveryCode");
|
||||
// No security-key button when the account has no registered key.
|
||||
expect(html).not.toContain("auth.mfa.useSecurityKey");
|
||||
});
|
||||
|
||||
it("renders the security-key button alongside TOTP when both are available", () => {
|
||||
const html = render(["totp", "recovery", "webauthn"]);
|
||||
expect(html).toContain("auth.mfa.codeLabel");
|
||||
expect(html).toContain("auth.mfa.useSecurityKey");
|
||||
});
|
||||
|
||||
it("leads with the security key for a WebAuthn-only account", () => {
|
||||
const html = render(["webauthn"]);
|
||||
expect(html).toContain("auth.mfa.useSecurityKey");
|
||||
// No code form and no recovery toggle without TOTP.
|
||||
expect(html).not.toContain("auth.mfa.codeLabel");
|
||||
expect(html).not.toContain("auth.mfa.useRecoveryCode");
|
||||
});
|
||||
|
||||
it("always offers a way back to the password step", () => {
|
||||
expect(render(["totp"])).toContain("auth.mfa.backToLogin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { KeyRound, Loader2, Lock, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
|
||||
export type MfaMethod = "totp" | "recovery" | "webauthn";
|
||||
|
||||
/**
|
||||
* Second step of login when the password response carried `meta.mfaRequired`.
|
||||
*
|
||||
* Renders the TOTP code entry by default, with a switch to a recovery code and
|
||||
* — when the account has a registered security key — a "use a security key"
|
||||
* button that runs the scoped WebAuthn assertion. Each path posts back to the
|
||||
* matching verify endpoint and, on success, calls `onSuccess` (the parent
|
||||
* finishes the login exactly like a normal sign-in).
|
||||
*/
|
||||
export function MfaLoginStep({
|
||||
mfaTicket,
|
||||
methods,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: {
|
||||
mfaTicket: string;
|
||||
methods: MfaMethod[];
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const codeFieldId = useId();
|
||||
const errorId = useId();
|
||||
|
||||
const hasTotp = methods.includes("totp");
|
||||
const hasWebauthn = methods.includes("webauthn");
|
||||
|
||||
// Default to a code entry when TOTP is available; otherwise lead with the
|
||||
// security-key path (a WebAuthn-only account).
|
||||
const [useRecovery, setUseRecovery] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submitCode(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiPost("/api/auth/mfa/verify", {
|
||||
mfaTicket,
|
||||
method: useRecovery ? "recovery" : "totp",
|
||||
code: code.trim(),
|
||||
});
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: t("auth.mfa.verifyFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSecurityKey() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const { startAuthentication } = await import("@simplewebauthn/browser");
|
||||
const { options, challengeId } = await apiPost<{
|
||||
options: Parameters<typeof startAuthentication>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/mfa/webauthn/verify/options", { mfaTicket });
|
||||
const credential = await startAuthentication({ optionsJSON: options });
|
||||
await apiPost("/api/auth/mfa/webauthn/verify", {
|
||||
mfaTicket,
|
||||
challengeId,
|
||||
credential,
|
||||
});
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message || t("auth.mfa.verifyFailed"));
|
||||
} else {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setError(t(key, params));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="mfa-login-step">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<ShieldCheck className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">{t("auth.mfa.title")}</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{useRecovery
|
||||
? t("auth.mfa.recoveryHint")
|
||||
: hasTotp
|
||||
? t("auth.mfa.totpHint")
|
||||
: t("auth.mfa.securityKeyHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(hasTotp || useRecovery) && (
|
||||
<form onSubmit={submitCode} className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={codeFieldId}>
|
||||
{useRecovery
|
||||
? t("auth.mfa.recoveryLabel")
|
||||
: t("auth.mfa.codeLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id={codeFieldId}
|
||||
value={code}
|
||||
onChange={(e) =>
|
||||
setCode(
|
||||
useRecovery
|
||||
? e.target.value
|
||||
: e.target.value.replace(/\D/g, "").slice(0, 6),
|
||||
)
|
||||
}
|
||||
inputMode={useRecovery ? "text" : "numeric"}
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
placeholder={useRecovery ? "XXXXX-XXXXX" : "123456"}
|
||||
className="font-mono tracking-widest"
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={loading || code.trim().length < 6}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.mfa.verify")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{hasWebauthn && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasTotp ? "outline" : "default"}
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
onClick={submitSecurityKey}
|
||||
disabled={loading}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
{t("auth.mfa.useSecurityKey")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hasTotp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUseRecovery((v) => !v);
|
||||
setCode("");
|
||||
setError(null);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
|
||||
>
|
||||
{useRecovery
|
||||
? t("auth.mfa.useAuthenticator")
|
||||
: t("auth.mfa.useRecoveryCode")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
id={errorId}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
|
||||
>
|
||||
{t("auth.mfa.backToLogin")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -99,6 +99,7 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
// health record belongs next to the health-record export.
|
||||
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
|
||||
"account",
|
||||
"security",
|
||||
"modules",
|
||||
"integrations",
|
||||
"channels",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { KeyRound, Loader2, Save, Shield, User } from "lucide-react";
|
||||
import { Loader2, Save, Shield, User } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -21,7 +21,6 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { AboutMeSection } from "@/components/settings/about-me-section";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
@@ -37,7 +36,6 @@ import {
|
||||
statusText,
|
||||
type StatusMessage,
|
||||
} from "./account-section-utils";
|
||||
import { PasskeyListSection } from "./passkey-list-section";
|
||||
import { AvatarSection } from "./avatar-section";
|
||||
import { SecuritySessionsCard } from "@/components/settings/security-sessions-card";
|
||||
import { SecurityActivityCard } from "@/components/settings/security-activity-card";
|
||||
@@ -79,12 +77,8 @@ export function AccountSection() {
|
||||
>(null);
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||
|
||||
// Passkey registration state.
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
const [passkeyMsg, setPasskeyMsg] = useState<StatusMessage | null>(null);
|
||||
const [passkeyMsgType, setPasskeyMsgType] = useState<
|
||||
"success" | "error" | null
|
||||
>(null);
|
||||
// v1.23 — passkey + second-factor management moved to the dedicated
|
||||
// /settings/security hub so "how I secure my account" reads as one place.
|
||||
|
||||
// Auth gate — push back to login if the user is unauthenticated. Effect
|
||||
// intentionally only navigates (never sets state in a way that re-runs the
|
||||
@@ -179,56 +173,6 @@ export function AccountSection() {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleAddPasskey() {
|
||||
setPasskeyLoading(true);
|
||||
setPasskeyMsg(null);
|
||||
setPasskeyMsgType(null);
|
||||
|
||||
try {
|
||||
const optRes = await apiFetchRaw("/api/auth/passkey/register-options", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!optRes.ok) {
|
||||
setPasskeyMsg({ key: "settings.passkeyOptionsError" });
|
||||
setPasskeyMsgType("error");
|
||||
setPasskeyLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const optJson = await optRes.json();
|
||||
const { options, challengeId } = optJson.data;
|
||||
|
||||
const { startRegistration } = await import("@simplewebauthn/browser");
|
||||
const credential = await startRegistration({ optionsJSON: options });
|
||||
|
||||
const verifyRes = await apiFetchRaw("/api/auth/passkey/register-verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ challengeId, credential }),
|
||||
});
|
||||
|
||||
if (verifyRes.ok) {
|
||||
setPasskeyMsg({ key: "settings.passkeyAdded" });
|
||||
setPasskeyMsgType("success");
|
||||
} else {
|
||||
const verifyJson = await verifyRes.json();
|
||||
setPasskeyMsg(
|
||||
verifyJson.error
|
||||
? { text: verifyJson.error }
|
||||
: { key: "settings.passkeyRegistrationFailed" },
|
||||
);
|
||||
setPasskeyMsgType("error");
|
||||
}
|
||||
} catch (err) {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setPasskeyMsg({ key, params });
|
||||
setPasskeyMsgType("error");
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPasswordSaving(true);
|
||||
@@ -521,43 +465,6 @@ export function AccountSection() {
|
||||
reachable. */}
|
||||
<CycleTrackingCard isAuthenticated={isAuthenticated} />
|
||||
|
||||
{/* Passkeys card */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Shield}
|
||||
title={t("settings.passkeys")}
|
||||
className="mb-4"
|
||||
/>
|
||||
<div className="pl-7">
|
||||
<PasskeyListSection isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end pl-7">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={handleAddPasskey}
|
||||
disabled={passkeyLoading}
|
||||
>
|
||||
{passkeyLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<KeyRound className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.addPasskey")}
|
||||
</Button>
|
||||
</div>
|
||||
{passkeyMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`mt-2 text-right text-sm ${
|
||||
passkeyMsgType === "success" ? "text-success" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{statusText(passkeyMsg, t)}
|
||||
</p>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* Password card. v1.4.19 A6: action-button placement contract —
|
||||
on mobile (`<sm`) the action button stacks below the title +
|
||||
description and renders full-width; on desktop (`>=sm`) it
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
interface PasskeyInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
credentialDeviceType: string;
|
||||
credentialBackedUp: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function PasskeyListSection({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [deleteMsg, setDeleteMsg] = useState<string | null>(null);
|
||||
|
||||
const { data: passkeys } = useQuery({
|
||||
queryKey: queryKeys.passkeys(),
|
||||
queryFn: async () => {
|
||||
return apiGet<PasskeyInfo[]>("/api/auth/passkeys");
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const deletePasskey = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiDelete(`/api/auth/passkeys/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
||||
setDeleteMsg(null);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setDeleteMsg(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const DEVICE_TYPE_LABELS: Record<string, string> = {
|
||||
singleDevice: t("settings.singleDevice"),
|
||||
multiDevice: t("settings.multiDevice"),
|
||||
};
|
||||
|
||||
if (!passkeys || passkeys.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{t("settings.passkeys")}</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.noPasskeys")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("settings.registeredPasskeys")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.passkeysDescription")}
|
||||
</p>
|
||||
{/* Phase A5 / B-mobile HIGH: the passkey table previously
|
||||
rendered with `min-w-[620px]` inside `overflow-x-auto`,
|
||||
which on a 393px viewport hid the right-most column —
|
||||
including the destructive delete action. Render the desktop
|
||||
table only at `≥ md`, and at `< md` paint a card-list where
|
||||
every passkey's name, device type, backup status, created
|
||||
date, and delete action are all visible without scrolling. */}
|
||||
{/* v1.4.33 — desktop table flips to card list at `lg` instead
|
||||
of `md` so iPad portrait (768 px = exactly the `md`
|
||||
inflection) lands on the card layout. The table needs
|
||||
~620 px of column width to read; below that it scrolls
|
||||
horizontally and the destructive delete column is the one
|
||||
that disappears. */}
|
||||
<div className="border-border mt-3 hidden overflow-x-auto rounded-lg border lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyDevice")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyBackup")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("settings.passkeyActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{passkeys.map((pk, idx) => (
|
||||
<tr key={pk.id} className={idx % 2 === 0 ? "bg-muted/20" : ""}>
|
||||
<td className="px-3 py-2 font-medium">{pk.name}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
|
||||
pk.credentialDeviceType}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<Badge
|
||||
variant={pk.credentialBackedUp ? "secondary" : "outline"}
|
||||
className="text-xs"
|
||||
>
|
||||
{pk.credentialBackedUp
|
||||
? t("settings.backedUp")
|
||||
: t("common.no")}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDate(pk.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-8 w-8"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-label={t("settings.deletePasskey")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deletePasskey")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deletePasskeyDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-busy={deletePasskey.isPending || undefined}
|
||||
onClick={() => deletePasskey.mutate(pk.id)}
|
||||
>
|
||||
{deletePasskey.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile + tablet card list — < lg only. Each passkey gets
|
||||
its own card so the delete action stays visible and
|
||||
tap-targetable (44 px) without the user having to
|
||||
horizontally scroll through a wide table. v1.4.33 bumped
|
||||
the breakpoint from `md` to `lg` so iPad portrait (768 px)
|
||||
stays on the card layout instead of flipping between
|
||||
layouts on rotation. */}
|
||||
<ul
|
||||
className="mt-3 space-y-2 lg:hidden"
|
||||
data-testid="passkeys-mobile-list"
|
||||
>
|
||||
{passkeys.map((pk) => (
|
||||
<SettingsCard as="li" key={pk.id}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{pk.name}</p>
|
||||
{/* v1.4.27 MB7 / CF-75 — promote the device-type from
|
||||
plain text to an outline Badge so the mobile card
|
||||
list reads consistent with the desktop table's
|
||||
"Single-device / Multi-device" column. Sits on the
|
||||
same chip row as the backup status and date so all
|
||||
metadata reads as a single horizontal stride. */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
|
||||
pk.credentialDeviceType}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={pk.credentialBackedUp ? "secondary" : "outline"}
|
||||
className="text-xs"
|
||||
>
|
||||
{pk.credentialBackedUp
|
||||
? t("settings.backedUp")
|
||||
: t("common.no")}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{formatDate(pk.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive min-h-11 min-w-11"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-label={t("settings.deletePasskey")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deletePasskey")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deletePasskeyDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-busy={deletePasskey.isPending || undefined}
|
||||
onClick={() => deletePasskey.mutate(pk.id)}
|
||||
>
|
||||
{deletePasskey.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
))}
|
||||
</ul>
|
||||
{deleteMsg && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive mt-2 flex items-center gap-2 text-sm"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{deleteMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Radio,
|
||||
Settings2,
|
||||
Share2,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Smile,
|
||||
Sparkles,
|
||||
@@ -43,6 +44,7 @@ import type { SettingsSectionSlug } from "./section-slugs";
|
||||
|
||||
const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
account: User,
|
||||
security: ShieldCheck,
|
||||
modules: Blocks,
|
||||
integrations: Link2,
|
||||
channels: Radio,
|
||||
|
||||
@@ -49,6 +49,10 @@
|
||||
// its own top-level entry.
|
||||
export const SETTINGS_SECTION_SLUGS = [
|
||||
"account",
|
||||
// v1.23 — `security` is the dedicated account-security home: second-factor
|
||||
// setup (TOTP + security keys), recovery codes, and passkey management. Sits
|
||||
// right after `account` so the two identity surfaces read as neighbours.
|
||||
"security",
|
||||
// v1.18.0 — `modules` ("Was du trackst") sits right after account: the
|
||||
// single front door for enabling/disabling secondary tracking domains.
|
||||
"modules",
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { TotpCard } from "./totp-card";
|
||||
import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card";
|
||||
import { PasskeyListSection } from "./passkey-list-section";
|
||||
import { PasskeyUpgradeNudge } from "./passkey-upgrade-nudge";
|
||||
|
||||
interface MfaStatus {
|
||||
totp: { enabled: boolean };
|
||||
recoveryCodesRemaining: number;
|
||||
webauthn: WebauthnKeyInfo[];
|
||||
passkeyNudgeDismissed: boolean;
|
||||
}
|
||||
|
||||
interface PasskeyInfo {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function SecuritySection() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: queryKeys.mfaStatus(),
|
||||
queryFn: async () => apiGet<MfaStatus>("/api/auth/me/mfa"),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// Passkey count drives whether the upgrade nudge shows. Shares the cached
|
||||
// `passkeys()` key with the list below, so this is not an extra round-trip.
|
||||
const { data: passkeys } = useQuery({
|
||||
queryKey: queryKeys.passkeys(),
|
||||
queryFn: async () => apiGet<PasskeyInfo[]>("/api/auth/passkeys"),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const showNudge =
|
||||
status != null &&
|
||||
!status.passkeyNudgeDismissed &&
|
||||
passkeys != null &&
|
||||
passkeys.length === 0;
|
||||
|
||||
if (isLoading || !status) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{showNudge && <PasskeyUpgradeNudge />}
|
||||
|
||||
<TotpCard
|
||||
enabled={status.totp.enabled}
|
||||
recoveryCodesRemaining={status.recoveryCodesRemaining}
|
||||
/>
|
||||
|
||||
<SecurityKeysCard keys={status.webauthn} />
|
||||
|
||||
<PasskeyListSection isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Fingerprint,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
ApiError,
|
||||
apiDelete,
|
||||
apiGet,
|
||||
apiPatch,
|
||||
apiPost,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
|
||||
interface PasskeyInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
credentialDeviceType: string;
|
||||
credentialBackedUp: boolean;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary-passkey management: list (name, device, backup, created, last used),
|
||||
* add (registration ceremony), rename, and delete. The passwordless-primary
|
||||
* passkey home — distinct from the second-factor security keys above.
|
||||
*/
|
||||
export function PasskeyListSection({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
|
||||
const { data: passkeys } = useQuery({
|
||||
queryKey: queryKeys.passkeys(),
|
||||
queryFn: async () => apiGet<PasskeyInfo[]>("/api/auth/passkeys"),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { startRegistration } = await import("@simplewebauthn/browser");
|
||||
const { options, challengeId } = await apiPost<{
|
||||
options: Parameters<typeof startRegistration>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/passkey/register-options");
|
||||
const credential = await startRegistration({ optionsJSON: options });
|
||||
await apiPost("/api/auth/passkey/register-verify", {
|
||||
challengeId,
|
||||
credential,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMsg(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof ApiError) {
|
||||
setMsg(err.message || t("settings.passkeyRegistrationFailed"));
|
||||
} else {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setMsg(t(key, params));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const rename = useMutation({
|
||||
mutationFn: async ({ id, name }: { id: string; name: string }) => {
|
||||
await apiPatch(`/api/auth/passkeys/${id}`, { name });
|
||||
},
|
||||
onSuccess: () => {
|
||||
setEditingId(null);
|
||||
setMsg(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
||||
},
|
||||
onError: (err: Error) => setMsg(err.message),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiDelete(`/api/auth/passkeys/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMsg(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
||||
},
|
||||
onError: (err: Error) => setMsg(err.message),
|
||||
});
|
||||
|
||||
const DEVICE_TYPE_LABELS: Record<string, string> = {
|
||||
singleDevice: t("settings.singleDevice"),
|
||||
multiDevice: t("settings.multiDevice"),
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Fingerprint}
|
||||
title={t("settings.passkeys")}
|
||||
description={t("settings.passkeysDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
{!passkeys || passkeys.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.noPasskeys")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2" data-testid="passkeys-list">
|
||||
{passkeys.map((pk) => (
|
||||
<SettingsCard as="li" key={pk.id}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{editingId === pk.id ? (
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
rename.mutate({ id: pk.id, name: editName.trim() });
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
maxLength={64}
|
||||
aria-label={t("settings.passkeyName")}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
disabled={
|
||||
rename.isPending || editName.trim().length === 0
|
||||
}
|
||||
aria-label={t("common.save")}
|
||||
>
|
||||
{rename.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setEditingId(null)}
|
||||
aria-label={t("common.cancel")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<p className="truncate text-sm font-medium">
|
||||
{pk.name}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
|
||||
pk.credentialDeviceType}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={
|
||||
pk.credentialBackedUp ? "secondary" : "outline"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{pk.credentialBackedUp
|
||||
? t("settings.backedUp")
|
||||
: t("common.no")}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{pk.lastUsedAt
|
||||
? t("settings.security.keys.lastUsed", {
|
||||
date: formatDate(pk.lastUsedAt),
|
||||
})
|
||||
: t("settings.security.keys.neverUsed")}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editingId !== pk.id && (
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
|
||||
onClick={() => {
|
||||
setEditingId(pk.id);
|
||||
setEditName(pk.name);
|
||||
}}
|
||||
aria-label={t("settings.security.keys.rename")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
|
||||
disabled={remove.isPending}
|
||||
aria-label={t("settings.deletePasskey")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deletePasskey")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deletePasskeyDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={remove.isPending}
|
||||
aria-busy={remove.isPending || undefined}
|
||||
onClick={() => remove.mutate(pk.id)}
|
||||
>
|
||||
{remove.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => add.mutate()}
|
||||
disabled={add.isPending}
|
||||
>
|
||||
{add.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<KeyRound className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.addPasskey")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive mt-3 flex items-center gap-2 text-sm"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Sparkles, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* One-time, dismissible "add a passkey" prompt. Shown only when the account
|
||||
* has no passkey registered and has not dismissed the nudge before. Dismissal
|
||||
* is persisted on the user (`passkeyUpgradeNudgeDismissed`).
|
||||
*/
|
||||
export function PasskeyUpgradeNudge() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const dismiss = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiPost("/api/auth/me/passkey-nudge/dismiss");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="border-primary/30 bg-primary/5 flex items-start gap-3 rounded-lg border p-4">
|
||||
<Sparkles className="text-primary mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.security.passkeyNudge.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.security.passkeyNudge.body")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => dismiss.mutate()}
|
||||
disabled={dismiss.isPending}
|
||||
aria-label={t("common.dismiss")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Copy, Download, ShieldAlert } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* One-time display of a freshly generated recovery-code set.
|
||||
*
|
||||
* Recovery codes are shown exactly once (the server only stores their hashes),
|
||||
* so this panel leads with an explicit "save these now" warning and offers
|
||||
* copy + download so the user cannot lose them to a closed tab. The caller
|
||||
* owns when the panel disappears (e.g. an explicit "I've saved them" action).
|
||||
*/
|
||||
export function RecoveryCodesPanel({ codes }: { codes: string[] }) {
|
||||
const { t } = useTranslations();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const asText = codes.join("\n");
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(asText);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard can be blocked (insecure context / permissions); the codes
|
||||
// stay visible and downloadable, so this is a non-fatal best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
const blob = new Blob([`${asText}\n`], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "healthlog-recovery-codes.txt";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-border bg-muted/30 mt-4 rounded-lg border p-4">
|
||||
<div className="text-destructive flex items-start gap-2">
|
||||
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.security.recovery.saveNow")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.security.recovery.saveNowHint")}
|
||||
</p>
|
||||
<ul
|
||||
className="mt-3 grid grid-cols-2 gap-2 font-mono text-sm"
|
||||
data-testid="recovery-codes"
|
||||
>
|
||||
{codes.map((code) => (
|
||||
<li
|
||||
key={code}
|
||||
className="border-border bg-background rounded border px-2 py-1.5 text-center tracking-wider"
|
||||
>
|
||||
{code}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{copied ? t("common.copied") : t("common.copy")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{t("settings.security.recovery.download")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Usb,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { formatDate } from "@/lib/format";
|
||||
|
||||
export interface WebauthnKeyInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
function describeStepUp(
|
||||
err: unknown,
|
||||
fallback: string,
|
||||
stepUpMsg: string,
|
||||
): string {
|
||||
if (err instanceof ApiError) {
|
||||
const code = err.meta?.errorCode;
|
||||
if (
|
||||
err.status === 401 &&
|
||||
typeof code === "string" &&
|
||||
code.startsWith("auth.stepup")
|
||||
) {
|
||||
return stepUpMsg;
|
||||
}
|
||||
return err.message || fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { startRegistration } = await import("@simplewebauthn/browser");
|
||||
const { options, challengeId } = await apiPost<{
|
||||
options: Parameters<typeof startRegistration>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/me/mfa/webauthn/register/options");
|
||||
const credential = await startRegistration({ optionsJSON: options });
|
||||
await apiPost("/api/auth/me/mfa/webauthn/register/verify", {
|
||||
challengeId,
|
||||
credential,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message || t("settings.security.keys.addFailed"));
|
||||
} else {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setError(t(key, params));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const rename = useMutation({
|
||||
mutationFn: async ({ id, name }: { id: string; name: string }) => {
|
||||
await apiPatch(`/api/auth/me/mfa/webauthn/${id}`, { name });
|
||||
},
|
||||
onSuccess: () => {
|
||||
setEditingId(null);
|
||||
setError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
},
|
||||
onError: (err) =>
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: t("settings.security.keys.renameFailed"),
|
||||
),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiDelete(`/api/auth/me/mfa/webauthn/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
},
|
||||
onError: (err) =>
|
||||
setError(
|
||||
describeStepUp(
|
||||
err,
|
||||
t("settings.security.keys.removeFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Usb}
|
||||
title={t("settings.security.keys.title")}
|
||||
description={t("settings.security.keys.description")}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
{keys.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.security.keys.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2" data-testid="security-keys-list">
|
||||
{keys.map((key) => (
|
||||
<SettingsCard as="li" key={key.id}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{editingId === key.id ? (
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
rename.mutate({ id: key.id, name: editName.trim() });
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
maxLength={64}
|
||||
aria-label={t("settings.security.keys.nameLabel")}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
disabled={
|
||||
rename.isPending || editName.trim().length === 0
|
||||
}
|
||||
aria-label={t("common.save")}
|
||||
>
|
||||
{rename.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setEditingId(null)}
|
||||
aria-label={t("common.cancel")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<p className="truncate text-sm font-medium">
|
||||
{key.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{key.lastUsedAt
|
||||
? t("settings.security.keys.lastUsed", {
|
||||
date: formatDate(key.lastUsedAt),
|
||||
})
|
||||
: t("settings.security.keys.neverUsed")}
|
||||
{" · "}
|
||||
{t("settings.security.keys.added", {
|
||||
date: formatDate(key.createdAt),
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editingId !== key.id && (
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
|
||||
onClick={() => {
|
||||
setEditingId(key.id);
|
||||
setEditName(key.name);
|
||||
}}
|
||||
aria-label={t("settings.security.keys.rename")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
|
||||
disabled={remove.isPending}
|
||||
aria-label={t("settings.security.keys.remove")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.security.keys.remove")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.security.keys.removeConfirm")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={remove.isPending}
|
||||
aria-busy={remove.isPending || undefined}
|
||||
onClick={() => remove.mutate(key.id)}
|
||||
>
|
||||
{remove.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => add.mutate()}
|
||||
disabled={add.isPending}
|
||||
>
|
||||
{add.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Usb className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.security.keys.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive mt-3 flex items-center gap-2 text-sm"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Copy,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Smartphone,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { RecoveryCodesPanel } from "./recovery-codes-panel";
|
||||
|
||||
interface SetupData {
|
||||
otpauthUri: string;
|
||||
totpSecret: string;
|
||||
}
|
||||
|
||||
interface ConfirmResult {
|
||||
enabled: boolean;
|
||||
recoveryCodes: string[];
|
||||
recoveryCodesRemaining: number;
|
||||
}
|
||||
|
||||
/** Map a step-up 401 to a clear re-login message; otherwise the raw message. */
|
||||
function describeError(
|
||||
err: unknown,
|
||||
fallback: string,
|
||||
stepUpMsg: string,
|
||||
): string {
|
||||
if (err instanceof ApiError) {
|
||||
const code = err.meta?.errorCode;
|
||||
if (
|
||||
err.status === 401 &&
|
||||
typeof code === "string" &&
|
||||
code.startsWith("auth.stepup")
|
||||
) {
|
||||
return stepUpMsg;
|
||||
}
|
||||
return err.message || fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function TotpCard({
|
||||
enabled,
|
||||
recoveryCodesRemaining,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
recoveryCodesRemaining: number;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const codeFieldId = useId();
|
||||
|
||||
const [setup, setSetup] = useState<SetupData | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [secretCopied, setSecretCopied] = useState(false);
|
||||
const [freshCodes, setFreshCodes] = useState<string[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [disableCode, setDisableCode] = useState("");
|
||||
const [disableMethod, setDisableMethod] = useState<"totp" | "recovery">(
|
||||
"totp",
|
||||
);
|
||||
|
||||
function resetWizard() {
|
||||
setSetup(null);
|
||||
setQrDataUrl(null);
|
||||
setCode("");
|
||||
setSecretCopied(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const beginSetup = useMutation({
|
||||
mutationFn: async () => {
|
||||
return apiPost<SetupData>("/api/auth/me/mfa/totp/setup");
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
setError(null);
|
||||
setSetup(data);
|
||||
try {
|
||||
const QRCode = (await import("qrcode")).default;
|
||||
setQrDataUrl(await QRCode.toDataURL(data.otpauthUri, { margin: 1 }));
|
||||
} catch {
|
||||
// QR render is best-effort — the manual secret below is the fallback.
|
||||
setQrDataUrl(null);
|
||||
}
|
||||
},
|
||||
onError: (err) =>
|
||||
setError(
|
||||
describeError(
|
||||
err,
|
||||
t("settings.security.totp.setupFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
const confirm = useMutation({
|
||||
mutationFn: async () => {
|
||||
return apiPost<ConfirmResult>("/api/auth/me/mfa/totp/confirm", { code });
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setError(null);
|
||||
setFreshCodes(data.recoveryCodes);
|
||||
resetWizard();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
},
|
||||
onError: (err) =>
|
||||
setError(
|
||||
describeError(
|
||||
err,
|
||||
t("settings.security.totp.invalidCode"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
const disable = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiPost("/api/auth/me/mfa/disable", {
|
||||
code: disableCode,
|
||||
method: disableMethod,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setDisableCode("");
|
||||
setFreshCodes(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
},
|
||||
onError: (err) =>
|
||||
setError(
|
||||
describeError(
|
||||
err,
|
||||
t("settings.security.totp.disableFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
const regenerate = useMutation({
|
||||
mutationFn: async () => {
|
||||
return apiPost<{ recoveryCodes: string[] }>(
|
||||
"/api/auth/me/mfa/recovery-codes/regenerate",
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setError(null);
|
||||
setFreshCodes(data.recoveryCodes);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
},
|
||||
onError: (err) =>
|
||||
setError(
|
||||
describeError(
|
||||
err,
|
||||
t("settings.security.recovery.regenFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
async function copySecret() {
|
||||
if (!setup) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(setup.totpSecret);
|
||||
setSecretCopied(true);
|
||||
setTimeout(() => setSecretCopied(false), 2000);
|
||||
} catch {
|
||||
/* clipboard blocked — the secret stays visible for manual entry */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Smartphone}
|
||||
title={t("settings.security.totp.title")}
|
||||
description={t("settings.security.totp.description")}
|
||||
status={
|
||||
enabled ? (
|
||||
<Badge variant="secondary">{t("settings.security.active")}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{t("settings.security.inactive")}</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
{/* ── Not enabled, no wizard: the enable CTA ── */}
|
||||
{!enabled && !setup && !freshCodes && (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => beginSetup.mutate()}
|
||||
disabled={beginSetup.isPending}
|
||||
>
|
||||
{beginSetup.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<KeyRound className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.security.totp.setUp")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* ── Enable wizard ── */}
|
||||
{setup && (
|
||||
<div className="space-y-4">
|
||||
<ol className="text-muted-foreground list-decimal space-y-3 pl-5 text-sm">
|
||||
<li>{t("settings.security.totp.step1")}</li>
|
||||
<li>{t("settings.security.totp.step2")}</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start">
|
||||
{qrDataUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt={t("settings.security.totp.qrAlt")}
|
||||
width={176}
|
||||
height={176}
|
||||
className="border-border rounded-lg border bg-white p-2"
|
||||
/>
|
||||
) : (
|
||||
<div className="border-border text-muted-foreground flex h-44 w-44 items-center justify-center rounded-lg border text-xs">
|
||||
{t("settings.security.totp.qrUnavailable")}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium">
|
||||
{t("settings.security.totp.manualEntry")}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<code className="border-border bg-muted/40 min-w-0 flex-1 truncate rounded border px-2 py-1.5 font-mono text-xs">
|
||||
{setup.totpSecret}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={copySecret}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
{secretCopied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
confirm.mutate();
|
||||
}}
|
||||
>
|
||||
<Label htmlFor={codeFieldId}>
|
||||
{t("settings.security.totp.enterCode")}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={codeFieldId}
|
||||
value={code}
|
||||
onChange={(e) =>
|
||||
setCode(e.target.value.replace(/\D/g, "").slice(0, 6))
|
||||
}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="123456"
|
||||
className="font-mono tracking-widest"
|
||||
aria-invalid={!!error || undefined}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={confirm.isPending || code.length !== 6}
|
||||
>
|
||||
{confirm.isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.security.totp.verify")}
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetWizard}
|
||||
className="text-muted-foreground hover:text-foreground text-xs"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Fresh recovery codes (post-confirm or post-regen) ── */}
|
||||
{freshCodes && (
|
||||
<div>
|
||||
<RecoveryCodesPanel codes={freshCodes} />
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setFreshCodes(null)}
|
||||
>
|
||||
{t("settings.security.recovery.savedIt")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Enabled, idle: recovery status + disable/regen actions ── */}
|
||||
{enabled && !freshCodes && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
recoveryCodesRemaining === 1
|
||||
? "settings.security.recovery.remainingOne"
|
||||
: "settings.security.recovery.remainingOther",
|
||||
{ count: recoveryCodesRemaining },
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => regenerate.mutate()}
|
||||
disabled={regenerate.isPending}
|
||||
>
|
||||
{regenerate.isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.security.recovery.regenerate")}
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-destructive min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t("settings.security.totp.disable")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.security.totp.disableTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.security.totp.disableConfirm")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disable-code">
|
||||
{t("settings.security.totp.currentCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="disable-code"
|
||||
value={disableCode}
|
||||
onChange={(e) => setDisableCode(e.target.value)}
|
||||
inputMode={disableMethod === "totp" ? "numeric" : "text"}
|
||||
autoComplete="one-time-code"
|
||||
placeholder={
|
||||
disableMethod === "totp" ? "123456" : "XXXXX-XXXXX"
|
||||
}
|
||||
className="font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground text-xs"
|
||||
onClick={() =>
|
||||
setDisableMethod((m) =>
|
||||
m === "totp" ? "recovery" : "totp",
|
||||
)
|
||||
}
|
||||
>
|
||||
{disableMethod === "totp"
|
||||
? t("settings.security.useRecoveryCode")
|
||||
: t("settings.security.useAuthenticator")}
|
||||
</button>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={disable.isPending || disableCode.length < 6}
|
||||
aria-busy={disable.isPending || undefined}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
disable.mutate();
|
||||
}}
|
||||
>
|
||||
{disable.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.security.totp.disable")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive mt-3 flex items-center gap-2 text-sm"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
Radio,
|
||||
Settings2,
|
||||
Share2,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Smile,
|
||||
Sparkles,
|
||||
@@ -99,6 +100,13 @@ interface SettingsSection {
|
||||
*/
|
||||
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
{ slug: "account", titleKey: "settings.sections.account.title", icon: User },
|
||||
// v1.23 — the account-security home: second factors, recovery codes,
|
||||
// passkey management. Sits directly under Account.
|
||||
{
|
||||
slug: "security",
|
||||
titleKey: "settings.sections.security.title",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
// v1.18.0 — the "Was du trackst" hub. Sits right below Account: the one
|
||||
// place to enable/disable the secondary tracking domains.
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
session: { findUnique: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -91,8 +92,9 @@ describe("requireFreshMfa", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a user without an active second factor", async () => {
|
||||
it("rejects a user with neither TOTP nor a security key", async () => {
|
||||
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
|
||||
errorCode: "auth.stepup.mfa_not_enrolled",
|
||||
});
|
||||
@@ -100,6 +102,17 @@ describe("requireFreshMfa", () => {
|
||||
expect(prisma.session.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a WebAuthn-only account with a fresh stamp", async () => {
|
||||
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: new Date(Date.now() - 60_000),
|
||||
} as never);
|
||||
|
||||
const ctx = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
expect(ctx.user.id).toBe("user-1");
|
||||
});
|
||||
|
||||
it("rejects when there is no cookie session — Bearer can never satisfy it", async () => {
|
||||
// getSession is cookie-only; a Bearer header is irrelevant and never read.
|
||||
headersGet.mockImplementation((n) =>
|
||||
|
||||
+19
-2
@@ -539,8 +539,15 @@ export async function requireFreshMfa(
|
||||
// The user must actually have a second factor active. A single-factor
|
||||
// account cannot produce a fresh-MFA proof, so step-up-gated actions are
|
||||
// unreachable for it by design (the management UI gates enrolment first).
|
||||
// Either factor counts: a confirmed TOTP secret OR a registered WebAuthn
|
||||
// security key — both stamp `Session.mfaVerifiedAt` on a completed login.
|
||||
if (!sessionData.user.totpConfirmedAt) {
|
||||
throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled");
|
||||
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: sessionData.user.id },
|
||||
});
|
||||
if (webauthnKeyCount === 0) {
|
||||
throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled");
|
||||
}
|
||||
}
|
||||
|
||||
// Read the freshness stamp off the live session row — `getSession`'s
|
||||
@@ -579,7 +586,17 @@ export async function requireFreshMfaIfEnrolled(
|
||||
maxAgeSeconds: number,
|
||||
): Promise<AuthContext> {
|
||||
const auth = await requireAuth();
|
||||
if (auth.user.totpConfirmedAt) {
|
||||
// Either factor enrols the account: a confirmed TOTP secret OR a registered
|
||||
// WebAuthn security key. A webauthn-only user must clear step-up too, so the
|
||||
// destructive-action boundary tracks `requireFreshMfa`'s either-factor rule.
|
||||
let enrolled = Boolean(auth.user.totpConfirmedAt);
|
||||
if (!enrolled) {
|
||||
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: auth.user.id },
|
||||
});
|
||||
enrolled = webauthnKeyCount > 0;
|
||||
}
|
||||
if (enrolled) {
|
||||
await requireFreshMfa(maxAgeSeconds);
|
||||
}
|
||||
return auth;
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* WebAuthn as a **second factor** — distinct from the passwordless-primary
|
||||
* passkey path in `src/lib/auth/passkey.ts`.
|
||||
*
|
||||
* Differences from the primary path (see `.planning` design §3.2):
|
||||
* - Credentials live in `WebauthnMfaCredential`, NOT `Passkey`. A `Passkey`
|
||||
* row is itself a login credential; an MFA credential is meaningless without
|
||||
* the password, so conflating the two would silently turn a security-key
|
||||
* registration into a passwordless login credential.
|
||||
* - Registration uses `residentKey: "discouraged"` (non-resident → does not
|
||||
* consume a discoverable-credential slot, so an account can register many
|
||||
* keys) and `userVerification: "preferred"` (the password already supplied
|
||||
* one factor; a roaming key need not always carry a PIN/biometric).
|
||||
* - The login ceremony scopes `allowCredentials` to the **already
|
||||
* password-identified** user's MFA keys — never an empty allow-list, because
|
||||
* a non-resident credential is not discoverable.
|
||||
*
|
||||
* The transient challenge rides the shared `AuthChallenge` store under
|
||||
* dedicated `mfa_registration` / `mfa_authentication` types so it never
|
||||
* collides with the primary-passkey challenges.
|
||||
*/
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
type AuthenticationResponseJSON,
|
||||
type RegistrationResponseJSON,
|
||||
type VerifiedRegistrationResponse,
|
||||
type VerifiedAuthenticationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
RP_NAME as rpName,
|
||||
getRpId,
|
||||
getExpectedOrigin,
|
||||
} from "@/lib/auth/webauthn-rp";
|
||||
|
||||
// Boundary narrowing mirrors `passkey.ts`: a malformed body fails fast with a
|
||||
// structured error here, while SimpleWebAuthn still owns the cryptographic
|
||||
// validation downstream.
|
||||
const registrationResponseSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
rawId: z.string().min(1),
|
||||
response: z
|
||||
.object({
|
||||
clientDataJSON: z.string().min(1),
|
||||
attestationObject: z.string().min(1),
|
||||
transports: z.array(z.string()).optional(),
|
||||
publicKeyAlgorithm: z.number().optional(),
|
||||
publicKey: z.string().optional(),
|
||||
authenticatorData: z.string().optional(),
|
||||
})
|
||||
.loose(),
|
||||
authenticatorAttachment: z.string().optional(),
|
||||
clientExtensionResults: z.unknown().optional(),
|
||||
type: z.literal("public-key"),
|
||||
})
|
||||
.loose();
|
||||
|
||||
const authenticationResponseSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
rawId: z.string().min(1),
|
||||
response: z
|
||||
.object({
|
||||
clientDataJSON: z.string().min(1),
|
||||
authenticatorData: z.string().min(1),
|
||||
signature: z.string().min(1),
|
||||
userHandle: z.string().optional(),
|
||||
})
|
||||
.loose(),
|
||||
authenticatorAttachment: z.string().optional(),
|
||||
clientExtensionResults: z.unknown().optional(),
|
||||
type: z.literal("public-key"),
|
||||
})
|
||||
.loose();
|
||||
|
||||
type Transport =
|
||||
| "ble"
|
||||
| "cable"
|
||||
| "hybrid"
|
||||
| "internal"
|
||||
| "nfc"
|
||||
| "smart-card"
|
||||
| "usb";
|
||||
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
async function cleanupExpiredChallenges() {
|
||||
await prisma.authChallenge.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Registration (settings, cookie-authenticated) ────────────────────
|
||||
|
||||
export async function createMfaRegistrationOptions(
|
||||
userId: string,
|
||||
username: string,
|
||||
) {
|
||||
await cleanupExpiredChallenges();
|
||||
|
||||
const existing = await prisma.webauthnMfaCredential.findMany({
|
||||
where: { userId },
|
||||
select: { credentialId: true, transports: true },
|
||||
});
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID: getRpId(),
|
||||
userName: username,
|
||||
attestationType: "none",
|
||||
excludeCredentials: existing.map((c) => ({
|
||||
id: c.credentialId,
|
||||
transports: c.transports as Transport[],
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
// Non-resident: the password already identifies the account, so the
|
||||
// credential need not be discoverable — and a non-resident key does not
|
||||
// burn a discoverable-credential slot, allowing many keys per account.
|
||||
residentKey: "discouraged",
|
||||
// A roaming security key supplies the possession factor; UV is a bonus,
|
||||
// not a requirement, because the password already covered a factor.
|
||||
userVerification: "preferred",
|
||||
authenticatorAttachment: "cross-platform",
|
||||
},
|
||||
});
|
||||
|
||||
const challenge = await prisma.authChallenge.create({
|
||||
data: {
|
||||
userId,
|
||||
challenge: options.challenge,
|
||||
type: "mfa_registration",
|
||||
expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
return { options, challengeId: challenge.id };
|
||||
}
|
||||
|
||||
export async function verifyMfaRegistration(
|
||||
challengeId: string,
|
||||
userId: string,
|
||||
response: unknown,
|
||||
): Promise<VerifiedRegistrationResponse> {
|
||||
const challenge = await prisma.authChallenge.findUnique({
|
||||
where: { id: challengeId },
|
||||
});
|
||||
|
||||
// The challenge must be live, of the registration type, and bound to the
|
||||
// calling user — a challenge minted for another account can never be reused.
|
||||
if (
|
||||
!challenge ||
|
||||
challenge.expiresAt < new Date() ||
|
||||
challenge.type !== "mfa_registration" ||
|
||||
challenge.userId !== userId
|
||||
) {
|
||||
throw new Error("Challenge expired or not found");
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = registrationResponseSchema.safeParse(response);
|
||||
if (!parsed.success) {
|
||||
throw new Error("Registration response shape invalid");
|
||||
}
|
||||
return await verifyRegistrationResponse({
|
||||
response: parsed.data as RegistrationResponseJSON,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: getExpectedOrigin(),
|
||||
expectedRPID: getRpId(),
|
||||
});
|
||||
} finally {
|
||||
await prisma.authChallenge
|
||||
.delete({ where: { id: challengeId } })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Authentication (mid-login second factor) ─────────────────────────
|
||||
|
||||
/**
|
||||
* Generate an assertion challenge scoped to a single user's MFA credentials.
|
||||
* The caller has already password-identified the account (via the MFA ticket),
|
||||
* so `allowCredentials` is always populated — a non-resident credential is not
|
||||
* discoverable from an empty allow-list. Returns null when the user has no
|
||||
* security keys registered (the caller should fall back to another factor).
|
||||
*/
|
||||
export async function createMfaAuthenticationOptions(userId: string) {
|
||||
await cleanupExpiredChallenges();
|
||||
|
||||
const credentials = await prisma.webauthnMfaCredential.findMany({
|
||||
where: { userId },
|
||||
select: { credentialId: true, transports: true },
|
||||
});
|
||||
|
||||
if (credentials.length === 0) return null;
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: getRpId(),
|
||||
userVerification: "preferred",
|
||||
allowCredentials: credentials.map((c) => ({
|
||||
id: c.credentialId,
|
||||
transports: c.transports as Transport[],
|
||||
})),
|
||||
});
|
||||
|
||||
const challenge = await prisma.authChallenge.create({
|
||||
data: {
|
||||
userId,
|
||||
challenge: options.challenge,
|
||||
type: "mfa_authentication",
|
||||
expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
return { options, challengeId: challenge.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a mid-login security-key assertion against the user's MFA credentials.
|
||||
* Returns true on a verified assertion (counter + last-used are stamped); the
|
||||
* caller then claims the MFA ticket and finishes the login.
|
||||
*/
|
||||
export async function verifyMfaAuthentication(
|
||||
challengeId: string,
|
||||
userId: string,
|
||||
response: unknown,
|
||||
): Promise<boolean> {
|
||||
const challenge = await prisma.authChallenge.findUnique({
|
||||
where: { id: challengeId },
|
||||
});
|
||||
|
||||
if (
|
||||
!challenge ||
|
||||
challenge.expiresAt < new Date() ||
|
||||
challenge.type !== "mfa_authentication" ||
|
||||
challenge.userId !== userId
|
||||
) {
|
||||
throw new Error("Challenge expired or not found");
|
||||
}
|
||||
|
||||
const parsed = authenticationResponseSchema.safeParse(response);
|
||||
if (!parsed.success) {
|
||||
throw new Error("Malformed security-key authentication response");
|
||||
}
|
||||
const typedResponse = parsed.data as unknown as AuthenticationResponseJSON;
|
||||
|
||||
try {
|
||||
// The presented credential MUST belong to the password-identified user —
|
||||
// look it up scoped to `userId` so an assertion against another account's
|
||||
// key can never satisfy this account's second factor.
|
||||
const credential = await prisma.webauthnMfaCredential.findFirst({
|
||||
where: { credentialId: typedResponse.id, userId },
|
||||
});
|
||||
if (!credential) return false;
|
||||
|
||||
const verification: VerifiedAuthenticationResponse =
|
||||
await verifyAuthenticationResponse({
|
||||
response: typedResponse,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: getExpectedOrigin(),
|
||||
expectedRPID: getRpId(),
|
||||
credential: {
|
||||
id: credential.credentialId,
|
||||
publicKey: credential.credentialPublicKey,
|
||||
counter: Number(credential.counter),
|
||||
transports: credential.transports as Transport[],
|
||||
},
|
||||
});
|
||||
|
||||
if (verification.verified) {
|
||||
await prisma.webauthnMfaCredential.update({
|
||||
where: { id: credential.id },
|
||||
data: {
|
||||
counter: BigInt(verification.authenticationInfo.newCounter),
|
||||
lastUsedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return verification.verified;
|
||||
} finally {
|
||||
await prisma.authChallenge
|
||||
.delete({ where: { id: challengeId } })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
+14
-35
@@ -10,6 +10,11 @@ import {
|
||||
} from "@simplewebauthn/server";
|
||||
import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
RP_NAME as rpName,
|
||||
getRpId,
|
||||
getExpectedOrigin,
|
||||
} from "@/lib/auth/webauthn-rp";
|
||||
|
||||
// v1.4.43 W13 L-3 — explicit Zod narrowing in front of
|
||||
// `verifyAuthentication`. Replaces the raw
|
||||
@@ -76,7 +81,9 @@ type Transport =
|
||||
| "smart-card"
|
||||
| "usb";
|
||||
|
||||
const rpName = "HealthLog";
|
||||
// The relying-party config (`rpName` / `getRpId` / `getExpectedOrigin`) is
|
||||
// shared with the second-factor security-key path via `@/lib/auth/webauthn-rp`
|
||||
// so both ceremonies bind credentials to the identical RP.
|
||||
|
||||
async function cleanupExpiredChallenges() {
|
||||
await prisma.authChallenge.deleteMany({
|
||||
@@ -84,38 +91,6 @@ async function cleanupExpiredChallenges() {
|
||||
});
|
||||
}
|
||||
|
||||
function getConfiguredOrigins(): string[] {
|
||||
const candidates = [
|
||||
process.env.APP_URL,
|
||||
process.env.NEXT_PUBLIC_APP_URL,
|
||||
"http://localhost:3000",
|
||||
]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
const validOrigins = candidates
|
||||
.map((value) => {
|
||||
try {
|
||||
return new URL(value).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
return Array.from(new Set(validOrigins));
|
||||
}
|
||||
|
||||
function getRpId(): string {
|
||||
const origins = getConfiguredOrigins();
|
||||
return new URL(origins[0]).hostname;
|
||||
}
|
||||
|
||||
function getExpectedOrigin(): string | string[] {
|
||||
const origins = getConfiguredOrigins();
|
||||
return origins.length === 1 ? origins[0] : origins;
|
||||
}
|
||||
|
||||
// ── Registration ─────────────────────────────────────────
|
||||
|
||||
export async function createRegistrationOptions(
|
||||
@@ -294,10 +269,14 @@ export async function verifyAuthentication(
|
||||
});
|
||||
|
||||
if (verification.verified) {
|
||||
// Update counter
|
||||
// Update counter + stamp last-used so the management UI can surface
|
||||
// when each passkey was last exercised (v1.23 passkey QoL).
|
||||
await prisma.passkey.update({
|
||||
where: { id: passkey.id },
|
||||
data: { counter: BigInt(verification.authenticationInfo.newCounter) },
|
||||
data: {
|
||||
counter: BigInt(verification.authenticationInfo.newCounter),
|
||||
lastUsedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Shared WebAuthn relying-party configuration.
|
||||
*
|
||||
* Both the passwordless-primary passkey path (`src/lib/auth/passkey.ts`) and
|
||||
* the second-factor security-key path (`src/lib/auth/mfa/webauthn.ts`) resolve
|
||||
* the same relying-party id / origin from the deployment's configured app URL,
|
||||
* so the two ceremonies always agree on the RP they bind credentials to.
|
||||
*
|
||||
* The candidates mirror the rest of the app: `APP_URL` first, then the public
|
||||
* build-time URL, then a localhost fallback for dev. A multi-origin deployment
|
||||
* (rare for a single-tenant self-host, but supported) passes the whole set to
|
||||
* SimpleWebAuthn's `expectedOrigin`.
|
||||
*/
|
||||
|
||||
/** Human-readable relying-party name shown by the authenticator UI. */
|
||||
export const RP_NAME = "HealthLog";
|
||||
|
||||
export function getConfiguredOrigins(): string[] {
|
||||
const candidates = [
|
||||
process.env.APP_URL,
|
||||
process.env.NEXT_PUBLIC_APP_URL,
|
||||
"http://localhost:3000",
|
||||
]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
const validOrigins = candidates
|
||||
.map((value) => {
|
||||
try {
|
||||
return new URL(value).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
return Array.from(new Set(validOrigins));
|
||||
}
|
||||
|
||||
export function getRpId(): string {
|
||||
const origins = getConfiguredOrigins();
|
||||
return new URL(origins[0]).hostname;
|
||||
}
|
||||
|
||||
export function getExpectedOrigin(): string | string[] {
|
||||
const origins = getConfiguredOrigins();
|
||||
return origins.length === 1 ? origins[0] : origins;
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
mfaVerifySchema,
|
||||
totpConfirmSchema,
|
||||
mfaDisableSchema,
|
||||
mfaWebauthnRegisterVerifySchema,
|
||||
mfaWebauthnRenameSchema,
|
||||
mfaWebauthnLoginOptionsSchema,
|
||||
mfaWebauthnLoginVerifySchema,
|
||||
} from "@/lib/validations/mfa";
|
||||
import { dataEnvelope, stdResponses, errorEnvelope } from "./shared";
|
||||
|
||||
@@ -92,9 +96,9 @@ const mfaRequiredEnvelope = z
|
||||
"Opaque, single-use, ~5-minute ticket to present to /api/auth/mfa/verify.",
|
||||
),
|
||||
methods: z
|
||||
.array(z.enum(["totp", "recovery"]))
|
||||
.array(z.enum(["totp", "recovery", "webauthn"]))
|
||||
.describe(
|
||||
"Second factors the account can complete the challenge with.",
|
||||
"Second factors the account can complete the challenge with. `webauthn` is completed via /api/auth/mfa/webauthn/verify; the rest via /api/auth/mfa/verify.",
|
||||
),
|
||||
}),
|
||||
})
|
||||
@@ -184,6 +188,39 @@ const securityActivityResponse = z
|
||||
})
|
||||
.meta({ id: "SecurityActivityResponse" });
|
||||
|
||||
// ── v1.23 WebAuthn second-factor + status shapes ─────────────────────
|
||||
|
||||
const webauthnCredentialInfo = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
lastUsedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
})
|
||||
.meta({ id: "WebauthnMfaCredentialInfo" });
|
||||
|
||||
const mfaStatusResponse = z
|
||||
.object({
|
||||
totp: z.object({ enabled: z.boolean() }),
|
||||
recoveryCodesRemaining: z.number().int(),
|
||||
webauthn: z.array(webauthnCredentialInfo),
|
||||
passkeyNudgeDismissed: z.boolean(),
|
||||
})
|
||||
.meta({ id: "MfaStatusResponse" });
|
||||
|
||||
const webauthnOptionsResponse = z
|
||||
.object({
|
||||
options: z
|
||||
.record(z.string(), z.unknown())
|
||||
.describe("SimpleWebAuthn options to pass to the browser ceremony."),
|
||||
challengeId: z.string().describe("Server-issued challenge id."),
|
||||
})
|
||||
.meta({ id: "WebauthnOptionsResponse" });
|
||||
|
||||
const successFlagResponse = z
|
||||
.object({ success: z.boolean() })
|
||||
.meta({ id: "AuthSuccessFlagResponse" });
|
||||
|
||||
export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/auth/login": {
|
||||
post: {
|
||||
@@ -327,6 +364,176 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/me/mfa": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
summary: "Second-factor status (cookie session only)",
|
||||
description:
|
||||
"Whether TOTP is active, how many recovery codes remain, and the registered WebAuthn security keys. Metadata only — no secret, code, or public key. Cookie-only.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Second-factor status.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(mfaStatusResponse, "MfaStatusEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/me/mfa/webauthn/register/options": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
summary: "Begin registering a security key as a second factor",
|
||||
description:
|
||||
"Returns SimpleWebAuthn creation options + a challenge id. Cookie-only — a Bearer token cannot enrol MFA.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Registration options issued.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
webauthnOptionsResponse,
|
||||
"MfaWebauthnRegisterOptionsEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/me/mfa/webauthn/register/verify": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
summary: "Finish registering a security key as a second factor",
|
||||
description:
|
||||
"Verifies the attestation against the user-bound challenge and stores the credential in the second-factor store (separate from primary passkeys). Cookie-only.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: mfaWebauthnRegisterVerifySchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Security key registered.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
webauthnCredentialInfo,
|
||||
"MfaWebauthnRegisterVerifyEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/me/mfa/webauthn/{id}": {
|
||||
patch: {
|
||||
tags: ["Auth"],
|
||||
summary: "Rename a registered security key",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: mfaWebauthnRenameSchema } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Security key renamed.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
webauthnCredentialInfo,
|
||||
"MfaWebauthnRenameEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Auth"],
|
||||
summary: "Remove a registered security key (step-up gated)",
|
||||
description:
|
||||
"Requires a fresh second-factor step-up (cookie session). Bearer can never satisfy the gate.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Security key removed.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
successFlagResponse,
|
||||
"MfaWebauthnRemoveEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/mfa/webauthn/verify/options": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
summary: "Begin a mid-login security-key assertion",
|
||||
description:
|
||||
"Presents the login `mfaTicket` and returns assertion options scoped to the password-identified user's registered security keys. Anonymous surface; rate-limited.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: mfaWebauthnLoginOptionsSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assertion options issued.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
webauthnOptionsResponse,
|
||||
"MfaWebauthnLoginOptionsEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/mfa/webauthn/verify": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
summary: "Complete a security-key second-factor login challenge",
|
||||
description:
|
||||
"Presents the login `mfaTicket` plus the assertion. On success returns the SAME token bundle / session the password path issues, with the session marked second-factor-verified. The ticket is single-use; failures are throttled and the ticket is burned at the attempt cap.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: mfaWebauthnLoginVerifySchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Second factor verified — session + optional bearer issued.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
accessRefreshBundle,
|
||||
"MfaWebauthnVerifyResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/passkey/login-verify": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -48,6 +48,14 @@ export const authKeys = {
|
||||
*/
|
||||
securityActivity: () => ["auth", "security-activity"] as const,
|
||||
|
||||
/**
|
||||
* v1.23 — the security settings hub reads second-factor status (TOTP
|
||||
* enabled, recovery codes remaining, registered security keys, passkey-nudge
|
||||
* dismissal) from `GET /api/auth/me/mfa`. Mutations that change a factor
|
||||
* invalidate this key so the hub reflects the new state immediately.
|
||||
*/
|
||||
mfaStatus: () => ["auth", "mfa", "status"] as const,
|
||||
|
||||
userAiProvider: () => ["user", "ai-provider"] as const,
|
||||
userProfile: () => ["user", "profile"] as const,
|
||||
/**
|
||||
|
||||
@@ -48,3 +48,66 @@ export const mfaVerifySchema = z
|
||||
description:
|
||||
"Complete a second-factor login challenge and receive the same token bundle the password path issues.",
|
||||
});
|
||||
|
||||
// ── WebAuthn security key (second factor) ────────────────────────────
|
||||
|
||||
/**
|
||||
* A SimpleWebAuthn-style ceremony response. Kept permissive at the wire
|
||||
* boundary — the cryptographic validation lives inside
|
||||
* `@simplewebauthn/server`; this just pins the envelope shape for OpenAPI and
|
||||
* the runtime `safeParse`.
|
||||
*/
|
||||
const webauthnCredentialSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
rawId: z.string(),
|
||||
type: z.literal("public-key"),
|
||||
response: z.record(z.string(), z.unknown()),
|
||||
authenticatorAttachment: z.enum(["platform", "cross-platform"]).optional(),
|
||||
clientExtensionResults: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.describe("SimpleWebAuthn ceremony response payload.");
|
||||
|
||||
/** A human label for a registered security key (settings + rename). */
|
||||
export const webauthnKeyNameSchema = z.string().trim().min(1).max(64);
|
||||
|
||||
/** Register a security key as a second factor (cookie session, settings). */
|
||||
export const mfaWebauthnRegisterVerifySchema = z
|
||||
.object({
|
||||
challengeId: z.string().min(1),
|
||||
credential: webauthnCredentialSchema,
|
||||
name: webauthnKeyNameSchema.optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "MfaWebauthnRegisterVerifyRequest",
|
||||
description:
|
||||
"Finish registering a WebAuthn security key as a second factor.",
|
||||
});
|
||||
|
||||
/** Rename a registered security key. */
|
||||
export const mfaWebauthnRenameSchema = z
|
||||
.object({ name: webauthnKeyNameSchema })
|
||||
.meta({ id: "MfaWebauthnRenameRequest" });
|
||||
|
||||
/** Begin a mid-login security-key assertion: present the login MFA ticket. */
|
||||
export const mfaWebauthnLoginOptionsSchema = z
|
||||
.object({
|
||||
mfaTicket: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("The opaque single-use ticket from the login response."),
|
||||
})
|
||||
.meta({ id: "MfaWebauthnLoginOptionsRequest" });
|
||||
|
||||
/** Complete a mid-login security-key assertion. */
|
||||
export const mfaWebauthnLoginVerifySchema = z
|
||||
.object({
|
||||
mfaTicket: z.string().min(1),
|
||||
challengeId: z.string().min(1),
|
||||
credential: webauthnCredentialSchema,
|
||||
})
|
||||
.meta({
|
||||
id: "MfaWebauthnLoginVerifyRequest",
|
||||
description:
|
||||
"Complete a second-factor login challenge with a WebAuthn security key and receive the same token bundle the password path issues.",
|
||||
});
|
||||
Reference in new issue
Block a user