diff --git a/CHANGELOG.md b/CHANGELOG.md index 520c70801..b2224ad9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,91 @@ ## [Unreleased] +## [1.47.4] — 2026-11-12 + +### Fixed + +- **Web Push comes back when you turn it on again.** After a reinstall of + the Home Screen app, cleared site data, or turning push off on your + last device, the push channel switched itself off for good. Turning + push on again stored the browser and sent a test push, but no reminder + ever followed. Subscribing now turns the channel back on and clears + earlier failures, opening the push settings repairs a lost + subscription, and migration `0497_reenable_subscribed_web_push_channels` + turns push back on for every account that has a subscribed browser but + was switched off this way. A channel that has nobody to send to (no + browser, no paired iPhone, no mail server) now pauses for an hour and + says why on the status screen instead of counting failures. +- **No more invisible push for every recorded dose.** Logging a dose sent + a silent push to every browser to close the reminder. Safari does not + allow silent pushes and could switch push off for the iPhone app + without a word. A reminder already on screen now stays until you + dismiss it or the next one replaces it, and the dose count earlier + releases left on the Home Screen icon is cleared. +- **Signing out signs this device out of push too.** The browser kept + receiving the signed-out account's medication reminders, also after + someone else signed in. Every sign-out button now removes this + browser's subscription once the server confirmed the sign-out, and a + browser only carries the reminders of the account that subscribed it + last. If signing out fails, the app stays where it is and says you are + still signed in, instead of showing the sign-in page. +- **A shared browser no longer shows the previous account's data.** The + dashboard snapshot is restored only once the signed-in account is + known and is its owner, the sign-in pages clear the previous account's + copies as soon as the server confirms there is no session, and single + sign-on and the profile take-over clear them too. The daily briefing + text is no longer written to the browser's storage. +- **Offline, the open page stays.** Without a connection, a tap on a tab + or a link ended on the bare offline page, although the banner promised + your last synced data. The page you have open now stays readable, and + a note says other pages open once you are back online; the banner and + the README say exactly that. The service worker no longer stores API + answers on the device, and answers older releases stored are deleted + on update. +- **Links arrive where they pointed.** A Home Screen shortcut, a + notification or a bookmark opened while signed out takes you to that + page after you sign in. After you sign out yourself, the next sign-in + starts from the dashboard, and Back no longer reopens the page you + left. +- **Back works as expected.** Back after signing in, registering or + taking over a profile no longer shows the form you just sent, and Back + from a detail page returns to where you were in the list. +- **Sign-in errors say what happened, in your language.** A wrong + password or code says the sign-in failed, a lockout says to wait a few + minutes, and registration says when a username or address is taken or + an invitation is no longer valid. Passkey and single-sign-on refusals + say what went wrong. +- **The iPhone push hint.** In Safari on iPhone and iPad the push + settings now tell you to add HealthLog to your Home Screen first, + instead of saying push is not supported. The test push reaches every + device you subscribed, in your language, and says why it failed. +- **A 401 or 403 from a proxy or firewall in front of HealthLog no longer + signs you out.** An account that expires while the app is open now ends + the session, so the next save does not fail with "sign in again". +- Tapping a reminder while the app already shows that page no longer + reloads the app, and setup pages seen while signed in stay out of the + offline page cache. Single sign-on starts even when the browser's + storage answers slowly. +- The clock and battery of the installed iPhone app are readable again in + the light theme. + +### Changed + +- **Demo instances.** Visitors can sign out, each visitor's language stays + in their own browser instead of the shared account, refused language + and timing requests no longer show up on every page, and a demo allows + 30 password sign-ins per 15 minutes per address. Other instances keep + their limits. +- The "machine translated" notice no longer appears on the sign-in, + registration and profile take-over pages. +- The service worker file is half its former size. + +### Migration + +- `0497_reenable_subscribed_web_push_channels` turns Web Push back on for + accounts whose channel was switched off by failed sends while a browser + is still subscribed. A channel you switched off yourself stays off. + ## [1.47.3] — 2026-11-12 ### Changed diff --git a/README.md b/README.md index 6fa8591aa..1542e211a 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install. Sign i - **AI features (optional).** A daily briefing and a conversational coach grounded in your own measurements and read against cited guideline reference bands: where a value sits and what that means, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and skips a proactive check-in when there is nothing new to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so AI requests stay on your network. - **Doctor report and FHIR.** A doctor-report PDF rendered on your server, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit. - **Private by construction.** AES-256-GCM encryption at rest for notes and other free text, documents, questionnaire answers, AI-written text and credentials, with zero-downtime key rotation. Measurement values, lab values, medication names and mood scores are stored unencrypted in the database, so the database volume and its backups need the same protection as the host. Passkey login, server-side sessions, strict CSP, and no third-party tracking anywhere. -- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, Dutch, and Brazilian Portuguese end to end. +- **Built to be lived in.** Installable PWA that keeps the open page readable when the connection drops, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history, in English, German, Spanish, French, Italian, Polish, Korean, Dutch, Brazilian Portuguese and Russian end to end. The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev). diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7cab48dfe..1a889bcba 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.47.3 + version: 1.47.4 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -4016,15 +4016,16 @@ paths: "403": description: Refused before any work. Either the operator runs `OIDC_ONLY=true` (`meta.errorCode` = `oidc_only`), or self-registration is closed and the request carried no invite, or the invite it carried was invalid, expired - or exhausted. The three are distinguishable by prose but not by code. + or exhausted (`auth.register.invite_invalid`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": - description: The email or the username is taken. Deliberately one message for both, so this is not an account-existence - oracle. Also the answer when two concurrent signups race past the pre-check and the database unique index - catches the second. + description: The email or the username is taken (`auth.register.taken`). Deliberately one message and one code for both, + so this is not an account-existence oracle. Also the answer when two concurrent signups race past the + pre-check and the database unique index catches the second. `auth.already_authenticated` when the request + carries a signed-in session. content: application/json: schema: @@ -24732,16 +24733,17 @@ paths: post: tags: - Notifications - summary: Send a test push to the newest Web Push subscription + summary: Send a test push to every Web Push subscription description: >- - Fires one notification to the caller's MOST RECENT subscription only — a person with a phone, a tablet and a - desktop registered should not get three buzzes per click. Takes no body. Rate-limited 5 per minute per user. + Fires one notification to each of the caller's subscriptions, through the same sender real reminders use, with + the text in the account's language. The device the test was pressed on gets it whichever subscription is newest. + Endpoints the push service reports gone (404/410) are removed, as on a real send. Takes no body. Rate-limited 5 + per minute per user. - Read `ok`, not the status. A push that the service rejects still answers 200, with `ok: false` and the service's - own status in `perEndpoint`. Only the endpoint's HOST is reported: the full endpoint is the routing secret for - that subscription, and push services embed it in their own error messages, so it is stripped before anything is - logged or returned. + Read `ok`, not the status. A push that every service rejects still answers 200, with `ok: false`, a `reason`, + and `meta.errorCode`: `upstream_rejected`, or `connection_failed` when no push service answered. No endpoint is + returned: the full endpoint is the routing secret for that subscription. responses: "200": description: The test ran. `ok` says whether the push was accepted; a false here is a real failure reported inside a @@ -39724,42 +39726,43 @@ components: anamnesis.lifeConfirm.invalid, anamnesis.lifeSections.invalid; auth — auth.account.deletion_pending, auth.admin.required, auth.already_authenticated, auth.mfa.already_active, auth.mfa.code_invalid, auth.missing, auth.passkey.last_method, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, - auth.reproof.failed, auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient, - auth.session.insecure_transport, auth.session.unavailable, auth.stepup.required, auth.token.expired, - auth.token.invalid; backup — backup.foreign_reference, backup.key.missing, backup.owner.exists, - backup.owner.missing, backup.payload.undecryptable, backup.restore.active, - backup.restore.rows_exist_elsewhere, backup.section.missing, backup.target.admin_account, - backup.target.holds_records, backup.target.managed_profile, backup.target.not_found, - backup.target.pending_deletion, backup.target.unconfirmed, backup.target.username_taken, - backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach — - coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound, - coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, - coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, - coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, - coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, consent.ai.required; customMetric - — customMetric.name_taken; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, - cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, - cycle.disabled, cycle.feed.api_disabled, cycle.feed.cookie_only, cycle.feed.exists, - cycle.feed.fertile_goal, cycle.feed.none, cycle.feed.notFound, cycle.feed.rateLimited, - cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — - cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; - data_wipe — data_wipe.copy_failed; dawarich — dawarich.credentials_rejected, - dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, dawarich.rate_limited, - dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents — - documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, - documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, - documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, - documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, - documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType, - documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, - documents.inbound.localOcrDisabled, documents.inbound.notDeleted, documents.inbound.notFound, - documents.inbound.notIndexed, documents.inbound.outputLimit, documents.inbound.pdfRendererUnavailable, - documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, - documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, - documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, - documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly, - documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected, - documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed, + auth.register.invite_invalid, auth.register.taken, auth.reproof.failed, auth.reproof.required, + auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport, + auth.session.unavailable, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — + backup.foreign_reference, backup.key.missing, backup.owner.exists, backup.owner.missing, + backup.payload.undecryptable, backup.restore.active, backup.restore.rows_exist_elsewhere, + backup.section.missing, backup.target.admin_account, backup.target.holds_records, + backup.target.managed_profile, backup.target.not_found, backup.target.pending_deletion, + backup.target.unconfirmed, backup.target.username_taken, backup.upload.too_large; biomarkers — + biomarkers.unit.locked; body-site — body-site.invalid; coach — coach.budget.exceeded, + coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict, + coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, + coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid, + coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; + consent — consent.ai.confirmRequired, consent.ai.required; customMetric — customMetric.name_taken; cycle + — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, + cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, + cycle.feed.api_disabled, cycle.feed.cookie_only, cycle.feed.exists, cycle.feed.fertile_goal, + cycle.feed.none, cycle.feed.notFound, cycle.feed.rateLimited, cycle.period.invalid, + cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, + cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; + dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, + dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — + demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid, + documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, + documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, + documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, + documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, + documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, + documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notDeleted, + documents.inbound.notFound, documents.inbound.notIndexed, documents.inbound.outputLimit, + documents.inbound.pdfRendererUnavailable, documents.inbound.providerUnsupported, + documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, + documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy, + documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused, + documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress, + documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound, + documents.sources.organizationRequired, documents.sources.originNotAllowed, documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected, documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid, @@ -90554,40 +90557,18 @@ components: properties: ok: type: boolean - sent: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - description: 1 on success, 0 on failure. Never more than one. + description: Whether at least one of the account's subscriptions accepted the push. latencyMs: - description: Round-trip to the push service. Present on success only. + description: Time to send to every subscription. Present on success only. type: integer minimum: -9007199254740991 maximum: 9007199254740991 - perEndpoint: - type: array - items: - type: object - properties: - host: - type: string - description: Host of the push service the test went to — the endpoint path is a routing secret and is never returned. - status: - anyOf: - - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - - type: "null" - description: The push service's HTTP status, or null when the attempt threw before one existed. - required: - - host - - status - additionalProperties: false - description: "One entry: only the newest subscription is tested." + reason: + description: Why it was not, when `ok` is false. Absent on success. The sender's own classification, the same code a + failed reminder records (`web_push_410_gone` when every endpoint is gone, a status-derived code otherwise). + type: string required: - ok - - sent - - perEndpoint additionalProperties: false ApnsTestEnvelope: type: object @@ -90899,7 +90880,9 @@ components: - sending_paused description: Server-derived. `auto_disabled` — the dispatcher turned it off after repeated hard rejects (`disabledReason` says why). `manually_disabled` — the user turned it off. `sending_paused` — still enabled - but inside a retry cooldown until `nextRetryAt`. `active` — enabled and sending. + but inside a retry cooldown until `nextRetryAt`, after a transient failure or after a send that had nobody + to go to (no browser subscribed, no iPhone paired, a transport the operator has not set up; + `lastFailureReason` names it, `consecutiveFailures` is not raised). `active` — enabled and sending. disabledReason: description: Why the dispatcher auto-disabled the channel; null when it did not. This is the field that separates `auto_disabled` from `manually_disabled`. diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index 08eed4b7f..1a300a785 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -111,17 +111,9 @@ v1.18.4: - **Reminders.** Medication doses, mood check-ins, and any other event the user has enabled, delivered to the installed app even when it is closed. -- **Taken → clears the reminder.** When you log a dose, the server sends - a silent `type:"clear"` push that closes the still-pending dose-due - reminder for that slot (matched on its stable tag) without showing a - new notification (`public/sw.js`). This is the PWA equivalent of ending - an iOS Live Activity. -- **An app badge for outstanding doses.** The icon on the Home Screen - carries a server-authoritative count of doses still due; logging a dose - decrements it, and a count of zero clears the badge. The count is - computed on the server and reflected by the service worker via the - Badging API (`navigator.setAppBadge`), feature-detected — engines - without it (most desktop Firefox, older Safari) silently no-op. +- **A reminder stays until you act on it.** It stays on screen until it + is tapped or dismissed, or until the next reminder for the same dose + replaces it. ### iPhone — Add to Home Screen first @@ -141,11 +133,11 @@ permission, and push works. ### The inherent limit -The installed PWA gets reminders, the taken-clears behaviour, and the app -badge — but it **cannot** show a native iOS lock-screen Live Activity -widget. That single feature rides Apple Push Notification service and is -only available through a native iOS build with APNs. Reminders themselves -arrive on both paths; only the Live Activity is APNs-exclusive. +The installed PWA gets reminders, but it **cannot** show a native iOS +lock-screen Live Activity widget. That single feature rides Apple Push +Notification service and is only available through a native iOS build with +APNs. Reminders themselves arrive on both paths; only the Live Activity is +APNs-exclusive. ## Telegram @@ -586,14 +578,14 @@ it registers for the mood reminder. ## Which channel should I pick? -| Your setup | Recommended channel | -| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. | -| PWA on an iPhone | **Web Push**, but install the PWA via _Add to Home Screen_ first (iOS 16.4+). Reminders, taken-clears, and the app badge all work; the lock-screen Live Activity needs a native build. | -| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with Web Push as a fallback for any non-iOS device. | -| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. | -| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. | -| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. | +| Your setup | Recommended channel | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. | +| PWA on an iPhone | **Web Push**, but install the PWA via _Add to Home Screen_ first (iOS 16.4+). Reminders work; the lock-screen Live Activity needs a native build. | +| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with Web Push as a fallback for any non-iOS device. | +| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. | +| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. | +| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. | Mixing channels is fine and common — e.g. Web Push on the laptop and ntfy on the phone. Each user configures their own set from diff --git a/e2e/pwa-offline.spec.ts b/e2e/pwa-offline.spec.ts index 28b34c16a..7276c786b 100644 --- a/e2e/pwa-offline.spec.ts +++ b/e2e/pwa-offline.spec.ts @@ -12,12 +12,14 @@ import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; * * 1. A page that is open when the connection drops stays the app and says * so (the offline strip). - * 2. Opening a page without a connection gets the worker's offline page: - * the word "Offline" and a working retry control, never the browser's - * error. (Signed-in pages are never cached, by design: the proxy pins - * `private, no-store` on them because some carry the record in the - * HTML. So this is what an offline launch of the installed app shows.) - * 3. When the connection returns, that page reloads into the app by + * 2. A tap on a link to another page keeps the open page and repeats the + * hint, instead of trading the readable page for the offline one. + * 3. Opening a page without a connection (a typed URL, a cold start) gets + * the worker's offline page: the word "Offline" and a working retry + * control, never the browser's error. Signed-in pages are never cached: + * the proxy pins `private, no-store` on them because some carry the + * record in the HTML. + * 4. When the connection returns, that page reloads into the app by * itself. * * The account is the populated mobile-sweep account; nothing here writes. @@ -25,7 +27,7 @@ import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; test.describe("offline, with the service worker", () => { test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); - test("an open page says it is offline, a new one opens the offline page, and both recover @critical", async ({ + test("an open page stays readable offline, a new one opens the offline page, and both recover @critical", async ({ page, context, }) => { @@ -42,6 +44,16 @@ test.describe("offline, with the service worker", () => { await context.setOffline(true); await expect(page.locator('[data-slot="offline-banner"]')).toBeVisible(); + // A tap on the dashboard link stays on the open page. + await page.locator('a[href="/"]:visible').first().click(); + await expect( + page + .locator("[data-sonner-toast]") + .filter({ hasText: "other pages open once you're back online" }), + ).toBeVisible(); + expect(new URL(page.url()).pathname).toBe("/medications"); + await expect(page.locator("#main-content")).toBeVisible(); + const response = await page.goto("/timeline"); expect(response?.status()).toBe(503); await expect(page.getByText("Offline", { exact: true })).toBeVisible(); diff --git a/e2e/v137-record-session-fence-offline.spec.ts b/e2e/v137-record-session-fence-offline.spec.ts index 455df1581..dd31ead4a 100644 --- a/e2e/v137-record-session-fence-offline.spec.ts +++ b/e2e/v137-record-session-fence-offline.spec.ts @@ -4,45 +4,23 @@ import { expect, test } from "./setup/test"; import { FENCE_OFFLINE_STORAGE_STATE_PATH } from "./setup/test-helpers"; /** - * FENCE-AC-07 — the disk layers cannot serve one record's bytes inside - * another. + * FENCE-AC-07: no record's API answers are kept in CacheStorage, before or + * after a record switch, and the one per-account layer on disk, the IndexedDB + * dashboard snapshot, still belongs to the caller inside another record. * - * ## Why this is a spec of its own + * Runs in the `chromium-service-worker` project with the worker allowed and + * no route mocks, because the claim is about what the worker itself stores. + * The worker passes every `/api/` request to the network and keeps none of + * them. The snapshot refuses to write or restore inside another record + * (unit-tested in `query-persister-shared-record.test.ts`); here it is read + * off the disk after the record's pages were opened. * - * `playwright.config.ts` sets `serviceWorkers: "block"` for every other - * project, and for a good reason written there: a worker-originated `fetch` is - * not subject to `page.route`, so the SW would bypass the per-spec route mocks. - * - * This case is the exact inverse. Its whole claim is about what the - * `healthlog-data-*` cache can and cannot serve across a record switch — and - * with the worker blocked that cache is never populated at all, so the positive - * control ("the owner snapshot really WAS on disk before the switch") could - * never pass no matter what the application did. A check that cannot pass is - * worse than no check, so this runs in the `chromium-service-worker` project - * with the worker allowed, and uses no route mocks. - * - * ## What is asserted, in order - * - * 1. the owner's record reads really are on disk before the switch — a test - * that seeded nothing proves nothing; - * 2. only current-version caches exist, which is the eviction the fence's - * design relies on to make "a response with no echo" safe; - * 3. the switch empties the data cache before it reloads; - * 4. every response now on disk carries the TARGET's record scope and never - * the previous one — the claim, read off the disk layer itself rather - * than inferred from what a page did or did not paint; - * 5. and offline, that cache is what answers a record read, still carrying - * the target's context. The non-empty scope set is the paired positive - * control: an empty cache would satisfy "no previous-record bytes" while - * proving nothing. + * The positive control is the static cache (install precache): it proves the + * worker installed and that the cache read works, so "no API entry" is not + * the same sentence as "the read saw nothing". */ -/** - * Pages whose reads the service worker is configured to hold on disk - * (`sw-cache-policy.ts` allowlists `/api/dashboard/snapshot`, - * `/api/measurements` and `/api/medications`). Visited rather than fetched, so - * the requests are the app's own and carry the record-session assertion. - */ +/** Pages whose reads used to be kept on disk. Visited, so the reads are the app's own. */ const WARM_PAGES = ["/", "/measurements", "/medications"] as const; /** @@ -105,45 +83,62 @@ async function expectShellReady(page: Page) { }); } -/** What the `healthlog-data-*` caches hold right now. */ -async function dataCacheState(page: Page) { +/** Every HealthLog cache and the API answers it holds. */ +async function cacheState(page: Page) { return page.evaluate(async () => { - const names = await caches.keys(); - const data = names.filter((n) => n.startsWith("healthlog-data-")); + const names = (await caches.keys()).filter((n) => + n.startsWith("healthlog-"), + ); + const apiEntries: string[] = []; let entries = 0; - for (const name of data) { - entries += (await (await caches.open(name)).keys()).length; + for (const name of names) { + for (const request of await (await caches.open(name)).keys()) { + entries += 1; + const { pathname } = new URL(request.url); + if (pathname.startsWith("/api/")) apiEntries.push(pathname); + } } - return { names, dataCaches: data, entries }; + return { names, entries, apiEntries }; }); } /** - * The record context every cached response was stored WITH. - * - * This is the disk-layer claim, read off the disk layer. A cached record - * response carries the echo it was served under, so if a previous record's - * bytes were still reachable they would show up here as that record's scope. + * The owner the IndexedDB dashboard snapshot records, or `absent`. Opens the + * store the way the app does, so a database this read creates has the shape + * the app expects. */ -async function cachedScopes(page: Page): Promise { - return page.evaluate(async () => { - const scopes: string[] = []; - for (const name of (await caches.keys()).filter((n) => - n.startsWith("healthlog-data-"), - )) { - const cache = await caches.open(name); - for (const request of await cache.keys()) { - const cached = await cache.match(request); - const scope = cached?.headers.get("x-healthlog-record-scope"); - if (scope) scopes.push(scope); - } - } - return scopes; - }); +async function snapshotOwner(page: Page): Promise { + return page.evaluate( + () => + new Promise((resolve) => { + const request = indexedDB.open("healthlog-query-cache", 1); + request.onupgradeneeded = () => { + request.result.createObjectStore("kv"); + }; + request.onerror = () => resolve("absent"); + request.onsuccess = () => { + const db = request.result; + const read = db + .transaction("kv", "readonly") + .objectStore("kv") + .get("react-query"); + read.onsuccess = () => { + db.close(); + const payload = read.result as + { userId?: string | null } | undefined; + resolve(payload ? (payload.userId ?? null) : "absent"); + }; + read.onerror = () => { + db.close(); + resolve("absent"); + }; + }; + }), + ); } test.describe.serial("FENCE-AC-07 record-fence disk layers", () => { - test("FENCE-AC-07 the disk layers cannot serve one record's bytes inside another", async ({ + test("FENCE-AC-07 no record's API answers are kept on disk across a switch", async ({ browser, }) => { const context = await browser.newContext({ @@ -153,10 +148,6 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => { try { await page.goto("/"); - // The gate is spelled out here rather than left inside a helper because - // the next act is a one-shot `page.evaluate`, and - // `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source - // text between the navigation and the read. await expect( page.getByRole("button", { name: "User menu" }), ).toBeVisible(); @@ -168,148 +159,62 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => { await page.locator('[data-slot="shared-record-banner-exit"]').click(); await expect(banner).toHaveCount(0); } - - // Wait for the worker to control the page — until it does, nothing it - // would cache is going through it. await page.waitForFunction( () => navigator.serviceWorker?.controller !== null, undefined, { timeout: 20_000 }, ); - // Warm the disk cache by VISITING the pages, not by issuing bare - // fetches. A bare `fetch` in `page.evaluate` carries no record-session - // assertion, so on a session that has been inside a shared record the - // fence refuses it and nothing cacheable comes back — the cache filled - // from the app's own reads instead, and those were the ones that happened - // to resolve no record scope. Driving the app is what puts real, - // echo-carrying record responses on disk. for (const path of WARM_PAGES) { await page.goto(path); // Spelled out rather than left inside `expectShellReady`, because the // next act is a one-shot `page.evaluate` and // `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source - // text between the navigation and the read. A gate hidden in a helper - // is invisible to that guard and to the next reader. + // text between the navigation and the read. await expect( page.getByRole("button", { name: "User menu" }), ).toBeVisible(); await expectShellReady(page); } + + // POSITIVE CONTROL: the install precache is there and readable. await expect - .poll(async () => (await dataCacheState(page)).entries, { + .poll(async () => (await cacheState(page)).entries, { timeout: 15_000, }) .toBeGreaterThan(0); - - // (1) POSITIVE CONTROL: the owner's bytes really are on disk. - const seeded = await dataCacheState(page); - expect(seeded.dataCaches.length).toBeGreaterThan(0); - expect(seeded.entries).toBeGreaterThan(0); - - // (2) Only current-version caches survive activation. This is the - // eviction the fence's "a response with no echo is served normally" rule - // depends on: a PRE-fence cached response carries no echo, and it can - // only be served if its cache is still there. - const version = seeded.dataCaches[0].replace("healthlog-data-", ""); - const stale = seeded.names.filter( - (n) => - /^healthlog-(static|pages|data)-/.test(n) && !n.endsWith(version), + const own = await cacheState(page); + expect(own.apiEntries).toEqual([]); + expect(own.names.filter((n) => n.startsWith("healthlog-data-"))).toEqual( + [], ); - expect(stale).toEqual([]); - - await openSwitcher(page); - const entry = page.locator( - `[data-slot="account-switcher-entry"][data-account-username="${TARGET_USERNAME}"]`, + const ownId = await page.evaluate( + async () => + ( + (await (await fetch("/api/auth/me")).json()) as { + data: { id: string }; + } + ).data.id, ); - await expect(entry).toBeVisible(); - const targetAccountId = await entry.getAttribute("data-account-id"); - expect(targetAccountId).not.toBeNull(); - await entry.click(); - await expect( - page.locator('[data-slot="shared-record-banner"]'), - ).toBeVisible(); - // (3) NOT asserted: "the data cache is momentarily empty". - // - // The switch does wipe the disk layers — `clearOfflineCachesForRecordSwitch` - // runs before the reload — but the reload immediately refills them from - // the target's own reads, so an empty cache is a state that exists for a - // few milliseconds and cannot be sampled reliably. Polling for it made - // this case fail for a reason that had nothing to do with the claim. - // - // The claim is asserted directly below instead, and it is the stronger - // statement anyway: what matters is not that the cache was briefly empty - // but that nothing in it belongs to the previous record. - - // Visit inside the TARGET record so there is something of its own on - // disk to find. + await enterRecord(page); for (const path of WARM_PAGES) { await page.goto(path); - // Spelled out rather than left inside `expectShellReady`, because the - // next act is a one-shot `page.evaluate` and - // `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source - // text between the navigation and the read. A gate hidden in a helper - // is invisible to that guard and to the next reader. await expect( page.getByRole("button", { name: "User menu" }), ).toBeVisible(); await expectShellReady(page); } - await expect - .poll(async () => (await dataCacheState(page)).entries, { - timeout: 15_000, - }) - .toBeGreaterThan(0); - // (4) + (5) The disk layer now holds the TARGET's bytes and only those, - // read off the disk layer itself. - // - // The first draft asserted the shared-record banner after an offline - // reload. That could never pass and had nothing to do with the claim: - // `/api/auth/me` is not a cached read, so offline it fails, `useAuth` - // errors and the shell paints its offline gate rather than a banner. The - // banner's absence says nothing about what is on disk. - // - // What IS on disk is checkable, and it is the whole point: every cached - // record response carries the echo it was served under, so a previous - // record's bytes surviving the switch would appear here as that record's - // scope. - const scopes = await cachedScopes(page); - // POSITIVE CONTROL: something really is cached with a context, so the - // assertion below is about a non-empty set. - expect(scopes.length).toBeGreaterThan(0); - for (const scope of scopes) { - expect(scope).toBe(targetAccountId); - } - expect(scopes).not.toContain("self"); - - // And the target's own record read really is one of the entries on disk, - // so the scope assertion above is about the reads that matter rather - // than about some incidental cached response. - const cachedUrls = await page.evaluate(async () => { - const urls: string[] = []; - for (const name of (await caches.keys()).filter((n) => - n.startsWith("healthlog-data-"), - )) { - for (const request of await (await caches.open(name)).keys()) { - urls.push(new URL(request.url).pathname); - } - } - return urls; - }); - expect(cachedUrls).toContain("/api/measurements"); - - // NOT asserted: that a bare `fetch` from page context can read those - // bytes back while offline. Since v1.37.0 an echoing response also - // carries `Vary: x-healthlog-record-epoch, x-healthlog-record-scope`, and - // the Cache API honours `Vary` — so a request WITHOUT the fence headers - // does not match the stored entry at all. That is the correct behaviour - // and a second line of defence worth naming: bytes cached under one - // record context cannot be retrieved by a request asserting a different - // one, or none. The app's own reads carry the headers and do match. + const inside = await cacheState(page); + expect(inside.apiEntries).toEqual([]); + expect( + inside.names.filter((n) => n.startsWith("healthlog-data-")), + ).toEqual([]); + // Inside the record nothing is written, so the snapshot is either + // missing or the caller's own from before the switch. + expect(["absent", ownId]).toContain(await snapshotOwner(page)); } finally { - await context.setOffline(false); // Hand the shared session back to its own record; see the note in // `v137-record-session-fence.spec.ts`. await context.request @@ -318,185 +223,4 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => { await context.close(); } }); - - test("FENCE-AC-07 a cached record response cannot be SERVED under a different context", async ({ - browser, - }) => { - /** - * The serving path, which the case above does not exercise. - * - * The test above asserts what is IN the cache. That is worth asserting and - * it is not the same claim: an entry can sit on disk under one context and - * still be handed to a request made under another, and on the EXTERNAL - * switch path nothing wipes it — the app never initiated the switch, so - * `clearOfflineCachesForRecordSwitch` never runs. On that path `Vary` is - * the only thing standing between a cached delegated response and the next - * request for the same URL. - * - * So this asks the Cache API the question a service worker asks it: - * `cache.match(request)` honours the stored response's `Vary`, so a request - * whose fence headers differ from the ones the entry was stored with does - * not match at all. Remove the `Vary` block from `attachRecordContextEcho` - * and the second lookup starts hitting. - */ - const context = await browser.newContext({ - storageState: FENCE_OFFLINE_STORAGE_STATE_PATH, - }); - const page = await context.newPage(); - - try { - await page.goto("/"); - await expect( - page.getByRole("button", { name: "User menu" }), - ).toBeVisible(); - await expectShellReady(page); - const banner = page.locator('[data-slot="shared-record-banner"]'); - if ((await banner.count()) > 0) { - await page.locator('[data-slot="shared-record-banner-exit"]').click(); - await expect(banner).toHaveCount(0); - } - await page.waitForFunction( - () => navigator.serviceWorker?.controller !== null, - undefined, - { timeout: 20_000 }, - ); - - // Enter the record and let its reads land on disk. - await enterRecord(page); - await page.goto("/measurements"); - await expect( - page.getByRole("button", { name: "User menu" }), - ).toBeVisible(); - await expectShellReady(page); - - // The context those bytes were stored under, read off the entry itself. - // - // Polled, not sampled once. The service worker writes its `healthlog-data-*` - // entry AFTER the response has been handed to the page, so - // `expectShellReady` returning is not evidence that the write has landed — - // it is evidence that the read arrived. Sampling here read `null` on a - // loaded machine and failed the positive control below, which then said - // "there is no cache entry" about a cache that was about to have one. - // Waiting on the entry itself waits for the signal rather than for a - // number of milliseconds, and it still fails if the entry never appears. - const readStoredEntry = async () => - page.evaluate(async () => { - for (const name of (await caches.keys()).filter((n) => - n.startsWith("healthlog-data-"), - )) { - const cache = await caches.open(name); - for (const request of await cache.keys()) { - if ( - !new URL(request.url).pathname.startsWith("/api/measurements") - ) - continue; - const response = await cache.match(request); - if (!response) continue; - return { - cacheName: name, - url: request.url, - vary: response.headers.get("Vary"), - epoch: response.headers.get("x-healthlog-record-epoch"), - scope: response.headers.get("x-healthlog-record-scope"), - }; - } - } - return null; - }); - - // POSITIVE CONTROL: there really is an entry, stored with a context. - await expect.poll(readStoredEntry, { timeout: 20_000 }).not.toBeNull(); - const stored = await readStoredEntry(); - expect(stored).not.toBeNull(); - expect(stored!.epoch).toMatch(/^\d+$/); - expect(stored!.scope).not.toBe("self"); - - // The EXTERNAL switch: nothing in the app initiates it, so - // `clearOfflineCachesForRecordSwitch` never runs and the entry above - // survives — the positive control below is what proves that. - // - // Sent with the page's own `fetch` rather than through - // `context.request`. Both are outside the app: the switch UI is what runs - // the wipe, and a bare `fetch` is not it (the service worker returns - // without touching any non-GET, so the request reaches the network - // unaltered). What differs is the socket. Every Playwright API context in - // the runner shares one process-wide keep-alive agent, so this POST could - // be handed a connection the server had already closed on its five-second - // idle timeout, and a POST is never replayed on a fresh one — twice this - // line ended the spec with `read ECONNRESET`, on all three attempts. - const left = await page.evaluate(async () => { - const res = await fetch("/api/account/switch", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ accountId: null }), - }); - return res.status; - }); - expect(left).toBe(200); - // Read back through the page for the same reason the switch above goes - // that way. This was the last call in the file still using the shared - // API context, and it duly failed with `read ECONNRESET` on all three - // attempts once the pooled socket had gone stale. The browser opens its - // own connection, and a GET for the caller's own session is outside the - // app either way. - const now = (await page.evaluate(async () => { - const res = await fetch("/api/auth/me"); - return (await res.json()).data.recordSession; - })) as { - epoch: number; - scope: string | null; - }; - // The context genuinely moved, or the comparison below is vacuous. - expect(String(now.epoch)).not.toBe(stored!.epoch); - - const lookups = await page.evaluate( - async ({ - cacheName, - url, - storedEpoch, - storedScope, - nowEpoch, - nowScope, - }) => { - const cache = await caches.open(cacheName); - const ask = async (epoch: string, scope: string) => - (await cache.match( - new Request(url, { - headers: { - "x-healthlog-record-epoch": epoch, - "x-healthlog-record-scope": scope, - }, - }), - )) !== undefined; - return { - underStoredContext: await ask(storedEpoch, storedScope), - underNewContext: await ask(nowEpoch, nowScope), - withNoAssertion: - (await cache.match(new Request(url))) !== undefined, - }; - }, - { - cacheName: stored!.cacheName, - url: stored!.url, - storedEpoch: stored!.epoch!, - storedScope: stored!.scope!, - nowEpoch: String(now.epoch), - nowScope: now.scope ?? "self", - }, - ); - - // POSITIVE CONTROL: the entry IS servable to the context it belongs to. - // Without this, "the other context misses" would pass for an entry that - // was simply not there. - expect(lookups.underStoredContext).toBe(true); - // The claim. Remove the `Vary` and this starts hitting. - expect(lookups.underNewContext).toBe(false); - expect(lookups.withNoAssertion).toBe(false); - } finally { - await context.request - .post("/api/account/switch", { data: { accountId: null } }) - .catch(() => {}); - await context.close(); - } - }); }); diff --git a/messages/de.json b/messages/de.json index b7fdacfce..0a0d88e44 100644 --- a/messages/de.json +++ b/messages/de.json @@ -151,7 +151,12 @@ }, "mfa": { "already_active": "Für dieses Konto ist bereits eine Authenticator-App eingerichtet. Deaktiviere sie zuerst, um eine neue einzurichten." - } + }, + "register": { + "taken": "Dieser Nutzername oder diese E-Mail-Adresse wird bereits verwendet.", + "invite_invalid": "Diese Einladung gilt nicht mehr. Bitte die Person, die dich eingeladen hat, um einen neuen Link." + }, + "already_authenticated": "Du bist bereits angemeldet. Melde dich zuerst ab und versuche es dann erneut." }, "biomarkers": { "unit": { @@ -234,7 +239,17 @@ "budget": { "exceeded": "Das KI-Budget für heute ist aufgebraucht, daher wurde nichts gesendet. Morgen steht es wieder bereit." } - } + }, + "passkey": { + "unknown": "Dieser Passkey ist hier nicht registriert. Melde dich auf anderem Weg an.", + "challenge": { + "expired": "Die Anmeldung hat zu lange gedauert oder wurde schon verwendet. Versuche es erneut." + }, + "response": { + "invalid": "Die Antwort des Passkeys konnte nicht gelesen werden. Versuche es erneut." + } + }, + "oidc_only": "Dieser Server erlaubt nur Single Sign-on. Fahre stattdessen mit deinem Identitätsanbieter fort." }, "doctorReport": { "title": "Gesundheitsbericht", @@ -506,9 +521,9 @@ "backToDashboard": "Zurück zum Dashboard" }, "offlineBanner": { - "message": "Keine Verbindung: Änderungen können erst gespeichert werden, wenn du wieder online bist.", + "message": "Keine Verbindung: Änderungen können nicht gespeichert werden.", "saveFailed": "Speichern fehlgeschlagen, du bist offline. Bitte versuche es erneut, sobald du wieder online bist.", - "showingCached": "Zeigt deine zuletzt synchronisierten Daten." + "stayOnPage": "Diese Seite bleibt lesbar, andere Seiten öffnen sich, sobald du wieder online bist." }, "demoBanner": { "message": "Demo-Modus: Änderungen werden nicht gespeichert." @@ -585,6 +600,7 @@ "noAccount": "Noch kein Konto?", "hasAccount": "Bereits ein Konto?", "loginFailed": "Anmeldung fehlgeschlagen", + "signOutFailed": "Das Abmelden hat nicht geklappt. Du bist weiterhin angemeldet.", "registerFailed": "Registrierung fehlgeschlagen", "invitedBanner": "Du wurdest eingeladen. Erstelle dein Konto, auch wenn die Registrierung sonst geschlossen ist.", "alreadySignedIn": { @@ -668,7 +684,6 @@ "submit": "Übernehmen", "submitting": "Wird übernommen…", "errorTaken": "Dieser Benutzername oder diese E-Mail-Adresse ist schon vergeben.", - "errorRateLimit": "Zu viele Versuche. Warte ein paar Minuten und versuche es erneut.", "errorFailed": "Das hat nicht geklappt. Versuche es gleich noch einmal.", "errorOffline": "Das hat den Server nicht erreicht. Prüfe deine Verbindung und versuche es erneut.", "ssoContinue": "Weiter mit {provider}", @@ -690,7 +705,8 @@ "toLogin": "Zur Anmeldung" }, "deletionPending": "Dieses Konto wird am {date} gelöscht und ist bis dahin gesperrt. Ein Admin dieses Servers kann die Löschung vor diesem Datum rückgängig machen.", - "deletionPendingNoDate": "Dieses Konto ist zur Löschung vorgemerkt und gesperrt. Ein Admin dieses Servers kann die Löschung rückgängig machen, bis sie abgeschlossen ist." + "deletionPendingNoDate": "Dieses Konto ist zur Löschung vorgemerkt und gesperrt. Ein Admin dieses Servers kann die Löschung rückgängig machen, bis sie abgeschlossen ist.", + "signInRateLimited": "Zu viele Versuche. Warte ein paar Minuten und versuche es erneut." }, "dashboard": { "title": "Dashboard", @@ -6405,6 +6421,7 @@ "webPush": "Browser-Push", "webPushDescription": "Erhalte Benachrichtigungen direkt im Browser, auch wenn HealthLog nicht geöffnet ist.", "webPushNotSupported": "Dein Browser unterstützt keine Push-Benachrichtigungen.", + "webPushInstallFirst": "Auf iPhone und iPad funktionieren Push-Benachrichtigungen nur in der installierten App. Tippe in Safari auf Teilen und dann auf „Zum Home-Bildschirm“, öffne HealthLog vom Home-Bildschirm aus und tippe hier auf „Push aktivieren“.", "webPushDenied": "Push-Benachrichtigungen wurden blockiert. Erlaube sie in den Browser-Einstellungen.", "webPushNotConfigured": "Web Push ist serverseitig nicht konfiguriert.", "webPushSubscribe": "Push aktivieren", @@ -9284,7 +9301,9 @@ "accountErasureFailedBody": "Die Löschfrist von {username} ist abgelaufen, aber das Löschen des Kontos ist fehlgeschlagen. Es bleibt gesperrt und wird jede Stunde erneut versucht, nach den anderen fälligen Konten. Den Fehler zeigt die Karte der fehlgeschlagenen Jobs unter Administration." }, "user": { - "telegramTestBody": "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv." + "telegramTestBody": "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv.", + "webPushTestBody": "Wenn du das siehst, kommen Push-Benachrichtigungen auf diesem Gerät an.", + "webPushTestTitle": "Test-Benachrichtigung von HealthLog" }, "moodReminder": { "title": "Tägliche Stimmungs-Erinnerung", diff --git a/messages/en.json b/messages/en.json index 39c485e8f..358f3a7a5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -151,7 +151,12 @@ }, "mfa": { "already_active": "An authenticator app is already set up for this account. Disable it first to set up a new one." - } + }, + "register": { + "taken": "That username or email address is already in use.", + "invite_invalid": "This invitation is no longer valid. Ask the person who invited you for a new link." + }, + "already_authenticated": "You are already signed in. Sign out first, then try again." }, "biomarkers": { "unit": { @@ -234,7 +239,17 @@ "budget": { "exceeded": "Today's AI budget is used up, so nothing was sent. It renews tomorrow." } - } + }, + "passkey": { + "unknown": "This passkey is not registered here. Sign in another way.", + "challenge": { + "expired": "The sign-in took too long or was already used. Try again." + }, + "response": { + "invalid": "The passkey's answer could not be read. Try again." + } + }, + "oidc_only": "This server only allows single sign-on. Continue with your identity provider instead." }, "doctorReport": { "title": "Health Report", @@ -506,9 +521,9 @@ "backToDashboard": "Back to dashboard" }, "offlineBanner": { - "message": "No connection: changes can't be saved until you're back online.", + "message": "No connection: changes can't be saved.", "saveFailed": "Couldn't save, you're offline. Please try again once you're back online.", - "showingCached": "Showing your last synced data." + "stayOnPage": "This page stays readable; other pages open once you're back online." }, "demoBanner": { "message": "Demo mode: changes are not saved." @@ -585,6 +600,7 @@ "noAccount": "Don't have an account?", "hasAccount": "Already have an account?", "loginFailed": "Login failed", + "signOutFailed": "Could not sign out. You are still signed in.", "registerFailed": "Registration failed", "invitedBanner": "You have been invited. Create your account even while registration is otherwise closed.", "alreadySignedIn": { @@ -668,7 +684,6 @@ "submit": "Take over", "submitting": "Taking over…", "errorTaken": "That username or email address is already in use.", - "errorRateLimit": "Too many attempts. Wait a few minutes and try again.", "errorFailed": "That did not work. Try again in a moment.", "errorOffline": "That did not reach the server. Check your connection and try again.", "ssoContinue": "Continue with {provider}", @@ -690,7 +705,8 @@ "toLogin": "Go to sign in" }, "deletionPending": "This account is scheduled for deletion on {date} and is locked until then. An admin of this server can undo the deletion before that date.", - "deletionPendingNoDate": "This account is scheduled for deletion and is locked. An admin of this server can undo the deletion until it completes." + "deletionPendingNoDate": "This account is scheduled for deletion and is locked. An admin of this server can undo the deletion until it completes.", + "signInRateLimited": "Too many attempts. Wait a few minutes and try again." }, "dashboard": { "title": "Dashboard", @@ -6405,6 +6421,7 @@ "webPush": "Browser Push", "webPushDescription": "Receive notifications directly in your browser, even when HealthLog is not open.", "webPushNotSupported": "Your browser does not support push notifications.", + "webPushInstallFirst": "On iPhone and iPad, push notifications only work in the installed app. In Safari, tap Share, then Add to Home Screen, open HealthLog from the Home Screen and tap Enable push here.", "webPushDenied": "Push notifications are blocked. Allow them in your browser settings.", "webPushNotConfigured": "Web Push is not configured on the server.", "webPushSubscribe": "Enable push", @@ -9284,7 +9301,9 @@ "accountErasureFailedBody": "The deletion window of {username} has ended, but erasing the account failed. It stays locked and is tried again every hour, after the other accounts due. The failing jobs card in Administration has the error." }, "user": { - "telegramTestBody": "HealthLog: connection successful. Telegram notifications are active." + "telegramTestBody": "HealthLog: connection successful. Telegram notifications are active.", + "webPushTestBody": "If you can see this, push notifications reach this device.", + "webPushTestTitle": "HealthLog test notification" }, "moodReminder": { "title": "Daily mood reminder", diff --git a/messages/es.json b/messages/es.json index ce7e7417b..f5a71432e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -151,7 +151,12 @@ }, "mfa": { "already_active": "Esta cuenta ya tiene una aplicación de autenticación configurada. Desactívala primero para configurar una nueva." - } + }, + "register": { + "taken": "Ese nombre de usuario o correo electrónico ya está en uso.", + "invite_invalid": "Esta invitación ya no es válida. Pide un enlace nuevo a la persona que te invitó." + }, + "already_authenticated": "Ya has iniciado sesión. Cierra la sesión primero y vuelve a intentarlo." }, "biomarkers": { "unit": { @@ -234,7 +239,17 @@ "budget": { "exceeded": "El presupuesto de IA de hoy se ha agotado, así que no se ha enviado nada. Se renueva mañana." } - } + }, + "passkey": { + "unknown": "Esta passkey no está registrada aquí. Inicia sesión de otra forma.", + "challenge": { + "expired": "El inicio de sesión tardó demasiado o ya se usó. Vuelve a intentarlo." + }, + "response": { + "invalid": "No se pudo leer la respuesta de la passkey. Vuelve a intentarlo." + } + }, + "oidc_only": "Este servidor solo permite el inicio de sesión único. Continúa con tu proveedor de identidad." }, "doctorReport": { "title": "Informe de salud", @@ -506,9 +521,9 @@ "backToDashboard": "Volver al panel" }, "offlineBanner": { - "message": "Sin conexión: los cambios no se pueden guardar hasta que vuelvas a estar en línea.", + "message": "Sin conexión: los cambios no se pueden guardar.", "saveFailed": "No se pudo guardar porque estás sin conexión. Inténtalo de nuevo cuando vuelvas a estar en línea.", - "showingCached": "Mostrando tus últimos datos sincronizados." + "stayOnPage": "Esta página se puede seguir leyendo; las demás se abrirán cuando vuelvas a estar en línea." }, "demoBanner": { "message": "Modo demo: los cambios no se guardan." @@ -585,6 +600,7 @@ "noAccount": "¿Aún no tienes cuenta?", "hasAccount": "¿Ya tienes cuenta?", "loginFailed": "No se ha podido iniciar sesión", + "signOutFailed": "No se pudo cerrar la sesión. Sigues con la sesión iniciada.", "registerFailed": "No se ha podido completar el registro", "invitedBanner": "Te han invitado: crea tu cuenta aunque el registro esté cerrado.", "alreadySignedIn": { @@ -668,7 +684,6 @@ "submit": "Asumir", "submitting": "Asumiendo…", "errorTaken": "Ese nombre de usuario o correo electrónico ya está en uso.", - "errorRateLimit": "Demasiados intentos. Espera unos minutos y vuelve a intentarlo.", "errorFailed": "No ha funcionado. Vuelve a intentarlo en un momento.", "errorOffline": "No se llegó al servidor. Comprueba tu conexión y vuelve a intentarlo.", "ssoContinue": "Continuar con {provider}", @@ -690,7 +705,8 @@ "toLogin": "Ir a iniciar sesión" }, "deletionPending": "Esta cuenta se eliminará el {date} y está bloqueada hasta entonces. Un administrador de este servidor puede deshacer la eliminación antes de esa fecha.", - "deletionPendingNoDate": "Esta cuenta está programada para eliminarse y está bloqueada. Un administrador de este servidor puede deshacer la eliminación hasta que se complete." + "deletionPendingNoDate": "Esta cuenta está programada para eliminarse y está bloqueada. Un administrador de este servidor puede deshacer la eliminación hasta que se complete.", + "signInRateLimited": "Demasiados intentos. Espera unos minutos y vuelve a intentarlo." }, "dashboard": { "title": "Panel", @@ -6405,6 +6421,7 @@ "webPush": "Push del navegador", "webPushDescription": "Recibe notificaciones directamente en tu navegador, incluso cuando HealthLog no está abierto.", "webPushNotSupported": "Tu navegador no admite notificaciones push.", + "webPushInstallFirst": "En iPhone y iPad, las notificaciones push solo funcionan en la app instalada. En Safari, toca Compartir y luego «Añadir a pantalla de inicio», abre HealthLog desde la pantalla de inicio y toca aquí «Activar push».", "webPushDenied": "Las notificaciones push están bloqueadas. Permítelas en los ajustes de tu navegador.", "webPushNotConfigured": "Web Push no está configurado en el servidor.", "webPushSubscribe": "Activar push", @@ -9284,7 +9301,9 @@ "accountErasureFailedBody": "El plazo de eliminación de {username} ha terminado, pero no se pudo borrar la cuenta. Sigue bloqueada y se vuelve a intentar cada hora, después de las demás cuentas pendientes. El error aparece en la tarjeta de tareas fallidas de Administration." }, "user": { - "telegramTestBody": "HealthLog: conexión correcta. Las notificaciones de Telegram están activas." + "telegramTestBody": "HealthLog: conexión correcta. Las notificaciones de Telegram están activas.", + "webPushTestBody": "Si ves esto, las notificaciones push llegan a este dispositivo.", + "webPushTestTitle": "Notificación de prueba de HealthLog" }, "moodReminder": { "title": "Recordatorio diario de ánimo", diff --git a/messages/fr.json b/messages/fr.json index b6caaeb86..147e5d750 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -151,7 +151,12 @@ }, "mfa": { "already_active": "Une application d'authentification est déjà configurée pour ce compte. Désactivez-la d'abord pour en configurer une nouvelle." - } + }, + "register": { + "taken": "Ce nom d’utilisateur ou cette adresse e-mail est déjà utilisé.", + "invite_invalid": "Cette invitation n’est plus valable. Demandez un nouveau lien à la personne qui vous a invité." + }, + "already_authenticated": "Vous êtes déjà connecté. Déconnectez-vous d’abord, puis réessayez." }, "biomarkers": { "unit": { @@ -234,7 +239,17 @@ "budget": { "exceeded": "Le budget IA du jour est épuisé, rien n’a donc été envoyé. Il se renouvelle demain." } - } + }, + "passkey": { + "unknown": "Cette passkey n’est pas enregistrée ici. Connectez-vous d’une autre manière.", + "challenge": { + "expired": "La connexion a pris trop de temps ou a déjà été utilisée. Réessayez." + }, + "response": { + "invalid": "La réponse de la passkey n’a pas pu être lue. Réessayez." + } + }, + "oidc_only": "Ce serveur n’autorise que l’authentification unique. Continuez plutôt avec votre fournisseur d’identité." }, "doctorReport": { "title": "Rapport de santé", @@ -506,9 +521,9 @@ "backToDashboard": "Retour au tableau de bord" }, "offlineBanner": { - "message": "Pas de connexion : les modifications ne peuvent pas être enregistrées tant que vous n'êtes pas de nouveau en ligne.", + "message": "Pas de connexion : les modifications ne peuvent pas être enregistrées.", "saveFailed": "Enregistrement impossible : vous êtes hors ligne. Réessayez une fois de nouveau en ligne.", - "showingCached": "Affichage de vos dernières données synchronisées." + "stayOnPage": "Cette page reste lisible ; les autres s'ouvriront dès que vous serez de nouveau en ligne." }, "demoBanner": { "message": "Mode démo : les modifications ne sont pas enregistrées." @@ -585,6 +600,7 @@ "noAccount": "Pas encore de compte ?", "hasAccount": "Déjà un compte ?", "loginFailed": "Échec de la connexion", + "signOutFailed": "La déconnexion a échoué. Votre session est toujours ouverte.", "registerFailed": "Échec de l’inscription", "invitedBanner": "Vous avez été invité. Créez votre compte même si les inscriptions sont fermées.", "alreadySignedIn": { @@ -668,7 +684,6 @@ "submit": "Reprendre", "submitting": "Reprise en cours…", "errorTaken": "Ce nom d'utilisateur ou cette adresse e-mail est déjà utilisé.", - "errorRateLimit": "Trop de tentatives. Attendez quelques minutes puis réessayez.", "errorFailed": "Cela n'a pas fonctionné. Réessayez dans un instant.", "errorOffline": "Le serveur n'a pas été atteint. Vérifiez votre connexion et réessayez.", "ssoContinue": "Continuer avec {provider}", @@ -690,7 +705,8 @@ "toLogin": "Aller à la connexion" }, "deletionPending": "Ce compte sera supprimé le {date} et reste verrouillé d’ici là. Un administrateur de ce serveur peut annuler la suppression avant cette date.", - "deletionPendingNoDate": "Ce compte est programmé pour suppression et il est verrouillé. Un administrateur de ce serveur peut annuler la suppression tant qu’elle n’est pas terminée." + "deletionPendingNoDate": "Ce compte est programmé pour suppression et il est verrouillé. Un administrateur de ce serveur peut annuler la suppression tant qu’elle n’est pas terminée.", + "signInRateLimited": "Trop de tentatives. Attendez quelques minutes puis réessayez." }, "dashboard": { "title": "Tableau de bord", @@ -6405,6 +6421,7 @@ "webPush": "Push navigateur", "webPushDescription": "Recevez des notifications directement dans votre navigateur, même lorsque HealthLog n’est pas ouvert.", "webPushNotSupported": "Votre navigateur ne prend pas en charge les notifications push.", + "webPushInstallFirst": "Sur iPhone et iPad, les notifications push ne fonctionnent que dans l’app installée. Dans Safari, touchez Partager puis « Sur l’écran d’accueil », ouvrez HealthLog depuis l’écran d’accueil et touchez ici « Activer le push ».", "webPushDenied": "Les notifications push sont bloquées. Autorisez-les dans les réglages de votre navigateur.", "webPushNotConfigured": "Web Push n’est pas configuré sur le serveur.", "webPushSubscribe": "Activer le push", @@ -9284,7 +9301,9 @@ "accountErasureFailedBody": "Le délai de suppression de {username} est écoulé, mais l’effacement du compte a échoué. Il reste verrouillé et une nouvelle tentative a lieu chaque heure, après les autres comptes en attente. L’erreur figure dans la carte des tâches en échec, dans Administration." }, "user": { - "telegramTestBody": "HealthLog: connexion réussie. Les notifications Telegram sont actives." + "telegramTestBody": "HealthLog: connexion réussie. Les notifications Telegram sont actives.", + "webPushTestBody": "Si vous voyez ceci, les notifications push arrivent sur cet appareil.", + "webPushTestTitle": "Notification de test HealthLog" }, "moodReminder": { "title": "Rappel quotidien d’humeur", diff --git a/messages/it.json b/messages/it.json index 177023cb8..3f181295e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -151,7 +151,12 @@ }, "mfa": { "already_active": "Per questo account è già configurata un'app di autenticazione. Disattivala prima di configurarne una nuova." - } + }, + "register": { + "taken": "Questo nome utente o indirizzo e-mail è già in uso.", + "invite_invalid": "Questo invito non è più valido. Chiedi un nuovo link alla persona che ti ha invitato." + }, + "already_authenticated": "Hai già effettuato l’accesso. Prima esci, poi riprova." }, "biomarkers": { "unit": { @@ -234,7 +239,17 @@ "budget": { "exceeded": "Il budget IA di oggi è esaurito, quindi non è stato inviato nulla. Si rinnova domani." } - } + }, + "passkey": { + "unknown": "Questa passkey non è registrata qui. Accedi in un altro modo.", + "challenge": { + "expired": "L’accesso ha richiesto troppo tempo o è già stato usato. Riprova." + }, + "response": { + "invalid": "Non è stato possibile leggere la risposta della passkey. Riprova." + } + }, + "oidc_only": "Questo server consente solo il single sign-on. Continua invece con il tuo provider di identità." }, "doctorReport": { "title": "Rapporto sanitario", @@ -506,9 +521,9 @@ "backToDashboard": "Torna alla dashboard" }, "offlineBanner": { - "message": "Nessuna connessione: le modifiche non possono essere salvate finché non torni online.", + "message": "Nessuna connessione: le modifiche non possono essere salvate.", "saveFailed": "Impossibile salvare: sei offline. Riprova quando torni online.", - "showingCached": "Vengono mostrati i tuoi ultimi dati sincronizzati." + "stayOnPage": "Questa pagina resta leggibile; le altre si aprono quando torni online." }, "demoBanner": { "message": "Modalità demo: le modifiche non vengono salvate." @@ -585,6 +600,7 @@ "noAccount": "Non hai ancora un account?", "hasAccount": "Hai già un account?", "loginFailed": "Accesso non riuscito", + "signOutFailed": "Non è stato possibile uscire. La sessione è ancora attiva.", "registerFailed": "Registrazione non riuscita", "invitedBanner": "Sei stato invitato: crea il tuo account anche se la registrazione è chiusa.", "alreadySignedIn": { @@ -668,7 +684,6 @@ "submit": "Prendi in carico", "submitting": "Presa in carico…", "errorTaken": "Questo nome utente o indirizzo email è già in uso.", - "errorRateLimit": "Troppi tentativi. Attendi qualche minuto e riprova.", "errorFailed": "Non ha funzionato. Riprova tra un momento.", "errorOffline": "Il server non è stato raggiunto. Controlla la connessione e riprova.", "ssoContinue": "Continua con {provider}", @@ -690,7 +705,8 @@ "toLogin": "Vai all’accesso" }, "deletionPending": "Questo account verrà eliminato il {date} ed è bloccato fino ad allora. Un amministratore di questo server può annullare l’eliminazione prima di quella data.", - "deletionPendingNoDate": "Questo account è in attesa di eliminazione ed è bloccato. Un amministratore di questo server può annullare l’eliminazione finché non è completata." + "deletionPendingNoDate": "Questo account è in attesa di eliminazione ed è bloccato. Un amministratore di questo server può annullare l’eliminazione finché non è completata.", + "signInRateLimited": "Troppi tentativi. Attendi qualche minuto e riprova." }, "dashboard": { "title": "Cruscotto", @@ -6405,6 +6421,7 @@ "webPush": "Push del browser", "webPushDescription": "Ricevi notifiche direttamente nel browser, anche quando HealthLog non è aperto.", "webPushNotSupported": "Il tuo browser non supporta le notifiche push.", + "webPushInstallFirst": "Su iPhone e iPad le notifiche push funzionano solo nell'app installata. In Safari tocca Condividi, poi «Aggiungi alla schermata Home», apri HealthLog dalla schermata Home e tocca qui «Attiva push».", "webPushDenied": "Le notifiche push sono bloccate. Consentile nelle impostazioni del browser.", "webPushNotConfigured": "Web Push non è configurato sul server.", "webPushSubscribe": "Attiva push", @@ -9284,7 +9301,9 @@ "accountErasureFailedBody": "Il periodo di eliminazione di {username} è terminato, ma la cancellazione dell’account non è riuscita. Resta bloccato e viene ritentata ogni ora, dopo gli altri account in scadenza. L’errore è nella scheda dei job non riusciti in Administration." }, "user": { - "telegramTestBody": "HealthLog: connessione riuscita. Le notifiche Telegram sono attive." + "telegramTestBody": "HealthLog: connessione riuscita. Le notifiche Telegram sono attive.", + "webPushTestBody": "Se vedi questo messaggio, le notifiche push arrivano su questo dispositivo.", + "webPushTestTitle": "Notifica di prova di HealthLog" }, "moodReminder": { "title": "Promemoria quotidiano dell’umore", diff --git a/messages/ko.json b/messages/ko.json index a02fd7949..bd3936658 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -151,7 +151,12 @@ }, "mfa": { "already_active": "이 계정에는 이미 인증 앱이 설정되어 있어요. 새로 설정하려면 먼저 해제하세요." - } + }, + "register": { + "taken": "이미 사용 중인 사용자명 또는 이메일 주소예요.", + "invite_invalid": "이 초대는 더 이상 유효하지 않아요. 초대한 분께 새 링크를 요청해 주세요." + }, + "already_authenticated": "이미 로그인되어 있어요. 먼저 로그아웃한 다음 다시 시도해 주세요." }, "biomarkers": { "unit": { @@ -234,7 +239,17 @@ "budget": { "exceeded": "오늘의 AI 예산을 모두 사용해서 아무것도 보내지 않았어요. 내일 다시 채워져요." } - } + }, + "passkey": { + "unknown": "이 패스키는 여기에 등록되어 있지 않아요. 다른 방법으로 로그인해 주세요.", + "challenge": { + "expired": "로그인이 너무 오래 걸렸거나 이미 사용되었어요. 다시 시도해 주세요." + }, + "response": { + "invalid": "패스키의 응답을 읽을 수 없어요. 다시 시도해 주세요." + } + }, + "oidc_only": "이 서버는 통합 로그인(SSO)만 허용해요. 대신 ID 공급자로 계속해 주세요." }, "doctorReport": { "title": "건강 리포트", @@ -506,9 +521,9 @@ "backToDashboard": "대시보드로 돌아가기" }, "offlineBanner": { - "message": "연결이 없어요. 온라인으로 돌아올 때까지 저장할 수 없어요.", + "message": "연결이 없어요. 지금은 저장할 수 없어요.", "saveFailed": "저장하지 못했어요. 오프라인 상태예요. 온라인으로 돌아온 뒤 다시 시도해 주세요.", - "showingCached": "마지막으로 동기화한 데이터를 보여주고 있어요." + "stayOnPage": "이 페이지는 계속 볼 수 있어요. 다른 페이지는 온라인으로 돌아오면 열려요." }, "demoBanner": { "message": "데모 모드: 변경 내용은 저장되지 않아요." @@ -585,6 +600,7 @@ "noAccount": "계정이 없나요?", "hasAccount": "이미 계정이 있나요?", "loginFailed": "로그인 실패", + "signOutFailed": "로그아웃하지 못했어요. 아직 로그인되어 있어요.", "registerFailed": "회원가입 실패", "invitedBanner": "초대를 받았어요. 회원가입이 닫혀 있어도 계정을 만들 수 있어요.", "alreadySignedIn": { @@ -668,7 +684,6 @@ "submit": "넘겨받기", "submitting": "넘겨받는 중…", "errorTaken": "이미 사용 중인 사용자 이름 또는 이메일 주소입니다.", - "errorRateLimit": "시도 횟수가 너무 많습니다. 몇 분 후 다시 시도하세요.", "errorFailed": "처리하지 못했습니다. 잠시 후 다시 시도하세요.", "errorOffline": "서버에 연결하지 못했습니다. 연결을 확인하고 다시 시도하세요.", "ssoContinue": "{provider}(으)로 계속", @@ -690,7 +705,8 @@ "toLogin": "로그인으로 이동" }, "deletionPending": "이 계정은 {date}에 삭제될 예정이며 그때까지 잠겨 있어요. 그 전에는 이 서버의 관리자가 삭제를 취소할 수 있어요.", - "deletionPendingNoDate": "이 계정은 삭제 예정이라 잠겨 있어요. 삭제가 끝나기 전까지는 이 서버의 관리자가 취소할 수 있어요." + "deletionPendingNoDate": "이 계정은 삭제 예정이라 잠겨 있어요. 삭제가 끝나기 전까지는 이 서버의 관리자가 취소할 수 있어요.", + "signInRateLimited": "시도 횟수가 너무 많아요. 몇 분 후 다시 시도해 주세요." }, "dashboard": { "title": "대시보드", @@ -6405,6 +6421,7 @@ "webPush": "브라우저 푸시", "webPushDescription": "HealthLog를 열어 두지 않아도 브라우저로 바로 알림을 받아요.", "webPushNotSupported": "이 브라우저는 푸시 알림을 지원하지 않아요.", + "webPushInstallFirst": "iPhone과 iPad에서는 설치한 앱에서만 푸시 알림을 받을 수 있어요. Safari에서 공유를 누른 다음 '홈 화면에 추가'를 선택하고, 홈 화면에서 HealthLog를 연 뒤 여기서 '푸시 켜기'를 눌러 주세요.", "webPushDenied": "푸시 알림이 차단돼 있어요. 브라우저 설정에서 허용해 주세요.", "webPushNotConfigured": "서버에 Web Push가 설정돼 있지 않아요.", "webPushSubscribe": "푸시 켜기", @@ -9284,7 +9301,9 @@ "accountErasureFailedBody": "{username} 계정의 삭제 유예 기간이 끝났지만 계정을 지우지 못했어요. 계정은 계속 잠겨 있고, 삭제할 다른 계정들 다음으로 매시간 다시 시도해요. 오류는 관리자 화면의 실패한 작업 카드에서 볼 수 있어요." }, "user": { - "telegramTestBody": "HealthLog: 연결에 성공했어요. Telegram 알림이 켜져 있어요." + "telegramTestBody": "HealthLog: 연결에 성공했어요. Telegram 알림이 켜져 있어요.", + "webPushTestBody": "이 알림이 보이면 이 기기에서 푸시 알림을 받을 수 있어요.", + "webPushTestTitle": "HealthLog 테스트 알림" }, "moodReminder": { "title": "매일 기분 알림", diff --git a/messages/nl.json b/messages/nl.json index 38b89512e..d85233f20 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -151,7 +151,12 @@ }, "mfa": { "already_active": "Voor dit account is al een authenticator-app ingesteld. Schakel die eerst uit om een nieuwe in te stellen." - } + }, + "register": { + "taken": "Deze gebruikersnaam of dit e-mailadres is al in gebruik.", + "invite_invalid": "Deze uitnodiging is niet meer geldig. Vraag de persoon die je heeft uitgenodigd om een nieuwe link." + }, + "already_authenticated": "Je bent al ingelogd. Log eerst uit en probeer het dan opnieuw." }, "biomarkers": { "unit": { @@ -234,7 +239,17 @@ "budget": { "exceeded": "Het AI-budget voor vandaag is op, dus er is niets verzonden. Morgen is het er weer." } - } + }, + "passkey": { + "unknown": "Deze passkey is hier niet geregistreerd. Log op een andere manier in.", + "challenge": { + "expired": "Het inloggen duurde te lang of is al gebruikt. Probeer het opnieuw." + }, + "response": { + "invalid": "Het antwoord van de passkey kon niet worden gelezen. Probeer het opnieuw." + } + }, + "oidc_only": "Deze server staat alleen single sign-on toe. Ga in plaats daarvan verder met je identiteitsprovider." }, "doctorReport": { "title": "Gezondheidsrapport", @@ -506,9 +521,9 @@ "backToDashboard": "Terug naar het dashboard" }, "offlineBanner": { - "message": "Geen verbinding: wijzigingen kunnen pas worden opgeslagen als je weer online bent.", + "message": "Geen verbinding: wijzigingen kunnen niet worden opgeslagen.", "saveFailed": "Opslaan is mislukt, je bent offline. Probeer het opnieuw zodra je weer online bent.", - "showingCached": "Je laatst gesynchroniseerde gegevens worden getoond." + "stayOnPage": "Deze pagina blijft leesbaar; andere pagina's openen zodra je weer online bent." }, "demoBanner": { "message": "Demomodus: wijzigingen worden niet opgeslagen." @@ -585,6 +600,7 @@ "noAccount": "Nog geen account?", "hasAccount": "Heb je al een account?", "loginFailed": "Inloggen mislukt", + "signOutFailed": "Uitloggen is mislukt. Je bent nog steeds ingelogd.", "registerFailed": "Registratie mislukt", "invitedBanner": "Je bent uitgenodigd. Maak je account aan, ook al is registratie verder gesloten.", "alreadySignedIn": { @@ -668,7 +684,6 @@ "submit": "Overnemen", "submitting": "Overnemen…", "errorTaken": "Die gebruikersnaam of dat e-mailadres is al in gebruik.", - "errorRateLimit": "Te veel pogingen. Wacht een paar minuten en probeer het opnieuw.", "errorFailed": "Dat is niet gelukt. Probeer het zo opnieuw.", "errorOffline": "De server is niet bereikt. Controleer je verbinding en probeer het opnieuw.", "ssoContinue": "Doorgaan met {provider}", @@ -690,7 +705,8 @@ "toLogin": "Naar inloggen" }, "deletionPending": "Dit account wordt op {date} verwijderd en is tot dan vergrendeld. Een beheerder van deze server kan de verwijdering vóór die datum ongedaan maken.", - "deletionPendingNoDate": "Dit account staat gepland voor verwijdering en is vergrendeld. Een beheerder van deze server kan de verwijdering ongedaan maken tot die voltooid is." + "deletionPendingNoDate": "Dit account staat gepland voor verwijdering en is vergrendeld. Een beheerder van deze server kan de verwijdering ongedaan maken tot die voltooid is.", + "signInRateLimited": "Te veel pogingen. Wacht een paar minuten en probeer het opnieuw." }, "dashboard": { "title": "Dashboard", @@ -6405,6 +6421,7 @@ "webPush": "Browserpush", "webPushDescription": "Ontvang meldingen rechtstreeks in je browser, ook als HealthLog niet geopend is.", "webPushNotSupported": "Je browser ondersteunt geen pushmeldingen.", + "webPushInstallFirst": "Op iPhone en iPad werken pushmeldingen alleen in de geïnstalleerde app. Tik in Safari op Deel en daarna op 'Zet op beginscherm', open HealthLog vanaf het beginscherm en tik hier op 'Push inschakelen'.", "webPushDenied": "Pushmeldingen zijn geblokkeerd. Sta ze toe in de instellingen van je browser.", "webPushNotConfigured": "Web Push is niet geconfigureerd op de server.", "webPushSubscribe": "Push inschakelen", @@ -9284,7 +9301,9 @@ "accountErasureFailedBody": "De verwijdertermijn van {username} is afgelopen, maar het wissen van het account is mislukt. Het blijft vergrendeld en wordt elk uur opnieuw geprobeerd, na de andere accounts die aan de beurt zijn. De fout staat op de kaart met mislukte taken in Beheer." }, "user": { - "telegramTestBody": "HealthLog: verbinding geslaagd. Telegram-meldingen zijn actief." + "telegramTestBody": "HealthLog: verbinding geslaagd. Telegram-meldingen zijn actief.", + "webPushTestBody": "Zie je dit, dan komen pushmeldingen aan op dit apparaat.", + "webPushTestTitle": "Testmelding van HealthLog" }, "moodReminder": { "title": "Dagelijkse stemmingsherinnering", diff --git a/messages/pl.json b/messages/pl.json index b54a69d49..4085c04fc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -151,7 +151,12 @@ }, "mfa": { "already_active": "To konto ma już skonfigurowaną aplikację uwierzytelniającą. Najpierw ją wyłącz, aby skonfigurować nową." - } + }, + "register": { + "taken": "Ta nazwa użytkownika lub adres e-mail jest już zajęty.", + "invite_invalid": "To zaproszenie jest już nieważne. Poproś osobę, która cię zaprosiła, o nowy link." + }, + "already_authenticated": "Masz już zalogowaną sesję. Najpierw się wyloguj, a potem spróbuj ponownie." }, "biomarkers": { "unit": { @@ -234,7 +239,17 @@ "budget": { "exceeded": "Dzisiejszy budżet AI został wykorzystany, więc nic nie zostało wysłane. Odnowi się jutro." } - } + }, + "passkey": { + "unknown": "Ten passkey nie jest tu zarejestrowany. Zaloguj się w inny sposób.", + "challenge": { + "expired": "Logowanie trwało zbyt długo lub zostało już użyte. Spróbuj ponownie." + }, + "response": { + "invalid": "Nie udało się odczytać odpowiedzi passkey. Spróbuj ponownie." + } + }, + "oidc_only": "Ten serwer pozwala tylko na logowanie jednokrotne (SSO). Kontynuuj u swojego dostawcy tożsamości." }, "doctorReport": { "title": "Raport zdrowotny", @@ -506,9 +521,9 @@ "backToDashboard": "Powrót do panelu" }, "offlineBanner": { - "message": "Brak połączenia: zmian nie można zapisać, dopóki nie wrócisz online.", + "message": "Brak połączenia: nie można zapisać zmian.", "saveFailed": "Nie udało się zapisać, jesteś offline. Spróbuj ponownie, gdy wrócisz online.", - "showingCached": "Wyświetlane są ostatnio zsynchronizowane dane." + "stayOnPage": "Ta strona pozostaje czytelna, inne otworzą się, gdy wrócisz online." }, "demoBanner": { "message": "Tryb demo: zmiany nie są zapisywane." @@ -585,6 +600,7 @@ "noAccount": "Nie masz jeszcze konta?", "hasAccount": "Masz już konto?", "loginFailed": "Logowanie nie powiodło się", + "signOutFailed": "Nie udało się wylogować. Sesja jest nadal aktywna.", "registerFailed": "Rejestracja nie powiodła się", "invitedBanner": "Otrzymano zaproszenie. Utwórz konto, nawet jeśli rejestracja jest zamknięta.", "alreadySignedIn": { @@ -668,7 +684,6 @@ "submit": "Przejmij", "submitting": "Przejmowanie…", "errorTaken": "Ta nazwa użytkownika lub adres e-mail jest już zajęty.", - "errorRateLimit": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie.", "errorFailed": "Nie udało się. Spróbuj ponownie za chwilę.", "errorOffline": "Nie udało się połączyć z serwerem. Sprawdź połączenie i spróbuj ponownie.", "ssoContinue": "Kontynuuj przez {provider}", @@ -690,7 +705,8 @@ "toLogin": "Przejdź do logowania" }, "deletionPending": "To konto zostanie usunięte {date} i do tego czasu jest zablokowane. Administrator tego serwera może cofnąć usunięcie przed tą datą.", - "deletionPendingNoDate": "To konto czeka na usunięcie i jest zablokowane. Administrator tego serwera może cofnąć usunięcie, dopóki się nie zakończy." + "deletionPendingNoDate": "To konto czeka na usunięcie i jest zablokowane. Administrator tego serwera może cofnąć usunięcie, dopóki się nie zakończy.", + "signInRateLimited": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie." }, "dashboard": { "title": "Pulpit", @@ -6405,6 +6421,7 @@ "webPush": "Push przeglądarki", "webPushDescription": "Otrzymuj powiadomienia bezpośrednio w przeglądarce, nawet gdy HealthLog nie jest otwarty.", "webPushNotSupported": "Twoja przeglądarka nie obsługuje powiadomień push.", + "webPushInstallFirst": "Na iPhonie i iPadzie powiadomienia push działają tylko w zainstalowanej aplikacji. W Safari stuknij Udostępnij, potem „Do ekranu początkowego”, otwórz HealthLog z ekranu początkowego i stuknij tutaj „Włącz push”.", "webPushDenied": "Powiadomienia push są zablokowane. Zezwól na nie w ustawieniach przeglądarki.", "webPushNotConfigured": "Web Push nie jest skonfigurowany na serwerze.", "webPushSubscribe": "Włącz push", @@ -9284,7 +9301,9 @@ "accountErasureFailedBody": "Okres usuwania konta {username} minął, ale usunięcie konta się nie powiodło. Konto pozostaje zablokowane, a próba jest ponawiana co godzinę, po innych kontach do usunięcia. Błąd widać na karcie nieudanych zadań w Administracja." }, "user": { - "telegramTestBody": "HealthLog: połączenie udane. Powiadomienia Telegram są aktywne." + "telegramTestBody": "HealthLog: połączenie udane. Powiadomienia Telegram są aktywne.", + "webPushTestBody": "Jeśli to widzisz, powiadomienia push docierają na to urządzenie.", + "webPushTestTitle": "Powiadomienie testowe HealthLog" }, "moodReminder": { "title": "Codzienne przypomnienie o nastroju", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index d2b0e72e0..6cba6c6a8 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -151,7 +151,12 @@ }, "mfa": { "already_active": "Esta conta já tem um aplicativo autenticador configurado. Desative-o primeiro para configurar um novo." - } + }, + "register": { + "taken": "Esse nome de usuário ou endereço de e-mail já está em uso.", + "invite_invalid": "Este convite não é mais válido. Peça um novo link à pessoa que convidou você." + }, + "already_authenticated": "Você já está conectado. Saia primeiro e tente novamente." }, "biomarkers": { "unit": { @@ -234,7 +239,17 @@ "budget": { "exceeded": "O orçamento de IA de hoje acabou, então nada foi enviado. Ele é renovado amanhã." } - } + }, + "passkey": { + "unknown": "Esta chave de acesso não está registrada aqui. Entre de outra forma.", + "challenge": { + "expired": "O login demorou demais ou já foi usado. Tente novamente." + }, + "response": { + "invalid": "Não foi possível ler a resposta da chave de acesso. Tente novamente." + } + }, + "oidc_only": "Este servidor permite apenas login único. Continue com seu provedor de identidade." }, "doctorReport": { "title": "Relatório de Saúde", @@ -506,9 +521,9 @@ "backToDashboard": "Voltar ao painel" }, "offlineBanner": { - "message": "Sem conexão: as alterações não podem ser salvas até você voltar a ficar online.", + "message": "Sem conexão: as alterações não podem ser salvas.", "saveFailed": "Não foi possível salvar, você está offline. Tente novamente quando voltar a ficar online.", - "showingCached": "Mostrando seus últimos dados sincronizados." + "stayOnPage": "Esta página continua legível; as outras abrem quando você voltar a ficar online." }, "demoBanner": { "message": "Modo demonstração: as alterações não são salvas." @@ -585,6 +600,7 @@ "noAccount": "Não tem uma conta?", "hasAccount": "Já tem uma conta?", "loginFailed": "Falha no login", + "signOutFailed": "Não foi possível sair. Sua sessão continua ativa.", "registerFailed": "Falha no cadastro", "invitedBanner": "Você foi convidado. Crie sua conta mesmo que o cadastro esteja fechado.", "alreadySignedIn": { @@ -668,7 +684,6 @@ "submit": "Assumir", "submitting": "Assumindo…", "errorTaken": "Esse nome de usuário ou e-mail já está em uso.", - "errorRateLimit": "Muitas tentativas. Aguarde alguns minutos e tente novamente.", "errorFailed": "Isso não funcionou. Tente novamente em instantes.", "errorOffline": "Não foi possível alcançar o servidor. Verifique sua conexão e tente novamente.", "ssoContinue": "Continuar com {provider}", @@ -690,7 +705,8 @@ "toLogin": "Ir para o login" }, "deletionPending": "Esta conta será excluída em {date} e está bloqueada até lá. Um administrador deste servidor pode desfazer a exclusão antes dessa data.", - "deletionPendingNoDate": "Esta conta está programada para exclusão e está bloqueada. Um administrador deste servidor pode desfazer a exclusão até que ela seja concluída." + "deletionPendingNoDate": "Esta conta está programada para exclusão e está bloqueada. Um administrador deste servidor pode desfazer a exclusão até que ela seja concluída.", + "signInRateLimited": "Muitas tentativas. Aguarde alguns minutos e tente novamente." }, "dashboard": { "title": "Painel", @@ -6405,6 +6421,7 @@ "webPush": "Push do navegador", "webPushDescription": "Receba notificações diretamente no navegador, mesmo quando o HealthLog não estiver aberto.", "webPushNotSupported": "Seu navegador não oferece suporte a notificações push.", + "webPushInstallFirst": "No iPhone e no iPad, as notificações push só funcionam no app instalado. No Safari, toque em Compartilhar e depois em “Adicionar à Tela de Início”, abra o HealthLog pela Tela de Início e toque aqui em “Ativar push”.", "webPushDenied": "As notificações push estão bloqueadas. Permita-as nas configurações do navegador.", "webPushNotConfigured": "O Web Push não está configurado no servidor.", "webPushSubscribe": "Ativar push", @@ -9284,7 +9301,9 @@ "accountErasureFailedBody": "O prazo de exclusão de {username} terminou, mas apagar a conta falhou. Ela continua bloqueada e uma nova tentativa é feita a cada hora, depois das outras contas pendentes. O erro aparece no cartão de tarefas com falha em Administração." }, "user": { - "telegramTestBody": "HealthLog: conexão bem-sucedida. As notificações do Telegram estão ativas." + "telegramTestBody": "HealthLog: conexão bem-sucedida. As notificações do Telegram estão ativas.", + "webPushTestBody": "Se você está vendo isto, as notificações push chegam a este dispositivo.", + "webPushTestTitle": "Notificação de teste do HealthLog" }, "moodReminder": { "title": "Lembrete diário de humor", diff --git a/messages/ru.json b/messages/ru.json index 85db67ff1..bb63fee93 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -151,7 +151,12 @@ }, "mfa": { "already_active": "Для этого аккаунта уже настроено приложение-аутентификатор. Сначала выключите его, чтобы настроить новое." - } + }, + "register": { + "taken": "Это имя пользователя или адрес электронной почты уже занят.", + "invite_invalid": "Это приглашение больше не действует. Попросите у пригласившего новую ссылку." + }, + "already_authenticated": "Вы уже вошли. Сначала выйдите, затем попробуйте снова." }, "biomarkers": { "unit": { @@ -234,7 +239,17 @@ "budget": { "exceeded": "Бюджет ИИ на сегодня исчерпан, поэтому ничего не отправлено. Он обновится завтра." } - } + }, + "passkey": { + "unknown": "Этот ключ доступа здесь не зарегистрирован. Войдите другим способом.", + "challenge": { + "expired": "Вход занял слишком много времени или уже был использован. Попробуйте снова." + }, + "response": { + "invalid": "Не удалось прочитать ответ ключа доступа. Попробуйте снова." + } + }, + "oidc_only": "Этот сервер допускает только единый вход. Продолжите через своего провайдера идентификации." }, "doctorReport": { "title": "Отчёт о здоровье", @@ -506,9 +521,9 @@ "backToDashboard": "Назад к сводке" }, "offlineBanner": { - "message": "Нет подключения: изменения нельзя сохранить, пока вы снова не в сети.", + "message": "Нет подключения: изменения нельзя сохранить.", "saveFailed": "Не удалось сохранить, вы не в сети. Попробуйте снова, когда подключение вернётся.", - "showingCached": "Показаны последние синхронизированные данные." + "stayOnPage": "Эту страницу можно читать дальше, другие откроются, когда вы снова будете в сети." }, "demoBanner": { "message": "Деморежим: изменения не сохраняются." @@ -585,6 +600,7 @@ "noAccount": "Нет аккаунта?", "hasAccount": "Уже есть аккаунт?", "loginFailed": "Не удалось войти", + "signOutFailed": "Не удалось выйти. Вы по-прежнему в системе.", "registerFailed": "Не удалось зарегистрироваться", "invitedBanner": "Вас пригласили. Создайте аккаунт, даже если регистрация в остальном закрыта.", "alreadySignedIn": { @@ -668,7 +684,6 @@ "submit": "Принять", "submitting": "Принимаем…", "errorTaken": "Это имя пользователя или адрес электронной почты уже используется.", - "errorRateLimit": "Слишком много попыток. Подождите несколько минут и попробуйте снова.", "errorFailed": "Не сработало. Попробуйте через минуту.", "errorOffline": "Не дошло до сервера. Проверьте подключение и попробуйте снова.", "ssoContinue": "Продолжить с {provider}", @@ -690,7 +705,8 @@ "toLogin": "Перейти ко входу" }, "deletionPending": "Этот аккаунт запланирован к удалению на {date} и заблокирован до тех пор. Администратор этого сервера может отменить удаление до этой даты.", - "deletionPendingNoDate": "Этот аккаунт запланирован к удалению и заблокирован. Администратор этого сервера может отменить удаление до его завершения." + "deletionPendingNoDate": "Этот аккаунт запланирован к удалению и заблокирован. Администратор этого сервера может отменить удаление до его завершения.", + "signInRateLimited": "Слишком много попыток. Подождите несколько минут и попробуйте снова." }, "dashboard": { "title": "Сводка", @@ -6405,6 +6421,7 @@ "webPush": "Push в браузере", "webPushDescription": "Получайте уведомления прямо в браузере, даже когда HealthLog не открыт.", "webPushNotSupported": "Ваш браузер не поддерживает push-уведомления.", + "webPushInstallFirst": "На iPhone и iPad push-уведомления работают только в установленном приложении. В Safari нажмите «Поделиться», затем «На экран „Домой“», откройте HealthLog с экрана «Домой» и нажмите здесь «Включить push».", "webPushDenied": "Push-уведомления заблокированы. Разрешите их в настройках браузера.", "webPushNotConfigured": "Web Push не настроен на сервере.", "webPushSubscribe": "Включить push", @@ -9284,7 +9301,9 @@ "accountErasureFailedBody": "Срок удаления {username} истёк, но удалить аккаунт не удалось. Он остаётся заблокированным, и попытка повторяется каждый час после остальных аккаунтов с наступившим сроком. Ошибка указана в карточке сбоящих заданий в разделе Администрирование." }, "user": { - "telegramTestBody": "HealthLog: подключение успешно. Уведомления в Telegram активны." + "telegramTestBody": "HealthLog: подключение успешно. Уведомления в Telegram активны.", + "webPushTestBody": "Если вы видите это сообщение, push-уведомления приходят на это устройство.", + "webPushTestTitle": "Тестовое уведомление HealthLog" }, "moodReminder": { "title": "Ежедневное напоминание о настроении", diff --git a/package.json b/package.json index bcc7d9f4b..4a741829c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.47.3", + "version": "1.47.4", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0497_reenable_subscribed_web_push_channels/migration.sql b/prisma/migrations/0497_reenable_subscribed_web_push_channels/migration.sql new file mode 100644 index 000000000..5eb18c6c7 --- /dev/null +++ b/prisma/migrations/0497_reenable_subscribed_web_push_channels/migration.sql @@ -0,0 +1,38 @@ +-- Turn Web Push back on for every account that has a browser subscription +-- but whose WEB_PUSH channel was switched off by a delivery failure. +-- +-- Until this release a send with no subscription left (push turned off on the +-- last device, or the Home Screen app removed) counted as a failure, and five +-- of them, or one 410, switched the channel off. Subscribing again stored the +-- new browser but left the channel off, so the settings card read "enabled", +-- a test push arrived, and no reminder was ever sent. Subscribing now turns +-- the channel back on; this does the same for the accounts already caught. +-- +-- Only an automatic switch-off is touched: the dispatcher always writes a +-- `disabled_reason`, a channel switched off by hand has none. The fields +-- reset are the ones a re-enable resets (`reEnableChannel`). A channel +-- without any subscription stays off. Running this again finds nothing left +-- to change. +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + + UPDATE "notification_channels" AS c + SET "enabled" = true, + "disabled_reason" = NULL, + "consecutive_failures" = 0, + "next_retry_at" = NULL, + "last_failure_reason" = NULL, + "updated_at" = CURRENT_TIMESTAMP + WHERE c."type" = 'WEB_PUSH' + AND c."enabled" = false + AND c."disabled_reason" IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM "push_subscriptions" AS s + WHERE s."user_id" = c."user_id" + ); +END +$migration$; diff --git a/public/manifest.json b/public/manifest.json index d94dc7fe3..402fcf837 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -40,7 +40,7 @@ { "name": "Add measurement", "short_name": "Add", - "url": "/measurements?add=WEIGHT&source=shortcut", + "url": "/measurements?add=WEIGHT", "icons": [ { "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" } ] @@ -48,7 +48,7 @@ { "name": "Today", "short_name": "Today", - "url": "/?source=shortcut", + "url": "/", "icons": [ { "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" } ] @@ -56,7 +56,7 @@ { "name": "Medications", "short_name": "Meds", - "url": "/medications?source=shortcut", + "url": "/medications", "icons": [ { "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" } ] diff --git a/public/sw.js b/public/sw.js index c6696d309..7b7df52cf 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,159 +1,52 @@ /** - * Service Worker for HealthLog PWA. - * Handles Web Push notifications and offline caching. + * HealthLog service worker: Web Push, a page cache for public pages, and a + * static cache for catalogs, icons, fonts and images. API answers and app + * chunks are never stored here. */ -// v1.4.38.4 — `CACHE_VERSION` now tracks the app release tag. -// Bumped on every release so the `activate` step evicts every -// pre-release cache entry. Without this the precached root HTML and -// the cached `/_next/static/*` chunks survived across deploys, -// stale-shell-served the old chunk graph, and the running app then -// hit `ChunkLoadError` on the first lazy navigation. Pair with the -// `` client component (mounted in ``) that -// polls `/api/version`, compares against `NEXT_PUBLIC_APP_VERSION`, -// and triggers an SW-unregister + cache-wipe + hard reload when the -// server moves ahead of the running shell. -// -// v1.4.43 — read the active version from `self.__APP_VERSION__`, -// which the build step writes to `/public/sw-version.js` (loaded -// synchronously below via `importScripts`). Pre-fix the literal was -// hand-bumped per release and quietly drifted four releases stale, so -// every deploy between v1.4.38.4 → v1.4.42 served the previous shell's -// cache instead of evicting it on activate. The fallback literal stays -// in case the import is missing (legacy SW versions, dev mode without -// the build step), but the source of truth is now the generated file. +// Cache names carry the release, so `activate` drops every older cache. The +// build writes the release to `/sw-version.js`; the literal below is the +// fallback when that file is missing (dev without `prebuild`, a 404), and +// `scripts/generate-sw-version.mjs` rewrites it at the marker on every +// build. Do not edit it by hand. try { importScripts("/sw-version.js"); } catch { - // Fall through to the literal fallback below. + // Fall through to the fallback literal. } -// The `|| "..."` fallback literal is the value used only when the -// `importScripts('/sw-version.js')` above is absent (dev with no `prebuild` -// step, or a 404 on the generated file). `scripts/generate-sw-version.mjs` -// rewrites it to the current `package.json` version on every prebuild — the -// marker below is the rewrite anchor — so in any shipped image the literal -// matches the active release and can never drift stale the way it did across -// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.47.3"; + /* @sw-version-fallback */ "v1.47.4"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; -// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` -// reads. Network-first: online callers always get the fresh server response -// (and the cache is refreshed behind it), so a read-after-write never serves a -// stale pre-mutation list. Offline (the network fetch throws) the LAST cached -// dashboard/series is served instead of empty skeletons forever. -// -// v1.18.6.x — reverted from stale-while-revalidate, which served the cached -// pre-mutation copy first and only revalidated in the background, so a list -// read straight after a create/update showed the old (or empty) data until the -// next paint. Network-first restores read-after-write freshness while keeping -// the offline fallback. -const DATA_CACHE = `healthlog-data-${CACHE_VERSION}`; -// Keep this ownership contract aligned with -// `clearOfflineCachesForSessionEnd()` in `query-persister.ts`. Cache cleanup -// must cover every current/legacy HealthLog cache without deleting unrelated -// CacheStorage namespaces owned by another app on the same origin. +// Every HealthLog cache, current and legacy, and nothing another app on the +// origin owns. `data` stays in the pattern so caches from releases that still +// kept API reads are deleted on activate. Same pattern as +// `HEALTHLOG_CACHE_NAME_RE` in `src/lib/pwa/sw-cache-policy.ts`. const HEALTHLOG_CACHE_NAME_RE = /^healthlog-(?:static|pages|data)-/; -const CURRENT_HEALTHLOG_CACHES = new Set([ - STATIC_CACHE, - PAGE_CACHE, - DATA_CACHE, -]); -// Presence marker `src/proxy.ts` sets on every HealthLog page response. See -// `isForeignNavigationResponse` for why the worker needs it and the proxy for -// why it carries no version. Keep the name in lockstep with -// `HEALTHLOG_PAGE_MARKER_HEADER` there. +const CURRENT_HEALTHLOG_CACHES = new Set([STATIC_CACHE, PAGE_CACHE]); +// Set by `src/proxy.ts` on every HealthLog page; same name as there. const HEALTHLOG_PAGE_MARKER_HEADER = "X-HealthLog-Page"; -// Set as soon as a navigation looks like another app (see -// `isForeignNavigationResponse`). While it is set the fetch handler declines -// every request, so the page and its assets come from the network. It is -// cleared again when `confirmForeignOrigin` finds HealthLog after all, and -// stays set for the rest of the worker's life when the origin is confirmed -// foreign and the worker retires. +// While set, the fetch handler declines every request. Set when a page looks +// like another app, cleared unless the probe confirms another app. let foreignOrigin = false; -// One confirmation at a time: a burst of navigations must not fire a burst of -// probes, or race one probe's "HealthLog after all" against another's retire. +// One confirmation at a time, shared by a burst of navigations. let foreignOriginCheck = null; const MAX_STATIC_ENTRIES = 150; const MAX_PAGE_ENTRIES = 30; -const MAX_DATA_ENTRIES = 60; -// Safe-GET read allowlist. ONLY idempotent reads that render the core views -// and carry NO secret-shaped body. Auth, mutations and token endpoints are -// never listed — and `isCacheableApiResponse` is a second, body-level guard -// (mirrors the idempotency cache's `hlk_`/`hlr_`/`sk-` refusal) so a future -// endpoint that slipped onto the list can still never persist a secret. -// Matched by exact path or `/` / `?` prefix. -// v1.18.6 — AI/clinical narrative surfaces (`/api/insights`, `/api/analytics`) -// are deliberately NOT cached: `/api/insights` prefix-matched the Coach chat -// endpoint into the disk cache, and neither belongs on a self-hosted health -// PWA's durable storage. Only the dashboard snapshot, the widget layout, the -// measurement reads (incl. the batched daily series), the medication reads, -// and the version probe are eligible. -const API_READ_ALLOWLIST = [ - "/api/dashboard/snapshot", - // v1.37.20 — daily digest: the Today rail's offline read. Keep in - // lockstep with src/lib/pwa/sw-cache-policy.ts. - "/api/daily/digest", - "/api/dashboard/widgets", - "/api/measurements", - "/api/medications", - "/api/version", -]; - -// Defence in depth: even an allowlisted path is refused if its pathname looks -// like an auth/token/secret surface. -const API_DENY_RE = /\/(auth|tokens?|sessions?|login|password|webauthn)(\/|$|\?)/i; - -function isAllowlistedApiRead(pathname) { - if (API_DENY_RE.test(pathname)) return false; - return API_READ_ALLOWLIST.some( - (p) => - pathname === p || - pathname.startsWith(p + "/") || - pathname.startsWith(p + "?"), - ); -} - -// Body-level secret refusal: never persist a body carrying a token/secret- -// shaped pattern even if the path was allowlisted by mistake. -const SECRET_BODY_RE = /(hlk_|hlr_|sk-)[A-Za-z0-9_-]/; - -function isCacheableApiResponse(response, bodyText) { - if (!response || !response.ok) return false; - const cacheControl = response.headers.get("Cache-Control") || ""; - if (/no-store/i.test(cacheControl)) return false; - if (typeof bodyText === "string" && SECRET_BODY_RE.test(bodyText)) { - return false; - } - return true; -} - -// Public immutable assets to precache on install. Never include `/`: installing -// while signed in would fetch the authenticated Dashboard document with the -// browser's session cookies and persist that health-bearing HTML in -// CacheStorage for replay after logout. +// Never `/`: installed while signed in, it would store the dashboard HTML. const PRECACHE_URLS = ["/logo-192.png", "/logo-512.png", "/favicon.svg"]; -// ── Install: precache public immutable assets ──────────────────────────────── self.addEventListener("install", (event) => { event.waitUntil( caches .open(STATIC_CACHE) - .then(async (cache) => { - // An updated worker can reuse the same release-scoped cache when its - // source changes without a version bump. Remove a root document left - // by the previous worker before adding the safe public asset set. - await cache.delete("/"); - await cache.addAll(PRECACHE_URLS); - }) + .then((cache) => cache.addAll(PRECACHE_URLS)) .then(() => self.skipWaiting()), ); }); -// ── Activate: clean up old caches ──────────────────────────────────────────── self.addEventListener("activate", (event) => { event.waitUntil( Promise.all([ @@ -170,42 +63,27 @@ self.addEventListener("activate", (event) => { .map((k) => caches.delete(k)), ), ), - // Navigation preload lets the browser start the navigation request - // in parallel with service-worker startup; `networkFirst` consumes - // it via `event.preloadResponse`, shaving SW boot latency off every - // page navigation. Guarded — older engines lack the API. + // The browser starts the navigation while the worker boots; + // `networkFirst` uses `event.preloadResponse`. self.registration.navigationPreload ? self.registration.navigationPreload.enable() : Promise.resolve(), + clearLeftoverAppBadge(), ]).then(() => self.clients.claim()), ); }); -// ── Fetch: strategy per resource type ──────────────────────────────────────── self.addEventListener("fetch", (event) => { - // The origin serves another app now (see `retireFromForeignOrigin`): no - // `respondWith`, no cache, the browser's own fetch for everything. if (foreignOrigin) return; const { request } = event; const url = new URL(request.url); - - // Only handle same-origin GET requests if (request.method !== "GET" || url.origin !== self.location.origin) return; - // One-shot auth navigations must reach the server EXACTLY ONCE. The OIDC - // login + callback endpoints are top-level browser navigations (not - // `fetch`/XHR), so with navigation preload enabled (see `activate`) the - // browser has ALREADY dispatched the request by the time this handler runs. - // Returning early WITHOUT `event.respondWith()` — the general `/api/` path - // below does exactly that — makes the browser ALSO run its own default - // navigation fetch, so the request is sent twice. The OIDC authorization - // code is single-use: the first callback redeems it and sets the session, - // the second fails at the IdP and redirects to - // `/auth/login?error=oidc_failed` on an otherwise-successful sign-in. - // Consuming the preload response here (or fetching once when there is none) - // settles the event with a single network request. These responses are - // never cached. + // The OIDC login and callback are navigations the browser has already sent + // as a preload. Declining here would make it send them a second time, and + // the single-use code fails on the second. Answer with the preload (or one + // fetch) and store nothing. if (url.pathname.startsWith("/api/auth/")) { event.respondWith( (async () => (await event.preloadResponse) || fetch(request))(), @@ -213,52 +91,28 @@ self.addEventListener("fetch", (event) => { return; } - // API calls. Allowlisted safe GET reads use network-first so the installed - // PWA always renders fresh server data online (and falls back to the last - // cached copy only when the network fails); everything else (auth, - // mutations, anything not on the list) stays network-only. - if (url.pathname.startsWith("/api/")) { - if (isAllowlistedApiRead(url.pathname)) { - event.respondWith(networkFirstApi(request)); - } - return; - } + // Every other API request goes to the network untouched: no API answer is + // kept on this device. + if (url.pathname.startsWith("/api/")) return; - // Next.js static assets — cache-first (immutable hashed filenames) - if (url.pathname.startsWith("/_next/static/")) { - event.respondWith(cacheFirst(request, STATIC_CACHE)); - return; - } - - // Locale-catalog boot script — cache-first. The URL is versioned by the - // catalogs' content hash (`/i18n/?v=`, `next.config.ts`), - // so a changed catalog is a new URL and cache-first is - // correct and keeps the catalog available on an offline relaunch (the - // cached shell HTML references the same versioned URL it was rendered - // with). Carries no user data — same public strings as the repository's - // messages/*.json. + // Locale catalog: the URL carries the catalogs' content hash, so a change + // is a new URL. Public strings only. if (url.pathname.startsWith("/i18n/")) { event.respondWith(cacheFirst(request, STATIC_CACHE)); return; } - // Static files (fonts, icons, images) — cache-first with long TTL if (/\.(png|svg|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$/i.test(url.pathname)) { event.respondWith(cacheFirst(request, STATIC_CACHE)); return; } - // HTML pages — network-first with cache fallback. The whole event is - // passed so `networkFirst` can consume the navigation-preload response. if (request.headers.get("accept")?.includes("text/html")) { event.respondWith(networkFirst(event, PAGE_CACHE)); return; } }); -/** - * Cache-first: serve from cache, fall back to network and cache the response. - */ async function cacheFirst(request, cacheName) { const cached = await caches.match(request); if (cached) return cached; @@ -276,80 +130,13 @@ async function cacheFirst(request, cacheName) { } /** - * Network-first for allowlisted safe GET `/api/*` reads. - * - * Always tries the network first so an online caller gets the fresh server - * response — critical for read-after-write: a list fetched straight after a - * create/update must reflect the mutation, not a stale pre-mutation copy. On a - * cacheable response the data cache is refreshed behind it. Only when the - * network fails (offline) is the last cached copy served; with neither, a JSON - * 503 lets the client's TanStack Query cache / IndexedDB persister supply data. - * - * Caching is gated twice: `isAllowlistedApiRead` already filtered the path, - * and `isCacheableApiResponse` inspects the actual body so a `no-store` or - * secret-shaped (`hlk_`/`hlr_`/`sk-`) response is never persisted — the same - * refusal discipline the idempotency cache uses. - */ -async function networkFirstApi(request) { - - try { - const response = await fetch(request); - // Read the body once to body-screen it, then reconstruct a fresh Response - // so both the cache.put and the return value have an unconsumed body. - const bodyText = await response - .clone() - .text() - .catch(() => undefined); - if (isCacheableApiResponse(response, bodyText)) { - await bestEffortCacheWrite( - DATA_CACHE, - request, - new Response(bodyText, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }), - MAX_DATA_ENTRIES, - ); - } - return response; - } catch { - // Network failed (offline) — serve the last cached copy if we have one. - const cache = await caches.open(DATA_CACHE); - const cached = await cache.match(request); - if (cached) return cached; - - return new Response( - JSON.stringify({ data: null, error: "offline", meta: { offline: true } }), - { - status: 503, - headers: { "Content-Type": "application/json; charset=utf-8" }, - }, - ); - } -} - -/** - * Privacy gate for the navigation page cache. Most app routes are - * client-fetch-only shells with no server-rendered PII (health JSON loads - * over `/api/*`, which is network-only here), so caching their HTML is - * safe. Two carve-outs: - * - * 1. A `Cache-Control: no-store` response opts itself out — the - * principled, future-proof rule for any current/future server RSC - * that renders user data and sets the header. - * 2. `/c/*` (the clinician-share view) renders health values + wellness - * scores straight into the HTML and emits `no-store`; the explicit - * path skip is belt-and-braces so a revoked share can never linger in - * CacheStorage and render back offline. - * - * In practice rule 1 covers every signed-in page: `src/proxy.ts` pins - * `private, no-store` on all of them, because several server-prefetch one - * account's record into the HTML. Only the public pages (sign-in, legal) - * land in this cache, so a navigation to an app page without a connection - * gets the offline page below, never somebody's cached record. A page that - * is already open keeps working offline from the cached chunks and the - * allowlisted data reads. + * Which navigation responses may land in the page cache. Signed-in pages + * never do: the proxy sends them `private, no-store` because some carry one + * account's record in the HTML. Two paths are skipped by name as well: the + * clinician share view `/c/*` (no-store too; a revoked share must never + * render back offline) and `/onboarding/*`. What is left are the public + * pages (sign-in, legal), so a navigation to an app page without a + * connection gets the offline page, never somebody's cached record. */ function isCacheableNavigation(request, response) { const cacheControl = response.headers.get("Cache-Control") || ""; @@ -357,6 +144,11 @@ function isCacheableNavigation(request, response) { try { const { pathname } = new URL(request.url); if (pathname === "/c" || pathname.startsWith("/c/")) return false; + // Public to the proxy, so not no-store, but rendered for a signed-in + // account during its setup. + if (pathname === "/onboarding" || pathname.startsWith("/onboarding/")) { + return false; + } } catch { // Unparseable URL — fall through; the no-store check already ran. } @@ -364,30 +156,11 @@ function isCacheableNavigation(request, response) { } /** - * Does this navigation response come from something other than HealthLog? - * - * The worker is registered with scope `/` because HealthLog serves everything - * from `/`. When HealthLog is stopped and a different app starts on the same - * origin (the usual case is `localhost:3000` on a developer machine), this - * worker kept answering: `/_next/static/*` is cache-first, so the other app - * ran on HealthLog's cached chunks, and the only way out was unregistering by - * hand in the browser's developer tools. - * - * Only a response that positively looks like another app counts: - * - * - a redirect is opaque to the worker (`opaqueredirect`, status 0, no - * readable headers), and HealthLog redirects every signed-out page, so it - * proves nothing; status 0 fails the `ok` check below; - * - a non-2xx answer proves nothing either: a reverse proxy or auth gateway - * in front of a stopped HealthLog answers 502 or 401 without the marker, - * and unregistering there would also drop the Web Push subscription; - * - a network failure never reaches this check (it throws into the offline - * fallback in `networkFirst`). - * - * What is left is a successful page without the marker HealthLog's proxy puts - * on every page response. That is a suspicion, not a verdict: a reverse proxy - * with a response-header allowlist strips the marker from every genuine page. - * `confirmForeignOrigin` settles it before anything is deleted. + * Does this page come from another app on the same origin (a developer's + * `localhost:3000`, say)? Only a successful page without the proxy's marker + * counts: a redirect is opaque, and a 401 or 502 from a gateway in front of a + * stopped HealthLog proves nothing. Still only a suspicion, because a proxy + * with a header allowlist strips the marker; `confirmForeignOrigin` decides. */ function isForeignNavigationResponse(response) { if (!response || !response.ok) return false; @@ -395,30 +168,15 @@ function isForeignNavigationResponse(response) { } /** - * Is the origin positively not HealthLog? Asked of `/api/version`, which is - * public (on the proxy's `PUBLIC_PATHS` list, no session needed) and answers - * `{ data: { version, buildSha, ... } }`. A body check survives any proxy that - * rewrites headers. + * Is the origin positively not HealthLog? Asks the public `/api/version`, + * whose body (`data.version` a string, `data.buildSha` present, possibly + * null) survives any header rewriting. A 404, non-JSON or another shape is + * foreign; a thrown fetch, a 5xx, a 401 or a redirect proves nothing. * - * Retire only on a positive answer from something else: a 404, a 2xx that is - * not JSON (another app's HTML fallback), or JSON without the HealthLog shape. - * `buildSha` is `null` on an image not built by the release workflow, so the - * shape is "`version` is a string and `buildSha` is present", not "both are - * strings". Anything that is not an answer (a thrown fetch, a 5xx, a 401 from - * an auth gateway, a redirect) proves nothing, and the worker resumes normal - * service. - * - * One gateway shape still reads as "another app": a same-origin auth gateway - * that answers an expired session with its sign-in page and status 200 (nginx - * `auth_request` with `error_page 401 = /oauth2/sign_in` does this). Both the - * navigation and this probe then come back as HTML. The probe sends the - * gateway cookie (`credentials: "same-origin"`), so a valid gateway session - * reaches HealthLog; with an expired one the verdict is wrong, which is why - * `retireFromForeignOrigin` never unregisters while a push subscription exists. - * - * The probe is the worker's own `fetch`, which never passes through this - * worker's fetch handler; `no-store` keeps the HTTP cache out of it and - * `redirect: "manual"` keeps a login redirect from reading as an answer. + * An auth gateway that answers an expired session with a 200 sign-in page + * still reads as foreign, which is why `retireFromForeignOrigin` keeps a + * worker that holds a push subscription. The gateway cookie is sent, so a + * valid gateway session reaches HealthLog. */ async function confirmForeignOrigin() { let response; @@ -448,9 +206,7 @@ async function confirmForeignOrigin() { return !isHealthlog; } -/** - * Confirm, then either retire or resume. Shared by concurrent navigations. - */ +/** Confirm, then retire or resume. Shared by concurrent navigations. */ function checkForeignOrigin() { if (!foreignOriginCheck) { foreignOriginCheck = (async () => { @@ -464,8 +220,7 @@ function checkForeignOrigin() { await retireFromForeignOrigin(); return; } - // HealthLog after all, or no answer: serve normally again, and let a - // later suspicious navigation ask again. + // HealthLog after all, or no answer: resume, and ask again next time. foreignOrigin = false; foreignOriginCheck = null; })(); @@ -474,11 +229,9 @@ function checkForeignOrigin() { } /** - * Whether this registration holds a Web Push subscription. Unregistering - * destroys the subscription and nothing in the app re-creates it (the push - * card only reads it), so reminders would stop without notice. A missing - * `pushManager` or a throwing lookup counts as "has one": keeping a worker - * that only passes requests through costs nothing, losing reminders does. + * Whether this registration holds a Web Push subscription, which + * unregistering would destroy without notice. When unsure, yes: a + * pass-through worker costs nothing, lost reminders do. */ async function hasPushSubscription() { try { @@ -493,18 +246,11 @@ async function hasPushSubscription() { } /** - * Leave an origin that no longer serves HealthLog: delete every HealthLog - * cache (only ours, matched by name, never another app's CacheStorage) and - * unregister, so the next load of that origin runs without this worker. The - * `foreignOrigin` flag set by the caller stays set, so the requests the - * current page still makes go to the network. Each step is best-effort and - * independent: a failed cache delete must not keep the worker registered. - * - * With a push subscription the worker does not unregister: the verdict may - * be a gateway's 200 sign-in page (see `confirmForeignOrigin`), and - * unregistering would destroy the subscription. It deletes the caches and - * stays in pass-through for the rest of its life instead, so another app on - * the origin still gets network content. The next worker start probes again. + * Leave an origin that no longer serves HealthLog: delete HealthLog's caches + * (never another app's) and unregister. With a push subscription it stays + * registered in pass-through instead (`foreignOrigin` stays set), and the + * next worker start probes again. Each step is independent: a failed delete + * must not keep the worker registered. */ async function retireFromForeignOrigin() { try { @@ -515,7 +261,7 @@ async function retireFromForeignOrigin() { .map((k) => caches.delete(k)), ); } catch { - // CacheStorage unavailable; unregistering still matters more. + // CacheStorage unavailable; unregistering matters more. } if (await hasPushSubscription()) return; try { @@ -526,14 +272,11 @@ async function retireFromForeignOrigin() { } /** - * Network-first: try network (preferring the navigation-preload response - * when the browser already started it), fall back to cache. - * - * The offline fallback only serves a shell cached under the CURRENT - * `CACHE_VERSION` cache names. The previous global `caches.match()` could - * resolve from a stale pre-update cache in the activation gap and serve a - * shell whose `/_next/static/*` chunk graph no longer exists - * (`ChunkLoadError` on the first lazy navigation). + * Network-first for pages: the navigation-preload response when the browser + * already started it, else a fetch. Without a connection, a page from this + * release's page cache (only public pages are there), else the offline page. + * The lookup is scoped to this release's cache name: a page from an older + * release would reference chunks that no longer exist. */ async function networkFirst(event, cacheName) { const { request } = event; @@ -542,8 +285,7 @@ async function networkFirst(event, cacheName) { (event.preloadResponse ? await event.preloadResponse : null) || (await fetch(request)); if (isForeignNavigationResponse(response)) { - // Step aside at once (declining a request only sends it to the - // network), but delete nothing until the origin is confirmed foreign. + // Step aside at once; delete nothing until it is confirmed. foreignOrigin = true; event.waitUntil(checkForeignOrigin()); return response; @@ -560,28 +302,8 @@ async function networkFirst(event, cacheName) { } return response; } catch { - // Version-scoped fallback: the page cache first, then the precached - // app shell — both names embed CACHE_VERSION, so a stale shell from a - // previous release can never be served here. - const pageCache = await caches.open(cacheName); - const cachedPage = await pageCache.match(request); + const cachedPage = await (await caches.open(cacheName)).match(request); if (cachedPage) return cachedPage; - const staticCache = await caches.open(STATIC_CACHE); - const precached = await staticCache.match(request); - if (precached) return precached; - - // Offline fallback. F-7 (mobile security audit, 2026-05-16): the - // previous fallback rendered hard-coded German body copy, which is - // wrong for any en/* user reaching it offline. The replacement is - // language-neutral — wordmark + the universally understood - // "Offline" token + a generic retry hint expressed as an icon - // (the round-trip arrow), so the page does the right thing in any - // locale without shipping a translation bundle into the worker. - // - // v1.42 — the arrow is a real control now (a link to the same URL, - // 64 px), the page reloads by itself as soon as the connection is - // back, it follows the device's light or dark scheme, and it keeps - // clear of the notch and home indicator of an installed app. return new Response(OFFLINE_PAGE, { status: 503, headers: { "Content-Type": "text/html; charset=utf-8" }, @@ -589,6 +311,9 @@ async function networkFirst(event, cacheName) { } } +// Language-neutral, because the worker ships no catalog: the wordmark, the +// word "Offline" and a 64 px retry link. It reloads itself when the +// connection returns, follows the device scheme and keeps clear of the notch. const OFFLINE_PAGE = 'HealthLog — Offline