mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge branch 'feat/v1462-auth' into release/v1.46.2
This commit is contained in:
commit
d0be3ae106
74 files changed
+3645
-901
No files matched your search
+149
-26
@@ -2481,10 +2481,13 @@ paths:
|
||||
|
||||
The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`,
|
||||
`webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second
|
||||
factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422
|
||||
`auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also
|
||||
refreshes the session's second-factor stamp, exactly as signing in again would. Assertions begin at POST
|
||||
/api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.
|
||||
factor; on one without, `password` or `passkey`. An account without a second factor whose identity is linked to
|
||||
the configured identity provider is also offered `oidc` in `meta.methods`; that proof is not posted here but
|
||||
given by the browser round trip GET /api/auth/oidc/reproof, which stamps the session exactly as a password proof
|
||||
does. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`,
|
||||
before any guess is spent. A second-factor or passkey proof also refreshes the session's second-factor stamp,
|
||||
exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method:
|
||||
"passkey" | "webauthn" }`.
|
||||
|
||||
|
||||
Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that
|
||||
@@ -4077,7 +4080,9 @@ paths:
|
||||
|
||||
**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a
|
||||
password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options
|
||||
issued. On an account that already has a second factor a password is refused before it is checked, with 422
|
||||
issued. An account confirmed at its identity provider (GET /api/auth/oidc/reproof, within five minutes) sends `{
|
||||
method: "oidc" }` instead, which takes that re-proof as the proof and, like a password, clears the second-factor
|
||||
stamp. On an account that already has a second factor a password is refused before it is checked, with 422
|
||||
`auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone
|
||||
would sign in without the second factor from then on.
|
||||
|
||||
@@ -4103,6 +4108,7 @@ paths:
|
||||
schema:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/PasskeyReauthRequest"
|
||||
- $ref: "#/components/schemas/PasskeyProviderProofRequest"
|
||||
- $ref: "#/components/schemas/StepUpMintRequest"
|
||||
responses:
|
||||
"200":
|
||||
@@ -13217,12 +13223,19 @@ paths:
|
||||
- in: query
|
||||
name: locale
|
||||
schema:
|
||||
description: Optional UI-locale override; defaults to the session locale.
|
||||
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
|
||||
type: string
|
||||
enum:
|
||||
- de
|
||||
- en
|
||||
description: Optional UI-locale override; defaults to the session locale.
|
||||
- fr
|
||||
- es
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
- pt-BR
|
||||
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
|
||||
responses:
|
||||
"200":
|
||||
description: Assessment envelope (fresh, cached, or preparing).
|
||||
@@ -13249,12 +13262,19 @@ paths:
|
||||
- in: query
|
||||
name: locale
|
||||
schema:
|
||||
description: Optional UI-locale override; defaults to the session locale.
|
||||
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
|
||||
type: string
|
||||
enum:
|
||||
- de
|
||||
- en
|
||||
description: Optional UI-locale override; defaults to the session locale.
|
||||
- fr
|
||||
- es
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
- pt-BR
|
||||
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
|
||||
responses:
|
||||
"200":
|
||||
description: Assessment envelope (fresh, cached, or preparing).
|
||||
@@ -13281,12 +13301,19 @@ paths:
|
||||
- in: query
|
||||
name: locale
|
||||
schema:
|
||||
description: Optional UI-locale override; defaults to the session locale.
|
||||
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
|
||||
type: string
|
||||
enum:
|
||||
- de
|
||||
- en
|
||||
description: Optional UI-locale override; defaults to the session locale.
|
||||
- fr
|
||||
- es
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
- pt-BR
|
||||
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
|
||||
responses:
|
||||
"200":
|
||||
description: Assessment envelope (fresh, cached, or preparing).
|
||||
@@ -13313,12 +13340,19 @@ paths:
|
||||
- in: query
|
||||
name: locale
|
||||
schema:
|
||||
description: Optional UI-locale override; defaults to the session locale.
|
||||
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
|
||||
type: string
|
||||
enum:
|
||||
- de
|
||||
- en
|
||||
description: Optional UI-locale override; defaults to the session locale.
|
||||
- fr
|
||||
- es
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
- pt-BR
|
||||
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
|
||||
responses:
|
||||
"200":
|
||||
description: Assessment envelope (fresh, cached, or preparing).
|
||||
@@ -13345,12 +13379,19 @@ paths:
|
||||
- in: query
|
||||
name: locale
|
||||
schema:
|
||||
description: Optional UI-locale override; defaults to the session locale.
|
||||
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
|
||||
type: string
|
||||
enum:
|
||||
- de
|
||||
- en
|
||||
description: Optional UI-locale override; defaults to the session locale.
|
||||
- fr
|
||||
- es
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
- pt-BR
|
||||
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
|
||||
responses:
|
||||
"200":
|
||||
description: Assessment envelope (fresh, cached, or preparing).
|
||||
@@ -13378,12 +13419,19 @@ paths:
|
||||
- in: query
|
||||
name: locale
|
||||
schema:
|
||||
description: Optional UI-locale override; defaults to the session locale.
|
||||
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
|
||||
type: string
|
||||
enum:
|
||||
- de
|
||||
- en
|
||||
description: Optional UI-locale override; defaults to the session locale.
|
||||
- fr
|
||||
- es
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
- pt-BR
|
||||
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
|
||||
responses:
|
||||
"200":
|
||||
description: Compliance assessment envelope (fresh, cached, or preparing).
|
||||
@@ -13473,12 +13521,19 @@ paths:
|
||||
- in: query
|
||||
name: locale
|
||||
schema:
|
||||
description: Optional UI-locale override; defaults to the session locale.
|
||||
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
|
||||
type: string
|
||||
enum:
|
||||
- de
|
||||
- en
|
||||
description: Optional UI-locale override; defaults to the session locale.
|
||||
- fr
|
||||
- es
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
- pt-BR
|
||||
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
|
||||
responses:
|
||||
"200":
|
||||
description: Assessment envelope (fresh, cached, or preparing).
|
||||
@@ -13518,12 +13573,19 @@ paths:
|
||||
- in: query
|
||||
name: locale
|
||||
schema:
|
||||
description: Optional UI-locale override; defaults to the session locale.
|
||||
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
|
||||
type: string
|
||||
enum:
|
||||
- de
|
||||
- en
|
||||
description: Optional UI-locale override; defaults to the session locale.
|
||||
- fr
|
||||
- es
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
- pt-BR
|
||||
description: Optional UI-locale override, any language the app ships; defaults to the session locale.
|
||||
responses:
|
||||
"200":
|
||||
description: Assessment envelope (fresh, cached, or preparing).
|
||||
@@ -28540,6 +28602,13 @@ components:
|
||||
enum:
|
||||
- de
|
||||
- en
|
||||
- fr
|
||||
- es
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
- pt-BR
|
||||
- type: "null"
|
||||
timezone:
|
||||
type: string
|
||||
@@ -28618,6 +28687,17 @@ components:
|
||||
description: The re-proof arm of register-options. Recognised ONLY when `method` is the body's single key — adding any
|
||||
other property routes the request to the enrollment arm, where it will be read as a factor proof and almost
|
||||
certainly refused.
|
||||
PasskeyProviderProofRequest:
|
||||
type: object
|
||||
properties:
|
||||
method:
|
||||
type: string
|
||||
const: oidc
|
||||
required:
|
||||
- method
|
||||
description: Take the session's recent identity-provider re-proof (GET /api/auth/oidc/reproof) as the proof. Accepted
|
||||
only for an account whose `proofMethods` list `oidc` and only within five minutes of that re-proof; otherwise
|
||||
401 `auth.reproof.required` with `meta.methods`. Web only.
|
||||
PasskeyRegisterVerifyRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -31354,8 +31434,15 @@ components:
|
||||
locale:
|
||||
type: string
|
||||
enum:
|
||||
- en
|
||||
- de
|
||||
- en
|
||||
- fr
|
||||
- es
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
- pt-BR
|
||||
scope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -31518,8 +31605,15 @@ components:
|
||||
locale:
|
||||
type: string
|
||||
enum:
|
||||
- en
|
||||
- de
|
||||
- en
|
||||
- fr
|
||||
- es
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
- pt-BR
|
||||
attachmentIds:
|
||||
minItems: 1
|
||||
maxItems: 5
|
||||
@@ -35509,8 +35603,15 @@ components:
|
||||
locale:
|
||||
type: string
|
||||
enum:
|
||||
- en
|
||||
- de
|
||||
- en
|
||||
- fr
|
||||
- es
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
- pt-BR
|
||||
required:
|
||||
- message
|
||||
InboundFactEditRequest:
|
||||
@@ -38533,8 +38634,9 @@ components:
|
||||
`upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each
|
||||
`/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a
|
||||
credential of its own carries the generic `http.unauthenticated`. account —
|
||||
account.deletion.no_guardian, account.deletion.not_pending; activity — activity.calendar.future_date,
|
||||
activity.calendar.too_old; ai — ai.budget.exceeded, ai.provider.none, ai.record.notPermitted,
|
||||
account.deletion.already_pending, account.deletion.no_guardian, account.deletion.not_pending; activity —
|
||||
activity.calendar.future_date, activity.calendar.too_old; admin — admin.users.delete.managed_profile,
|
||||
admin.users.delete.self; ai — ai.budget.exceeded, ai.provider.none, ai.record.notPermitted,
|
||||
ai.unavailable, ai.usage.aboveCeiling, ai.usage.defaultAboveCeiling, ai.usage.invalid,
|
||||
ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider — ai_provider.invalid, ai_provider.no_fields;
|
||||
aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.readerBusy, aiRuns.timedOut, aiRuns.workerUnavailable;
|
||||
@@ -47239,11 +47341,25 @@ components:
|
||||
$ref: "#/components/schemas/WebauthnMfaCredentialInfo"
|
||||
passkeyNudgeDismissed:
|
||||
type: boolean
|
||||
proofMethods:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- password
|
||||
- oidc
|
||||
- totp
|
||||
- webauthn
|
||||
- passkey
|
||||
description: The ways this account can confirm it is you, the same list a recent-proof refusal names in `meta.methods`.
|
||||
`password` and `oidc` only on an account without a second factor; `oidc` is a browser round trip to the
|
||||
identity provider (`GET /api/auth/oidc/reproof`) and is never offered to a token.
|
||||
required:
|
||||
- totp
|
||||
- recoveryCodesRemaining
|
||||
- webauthn
|
||||
- passkeyNudgeDismissed
|
||||
- proofMethods
|
||||
additionalProperties: false
|
||||
WebauthnMfaCredentialInfo:
|
||||
type: object
|
||||
@@ -86337,6 +86453,12 @@ components:
|
||||
type: string
|
||||
username:
|
||||
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)))$
|
||||
description: When the signed-in account was created. The web greets an account in its first day as new rather than
|
||||
welcoming it back.
|
||||
timezone:
|
||||
type: string
|
||||
description: "The IANA zone the server cuts this account's days in: the stored zone, or the instance default when the
|
||||
@@ -86412,6 +86534,7 @@ components:
|
||||
required:
|
||||
- id
|
||||
- username
|
||||
- createdAt
|
||||
- timezone
|
||||
- glucoseUnit
|
||||
- features
|
||||
|
||||
+28
-5
@@ -789,7 +789,10 @@
|
||||
"title": "Wiederherstellungs-Kit sichern",
|
||||
"body": "Ohne Kopien der Schlüssel, die dieser Server nutzt, lassen sich seine Daten nach einer Neuinstallation nicht wiederherstellen. Das dauert nur ein paar Minuten.",
|
||||
"action": "Kit öffnen"
|
||||
}
|
||||
},
|
||||
"greetingOnly": "{greeting}.",
|
||||
"welcomeNew": "Willkommen.",
|
||||
"welcomeNewWithName": "Willkommen, {name}."
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Messungen",
|
||||
@@ -6237,14 +6240,17 @@
|
||||
"password": "Passwort",
|
||||
"totp": "Authenticator-Code",
|
||||
"passkey": "Passkey",
|
||||
"webauthn": "Sicherheitsschlüssel"
|
||||
"webauthn": "Sicherheitsschlüssel",
|
||||
"oidc": "Identitätsanbieter"
|
||||
},
|
||||
"currentPassword": "Aktuelles Passwort",
|
||||
"authenticatorCode": "Authenticator-Code",
|
||||
"authenticatorPlaceholder": "Sechsstelliger Authenticator-Code",
|
||||
"verifyAndAdd": "Bestätigen und hinzufügen",
|
||||
"factorDescription": "Bestätige eine vorhandene Anmeldemethode, bevor du einen zweiten Faktor hinzufügst.",
|
||||
"verifyAndContinue": "Bestätigen und fortfahren"
|
||||
"verifyAndContinue": "Bestätigen und fortfahren",
|
||||
"oidcHint": "Du meldest dich bei deinem Identitätsanbieter noch einmal an und kommst direkt auf diese Seite zurück. Dann wiederholst du, was du gerade tun wolltest.",
|
||||
"continueToProvider": "Weiter zur Anmeldung"
|
||||
},
|
||||
"passkeyName": "Name",
|
||||
"noPasskeys": "Keine Passkeys registriert.",
|
||||
@@ -7517,7 +7523,9 @@
|
||||
"description": "Damit werden deine Gesundheitsdaten weitergegeben oder exportiert. Bestätige, dass du es bist, um fortzufahren.",
|
||||
"descriptionChange": "Das ändert etwas, das nur du ändern können solltest. Bestätige, dass du es bist, um fortzufahren.",
|
||||
"signInAgain": "Melde dich neu an und versuche es dann noch einmal. Dein Konto hat kein Passwort und keinen Passkey, den du hier bestätigen könntest.",
|
||||
"failed": "Das hat nicht geklappt. Prüfe deine Eingabe und versuche es noch einmal."
|
||||
"failed": "Das hat nicht geklappt. Prüfe deine Eingabe und versuche es noch einmal.",
|
||||
"oidcDone": "Bestätigt. In den nächsten fünf Minuten kannst du weitermachen, ohne erneut gefragt zu werden.",
|
||||
"oidcFailed": "Dein Identitätsanbieter konnte nicht bestätigen, dass du es bist. Versuche es noch einmal."
|
||||
},
|
||||
"passwordTitle": "Passwort",
|
||||
"tokenNameLabel": "Token-Name",
|
||||
@@ -8105,7 +8113,16 @@
|
||||
"undoDeletionBody": "{name} hat die Löschung dieses Kontos verlangt; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Konto und alles darin so, wie es war. {name} kann sich wieder anmelden, aber API-Tokens, Freigabe-Links und vertrauenswürdige Geräte bleiben widerrufen und müssen neu eingerichtet werden.",
|
||||
"undoDeletionBodyGuardian": "Eine betreuende Person hat das verwaltete Profil {name} gelöscht; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Profil und alles darin so, wie es war, und die betreuenden Personen erreichen es wieder. Seine Freigabe-Links und Tokens bleiben widerrufen und müssen neu eingerichtet werden.",
|
||||
"undoDeletionDone": "Löschung von {name} rückgängig gemacht",
|
||||
"undoDeletionFailed": "Löschung konnte nicht rückgängig gemacht werden"
|
||||
"undoDeletionFailed": "Löschung konnte nicht rückgängig gemacht werden",
|
||||
"undoDeletionBodyAdmin": "Ein Admin hat das Konto {name} gelöscht; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Konto und alles darin so, wie es war. {name} kann sich wieder anmelden, aber API-Tokens, Freigabe-Links und vertrauenswürdige Geräte bleiben widerrufen und müssen neu eingerichtet werden.",
|
||||
"deleteUser": "Konto löschen",
|
||||
"deleteUserTitle": "Das Konto {name} löschen?",
|
||||
"deleteUserBodyGrace": "{name} wird sofort überall abgemeldet und kann sich nicht mehr anmelden. Das Konto und alles darin wird am {date} gelöscht. Bis dahin kannst du die Löschung hier rückgängig machen.",
|
||||
"deleteUserBodyNow": "{name} und alles im Konto wird sofort gelöscht. Dieser Server hat keine Frist eingestellt, die Löschung lässt sich also nicht rückgängig machen.",
|
||||
"deleteUserConfirm": "Konto löschen",
|
||||
"deleteUserScheduled": "Das Konto {name} ist gesperrt und wird am {date} gelöscht",
|
||||
"deleteUserDone": "Das Konto {name} wurde gelöscht",
|
||||
"deleteUserFailed": "Das Konto konnte nicht gelöscht werden"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Einladungen",
|
||||
@@ -11250,6 +11267,12 @@
|
||||
},
|
||||
"sosVozAmiga": {
|
||||
"name": "SOS Voz Amiga (Portugal, täglich 15:30 bis 0:30 Uhr)"
|
||||
},
|
||||
"zelfmoordpreventie113": {
|
||||
"name": "113 Zelfmoordpreventie (Niederlande, rund um die Uhr, kostenlos, anonym, auch per Chat)"
|
||||
},
|
||||
"zelfmoordlijn1813": {
|
||||
"name": "Zelfmoordlijn 1813 (Belgien, rund um die Uhr, kostenlos, anonym)"
|
||||
}
|
||||
},
|
||||
"instrumentDescription": {
|
||||
|
||||
+28
-5
@@ -789,7 +789,10 @@
|
||||
"title": "Save your recovery kit",
|
||||
"body": "Without copies of the keys this server uses, its data cannot be recovered after a reinstall. It takes a few minutes.",
|
||||
"action": "Open the kit"
|
||||
}
|
||||
},
|
||||
"greetingOnly": "{greeting}.",
|
||||
"welcomeNew": "Welcome.",
|
||||
"welcomeNewWithName": "Welcome, {name}."
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Measurements",
|
||||
@@ -6237,14 +6240,17 @@
|
||||
"password": "Password",
|
||||
"totp": "Authenticator code",
|
||||
"passkey": "Passkey",
|
||||
"webauthn": "Security key"
|
||||
"webauthn": "Security key",
|
||||
"oidc": "Identity provider"
|
||||
},
|
||||
"currentPassword": "Current password",
|
||||
"authenticatorCode": "Authenticator code",
|
||||
"authenticatorPlaceholder": "6-digit authenticator code",
|
||||
"verifyAndAdd": "Verify and add",
|
||||
"factorDescription": "Verify an existing sign-in method before adding a second factor.",
|
||||
"verifyAndContinue": "Verify and continue"
|
||||
"verifyAndContinue": "Verify and continue",
|
||||
"oidcHint": "You sign in once more at your identity provider and come straight back to this page. Then do what you were about to do again.",
|
||||
"continueToProvider": "Continue to sign-in"
|
||||
},
|
||||
"passkeyName": "Name",
|
||||
"noPasskeys": "No passkeys registered.",
|
||||
@@ -7517,7 +7523,9 @@
|
||||
"description": "This shares or exports your health data. Confirm it's you to continue.",
|
||||
"descriptionChange": "This changes something only you should be able to change. Confirm it's you to continue.",
|
||||
"signInAgain": "Sign in again, then try once more. Your account has no password or passkey to confirm here.",
|
||||
"failed": "That did not work. Check what you entered and try again."
|
||||
"failed": "That did not work. Check what you entered and try again.",
|
||||
"oidcDone": "Confirmed. For the next five minutes you can go ahead without being asked again.",
|
||||
"oidcFailed": "Your identity provider could not confirm it's you. Try again."
|
||||
},
|
||||
"passwordTitle": "Password",
|
||||
"tokenNameLabel": "Token name",
|
||||
@@ -8105,7 +8113,16 @@
|
||||
"undoDeletionBody": "{name} asked to delete this account, and it is erased when the grace window ends. Undoing keeps the account and everything in it as it was. {name} can sign in again, but API tokens, share links and trusted devices stay revoked and have to be set up again.",
|
||||
"undoDeletionBodyGuardian": "A guardian deleted the managed profile {name}, and it is erased when the grace window ends. Undoing keeps the profile and everything in it as it was, and its guardians can reach it again. Its share links and tokens stay revoked and have to be set up again.",
|
||||
"undoDeletionDone": "Deletion undone for {name}",
|
||||
"undoDeletionFailed": "Could not undo the deletion"
|
||||
"undoDeletionFailed": "Could not undo the deletion",
|
||||
"undoDeletionBodyAdmin": "An admin deleted the account {name}, and it is erased when the grace window ends. Undoing keeps the account and everything in it as it was. {name} can sign in again, but API tokens, share links and trusted devices stay revoked and have to be set up again.",
|
||||
"deleteUser": "Delete account",
|
||||
"deleteUserTitle": "Delete the account {name}?",
|
||||
"deleteUserBodyGrace": "{name} is signed out everywhere at once and can no longer sign in. The account and everything in it is erased on {date}. Until then you can undo the deletion here.",
|
||||
"deleteUserBodyNow": "{name} and everything in the account is erased right away. This server keeps no grace window, so the deletion cannot be undone.",
|
||||
"deleteUserConfirm": "Delete account",
|
||||
"deleteUserScheduled": "The account {name} is locked and will be erased on {date}",
|
||||
"deleteUserDone": "The account {name} was deleted",
|
||||
"deleteUserFailed": "Could not delete the account"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Invites",
|
||||
@@ -11250,6 +11267,12 @@
|
||||
},
|
||||
"sosVozAmiga": {
|
||||
"name": "SOS Voz Amiga (Portugal, daily 3.30 pm to 12.30 am)"
|
||||
},
|
||||
"zelfmoordpreventie113": {
|
||||
"name": "113 Zelfmoordpreventie (Netherlands, 24/7, free, anonymous, also by chat)"
|
||||
},
|
||||
"zelfmoordlijn1813": {
|
||||
"name": "Zelfmoordlijn 1813 (Belgium, 24/7, free, anonymous)"
|
||||
}
|
||||
},
|
||||
"instrumentDescription": {
|
||||
|
||||
+28
-5
@@ -789,7 +789,10 @@
|
||||
"title": "Guarda tu kit de recuperación",
|
||||
"body": "Sin copias de las claves que usa este servidor, sus datos no se pueden recuperar tras una reinstalación. Es cuestión de unos minutos.",
|
||||
"action": "Abrir el kit"
|
||||
}
|
||||
},
|
||||
"greetingOnly": "{greeting}.",
|
||||
"welcomeNew": "Te damos la bienvenida.",
|
||||
"welcomeNewWithName": "Te damos la bienvenida, {name}."
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Mediciones",
|
||||
@@ -6237,14 +6240,17 @@
|
||||
"password": "Contraseña",
|
||||
"totp": "Código del autenticador",
|
||||
"passkey": "Clave de acceso",
|
||||
"webauthn": "Llave de seguridad"
|
||||
"webauthn": "Llave de seguridad",
|
||||
"oidc": "Proveedor de identidad"
|
||||
},
|
||||
"currentPassword": "Contraseña actual",
|
||||
"authenticatorCode": "Código del autenticador",
|
||||
"authenticatorPlaceholder": "Código de seis dígitos del autenticador",
|
||||
"verifyAndAdd": "Verificar y añadir",
|
||||
"factorDescription": "Verifica un método de inicio de sesión existente antes de añadir un segundo factor.",
|
||||
"verifyAndContinue": "Verificar y continuar"
|
||||
"verifyAndContinue": "Verificar y continuar",
|
||||
"oidcHint": "Vuelves a iniciar sesión en tu proveedor de identidad y regresas directamente a esta página. Después repite lo que ibas a hacer.",
|
||||
"continueToProvider": "Continuar al inicio de sesión"
|
||||
},
|
||||
"passkeyName": "Nombre",
|
||||
"noPasskeys": "No hay claves de acceso registradas.",
|
||||
@@ -7517,7 +7523,9 @@
|
||||
"description": "Esto comparte o exporta tus datos de salud. Confirma que eres tú para continuar.",
|
||||
"descriptionChange": "Esto cambia algo que solo tú deberías poder cambiar. Confirma que eres tú para continuar.",
|
||||
"signInAgain": "Vuelve a iniciar sesión y inténtalo de nuevo. Tu cuenta no tiene contraseña ni llave de acceso que confirmar aquí.",
|
||||
"failed": "No ha funcionado. Revisa lo que has introducido e inténtalo de nuevo."
|
||||
"failed": "No ha funcionado. Revisa lo que has introducido e inténtalo de nuevo.",
|
||||
"oidcDone": "Confirmado. Durante los próximos cinco minutos puedes continuar sin que se te vuelva a preguntar.",
|
||||
"oidcFailed": "Tu proveedor de identidad no pudo confirmar que eres tú. Inténtalo de nuevo."
|
||||
},
|
||||
"passwordTitle": "Contraseña",
|
||||
"tokenNameLabel": "Nombre del token",
|
||||
@@ -8105,7 +8113,16 @@
|
||||
"undoDeletionBody": "{name} pidió eliminar esta cuenta, y se borra cuando termina el plazo de gracia. Deshacerlo conserva la cuenta y todo su contenido tal como estaba. {name} puede volver a iniciar sesión, pero los tokens de API, los enlaces compartidos y los dispositivos de confianza siguen revocados y hay que configurarlos de nuevo.",
|
||||
"undoDeletionBodyGuardian": "Una persona responsable eliminó el perfil gestionado {name}, y se borra cuando termina el plazo de gracia. Deshacerlo conserva el perfil y todo su contenido tal como estaba, y las personas responsables vuelven a tener acceso. Sus enlaces compartidos y tokens siguen revocados y hay que configurarlos de nuevo.",
|
||||
"undoDeletionDone": "Eliminación de {name} deshecha",
|
||||
"undoDeletionFailed": "No se pudo deshacer la eliminación"
|
||||
"undoDeletionFailed": "No se pudo deshacer la eliminación",
|
||||
"undoDeletionBodyAdmin": "Un administrador eliminó la cuenta {name}, y se borra cuando termina el plazo de gracia. Deshacerlo conserva la cuenta y todo su contenido tal como estaba. {name} puede volver a iniciar sesión, pero los tokens de API, los enlaces compartidos y los dispositivos de confianza siguen revocados y hay que configurarlos de nuevo.",
|
||||
"deleteUser": "Eliminar cuenta",
|
||||
"deleteUserTitle": "¿Eliminar la cuenta {name}?",
|
||||
"deleteUserBodyGrace": "{name} cierra sesión en todas partes al instante y ya no puede iniciar sesión. La cuenta y todo su contenido se borran el {date}. Hasta entonces puedes deshacer la eliminación aquí.",
|
||||
"deleteUserBodyNow": "{name} y todo el contenido de la cuenta se borran de inmediato. Este servidor no tiene plazo de gracia, así que la eliminación no se puede deshacer.",
|
||||
"deleteUserConfirm": "Eliminar cuenta",
|
||||
"deleteUserScheduled": "La cuenta {name} está bloqueada y se borrará el {date}",
|
||||
"deleteUserDone": "Se eliminó la cuenta {name}",
|
||||
"deleteUserFailed": "No se pudo eliminar la cuenta"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Invitaciones",
|
||||
@@ -11250,6 +11267,12 @@
|
||||
},
|
||||
"sosVozAmiga": {
|
||||
"name": "SOS Voz Amiga (Portugal, todos los días de 15:30 a 0:30)"
|
||||
},
|
||||
"zelfmoordpreventie113": {
|
||||
"name": "113 Zelfmoordpreventie (Países Bajos, 24 horas, gratuito, anónimo, también por chat)"
|
||||
},
|
||||
"zelfmoordlijn1813": {
|
||||
"name": "Zelfmoordlijn 1813 (Bélgica, 24 horas, gratuito, anónimo)"
|
||||
}
|
||||
},
|
||||
"instrumentDescription": {
|
||||
|
||||
+529
-506
File diff suppressed because it is too large.
Load diff
+28
-5
@@ -789,7 +789,10 @@
|
||||
"title": "Salva il tuo kit di recupero",
|
||||
"body": "Senza copie delle chiavi usate da questo server, i suoi dati non si possono recuperare dopo una reinstallazione. Ci vogliono pochi minuti.",
|
||||
"action": "Apri il kit"
|
||||
}
|
||||
},
|
||||
"greetingOnly": "{greeting}.",
|
||||
"welcomeNew": "Ti diamo il benvenuto.",
|
||||
"welcomeNewWithName": "Ti diamo il benvenuto, {name}."
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Misurazioni",
|
||||
@@ -6237,14 +6240,17 @@
|
||||
"password": "Password",
|
||||
"totp": "Codice dell’app di autenticazione",
|
||||
"passkey": "Passkey",
|
||||
"webauthn": "Chiave di sicurezza"
|
||||
"webauthn": "Chiave di sicurezza",
|
||||
"oidc": "Provider di identità"
|
||||
},
|
||||
"currentPassword": "Password attuale",
|
||||
"authenticatorCode": "Codice dell’app di autenticazione",
|
||||
"authenticatorPlaceholder": "Codice a 6 cifre dell’app di autenticazione",
|
||||
"verifyAndAdd": "Verifica e aggiungi",
|
||||
"factorDescription": "Verifica un metodo di accesso esistente prima di aggiungere un secondo fattore.",
|
||||
"verifyAndContinue": "Verifica e continua"
|
||||
"verifyAndContinue": "Verifica e continua",
|
||||
"oidcHint": "Accedi di nuovo presso il tuo provider di identità e torni subito a questa pagina. Poi ripeti quello che stavi per fare.",
|
||||
"continueToProvider": "Continua all'accesso"
|
||||
},
|
||||
"passkeyName": "Nome",
|
||||
"noPasskeys": "Nessuna passkey registrata.",
|
||||
@@ -7517,7 +7523,9 @@
|
||||
"description": "Questa azione condivide o esporta i tuoi dati sanitari. Conferma che sei tu per continuare.",
|
||||
"descriptionChange": "Questo modifica qualcosa che solo tu dovresti poter modificare. Conferma che sei tu per continuare.",
|
||||
"signInAgain": "Accedi di nuovo, poi riprova. Il tuo account non ha una password o una passkey da confermare qui.",
|
||||
"failed": "Non ha funzionato. Controlla quanto inserito e riprova."
|
||||
"failed": "Non ha funzionato. Controlla quanto inserito e riprova.",
|
||||
"oidcDone": "Confermato. Per i prossimi cinque minuti puoi procedere senza che ti venga chiesto di nuovo.",
|
||||
"oidcFailed": "Il tuo provider di identità non ha potuto confermare che sei tu. Riprova."
|
||||
},
|
||||
"passwordTitle": "Password",
|
||||
"tokenNameLabel": "Nome del token",
|
||||
@@ -8105,7 +8113,16 @@
|
||||
"undoDeletionBody": "{name} ha chiesto di eliminare questo account, che viene cancellato alla fine del periodo di tolleranza. Annullare mantiene l’account e tutto il suo contenuto com’era. {name} può accedere di nuovo, ma i token API, i link di condivisione e i dispositivi attendibili restano revocati e vanno configurati di nuovo.",
|
||||
"undoDeletionBodyGuardian": "Una persona responsabile ha eliminato il profilo gestito {name}, che viene cancellato alla fine del periodo di tolleranza. Annullare mantiene il profilo e tutto il suo contenuto com’era, e le persone responsabili possono di nuovo accedervi. I suoi link di condivisione e token restano revocati e vanno configurati di nuovo.",
|
||||
"undoDeletionDone": "Eliminazione di {name} annullata",
|
||||
"undoDeletionFailed": "Impossibile annullare l’eliminazione"
|
||||
"undoDeletionFailed": "Impossibile annullare l’eliminazione",
|
||||
"undoDeletionBodyAdmin": "Un amministratore ha eliminato l’account {name}, che viene cancellato alla fine del periodo di tolleranza. Annullare mantiene l’account e tutto il suo contenuto com’era. {name} può accedere di nuovo, ma i token API, i link di condivisione e i dispositivi attendibili restano revocati e vanno configurati di nuovo.",
|
||||
"deleteUser": "Elimina account",
|
||||
"deleteUserTitle": "Eliminare l’account {name}?",
|
||||
"deleteUserBodyGrace": "{name} viene disconnesso ovunque subito e non può più accedere. L’account e tutto il suo contenuto vengono cancellati il {date}. Fino ad allora puoi annullare l’eliminazione da qui.",
|
||||
"deleteUserBodyNow": "{name} e tutto il contenuto dell’account vengono cancellati subito. Questo server non ha un periodo di tolleranza, quindi l’eliminazione non si può annullare.",
|
||||
"deleteUserConfirm": "Elimina account",
|
||||
"deleteUserScheduled": "L’account {name} è bloccato e verrà cancellato il {date}",
|
||||
"deleteUserDone": "L’account {name} è stato eliminato",
|
||||
"deleteUserFailed": "Impossibile eliminare l’account"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Inviti",
|
||||
@@ -11250,6 +11267,12 @@
|
||||
},
|
||||
"sosVozAmiga": {
|
||||
"name": "SOS Voz Amiga (Portogallo, tutti i giorni dalle 15:30 alle 0:30)"
|
||||
},
|
||||
"zelfmoordpreventie113": {
|
||||
"name": "113 Zelfmoordpreventie (Paesi Bassi, 24 ore su 24, gratuito, anonimo, anche in chat)"
|
||||
},
|
||||
"zelfmoordlijn1813": {
|
||||
"name": "Zelfmoordlijn 1813 (Belgio, 24 ore su 24, gratuito, anonimo)"
|
||||
}
|
||||
},
|
||||
"instrumentDescription": {
|
||||
|
||||
+28
-5
@@ -789,7 +789,10 @@
|
||||
"title": "복구 키트를 보관하세요",
|
||||
"body": "이 서버가 사용하는 키의 사본이 없으면 다시 설치한 뒤 데이터를 복구할 수 없습니다. 몇 분이면 됩니다.",
|
||||
"action": "키트 열기"
|
||||
}
|
||||
},
|
||||
"greetingOnly": "{greeting}.",
|
||||
"welcomeNew": "환영합니다.",
|
||||
"welcomeNewWithName": "{name}님, 환영합니다."
|
||||
},
|
||||
"measurements": {
|
||||
"title": "측정",
|
||||
@@ -6237,14 +6240,17 @@
|
||||
"password": "비밀번호",
|
||||
"totp": "인증 앱 코드",
|
||||
"passkey": "패스키",
|
||||
"webauthn": "보안 키"
|
||||
"webauthn": "보안 키",
|
||||
"oidc": "ID 공급자"
|
||||
},
|
||||
"currentPassword": "현재 비밀번호",
|
||||
"authenticatorCode": "인증 앱 코드",
|
||||
"authenticatorPlaceholder": "6자리 인증 앱 코드",
|
||||
"verifyAndAdd": "확인 후 추가",
|
||||
"factorDescription": "두 번째 인증 수단을 추가하기 전에 기존 로그인 방법을 확인하세요.",
|
||||
"verifyAndContinue": "확인하고 계속"
|
||||
"verifyAndContinue": "확인하고 계속",
|
||||
"oidcHint": "ID 공급자에서 한 번 더 로그인하면 바로 이 페이지로 돌아옵니다. 그런 다음 하려던 작업을 다시 진행하세요.",
|
||||
"continueToProvider": "로그인으로 계속"
|
||||
},
|
||||
"passkeyName": "이름",
|
||||
"noPasskeys": "등록된 패스키가 없어요.",
|
||||
@@ -7517,7 +7523,9 @@
|
||||
"description": "건강 데이터를 공유하거나 내보내는 작업입니다. 계속하려면 본인임을 확인하세요.",
|
||||
"descriptionChange": "본인만 변경할 수 있어야 하는 항목을 변경합니다. 계속하려면 본인임을 확인하세요.",
|
||||
"signInAgain": "다시 로그인한 후 한 번 더 시도하세요. 이 계정에는 여기서 확인할 비밀번호나 패스키가 없습니다.",
|
||||
"failed": "확인하지 못했습니다. 입력한 내용을 확인하고 다시 시도하세요."
|
||||
"failed": "확인하지 못했습니다. 입력한 내용을 확인하고 다시 시도하세요.",
|
||||
"oidcDone": "확인되었습니다. 앞으로 5분 동안은 다시 묻지 않고 계속할 수 있습니다.",
|
||||
"oidcFailed": "ID 공급자가 본인임을 확인하지 못했습니다. 다시 시도하세요."
|
||||
},
|
||||
"passwordTitle": "비밀번호",
|
||||
"tokenNameLabel": "토큰 이름",
|
||||
@@ -8080,7 +8088,16 @@
|
||||
"undoDeletionBody": "{name} 님이 이 계정의 삭제를 요청했고, 유예 기간이 끝나면 지워져요. 취소하면 계정과 그 안의 모든 내용이 그대로 남아요. {name} 님은 다시 로그인할 수 있지만, API 토큰, 공유 링크, 신뢰하는 기기는 계속 취소된 상태라 다시 설정해야 해요.",
|
||||
"undoDeletionBodyGuardian": "보호자가 관리 프로필 {name}을(를) 삭제했고, 유예 기간이 끝나면 지워져요. 취소하면 프로필과 그 안의 모든 내용이 그대로 남고, 보호자들이 다시 접근할 수 있어요. 공유 링크와 토큰은 계속 취소된 상태라 다시 설정해야 해요.",
|
||||
"undoDeletionDone": "{name} 삭제를 취소했어요",
|
||||
"undoDeletionFailed": "삭제를 취소하지 못했어요"
|
||||
"undoDeletionFailed": "삭제를 취소하지 못했어요",
|
||||
"undoDeletionBodyAdmin": "관리자가 {name} 계정을 삭제했고, 유예 기간이 끝나면 지워져요. 취소하면 계정과 그 안의 모든 내용이 그대로 남아요. {name} 님은 다시 로그인할 수 있지만, API 토큰, 공유 링크, 신뢰하는 기기는 계속 취소된 상태라 다시 설정해야 해요.",
|
||||
"deleteUser": "계정 삭제",
|
||||
"deleteUserTitle": "{name} 계정을 삭제할까요?",
|
||||
"deleteUserBodyGrace": "{name} 님은 즉시 모든 곳에서 로그아웃되고 더 이상 로그인할 수 없어요. 계정과 그 안의 모든 내용은 {date}에 지워져요. 그때까지는 여기서 삭제를 취소할 수 있어요.",
|
||||
"deleteUserBodyNow": "{name} 님과 계정 안의 모든 내용이 바로 지워져요. 이 서버에는 유예 기간이 없어서 삭제를 취소할 수 없어요.",
|
||||
"deleteUserConfirm": "계정 삭제",
|
||||
"deleteUserScheduled": "{name} 계정이 잠겼고 {date}에 지워져요",
|
||||
"deleteUserDone": "{name} 계정을 삭제했어요",
|
||||
"deleteUserFailed": "계정을 삭제하지 못했어요"
|
||||
},
|
||||
"invites": {
|
||||
"title": "초대",
|
||||
@@ -11250,6 +11267,12 @@
|
||||
},
|
||||
"sosVozAmiga": {
|
||||
"name": "SOS Voz Amiga (포르투갈, 매일 15:30~00:30)"
|
||||
},
|
||||
"zelfmoordpreventie113": {
|
||||
"name": "113 Zelfmoordpreventie (네덜란드, 24시간, 무료, 익명, 채팅 가능)"
|
||||
},
|
||||
"zelfmoordlijn1813": {
|
||||
"name": "Zelfmoordlijn 1813 (벨기에, 24시간, 무료, 익명)"
|
||||
}
|
||||
},
|
||||
"instrumentDescription": {
|
||||
|
||||
+28
-5
@@ -789,7 +789,10 @@
|
||||
"title": "Bewaar je herstelkit",
|
||||
"body": "Zonder kopieën van de sleutels die deze server gebruikt, kunnen de gegevens na een herinstallatie niet worden hersteld. Het kost een paar minuten.",
|
||||
"action": "Kit openen"
|
||||
}
|
||||
},
|
||||
"greetingOnly": "{greeting}.",
|
||||
"welcomeNew": "Welkom.",
|
||||
"welcomeNewWithName": "Welkom, {name}."
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Metingen",
|
||||
@@ -6237,14 +6240,17 @@
|
||||
"password": "Wachtwoord",
|
||||
"totp": "Authenticatorcode",
|
||||
"passkey": "Passkey",
|
||||
"webauthn": "Beveiligingssleutel"
|
||||
"webauthn": "Beveiligingssleutel",
|
||||
"oidc": "Identiteitsprovider"
|
||||
},
|
||||
"currentPassword": "Huidig wachtwoord",
|
||||
"authenticatorCode": "Authenticatorcode",
|
||||
"authenticatorPlaceholder": "Authenticatorcode van 6 cijfers",
|
||||
"verifyAndAdd": "Verifiëren en toevoegen",
|
||||
"factorDescription": "Verifieer een bestaande inlogmethode voordat je een tweede factor toevoegt.",
|
||||
"verifyAndContinue": "Verifiëren en doorgaan"
|
||||
"verifyAndContinue": "Verifiëren en doorgaan",
|
||||
"oidcHint": "Je logt nog een keer in bij je identiteitsprovider en komt meteen terug op deze pagina. Doe daarna opnieuw wat je wilde doen.",
|
||||
"continueToProvider": "Verder naar inloggen"
|
||||
},
|
||||
"passkeyName": "Naam",
|
||||
"noPasskeys": "Geen passkeys geregistreerd.",
|
||||
@@ -7517,7 +7523,9 @@
|
||||
"description": "Hiermee deel of exporteer je je gezondheidsgegevens. Bevestig dat jij het bent om door te gaan.",
|
||||
"descriptionChange": "Hiermee wijzig je iets wat alleen jij zou moeten kunnen wijzigen. Bevestig dat jij het bent om door te gaan.",
|
||||
"signInAgain": "Log opnieuw in en probeer het daarna nog een keer. Je account heeft geen wachtwoord of passkey waarmee je hier kunt bevestigen.",
|
||||
"failed": "Dat is niet gelukt. Controleer wat je hebt ingevoerd en probeer het opnieuw."
|
||||
"failed": "Dat is niet gelukt. Controleer wat je hebt ingevoerd en probeer het opnieuw.",
|
||||
"oidcDone": "Bevestigd. De komende vijf minuten kun je verder zonder dat het opnieuw wordt gevraagd.",
|
||||
"oidcFailed": "Je identiteitsprovider kon niet bevestigen dat jij het bent. Probeer het opnieuw."
|
||||
},
|
||||
"passwordTitle": "Wachtwoord",
|
||||
"tokenNameLabel": "Tokennaam",
|
||||
@@ -8105,7 +8113,16 @@
|
||||
"undoDeletionBody": "{name} heeft gevraagd dit account te verwijderen; het wordt gewist als de bedenktijd afloopt. Ongedaan maken houdt het account en alles erin zoals het was. {name} kan weer inloggen, maar API-tokens, deellinks en vertrouwde apparaten blijven ingetrokken en moeten opnieuw worden ingesteld.",
|
||||
"undoDeletionBodyGuardian": "Een verzorger heeft het beheerde profiel {name} verwijderd; het wordt gewist als de bedenktijd afloopt. Ongedaan maken houdt het profiel en alles erin zoals het was, en de verzorgers kunnen het weer bereiken. De deellinks en tokens blijven ingetrokken en moeten opnieuw worden ingesteld.",
|
||||
"undoDeletionDone": "Verwijdering van {name} ongedaan gemaakt",
|
||||
"undoDeletionFailed": "Verwijdering kon niet ongedaan worden gemaakt"
|
||||
"undoDeletionFailed": "Verwijdering kon niet ongedaan worden gemaakt",
|
||||
"undoDeletionBodyAdmin": "Een beheerder heeft het account {name} verwijderd; het wordt gewist als de bedenktijd afloopt. Ongedaan maken houdt het account en alles erin zoals het was. {name} kan weer inloggen, maar API-tokens, deellinks en vertrouwde apparaten blijven ingetrokken en moeten opnieuw worden ingesteld.",
|
||||
"deleteUser": "Account verwijderen",
|
||||
"deleteUserTitle": "Het account {name} verwijderen?",
|
||||
"deleteUserBodyGrace": "{name} wordt meteen overal uitgelogd en kan niet meer inloggen. Het account en alles erin wordt op {date} gewist. Tot dan kun je de verwijdering hier ongedaan maken.",
|
||||
"deleteUserBodyNow": "{name} en alles in het account wordt meteen gewist. Deze server heeft geen bedenktijd, dus de verwijdering kan niet ongedaan worden gemaakt.",
|
||||
"deleteUserConfirm": "Account verwijderen",
|
||||
"deleteUserScheduled": "Het account {name} is vergrendeld en wordt op {date} gewist",
|
||||
"deleteUserDone": "Het account {name} is verwijderd",
|
||||
"deleteUserFailed": "Het account kon niet worden verwijderd"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Uitnodigingen",
|
||||
@@ -11250,6 +11267,12 @@
|
||||
},
|
||||
"sosVozAmiga": {
|
||||
"name": "SOS Voz Amiga (Portugal, dagelijks van 15.30 tot 0.30 uur)"
|
||||
},
|
||||
"zelfmoordpreventie113": {
|
||||
"name": "113 Zelfmoordpreventie (Nederland, dag en nacht, gratis, anoniem, ook via chat)"
|
||||
},
|
||||
"zelfmoordlijn1813": {
|
||||
"name": "Zelfmoordlijn 1813 (België, dag en nacht, gratis, anoniem)"
|
||||
}
|
||||
},
|
||||
"instrumentDescription": {
|
||||
|
||||
+28
-5
@@ -789,7 +789,10 @@
|
||||
"title": "Zapisz zestaw odzyskiwania",
|
||||
"body": "Bez kopii kluczy, których używa ten serwer, jego danych nie da się odzyskać po ponownej instalacji. To zajmie kilka minut.",
|
||||
"action": "Otwórz zestaw"
|
||||
}
|
||||
},
|
||||
"greetingOnly": "{greeting}.",
|
||||
"welcomeNew": "Witaj.",
|
||||
"welcomeNewWithName": "Witaj, {name}."
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Pomiary",
|
||||
@@ -6237,14 +6240,17 @@
|
||||
"password": "Hasło",
|
||||
"totp": "Kod z aplikacji uwierzytelniającej",
|
||||
"passkey": "Klucz dostępu",
|
||||
"webauthn": "Klucz bezpieczeństwa"
|
||||
"webauthn": "Klucz bezpieczeństwa",
|
||||
"oidc": "Dostawca tożsamości"
|
||||
},
|
||||
"currentPassword": "Aktualne hasło",
|
||||
"authenticatorCode": "Kod z aplikacji uwierzytelniającej",
|
||||
"authenticatorPlaceholder": "6-cyfrowy kod z aplikacji uwierzytelniającej",
|
||||
"verifyAndAdd": "Zweryfikuj i dodaj",
|
||||
"factorDescription": "Potwierdź istniejącą metodę logowania, zanim dodasz drugi składnik.",
|
||||
"verifyAndContinue": "Potwierdź i kontynuuj"
|
||||
"verifyAndContinue": "Potwierdź i kontynuuj",
|
||||
"oidcHint": "Zalogujesz się jeszcze raz u swojego dostawcy tożsamości i wrócisz od razu na tę stronę. Potem powtórz to, co chcesz zrobić.",
|
||||
"continueToProvider": "Przejdź do logowania"
|
||||
},
|
||||
"passkeyName": "Nazwa",
|
||||
"noPasskeys": "Brak zarejestrowanych kluczy dostępu.",
|
||||
@@ -7517,7 +7523,9 @@
|
||||
"description": "Ta czynność udostępnia lub eksportuje Twoje dane zdrowotne. Potwierdź, że to Ty, aby kontynuować.",
|
||||
"descriptionChange": "To zmienia coś, co tylko Ty powinieneś móc zmienić. Potwierdź, że to Ty, aby kontynuować.",
|
||||
"signInAgain": "Zaloguj się ponownie, a potem spróbuj jeszcze raz. Twoje konto nie ma hasła ani klucza dostępu do potwierdzenia tutaj.",
|
||||
"failed": "To się nie udało. Sprawdź wprowadzone dane i spróbuj ponownie."
|
||||
"failed": "To się nie udało. Sprawdź wprowadzone dane i spróbuj ponownie.",
|
||||
"oidcDone": "Potwierdzono. Przez następne pięć minut możesz kontynuować bez ponownego pytania.",
|
||||
"oidcFailed": "Twój dostawca tożsamości nie mógł potwierdzić, że to Ty. Spróbuj ponownie."
|
||||
},
|
||||
"passwordTitle": "Hasło",
|
||||
"tokenNameLabel": "Nazwa tokena",
|
||||
@@ -8105,7 +8113,16 @@
|
||||
"undoDeletionBody": "{name} poprosił o usunięcie tego konta; zostanie ono usunięte po upływie okresu karencji. Cofnięcie zachowuje konto i całą jego zawartość bez zmian. {name} może znów się zalogować, ale tokeny API, linki udostępniania i zaufane urządzenia pozostają unieważnione i trzeba je skonfigurować od nowa.",
|
||||
"undoDeletionBodyGuardian": "Osoba opiekująca się profilem usunęła profil zarządzany {name}; zostanie on usunięty po upływie okresu karencji. Cofnięcie zachowuje profil i całą jego zawartość bez zmian, a osoby opiekujące się nim znów mają do niego dostęp. Jego linki udostępniania i tokeny pozostają unieważnione i trzeba je skonfigurować od nowa.",
|
||||
"undoDeletionDone": "Cofnięto usunięcie {name}",
|
||||
"undoDeletionFailed": "Nie udało się cofnąć usunięcia"
|
||||
"undoDeletionFailed": "Nie udało się cofnąć usunięcia",
|
||||
"undoDeletionBodyAdmin": "Administrator usunął konto {name}; zostanie ono usunięte po upływie okresu karencji. Cofnięcie zachowuje konto i całą jego zawartość bez zmian. {name} może znów się zalogować, ale tokeny API, linki udostępniania i zaufane urządzenia pozostają unieważnione i trzeba je skonfigurować od nowa.",
|
||||
"deleteUser": "Usuń konto",
|
||||
"deleteUserTitle": "Usunąć konto {name}?",
|
||||
"deleteUserBodyGrace": "{name} zostanie od razu wszędzie wylogowany i nie będzie mógł się już zalogować. Konto i cała jego zawartość zostaną usunięte {date}. Do tego czasu możesz tutaj cofnąć usunięcie.",
|
||||
"deleteUserBodyNow": "{name} i cała zawartość konta zostaną usunięte od razu. Ten serwer nie ma okresu karencji, więc usunięcia nie da się cofnąć.",
|
||||
"deleteUserConfirm": "Usuń konto",
|
||||
"deleteUserScheduled": "Konto {name} jest zablokowane i zostanie usunięte {date}",
|
||||
"deleteUserDone": "Konto {name} zostało usunięte",
|
||||
"deleteUserFailed": "Nie udało się usunąć konta"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Zaproszenia",
|
||||
@@ -11250,6 +11267,12 @@
|
||||
},
|
||||
"sosVozAmiga": {
|
||||
"name": "SOS Voz Amiga (Portugalia, codziennie od 15:30 do 0:30)"
|
||||
},
|
||||
"zelfmoordpreventie113": {
|
||||
"name": "113 Zelfmoordpreventie (Holandia, całą dobę, bezpłatnie, anonimowo, także przez czat)"
|
||||
},
|
||||
"zelfmoordlijn1813": {
|
||||
"name": "Zelfmoordlijn 1813 (Belgia, całą dobę, bezpłatnie, anonimowo)"
|
||||
}
|
||||
},
|
||||
"instrumentDescription": {
|
||||
|
||||
+28
-5
@@ -789,7 +789,10 @@
|
||||
"title": "Guarde seu kit de recuperação",
|
||||
"body": "Sem cópias das chaves que este servidor usa, os dados dele não podem ser recuperados após uma reinstalação. Leva poucos minutos.",
|
||||
"action": "Abrir o kit"
|
||||
}
|
||||
},
|
||||
"greetingOnly": "{greeting}.",
|
||||
"welcomeNew": "Boas-vindas.",
|
||||
"welcomeNewWithName": "Boas-vindas, {name}."
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Medições",
|
||||
@@ -6237,14 +6240,17 @@
|
||||
"password": "Senha",
|
||||
"totp": "Código do autenticador",
|
||||
"passkey": "Chave de acesso",
|
||||
"webauthn": "Chave de segurança"
|
||||
"webauthn": "Chave de segurança",
|
||||
"oidc": "Provedor de identidade"
|
||||
},
|
||||
"currentPassword": "Senha atual",
|
||||
"authenticatorCode": "Código do autenticador",
|
||||
"authenticatorPlaceholder": "Código do autenticador de 6 dígitos",
|
||||
"verifyAndAdd": "Verificar e adicionar",
|
||||
"factorDescription": "Verifique um método de entrada existente antes de adicionar um segundo fator.",
|
||||
"verifyAndContinue": "Verificar e continuar"
|
||||
"verifyAndContinue": "Verificar e continuar",
|
||||
"oidcHint": "Você entra mais uma vez no seu provedor de identidade e volta direto para esta página. Depois, repita o que ia fazer.",
|
||||
"continueToProvider": "Continuar para o login"
|
||||
},
|
||||
"passkeyName": "Nome",
|
||||
"noPasskeys": "Nenhuma chave de acesso registrada.",
|
||||
@@ -7517,7 +7523,9 @@
|
||||
"description": "Isto compartilha ou exporta os seus dados de saúde. Confirme que é você para continuar.",
|
||||
"descriptionChange": "Isto altera algo que só você deveria poder alterar. Confirme que é você para continuar.",
|
||||
"signInAgain": "Entre novamente e tente mais uma vez. Sua conta não tem senha nem chave de acesso para confirmar aqui.",
|
||||
"failed": "Não deu certo. Confira o que você digitou e tente de novo."
|
||||
"failed": "Não deu certo. Confira o que você digitou e tente de novo.",
|
||||
"oidcDone": "Confirmado. Nos próximos cinco minutos você pode continuar sem que isso seja pedido de novo.",
|
||||
"oidcFailed": "Seu provedor de identidade não conseguiu confirmar que é você. Tente novamente."
|
||||
},
|
||||
"passwordTitle": "Senha",
|
||||
"tokenNameLabel": "Nome do token",
|
||||
@@ -8105,7 +8113,16 @@
|
||||
"undoDeletionBody": "{name} pediu para excluir esta conta, e ela é apagada quando o prazo de carência termina. Desfazer mantém a conta e tudo o que há nela como estava. {name} pode entrar de novo, mas os tokens de API, os links de compartilhamento e os dispositivos confiáveis continuam revogados e precisam ser configurados de novo.",
|
||||
"undoDeletionBodyGuardian": "Um guardião excluiu o perfil gerenciado {name}, e ele é apagado quando o prazo de carência termina. Desfazer mantém o perfil e tudo o que há nele como estava, e os guardiões voltam a ter acesso. Os links de compartilhamento e tokens continuam revogados e precisam ser configurados de novo.",
|
||||
"undoDeletionDone": "Exclusão de {name} desfeita",
|
||||
"undoDeletionFailed": "Não foi possível desfazer a exclusão"
|
||||
"undoDeletionFailed": "Não foi possível desfazer a exclusão",
|
||||
"undoDeletionBodyAdmin": "Um administrador excluiu a conta {name}, e ela é apagada quando o prazo de carência termina. Desfazer mantém a conta e tudo o que há nela como estava. {name} pode entrar de novo, mas os tokens de API, os links de compartilhamento e os dispositivos confiáveis continuam revogados e precisam ser configurados de novo.",
|
||||
"deleteUser": "Excluir conta",
|
||||
"deleteUserTitle": "Excluir a conta {name}?",
|
||||
"deleteUserBodyGrace": "{name} sai da conta em todos os lugares na hora e não consegue mais entrar. A conta e tudo o que há nela são apagados em {date}. Até lá, você pode desfazer a exclusão aqui.",
|
||||
"deleteUserBodyNow": "{name} e tudo o que há na conta são apagados na hora. Este servidor não tem prazo de carência, então a exclusão não pode ser desfeita.",
|
||||
"deleteUserConfirm": "Excluir conta",
|
||||
"deleteUserScheduled": "A conta {name} está bloqueada e será apagada em {date}",
|
||||
"deleteUserDone": "A conta {name} foi excluída",
|
||||
"deleteUserFailed": "Não foi possível excluir a conta"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Convites",
|
||||
@@ -11250,6 +11267,12 @@
|
||||
},
|
||||
"sosVozAmiga": {
|
||||
"name": "SOS Voz Amiga (Portugal, todos os dias das 15h30 às 0h30)"
|
||||
},
|
||||
"zelfmoordpreventie113": {
|
||||
"name": "113 Zelfmoordpreventie (Países Baixos, 24 horas, gratuito, anônimo, também por chat)"
|
||||
},
|
||||
"zelfmoordlijn1813": {
|
||||
"name": "Zelfmoordlijn 1813 (Bélgica, 24 horas, gratuito, anônimo)"
|
||||
}
|
||||
},
|
||||
"instrumentDescription": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 346,
|
||||
"total": 112297,
|
||||
"total": 112215,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 286,
|
||||
@@ -9,7 +9,7 @@
|
||||
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211,
|
||||
"app/api/auth/login/route.ts :: apiHandler(arg)": 262,
|
||||
"app/api/auth/me/route.ts :: apiHandler(arg)": 386,
|
||||
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 281,
|
||||
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258,
|
||||
"app/api/auth/register/route.ts :: apiHandler(arg)": 268,
|
||||
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
|
||||
"app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230,
|
||||
@@ -217,7 +217,7 @@
|
||||
"components/settings/mood-reminder-card.tsx :: MoodReminderCard": 234,
|
||||
"components/settings/report-selection/selection-panel.tsx :: HealthRecordExportPanel": 370,
|
||||
"components/settings/score-section.tsx :: ScoreSection": 383,
|
||||
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 410,
|
||||
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 390,
|
||||
"components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 288,
|
||||
"components/settings/security-section/totp-card.tsx :: TotpCard": 533,
|
||||
"components/settings/security-sessions-card.tsx :: SecuritySessionsCard": 245,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
|
||||
"count": 524,
|
||||
"total": 5488,
|
||||
"count": 523,
|
||||
"total": 5483,
|
||||
"entries": {
|
||||
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
|
||||
"__tests__/api/auth/mfa/verify.test.ts": 9,
|
||||
@@ -337,7 +337,6 @@
|
||||
"lib/auth/__tests__/mfa-enrollment.test.ts": 8,
|
||||
"lib/auth/__tests__/native-handoff-flow.test.ts": 10,
|
||||
"lib/auth/__tests__/oidc-native-handoff.test.ts": 13,
|
||||
"lib/auth/__tests__/oidc.test.ts": 5,
|
||||
"lib/auth/__tests__/passkey.test.ts": 7,
|
||||
"lib/auth/__tests__/profile-update-email-limit.test.ts": 5,
|
||||
"lib/auth/__tests__/profile-update.test.ts": 4,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2897,
|
||||
"total": 9236,
|
||||
"count": 2896,
|
||||
"total": 9235,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -2067,7 +2067,6 @@
|
||||
"lib/google-health/sync-metrics.ts": 1,
|
||||
"lib/i18n/__tests__/relative-time-updated.test.ts": 2,
|
||||
"lib/i18n/config.ts": 1,
|
||||
"lib/i18n/context.tsx": 1,
|
||||
"lib/i18n/relative-time.ts": 4,
|
||||
"lib/i18n/shared-resolve.ts": 1,
|
||||
"lib/i18n/source-labels.ts": 1,
|
||||
|
||||
@@ -679,6 +679,17 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/admin/delete-user-control.tsx",
|
||||
destroys:
|
||||
"another user's whole account, locked at once and erased when the deletion grace window ends (or at once when the window is 0)",
|
||||
triggers: ["components/admin/user-management-section.tsx"],
|
||||
// The undo route exists and the row offers it until the purge date
|
||||
// (`pending-deletion-controls.tsx`); with the window at 0 there is
|
||||
// nothing to undo, and the dialog says so.
|
||||
recovery: "restorable",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
|
||||
// ── Integrations ────────────────────────────────────────────────────────
|
||||
// Every connection renders the shared integration card, whose three-dot
|
||||
|
||||
@@ -145,15 +145,27 @@ describe("managed-profile contract", () => {
|
||||
path.join(root, "src/lib/sharing/grants.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const accountDeletion = await readFile(
|
||||
path.join(root, "src/app/api/settings/account/route.ts"),
|
||||
// Both account deletions (the account's own and an admin's) take the one
|
||||
// request path, and that path decides the Guardian invariant.
|
||||
const deletionRequest = await readFile(
|
||||
path.join(root, "src/lib/account-deletion/request.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const deletionRoutes = await Promise.all(
|
||||
[
|
||||
"src/app/api/settings/account/route.ts",
|
||||
"src/app/api/admin/users/[id]/route.ts",
|
||||
].map((rel) => readFile(path.join(root, rel), "utf8")),
|
||||
);
|
||||
|
||||
expect(lifecycle).toContain("pg_advisory_xact_lock");
|
||||
expect(lifecycle).toContain("clearManagedProfileMarker");
|
||||
expect(lifecycle).toContain("deleteManagedProfile");
|
||||
expect(grants).toContain("reduceManagedProfileGuardian");
|
||||
expect(accountDeletion).toContain("deleteGuardianAccountWithLifecycle");
|
||||
expect(deletionRequest).toContain("deleteGuardianAccountWithLifecycle(");
|
||||
for (const route of deletionRoutes) {
|
||||
expect(route).toContain("requestAccountDeletion(");
|
||||
expect(route).not.toContain("lockAccountForDeletion(");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -295,7 +295,7 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
"/api/admin/support-report": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/tokens": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/users": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/users/{id}": { kind: "adminConsole", methods: ["PUT"] },
|
||||
"/api/admin/users/{id}": { kind: "adminConsole", methods: ["DELETE", "PUT"] },
|
||||
"/api/admin/users/{id}/force-logout": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
@@ -308,6 +308,10 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
// #1296 — the identity-provider re-proof start. A top-level navigation from
|
||||
// the web's re-proof dialog that ends at the provider's sign-in page; the
|
||||
// native client never re-proves this way (Bearer has its own step-up).
|
||||
"/api/auth/oidc/reproof": { kind: "browserHandoff", methods: ["GET"] },
|
||||
"/api/fitbit/connect": { kind: "browserHandoff", methods: ["GET"] },
|
||||
"/api/google-health/connect": { kind: "browserHandoff", methods: ["GET"] },
|
||||
// `/api/nightscout/connect` sat here as a `browserHandoff` and is published
|
||||
|
||||
@@ -111,6 +111,7 @@ const COOKIE_PROOF_ROUTES = [
|
||||
"app/api/admin/backups/upload/route.ts",
|
||||
"app/api/admin/data/route.ts",
|
||||
"app/api/admin/users/[id]/reset-password/route.ts",
|
||||
"app/api/admin/users/[id]/route.ts",
|
||||
"app/api/auth/reproof/route.ts",
|
||||
"app/api/tokens/documents/route.ts",
|
||||
"app/api/tokens/measurements/route.ts",
|
||||
|
||||
@@ -102,8 +102,11 @@ describe("S1 — the direct session-resolution set is frozen", () => {
|
||||
// replayed callback redirects instead of minting a second identity, and
|
||||
// (v1.43, #959) so a single-sign-on claim is refused over a live session,
|
||||
// as the claim routes refuse it.
|
||||
// And the identity-provider re-proof (#1296), which stamps only
|
||||
// the session the start bound into the sealed state, and only while the
|
||||
// browser still carries it.
|
||||
"app/api/auth/oidc/callback/route.ts":
|
||||
"duplicate-callback probe and the claim's live-session refusal on an otherwise anonymous route",
|
||||
"duplicate-callback probe, the claim's live-session refusal and the re-proof's session binding on an otherwise anonymous route",
|
||||
// Public liveness probe. The session read only decides whether the body
|
||||
// carries operational detail; the status code is computed without it.
|
||||
"app/api/health/route.ts": "admin-only detail on a public probe",
|
||||
|
||||
@@ -112,6 +112,7 @@ function discoverGatedRoutes(): string[] {
|
||||
*/
|
||||
const GATED_ROUTES: Record<string, string> = {
|
||||
"DELETE /api/admin/data": "web",
|
||||
"DELETE /api/admin/users/[id]": "web",
|
||||
"DELETE /api/auth/me/mfa/webauthn/[id]": "web",
|
||||
"DELETE /api/auth/passkeys/[id]": "web",
|
||||
"DELETE /api/managed-profiles/[id]": "web",
|
||||
|
||||
@@ -3,13 +3,21 @@ import {
|
||||
assertAnotherAdminStays,
|
||||
LastAdminError,
|
||||
} from "@/lib/auth/admin-roster";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
assertRecentCookieProof,
|
||||
requireAdmin,
|
||||
} from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { requestAccountDeletion } from "@/lib/account-deletion/request";
|
||||
import { lockPendingAccount } from "@/lib/account-deletion/lifecycle";
|
||||
import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle";
|
||||
import { adminDeleteUserSchema } from "@/lib/validations/admin";
|
||||
|
||||
const updateUserSchema = z
|
||||
.object({
|
||||
@@ -122,3 +130,134 @@ export const PUT = apiHandler(
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/** A refusal decided under the account's row lock, inside the transaction. */
|
||||
class DeletionRefusal extends Error {
|
||||
constructor(readonly kind: "not_found" | "managed_profile" | "pending") {
|
||||
super(`admin deletion refused: ${kind}`);
|
||||
this.name = "DeletionRefusal";
|
||||
}
|
||||
}
|
||||
|
||||
function deletionRefusalResponse(kind: DeletionRefusal["kind"]): Response {
|
||||
if (kind === "not_found") return apiError("User not found", 404);
|
||||
if (kind === "managed_profile") {
|
||||
return apiError("A managed profile is deleted by its Guardians", 409, {
|
||||
errorCode: "admin.users.delete.managed_profile",
|
||||
});
|
||||
}
|
||||
return apiError("This account is already pending deletion", 409, {
|
||||
errorCode: "account.deletion.already_pending",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/users/{id} — an admin deletes another account.
|
||||
*
|
||||
* The same request the account's own deletion makes
|
||||
* (`requestAccountDeletion`): with a grace window the account is locked at
|
||||
* once (every credential revoked, sign-in refused) and erased by the purge job
|
||||
* when the window ends, and `POST /api/admin/users/{id}/undo-deletion` gives it
|
||||
* back until then; with `ACCOUNT_DELETION_GRACE_DAYS=0` it is erased here.
|
||||
*
|
||||
* Cookie-only (`requireAdmin`) behind a fresh proof (`assertRecentCookieProof`,
|
||||
* the gate in front of the other admin acts on someone's data), with the typed
|
||||
* confirmation the self-deletion asks for. Refused for the caller's own
|
||||
* account (that is the account settings' deletion, with its own step-up), for
|
||||
* the last admin who can sign in, for a managed profile (its Guardians delete
|
||||
* it), for an account already pending deletion, and for the last Guardian of
|
||||
* a managed profile. Audit-logged for the admin and on the account itself.
|
||||
*/
|
||||
export const DELETE = apiHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user: admin, session } = await requireAdmin();
|
||||
await assertRecentCookieProof(admin, session.id);
|
||||
|
||||
const { id } = await params;
|
||||
annotate({
|
||||
action: {
|
||||
name: "admin.users.delete",
|
||||
entity_type: "user",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
const parsed = await parseJsonBody(request, adminDeleteUserSchema, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
if (id === admin.id) {
|
||||
return apiError(
|
||||
"Delete your own account from your account settings",
|
||||
400,
|
||||
{ errorCode: "admin.users.delete.self" },
|
||||
);
|
||||
}
|
||||
|
||||
const target = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { role: true, username: true },
|
||||
});
|
||||
if (!target) return apiError("User not found", 404);
|
||||
|
||||
let purgeAt: Date | null;
|
||||
try {
|
||||
({ purgeAt } = await requestAccountDeletion({
|
||||
userId: id,
|
||||
targetIsAdmin: target.role === "ADMIN",
|
||||
requestedById: admin.id,
|
||||
inTransaction: async (tx, scheduledPurgeAt) => {
|
||||
const state = await lockPendingAccount(tx, id);
|
||||
if (!state) throw new DeletionRefusal("not_found");
|
||||
if (state.managedProfile)
|
||||
throw new DeletionRefusal("managed_profile");
|
||||
if (state.pending) throw new DeletionRefusal("pending");
|
||||
const details = {
|
||||
targetUserId: id,
|
||||
username: target.username,
|
||||
scheduledPurgeAt: scheduledPurgeAt?.toISOString() ?? null,
|
||||
};
|
||||
await auditLog("admin.user.delete", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details,
|
||||
client: tx,
|
||||
});
|
||||
// On the account itself too, so its own activity shows who asked.
|
||||
// An immediate erasure takes this row with the account's history.
|
||||
await auditLog("user.account.delete", {
|
||||
userId: id,
|
||||
actorUserId: admin.id,
|
||||
details: { ...details, requestedBy: "admin" },
|
||||
client: tx,
|
||||
});
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof DeletionRefusal) {
|
||||
return deletionRefusalResponse(error.kind);
|
||||
}
|
||||
if (error instanceof LastAdminError) {
|
||||
return apiError("The last admin account cannot be deleted", 400);
|
||||
}
|
||||
if (error instanceof LastManagedGuardianError) {
|
||||
return apiError(
|
||||
"This account is the last Guardian of a managed profile. Add another Guardian or delete the profile first",
|
||||
409,
|
||||
{ errorCode: "managed_profile.guardian.required" },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
annotate({ meta: { deletion_scheduled: purgeAt !== null } });
|
||||
return apiSuccess({
|
||||
deleted: true,
|
||||
scheduledPurgeAt: purgeAt?.toISOString() ?? null,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -100,6 +100,7 @@ describe("GET /api/admin/users", () => {
|
||||
// BigInt in Prisma, plain number on the wire.
|
||||
documentQuotaBytes: 2_147_483_648,
|
||||
passkeyCount: 2,
|
||||
managedProfile: false,
|
||||
// Not pending deletion.
|
||||
deletion: null,
|
||||
});
|
||||
@@ -128,6 +129,7 @@ describe("GET /api/admin/users", () => {
|
||||
deletionRequestedAt: true,
|
||||
deletionPurgeAt: true,
|
||||
deletionRequestedById: true,
|
||||
managedProfileAt: true,
|
||||
_count: { select: { passkeys: true } },
|
||||
});
|
||||
expect(args.select).not.toHaveProperty("passwordHash");
|
||||
|
||||
@@ -21,6 +21,7 @@ export const GET = apiHandler(async () => {
|
||||
deletionRequestedAt: true,
|
||||
deletionPurgeAt: true,
|
||||
deletionRequestedById: true,
|
||||
managedProfileAt: true,
|
||||
_count: { select: { passkeys: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
@@ -42,9 +43,12 @@ export const GET = apiHandler(async () => {
|
||||
documentQuotaBytes:
|
||||
u.documentQuotaBytes === null ? null : Number(u.documentQuotaBytes),
|
||||
passkeyCount: u._count.passkeys,
|
||||
// A record somebody else runs. Its Guardians delete it, so the list
|
||||
// offers no admin deletion on it.
|
||||
managedProfile: u.managedProfileAt != null,
|
||||
// An account pending deletion: when it was asked for, when the
|
||||
// purge job erases it, and whether the account asked itself or a
|
||||
// Guardian deleted the managed profile. Null on every other account.
|
||||
// purge job erases it, and who asked: the account itself, a Guardian
|
||||
// (a managed profile), or an admin. Null on every other account.
|
||||
// The list offers "Undo deletion" on these rows until `purgeAt`.
|
||||
deletion: u.deletionRequestedAt
|
||||
? {
|
||||
@@ -53,7 +57,9 @@ export const GET = apiHandler(async () => {
|
||||
requestedBy:
|
||||
u.deletionRequestedById === u.id
|
||||
? ("self" as const)
|
||||
: ("guardian" as const),
|
||||
: u.managedProfileAt != null
|
||||
? ("guardian" as const)
|
||||
: ("admin" as const),
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
|
||||
@@ -22,19 +22,21 @@ 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";
|
||||
import { recentProofMethods } from "@/lib/auth/second-factor";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const [recoveryCodesRemaining, webauthn] = await Promise.all([
|
||||
const [recoveryCodesRemaining, webauthn, proofMethods] = 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" },
|
||||
}),
|
||||
recentProofMethods(user),
|
||||
]);
|
||||
|
||||
annotate({ action: { name: "auth.mfa.status" } });
|
||||
@@ -46,5 +48,9 @@ export const GET = apiHandler(async () => {
|
||||
// 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),
|
||||
// The ways this account can confirm it is you, the list the recent-proof
|
||||
// refusal names. The passkey card offers exactly these before adding one,
|
||||
// so an account without a password is not handed a password field.
|
||||
proofMethods,
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,21 @@ const SERVER_FEATURES = {
|
||||
lifeCircumstances: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* v1.7.0 — patient-identity fields for the health-record export. The KVNR is
|
||||
* stored encrypted; decrypt fail-soft so a key-rotation gap on one row never
|
||||
* 500s the whole profile fetch (the field just reads null and the user
|
||||
* re-enters it).
|
||||
*/
|
||||
function readInsuranceNumber(encrypted: string | null | undefined) {
|
||||
if (!encrypted) return null;
|
||||
try {
|
||||
return decrypt(encrypted);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const auth = await requireActorAuth();
|
||||
const { user } = auth;
|
||||
@@ -289,22 +304,14 @@ export const GET = apiHandler(async () => {
|
||||
sectionsOpen(sections, "cycle") &&
|
||||
isCycleEnabled(record?.gender ?? null, cycleProfile);
|
||||
|
||||
// v1.7.0 — patient-identity fields for the health-record export. The
|
||||
// KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on
|
||||
// one row never 500s the whole profile fetch (the field just reads
|
||||
// null and the user re-enters it).
|
||||
let insuranceNumber: string | null = null;
|
||||
if (identity?.insuranceNumberEncrypted) {
|
||||
try {
|
||||
insuranceNumber = decrypt(identity.insuranceNumberEncrypted);
|
||||
} catch {
|
||||
insuranceNumber = null;
|
||||
}
|
||||
}
|
||||
const insuranceNumber = readInsuranceNumber(
|
||||
identity?.insuranceNumberEncrypted,
|
||||
);
|
||||
|
||||
return apiSuccess({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
createdAt: user.createdAt,
|
||||
email: user.email,
|
||||
role: user.role ?? "USER",
|
||||
heightCm: user.heightCm,
|
||||
|
||||
@@ -47,6 +47,11 @@ import {
|
||||
} from "@/lib/managed-profiles/claim-sso";
|
||||
import { pendingDeletionOf } from "@/lib/account-deletion/pending";
|
||||
import { isHandoverUsable } from "@/lib/managed-profiles/handover";
|
||||
import {
|
||||
isReproofSession,
|
||||
recordOidcReproof,
|
||||
reproofReturnUrl,
|
||||
} from "@/lib/auth/oidc-reproof";
|
||||
|
||||
const LOGIN_ERROR_URL = "/auth/login";
|
||||
|
||||
@@ -103,6 +108,20 @@ function deletionPendingRedirect(purgeAt: Date | null): NextResponse {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1296 — a re-proof returns to the page it left, never to the sign-in page:
|
||||
* the person is still signed in, and the session they came from is the one
|
||||
* thing that leg may touch. `reason` null is the success.
|
||||
*/
|
||||
function reproofRedirect(next: string, reason: string | null): NextResponse {
|
||||
if (reason) annotate({ meta: { reason } });
|
||||
const response = NextResponse.redirect(
|
||||
reproofReturnUrl(next, reason ? "failed" : "done"),
|
||||
);
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state cookie is set with `path: "/api/auth/oidc"`; the delete must
|
||||
* repeat that path (RFC 6265 keys cookies by name+domain+path — a bare
|
||||
@@ -139,6 +158,10 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
const stored = readStoredOidcState(req.cookies.get(OIDC_STATE_COOKIE)?.value);
|
||||
const isNative = stored?.native === true;
|
||||
const isClaim = stored?.intent === "claim";
|
||||
// #1296 — a re-proof returns to the page it left (`reproofRedirect`).
|
||||
const isReproof = stored?.intent === "reproof";
|
||||
const reproofBack = (reason: string | null) =>
|
||||
reproofRedirect(stored?.next ?? "/", reason);
|
||||
|
||||
// Native-aware error redirect: the custom scheme for a decrypted native flow,
|
||||
// the web login page otherwise. Deletes the single-use state cookie either
|
||||
@@ -158,6 +181,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
};
|
||||
|
||||
const failRedirect = (reason: string): NextResponse => {
|
||||
if (isReproof) return reproofBack(reason);
|
||||
if (isClaim) return claimRedirect(reason);
|
||||
if (isNative) {
|
||||
const response = NextResponse.redirect(
|
||||
@@ -199,42 +223,11 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
// resolve to the web `oidc_state` fallback: the flow is fundamentally broken
|
||||
// and there is nothing safe to hand the app (spec §1 web-fallback).
|
||||
if (!code || !state) {
|
||||
if (isReproof) return reproofBack("oidc_state");
|
||||
return isClaim ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
|
||||
}
|
||||
|
||||
if (!stored) {
|
||||
// Defence-in-depth idempotency for a duplicated callback. The single-use
|
||||
// state cookie is set with `path: "/api/auth/oidc"` and DELETED by the
|
||||
// first callback the moment it succeeds. A second callback for the same
|
||||
// sign-in (a client/proxy retry, a double navigation, or the
|
||||
// service-worker double-request this release also fixes) therefore arrives
|
||||
// with the state cookie already gone — `stored` is null — and, because the
|
||||
// first callback set the session, it carries a live session cookie.
|
||||
// Redeeming the now-consumed authorization code again fails at the IdP and
|
||||
// surfaces `?error=oidc_failed` on a sign-in that actually succeeded. So
|
||||
// when a VALIDATED session is present, treat this as a duplicate of a
|
||||
// completed login and send the user into the app instead of erroring.
|
||||
//
|
||||
// Fail-closed is preserved for the real attack case: the code is NEVER
|
||||
// exchanged on this branch, and the session is resolved against the
|
||||
// database + its expiry (not merely "a cookie is present"). A forged,
|
||||
// replayed, or expired state from a caller with no valid session gets the
|
||||
// unchanged `oidc_state` error; a caller who already holds their own valid
|
||||
// session gains nothing here but a redirect to a page they could already
|
||||
// reach — no code redemption, no session mint, no identity change.
|
||||
const existingSession = await validateSessionFromCookieValue(
|
||||
readSessionCookie(req.cookies)?.value,
|
||||
);
|
||||
if (existingSession) {
|
||||
annotate({ meta: { reason: "duplicate_callback_authenticated" } });
|
||||
const response = NextResponse.redirect(oidcAppUrl("/"));
|
||||
// The state cookie is already consumed; delete defensively so a stray
|
||||
// blob can never outlive this response.
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
}
|
||||
return errorRedirect("oidc_state");
|
||||
}
|
||||
if (!stored) return stateMissing(req);
|
||||
|
||||
// CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback.
|
||||
if (
|
||||
@@ -242,9 +235,12 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
!timingSafeEqual(Buffer.from(state), Buffer.from(stored.state))
|
||||
) {
|
||||
annotate({ meta: { reason: "csrf" } });
|
||||
if (isReproof) return reproofBack("oidc_state");
|
||||
return isClaim ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
|
||||
}
|
||||
|
||||
if (isReproof) return finishReproof(req, stored, config, ip, reproofBack);
|
||||
|
||||
// v1.43 (#959) — a claim is never finished over a live session (the claim
|
||||
// routes' rule: a Guardian finishing their own link would otherwise be
|
||||
// signed out of their account and into the person's), and is admitted only
|
||||
@@ -408,3 +404,90 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
return failRedirect("oidc_failed");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* #1296 — the re-proof leg. The browser must still carry the session the
|
||||
* start bound into the state, checked before the code is redeemed; then the
|
||||
* provider's answer must name this account's linked identity and an
|
||||
* authentication inside the gate's window. Only then is `reproofAt` stamped.
|
||||
* No session is created, and every refusal lands back on the page it left.
|
||||
*/
|
||||
async function finishReproof(
|
||||
req: NextRequest,
|
||||
stored: NonNullable<ReturnType<typeof readStoredOidcState>>,
|
||||
config: NonNullable<ReturnType<typeof getOidcConfig>>,
|
||||
ip: string,
|
||||
back: (reason: string | null) => NextResponse,
|
||||
): Promise<NextResponse> {
|
||||
const live = await validateSessionFromCookieValue(
|
||||
readSessionCookie(req.cookies)?.value,
|
||||
);
|
||||
if (!isReproofSession(stored, live)) return back("session_mismatch");
|
||||
|
||||
try {
|
||||
const metadata = await discoverOidcMetadata(config);
|
||||
const tokens = await exchangeCodeForTokens({
|
||||
metadata,
|
||||
config,
|
||||
code: req.nextUrl.searchParams.get("code") as string,
|
||||
codeVerifier: stored.codeVerifier,
|
||||
redirectUri: getOidcRedirectUri(),
|
||||
});
|
||||
if (!tokens.id_token) return back("oidc_failed");
|
||||
const identity = await verifyIdToken({
|
||||
metadata,
|
||||
config,
|
||||
idToken: tokens.id_token,
|
||||
nonce: stored.nonce,
|
||||
});
|
||||
const outcome = await recordOidcReproof({
|
||||
userId: stored.reproofUserId as string,
|
||||
sessionId: stored.reproofSessionId as string,
|
||||
issuer: metadata.issuer,
|
||||
identity,
|
||||
ipAddress: ip,
|
||||
});
|
||||
return back(outcome.ok ? null : outcome.reason);
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
return back("exchange_or_verify_failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A callback that arrives without its state blob: the duplicate of a sign-in
|
||||
* that already completed, or a forged, replayed or expired one.
|
||||
*/
|
||||
async function stateMissing(req: NextRequest): Promise<NextResponse> {
|
||||
// Defence-in-depth idempotency for a duplicated callback. The single-use
|
||||
// state cookie is set with `path: "/api/auth/oidc"` and DELETED by the
|
||||
// first callback the moment it succeeds. A second callback for the same
|
||||
// sign-in (a client/proxy retry, a double navigation, or the
|
||||
// service-worker double-request this release also fixes) therefore arrives
|
||||
// with the state cookie already gone — `stored` is null — and, because the
|
||||
// first callback set the session, it carries a live session cookie.
|
||||
// Redeeming the now-consumed authorization code again fails at the IdP and
|
||||
// surfaces `?error=oidc_failed` on a sign-in that actually succeeded. So
|
||||
// when a VALIDATED session is present, treat this as a duplicate of a
|
||||
// completed login and send the user into the app instead of erroring.
|
||||
//
|
||||
// Fail-closed is preserved for the real attack case: the code is NEVER
|
||||
// exchanged on this branch, and the session is resolved against the
|
||||
// database + its expiry (not merely "a cookie is present"). A forged,
|
||||
// replayed, or expired state from a caller with no valid session gets the
|
||||
// unchanged `oidc_state` error; a caller who already holds their own valid
|
||||
// session gains nothing here but a redirect to a page they could already
|
||||
// reach — no code redemption, no session mint, no identity change.
|
||||
const existingSession = await validateSessionFromCookieValue(
|
||||
readSessionCookie(req.cookies)?.value,
|
||||
);
|
||||
if (existingSession) {
|
||||
annotate({ meta: { reason: "duplicate_callback_authenticated" } });
|
||||
const response = NextResponse.redirect(oidcAppUrl("/"));
|
||||
// The state cookie is already consumed; delete defensively so a stray
|
||||
// blob can never outlive this response.
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
}
|
||||
return errorRedirect("oidc_state");
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { getClientIp } from "@/lib/api-response";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
|
||||
import { recentProofMethods } from "@/lib/auth/second-factor";
|
||||
import { throttleReproof } from "@/lib/auth/existing-factor-proof";
|
||||
import {
|
||||
buildAuthorizationUrl,
|
||||
discoverOidcMetadata,
|
||||
getOidcConfig,
|
||||
getOidcRedirectUri,
|
||||
sanitizeOidcNextPath,
|
||||
} from "@/lib/auth/oidc";
|
||||
import {
|
||||
OIDC_STATE_COOKIE,
|
||||
OIDC_STATE_COOKIE_PATH,
|
||||
OIDC_STATE_TTL_MS,
|
||||
} from "@/lib/auth/oidc-cookie";
|
||||
import { OIDC_REPROOF_STAGE, reproofReturnUrl } from "@/lib/auth/oidc-reproof";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Start confirming it is you at the identity provider (#1296).
|
||||
*
|
||||
* A top-level navigation from the re-proof dialog, cookie session only: a
|
||||
* Bearer token never re-proves this way, because a stamp on a session row
|
||||
* means nothing to a token (`requireCookieAuth` reads the session cookie and
|
||||
* nothing else). Offered only where `recentProofMethods` lists `oidc`, which
|
||||
* is an account without a second factor whose identity is linked while a
|
||||
* provider is configured; anything else goes straight back with
|
||||
* `reproof=failed`.
|
||||
*
|
||||
* The round trip asks for a fresh authentication (`prompt=login`,
|
||||
* `max_age=0`) with its own state, nonce and PKCE verifier, sealed into the
|
||||
* same single-use cookie the sign-in uses, together with the account and the
|
||||
* session it confirms. The callback stamps that session's `reproofAt` and
|
||||
* returns to `next` (a same-origin path) with `reproof=done`. It never mints
|
||||
* a session.
|
||||
*
|
||||
* Draws on the account's shared re-proof budget, like every other proof.
|
||||
*/
|
||||
export const GET = apiHandler(async (req: NextRequest) => {
|
||||
annotate({ action: { name: "auth.oidc.reproof.start" } });
|
||||
const { user, session } = await requireCookieAuth();
|
||||
|
||||
const next = sanitizeOidcNextPath(
|
||||
req.nextUrl.searchParams.get("next"),
|
||||
req.url,
|
||||
);
|
||||
const back = (reason: string): NextResponse => {
|
||||
annotate({ meta: { reason } });
|
||||
return NextResponse.redirect(reproofReturnUrl(next, "failed"));
|
||||
};
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) return back("oidc_disabled");
|
||||
|
||||
const methods = await recentProofMethods(user);
|
||||
if (!methods.includes("oidc")) return back("not_offered");
|
||||
|
||||
const limited = await throttleReproof(
|
||||
user.id,
|
||||
getClientIp(req),
|
||||
OIDC_REPROOF_STAGE,
|
||||
);
|
||||
if (limited) return back("rate_limited");
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await discoverOidcMetadata(config);
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
return back("oidc_failed");
|
||||
}
|
||||
|
||||
const state = randomBytes(32).toString("base64url");
|
||||
const nonce = randomBytes(32).toString("base64url");
|
||||
const codeVerifier = randomBytes(64).toString("base64url");
|
||||
|
||||
const response = NextResponse.redirect(
|
||||
buildAuthorizationUrl({
|
||||
metadata,
|
||||
config,
|
||||
state,
|
||||
nonce,
|
||||
codeChallenge: s256Challenge(codeVerifier),
|
||||
redirectUri: getOidcRedirectUri(),
|
||||
reauthenticate: true,
|
||||
}),
|
||||
);
|
||||
response.cookies.set(
|
||||
OIDC_STATE_COOKIE,
|
||||
encrypt(
|
||||
JSON.stringify({
|
||||
state,
|
||||
nonce,
|
||||
codeVerifier,
|
||||
next,
|
||||
intent: "reproof" as const,
|
||||
reproofUserId: user.id,
|
||||
reproofSessionId: session.id,
|
||||
}),
|
||||
),
|
||||
{
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "lax",
|
||||
maxAge: Math.floor(OIDC_STATE_TTL_MS / 1000),
|
||||
path: OIDC_STATE_COOKIE_PATH,
|
||||
},
|
||||
);
|
||||
return response;
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
} from "@/lib/auth/passkey";
|
||||
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
import {
|
||||
RECENT_PROOF_MAX_AGE_MS,
|
||||
REPROOF_REQUIRED_CODE,
|
||||
isSecondFactorProof,
|
||||
recordReproofFailure,
|
||||
refundReproof,
|
||||
@@ -59,6 +61,30 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiSuccess({ ...result, reauth: true });
|
||||
}
|
||||
|
||||
// #1296 — an account whose only credential is its identity provider proves
|
||||
// itself there (`GET /api/auth/oidc/reproof`), which stamps
|
||||
// `Session.reproofAt`. `{ method: "oidc" }` asks this route to take that
|
||||
// stamp as the proof: only where `oidc` is on the account's list (no second
|
||||
// factor, a linked identity, a configured provider) and only inside the
|
||||
// five-minute window. Like a password, it clears the MFA stamp rather than
|
||||
// setting it.
|
||||
if (Object.keys(body).length === 1 && body.method === "oidc") {
|
||||
if (
|
||||
!(await recentProofMethods(user)).includes("oidc") ||
|
||||
!(await hasRecentReproof(session.id))
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "auth.reproof.required" },
|
||||
meta: { stage: "passkey_enroll", method: "oidc" },
|
||||
});
|
||||
return apiError("Confirm it is you before adding a passkey", 401, {
|
||||
errorCode: REPROOF_REQUIRED_CODE,
|
||||
methods: await recentProofMethods(user),
|
||||
});
|
||||
}
|
||||
return issueRegistrationOptions(user, session.id, null);
|
||||
}
|
||||
|
||||
const parsed = stepUpMintSchema.safeParse(body);
|
||||
if (!parsed.success) return proofRequired();
|
||||
|
||||
@@ -101,23 +127,48 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
await refundReproof(user.id);
|
||||
|
||||
// A strong existing factor refreshes the session's MFA stamp. Password proof
|
||||
// deliberately clears it: registration is authorized by this single-use,
|
||||
// session-bound challenge but cannot upgrade password-only authentication.
|
||||
return issueRegistrationOptions(
|
||||
user,
|
||||
session.id,
|
||||
parsed.data.method === "password" ? null : new Date(),
|
||||
);
|
||||
});
|
||||
|
||||
/** A password or provider re-proof on this session inside the window. */
|
||||
async function hasRecentReproof(sessionId: string): Promise<boolean> {
|
||||
const row = await prisma.session.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: { reproofAt: true },
|
||||
});
|
||||
return (
|
||||
row?.reproofAt != null &&
|
||||
Date.now() - row.reproofAt.getTime() <= RECENT_PROOF_MAX_AGE_MS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A strong existing factor refreshes the session's MFA stamp. A password or
|
||||
* provider proof deliberately clears it (`mfaVerifiedAt: null`): registration
|
||||
* is authorized by this single-use, session-bound challenge but cannot upgrade
|
||||
* first-factor authentication.
|
||||
*/
|
||||
async function issueRegistrationOptions(
|
||||
user: { id: string; email: string | null; username: string },
|
||||
sessionId: string,
|
||||
mfaVerifiedAt: Date | null,
|
||||
): Promise<Response> {
|
||||
await prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data: {
|
||||
mfaVerifiedAt: parsed.data.method === "password" ? null : new Date(),
|
||||
},
|
||||
where: { id: sessionId },
|
||||
data: { mfaVerifiedAt },
|
||||
});
|
||||
|
||||
const { options, challengeId } = await createRegistrationOptions(
|
||||
user.id,
|
||||
user.email ?? user.username,
|
||||
session.id,
|
||||
sessionId,
|
||||
);
|
||||
|
||||
annotate({ action: { name: "auth.passkey.register-options" } });
|
||||
|
||||
return apiSuccess({ options, challengeId });
|
||||
});
|
||||
}
|
||||
@@ -8,24 +8,10 @@ import {
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { kickOffhostPurge } from "@/lib/jobs/offhost-purge";
|
||||
import {
|
||||
deleteGuardianAccountWithLifecycle,
|
||||
LastManagedGuardianError,
|
||||
} from "@/lib/managed-profiles/lifecycle";
|
||||
import {
|
||||
accountDeletionGraceDays,
|
||||
accountDeletionPurgeAt,
|
||||
} from "@/lib/account-deletion/grace";
|
||||
import {
|
||||
eraseAccount,
|
||||
lockAccountForDeletion,
|
||||
} from "@/lib/account-deletion/lifecycle";
|
||||
import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle";
|
||||
import { requestAccountDeletion } from "@/lib/account-deletion/request";
|
||||
import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account";
|
||||
import {
|
||||
assertAnotherAdminStays,
|
||||
LastAdminError,
|
||||
} from "@/lib/auth/admin-roster";
|
||||
import { LastAdminError } from "@/lib/auth/admin-roster";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -104,53 +90,27 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
// the elevation would stay redeemable while the erasure was already running.
|
||||
await auth.commitElevation();
|
||||
|
||||
const requestedAt = new Date();
|
||||
const purgeAt = accountDeletionPurgeAt(
|
||||
requestedAt,
|
||||
accountDeletionGraceDays(),
|
||||
);
|
||||
|
||||
let purgeAt: Date | null;
|
||||
try {
|
||||
await deleteGuardianAccountWithLifecycle(
|
||||
({ purgeAt } = await requestAccountDeletion({
|
||||
userId,
|
||||
async (tx) => {
|
||||
// Checked again under the roster lock: two admins deleting at the
|
||||
// same moment must not each count the other.
|
||||
if (user.role === "ADMIN") await assertAnotherAdminStays(tx, userId);
|
||||
// The invariant has now passed, so this success event cannot survive
|
||||
// a refusal. On an immediate erasure it is purged with the rest of the
|
||||
// account's audit history; during a grace window it is the account's
|
||||
// own record of the request.
|
||||
await auditLog("user.account.delete", {
|
||||
targetIsAdmin: user.role === "ADMIN",
|
||||
requestedById: userId,
|
||||
// The invariants have passed by the time this runs, so this success
|
||||
// event cannot survive a refusal. On an immediate erasure it is purged
|
||||
// with the rest of the account's audit history; during a grace window
|
||||
// it is the account's own record of the request.
|
||||
inTransaction: (tx, scheduledPurgeAt) =>
|
||||
auditLog("user.account.delete", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
username,
|
||||
scheduledPurgeAt: purgeAt?.toISOString() ?? null,
|
||||
scheduledPurgeAt: scheduledPurgeAt?.toISOString() ?? null,
|
||||
},
|
||||
client: tx,
|
||||
});
|
||||
|
||||
if (purgeAt) {
|
||||
// Locked, not erased: credentials revoked in this transaction,
|
||||
// the record untouched until the purge job reaches `purgeAt`.
|
||||
await lockAccountForDeletion(tx, {
|
||||
userId,
|
||||
requestedById: userId,
|
||||
requestedAt,
|
||||
purgeAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No window: erase now, inside the same record lifecycle transaction
|
||||
// so a refusal leaves every account state intact (sessions, the
|
||||
// account's audit history — GDPR Art. 17 — the off-host purge
|
||||
// request, then the row with everything cascading from it).
|
||||
await eraseAccount(tx, userId, "account_deleted");
|
||||
},
|
||||
{ erasing: purgeAt === null },
|
||||
);
|
||||
}),
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof LastAdminError) {
|
||||
return apiError("The last admin account cannot be deleted", 400);
|
||||
@@ -165,8 +125,6 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!purgeAt) await kickOffhostPurge();
|
||||
|
||||
annotate({
|
||||
action: { name: "settings.account.delete" },
|
||||
meta: { username, deletion_scheduled: purgeAt !== null },
|
||||
|
||||
@@ -30,6 +30,8 @@ const sampleUsers = [
|
||||
role: "ADMIN" as const,
|
||||
passkeyCount: 2,
|
||||
createdAt: "2026-04-01T08:00:00Z",
|
||||
managedProfile: false,
|
||||
deletion: null,
|
||||
},
|
||||
{
|
||||
id: "u2",
|
||||
@@ -38,6 +40,8 @@ const sampleUsers = [
|
||||
role: "USER" as const,
|
||||
passkeyCount: 1,
|
||||
createdAt: "2026-04-15T08:00:00Z",
|
||||
managedProfile: false,
|
||||
deletion: null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -53,6 +57,10 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/queries/use-privacy-summary", () => ({
|
||||
useAccountDeletionGrace: () => ({ days: 14, pending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
@@ -115,6 +123,15 @@ describe("UserManagementSection — mobile responsive", () => {
|
||||
expect(mobileSlice).toContain("Force logout");
|
||||
});
|
||||
|
||||
it("offers deleting every account but the admin's own", () => {
|
||||
const html = render();
|
||||
// Two rows, each in the desktop table and the mobile list; only the
|
||||
// other account carries the control.
|
||||
const deletes = html.match(/data-slot="admin-user-delete"/g) ?? [];
|
||||
expect(deletes).toHaveLength(2);
|
||||
expect(html).toContain('aria-label="Delete account"');
|
||||
});
|
||||
|
||||
it("uses smaller card padding on mobile (p-4 md:p-6)", () => {
|
||||
const html = render();
|
||||
expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*md:p-6/);
|
||||
|
||||
@@ -34,15 +34,17 @@ export interface AdminUser {
|
||||
// the instance-wide default applies.
|
||||
documentQuotaBytes: number | null;
|
||||
passkeyCount: number;
|
||||
/** A record its Guardians run; they delete it, an admin does not. */
|
||||
managedProfile: boolean;
|
||||
/**
|
||||
* Set while the account is pending deletion: when the purge job
|
||||
* erases it, and whether the account asked itself or a Guardian deleted
|
||||
* the managed profile. The row offers "Undo deletion" until then.
|
||||
* Set while the account is pending deletion: when the purge job erases
|
||||
* it, and who asked (the account itself, a Guardian of the managed
|
||||
* profile, or an admin). The row offers "Undo deletion" until then.
|
||||
*/
|
||||
deletion: {
|
||||
requestedAt: string;
|
||||
purgeAt: string | null;
|
||||
requestedBy: "self" | "guardian";
|
||||
requestedBy: "self" | "guardian" | "admin";
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { apiDelete } from "@/lib/api/api-fetch";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useAccountDeletionGrace } from "@/lib/queries/use-privacy-summary";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import type { AdminUser } from "./_shared";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Whether the row offers deleting the account: never the admin's own (that is
|
||||
* the account settings' deletion), never a managed profile (its Guardians
|
||||
* delete it), and not one already pending deletion (the row offers the undo).
|
||||
*/
|
||||
export function canAdminDelete(user: AdminUser, currentUserId: string | null) {
|
||||
return (
|
||||
user.id !== currentUserId && !user.managedProfile && user.deletion === null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The day the purge job would erase an account deleted now, or null when the
|
||||
* window is 0 and the erasure happens at once. The server stamps the real
|
||||
* date; this is the one the dialog names before asking.
|
||||
*/
|
||||
export function deletionDateFrom(graceDays: number, now: Date): Date | null {
|
||||
return graceDays > 0 ? new Date(now.getTime() + graceDays * DAY_MS) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Delete account" on a row of the admin users list
|
||||
* (`DELETE /api/admin/users/{id}`), for the rows `canAdminDelete` allows.
|
||||
*/
|
||||
export function DeleteUserControl({
|
||||
user,
|
||||
currentUserId,
|
||||
}: {
|
||||
user: AdminUser;
|
||||
currentUserId: string | null;
|
||||
}) {
|
||||
if (!canAdminDelete(user, currentUserId)) return null;
|
||||
return <DeleteUserButton user={user} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dialog names the date the account is erased and that it can be undone
|
||||
* here until then, or, with no grace window, that it is erased at once. It
|
||||
* waits for the window before it offers anything, so the sentence is never a
|
||||
* guess. The fresh proof the route asks for is collected by the shared
|
||||
* re-proof dialog.
|
||||
*/
|
||||
function DeleteUserButton({ user }: { user: AdminUser }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const grace = useAccountDeletionGrace();
|
||||
const recentProof = useRecentProof();
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () =>
|
||||
recentProof.run(() =>
|
||||
apiDelete<{ scheduledPurgeAt: string | null }>(
|
||||
`/api/admin/users/${user.id}`,
|
||||
{ confirm: "DELETE_ACCOUNT" },
|
||||
),
|
||||
),
|
||||
onSuccess: (result) => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.adminUsers() });
|
||||
toastWrittenOutcome(
|
||||
"success",
|
||||
result.scheduledPurgeAt
|
||||
? t("admin.section.users.deleteUserScheduled", {
|
||||
name: user.username,
|
||||
date: formatDate(result.scheduledPurgeAt),
|
||||
})
|
||||
: t("admin.section.users.deleteUserDone", { name: user.username }),
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = recentProofErrorMessage(
|
||||
err,
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.users.deleteUserFailed"),
|
||||
);
|
||||
if (message) showErrorToast(message, { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
const purgeOn =
|
||||
grace.days === null ? null : deletionDateFrom(grace.days, new Date());
|
||||
|
||||
return (
|
||||
<>
|
||||
{recentProof.dialog}
|
||||
<ConfirmButton
|
||||
slot="admin-user-delete"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 px-2 text-xs"
|
||||
icon={<Trash2 className="h-3 w-3" aria-hidden="true" />}
|
||||
label=""
|
||||
ariaLabel={t("admin.section.users.deleteUser")}
|
||||
disabled={grace.days === null}
|
||||
title={t("admin.section.users.deleteUserTitle", {
|
||||
name: user.username,
|
||||
})}
|
||||
body={
|
||||
purgeOn
|
||||
? t("admin.section.users.deleteUserBodyGrace", {
|
||||
name: user.username,
|
||||
date: formatDate(purgeOn),
|
||||
})
|
||||
: t("admin.section.users.deleteUserBodyNow", {
|
||||
name: user.username,
|
||||
})
|
||||
}
|
||||
confirmLabel={t("admin.section.users.deleteUserConfirm")}
|
||||
pending={remove.isPending}
|
||||
onConfirm={() => remove.mutate()}
|
||||
destructive
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,13 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { AdminUser } from "./_shared";
|
||||
|
||||
/** The undo dialog's sentence, by who asked for the deletion. */
|
||||
const UNDO_BODY_KEYS = {
|
||||
self: "admin.section.users.undoDeletionBody",
|
||||
guardian: "admin.section.users.undoDeletionBodyGuardian",
|
||||
admin: "admin.section.users.undoDeletionBodyAdmin",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* An account pending deletion, in the admin users list: the date the purge
|
||||
* job erases it, and the one action that stops it.
|
||||
@@ -64,12 +71,9 @@ export function PendingDeletionControls({ user }: { user: AdminUser }) {
|
||||
title={t("admin.section.users.undoDeletionTitle", {
|
||||
name: user.username,
|
||||
})}
|
||||
body={t(
|
||||
user.deletion.requestedBy === "guardian"
|
||||
? "admin.section.users.undoDeletionBodyGuardian"
|
||||
: "admin.section.users.undoDeletionBody",
|
||||
{ name: user.username },
|
||||
)}
|
||||
body={t(UNDO_BODY_KEYS[user.deletion.requestedBy], {
|
||||
name: user.username,
|
||||
})}
|
||||
confirmLabel={t("admin.section.users.undoDeletion")}
|
||||
pending={undo.isPending}
|
||||
onConfirm={() => undo.mutate()}
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { type AdminUser, PasswordInput } from "./_shared";
|
||||
import { PendingDeletionControls } from "./pending-deletion-controls";
|
||||
import { DeleteUserControl } from "./delete-user-control";
|
||||
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
@@ -55,6 +56,13 @@ import {
|
||||
*/
|
||||
type UserFilter = "all" | "admin" | "user";
|
||||
|
||||
/** The quota override as the edit form's gigabyte field shows it. */
|
||||
function quotaGbInput(bytes: number | null): string {
|
||||
if (bytes === null) return "";
|
||||
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- value of a type="number" input, which only takes a full stop
|
||||
return String(Math.round((bytes / 1_073_741_824) * 10) / 10);
|
||||
}
|
||||
|
||||
export function UserManagementSection() {
|
||||
const { t } = useTranslations();
|
||||
const currentUserId = useAuth().user?.id ?? "";
|
||||
@@ -160,12 +168,7 @@ export function UserManagementSection() {
|
||||
setEditingUser(u);
|
||||
setEditUsername(u.username);
|
||||
setEditEmail(u.email ?? "");
|
||||
setEditQuotaGb(
|
||||
u.documentQuotaBytes === null
|
||||
? ""
|
||||
: // eslint-disable-next-line healthlog/no-unlocalised-decimal -- value of a type="number" input, which only takes a full stop
|
||||
String(Math.round((u.documentQuotaBytes / 1_073_741_824) * 10) / 10),
|
||||
);
|
||||
setEditQuotaGb(quotaGbInput(u.documentQuotaBytes));
|
||||
}
|
||||
|
||||
function startReset(u: AdminUser) {
|
||||
@@ -229,7 +232,7 @@ export function UserManagementSection() {
|
||||
{u.mfaEnforced ? t("admin.mfaEnforcedOn") : t("admin.mfaEnforcedOff")}
|
||||
<span className="sr-only">, {u.username}</span>
|
||||
</Button>
|
||||
{/* The three icon tools travel as one group: on a phone the row wraps
|
||||
{/* The icon tools travel as one group: on a phone the row wraps
|
||||
between the labelled toggles and the tools, never leaving the
|
||||
sign-out icon alone on a line. */}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
@@ -272,6 +275,7 @@ export function UserManagementSection() {
|
||||
>
|
||||
<LogOut className="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
<DeleteUserControl user={u} currentUserId={currentUserId} />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { dropParams, writeUrlState } from "@/lib/url-state";
|
||||
|
||||
/**
|
||||
* The outcome a confirmation at the identity provider came back with
|
||||
* (`?reproof=done|failed`, set by the OIDC callback). Pure, so the parsing is
|
||||
* testable without a browser.
|
||||
*/
|
||||
export function readReproofReturn(search: string): "done" | "failed" | null {
|
||||
const raw = new URLSearchParams(search).get("reproof");
|
||||
return raw === "done" || raw === "failed" ? raw : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Says once how the confirmation at the identity provider went, then takes the
|
||||
* marker off the address so a reload does not say it again. The browser lands
|
||||
* on the page it left, which is where the person repeats what they were
|
||||
* about to do.
|
||||
*/
|
||||
export function OidcReproofReturnBridge() {
|
||||
const { t } = useTranslations();
|
||||
useEffect(() => {
|
||||
const outcome = readReproofReturn(window.location.search);
|
||||
if (!outcome) return;
|
||||
writeUrlState(dropParams("reproof"), { mode: "replace", state: "keep" });
|
||||
toastWrittenOutcome(
|
||||
outcome === "done" ? "success" : "failed",
|
||||
outcome === "done"
|
||||
? t("settings.reproof.oidcDone")
|
||||
: t("settings.reproof.oidcFailed"),
|
||||
);
|
||||
// A later run (a locale switch) finds no marker and does nothing.
|
||||
}, [t]);
|
||||
return null;
|
||||
}
|
||||
@@ -43,7 +43,7 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { DashboardHeader } from "../dashboard-header";
|
||||
import { DashboardHeader, greetingLine } from "../dashboard-header";
|
||||
import {
|
||||
CAPTURE_KIND_ORDER,
|
||||
visibleCaptureKinds,
|
||||
@@ -58,12 +58,73 @@ function renderSSR(node: React.ReactElement, locale: "en" | "de" = "de") {
|
||||
);
|
||||
}
|
||||
|
||||
describe("greetingLine", () => {
|
||||
const now = Date.parse("2026-10-29T09:00:00Z");
|
||||
const base = { greeting: "Hello", nowMs: now };
|
||||
|
||||
it("welcomes an account in its first day, without a 'back'", () => {
|
||||
expect(
|
||||
greetingLine({
|
||||
...base,
|
||||
known: true,
|
||||
name: "tester",
|
||||
createdAt: "2026-10-29T08:50:00Z",
|
||||
}),
|
||||
).toEqual({
|
||||
key: "dashboard.welcomeNewWithName",
|
||||
values: { name: "tester" },
|
||||
});
|
||||
expect(
|
||||
greetingLine({
|
||||
...base,
|
||||
known: true,
|
||||
name: null,
|
||||
createdAt: "2026-10-28T10:00:00Z",
|
||||
}).key,
|
||||
).toBe("dashboard.welcomeNew");
|
||||
});
|
||||
|
||||
it("welcomes back an account older than a day", () => {
|
||||
expect(
|
||||
greetingLine({
|
||||
...base,
|
||||
known: true,
|
||||
name: "tester",
|
||||
createdAt: "2026-10-20T08:00:00Z",
|
||||
}),
|
||||
).toEqual({
|
||||
key: "dashboard.welcomeBackWithName",
|
||||
values: { greeting: "Hello", name: "tester" },
|
||||
});
|
||||
});
|
||||
|
||||
it("welcomes back when an older server does not say when the account began", () => {
|
||||
expect(
|
||||
greetingLine({ ...base, known: true, name: null, createdAt: undefined })
|
||||
.key,
|
||||
).toBe("dashboard.welcomeBack");
|
||||
});
|
||||
|
||||
it("says only the greeting while the account is not known", () => {
|
||||
expect(
|
||||
greetingLine({
|
||||
...base,
|
||||
known: false,
|
||||
name: "tester",
|
||||
createdAt: "2026-10-29T08:50:00Z",
|
||||
}),
|
||||
).toEqual({ key: "dashboard.greetingOnly", values: { greeting: "Hello" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DashboardHeader> — greeting", () => {
|
||||
it("renders the greeting line under the title", () => {
|
||||
const html = renderSSR(<DashboardHeader onQuickEntry={() => undefined} />);
|
||||
expect(html).toContain('data-slot="dashboard-header-greeting"');
|
||||
// SSR pass: name-less fallback (personalises post-hydration only).
|
||||
expect(html).toContain("willkommen zurück.");
|
||||
// SSR pass: the time-of-day greeting alone. Nothing says "welcome
|
||||
// back" before the client knows the account is not new.
|
||||
expect(html).toContain("Hallo.");
|
||||
expect(html).not.toContain("willkommen zurück");
|
||||
// Reserved line box keeps the header height stable through the
|
||||
// post-hydration personalisation swap.
|
||||
expect(html).toMatch(/data-slot="dashboard-header-greeting"[^>]*>/);
|
||||
|
||||
@@ -42,6 +42,43 @@ import {
|
||||
} from "@/components/layout/capture-picker";
|
||||
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
|
||||
|
||||
const FIRST_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The greeting line, as an i18n key and its values.
|
||||
*
|
||||
* Until the account is known (the SSR pass and the hydration render) it is the
|
||||
* time-of-day greeting alone, so nothing claims "welcome back" before the
|
||||
* client knows it is true. An account in its first day (`createdAt` from
|
||||
* `/api/auth/me`) is welcomed, not welcomed back: the setup flow lands a new
|
||||
* account here minutes after it was created. Any other account, and one whose
|
||||
* server does not say when it was created, is welcomed back.
|
||||
*/
|
||||
export function greetingLine(input: {
|
||||
greeting: string;
|
||||
known: boolean;
|
||||
name: string | null;
|
||||
createdAt: string | null | undefined;
|
||||
/** The clock, for tests; the moment of the call otherwise. */
|
||||
nowMs?: number;
|
||||
}): { key: string; values: Record<string, string> } {
|
||||
const { greeting, name } = input;
|
||||
const nowMs = input.nowMs ?? Date.now();
|
||||
if (!input.known) {
|
||||
return { key: "dashboard.greetingOnly", values: { greeting } };
|
||||
}
|
||||
const created = input.createdAt ? Date.parse(input.createdAt) : Number.NaN;
|
||||
const isNew = Number.isFinite(created) && nowMs - created < FIRST_DAY_MS;
|
||||
if (isNew) {
|
||||
return name
|
||||
? { key: "dashboard.welcomeNewWithName", values: { name } }
|
||||
: { key: "dashboard.welcomeNew", values: {} };
|
||||
}
|
||||
return name
|
||||
? { key: "dashboard.welcomeBackWithName", values: { greeting, name } }
|
||||
: { key: "dashboard.welcomeBack", values: { greeting } };
|
||||
}
|
||||
|
||||
export function DashboardHeader({
|
||||
onQuickEntry,
|
||||
}: {
|
||||
@@ -103,22 +140,22 @@ export function DashboardHeader({
|
||||
: hour >= 12 && hour < 18
|
||||
? t("dashboard.greeting.day")
|
||||
: t("dashboard.greeting.evening");
|
||||
const welcomeText =
|
||||
mounted && user?.username && user.username.trim().length > 0
|
||||
? t("dashboard.welcomeBackWithName", {
|
||||
greeting: timeGreeting,
|
||||
name: user.username,
|
||||
})
|
||||
: t("dashboard.welcomeBack", { greeting: timeGreeting });
|
||||
const greeting = greetingLine({
|
||||
greeting: timeGreeting,
|
||||
known: mounted && user != null,
|
||||
name: user?.username?.trim() ? user.username : null,
|
||||
createdAt: user?.createdAt,
|
||||
});
|
||||
const welcomeText = t(greeting.key, greeting.values);
|
||||
|
||||
return (
|
||||
<PageHeader
|
||||
title={t("dashboard.title")}
|
||||
description={
|
||||
/* `min-h-5` reserves the greeting's line box from the SSR
|
||||
pass on: the text personalises (name appended) on the first
|
||||
client re-render after hydration, and the reserved line
|
||||
keeps the header from collapsing/growing around that swap. */
|
||||
pass on: the text personalises (name and welcome appended) on
|
||||
the first client re-render after hydration, and the reserved
|
||||
line keeps the header from collapsing/growing around that swap. */
|
||||
<span
|
||||
data-slot="dashboard-header-greeting"
|
||||
className="block min-h-5 truncate"
|
||||
|
||||
@@ -895,7 +895,7 @@ export interface SendCoachMessageParams {
|
||||
conversationId?: string;
|
||||
message: string;
|
||||
prefill?: string;
|
||||
locale?: "en" | "de";
|
||||
locale?: import("@/lib/i18n/config").Locale;
|
||||
/**
|
||||
* v1.4.20.1 — optional snapshot scope (per-source toggles + window)
|
||||
* driven by the sources-rail picker. When omitted the server falls
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* The language reaches the account only when there is an account.
|
||||
*
|
||||
* The provider used to PUT the locale on every mount, signed in or not. A
|
||||
* signed-out page got a 401, the once-per-tab marker was never set, and every
|
||||
* signed-out page load sent the refused write again. The write now belongs to
|
||||
* `LocaleServerSyncBridge`, which waits for the account payload without
|
||||
* fetching it and then sends the choice once.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { authUser, renderWithApp } from "../../../../tests/browser/render";
|
||||
import { stubFetch } from "../../../../tests/browser/fetch-stub";
|
||||
import { LocaleServerSyncBridge } from "../locale-server-sync-bridge";
|
||||
|
||||
const LOCALE_PUT = "/api/auth/me/locale";
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
}
|
||||
|
||||
describe("LocaleServerSyncBridge", () => {
|
||||
it("sends nothing while nobody is signed in, and never asks who is", async () => {
|
||||
const fetchStub = stubFetch().on("PUT", LOCALE_PUT, { data: null });
|
||||
await renderWithApp(<LocaleServerSyncBridge />, {
|
||||
user: null,
|
||||
locale: "de",
|
||||
});
|
||||
await settle();
|
||||
expect(fetchStub.calls("PUT", LOCALE_PUT)).toHaveLength(0);
|
||||
expect(fetchStub.calls("GET", "/api/auth/me")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sends the choice made while signed out once an account appears", async () => {
|
||||
const fetchStub = stubFetch().on("PUT", LOCALE_PUT, { data: null });
|
||||
const { queryClient } = await renderWithApp(<LocaleServerSyncBridge />, {
|
||||
user: null,
|
||||
locale: "de",
|
||||
});
|
||||
await settle();
|
||||
expect(fetchStub.calls("PUT", LOCALE_PUT)).toHaveLength(0);
|
||||
|
||||
queryClient.setQueryData(
|
||||
queryKeys.authMe(),
|
||||
authUser({ id: "account-signed-in-later" }),
|
||||
);
|
||||
await expect.poll(() => fetchStub.calls("PUT", LOCALE_PUT).length).toBe(1);
|
||||
expect(fetchStub.calls("PUT", LOCALE_PUT)[0].body).toEqual({
|
||||
locale: "de",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes once per tab for the same account and language", async () => {
|
||||
const fetchStub = stubFetch().on("PUT", LOCALE_PUT, { data: null });
|
||||
const first = await renderWithApp(<LocaleServerSyncBridge />, {
|
||||
user: { id: "account-already-synced" },
|
||||
locale: "en",
|
||||
});
|
||||
await expect.poll(() => fetchStub.calls("PUT", LOCALE_PUT).length).toBe(1);
|
||||
await first.screen.unmount();
|
||||
|
||||
await renderWithApp(<LocaleServerSyncBridge />, {
|
||||
user: { id: "account-already-synced" },
|
||||
locale: "en",
|
||||
});
|
||||
await settle();
|
||||
expect(fetchStub.calls("PUT", LOCALE_PUT)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { fetchMe } from "@/hooks/use-auth";
|
||||
import { persistLocaleToServer, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* Keeps `User.locale` in step with the language on screen, for a signed-in
|
||||
* account only, so the proactive Coach nudge and the other cookie-blind
|
||||
* background paths write in the language the person reads.
|
||||
*
|
||||
* It watches the account payload without fetching it (`enabled: false`): the
|
||||
* shell already reads `/api/auth/me` on every signed-in page, and a signed-out
|
||||
* page has no account to write to. Fetching here would trade the refused
|
||||
* locale PUT for a refused account read on every signed-out load. Same key
|
||||
* and same fetcher as `useAuth`, so the cache entry is shared, not shadowed.
|
||||
*
|
||||
* Fires when an account appears (right after signing in, carrying the choice
|
||||
* made on the sign-in page) and on every switch; `persistLocaleToServer`
|
||||
* skips a value this tab already wrote for this account.
|
||||
*/
|
||||
export function LocaleServerSyncBridge() {
|
||||
const { locale } = useTranslations();
|
||||
const { data: account } = useQuery({
|
||||
queryKey: queryKeys.authMe(),
|
||||
queryFn: fetchMe,
|
||||
enabled: false,
|
||||
});
|
||||
const accountId = account?.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId) persistLocaleToServer(locale, accountId);
|
||||
}, [locale, accountId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import { VersionPoller } from "@/components/version-poller";
|
||||
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
|
||||
import { SharedRecordGrantLossBridge } from "@/components/layout/shared-record-grant-loss-bridge";
|
||||
import { RecordSessionFenceBridge } from "@/components/layout/record-session-fence-bridge";
|
||||
import { OidcReproofReturnBridge } from "@/components/auth/oidc-reproof-return";
|
||||
import { LocaleServerSyncBridge } from "@/components/layout/locale-server-sync-bridge";
|
||||
import { useAuth, type AccountAccessStatus } from "@/hooks/use-auth";
|
||||
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
@@ -432,6 +434,8 @@ export function Providers({
|
||||
in its own account, rather than asking every call site to. */}
|
||||
<SharedRecordGrantLossBridge />
|
||||
<RecordSessionFenceBridge />
|
||||
<OidcReproofReturnBridge />
|
||||
<LocaleServerSyncBridge />
|
||||
{children}
|
||||
<AppToaster />
|
||||
<KeyboardInsetBridge />
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The web side of confirming at the identity provider (#1296): the dialogs
|
||||
* offer it when the server names it, the round trip starts from and returns
|
||||
* to the page the person is on, and the outcome marker is read once.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { oidcReproofStartUrl } from "../existing-factor-reauth-dialog";
|
||||
import { passkeyProofChoices } from "../passkey-list-section";
|
||||
import { createRecentProofFlow } from "../recent-proof-flow";
|
||||
import { readReproofReturn } from "@/components/auth/oidc-reproof-return";
|
||||
|
||||
describe("passkeyProofChoices", () => {
|
||||
it("offers only what the account can give once its list is known", () => {
|
||||
expect(passkeyProofChoices(["oidc"])).toEqual(["oidc"]);
|
||||
expect(passkeyProofChoices(["password", "oidc", "passkey"])).toEqual([
|
||||
"password",
|
||||
"oidc",
|
||||
"passkey",
|
||||
]);
|
||||
expect(passkeyProofChoices(["totp", "something-new"])).toEqual(["totp"]);
|
||||
});
|
||||
|
||||
it("falls back to the four body proofs while the list is loading", () => {
|
||||
expect(passkeyProofChoices(undefined)).toEqual([
|
||||
"password",
|
||||
"totp",
|
||||
"passkey",
|
||||
"webauthn",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("oidcReproofStartUrl", () => {
|
||||
it("names the current page as the way back, without an old outcome", () => {
|
||||
const href = oidcReproofStartUrl({
|
||||
pathname: "/settings/api",
|
||||
search: "?tab=mcp&reproof=failed",
|
||||
});
|
||||
const url = new URL(href, "https://healthlog.example.test");
|
||||
expect(url.pathname).toBe("/api/auth/oidc/reproof");
|
||||
expect(url.searchParams.get("next")).toBe("/settings/api?tab=mcp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readReproofReturn", () => {
|
||||
it("reads a known outcome off the query string", () => {
|
||||
expect(readReproofReturn("?tab=mcp&reproof=done")).toBe("done");
|
||||
expect(readReproofReturn("?reproof=failed")).toBe("failed");
|
||||
});
|
||||
|
||||
it("ignores an address without a known outcome", () => {
|
||||
expect(readReproofReturn("")).toBeNull();
|
||||
expect(readReproofReturn("?reproof=maybe")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the re-proof dialog flow", () => {
|
||||
it("opens with the identity provider when that is the account's only way", async () => {
|
||||
const setMethods = vi.fn();
|
||||
const flow = createRecentProofFlow({
|
||||
view: { setMethods, setPending: vi.fn(), setError: vi.fn() },
|
||||
submit: vi.fn(),
|
||||
messages: () => ({ signInAgain: "sign in again", failed: "failed" }),
|
||||
});
|
||||
void flow
|
||||
.run(async () => {
|
||||
throw new ApiError("Confirm it is you", 401, {
|
||||
errorCode: "auth.reproof.required",
|
||||
methods: ["oidc"],
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
await vi.waitFor(() => expect(setMethods).toHaveBeenCalledWith(["oidc"]));
|
||||
flow.cancel();
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,30 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
|
||||
export type ReauthMethod = "password" | "totp" | "passkey" | "webauthn";
|
||||
export type ReauthMethod =
|
||||
"password" | "oidc" | "totp" | "passkey" | "webauthn";
|
||||
|
||||
/**
|
||||
* Where the browser goes to confirm at the identity provider: the re-proof
|
||||
* start, told to come back to the page it is on now. A `reproof` outcome left
|
||||
* on the current address from an earlier round trip is dropped, so the page
|
||||
* does not come back announcing the old one.
|
||||
*/
|
||||
export function oidcReproofStartUrl(current: {
|
||||
pathname: string;
|
||||
search: string;
|
||||
}): string {
|
||||
const params = new URLSearchParams(current.search);
|
||||
params.delete("reproof");
|
||||
const query = params.toString();
|
||||
const next = `${current.pathname}${query ? `?${query}` : ""}`;
|
||||
return `/api/auth/oidc/reproof?${new URLSearchParams({ next }).toString()}`;
|
||||
}
|
||||
|
||||
/** Leave for the identity provider. The page unloads; nothing resolves. */
|
||||
export function startOidcReproof(): void {
|
||||
window.location.assign(oidcReproofStartUrl(window.location));
|
||||
}
|
||||
|
||||
/** The body the enrollment routes accept as a fresh existing-factor proof. */
|
||||
export type ExistingFactorProof =
|
||||
@@ -46,23 +69,19 @@ export function isReproofRequired(err: unknown): boolean {
|
||||
|
||||
const REAUTH_METHODS: readonly ReauthMethod[] = [
|
||||
"password",
|
||||
"oidc",
|
||||
"totp",
|
||||
"passkey",
|
||||
"webauthn",
|
||||
];
|
||||
|
||||
/**
|
||||
* The proofs the refusal says this account can give, or null when it names
|
||||
* none. On an account with a second factor the server leaves the password out,
|
||||
* so the dialog does not offer a choice that can only be refused.
|
||||
*/
|
||||
/**
|
||||
* The refusal says the account holds nothing it could re-prove here: an
|
||||
* account that signs in through an identity provider only, with no password,
|
||||
* passkey or second factor. A dialog would only offer it a password it does
|
||||
* not have, so the callers say "sign in again" instead — a fresh sign-in is
|
||||
* the proof such an account can give (`Session.createdAt` within five
|
||||
* minutes).
|
||||
* The refusal says the account holds nothing it could re-prove here: no
|
||||
* password, passkey or second factor, and no identity provider to confirm at
|
||||
* (an account whose provider the operator has since switched off). A dialog
|
||||
* would only offer it a password it does not have, so the callers say "sign
|
||||
* in again" instead — a fresh sign-in is the proof such an account can give
|
||||
* (`Session.createdAt` within five minutes).
|
||||
*/
|
||||
export function nothingToReprove(err: unknown): boolean {
|
||||
return (
|
||||
@@ -72,6 +91,11 @@ export function nothingToReprove(err: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The proofs the refusal says this account can give, or null when it names
|
||||
* none. On an account with a second factor the server leaves the password out,
|
||||
* so the dialog does not offer a choice that can only be refused.
|
||||
*/
|
||||
export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
|
||||
if (!(err instanceof ApiError)) return null;
|
||||
const raw = err.meta?.methods;
|
||||
@@ -87,7 +111,9 @@ export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
|
||||
* new factor is added. A passkey or security-key assertion begins at the
|
||||
* cookie-only `/api/auth/passkey/register-options` re-proof arm, which issues
|
||||
* an assertion challenge and nothing else; the resulting proof is handed to
|
||||
* `onProof`, which retries the enrollment call with it.
|
||||
* `onProof`, which retries the enrollment call with it. The identity provider
|
||||
* is not a proof in the body: choosing it leaves the page for the provider and
|
||||
* comes back to it, and the action is then repeated by hand.
|
||||
*/
|
||||
export function ExistingFactorReauthDialog({
|
||||
open,
|
||||
@@ -117,6 +143,11 @@ export function ExistingFactorReauthDialog({
|
||||
|
||||
async function submit() {
|
||||
setAssertionError(null);
|
||||
if (method === "oidc") {
|
||||
setAsserting(true);
|
||||
startOidcReproof();
|
||||
return;
|
||||
}
|
||||
if (method === "password") {
|
||||
onProof({ method, password: secret });
|
||||
return;
|
||||
@@ -152,6 +183,7 @@ export function ExistingFactorReauthDialog({
|
||||
|
||||
const labels: Record<ReauthMethod, string> = {
|
||||
password: t("settings.passkeyReauth.methods.password"),
|
||||
oidc: t("settings.passkeyReauth.methods.oidc"),
|
||||
totp: t("settings.passkeyReauth.methods.totp"),
|
||||
passkey: t("settings.passkeyReauth.methods.passkey"),
|
||||
webauthn: t("settings.passkeyReauth.methods.webauthn"),
|
||||
@@ -223,6 +255,10 @@ export function ExistingFactorReauthDialog({
|
||||
/>
|
||||
)}
|
||||
|
||||
{method === "oidc" && (
|
||||
<p className="text-sm">{t("settings.passkeyReauth.oidcHint")}</p>
|
||||
)}
|
||||
|
||||
{shown && (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -253,7 +289,9 @@ export function ExistingFactorReauthDialog({
|
||||
{busy && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.passkeyReauth.verifyAndContinue")}
|
||||
{method === "oidc"
|
||||
? t("settings.passkeyReauth.continueToProvider")
|
||||
: t("settings.passkeyReauth.verifyAndContinue")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -20,6 +20,7 @@ interface MfaStatus {
|
||||
recoveryCodesRemaining: number;
|
||||
webauthn: WebauthnKeyInfo[];
|
||||
passkeyNudgeDismissed: boolean;
|
||||
proofMethods: string[];
|
||||
}
|
||||
|
||||
interface PasskeyInfo {
|
||||
@@ -71,7 +72,10 @@ export function SecuritySection() {
|
||||
totpEnabled={status.totp.enabled}
|
||||
/>
|
||||
|
||||
<PasskeyListSection isAuthenticated={isAuthenticated} />
|
||||
<PasskeyListSection
|
||||
isAuthenticated={isAuthenticated}
|
||||
proofMethods={status.proofMethods}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -45,6 +45,11 @@ import {
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { describeStepUp } from "./security-keys-card";
|
||||
import {
|
||||
isReproofRequired,
|
||||
startOidcReproof,
|
||||
type ReauthMethod,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
import { ReproofCancelledError, useRecentProof } from "./use-recent-proof";
|
||||
|
||||
interface PasskeyInfo {
|
||||
@@ -56,17 +61,102 @@ interface PasskeyInfo {
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
type ReauthMethod = "password" | "passkey" | "totp" | "webauthn";
|
||||
|
||||
type ExistingFactorProof =
|
||||
| { method: "password"; password: string }
|
||||
| { method: "totp"; code: string }
|
||||
| { method: "oidc" }
|
||||
| {
|
||||
method: "passkey" | "webauthn";
|
||||
challengeId: string;
|
||||
credential: unknown;
|
||||
};
|
||||
|
||||
/** What the card offers while the account's own list has not arrived. */
|
||||
const FALLBACK_METHODS: readonly ReauthMethod[] = [
|
||||
"password",
|
||||
"totp",
|
||||
"passkey",
|
||||
"webauthn",
|
||||
];
|
||||
|
||||
/**
|
||||
* The proofs to offer before adding a passkey: the account's own list when the
|
||||
* status read has it, so an account without a password is not handed a
|
||||
* password field, and the four body proofs otherwise.
|
||||
*/
|
||||
export function passkeyProofChoices(
|
||||
proofMethods: readonly string[] | undefined,
|
||||
): ReauthMethod[] {
|
||||
if (!proofMethods) return [...FALLBACK_METHODS];
|
||||
const known: readonly ReauthMethod[] = [
|
||||
"password",
|
||||
"oidc",
|
||||
"totp",
|
||||
"passkey",
|
||||
"webauthn",
|
||||
];
|
||||
return proofMethods.filter((m): m is ReauthMethod =>
|
||||
known.includes(m as ReauthMethod),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove an existing factor, then run the registration ceremony. The proof
|
||||
* finishes before the first registration challenge is requested, so a
|
||||
* cancelled or failed proof leaves no enrollable challenge behind.
|
||||
*/
|
||||
async function addPasskey(method: ReauthMethod, secret: string): Promise<void> {
|
||||
const webauthn = await import("@simplewebauthn/browser");
|
||||
let freshFactor: ExistingFactorProof;
|
||||
|
||||
if (method === "oidc") {
|
||||
// No secret: the proof is a provider re-proof already on the session.
|
||||
freshFactor = { method };
|
||||
} else if (method === "password") {
|
||||
freshFactor = { method, password: secret };
|
||||
} else if (method === "totp") {
|
||||
freshFactor = { method, code: secret };
|
||||
} else {
|
||||
const assertionChallenge = await apiPost<{
|
||||
options: Parameters<
|
||||
typeof webauthn.startAuthentication
|
||||
>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/passkey/register-options", { method });
|
||||
const credential = await webauthn.startAuthentication({
|
||||
optionsJSON: assertionChallenge.options,
|
||||
});
|
||||
freshFactor = {
|
||||
method,
|
||||
challengeId: assertionChallenge.challengeId,
|
||||
credential,
|
||||
};
|
||||
}
|
||||
|
||||
let begun: {
|
||||
options: Parameters<typeof webauthn.startRegistration>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
};
|
||||
try {
|
||||
begun = await apiPost("/api/auth/passkey/register-options", freshFactor);
|
||||
} catch (err) {
|
||||
// No provider re-proof on the session yet: go and get one. The page
|
||||
// unloads and comes back here, so the mutation stays pending rather
|
||||
// than flashing an error on the way out.
|
||||
if (method !== "oidc" || !isReproofRequired(err)) throw err;
|
||||
startOidcReproof();
|
||||
return new Promise<never>(() => {});
|
||||
}
|
||||
const { options, challengeId } = begun;
|
||||
const credential = await webauthn.startRegistration({
|
||||
optionsJSON: options,
|
||||
});
|
||||
await apiPost("/api/auth/passkey/register-verify", {
|
||||
challengeId,
|
||||
credential,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary-passkey management: list (name, device, backup, created, last used),
|
||||
* add (registration ceremony), rename, and delete. The passwordless-primary
|
||||
@@ -74,8 +164,11 @@ type ExistingFactorProof =
|
||||
*/
|
||||
export function PasskeyListSection({
|
||||
isAuthenticated,
|
||||
proofMethods,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
/** `proofMethods` from `GET /api/auth/me/mfa`. */
|
||||
proofMethods?: readonly string[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -83,7 +176,10 @@ export function PasskeyListSection({
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [reauthOpen, setReauthOpen] = useState(false);
|
||||
const [reauthMethod, setReauthMethod] = useState<ReauthMethod>("password");
|
||||
const choices = passkeyProofChoices(proofMethods);
|
||||
const [reauthMethod, setReauthMethod] = useState<ReauthMethod>(
|
||||
choices[0] ?? "password",
|
||||
);
|
||||
const [reauthSecret, setReauthSecret] = useState("");
|
||||
|
||||
const { data: passkeys } = useQuery({
|
||||
@@ -93,56 +189,18 @@ export function PasskeyListSection({
|
||||
});
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: async ({
|
||||
mutationFn: ({
|
||||
method,
|
||||
secret,
|
||||
}: {
|
||||
method: ReauthMethod;
|
||||
secret: string;
|
||||
}) => {
|
||||
const webauthn = await import("@simplewebauthn/browser");
|
||||
let freshFactor: ExistingFactorProof;
|
||||
|
||||
if (method === "password") {
|
||||
freshFactor = { method, password: secret };
|
||||
} else if (method === "totp") {
|
||||
freshFactor = { method, code: secret };
|
||||
} else {
|
||||
const assertionChallenge = await apiPost<{
|
||||
options: Parameters<
|
||||
typeof webauthn.startAuthentication
|
||||
>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/passkey/register-options", { method });
|
||||
const credential = await webauthn.startAuthentication({
|
||||
optionsJSON: assertionChallenge.options,
|
||||
});
|
||||
freshFactor = {
|
||||
method,
|
||||
challengeId: assertionChallenge.challengeId,
|
||||
credential,
|
||||
};
|
||||
}
|
||||
|
||||
const { options, challengeId } = await apiPost<{
|
||||
options: Parameters<
|
||||
typeof webauthn.startRegistration
|
||||
>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/passkey/register-options", freshFactor);
|
||||
const credential = await webauthn.startRegistration({
|
||||
optionsJSON: options,
|
||||
});
|
||||
await apiPost("/api/auth/passkey/register-verify", {
|
||||
challengeId,
|
||||
credential,
|
||||
});
|
||||
},
|
||||
}) => addPasskey(method, secret),
|
||||
onSuccess: () => {
|
||||
setMsg(null);
|
||||
setReauthOpen(false);
|
||||
setReauthSecret("");
|
||||
setReauthMethod("password");
|
||||
setReauthMethod(choices[0] ?? "password");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
||||
toastWrittenOutcome("success", t("settings.passkeyAddedToast"));
|
||||
},
|
||||
@@ -195,6 +253,14 @@ export function PasskeyListSection({
|
||||
},
|
||||
});
|
||||
|
||||
const methodLabels: Record<ReauthMethod, string> = {
|
||||
password: t("settings.passkeyReauth.methods.password"),
|
||||
oidc: t("settings.passkeyReauth.methods.oidc"),
|
||||
totp: t("settings.passkeyReauth.methods.totp"),
|
||||
passkey: t("settings.passkeyReauth.methods.passkey"),
|
||||
webauthn: t("settings.passkeyReauth.methods.webauthn"),
|
||||
};
|
||||
|
||||
const DEVICE_TYPE_LABELS: Record<string, string> = {
|
||||
singleDevice: t("settings.singleDevice"),
|
||||
multiDevice: t("settings.multiDevice"),
|
||||
@@ -350,7 +416,14 @@ export function PasskeyListSection({
|
||||
onOpenChange={(open) => {
|
||||
if (add.isPending) return;
|
||||
setReauthOpen(open);
|
||||
if (open) setMsg(null);
|
||||
if (open) {
|
||||
setMsg(null);
|
||||
// The choice the account's list leads with, in case the list
|
||||
// arrived after the card first rendered.
|
||||
if (!choices.includes(reauthMethod)) {
|
||||
setReauthMethod(choices[0] ?? "password");
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SettingsCardActions>
|
||||
@@ -384,14 +457,7 @@ export function PasskeyListSection({
|
||||
role="group"
|
||||
aria-label={t("settings.passkeyReauth.methodLabel")}
|
||||
>
|
||||
{(
|
||||
[
|
||||
["password", t("settings.passkeyReauth.methods.password")],
|
||||
["totp", t("settings.passkeyReauth.methods.totp")],
|
||||
["passkey", t("settings.passkeyReauth.methods.passkey")],
|
||||
["webauthn", t("settings.passkeyReauth.methods.webauthn")],
|
||||
] satisfies Array<[ReauthMethod, string]>
|
||||
).map(([method, label]) => (
|
||||
{choices.map((method) => (
|
||||
<Button
|
||||
key={method}
|
||||
type="button"
|
||||
@@ -407,11 +473,15 @@ export function PasskeyListSection({
|
||||
setMsg(null);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{methodLabels[method]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{reauthMethod === "oidc" && (
|
||||
<p className="text-sm">{t("settings.passkeyReauth.oidcHint")}</p>
|
||||
)}
|
||||
|
||||
{reauthMethod === "password" && (
|
||||
<Input
|
||||
type="password"
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
|
||||
const KNOWN_METHODS: readonly ReauthMethod[] = [
|
||||
"password",
|
||||
"oidc",
|
||||
"totp",
|
||||
"webauthn",
|
||||
"passkey",
|
||||
@@ -93,7 +94,9 @@ export function createRecentProofFlow(deps: {
|
||||
if (!isProofRefusal(err)) throw err;
|
||||
const offered = methodsFrom(err as ApiError);
|
||||
// Nothing to re-prove in place: an account that signs in through an
|
||||
// identity provider only. A fresh sign-in is the proof it can give.
|
||||
// identity provider the instance no longer offers. A fresh sign-in is
|
||||
// the proof it can give. One that can still reach its provider is
|
||||
// offered `oidc` instead, which leaves the page and comes back to it.
|
||||
if (offered.length === 0) {
|
||||
throw new ApiError(deps.messages().signInAgain, 401, {
|
||||
errorCode: "auth.reproof.sign_in_again",
|
||||
|
||||
@@ -65,6 +65,11 @@ export type AuthTourProgress = TourProgress;
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
/**
|
||||
* When the account was created (ISO). Absent from an older server image,
|
||||
* which every reader treats as an established account.
|
||||
*/
|
||||
createdAt?: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
heightCm: number | null;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Asking for an account to be deleted: the one path the account's own
|
||||
* deletion (`DELETE /api/settings/account`) and an admin's deletion of
|
||||
* another account (`DELETE /api/admin/users/{id}`) both take.
|
||||
*
|
||||
* With a grace window (`ACCOUNT_DELETION_GRACE_DAYS`) the account is locked:
|
||||
* every credential is revoked at once, the record stays exactly as it is, and
|
||||
* the purge job erases it when the window ends; an admin can undo it until
|
||||
* then. With the window at 0 it is erased here, in the same transaction.
|
||||
*
|
||||
* The Guardian invariant (an account may not leave a managed profile without
|
||||
* a Guardian) and the admin roster (the last admin who can sign in stays) are
|
||||
* decided inside the transaction, under their locks, and refuse by throwing
|
||||
* `LastManagedGuardianError` or `LastAdminError`.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { kickOffhostPurge } from "@/lib/jobs/offhost-purge";
|
||||
import { deleteGuardianAccountWithLifecycle } from "@/lib/managed-profiles/lifecycle";
|
||||
import { assertAnotherAdminStays } from "@/lib/auth/admin-roster";
|
||||
import {
|
||||
accountDeletionGraceDays,
|
||||
accountDeletionPurgeAt,
|
||||
} from "@/lib/account-deletion/grace";
|
||||
import {
|
||||
eraseAccount,
|
||||
lockAccountForDeletion,
|
||||
} from "@/lib/account-deletion/lifecycle";
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
export async function requestAccountDeletion(input: {
|
||||
userId: string;
|
||||
/** Whether the account being deleted is an admin (the roster check). */
|
||||
targetIsAdmin: boolean;
|
||||
/** Who asked: the account itself, or the admin acting on it. */
|
||||
requestedById: string;
|
||||
/**
|
||||
* Runs first inside the transaction, before anything is written: the place
|
||||
* for a refusal that must be decided under the same locks (throw), and for
|
||||
* the audit rows, which then cannot outlive a refusal.
|
||||
*/
|
||||
inTransaction: (tx: Tx, purgeAt: Date | null) => Promise<void>;
|
||||
}): Promise<{ purgeAt: Date | null }> {
|
||||
const requestedAt = new Date();
|
||||
const purgeAt = accountDeletionPurgeAt(
|
||||
requestedAt,
|
||||
accountDeletionGraceDays(),
|
||||
);
|
||||
|
||||
await deleteGuardianAccountWithLifecycle(
|
||||
input.userId,
|
||||
async (tx) => {
|
||||
// Checked again under the roster lock: two admins deleting at the same
|
||||
// moment must not each count the other.
|
||||
if (input.targetIsAdmin) await assertAnotherAdminStays(tx, input.userId);
|
||||
await input.inTransaction(tx, purgeAt);
|
||||
|
||||
if (purgeAt) {
|
||||
// Locked, not erased: credentials revoked in this transaction, the
|
||||
// record untouched until the purge job reaches `purgeAt`.
|
||||
await lockAccountForDeletion(tx, {
|
||||
userId: input.userId,
|
||||
requestedById: input.requestedById,
|
||||
requestedAt,
|
||||
purgeAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No window: erase now, inside the same record lifecycle transaction so
|
||||
// a refusal leaves every account state intact (sessions, the account's
|
||||
// audit history — GDPR Art. 17 — the off-host purge request, then the
|
||||
// row with everything cascading from it).
|
||||
await eraseAccount(tx, input.userId, "account_deleted");
|
||||
},
|
||||
{ erasing: purgeAt === null },
|
||||
);
|
||||
|
||||
if (!purgeAt) await kickOffhostPurge();
|
||||
return { purgeAt };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* The chat request bodies take every language the app ships.
|
||||
*
|
||||
* The Coach chat, the document chat and the fenced chat declared `locale` as
|
||||
* the pair de/en, so a body naming any other app language (French, Korean,
|
||||
* Brazilian Portuguese, …) was refused with 422 before the reply language was
|
||||
* ever resolved, although `resolveServerLocale` and the prompts downstream
|
||||
* carry every locale.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import { coachChatRequestSchema } from "@/lib/ai/coach/types";
|
||||
import {
|
||||
documentChatRequestSchema,
|
||||
fencedChatRequestSchema,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
const SCHEMAS = {
|
||||
coach: (locale: string) =>
|
||||
coachChatRequestSchema.safeParse({ message: "Hello", locale }),
|
||||
document: (locale: string) =>
|
||||
documentChatRequestSchema.safeParse({ message: "Hello", locale }),
|
||||
fenced: (locale: string) =>
|
||||
fencedChatRequestSchema.safeParse({ message: "Hello", locale }),
|
||||
};
|
||||
|
||||
describe.each(Object.entries(SCHEMAS))("%s chat request", (_name, parse) => {
|
||||
it.each([...locales])("accepts %s", (locale) => {
|
||||
const parsed = parse(locale);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data?.locale).toBe(locale);
|
||||
});
|
||||
|
||||
it("refuses a language the app does not ship", () => {
|
||||
expect(parse("xx").success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
* can pass messages through with minimal translation.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
|
||||
import { COACH_SCOPE_SOURCES } from "./scope-sources";
|
||||
|
||||
@@ -85,15 +86,14 @@ export const coachChatRequestSchema = z.object({
|
||||
conversationId: z.string().min(1).max(64).optional(),
|
||||
message: z.string().min(1).max(4000),
|
||||
prefill: z.string().max(2000).optional(),
|
||||
locale: z.enum(["en", "de"]).optional(),
|
||||
locale: z.enum(locales).optional(),
|
||||
scope: coachScopeSchema.optional(),
|
||||
/**
|
||||
* v1.16.6 — guided clarifying-questions flow: the pending question
|
||||
* this message answers. The question bubble is client-side only
|
||||
* (never persisted), so without this context the model would see a
|
||||
* bare answer ("since 2019, with medication") and could not react
|
||||
* to it. Rides the prompt as delimited user-provided context; the
|
||||
* persisted user turn stays the answer alone.
|
||||
* v1.16.6 — guided clarifying-questions flow: the pending question this
|
||||
* message answers. The question bubble is client-side only (never persisted),
|
||||
* so without this context the model would see a bare answer ("since 2019,
|
||||
* with medication") and could not react to it. Rides the prompt as delimited
|
||||
* user-provided context; the persisted user turn stays the answer alone.
|
||||
*/
|
||||
guidedQuestion: z.string().min(1).max(500).optional(),
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Confirming at the identity provider (#1296): which accounts are offered it,
|
||||
* what the callback accepts as fresh, whose identity counts, and what the
|
||||
* sealed state must carry.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
findUser: vi.fn<(args: unknown) => Promise<unknown>>(),
|
||||
updateSession: vi.fn<(args: unknown) => Promise<unknown>>(),
|
||||
countKeys: vi.fn<(args: unknown) => Promise<number>>(),
|
||||
countPasskeys: vi.fn<(args: unknown) => Promise<number>>(),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: db.findUser },
|
||||
session: { update: db.updateSession },
|
||||
webauthnMfaCredential: { count: db.countKeys },
|
||||
passkey: { count: db.countPasskeys },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
refundRateLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((s: string) => s),
|
||||
encrypt: vi.fn((s: string) => s),
|
||||
}));
|
||||
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { refundRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
isFirstFactorOnlyProof,
|
||||
recentProofMethods,
|
||||
} from "@/lib/auth/second-factor";
|
||||
import {
|
||||
isFreshAuthTime,
|
||||
isLinkedIdentity,
|
||||
isReproofSession,
|
||||
recordOidcReproof,
|
||||
reproofReturnUrl,
|
||||
} from "@/lib/auth/oidc-reproof";
|
||||
import { readStoredOidcState } from "@/lib/auth/oidc-callback/state";
|
||||
|
||||
const ISSUER = "https://idp.example.test";
|
||||
const OIDC_ENV = {
|
||||
OIDC_ISSUER_URL: ISSUER,
|
||||
OIDC_CLIENT_ID: "client",
|
||||
OIDC_CLIENT_SECRET: "secret",
|
||||
};
|
||||
|
||||
function accountRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
passwordHash: null,
|
||||
oidcIssuer: ISSUER,
|
||||
oidcSub: "sub-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.assign(process.env, OIDC_ENV);
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://healthlog.example.test";
|
||||
db.countKeys.mockResolvedValue(0);
|
||||
db.countPasskeys.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of Object.keys(OIDC_ENV)) delete process.env[key];
|
||||
});
|
||||
|
||||
describe("recentProofMethods offers the identity provider", () => {
|
||||
const user = { id: "u1", totpConfirmedAt: null };
|
||||
|
||||
it("to an account without a second factor whose identity is linked", async () => {
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
expect(await recentProofMethods(user, false)).toEqual(["oidc"]);
|
||||
});
|
||||
|
||||
it("beside the password and passkey the account also holds", async () => {
|
||||
db.countPasskeys.mockResolvedValue(1);
|
||||
db.findUser.mockResolvedValue(accountRow({ passwordHash: "hash" }));
|
||||
expect(await recentProofMethods(user, false)).toEqual([
|
||||
"password",
|
||||
"oidc",
|
||||
"passkey",
|
||||
]);
|
||||
});
|
||||
|
||||
it("not to an account without a linked identity", async () => {
|
||||
db.findUser.mockResolvedValue(
|
||||
accountRow({ oidcIssuer: null, oidcSub: null }),
|
||||
);
|
||||
expect(await recentProofMethods(user, false)).toEqual([]);
|
||||
});
|
||||
|
||||
it("not when the instance has no provider configured", async () => {
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
expect(await recentProofMethods(user, false)).toEqual([]);
|
||||
});
|
||||
|
||||
it("not to an account with a second factor, where it could not clear the gate", async () => {
|
||||
db.countKeys.mockResolvedValue(1);
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
expect(await recentProofMethods(user, true)).toEqual(["webauthn"]);
|
||||
});
|
||||
|
||||
it("and never counts it as a second factor", () => {
|
||||
expect(isFirstFactorOnlyProof("oidc")).toBe(true);
|
||||
expect(isFirstFactorOnlyProof("password")).toBe(true);
|
||||
expect(isFirstFactorOnlyProof("passkey")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFreshAuthTime", () => {
|
||||
const now = 1_800_000_000_000;
|
||||
const seconds = (ms: number) => Math.floor(ms / 1000);
|
||||
|
||||
it("accepts an authentication inside the five-minute window", () => {
|
||||
expect(isFreshAuthTime(seconds(now - 10_000), now)).toBe(true);
|
||||
expect(isFreshAuthTime(seconds(now - 300_000), now)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses one older than the window", () => {
|
||||
expect(isFreshAuthTime(seconds(now - 301_000), now)).toBe(false);
|
||||
expect(isFreshAuthTime(seconds(now - 3_600_000), now)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a token that does not say when", () => {
|
||||
expect(isFreshAuthTime(null, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("tolerates a provider clock a minute ahead, and no more", () => {
|
||||
expect(isFreshAuthTime(seconds(now + 30_000), now)).toBe(true);
|
||||
expect(isFreshAuthTime(seconds(now + 120_000), now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLinkedIdentity", () => {
|
||||
it("needs both the issuer and the subject to match", () => {
|
||||
const account = { oidcIssuer: ISSUER, oidcSub: "sub-1" };
|
||||
expect(isLinkedIdentity(account, ISSUER, "sub-1")).toBe(true);
|
||||
expect(isLinkedIdentity(account, ISSUER, "sub-2")).toBe(false);
|
||||
expect(isLinkedIdentity(account, "https://other.test", "sub-1")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isLinkedIdentity({ oidcIssuer: null, oidcSub: null }, ISSUER, "sub-1"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isReproofSession", () => {
|
||||
const bound = { reproofUserId: "u1", reproofSessionId: "s1" };
|
||||
|
||||
it("holds only for the session and account the start bound", () => {
|
||||
expect(
|
||||
isReproofSession(bound, { session: { id: "s1" }, user: { id: "u1" } }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isReproofSession(bound, { session: { id: "s2" }, user: { id: "u1" } }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isReproofSession(bound, { session: { id: "s1" }, user: { id: "u2" } }),
|
||||
).toBe(false);
|
||||
expect(isReproofSession(bound, null)).toBe(false);
|
||||
expect(
|
||||
isReproofSession({}, { session: { id: "s1" }, user: { id: "u1" } }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordOidcReproof", () => {
|
||||
const now = 1_800_000_000_000;
|
||||
const args = {
|
||||
userId: "u1",
|
||||
sessionId: "s1",
|
||||
issuer: ISSUER,
|
||||
ipAddress: "203.0.113.5",
|
||||
nowMs: now,
|
||||
};
|
||||
|
||||
it("stamps reproofAt only, refunds the attempt and audits the success", async () => {
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
const outcome = await recordOidcReproof({
|
||||
...args,
|
||||
identity: { sub: "sub-1", authTime: Math.floor(now / 1000) - 5 },
|
||||
});
|
||||
expect(outcome).toEqual({ ok: true });
|
||||
expect(db.updateSession).toHaveBeenCalledWith({
|
||||
where: { id: "s1" },
|
||||
data: { reproofAt: new Date(now) },
|
||||
});
|
||||
expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:u1");
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.reproof.succeeded",
|
||||
expect.objectContaining({ details: { method: "oidc" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a stale auth_time without stamping", async () => {
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
const outcome = await recordOidcReproof({
|
||||
...args,
|
||||
identity: { sub: "sub-1", authTime: Math.floor(now / 1000) - 3600 },
|
||||
});
|
||||
expect(outcome).toEqual({ ok: false, reason: "stale_auth_time" });
|
||||
expect(db.updateSession).not.toHaveBeenCalled();
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.mfa.failed",
|
||||
expect.objectContaining({
|
||||
details: {
|
||||
stage: "oidc_reproof",
|
||||
method: "oidc",
|
||||
reason: "stale_auth_time",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a different subject without stamping", async () => {
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
const outcome = await recordOidcReproof({
|
||||
...args,
|
||||
identity: { sub: "someone-else", authTime: Math.floor(now / 1000) },
|
||||
});
|
||||
expect(outcome).toEqual({ ok: false, reason: "identity_mismatch" });
|
||||
expect(db.updateSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the re-proof return and state", () => {
|
||||
it("returns to the page it left with the outcome attached", () => {
|
||||
const url = reproofReturnUrl("/settings/api?tab=mcp", "done");
|
||||
expect(url.origin).toBe("https://healthlog.example.test");
|
||||
expect(url.pathname).toBe("/settings/api");
|
||||
expect(url.searchParams.get("tab")).toBe("mcp");
|
||||
expect(url.searchParams.get("reproof")).toBe("done");
|
||||
});
|
||||
|
||||
it("reads a reproof state only with both bindings and no other intent's fields", () => {
|
||||
const base = { state: "s", nonce: "n", codeVerifier: "v", next: "/" };
|
||||
const ok = readStoredOidcState(
|
||||
JSON.stringify({
|
||||
...base,
|
||||
intent: "reproof",
|
||||
reproofUserId: "u1",
|
||||
reproofSessionId: "s1",
|
||||
}),
|
||||
);
|
||||
expect(ok?.intent).toBe("reproof");
|
||||
expect(
|
||||
readStoredOidcState(
|
||||
JSON.stringify({ ...base, intent: "reproof", reproofUserId: "u1" }),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
readStoredOidcState(
|
||||
JSON.stringify({
|
||||
...base,
|
||||
intent: "reproof",
|
||||
reproofUserId: "u1",
|
||||
reproofSessionId: "s1",
|
||||
native: true,
|
||||
appCodeChallenge: "c",
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
readStoredOidcState(JSON.stringify({ ...base, intent: "other" })),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,15 @@ const ENV_KEYS = [
|
||||
] as const;
|
||||
const ORIGINAL_ENV: Record<string, string | undefined> = {};
|
||||
|
||||
/**
|
||||
* A verification result carrying only what `verifyIdToken` reads: the claims
|
||||
* and the header `jose` returns beside them.
|
||||
*/
|
||||
type VerifyResult = Awaited<ReturnType<typeof mockJwtVerify>>;
|
||||
function verified(payload: Record<string, unknown>): VerifyResult {
|
||||
return { payload, protectedHeader: { alg: "RS256" } } as VerifyResult;
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, ok = true, status = 200): Response {
|
||||
return {
|
||||
ok,
|
||||
@@ -270,6 +279,25 @@ describe("buildAuthorizationUrl", () => {
|
||||
}),
|
||||
);
|
||||
expect(url.searchParams.get("prompt")).toBe("login");
|
||||
expect(url.searchParams.has("max_age")).toBe(false);
|
||||
});
|
||||
|
||||
it("asks for a fresh, timed authentication for a re-proof", () => {
|
||||
configureEnv();
|
||||
const url = new URL(
|
||||
buildAuthorizationUrl({
|
||||
metadata: METADATA_DOC,
|
||||
config: getOidcConfig()!,
|
||||
state: "state-1",
|
||||
nonce: "nonce-1",
|
||||
codeChallenge: "challenge-1",
|
||||
redirectUri: "https://healthlog.example.com/api/auth/oidc/callback",
|
||||
reauthenticate: true,
|
||||
}),
|
||||
);
|
||||
expect(url.searchParams.get("prompt")).toBe("login");
|
||||
expect(url.searchParams.get("max_age")).toBe("0");
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -332,16 +360,15 @@ describe("verifyIdToken", () => {
|
||||
beforeEach(() => configureEnv());
|
||||
|
||||
it("returns the identity claims on success", async () => {
|
||||
// Test payloads deliberately omit the full JWTVerifyResult shape.
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({
|
||||
sub: "user-sub-1",
|
||||
email: "person@example.com",
|
||||
email_verified: true,
|
||||
name: "Person",
|
||||
nonce: "nonce-1",
|
||||
},
|
||||
} as never);
|
||||
}),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
const identity = await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
@@ -354,13 +381,43 @@ describe("verifyIdToken", () => {
|
||||
email: "person@example.com",
|
||||
emailVerified: true,
|
||||
name: "Person",
|
||||
authTime: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns auth_time when the provider states it, and null otherwise", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({
|
||||
sub: "user-sub-1",
|
||||
nonce: "nonce-1",
|
||||
auth_time: 1_700_000_000,
|
||||
}),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
const stated = await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
config,
|
||||
idToken: "id-token",
|
||||
nonce: "nonce-1",
|
||||
});
|
||||
expect(stated.authTime).toBe(1_700_000_000);
|
||||
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({ sub: "user-sub-1", nonce: "nonce-1", auth_time: "recent" }),
|
||||
);
|
||||
const unstated = await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
config,
|
||||
idToken: "id-token",
|
||||
nonce: "nonce-1",
|
||||
});
|
||||
expect(unstated.authTime).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a nonce mismatch", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: { sub: "user-sub-1", nonce: "wrong-nonce" },
|
||||
} as never);
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({ sub: "user-sub-1", nonce: "wrong-nonce" }),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
await expect(
|
||||
verifyIdToken({
|
||||
@@ -373,9 +430,7 @@ describe("verifyIdToken", () => {
|
||||
});
|
||||
|
||||
it("rejects a token missing sub", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: { nonce: "nonce-1" },
|
||||
} as never);
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(verified({ nonce: "nonce-1" }));
|
||||
const config = getOidcConfig()!;
|
||||
await expect(
|
||||
verifyIdToken({
|
||||
@@ -388,9 +443,9 @@ describe("verifyIdToken", () => {
|
||||
});
|
||||
|
||||
it("verifies with a closed asymmetric algorithm allowlist and bounded clock tolerance", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: { sub: "user-sub-1", nonce: "nonce-1" },
|
||||
} as never);
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({ sub: "user-sub-1", nonce: "nonce-1" }),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
@@ -411,9 +466,9 @@ describe("verifyIdToken", () => {
|
||||
});
|
||||
|
||||
it("pins the JWKS fetch to the issuer's host with a bounded timeout", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: { sub: "user-sub-1", nonce: "nonce-1" },
|
||||
} as never);
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({ sub: "user-sub-1", nonce: "nonce-1" }),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
|
||||
@@ -56,6 +56,21 @@ beforeEach(() => {
|
||||
] as never);
|
||||
});
|
||||
|
||||
describe("applyProfileUpdate locale", () => {
|
||||
it.each(["fr", "pt-BR", "ko"])(
|
||||
"stores %s, a language the app ships",
|
||||
async (locale) => {
|
||||
const result = await applyProfileUpdate(USER_ID, { locale });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ locale }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("applyProfileUpdate timezone validation", () => {
|
||||
it("accepts a valid IANA zone", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, {
|
||||
@@ -271,7 +286,7 @@ describe("applyProfileUpdate partial-field save", () => {
|
||||
it("writes nothing and reports every rejected field when the whole body is invalid", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, {
|
||||
gender: "male",
|
||||
locale: "fr",
|
||||
locale: "xx",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
@@ -290,15 +305,15 @@ describe("applyProfileUpdate top-level error message", () => {
|
||||
// sharpest regression check available for "does the raw Zod default
|
||||
// ever reach the top-level string a person reads". Zod v4's default
|
||||
// enum message is shaped like:
|
||||
// Invalid option: expected one of "de"|"en"
|
||||
// Invalid option: expected one of "de"|"en"|"fr"|…
|
||||
const RAW_ZOD_ENUM_PATTERN = /expected one of/i;
|
||||
|
||||
it("never lets the raw Zod enum message reach the top-level `message`", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, { locale: "fr" });
|
||||
const result = await applyProfileUpdate(USER_ID, { locale: "xx" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.message).not.toMatch(RAW_ZOD_ENUM_PATTERN);
|
||||
expect(result.message).not.toContain('"de"|"en"');
|
||||
expect(result.message).not.toContain('"de"|"en"|"fr"');
|
||||
// Relocated, not deleted: the same detail is still available for
|
||||
// whoever needs it (the iOS `details.issues` contract), just not
|
||||
// in the string a person reads first.
|
||||
@@ -318,7 +333,7 @@ describe("applyProfileUpdate top-level error message", () => {
|
||||
});
|
||||
|
||||
it("carries a stable errorCode so the web client can localize the sentence", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, { locale: "fr" });
|
||||
const result = await applyProfileUpdate(USER_ID, { locale: "xx" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errorCode).toBe("profile.update.nothingSaved");
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import {
|
||||
hasSecondFactorEnrolled,
|
||||
recentProofMethods,
|
||||
type RecentProofMethod,
|
||||
} from "@/lib/auth/second-factor";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
@@ -171,7 +172,7 @@ export async function recordReproofFailure(
|
||||
userId: string,
|
||||
ipAddress: string | null,
|
||||
stage: string,
|
||||
method: ExistingFactorProof["method"],
|
||||
method: RecentProofMethod,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
await auditLog("auth.mfa.failed", {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import {
|
||||
hasSecondFactorEnrolled,
|
||||
isFirstFactorOnlyProof,
|
||||
recentProofMethods,
|
||||
} from "@/lib/auth/second-factor";
|
||||
import { meterAccountWrite } from "@/lib/auth/write-meter";
|
||||
@@ -80,9 +81,10 @@ export async function requireFreshMfa(
|
||||
// does, so the browser can open the re-proof dialog instead of printing a
|
||||
// sentence with nowhere to act on it. Only the possession proofs: a
|
||||
// `POST /api/auth/reproof` with one of them stamps `mfaVerifiedAt`, a
|
||||
// password stamps `reproofAt` only and would leave this gate shut.
|
||||
// password or a provider sign-in stamps `reproofAt` only and would leave
|
||||
// this gate shut.
|
||||
const methods = (await recentProofMethods(sessionData.user)).filter(
|
||||
(method) => method !== "password",
|
||||
(method) => !isFirstFactorOnlyProof(method),
|
||||
);
|
||||
annotate({ meta: { step_up_methods: methods.join(",") } });
|
||||
throw new StepUpRequiredError(
|
||||
|
||||
@@ -27,8 +27,17 @@ export interface StoredOidcState {
|
||||
* names instead of signing in, and only when the cookie still carries this
|
||||
* exact binding. Web only: never together with `native`.
|
||||
*/
|
||||
intent?: "claim";
|
||||
intent?: "claim" | "reproof";
|
||||
claimBinding?: string;
|
||||
/**
|
||||
* Set only for `intent: "reproof"` (`GET /api/auth/oidc/reproof`): the
|
||||
* signed-in account and the session row the round trip confirms. The
|
||||
* callback stamps nothing unless the browser still carries exactly this
|
||||
* session and the provider names exactly this account's linked identity.
|
||||
* Web only, like the claim.
|
||||
*/
|
||||
reproofUserId?: string;
|
||||
reproofSessionId?: string;
|
||||
}
|
||||
|
||||
function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
@@ -50,11 +59,24 @@ function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
if (typeof v.appCodeChallenge !== "string") return false;
|
||||
}
|
||||
if (v.intent !== undefined) {
|
||||
if (v.intent !== "claim") return false;
|
||||
if (typeof v.claimBinding !== "string" || v.claimBinding.length === 0) {
|
||||
if (v.native !== undefined) return false;
|
||||
if (v.intent === "claim") {
|
||||
if (typeof v.claimBinding !== "string" || v.claimBinding.length === 0) {
|
||||
return false;
|
||||
}
|
||||
} else if (v.intent === "reproof") {
|
||||
if (
|
||||
typeof v.reproofUserId !== "string" ||
|
||||
v.reproofUserId.length === 0 ||
|
||||
typeof v.reproofSessionId !== "string" ||
|
||||
v.reproofSessionId.length === 0 ||
|
||||
v.claimBinding !== undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (v.native !== undefined) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Confirming it is you through the identity provider.
|
||||
*
|
||||
* An account created by single sign-on holds no password, passkey or second
|
||||
* factor, so the recent-proof gate (token minting, exports, share links, the
|
||||
* admin data actions) had nothing to ask it for. This is the proof such an
|
||||
* account can give: the browser goes back to the provider with
|
||||
* `prompt=login` and `max_age=0`, and the callback counts the round trip only
|
||||
* when the ID token says the provider authenticated the person inside the
|
||||
* gate's own window and names the identity linked to the signed-in account.
|
||||
*
|
||||
* It records the proof exactly as a password re-proof does, `Session.reproofAt`
|
||||
* and nothing else, so it never stands in for a second factor. It creates no
|
||||
* session and switches none: the callback stamps the session row the start
|
||||
* bound into the sealed state, and only while the browser still carries it.
|
||||
*
|
||||
* Start: `GET /api/auth/oidc/reproof?next=<path>`. Finish: the shared
|
||||
* `GET /api/auth/oidc/callback` with `intent: "reproof"` in the state blob.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { oidcAppUrl, type OidcIdentity } from "@/lib/auth/oidc";
|
||||
import {
|
||||
RECENT_PROOF_MAX_AGE_MS,
|
||||
recordReproofFailure,
|
||||
refundReproof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
|
||||
/** The query parameter the browser comes back with, and its two values. */
|
||||
export const OIDC_REPROOF_RESULT_PARAM = "reproof";
|
||||
export type OidcReproofResult = "done" | "failed";
|
||||
|
||||
/** The stage name the shared re-proof bucket and the audit rows carry. */
|
||||
export const OIDC_REPROOF_STAGE = "oidc_reproof";
|
||||
|
||||
/**
|
||||
* How far in the future `auth_time` may sit and still count: the clock
|
||||
* tolerance `verifyIdToken` already grants `iat` and `exp`, for an identity
|
||||
* provider on another box whose clock runs a little ahead.
|
||||
*/
|
||||
const AUTH_TIME_FUTURE_TOLERANCE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Did the provider authenticate the person inside the gate's window (the five
|
||||
* minutes every recent-proof surface uses)? A token
|
||||
* without `auth_time` is refused: with `max_age` in the request the provider
|
||||
* owes the claim (OpenID Connect Core 2), and without it nothing says the
|
||||
* person signed in now rather than an hour ago.
|
||||
*/
|
||||
export function isFreshAuthTime(
|
||||
authTime: number | null,
|
||||
nowMs: number,
|
||||
maxAgeMs: number = RECENT_PROOF_MAX_AGE_MS,
|
||||
): boolean {
|
||||
if (authTime === null) return false;
|
||||
const at = authTime * 1000;
|
||||
return nowMs - at <= maxAgeMs && at - nowMs <= AUTH_TIME_FUTURE_TOLERANCE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the identity the provider named the one linked to this account? Both
|
||||
* halves, compared exactly: the subject is only unique per issuer.
|
||||
*/
|
||||
export function isLinkedIdentity(
|
||||
account: { oidcIssuer: string | null; oidcSub: string | null },
|
||||
issuer: string,
|
||||
sub: string,
|
||||
): boolean {
|
||||
return (
|
||||
account.oidcIssuer !== null &&
|
||||
account.oidcSub !== null &&
|
||||
account.oidcIssuer === issuer &&
|
||||
account.oidcSub === sub
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the browser still carry the session the round trip was started from?
|
||||
* Checked before the code is redeemed, so a mismatch spends nothing at the
|
||||
* provider.
|
||||
*/
|
||||
export function isReproofSession(
|
||||
bound: { reproofUserId?: string; reproofSessionId?: string },
|
||||
live: { session: { id: string }; user: { id: string } } | null,
|
||||
): boolean {
|
||||
return (
|
||||
live !== null &&
|
||||
typeof bound.reproofSessionId === "string" &&
|
||||
typeof bound.reproofUserId === "string" &&
|
||||
live.session.id === bound.reproofSessionId &&
|
||||
live.user.id === bound.reproofUserId
|
||||
);
|
||||
}
|
||||
|
||||
/** Where the browser lands: the page it left, with the outcome attached. */
|
||||
export function reproofReturnUrl(next: string, result: OidcReproofResult): URL {
|
||||
const url = oidcAppUrl(next);
|
||||
url.searchParams.set(OIDC_REPROOF_RESULT_PARAM, result);
|
||||
return url;
|
||||
}
|
||||
|
||||
export type OidcReproofOutcome =
|
||||
{ ok: true } | { ok: false; reason: "identity_mismatch" | "stale_auth_time" };
|
||||
|
||||
/**
|
||||
* Check the verified ID token against the account and stamp the session.
|
||||
* Every refusal is audited like a failed password re-proof; a success gives
|
||||
* back the attempt the start charged to the shared bucket.
|
||||
*/
|
||||
export async function recordOidcReproof(args: {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
issuer: string;
|
||||
identity: Pick<OidcIdentity, "sub" | "authTime">;
|
||||
ipAddress: string | null;
|
||||
nowMs?: number;
|
||||
}): Promise<OidcReproofOutcome> {
|
||||
const { userId, sessionId, issuer, identity, ipAddress } = args;
|
||||
const nowMs = args.nowMs ?? Date.now();
|
||||
|
||||
const account = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { oidcIssuer: true, oidcSub: true },
|
||||
});
|
||||
let refusal: "identity_mismatch" | "stale_auth_time" | null = null;
|
||||
if (!account || !isLinkedIdentity(account, issuer, identity.sub)) {
|
||||
refusal = "identity_mismatch";
|
||||
} else if (!isFreshAuthTime(identity.authTime, nowMs)) {
|
||||
refusal = "stale_auth_time";
|
||||
}
|
||||
if (refusal) {
|
||||
await recordReproofFailure(
|
||||
userId,
|
||||
ipAddress,
|
||||
OIDC_REPROOF_STAGE,
|
||||
"oidc",
|
||||
refusal,
|
||||
);
|
||||
return { ok: false, reason: refusal };
|
||||
}
|
||||
|
||||
await prisma.session.update({
|
||||
where: { id: sessionId },
|
||||
data: { reproofAt: new Date(nowMs) },
|
||||
});
|
||||
await refundReproof(userId);
|
||||
await auditLog("auth.reproof.succeeded", {
|
||||
userId,
|
||||
ipAddress,
|
||||
details: { method: "oidc" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.reproof.succeeded" },
|
||||
meta: { method: "oidc" },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
+20
-1
@@ -244,6 +244,13 @@ export function buildAuthorizationUrl(params: {
|
||||
* keyboard now, not a session someone else left open in this browser.
|
||||
*/
|
||||
forceLogin?: boolean;
|
||||
/**
|
||||
* Ask the provider to authenticate afresh AND to say when it did: `prompt=login`
|
||||
* plus `max_age=0` (OpenID Connect Core 3.1.2.1). With `max_age` in the
|
||||
* request the ID token must carry `auth_time`, which is what the re-proof
|
||||
* leg checks before it counts the round trip as a proof.
|
||||
*/
|
||||
reauthenticate?: boolean;
|
||||
}): string {
|
||||
const url = new URL(params.metadata.authorization_endpoint);
|
||||
url.search = new URLSearchParams({
|
||||
@@ -255,7 +262,8 @@ export function buildAuthorizationUrl(params: {
|
||||
nonce: params.nonce,
|
||||
code_challenge: params.codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
...(params.forceLogin ? { prompt: "login" } : {}),
|
||||
...(params.forceLogin || params.reauthenticate ? { prompt: "login" } : {}),
|
||||
...(params.reauthenticate ? { max_age: "0" } : {}),
|
||||
}).toString();
|
||||
return url.toString();
|
||||
}
|
||||
@@ -304,6 +312,12 @@ export interface OidcIdentity {
|
||||
email: string | null;
|
||||
emailVerified: boolean | undefined;
|
||||
name: string | null;
|
||||
/**
|
||||
* When the provider last authenticated the person (`auth_time`, seconds
|
||||
* since the epoch), or null when the token does not say. Only the re-proof
|
||||
* leg reads it, and it refuses a null.
|
||||
*/
|
||||
authTime: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -350,6 +364,11 @@ export async function verifyIdToken(params: {
|
||||
? payload.email_verified
|
||||
: undefined,
|
||||
name: typeof payload.name === "string" ? payload.name : null,
|
||||
authTime:
|
||||
typeof payload.auth_time === "number" &&
|
||||
Number.isFinite(payload.auth_time)
|
||||
? payload.auth_time
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { sanitiseZodIssues, type SanitisedZodIssue } from "@/lib/api-response";
|
||||
import { checkProfileEmailRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { z } from "zod/v4";
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import {
|
||||
REPROOF_FAILED_CODE,
|
||||
REPROOF_REQUIRED_CODE,
|
||||
@@ -26,9 +27,11 @@ import {
|
||||
const extendedProfileSchema = profileSchema.extend({
|
||||
// Trimmed first; "" (or only spaces) clears the name to null below.
|
||||
displayName: optionalDisplayNameSchema.nullable().optional(),
|
||||
// Every language the app ships: the stored language is what background
|
||||
// work (the Coach nudge, the notification texts) writes in.
|
||||
locale: z
|
||||
.enum(["de", "en"], {
|
||||
error: "Locale must be de or en.",
|
||||
.enum(locales, {
|
||||
error: "Locale must be one of the languages the app ships.",
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||
|
||||
/**
|
||||
* Does this account have a SECOND FACTOR at all?
|
||||
@@ -40,11 +41,25 @@ export async function hasSecondFactorEnrolled(user: {
|
||||
}
|
||||
|
||||
/**
|
||||
* A proof the recent-proof gate can ask for. `password` and `passkey` on an
|
||||
* account without a second factor; `totp`, `webauthn` and `passkey` on one
|
||||
* with a second factor, where a password alone has never been enough.
|
||||
* A proof the recent-proof gate can ask for. `password`, `oidc` and `passkey`
|
||||
* on an account without a second factor; `totp`, `webauthn` and `passkey` on
|
||||
* one with a second factor, where a password alone has never been enough.
|
||||
*
|
||||
* `oidc` is a fresh sign-in at the account's linked identity provider
|
||||
* (`GET /api/auth/oidc/reproof`), not a body proof: it stamps
|
||||
* `Session.reproofAt` exactly as a password does, and like a password it never
|
||||
* stands in for a second factor.
|
||||
*/
|
||||
export type RecentProofMethod = "password" | "totp" | "webauthn" | "passkey";
|
||||
export type RecentProofMethod =
|
||||
"password" | "oidc" | "totp" | "webauthn" | "passkey";
|
||||
|
||||
/**
|
||||
* The proofs that stamp only `Session.reproofAt` and so never clear a gate that
|
||||
* asks for a second factor (`requireFreshMfa`).
|
||||
*/
|
||||
export function isFirstFactorOnlyProof(method: RecentProofMethod): boolean {
|
||||
return method === "password" || method === "oidc";
|
||||
}
|
||||
|
||||
/**
|
||||
* The proofs that would satisfy `assertRecentCookieProof` for this
|
||||
@@ -63,15 +78,21 @@ export async function recentProofMethods(
|
||||
prisma.passkey.count({ where: { userId: user.id } }),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { passwordHash: true },
|
||||
select: { passwordHash: true, oidcIssuer: true, oidcSub: true },
|
||||
}),
|
||||
]);
|
||||
const methods: RecentProofMethod[] = [];
|
||||
if (hasSecond) {
|
||||
if (user.totpConfirmedAt) methods.push("totp");
|
||||
if (keys > 0) methods.push("webauthn");
|
||||
} else if (credential?.passwordHash) {
|
||||
methods.push("password");
|
||||
} else {
|
||||
if (credential?.passwordHash) methods.push("password");
|
||||
// A linked identity is only a way to confirm while the instance still
|
||||
// talks to a provider: with the provider group unset the round trip has
|
||||
// nowhere to go.
|
||||
if (credential?.oidcIssuer && credential.oidcSub && isOidcConfigured()) {
|
||||
methods.push("oidc");
|
||||
}
|
||||
}
|
||||
if (passkeys > 0) methods.push("passkey");
|
||||
return methods;
|
||||
|
||||
@@ -63,8 +63,11 @@ describe("first paint carries the resolved locale, not a default", () => {
|
||||
/prev\.locale === locale \? \{ locale, messages: loaded \} : prev/,
|
||||
);
|
||||
|
||||
// And the persistence effect keys on the locale alone, so one mount
|
||||
// with one locale means one write.
|
||||
expect(src).toMatch(/persistLocaleToServer\(locale\);\s*\}, \[locale\]\);/);
|
||||
// And the provider writes nothing to the server itself: the mirror lives
|
||||
// in `LocaleServerSyncBridge`, keyed on the locale and the signed-in
|
||||
// account, so a signed-out mount sends nothing and one account with one
|
||||
// locale means one write (its browser test pins both).
|
||||
expect(src).toMatch(/export function persistLocaleToServer\(/);
|
||||
expect(src).not.toMatch(/persistLocaleToServer\(locale/);
|
||||
});
|
||||
});
|
||||
+12
-14
@@ -125,16 +125,23 @@ function persistLocale(newLocale: Locale) {
|
||||
// the server-set cookie the route emits in reply: Safari's ITP caps the
|
||||
// script-written copy at 7 days, and a Set-Cookie is exempt.
|
||||
//
|
||||
// Once per tab session and per locale, not on every mount: every page load
|
||||
// mounts the provider, and every authenticated write is charged to the
|
||||
// Once per tab session, account and locale, not on every mount: every page
|
||||
// load mounts the provider, and every authenticated write is charged to the
|
||||
// account's shared write bucket, so a mount-time PUT spent one write per page
|
||||
// view on a value that had not changed. A switch still writes at once (the
|
||||
// locale differs from the one recorded), and a new tab writes once.
|
||||
//
|
||||
// Only for a signed-in account. The route answers 401 to nobody, the marker
|
||||
// is then never set, and every signed-out page load used to repeat the
|
||||
// refused PUT. Signed out, the choice stays in localStorage and the cookie;
|
||||
// `LocaleServerSyncBridge` calls this once an account is known, so the
|
||||
// choice made before signing in reaches the column right after.
|
||||
const LOCALE_SYNCED_KEY = "healthlog-locale-synced";
|
||||
|
||||
function persistLocaleToServer(newLocale: Locale) {
|
||||
export function persistLocaleToServer(newLocale: Locale, accountId: string) {
|
||||
const marker = `${accountId}:${newLocale}`;
|
||||
try {
|
||||
if (sessionStorage.getItem(LOCALE_SYNCED_KEY) === newLocale) return;
|
||||
if (sessionStorage.getItem(LOCALE_SYNCED_KEY) === marker) return;
|
||||
} catch {
|
||||
// Storage unavailable: write as before.
|
||||
}
|
||||
@@ -148,7 +155,7 @@ function persistLocaleToServer(newLocale: Locale) {
|
||||
.then((res) => {
|
||||
if (!res.ok) return;
|
||||
try {
|
||||
sessionStorage.setItem(LOCALE_SYNCED_KEY, newLocale);
|
||||
sessionStorage.setItem(LOCALE_SYNCED_KEY, marker);
|
||||
} catch {
|
||||
// Storage unavailable: the next mount writes again.
|
||||
}
|
||||
@@ -238,15 +245,6 @@ export function I18nProvider({
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
// v1.25.0 — keep `User.locale` in step with the committed UI locale. Runs on
|
||||
// the first mount of a tab session (backfilling the column for users who set
|
||||
// their language before this landed) and on every switch, so the proactive
|
||||
// Coach nudge and other cookie-blind background paths render in the language
|
||||
// the user reads.
|
||||
useEffect(() => {
|
||||
persistLocaleToServer(locale);
|
||||
}, [locale]);
|
||||
|
||||
const setLocale = useCallback((newLocale: Locale) => {
|
||||
if (!(locales as readonly string[]).includes(newLocale)) return;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* to the rounding precision, the chart-token scrub, or the cache-row
|
||||
* shape lands once rather than seven times.
|
||||
*
|
||||
* `SupportedLocale` is the full six-locale UI union. The status prompts
|
||||
* `SupportedLocale` is the full UI locale union. The status prompts
|
||||
* compose one of two reviewed instruction bodies (de / en) but name the
|
||||
* reader's own language in an explicit output directive, so the reader's
|
||||
* locale must survive the whole pipeline rather than being collapsed to a
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from "@/lib/ai/safety/outbound-screen";
|
||||
|
||||
/**
|
||||
* The locales the assessment pipeline carries end-to-end — all six the UI
|
||||
* The locales the assessment pipeline carries end-to-end — every one the UI
|
||||
* ships. Aliased to the i18n `Locale` so adding a UI locale widens the
|
||||
* pipeline in one place rather than needing a second edit here.
|
||||
*/
|
||||
@@ -108,7 +108,7 @@ export function normalizeSummaryText(value: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an arbitrary locale string against the six the UI ships.
|
||||
* Validate an arbitrary locale string against every locale the UI ships.
|
||||
*
|
||||
* A recognised locale passes through UNCHANGED — that is the whole point:
|
||||
* the former `value === "de" ? "de" : "en"` binary erased a French reader's
|
||||
|
||||
@@ -53,13 +53,54 @@ describe("crisisResourcesForLocale", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("gives a Dutch reader with no region 112, then the Dutch and Belgian lines by name", () => {
|
||||
for (const locale of ["nl", "nl-SR"]) {
|
||||
const set = crisisResourcesForLocale(locale);
|
||||
expect(set.emergencyNumber, locale).toBe("112");
|
||||
expect(set.resources).toEqual([
|
||||
{
|
||||
id: "zelfmoordpreventie113",
|
||||
contacts: ["113", "0800-0113", "113.nl"],
|
||||
},
|
||||
{ id: "zelfmoordlijn1813", contacts: ["1813", "zelfmoord1813.be"] },
|
||||
{ id: "findahelpline", contacts: ["findahelpline.com"] },
|
||||
]);
|
||||
}
|
||||
const { t } = getServerTranslator("nl");
|
||||
expect(
|
||||
t("mentalHealth.crisisResource.zelfmoordpreventie113.name"),
|
||||
).toContain("Nederland");
|
||||
expect(t("mentalHealth.crisisResource.zelfmoordlijn1813.name")).toContain(
|
||||
"België",
|
||||
);
|
||||
});
|
||||
|
||||
it("gives an explicit Dutch region 113 and an explicit Belgian one 1813", () => {
|
||||
for (const locale of ["nl-NL", "nl_NL"]) {
|
||||
const ids = crisisResourcesForLocale(locale).resources.map((r) => r.id);
|
||||
expect(ids, locale).toEqual(["zelfmoordpreventie113", "findahelpline"]);
|
||||
}
|
||||
for (const locale of ["nl-BE", "nl_BE", "be"]) {
|
||||
const ids = crisisResourcesForLocale(locale).resources.map((r) => r.id);
|
||||
expect(ids, locale).toEqual(["zelfmoordlijn1813", "findahelpline"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("names every crisis resource in every language", () => {
|
||||
// History re-derives the set from the row's stored locale and renders it
|
||||
// in whatever language the reader uses now.
|
||||
const ids = new Set(
|
||||
["pt-BR", "pt-PT", "br", "de", "de-AT", "de-CH", "en-US", "fr"].flatMap(
|
||||
(l) => crisisResourcesForLocale(l).resources.map((r) => r.id),
|
||||
),
|
||||
[
|
||||
"pt-BR",
|
||||
"pt-PT",
|
||||
"br",
|
||||
"de",
|
||||
"de-AT",
|
||||
"de-CH",
|
||||
"en-US",
|
||||
"fr",
|
||||
"nl",
|
||||
].flatMap((l) => crisisResourcesForLocale(l).resources.map((r) => r.id)),
|
||||
);
|
||||
for (const locale of locales) {
|
||||
const { t } = getServerTranslator(locale);
|
||||
|
||||
@@ -149,6 +149,51 @@ const PT_BR_AMBIGUOUS: CrisisResourceSet = {
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* The Netherlands (explicit "nl-NL" / "nl_NL"). 112 is the emergency number.
|
||||
* 113 Zelfmoordpreventie answers by phone on 113 or the free 0800-0113 and by
|
||||
* chat on 113.nl, anonymously.
|
||||
*/
|
||||
const NL: CrisisResourceSet = {
|
||||
emergencyNumber: "112",
|
||||
resources: [
|
||||
{
|
||||
id: "zelfmoordpreventie113",
|
||||
contacts: ["113", "0800-0113", "113.nl"],
|
||||
},
|
||||
{ id: "findahelpline", contacts: ["findahelpline.com"] },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Belgium (explicit "nl-BE" / "nl_BE", or the bare region "be"). 112 is the
|
||||
* emergency number. The Zelfmoordlijn answers 1813 around the clock and runs
|
||||
* a chat in the evenings (zelfmoord1813.be).
|
||||
*/
|
||||
const BE: CrisisResourceSet = {
|
||||
emergencyNumber: "112",
|
||||
resources: [
|
||||
{ id: "zelfmoordlijn1813", contacts: ["1813", "zelfmoord1813.be"] },
|
||||
{ id: "findahelpline", contacts: ["findahelpline.com"] },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Dutch with no region signal. The app ships one Dutch bundle, `nl`, read in
|
||||
* the Netherlands and in Flanders alike, and the account keeps no country to
|
||||
* tell them apart. Following DE_AT_CH, the set leads with 112 (the emergency
|
||||
* number in both countries) and names each country's line with the country
|
||||
* in its label, so nothing reads as if it reached everyone.
|
||||
*/
|
||||
const NL_BE: CrisisResourceSet = {
|
||||
emergencyNumber: "112",
|
||||
resources: [
|
||||
...NL.resources.filter((r) => r.id !== "findahelpline"),
|
||||
...BE.resources.filter((r) => r.id !== "findahelpline"),
|
||||
{ id: "findahelpline", contacts: ["findahelpline.com"] },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a crisis-resource set from the user's locale. Region detection is
|
||||
* coarse (locale prefix) and defaults to the international set — the goal is to
|
||||
@@ -156,8 +201,9 @@ const PT_BR_AMBIGUOUS: CrisisResourceSet = {
|
||||
*
|
||||
* A region-qualified tag ("de-AT", "de_CH", "pt-PT", …) resolves to the exact
|
||||
* country set when the caller has one to give us. The web stores the app
|
||||
* locale, which is a bare "de" or the folded "pt-BR", so it lands on the
|
||||
* combined sets: never assume Germany or Brazil when the region is unknown.
|
||||
* locale, which is a bare "de", "nl" or the folded "pt-BR", so it lands on the
|
||||
* combined sets: never assume Germany, the Netherlands or Brazil when the
|
||||
* region is unknown.
|
||||
*/
|
||||
export function crisisResourcesForLocale(
|
||||
locale: string | null | undefined,
|
||||
@@ -173,5 +219,9 @@ export function crisisResourcesForLocale(
|
||||
// PT_BR_AMBIGUOUS. Any other Portuguese tag (pt, pt-AO, pt-MZ) lands here
|
||||
// too, and still gets 112 and the worldwide directory first.
|
||||
if (lc === "pt" || lc.startsWith("pt-")) return PT_BR_AMBIGUOUS;
|
||||
if (lc === "nl-nl") return NL;
|
||||
if (lc === "nl-be" || lc === "be") return BE;
|
||||
// The bare "nl" the web stores, and any other Dutch tag: both countries.
|
||||
if (lc === "nl" || lc.startsWith("nl-")) return NL_BE;
|
||||
return INTERNATIONAL;
|
||||
}
|
||||
@@ -40,8 +40,13 @@ import { DEFAULT_ERROR_CODES } from "@/lib/api/default-error-codes";
|
||||
*/
|
||||
export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
{
|
||||
account: ["account.deletion.no_guardian", "account.deletion.not_pending"],
|
||||
account: [
|
||||
"account.deletion.already_pending",
|
||||
"account.deletion.no_guardian",
|
||||
"account.deletion.not_pending",
|
||||
],
|
||||
activity: ["activity.calendar.future_date", "activity.calendar.too_old"],
|
||||
admin: ["admin.users.delete.managed_profile", "admin.users.delete.self"],
|
||||
ai: [
|
||||
"ai.budget.exceeded",
|
||||
"ai.provider.none",
|
||||
|
||||
@@ -508,6 +508,9 @@ const accountPayload = z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
createdAt: isoDateTime().describe(
|
||||
"When the signed-in account was created. The web greets an account in its first day as new rather than welcoming it back.",
|
||||
),
|
||||
// Enumerated because clients cut days and convert glucose by them: the
|
||||
// day key and the unit must be the server's, not the device's.
|
||||
timezone: z
|
||||
|
||||
@@ -342,6 +342,11 @@ const mfaStatusResponse = z
|
||||
recoveryCodesRemaining: z.number().int(),
|
||||
webauthn: z.array(webauthnCredentialInfo),
|
||||
passkeyNudgeDismissed: z.boolean(),
|
||||
proofMethods: z
|
||||
.array(z.enum(["password", "oidc", "totp", "webauthn", "passkey"]))
|
||||
.describe(
|
||||
"The ways this account can confirm it is you, the same list a recent-proof refusal names in `meta.methods`. `password` and `oidc` only on an account without a second factor; `oidc` is a browser round trip to the identity provider (`GET /api/auth/oidc/reproof`) and is never offered to a token.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "MfaStatusResponse" });
|
||||
|
||||
@@ -599,6 +604,14 @@ const changePasswordRequest = changePasswordSchema.meta({
|
||||
"Password rotation. `newPassword` and `confirmPassword` must match, and `newPassword` must differ from the current one. The strength floor is 12 characters plus a scored check against the account's own username and email.",
|
||||
});
|
||||
|
||||
const passkeyProviderProofRequest = z
|
||||
.object({ method: z.literal("oidc") })
|
||||
.meta({
|
||||
id: "PasskeyProviderProofRequest",
|
||||
description:
|
||||
"Take the session's recent identity-provider re-proof (GET /api/auth/oidc/reproof) as the proof. Accepted only for an account whose `proofMethods` list `oidc` and only within five minutes of that re-proof; otherwise 401 `auth.reproof.required` with `meta.methods`. Web only.",
|
||||
});
|
||||
|
||||
const passkeyReauthRequest = z
|
||||
.object({
|
||||
method: z
|
||||
@@ -1130,7 +1143,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Re-prove a credential on a browser session (cookie only)",
|
||||
description:
|
||||
"Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/workouts, POST /api/tokens/documents, and the admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five minutes ago. A `totp`, `webauthn` or `passkey` proof here also clears the routes that ask for a fresh second factor (the managed-profile acts, the second-factor and passkey changes, the password change, account deletion, the data wipe and key rotation): on a browser session those answer 401 `auth.stepup.required` with `meta.methods` naming the possession proofs the account holds, and the web re-proves here and retries.\n\n" +
|
||||
'The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also refreshes the session\'s second-factor stamp, exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.\n\n' +
|
||||
'The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second factor; on one without, `password` or `passkey`. An account without a second factor whose identity is linked to the configured identity provider is also offered `oidc` in `meta.methods`; that proof is not posted here but given by the browser round trip GET /api/auth/oidc/reproof, which stamps the session exactly as a password proof does. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also refreshes the session\'s second-factor stamp, exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.\n\n' +
|
||||
"Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that does not verify is 401 `auth.reproof.failed` and is audited. Bearer callers use POST /api/auth/step-up instead.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -2454,7 +2467,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description:
|
||||
"Cookie-only — a Bearer token cannot enroll a sign-in credential on this surface at any scope. The route has TWO arms and the body decides which.\n\n" +
|
||||
'**Re-proof arm.** A body of exactly `{ method: "passkey" }` or `{ method: "webauthn" }` begins an assertion ceremony WITHOUT beginning enrollment, and answers with `reauth: true` alongside the options. Use it to obtain the assertion the second arm needs.\n\n' +
|
||||
"**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options issued. On an account that already has a second factor a password is refused before it is checked, with 422 `auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone would sign in without the second factor from then on.\n\n" +
|
||||
'**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options issued. An account confirmed at its identity provider (GET /api/auth/oidc/reproof, within five minutes) sends `{ method: "oidc" }` instead, which takes that re-proof as the proof and, like a password, clears the second-factor stamp. On an account that already has a second factor a password is refused before it is checked, with 422 `auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone would sign in without the second factor from then on.\n\n' +
|
||||
"Which factor you prove changes the session, and the direction is not the obvious one: a strong proof stamps the session second-factor-verified, while a PASSWORD proof deliberately CLEARS that stamp. Registering with a password is authorised by the single-use, session-bound challenge alone and must not silently upgrade password-only authentication into something the rest of the app reads as a second factor.\n\n" +
|
||||
"Every refusal on the proof path is the same 401 with the same prose — a missing content type, an unparseable body, a body that is not a valid proof shape, and a wrong password are indistinguishable to the caller. A `totp` proof counts only from an authenticator whose enrollment was confirmed.\n\n" +
|
||||
"Each proof on the enrollment arm draws on the account's shared re-proof budget (five per fifteen minutes, the same budget as POST /api/auth/step-up), and every failed proof writes an `auth.mfa.failed` audit row. Once the budget is spent the answer is 429 until the window passes, even for a correct proof.",
|
||||
@@ -2462,7 +2475,11 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.union([passkeyReauthRequest, stepUpMintSchema]),
|
||||
schema: z.union([
|
||||
passkeyReauthRequest,
|
||||
passkeyProviderProofRequest,
|
||||
stepUpMintSchema,
|
||||
]),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { recoveryEmptySchema } from "../recovery-empty";
|
||||
import { trendSentiment, trendVerdict } from "../trend-verdict";
|
||||
import { z } from "zod/v4";
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import { measurementSourceEnum, measurementTypeEnum } from "../shared";
|
||||
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
|
||||
import {
|
||||
@@ -45,9 +46,11 @@ export const metricStatusQuery = z
|
||||
"HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s. The seven specialised metrics are served by their own routes and are not accepted here.",
|
||||
),
|
||||
locale: z
|
||||
.enum(["de", "en"])
|
||||
.enum(locales)
|
||||
.optional()
|
||||
.describe("Optional UI-locale override; defaults to the session locale."),
|
||||
.describe(
|
||||
"Optional UI-locale override, any language the app ships; defaults to the session locale.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "MetricStatusQuery" });
|
||||
|
||||
@@ -792,9 +795,11 @@ export const glp1PlateauResponse = z
|
||||
export const insightStatusQuery = z
|
||||
.object({
|
||||
locale: z
|
||||
.enum(["de", "en"])
|
||||
.enum(locales)
|
||||
.optional()
|
||||
.describe("Optional UI-locale override; defaults to the session locale."),
|
||||
.describe(
|
||||
"Optional UI-locale override, any language the app ships; defaults to the session locale.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "InsightStatusQuery" });
|
||||
|
||||
@@ -856,9 +861,11 @@ export const biomarkerAssessmentQuery = z
|
||||
"User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a 404 (existence sealed).",
|
||||
),
|
||||
locale: z
|
||||
.enum(["de", "en"])
|
||||
.enum(locales)
|
||||
.optional()
|
||||
.describe("Optional UI-locale override; defaults to the session locale."),
|
||||
.describe(
|
||||
"Optional UI-locale override, any language the app ships; defaults to the session locale.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "BiomarkerAssessmentQuery" });
|
||||
|
||||
|
||||
@@ -554,7 +554,7 @@ export const profileUpdateRequest = z
|
||||
.describe(
|
||||
"Trimmed before its bound is checked. Null, an empty string or only spaces clears it; an omitted field is left untouched.",
|
||||
),
|
||||
locale: z.enum(["de", "en"]).nullable().optional(),
|
||||
locale: z.enum(locales).nullable().optional(),
|
||||
timezone: z.string().min(1).max(64).optional(),
|
||||
timeFormat: z
|
||||
.enum(["AUTO", "H12", "H24"])
|
||||
|
||||
@@ -106,3 +106,11 @@ export const adminSettingsSchema = z
|
||||
assistantDocumentAiEnabled: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Body of DELETE /api/admin/users/{id}: the typed confirmation the account's
|
||||
* own deletion asks for, so a stray request cannot lock an account.
|
||||
*/
|
||||
export const adminDeleteUserSchema = z
|
||||
.object({ confirm: z.literal("DELETE_ACCOUNT") })
|
||||
.strict();
|
||||
@@ -24,6 +24,7 @@
|
||||
* it is always narrowed from the session.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import {
|
||||
DOCUMENT_BULK_MAX_IDS,
|
||||
DOCUMENT_CONTENT_INDEX_SOURCES,
|
||||
@@ -944,14 +945,13 @@ export const DOCUMENT_CHAT_MESSAGE_MAX = 4000;
|
||||
* Request body for `POST /api/documents/inbound/{id}/chat`. No `userId` and no
|
||||
* `documentId` field — the user is narrowed from the session and the document id
|
||||
* comes from the path. `conversationId` continues an existing thread scoped to
|
||||
* this document; omitting it starts a new one. `locale` is the reply language
|
||||
* (the document chat, like the Coach, replies in de/en).
|
||||
* this document; omitting it starts a new one. `locale` is the reply language.
|
||||
*/
|
||||
export const documentChatRequestSchema = z
|
||||
.object({
|
||||
conversationId: z.string().trim().min(1).max(64).optional(),
|
||||
message: z.string().trim().min(1).max(DOCUMENT_CHAT_MESSAGE_MAX),
|
||||
locale: z.enum(["en", "de"]).optional(),
|
||||
locale: z.enum(locales).optional(),
|
||||
})
|
||||
.meta({ id: "DocumentChatRequest" });
|
||||
|
||||
@@ -990,7 +990,7 @@ export const fencedChatRequestSchema = z
|
||||
.object({
|
||||
conversationId: z.string().trim().min(1).max(64).optional(),
|
||||
message: z.string().trim().min(1).max(DOCUMENT_CHAT_MESSAGE_MAX),
|
||||
locale: z.enum(["en", "de"]).optional(),
|
||||
locale: z.enum(locales).optional(),
|
||||
attachmentIds: z
|
||||
.array(z.string().trim().min(1).max(64))
|
||||
.min(1)
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
* state. A request no route matches fails with a 404 envelope and is recorded
|
||||
* too, so an unexpected call is visible rather than hanging.
|
||||
*
|
||||
* One request is ambient: `I18nProvider` mirrors the active locale to
|
||||
* `PUT /api/auth/me/locale` on every mount, as it does in the app. The stub
|
||||
* answers it and leaves it out of `requests`, so "nothing was sent" means
|
||||
* nothing the component under test sent.
|
||||
* Nothing is ambient: the locale mirror (`PUT /api/auth/me/locale`) belongs
|
||||
* to `LocaleServerSyncBridge` in the app's providers, which `renderWithApp`
|
||||
* does not mount, so "nothing was sent" means nothing the component under
|
||||
* test sent.
|
||||
*
|
||||
* `setup.ts` restores the real `fetch` after every test.
|
||||
*/
|
||||
@@ -72,9 +72,6 @@ export interface FetchStub {
|
||||
|
||||
let realFetch: typeof fetch | null = null;
|
||||
|
||||
/** Sent by the providers on every mount, not by the component under test. */
|
||||
const AMBIENT = [{ method: "PUT", path: "/api/auth/me/locale" }];
|
||||
|
||||
function matches(route: Route, method: string, path: string): boolean {
|
||||
if (route.method !== method) return false;
|
||||
const bare = path.split("?")[0];
|
||||
@@ -123,10 +120,6 @@ export function stubFetch(): FetchStub {
|
||||
|
||||
window.fetch = async (input, init) => {
|
||||
const request = await record(input, init);
|
||||
const ambient = AMBIENT.some(
|
||||
(a) => a.method === request.method && a.path === request.path,
|
||||
);
|
||||
if (ambient) return envelope({ data: request.body ?? null });
|
||||
requests.push(request);
|
||||
// The newest route wins, so a test can override a default it set up.
|
||||
const route = [...routes]
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* An admin deletes another account, against real Postgres.
|
||||
*
|
||||
* `DELETE /api/admin/users/{id}` takes the account's own deletion path: with
|
||||
* a grace window the account is locked at once (credentials revoked, record
|
||||
* untouched) and the admin undo gives it back until the purge date; with the
|
||||
* window at 0 it is erased in the request. Mutations that must turn this red:
|
||||
* - erase instead of lock (the row is gone after the request);
|
||||
* - lock without revoking (the target's session survives);
|
||||
* - drop the fresh-proof gate (an old admin session deletes);
|
||||
* - drop the self, managed-profile or already-pending refusals.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
type Handler = (
|
||||
request: NextRequest,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>;
|
||||
|
||||
async function seedUser(id: string, role = "USER", managed = false) {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
username: id,
|
||||
email: `${id}@example.test`,
|
||||
role,
|
||||
onboardingCompletedAt: new Date(),
|
||||
...(managed ? { managedProfileAt: new Date() } : {}),
|
||||
},
|
||||
});
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: id,
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: new Date(Date.UTC(2026, 8, 1, 7)),
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Sign the admin in; `createdAgoMs` ages the session past the proof window. */
|
||||
async function signInAdmin(createdAgoMs = 0) {
|
||||
const now = Date.now();
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: "admin-1",
|
||||
createdAt: new Date(now - createdAgoMs),
|
||||
expiresAt: new Date(now + HOUR_MS),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
async function deleteUser(
|
||||
id: string,
|
||||
body: unknown = { confirm: "DELETE_ACCOUNT" },
|
||||
) {
|
||||
const { DELETE } = await import("@/app/api/admin/users/[id]/route");
|
||||
return (DELETE as unknown as Handler)(
|
||||
new NextRequest(`http://localhost/api/admin/users/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
}
|
||||
|
||||
async function errorCode(res: Response) {
|
||||
return ((await res.json()) as { meta?: { errorCode?: string } }).meta
|
||||
?.errorCode;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.rateLimit.deleteMany();
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", "14");
|
||||
await seedUser("admin-1", "ADMIN");
|
||||
await seedUser("target-1");
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
userId: "target-1",
|
||||
expiresAt: new Date(Date.now() + HOUR_MS),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("DELETE /api/admin/users/{id}", () => {
|
||||
it("locks the account for the grace window, revokes it and keeps the record", async () => {
|
||||
await signInAdmin();
|
||||
const before = Date.now();
|
||||
const res = await deleteUser("target-1");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { deleted: boolean; scheduledPurgeAt: string | null };
|
||||
};
|
||||
expect(body.data.deleted).toBe(true);
|
||||
const purgeAt = new Date(body.data.scheduledPurgeAt!).getTime();
|
||||
expect(purgeAt).toBeGreaterThanOrEqual(before + 14 * DAY_MS - 1000);
|
||||
expect(purgeAt).toBeLessThanOrEqual(Date.now() + 14 * DAY_MS + 1000);
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const target = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: "target-1" },
|
||||
select: { deletionRequestedAt: true, deletionRequestedById: true },
|
||||
});
|
||||
expect(target.deletionRequestedAt).not.toBeNull();
|
||||
expect(target.deletionRequestedById).toBe("admin-1");
|
||||
expect(await prisma.session.count({ where: { userId: "target-1" } })).toBe(
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
await prisma.measurement.count({ where: { userId: "target-1" } }),
|
||||
).toBe(1);
|
||||
expect(
|
||||
await prisma.auditLog.count({
|
||||
where: { userId: "admin-1", action: "admin.user.delete" },
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(
|
||||
await prisma.auditLog.count({
|
||||
where: { userId: "target-1", action: "user.account.delete" },
|
||||
}),
|
||||
).toBe(1);
|
||||
|
||||
// The list names the admin as the one who asked, and the undo gives the
|
||||
// account back.
|
||||
const list = await import("@/app/api/admin/users/route");
|
||||
const listed = (await (await list.GET()).json()) as {
|
||||
data: Array<{ id: string; deletion: { requestedBy: string } | null }>;
|
||||
};
|
||||
expect(
|
||||
listed.data.find((u) => u.id === "target-1")?.deletion?.requestedBy,
|
||||
).toBe("admin");
|
||||
|
||||
const undo = await import("@/app/api/admin/users/[id]/undo-deletion/route");
|
||||
const undone = await (undo.POST as unknown as Handler)(
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/users/target-1/undo-deletion",
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
),
|
||||
{ params: Promise.resolve({ id: "target-1" }) },
|
||||
);
|
||||
expect(undone.status).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await prisma.user.findUniqueOrThrow({
|
||||
where: { id: "target-1" },
|
||||
select: { deletionRequestedAt: true },
|
||||
})
|
||||
).deletionRequestedAt,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("erases at once when the server keeps no grace window", async () => {
|
||||
vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", "0");
|
||||
await signInAdmin();
|
||||
const res = await deleteUser("target-1");
|
||||
expect(res.status).toBe(200);
|
||||
expect(
|
||||
((await res.json()) as { data: { scheduledPurgeAt: string | null } }).data
|
||||
.scheduledPurgeAt,
|
||||
).toBeNull();
|
||||
expect(
|
||||
await getPrismaClient().user.count({ where: { id: "target-1" } }),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("asks for a fresh proof from an old admin session", async () => {
|
||||
await signInAdmin(HOUR_MS);
|
||||
const res = await deleteUser("target-1");
|
||||
expect(res.status).toBe(401);
|
||||
expect(await errorCode(res)).toBe("auth.reproof.required");
|
||||
expect(
|
||||
(
|
||||
await getPrismaClient().user.findUniqueOrThrow({
|
||||
where: { id: "target-1" },
|
||||
select: { deletionRequestedAt: true },
|
||||
})
|
||||
).deletionRequestedAt,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses the admin's own account", async () => {
|
||||
await signInAdmin();
|
||||
const res = await deleteUser("admin-1");
|
||||
expect(res.status).toBe(400);
|
||||
expect(await errorCode(res)).toBe("admin.users.delete.self");
|
||||
});
|
||||
|
||||
it("refuses without the typed confirmation", async () => {
|
||||
await signInAdmin();
|
||||
const res = await deleteUser("target-1", {});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("refuses a managed profile and an account already pending deletion", async () => {
|
||||
await seedUser("profile-1", "USER", true);
|
||||
await signInAdmin();
|
||||
const managed = await deleteUser("profile-1");
|
||||
expect(managed.status).toBe(409);
|
||||
expect(await errorCode(managed)).toBe("admin.users.delete.managed_profile");
|
||||
|
||||
expect((await deleteUser("target-1")).status).toBe(200);
|
||||
const again = await deleteUser("target-1");
|
||||
expect(again.status).toBe(409);
|
||||
expect(await errorCode(again)).toBe("account.deletion.already_pending");
|
||||
});
|
||||
|
||||
it("refuses a caller who is not an admin", async () => {
|
||||
await getPrismaClient().user.update({
|
||||
where: { id: "admin-1" },
|
||||
data: { role: "USER" },
|
||||
});
|
||||
await signInAdmin();
|
||||
await expect(deleteUser("target-1")).resolves.toHaveProperty("status", 403);
|
||||
});
|
||||
|
||||
it("deletes another admin while the caller stays", async () => {
|
||||
await seedUser("admin-2", "ADMIN");
|
||||
await signInAdmin();
|
||||
expect((await deleteUser("admin-2")).status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Confirming at the identity provider (#1296), against real Postgres.
|
||||
*
|
||||
* An account created by single sign-on holds no password, passkey or second
|
||||
* factor, so the recent-proof gate in front of token minting had nothing to
|
||||
* ask it for. The browser now goes back to the provider with a fresh login
|
||||
* request, and the callback stamps the signed-in session's `reproofAt` when
|
||||
* the ID token names this account's linked identity and an authentication
|
||||
* inside the window. The provider's network legs are stubbed; the state blob,
|
||||
* the session binding, the stamp, the gate and the passkey enrollment are
|
||||
* real.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.API_TOKEN_HMAC_KEY ??=
|
||||
"test-hmac-key-oidc-reproof-32-bytes-minimum-1234567890abcd";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const ISSUER = "https://idp.example.test";
|
||||
const METADATA = {
|
||||
issuer: ISSUER,
|
||||
authorization_endpoint: `${ISSUER}/authorize`,
|
||||
token_endpoint: `${ISSUER}/token`,
|
||||
jwks_uri: `${ISSUER}/jwks`,
|
||||
};
|
||||
|
||||
vi.mock("@/lib/auth/oidc", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/auth/oidc")>();
|
||||
return {
|
||||
...actual,
|
||||
discoverOidcMetadata: vi.fn(async () => METADATA),
|
||||
exchangeCodeForTokens: vi.fn(async () => ({ id_token: "id-token" })),
|
||||
verifyIdToken: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const oidc = await import("@/lib/auth/oidc");
|
||||
|
||||
const USER_ID = "user-oidc-reproof";
|
||||
const SUB = "idp-sub-reproof";
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const OIDC_ENV = {
|
||||
OIDC_ISSUER_URL: ISSUER,
|
||||
OIDC_CLIENT_ID: "client",
|
||||
OIDC_CLIENT_SECRET: "secret",
|
||||
};
|
||||
|
||||
type RouteFn = (request: NextRequest) => Promise<Response>;
|
||||
|
||||
async function startSession(createdAgoMs: number): Promise<string> {
|
||||
const now = Date.now();
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
createdAt: new Date(now - createdAgoMs),
|
||||
expiresAt: new Date(now + HOUR),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return session.id;
|
||||
}
|
||||
|
||||
/** The provider says it authenticated `sub` this many seconds ago. */
|
||||
function providerSays(sub: string, secondsAgo: number) {
|
||||
vi.mocked(oidc.verifyIdToken).mockResolvedValue({
|
||||
sub,
|
||||
email: "sso@example.test",
|
||||
emailVerified: true,
|
||||
name: null,
|
||||
authTime: Math.floor(Date.now() / 1000) - secondsAgo,
|
||||
});
|
||||
}
|
||||
|
||||
async function startReproof(next: string) {
|
||||
const { GET } = await import("@/app/api/auth/oidc/reproof/route");
|
||||
const res = await (GET as unknown as RouteFn)(
|
||||
new NextRequest(
|
||||
`http://localhost:3000/api/auth/oidc/reproof?next=${encodeURIComponent(next)}`,
|
||||
),
|
||||
);
|
||||
return res as Response & { cookies?: unknown };
|
||||
}
|
||||
|
||||
/** Run the start, then come back through the callback with the browser's cookies. */
|
||||
async function roundTrip(next: string, sessionCookie: string) {
|
||||
const started = await startReproof(next);
|
||||
expect(started.status).toBe(307);
|
||||
const authorize = new URL(started.headers.get("location")!);
|
||||
const stateCookie = /oidc_auth_state=([^;]+)/.exec(
|
||||
started.headers.get("set-cookie") ?? "",
|
||||
)?.[1];
|
||||
expect(stateCookie).toBeTruthy();
|
||||
|
||||
const { GET } = await import("@/app/api/auth/oidc/callback/route");
|
||||
const res = await (GET as unknown as RouteFn)(
|
||||
new NextRequest(
|
||||
`http://localhost:3000/api/auth/oidc/callback?code=abc&state=${authorize.searchParams.get("state")}`,
|
||||
{
|
||||
headers: {
|
||||
cookie: `oidc_auth_state=${stateCookie}; healthlog_session=${sessionCookie}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
return { authorize, res };
|
||||
}
|
||||
|
||||
async function mintMeasurementToken() {
|
||||
const { POST } = await import("@/app/api/tokens/measurements/route");
|
||||
return (POST as unknown as RouteFn)(
|
||||
new NextRequest("http://localhost:3000/api/tokens/measurements", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ name: "shortcut" }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function reproofAtOf(sessionId: string) {
|
||||
const row = await getPrismaClient().session.findUniqueOrThrow({
|
||||
where: { id: sessionId },
|
||||
select: { reproofAt: true, mfaVerifiedAt: true },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
Object.assign(process.env, OIDC_ENV);
|
||||
process.env.APP_URL = "http://localhost:3000";
|
||||
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "sso-only",
|
||||
email: "sso@example.test",
|
||||
role: "USER",
|
||||
oidcIssuer: ISSUER,
|
||||
oidcSub: SUB,
|
||||
onboardingCompletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of Object.keys(OIDC_ENV)) delete process.env[key];
|
||||
});
|
||||
|
||||
describe("confirming at the identity provider", () => {
|
||||
it("is what the gate offers an account with nothing else to prove", async () => {
|
||||
await startSession(HOUR);
|
||||
const refused = await mintMeasurementToken();
|
||||
expect(refused.status).toBe(401);
|
||||
const body = (await refused.json()) as {
|
||||
meta?: { errorCode?: string; methods?: string[] };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("auth.reproof.required");
|
||||
expect(body.meta?.methods).toEqual(["oidc"]);
|
||||
});
|
||||
|
||||
it("asks the provider for a fresh, timed sign-in with state, nonce and PKCE", async () => {
|
||||
await startSession(HOUR);
|
||||
const started = await startReproof("/settings/api");
|
||||
const authorize = new URL(started.headers.get("location")!);
|
||||
expect(authorize.origin + authorize.pathname).toBe(
|
||||
METADATA.authorization_endpoint,
|
||||
);
|
||||
expect(authorize.searchParams.get("prompt")).toBe("login");
|
||||
expect(authorize.searchParams.get("max_age")).toBe("0");
|
||||
expect(authorize.searchParams.get("state")).toBeTruthy();
|
||||
expect(authorize.searchParams.get("nonce")).toBeTruthy();
|
||||
expect(authorize.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
});
|
||||
|
||||
it("stamps the session, returns to the page and lets the token mint", async () => {
|
||||
const sessionId = await startSession(HOUR);
|
||||
providerSays(SUB, 10);
|
||||
const { res } = await roundTrip("/settings/api?tab=tokens", sessionId);
|
||||
expect(res.status).toBe(307);
|
||||
const back = new URL(res.headers.get("location")!);
|
||||
expect(back.pathname).toBe("/settings/api");
|
||||
expect(back.searchParams.get("tab")).toBe("tokens");
|
||||
expect(back.searchParams.get("reproof")).toBe("done");
|
||||
|
||||
const row = await reproofAtOf(sessionId);
|
||||
expect(row.reproofAt).not.toBeNull();
|
||||
expect(row.mfaVerifiedAt).toBeNull();
|
||||
// No session was created or switched.
|
||||
expect(await getPrismaClient().session.count()).toBe(1);
|
||||
expect(
|
||||
await getPrismaClient().auditLog.count({
|
||||
where: { userId: USER_ID, action: "auth.reproof.succeeded" },
|
||||
}),
|
||||
).toBe(1);
|
||||
|
||||
const minted = await mintMeasurementToken();
|
||||
expect(minted.status).toBe(201);
|
||||
});
|
||||
|
||||
it("then lets the account enroll a passkey", async () => {
|
||||
const sessionId = await startSession(HOUR);
|
||||
const { POST } =
|
||||
await import("@/app/api/auth/passkey/register-options/route");
|
||||
const begin = () =>
|
||||
(POST as unknown as RouteFn)(
|
||||
new NextRequest(
|
||||
"http://localhost:3000/api/auth/passkey/register-options",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ method: "oidc" }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const before = await begin();
|
||||
expect(before.status).toBe(401);
|
||||
expect(
|
||||
((await before.json()) as { meta?: { methods?: string[] } }).meta
|
||||
?.methods,
|
||||
).toEqual(["oidc"]);
|
||||
|
||||
providerSays(SUB, 10);
|
||||
await roundTrip("/settings/security", sessionId);
|
||||
|
||||
const after = await begin();
|
||||
expect(after.status).toBe(200);
|
||||
const begun = (await after.json()) as {
|
||||
data: { challengeId: string; options: { challenge: string } };
|
||||
};
|
||||
expect(begun.data.challengeId).toBeTruthy();
|
||||
expect(begun.data.options.challenge).toBeTruthy();
|
||||
});
|
||||
|
||||
it("refuses a provider sign-in older than the window", async () => {
|
||||
const sessionId = await startSession(HOUR);
|
||||
providerSays(SUB, 60 * 60);
|
||||
const { res } = await roundTrip("/settings/api", sessionId);
|
||||
expect(
|
||||
new URL(res.headers.get("location")!).searchParams.get("reproof"),
|
||||
).toBe("failed");
|
||||
expect((await reproofAtOf(sessionId)).reproofAt).toBeNull();
|
||||
expect((await mintMeasurementToken()).status).toBe(401);
|
||||
expect(
|
||||
await getPrismaClient().auditLog.count({
|
||||
where: { userId: USER_ID, action: "auth.mfa.failed" },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses a different subject at the same provider", async () => {
|
||||
const sessionId = await startSession(HOUR);
|
||||
providerSays("someone-else", 10);
|
||||
const { res } = await roundTrip("/settings/api", sessionId);
|
||||
expect(
|
||||
new URL(res.headers.get("location")!).searchParams.get("reproof"),
|
||||
).toBe("failed");
|
||||
expect((await reproofAtOf(sessionId)).reproofAt).toBeNull();
|
||||
expect((await mintMeasurementToken()).status).toBe(401);
|
||||
});
|
||||
|
||||
it("stamps nothing and spends nothing when the browser comes back with another session", async () => {
|
||||
const sessionId = await startSession(HOUR);
|
||||
const other = await getPrismaClient().session.create({
|
||||
data: { userId: USER_ID, expiresAt: new Date(Date.now() + HOUR) },
|
||||
});
|
||||
providerSays(SUB, 10);
|
||||
const { res } = await roundTrip("/settings/api", other.id);
|
||||
expect(
|
||||
new URL(res.headers.get("location")!).searchParams.get("reproof"),
|
||||
).toBe("failed");
|
||||
expect(oidc.exchangeCodeForTokens).not.toHaveBeenCalled();
|
||||
expect((await reproofAtOf(sessionId)).reproofAt).toBeNull();
|
||||
expect((await reproofAtOf(other.id)).reproofAt).toBeNull();
|
||||
});
|
||||
|
||||
it("is not offered to an account with a second factor", async () => {
|
||||
await getPrismaClient().user.update({
|
||||
where: { id: USER_ID },
|
||||
data: { totpConfirmedAt: new Date() },
|
||||
});
|
||||
await startSession(HOUR);
|
||||
const started = await startReproof("/settings/api");
|
||||
const back = new URL(started.headers.get("location")!);
|
||||
expect(back.origin).toBe("http://localhost:3000");
|
||||
expect(back.searchParams.get("reproof")).toBe("failed");
|
||||
expect(oidc.discoverOidcMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user