diff --git a/README.md b/README.md index 392723cc0..f0954ed0d 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig - **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine 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. - **Clinician-ready output.** 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 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, 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/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/v137-record-session-fence-offline.spec.ts b/e2e/v137-record-session-fence-offline.spec.ts index 23984fe43..dd31ead4a 100644 --- a/e2e/v137-record-session-fence-offline.spec.ts +++ b/e2e/v137-record-session-fence-offline.spec.ts @@ -5,18 +5,19 @@ import { FENCE_OFFLINE_STORAGE_STATE_PATH } from "./setup/test-helpers"; /** * FENCE-AC-07: no record's API answers are kept in CacheStorage, before or - * after a record switch. + * after a record switch, and the one per-account layer on disk, the IndexedDB + * dashboard snapshot, still belongs to the caller inside another record. * * 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 only per-account layer on disk is the IndexedDB snapshot, which - * refuses to write or restore inside another record (unit-tested in - * `query-persister-shared-record.test.ts`). + * 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. * - * The positive control is the page cache: the worker is running and storing - * something (public pages, catalogs, icons), so "no API entry" is not the - * same sentence as "the worker did 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 used to be kept on disk. Visited, so the reads are the app's own. */ @@ -101,6 +102,41 @@ async function cacheState(page: Page) { }); } +/** + * 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 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 no record's API answers are kept on disk across a switch", async ({ browser, @@ -141,7 +177,7 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => { await expectShellReady(page); } - // POSITIVE CONTROL: the worker controls the page and keeps something. + // POSITIVE CONTROL: the install precache is there and readable. await expect .poll(async () => (await cacheState(page)).entries, { timeout: 15_000, @@ -152,6 +188,14 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => { expect(own.names.filter((n) => n.startsWith("healthlog-data-"))).toEqual( [], ); + const ownId = await page.evaluate( + async () => + ( + (await (await fetch("/api/auth/me")).json()) as { + data: { id: string }; + } + ).data.id, + ); await enterRecord(page); for (const path of WARM_PAGES) { @@ -167,6 +211,9 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => { 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 { // Hand the shared session back to its own record; see the note in // `v137-record-session-fence.spec.ts`. diff --git a/messages/de.json b/messages/de.json index 7ed96899a..41b64180e 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,7 +521,7 @@ "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.", "stayOnPage": "Diese Seite bleibt lesbar, andere Seiten öffnen sich, sobald du wieder online bist." }, @@ -585,7 +600,7 @@ "noAccount": "Noch kein Konto?", "hasAccount": "Bereits ein Konto?", "loginFailed": "Anmeldung fehlgeschlagen", - "signOutFailed": "Abmelden hat nicht geklappt. Du bist weiterhin angemeldet.", + "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": { @@ -669,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}", @@ -691,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", @@ -6393,7 +6408,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 schalte Push hier ein.", + "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", @@ -9274,7 +9289,8 @@ }, "user": { "telegramTestBody": "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv.", - "webPushTestBody": "Wenn du das siehst, kommen Push-Benachrichtigungen auf diesem Gerät an." + "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 6c67dc6c5..dea9402a2 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,7 +521,7 @@ "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.", "stayOnPage": "This page stays readable; other pages open once you're back online." }, @@ -669,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}", @@ -691,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", @@ -6393,7 +6408,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 turn on push here.", + "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", @@ -9274,7 +9289,8 @@ }, "user": { "telegramTestBody": "HealthLog: connection successful. Telegram notifications are active.", - "webPushTestBody": "If you can see this, push notifications reach this device." + "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 02a1df1e7..e4284f1e3 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,7 +521,7 @@ "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.", "stayOnPage": "Esta página se puede seguir leyendo; las demás se abrirán cuando vuelvas a estar en línea." }, @@ -585,7 +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. Tu sesión sigue abierta.", + "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": { @@ -669,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}", @@ -691,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", @@ -6393,7 +6408,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 activa las notificaciones push aquí.", + "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", @@ -9274,7 +9289,8 @@ }, "user": { "telegramTestBody": "HealthLog: conexión correcta. Las notificaciones de Telegram están activas.", - "webPushTestBody": "Si ves esto, las notificaciones push llegan a este dispositivo." + "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 0577bd9f1..0d5cddda6 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,7 +521,7 @@ "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.", "stayOnPage": "Cette page reste lisible ; les autres s'ouvriront dès que vous serez de nouveau en ligne." }, @@ -669,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}", @@ -691,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", @@ -6393,7 +6408,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 activez les notifications push ici.", + "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", @@ -9274,7 +9289,8 @@ }, "user": { "telegramTestBody": "HealthLog: connexion réussie. Les notifications Telegram sont actives.", - "webPushTestBody": "Si vous voyez ceci, les notifications push arrivent sur cet appareil." + "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 a2791af23..092346598 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,7 +521,7 @@ "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.", "stayOnPage": "Questa pagina resta leggibile; le altre si aprono quando torni online." }, @@ -669,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}", @@ -691,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", @@ -6393,7 +6408,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 attiva qui 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", @@ -9274,7 +9289,8 @@ }, "user": { "telegramTestBody": "HealthLog: connessione riuscita. Le notifiche Telegram sono attive.", - "webPushTestBody": "Se vedi questo messaggio, le notifiche push arrivano su questo dispositivo." + "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 6fa577571..dc6f68aee 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,7 +521,7 @@ "backToDashboard": "대시보드로 돌아가기" }, "offlineBanner": { - "message": "연결이 없어요. 온라인으로 돌아올 때까지 저장할 수 없어요.", + "message": "연결이 없어요. 지금은 저장할 수 없어요.", "saveFailed": "저장하지 못했어요. 오프라인 상태예요. 온라인으로 돌아온 뒤 다시 시도해 주세요.", "stayOnPage": "이 페이지는 계속 볼 수 있어요. 다른 페이지는 온라인으로 돌아오면 열려요." }, @@ -669,7 +684,6 @@ "submit": "넘겨받기", "submitting": "넘겨받는 중…", "errorTaken": "이미 사용 중인 사용자 이름 또는 이메일 주소입니다.", - "errorRateLimit": "시도 횟수가 너무 많습니다. 몇 분 후 다시 시도하세요.", "errorFailed": "처리하지 못했습니다. 잠시 후 다시 시도하세요.", "errorOffline": "서버에 연결하지 못했습니다. 연결을 확인하고 다시 시도하세요.", "ssoContinue": "{provider}(으)로 계속", @@ -691,7 +705,8 @@ "toLogin": "로그인으로 이동" }, "deletionPending": "이 계정은 {date}에 삭제될 예정이며 그때까지 잠겨 있어요. 그 전에는 이 서버의 관리자가 삭제를 취소할 수 있어요.", - "deletionPendingNoDate": "이 계정은 삭제 예정이라 잠겨 있어요. 삭제가 끝나기 전까지는 이 서버의 관리자가 취소할 수 있어요." + "deletionPendingNoDate": "이 계정은 삭제 예정이라 잠겨 있어요. 삭제가 끝나기 전까지는 이 서버의 관리자가 취소할 수 있어요.", + "signInRateLimited": "시도 횟수가 너무 많아요. 몇 분 후 다시 시도해 주세요." }, "dashboard": { "title": "대시보드", @@ -6393,7 +6408,7 @@ "webPush": "브라우저 푸시", "webPushDescription": "HealthLog를 열어 두지 않아도 브라우저로 바로 알림을 받아요.", "webPushNotSupported": "이 브라우저는 푸시 알림을 지원하지 않아요.", - "webPushInstallFirst": "iPhone과 iPad에서는 설치한 앱에서만 푸시 알림을 받을 수 있어요. Safari에서 공유를 누른 다음 '홈 화면에 추가'를 선택하고, 홈 화면에서 HealthLog를 연 뒤 여기서 푸시 알림을 켜 주세요.", + "webPushInstallFirst": "iPhone과 iPad에서는 설치한 앱에서만 푸시 알림을 받을 수 있어요. Safari에서 공유를 누른 다음 '홈 화면에 추가'를 선택하고, 홈 화면에서 HealthLog를 연 뒤 여기서 '푸시 켜기'를 눌러 주세요.", "webPushDenied": "푸시 알림이 차단돼 있어요. 브라우저 설정에서 허용해 주세요.", "webPushNotConfigured": "서버에 Web Push가 설정돼 있지 않아요.", "webPushSubscribe": "푸시 켜기", @@ -9274,7 +9289,8 @@ }, "user": { "telegramTestBody": "HealthLog: 연결에 성공했어요. Telegram 알림이 켜져 있어요.", - "webPushTestBody": "이 알림이 보이면 이 기기에서 푸시 알림을 받을 수 있어요." + "webPushTestBody": "이 알림이 보이면 이 기기에서 푸시 알림을 받을 수 있어요.", + "webPushTestTitle": "HealthLog 테스트 알림" }, "moodReminder": { "title": "매일 기분 알림", diff --git a/messages/nl.json b/messages/nl.json index 8d7ee8224..51d74116f 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,7 +521,7 @@ "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.", "stayOnPage": "Deze pagina blijft leesbaar; andere pagina's openen zodra je weer online bent." }, @@ -669,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}", @@ -691,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", @@ -6393,7 +6408,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 zet pushmeldingen hier aan.", + "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", @@ -9274,7 +9289,8 @@ }, "user": { "telegramTestBody": "HealthLog: verbinding geslaagd. Telegram-meldingen zijn actief.", - "webPushTestBody": "Zie je dit, dan komen pushmeldingen aan op dit apparaat." + "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 0ca051a4d..ba7664f2a 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,7 +521,7 @@ "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.", "stayOnPage": "Ta strona pozostaje czytelna, inne otworzą się, gdy wrócisz online." }, @@ -669,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}", @@ -691,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", @@ -6393,7 +6408,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 włącz tutaj powiadomienia 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", @@ -9274,7 +9289,8 @@ }, "user": { "telegramTestBody": "HealthLog: połączenie udane. Powiadomienia Telegram są aktywne.", - "webPushTestBody": "Jeśli to widzisz, powiadomienia push docierają na to urządzenie." + "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 d546d0448..9c4c36a1c 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,7 +521,7 @@ "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.", "stayOnPage": "Esta página continua legível; as outras abrem quando você voltar a ficar online." }, @@ -669,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}", @@ -691,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", @@ -6393,7 +6408,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 ative as notificações push aqui.", + "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", @@ -9274,7 +9289,8 @@ }, "user": { "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." + "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 f4ad47846..6f368e37c 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,7 +521,7 @@ "backToDashboard": "Назад к сводке" }, "offlineBanner": { - "message": "Нет подключения: изменения нельзя сохранить, пока вы снова не в сети.", + "message": "Нет подключения: изменения нельзя сохранить.", "saveFailed": "Не удалось сохранить, вы не в сети. Попробуйте снова, когда подключение вернётся.", "stayOnPage": "Эту страницу можно читать дальше, другие откроются, когда вы снова будете в сети." }, @@ -669,7 +684,6 @@ "submit": "Принять", "submitting": "Принимаем…", "errorTaken": "Это имя пользователя или адрес электронной почты уже используется.", - "errorRateLimit": "Слишком много попыток. Подождите несколько минут и попробуйте снова.", "errorFailed": "Не сработало. Попробуйте через минуту.", "errorOffline": "Не дошло до сервера. Проверьте подключение и попробуйте снова.", "ssoContinue": "Продолжить с {provider}", @@ -691,7 +705,8 @@ "toLogin": "Перейти ко входу" }, "deletionPending": "Этот аккаунт запланирован к удалению на {date} и заблокирован до тех пор. Администратор этого сервера может отменить удаление до этой даты.", - "deletionPendingNoDate": "Этот аккаунт запланирован к удалению и заблокирован. Администратор этого сервера может отменить удаление до его завершения." + "deletionPendingNoDate": "Этот аккаунт запланирован к удалению и заблокирован. Администратор этого сервера может отменить удаление до его завершения.", + "signInRateLimited": "Слишком много попыток. Подождите несколько минут и попробуйте снова." }, "dashboard": { "title": "Сводка", @@ -6393,7 +6408,7 @@ "webPush": "Push в браузере", "webPushDescription": "Получайте уведомления прямо в браузере, даже когда HealthLog не открыт.", "webPushNotSupported": "Ваш браузер не поддерживает push-уведомления.", - "webPushInstallFirst": "На iPhone и iPad push-уведомления работают только в установленном приложении. В Safari нажмите «Поделиться», затем «На экран „Домой“», откройте HealthLog с экрана «Домой» и включите push-уведомления здесь.", + "webPushInstallFirst": "На iPhone и iPad push-уведомления работают только в установленном приложении. В Safari нажмите «Поделиться», затем «На экран „Домой“», откройте HealthLog с экрана «Домой» и нажмите здесь «Включить push».", "webPushDenied": "Push-уведомления заблокированы. Разрешите их в настройках браузера.", "webPushNotConfigured": "Web Push не настроен на сервере.", "webPushSubscribe": "Включить push", @@ -9274,7 +9289,8 @@ }, "user": { "telegramTestBody": "HealthLog: подключение успешно. Уведомления в Telegram активны.", - "webPushTestBody": "Если вы видите это сообщение, push-уведомления приходят на это устройство." + "webPushTestBody": "Если вы видите это сообщение, push-уведомления приходят на это устройство.", + "webPushTestTitle": "Тестовое уведомление HealthLog" }, "moodReminder": { "title": "Ежедневное напоминание о настроении", 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/sw.js b/public/sw.js index f3b5c226f..2fe77d88e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,7 +1,7 @@ /** * HealthLog service worker: Web Push, a page cache for public pages, and a - * static cache for catalogs and icons. API answers and app chunks are never - * stored here. + * static cache for catalogs, icons, fonts and images. API answers and app + * chunks are never stored here. */ // Cache names carry the release, so `activate` drops every older cache. The @@ -28,7 +28,7 @@ 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"; // While set, the fetch handler declines every request. Set when a page looks -// like another app, cleared when `confirmForeignOrigin` finds HealthLog. +// like another app, cleared unless the probe confirms another app. let foreignOrigin = false; // One confirmation at a time, shared by a burst of navigations. let foreignOriginCheck = null; @@ -68,6 +68,7 @@ self.addEventListener("activate", (event) => { self.registration.navigationPreload ? self.registration.navigationPreload.enable() : Promise.resolve(), + clearLeftoverAppBadge(), ]).then(() => self.clients.claim()), ); }); @@ -352,20 +353,13 @@ async function trimCache(cacheName, maxEntries) { } } -// The outstanding-dose count on the app icon, where the Badging API exists. -// 0 clears it; anything that is not a number leaves it alone. -function applyAppBadge(count) { - if (typeof count !== "number" || !isFinite(count)) return; +// Earlier releases set a dose count on the app icon; nothing sends one now, +// so clear what they left. Never fails the activation. +async function clearLeftoverAppBadge() { try { - if (count > 0) { - if (typeof self.navigator?.setAppBadge === "function") { - self.navigator.setAppBadge(count); - } - } else if (typeof self.navigator?.clearAppBadge === "function") { - self.navigator.clearAppBadge(); - } + await self.navigator?.clearAppBadge?.(); } catch { - // Never let the badge break the push handler. + // Optional. } } @@ -384,25 +378,21 @@ self.addEventListener("push", (event) => { body = "", tag = "default", url = "/", - badge, requireInteraction = false, } = payload; event.waitUntil( - Promise.all([ - self.registration.showNotification(title, { - body, - tag, - // A re-sent reminder with the same tag alerts again. - renotify: true, - // Set by the sender for urgent events, where the browser honours it. - requireInteraction: requireInteraction === true, - icon: "/logo-192.png", - badge: "/logo-192.png", - data: { url }, - }), - Promise.resolve(applyAppBadge(badge)), - ]), + self.registration.showNotification(title, { + body, + tag, + // A re-sent reminder with the same tag alerts again. + renotify: true, + // Set by the sender for urgent events, where the browser honours it. + requireInteraction: requireInteraction === true, + icon: "/logo-192.png", + badge: "/logo-192.png", + data: { url }, + }), ); }); diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index b1b409738..59c4605ad 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 84, - "total": 93856, + "total": 93840, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, @@ -82,7 +82,7 @@ "lib/search/search-records.ts": 996, "lib/sharing/grants.ts": 806, "lib/support/report.ts": 903, - "lib/telegram-webhook-handlers.ts": 1335, + "lib/telegram-webhook-handlers.ts": 1319, "lib/validations/inbound-documents.ts": 1018, "lib/withings/sync-sleep.ts": 884, "lib/withings/sync.ts": 933, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 833dc4e09..5c72ec225 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 337, - "total": 109501, + "count": 336, + "total": 109290, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -218,7 +218,6 @@ "components/settings/sharing-section.tsx :: ShareLinksCard": 260, "components/settings/sources-section.tsx :: SourcesSection": 601, "components/settings/thresholds-editor-section.tsx :: MetricRow": 241, - "components/settings/web-push-card.tsx :: WebPushCard": 203, "components/symptoms/symptom-entry-form.tsx :: SymptomEntryForm": 317, "components/symptoms/symptoms-section.tsx :: SymptomsSection": 205, "components/targets/range-bar.tsx :: RangeBar": 220, @@ -333,7 +332,7 @@ "lib/support/report.ts :: collectSupportReportFacts": 332, "lib/targets/build-response.ts :: buildTargetsResponse": 332, "lib/targets/vitals-builder.ts :: buildVitalTargets": 326, - "lib/telegram-webhook-handlers.ts :: handleCallback": 590, + "lib/telegram-webhook-handlers.ts :: handleCallback": 582, "lib/telegram-webhook-handlers.ts :: handleTextMessage": 322, "lib/withings/sync-activity.ts :: syncUserActivity": 377, "lib/withings/sync-ecg.ts :: syncUserEcg": 202, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index f4c5e40e0..6f553040c 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2877, - "total": 9165, + "count": 2875, + "total": 9162, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -2502,7 +2502,6 @@ "lib/medications/list-read.ts": 23, "lib/medications/med-target-map.ts": 1, "lib/medications/medication-order.ts": 1, - "lib/medications/outstanding-doses.ts": 2, "lib/medications/route-guards.ts": 1, "lib/medications/schedule-units-dto.ts": 1, "lib/medications/scheduled-doses.ts": 1, @@ -2582,7 +2581,6 @@ "lib/notifications/delegated-intake.ts": 2, "lib/notifications/dispatch-localised.ts": 3, "lib/notifications/dispatcher.ts": 3, - "lib/notifications/dose-tag.ts": 1, "lib/notifications/medication-intake-sync.ts": 1, "lib/notifications/senders/__tests__/apns.test.ts": 7, "lib/notifications/senders/__tests__/push-attempt-record.test.ts": 1, diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index a9a1bc80d..39bf621d0 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -348,7 +348,7 @@ const REGISTRY: DestructiveEntry[] = [ }, ], undetected: - "No detector sees it. The DELETE goes out from `unsubscribeWebPush` in `lib/notifications/web-push-unsubscribe.ts`, which sign-out calls too, and `lib/` is an excluded root. Registered by hand.", + "No detector sees it. The DELETE goes out from `lib/notifications/web-push-subscription.ts` (`unsubscribeWebPush`, and `signOutWithoutWebPush` for sign-out), and `lib/` is an excluded root. Registered by hand.", }, // ── Measurements, mood, labs, illness — the undo-and-restore surfaces ──── diff --git a/src/__tests__/medication-intake-tracking-guard.test.ts b/src/__tests__/medication-intake-tracking-guard.test.ts index cd58f79bd..f5718f2f5 100644 --- a/src/__tests__/medication-intake-tracking-guard.test.ts +++ b/src/__tests__/medication-intake-tracking-guard.test.ts @@ -245,7 +245,6 @@ describe("medication intake tracking — intake-row readers honour the switch", "lib/jobs/coach-nudge.ts", "lib/insights/general-status.ts", "lib/analytics/correlations-fast-path.ts", - "lib/medications/outstanding-doses.ts", ]) { expect(intakeReaders).toContain(known); } diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index a3290e9da..6fc4a074e 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -158,10 +158,9 @@ function bootServiceWorker(): SwHarness { navPreload.enabled = true; }, }; - // Push-handler fakes: the notifications `showNotification` shows, and a - // Badging-API spy. + // The notifications `showNotification` shows, and a Badging-API spy. const shown: Array<{ title: string; tag?: string; closed: boolean }> = []; - const badge = { value: undefined as number | undefined, cleared: false }; + const badge = { cleared: false }; const selfObj = { addEventListener: (type: string, fn: (event: unknown) => void) => { listeners.set(type, fn); @@ -182,12 +181,8 @@ function bootServiceWorker(): SwHarness { }, }, navigator: { - setAppBadge: (n: number) => { - badge.value = n; - }, - clearAppBadge: () => { + clearAppBadge: async () => { badge.cleared = true; - badge.value = undefined; }, }, location: { origin: ORIGIN }, @@ -486,6 +481,15 @@ describe("sw.js — activate", () => { ); expect(harness.navPreload.enabled).toBe(true); }); + + it("clears the dose count an earlier release left on the app icon", async () => { + const harness = bootServiceWorker(); + const { badge } = swFakes(harness); + + await dispatchActivate(harness); + + expect(badge.cleared).toBe(true); + }); }); describe("sw.js — authenticated install cache isolation", () => { @@ -1094,27 +1098,25 @@ async function dispatchPush(harness: SwHarness, data: unknown): Promise { function swFakes(harness: SwHarness) { const selfObj = harness.context.self as unknown as { __shown: Array<{ title: string; tag?: string; closed: boolean }>; - __badge: { value: number | undefined; cleared: boolean }; + __badge: { cleared: boolean }; }; return { shown: selfObj.__shown, badge: selfObj.__badge }; } describe("sw.js — push handler", () => { - it("shows a reminder with its stable tag and sets the app badge", async () => { + it("shows a reminder with its stable tag", async () => { const harness = bootServiceWorker(); - const { shown, badge } = swFakes(harness); + const { shown } = swFakes(harness); const tag = "med:med-1:2026-06-18T07:00:00.000Z"; await dispatchPush(harness, { title: "Time for your dose", body: "Ramipril 5mg", tag, - badge: 3, }); expect(shown).toHaveLength(1); expect(shown[0].tag).toBe(tag); - expect(badge.value).toBe(3); }); }); diff --git a/src/app/api/auth/register/__tests__/route.test.ts b/src/app/api/auth/register/__tests__/route.test.ts index 3b435bf4b..b42a10889 100644 --- a/src/app/api/auth/register/__tests__/route.test.ts +++ b/src/app/api/auth/register/__tests__/route.test.ts @@ -226,8 +226,13 @@ describe("POST /api/auth/register — first-admin atomicity (M1)", () => { const res = await postPathThrough(validBody); expect(res.status).toBe(409); - const body = (await res.json()) as { error: string }; + const body = (await res.json()) as { + error: string; + meta?: { errorCode?: string }; + }; expect(body.error).toBe("Username or email already taken"); + // The code the registration page turns into "already in use". + expect(body.meta?.errorCode).toBe("auth.register.taken"); }); }); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index a50be77c2..0cf6a7547 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -24,6 +24,10 @@ import { } from "@/lib/tz/resolver"; import { isOidcOnly } from "@/lib/auth/oidc"; +// One code for a taken username and a taken address, like the one message. +const TAKEN = { errorCode: "auth.register.taken" }; +const INVITE_INVALID = { errorCode: "auth.register.invite_invalid" }; + export const POST = apiHandler(async (request: NextRequest) => { // OIDC_ONLY must block self-registration too — otherwise anyone can // self-provision a fresh password account and bypass the operator's @@ -144,7 +148,7 @@ export const POST = apiHandler(async (request: NextRequest) => { prisma.user.findUnique({ where: { username } }), ]); if (existingEmail || existingUsername) { - return apiError("Username or email already taken", 409); + return apiError("Username or email already taken", 409, TAKEN); } // Validate password strength (locale-aware feedback) @@ -196,7 +200,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); if (!registrationEnabled) { - return apiError("Invalid or expired invite", 403); + return apiError("Invalid or expired invite", 403, INVITE_INVALID); } } } @@ -249,7 +253,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if (user === null) { - return apiError("Username or email already taken", 409); + return apiError("Username or email already taken", 409, TAKEN); } // Create session immediately. v1.4.22 W5 reconcile (Sr-H1) — diff --git a/src/app/api/devices/__tests__/route.test.ts b/src/app/api/devices/__tests__/route.test.ts index a06d413e0..220b605c9 100644 --- a/src/app/api/devices/__tests__/route.test.ts +++ b/src/app/api/devices/__tests__/route.test.ts @@ -17,6 +17,7 @@ vi.mock("@/lib/db", () => ({ $transaction: vi.fn(), notificationChannel: { upsert: vi.fn(), + updateMany: vi.fn(), }, auditLog: { create: vi.fn(), @@ -89,6 +90,9 @@ beforeEach(() => { vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({ id: "ch-1", } as never); + vi.mocked(prisma.notificationChannel.updateMany).mockResolvedValue({ + count: 0, + }); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); // `resetAllMocks` strips the top-level `.mockResolvedValue(...)` // declaration too, so re-arm the rate-limit success path here. @@ -217,6 +221,16 @@ describe("POST /api/devices", () => { }; expect(args.data.apnsToken).toBe("deadbeef".repeat(8)); expect(args.data.apnsEnvironment).toBe("sandbox"); + // A reminder with no iPhone paired rests the APNs channel for an hour; + // pairing one ends that rest, so the next reminder does not wait it out. + expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({ + where: { + userId: "user-1", + type: "APNS", + lastFailureReason: "apns_no_devices", + }, + data: { nextRetryAt: null, lastFailureReason: null }, + }); }); it("rejects re-registration of an apnsToken owned by another user with 409", async () => { diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index 36f24678b..30d21330e 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -31,6 +31,7 @@ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; import { checkRateLimit } from "@/lib/rate-limit"; +import { endApnsNoDeviceRest } from "@/lib/notifications/channel-state"; const deviceSchema = z .object({ @@ -86,15 +87,14 @@ const deviceSchema = z export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - // Per-user rate cap on device registration. The handler returns a - // distinct 409 (`apns_token_owned_by_other_user` / - // `token_owned_by_other_user`) when an `apnsToken` is already - // bound to a different account — useful for the legitimate - // cross-user-hijack guard, but it also means an authenticated user - // could enumerate token ownership across accounts unboundedly. 20 - // device-mutations per 15 minutes is well outside any realistic - // client behaviour (the iOS app registers once on login + on token - // rotation; not 20× / window) while capping enumeration cost. + // Per-user rate cap on device registration. The handler returns a distinct + // 409 (`apns_token_owned_by_other_user` / `token_owned_by_other_user`) when + // an `apnsToken` is already bound to a different account — useful for the + // legitimate cross-user-hijack guard, but it also means an authenticated + // user could enumerate token ownership across accounts unboundedly. 20 + // device-mutations per 15 minutes is well outside any realistic client + // behaviour (the iOS app registers once on login + on token rotation; not + // 20× / window) while capping enumeration cost. const rl = await checkRateLimit( `devices:register:${user.id}`, 20, @@ -272,6 +272,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }, update: {}, }); + await endApnsNoDeviceRest(tx, user.id); } return { id: device.id }; diff --git a/src/app/api/notifications/web-push/__tests__/route.test.ts b/src/app/api/notifications/web-push/__tests__/route.test.ts index c93f702f4..65681cf16 100644 --- a/src/app/api/notifications/web-push/__tests__/route.test.ts +++ b/src/app/api/notifications/web-push/__tests__/route.test.ts @@ -214,6 +214,37 @@ describe("POST /api/notifications/web-push — the channel it delivers through", expect(prisma.notificationChannel.create).not.toHaveBeenCalled(); }); + it("clears failures and a cooldown left on an enabled channel", async () => { + // Four failed sends before the new subscription: one more transient + // failure would have switched the channel off, and the cooldown skipped + // every reminder until it ran out. + const strained = channelRow({ + consecutiveFailures: 4, + nextRetryAt: new Date(Date.now() + 2 * 3_600_000), + lastFailureReason: "web_push_no_subscriptions", + }); + vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue( + STORED_SUBSCRIPTION, + ); + vi.mocked(prisma.notificationChannel.findFirst).mockResolvedValue(strained); + vi.mocked(prisma.notificationChannel.update).mockResolvedValue( + channelRow({}), + ); + + const res = await (POST as unknown as Handler)(req(GOOD)); + + expect(res.status).toBe(200); + expect(prisma.notificationChannel.update).toHaveBeenCalledWith({ + where: { id: "ch-1" }, + data: expect.objectContaining({ + enabled: true, + consecutiveFailures: 0, + nextRetryAt: null, + lastFailureReason: null, + }), + }); + }); + it("leaves an enabled channel as it is", async () => { vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue( STORED_SUBSCRIPTION, @@ -230,6 +261,26 @@ describe("POST /api/notifications/web-push — the channel it delivers through", }); }); +describe("POST /api/notifications/web-push — one account per browser", () => { + it("removes another account's row for the same browser", async () => { + // A session that ended without a sign-out left the previous account's + // row on this endpoint; its reminders kept arriving for the next person. + vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue( + STORED_SUBSCRIPTION, + ); + vi.mocked(prisma.notificationChannel.findFirst).mockResolvedValue( + channelRow({}), + ); + + const res = await (POST as unknown as Handler)(req(GOOD)); + + expect(res.status).toBe(200); + expect(prisma.pushSubscription.deleteMany).toHaveBeenCalledWith({ + where: { endpoint: GOOD.endpoint, userId: { not: USER.id } }, + }); + }); +}); + describe("DELETE /api/notifications/web-push — the unsubscribe refusal", () => { it("names the offending field rather than answering flat", async () => { const res = await (DELETE as unknown as Handler)( diff --git a/src/app/api/notifications/web-push/route.ts b/src/app/api/notifications/web-push/route.ts index 7b25c92f2..f8884f9e0 100644 --- a/src/app/api/notifications/web-push/route.ts +++ b/src/app/api/notifications/web-push/route.ts @@ -98,9 +98,18 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); - // Ensure an enabled WEB_PUSH channel. A channel switched off because the - // last browser's endpoint died (410) or failed five times stays off until - // something turns it back on, and a new subscription is exactly that. + // An endpoint is one browser, and the account that subscribed it last owns + // it. A row another account left (a session that ended without a sign-out) + // would keep showing that account's reminders to whoever uses it now. + await prisma.pushSubscription.deleteMany({ + where: { endpoint, userId: { not: user.id } }, + }); + + // Ensure an enabled WEB_PUSH channel that is not waiting out a cooldown. A + // channel switched off because the last browser's endpoint died (410) or + // failed five times stays off until something turns it back on, and a new + // subscription is exactly that. Failures counted before it would switch it + // off again after a single later one. const existingChannel = await prisma.notificationChannel.findFirst({ where: { userId: user.id, type: "WEB_PUSH" }, }); @@ -114,7 +123,11 @@ export const POST = apiHandler(async (request: NextRequest) => { config: encrypt(JSON.stringify({})), }, }); - } else if (!existingChannel.enabled) { + } else if ( + !existingChannel.enabled || + existingChannel.consecutiveFailures > 0 || + existingChannel.nextRetryAt !== null + ) { await reEnableChannel({ id: existingChannel.id, userId: existingChannel.userId, diff --git a/src/app/api/notifications/web-push/test/__tests__/route.test.ts b/src/app/api/notifications/web-push/test/__tests__/route.test.ts index 980830013..f53d370af 100644 --- a/src/app/api/notifications/web-push/test/__tests__/route.test.ts +++ b/src/app/api/notifications/web-push/test/__tests__/route.test.ts @@ -134,7 +134,7 @@ describe("POST /api/notifications/web-push/test", () => { } expect(payloadsSent()).toEqual([ expect.objectContaining({ - title: "HealthLog Test-Benachrichtigung", + title: "Test-Benachrichtigung von HealthLog", body: "Wenn du das siehst, kommen Push-Benachrichtigungen auf diesem Gerät an.", tag: "self-test", }), @@ -181,6 +181,21 @@ describe("POST /api/notifications/web-push/test", () => { expect(res.status).toBe(200); expect(JSON.parse(text).data.ok).toBe(false); + // What the Test button reads to say why. + expect(JSON.parse(text).meta.errorCode).toBe("upstream_rejected"); expect(text).not.toMatch(/phone-secret|laptop-secret/); }); + + it("tells a push service that never answered apart from one that refused", async () => { + mocks.safeFetch.mockRejectedValue(new TypeError("fetch failed")); + + const res = await post(); + const body = (await res.json()) as { + data: { ok: boolean }; + meta?: { errorCode?: string }; + }; + + expect(body.data.ok).toBe(false); + expect(body.meta?.errorCode).toBe("connection_failed"); + }); }); diff --git a/src/app/api/notifications/web-push/test/route.ts b/src/app/api/notifications/web-push/test/route.ts index 54e45d09f..0ae423903 100644 --- a/src/app/api/notifications/web-push/test/route.ts +++ b/src/app/api/notifications/web-push/test/route.ts @@ -1,6 +1,6 @@ import type { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError } from "@/lib/api-response"; +import { apiSuccess, apiSuccessWithMeta, apiError } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { coerceLocale } from "@/lib/i18n/config"; @@ -34,7 +34,7 @@ export const POST = apiHandler(async (request: NextRequest) => { const result = await sendViaWebPush(user.id, { eventType: "SYSTEM_ALERT", userId: user.id, - title: t("notifications.admin.testNotificationTitle"), + title: t("notifications.user.webPushTestTitle"), message: t("notifications.user.webPushTestBody"), metadata: { webPushTag: "self-test" }, }); @@ -54,7 +54,15 @@ export const POST = apiHandler(async (request: NextRequest) => { annotate({ meta: { web_push_test_ok: result.ok, web_push_test_reason: result.reason }, }); - return apiSuccess( - result.ok ? { ok: true, latencyMs } : { ok: false, reason: result.reason }, + if (result.ok) return apiSuccess({ ok: true, latencyMs }); + // The Test button says what went wrong from `meta.errorCode`, like every + // other channel's test; `reason` is the sender's own code. + return apiSuccessWithMeta( + { ok: false, reason: result.reason }, + { + errorCode: result.reason?.endsWith("_network_error") + ? "connection_failed" + : "upstream_rejected", + }, ); }); diff --git a/src/app/auth/login/__tests__/page.browser.test.tsx b/src/app/auth/login/__tests__/page.browser.test.tsx index c4b163fde..234cbcf7d 100644 --- a/src/app/auth/login/__tests__/page.browser.test.tsx +++ b/src/app/auth/login/__tests__/page.browser.test.tsx @@ -63,6 +63,21 @@ describe("sign-in page, single sign-on", () => { }); }); +describe("sign-in page, single sign-on with a stuck cache", () => { + it("still leaves for the identity provider when the wipe never settles", async () => { + // WebKit can leave an IndexedDB open hanging after the app returns from + // the background; the button did nothing then. + clearOffline.mockImplementationOnce(() => new Promise(() => {})); + const { screen } = await renderWithApp(, { user: null }); + await screen.getByRole("button", { name: "Example ID" }).click(); + + await expect + .poll(() => loadDocument.mock.calls.length, { timeout: 4000 }) + .toBe(1); + expect(clearOffline).toHaveBeenCalledTimes(1); + }); +}); + describe("sign-in page, password", () => { it("replaces itself with the page asked for", async () => { api.on("POST", "/api/auth/login", { data: { id: "signed-in" } }); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index e07989fdc..579209b0d 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -24,8 +24,9 @@ import { OIDC_MFA_COOKIE, OIDC_MFA_COOKIE_PATH } from "@/lib/auth/oidc-cookie"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; +import { clearOfflineCachesBeforeLeaving } from "@/lib/pwa/clear-before-leaving"; import { sanitizeSameOriginPath } from "@/lib/url-safety"; -import { localizedApiError } from "@/lib/api/localized-error"; +import { localizedSignInError } from "@/lib/api/localized-error"; import { formatDate } from "@/lib/format"; import { InsecureTransportBanner, @@ -184,7 +185,7 @@ export default function LoginPage() { params.set("client_host", clientTransport.host); } // The session starts on the way back; the last account's copies go first. - await clearOfflineCachesForSessionEnd(); + await clearOfflineCachesBeforeLeaving(); loadDocument(`/api/auth/oidc/login?${params}`); } @@ -263,10 +264,10 @@ export default function LoginPage() { navigateAfterLogin(); } catch (err) { if (noteRefusal(err)) return; - // Route rejections carry the envelope error verbatim; everything - // else (WebAuthn ceremony failures) keeps the descriptive mapping. + // A route's refusal is named by its code; a WebAuthn ceremony failure + // keeps the descriptive mapping. if (err instanceof ApiError) { - setError(localizedApiError(err, t, "auth.loginFailed")); + setError(localizedSignInError(err, t, "auth.loginFailed")); } else { const { key, params } = describePasskeyError(err); setError(t(key, params)); @@ -310,7 +311,7 @@ export default function LoginPage() { navigateAfterLogin(); } catch (err) { if (noteRefusal(err)) return; - setError(localizedApiError(err, t, "auth.loginFailed")); + setError(localizedSignInError(err, t, "auth.loginFailed")); } finally { setLoading(false); } diff --git a/src/app/auth/register/__tests__/page.browser.test.tsx b/src/app/auth/register/__tests__/page.browser.test.tsx index 4a66bbd99..749457462 100644 --- a/src/app/auth/register/__tests__/page.browser.test.tsx +++ b/src/app/auth/register/__tests__/page.browser.test.tsx @@ -6,7 +6,7 @@ * existed belongs to it. The app replaces the page in the history, so Back * does not show the form again. */ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { page } from "vitest/browser"; import { queryKeys } from "@/lib/query-keys"; @@ -48,6 +48,45 @@ describe("registration page", () => { }); describe("registration page, already signed in", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("takes this browser's push subscription with it on sign-out", async () => { + // The button exists so someone else can register on this browser; a + // subscription left behind would show them the last account's reminders. + const unsubscribe = vi.fn(async () => true); + vi.spyOn(navigator.serviceWorker, "getRegistration").mockResolvedValue({ + pushManager: { + getSubscription: async () => ({ + endpoint: "https://push.example.test/send/abc", + unsubscribe, + }), + }, + } as unknown as ServiceWorkerRegistration); + const api = stubFetch() + .on("DELETE", "/api/notifications/web-push", { data: {} }) + .on("POST", "/api/auth/logout", { data: { loggedOut: true } }); + window.history.replaceState(null, "", "/auth/register?invite=hlv_x"); + const { screen } = await renderWithApp(, { + user: { username: "signed-in" }, + }); + + await screen.getByTestId("register-sign-out").click(); + + await expect.poll(() => unsubscribe.mock.calls.length).toBe(1); + const order = api.requests + .map((r) => `${r.method} ${r.path.split("?")[0]}`) + .filter((call) => !call.startsWith("GET ")); + expect(order).toEqual([ + "DELETE /api/notifications/web-push", + "POST /api/auth/logout", + ]); + expect(api.calls("DELETE", "/api/notifications/web-push")[0].body).toEqual({ + endpoint: "https://push.example.test/send/abc", + }); + }); + it("keeps the page and says so when the server refuses the sign-out", async () => { const api = stubFetch().on("POST", "/api/auth/logout", { status: 403, @@ -86,14 +125,39 @@ describe("registration refusals", () => { return screen; } - it("says the registration failed instead of the server's words", async () => { + it("says what to change when the name or address is taken", async () => { const screen = await register({ status: 409, error: "Username or email already taken", - meta: { errorCode: "http.conflict" }, + meta: { errorCode: "auth.register.taken" }, + }); + await expect + .element( + screen.getByText("That username or email address is already in use."), + ) + .toBeVisible(); + expect(document.body.textContent).not.toContain("already taken"); + }); + + it("tells an invitee their link no longer works", async () => { + const screen = await register({ + status: 403, + error: "Invalid or expired invite", + meta: { errorCode: "auth.register.invite_invalid" }, + }); + await expect + .element(screen.getByText(/This invitation is no longer valid/)) + .toBeVisible(); + }); + + it("says the registration failed for a refusal it has no words for", async () => { + const screen = await register({ + status: 500, + error: "Internal error", + meta: { errorCode: "http.internal" }, }); await expect.element(screen.getByText("Registration failed")).toBeVisible(); - expect(document.body.textContent).not.toContain("already taken"); + expect(document.body.textContent).not.toContain("Internal error"); }); it("shows the password feedback, which the server words for the reader", async () => { diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index 8832b0f50..99b4a9b98 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -12,9 +12,13 @@ import { Label } from "@/components/ui/label"; import { PasswordStrength } from "@/components/ui/password-strength"; import { useTranslations } from "@/lib/i18n/context"; import { detectBrowserTimezone } from "@/lib/tz/format"; -import { ApiError, apiFetch, apiPost } from "@/lib/api/api-fetch"; +import { ApiError, apiPost } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; -import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth"; +import { + clearCachesForSessionEnd, + signOutRequest, + useAuth, +} from "@/hooks/use-auth"; export default function RegisterPage() { const router = useRouter(); @@ -89,7 +93,7 @@ export default function RegisterPage() { setError(null); setSigningOut(true); try { - await apiFetch("/api/auth/logout", { method: "POST" }); + await signOutRequest(); // Same session-end wipe the shell and the menu do: the QueryClient // outlives the navigation on this SPA and the health-data families are // not user-scoped, so nothing may survive into the next account. diff --git a/src/components/auth/__tests__/claim-profile.browser.test.tsx b/src/components/auth/__tests__/claim-profile.browser.test.tsx index b4a80a369..d4a0f7080 100644 --- a/src/components/auth/__tests__/claim-profile.browser.test.tsx +++ b/src/components/auth/__tests__/claim-profile.browser.test.tsx @@ -117,6 +117,42 @@ describe("ClaimProfile", () => { }); describe("ClaimProfile, already signed in", () => { + it("takes this browser's push subscription with it on sign-out", async () => { + // The button exists so the profile's person can take over on this + // browser; a subscription left behind would show them the last + // account's reminders. + const unsubscribe = vi.fn(async () => true); + const registration = vi + .spyOn(navigator.serviceWorker, "getRegistration") + .mockResolvedValue({ + pushManager: { + getSubscription: async () => ({ + endpoint: "https://push.example.test/send/abc", + unsubscribe, + }), + }, + } as unknown as ServiceWorkerRegistration); + api + .on("DELETE", "/api/notifications/web-push", { data: {} }) + .on("POST", "/api/auth/logout", { data: { loggedOut: true } }); + await renderWithApp(, { + user: { username: "signed-in" }, + }); + + await click("claim-sign-out"); + + await expect.poll(() => unsubscribe.mock.calls.length).toBe(1); + const order = api.requests + .map((r) => `${r.method} ${r.path.split("?")[0]}`) + .filter((call) => !call.startsWith("GET ")); + // The signed-out page then loads the link's preview. + expect(order.slice(0, 2)).toEqual([ + "DELETE /api/notifications/web-push", + "POST /api/auth/logout", + ]); + registration.mockRestore(); + }); + it("keeps the session's page and says so when the server refuses the sign-out", async () => { api.on("POST", "/api/auth/logout", { status: 403, diff --git a/src/components/auth/claim-profile.tsx b/src/components/auth/claim-profile.tsx index 54120d064..360d5a1db 100644 --- a/src/components/auth/claim-profile.tsx +++ b/src/components/auth/claim-profile.tsx @@ -13,12 +13,16 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Logo } from "@/components/ui/logo"; import { PasswordStrength } from "@/components/ui/password-strength"; -import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth"; -import { ApiError, apiFetch } from "@/lib/api/api-fetch"; +import { + clearCachesForSessionEnd, + signOutRequest, + useAuth, +} from "@/hooks/use-auth"; +import { ApiError } from "@/lib/api/api-fetch"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { loadDocument } from "@/lib/navigation/load-document"; -import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; +import { clearOfflineCachesBeforeLeaving } from "@/lib/pwa/clear-before-leaving"; import { useClaimProfile, useClaimProfileSso, @@ -79,7 +83,7 @@ export function ClaimProfile({ async function signOut() { setSigningOut(true); try { - await apiFetch("/api/auth/logout", { method: "POST" }); + await signOutRequest(); clearCachesForSessionEnd(queryClient); // Back to this page with the link intact: the token only rides the URL. router.replace( @@ -108,7 +112,7 @@ export function ClaimProfile({ next.searchParams.set("client_host", clientTransport.host); } // The session starts on the way back; the last account's copies go first. - await clearOfflineCachesForSessionEnd(); + await clearOfflineCachesBeforeLeaving(); loadDocument(`${next.pathname}${next.search}`); }, }, @@ -225,7 +229,7 @@ export function ClaimProfile({ title={t("auth.claim.title")} description={t( previewStatus === 429 - ? "auth.claim.errorRateLimit" + ? "auth.signInRateLimited" : "auth.claim.errorFailed", )} /> @@ -544,6 +548,6 @@ export function claimErrorKey(err: unknown): string { : "auth.claim.errorTaken"; } if (err.status === 403) return "auth.claim.oidcOnly"; - if (err.status === 429) return "auth.claim.errorRateLimit"; + if (err.status === 429) return "auth.signInRateLimited"; return "auth.claim.errorFailed"; } diff --git a/src/components/auth/mfa-login-step.tsx b/src/components/auth/mfa-login-step.tsx index bbaa82bb8..ef63de07b 100644 --- a/src/components/auth/mfa-login-step.tsx +++ b/src/components/auth/mfa-login-step.tsx @@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useTranslations } from "@/lib/i18n/context"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; -import { localizedApiError } from "@/lib/api/localized-error"; +import { localizedSignInError } from "@/lib/api/localized-error"; import { currentClientTransport } from "@/lib/auth/client-transport"; /** The server's refusal for a page whose sign-in cookie cannot stick. */ @@ -74,7 +74,7 @@ export function MfaLoginStep({ err instanceof ApiError && err.meta?.errorCode === INSECURE_TRANSPORT_CODE ? t("auth.insecureTransport.title") - : localizedApiError(err, t, "auth.mfa.verifyFailed"), + : localizedSignInError(err, t, "auth.mfa.verifyFailed"), ); } finally { setLoading(false); @@ -101,7 +101,7 @@ export function MfaLoginStep({ onSuccess(); } catch (err) { if (err instanceof ApiError) { - setError(localizedApiError(err, t, "auth.mfa.verifyFailed")); + setError(localizedSignInError(err, t, "auth.mfa.verifyFailed")); } else { const { key, params } = describePasskeyError(err); setError(t(key, params)); diff --git a/src/components/layout/__tests__/auth-shell-scroll.browser.test.tsx b/src/components/layout/__tests__/auth-shell-scroll.browser.test.tsx index ec8a64d4b..70760cefa 100644 --- a/src/components/layout/__tests__/auth-shell-scroll.browser.test.tsx +++ b/src/components/layout/__tests__/auth-shell-scroll.browser.test.tsx @@ -113,4 +113,34 @@ describe("AuthShell scroll position", () => { .toBeTruthy(); await expect.poll(() => main().scrollTop).toBe(1500); }); + + it("stops pulling back once the person scrolls on a list that is still loading", async () => { + stubFetch(); + window.history.replaceState(null, "", "/labs"); + await renderWithApp( + + + , + { user: { id: "signed-in" } }, + ); + await expect + .poll(() => document.querySelector("[data-loaded]")) + .toBeTruthy(); + main().scrollTop = 1500; + await nextFrames(); + await openPage("/labs/biomarker-1"); + + window.history.back(); + await expect + .poll(() => document.querySelector('[data-path="/labs"]')) + .toBeTruthy(); + // While the list is still its short skeleton, the person scrolls. + window.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 })); + await expect + .poll(() => document.querySelector("[data-loaded]")) + .toBeTruthy(); + await nextFrames(); + + expect(main().scrollTop).toBe(0); + }); }); diff --git a/src/components/layout/__tests__/auth-shell-session-end.browser.test.tsx b/src/components/layout/__tests__/auth-shell-session-end.browser.test.tsx index 5a5e5ebd0..84e1ec086 100644 --- a/src/components/layout/__tests__/auth-shell-session-end.browser.test.tsx +++ b/src/components/layout/__tests__/auth-shell-session-end.browser.test.tsx @@ -72,6 +72,13 @@ function shellTree(queryClient: QueryClient) { ); } +/** HealthLog's answer once there is no session; it always names a code. */ +const SIGNED_OUT = { + status: 401, + error: "Unauthorized", + meta: { errorCode: "auth.missing" }, +}; + async function mountShell(user: Parameters[0] | null) { return renderWithApp( @@ -91,10 +98,7 @@ afterEach(() => { describe("AuthShell on a sign-in page", () => { it("clears the offline copies once the server says there is no session", async () => { - const fetchStub = stubFetch().on("GET", ME, { - status: 401, - error: "Unauthorized", - }); + const fetchStub = stubFetch().on("GET", ME, SIGNED_OUT); openAt("/auth/login"); const { queryClient } = await mountShell(null); @@ -108,7 +112,7 @@ describe("AuthShell on a sign-in page", () => { }); it("does the same on the profile take-over page", async () => { - stubFetch().on("GET", ME, { status: 401, error: "Unauthorized" }); + stubFetch().on("GET", ME, SIGNED_OUT); openAt("/auth/claim"); await mountShell(null); await expect.poll(() => clearOffline.mock.calls.length).toBe(1); @@ -139,8 +143,22 @@ describe("AuthShell on a sign-in page", () => { expect(clearOffline).not.toHaveBeenCalled(); }); + it("leaves them alone for a 401 from something in front of the server", async () => { + // A forward-auth proxy or a firewall answers with its own page, which + // names no HealthLog code and says nothing about this session. + const fetchStub = stubFetch().on("GET", ME, { + status: 401, + error: "Unauthorized", + }); + openAt("/auth/login"); + await mountShell(null); + await expect.poll(() => fetchStub.calls("GET", ME).length).toBe(1); + await settle(); + expect(clearOffline).not.toHaveBeenCalled(); + }); + it("does not clear on a public page outside the sign-in flow", async () => { - stubFetch().on("GET", ME, { status: 401, error: "Unauthorized" }); + stubFetch().on("GET", ME, SIGNED_OUT); openAt("/about"); await mountShell(null); await settle(); @@ -153,9 +171,7 @@ describe("AuthShell on a protected page", () => { async function signedInAt(path: string) { let session = true; stubFetch().on("GET", ME, () => - session - ? { data: authUser({ id: "signed-in" }) } - : { status: 401, error: "Unauthorized" }, + session ? { data: authUser({ id: "signed-in" }) } : SIGNED_OUT, ); openAt(path); const view = await mountShell(null); diff --git a/src/components/layout/__tests__/auth-shell-status-bar.browser.test.tsx b/src/components/layout/__tests__/auth-shell-status-bar.browser.test.tsx new file mode 100644 index 000000000..caf53e592 --- /dev/null +++ b/src/components/layout/__tests__/auth-shell-status-bar.browser.test.tsx @@ -0,0 +1,46 @@ +/** + * The strip behind the status bar on the sign-in pages. + * + * The installed iOS app draws the clock in white over whatever the page + * paints there. The signed-in shell paints the strip; the sign-in pages only + * padded it, so after a sign-out in the light theme the clock was white on + * near-white. + */ +import { afterEach, describe, expect, it } from "vitest"; + +import { renderWithApp } from "../../../../tests/browser/render"; +import { stubFetch } from "../../../../tests/browser/fetch-stub"; + +import { AuthShell } from "../auth-shell"; + +afterEach(() => { + document.documentElement.classList.remove("light"); + document.documentElement.classList.add("dark"); +}); + +describe("AuthShell status bar strip", () => { + it("is painted on the sign-in page in the light theme", async () => { + document.documentElement.classList.remove("dark"); + document.documentElement.classList.add("light"); + stubFetch().on("GET", "/api/auth/me", { + status: 401, + error: "Unauthorized", + meta: { errorCode: "auth.missing" }, + }); + window.history.replaceState(null, "", "/auth/login"); + await renderWithApp( + +
+ , + { user: null }, + ); + await expect + .poll(() => document.querySelector('[data-slot="page"]')) + .toBeTruthy(); + + const wrapper = document.getElementById("main-content")!.parentElement!; + expect(getComputedStyle(wrapper).backgroundImage).toContain( + "linear-gradient", + ); + }); +}); diff --git a/src/components/layout/__tests__/offline-banner.test.tsx b/src/components/layout/__tests__/offline-banner.test.tsx index cf7e225ac..8d90b48e8 100644 --- a/src/components/layout/__tests__/offline-banner.test.tsx +++ b/src/components/layout/__tests__/offline-banner.test.tsx @@ -46,17 +46,18 @@ describe("", () => { it("ships the EN copy for `offlineBanner.message`", () => { // The copy must not over-promise auto-save: offline mutations now // fail fast (networkMode "always") rather than queue, so the banner states - // the honest thing. + // the honest thing. "Once you're back online" is said by the sentence + // after it, once. const en = loadMessages("en"); expect(en.offlineBanner.message).toBe( - "No connection: changes can't be saved until you're back online.", + "No connection: changes can't be saved.", ); }); it("ships the DE copy for `offlineBanner.message`", () => { const de = loadMessages("de"); expect(de.offlineBanner.message).toBe( - "Keine Verbindung: Änderungen können erst gespeichert werden, wenn du wieder online bist.", + "Keine Verbindung: Änderungen können nicht gespeichert werden.", ); }); diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index a53ea8b43..10a355491 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -129,17 +129,24 @@ function useShellScrollMemory(pathname: string): void { } if (!main) return; // A list still painting its skeleton is shorter than the offset; keep - // trying until it has the height, for at most a second. - const until = performance.now() + 1000; + // trying until it has the height, for at most three seconds, and stop as + // soon as the person scrolls themselves. + const until = performance.now() + 3000; let frame = 0; + const stop = () => cancelAnimationFrame(frame); + const inputs = ["wheel", "touchstart", "keydown", "pointerdown"]; const apply = () => { main.scrollTop = target.top; if (main.scrollTop < target.top - 1 && performance.now() < until) { frame = requestAnimationFrame(apply); } }; + for (const type of inputs) window.addEventListener(type, stop, true); apply(); - return () => cancelAnimationFrame(frame); + return () => { + stop(); + for (const type of inputs) window.removeEventListener(type, stop, true); + }; }, [pathname]); } @@ -337,10 +344,10 @@ export function AuthShell({ // `min-h-dvh`, not `h-dvh`: a phone held sideways is ~390 px tall, and // a sign-in form that is taller than a fixed-height box centres out of // it at the top, where nothing can scroll back to it. A minimum lets the - // box grow and the document scroll. The safe-area padding keeps the form - // off the status bar and the home indicator in an installed app. + // box grow and the document scroll. The safe-area band (painted behind + // the status bar, see globals.css) and padding keep the form clear. return ( -
+
{demoMode ? : null}
) : null} -
+
{children} diff --git a/src/components/settings/__tests__/test-connection-button-response.test.tsx b/src/components/settings/__tests__/test-connection-button-response.test.tsx index 97ab62800..5c97ee3b2 100644 --- a/src/components/settings/__tests__/test-connection-button-response.test.tsx +++ b/src/components/settings/__tests__/test-connection-button-response.test.tsx @@ -101,6 +101,24 @@ describe("runConnectionTest", () => { }); }); + it("reads why a push test that ran was refused", async () => { + // The Web Push test answers 200 with `ok: false`; the code rides `meta`. + apiFetchRawMock.mockResolvedValue( + envelope(200, { + data: { ok: false, reason: "web_push_403" }, + error: null, + meta: { errorCode: "upstream_rejected" }, + }), + ); + + const result = await runConnectionTest("/api/notifications/web-push/test"); + + expect(result).toMatchObject({ + kind: "error", + errorCode: "upstream_rejected", + }); + }); + it("reports success with the latency", async () => { apiFetchRawMock.mockResolvedValue( envelope(200, { data: { ok: true, latencyMs: 42 }, error: null }), diff --git a/src/components/settings/__tests__/web-push-card.browser.test.tsx b/src/components/settings/__tests__/web-push-card.browser.test.tsx new file mode 100644 index 000000000..ca7ccb331 --- /dev/null +++ b/src/components/settings/__tests__/web-push-card.browser.test.tsx @@ -0,0 +1,64 @@ +/** + * The Web Push card with a subscription the browser already holds. + * + * The card reads its state from the browser alone. When the server row was + * lost, or the channel had been switched off by failed sends, the card said + * "enabled" and no reminder came; opening it now sends the subscription + * again, which stores the row and turns the channel back on. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { stubFetch } from "../../../../tests/browser/fetch-stub"; +import { renderWithApp } from "../../../../tests/browser/render"; + +import { WebPushCard } from "../web-push-card"; + +const SUBSCRIPTION = { + endpoint: "https://push.example.test/send/abc", + keys: { p256dh: "p256dh-key", auth: "auth-key" }, +}; + +function holdSubscription(): void { + const subscription = { toJSON: () => SUBSCRIPTION }; + // Headless Chromium answers "denied" without a prompt. + vi.spyOn(Notification, "permission", "get").mockReturnValue("granted"); + vi.spyOn(navigator.serviceWorker, "getRegistration").mockResolvedValue({ + pushManager: { getSubscription: async () => subscription }, + } as unknown as ServiceWorkerRegistration); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("WebPushCard", () => { + it("sends the browser's existing subscription to the server when it opens", async () => { + holdSubscription(); + const api = stubFetch().on("POST", "/api/notifications/web-push", { + data: { subscribed: true }, + }); + + const { screen } = await renderWithApp(); + + await expect + .poll(() => api.calls("POST", "/api/notifications/web-push").length) + .toBe(1); + expect(api.calls("POST", "/api/notifications/web-push")[0].body).toEqual( + SUBSCRIPTION, + ); + await expect.element(screen.getByText("Disable push")).toBeVisible(); + }); + + it("keeps showing push as on when that request fails", async () => { + holdSubscription(); + stubFetch().on("POST", "/api/notifications/web-push", { + status: 403, + error: "Demo mode: modifications are disabled", + }); + + const { screen } = await renderWithApp(); + + await expect.element(screen.getByText("Disable push")).toBeVisible(); + expect(document.querySelector('[role="alert"]')).toBeNull(); + }); +}); diff --git a/src/components/settings/access/__tests__/managed-profile-handover-errors.test.ts b/src/components/settings/access/__tests__/managed-profile-handover-errors.test.ts index b4e4b479a..3fa89e080 100644 --- a/src/components/settings/access/__tests__/managed-profile-handover-errors.test.ts +++ b/src/components/settings/access/__tests__/managed-profile-handover-errors.test.ts @@ -52,7 +52,7 @@ describe("claimErrorKey", () => { "auth.claim.signedInTitle", ], [new ApiError("x", 403, {}), "auth.claim.oidcOnly"], - [new ApiError("x", 429, {}), "auth.claim.errorRateLimit"], + [new ApiError("x", 429, {}), "auth.signInRateLimited"], [new ApiError("x", 500, {}), "auth.claim.errorFailed"], [new Error("offline"), "auth.claim.errorOffline"], ])("maps %o", (err, key) => { diff --git a/src/components/settings/web-push-card.tsx b/src/components/settings/web-push-card.tsx index f25ae2795..f64c7d440 100644 --- a/src/components/settings/web-push-card.tsx +++ b/src/components/settings/web-push-card.tsx @@ -10,7 +10,10 @@ import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { apiFetchRaw } from "@/lib/api/api-fetch"; -import { unsubscribeWebPush } from "@/lib/notifications/web-push-unsubscribe"; +import { + saveWebPushSubscription, + unsubscribeWebPush, +} from "@/lib/notifications/web-push-subscription"; /** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */ function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer { @@ -73,6 +76,11 @@ export function WebPushCard() { if (registration) { const subscription = await registration.pushManager.getSubscription(); setIsSubscribed(!!subscription); + // Sent again on every open: the server may have lost the row or + // switched the channel off, and the browser cannot tell. + if (subscription) { + void saveWebPushSubscription(subscription).catch(() => null); + } } } catch { // Ignore errors during check @@ -111,20 +119,7 @@ export function WebPushCard() { applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey), }); - const subJson = subscription.toJSON(); - - const res = await apiFetchRaw("/api/notifications/web-push", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - endpoint: subJson.endpoint, - keys: { - p256dh: subJson.keys?.p256dh ?? "", - auth: subJson.keys?.auth ?? "", - }, - }), - }); - + const res = await saveWebPushSubscription(subscription); if (res.ok) { setIsSubscribed(true); setMsg(t("settings.webPushSubscribed")); @@ -216,11 +211,12 @@ export function WebPushCard() { {(!loading && unsupported) || (!loading && isDenied) || msg ? (
- {!loading && unsupported ? ( + {!loading && unsupported === "install" ? ( + // Steps to follow: content, not meta. +

{t("settings.webPushInstallFirst")}

+ ) : !loading && unsupported ? (

- {unsupported === "install" - ? t("settings.webPushInstallFirst") - : t("settings.webPushNotSupported")} + {t("settings.webPushNotSupported")}

) : !loading && isDenied ? (

diff --git a/src/hooks/__tests__/use-auth-offline-gate.test.ts b/src/hooks/__tests__/use-auth-offline-gate.test.ts index 2d7f9f180..376feeb1b 100644 --- a/src/hooks/__tests__/use-auth-offline-gate.test.ts +++ b/src/hooks/__tests__/use-auth-offline-gate.test.ts @@ -55,6 +55,11 @@ import { useAuth, } from "../use-auth"; +/** What `/api/auth/me` answers once the session is gone. */ +const SESSION_GONE = new ApiError("Unauthorized", 401, { + errorCode: "auth.missing", +}); + // Node test env has no localStorage — back it with a Map. const store = new Map(); beforeEach(() => { @@ -97,9 +102,20 @@ describe("isAuthVerdictUnknown — failure classification", () => { expect(isAuthVerdictUnknown(new ApiError("boom", 500))).toBe(true); }); - it("treats 401 and 403 as the real session-end verdict", () => { - expect(isAuthVerdictUnknown(new ApiError("unauthorized", 401))).toBe(false); - expect(isAuthVerdictUnknown(new ApiError("forbidden", 403))).toBe(false); + it("treats HealthLog's 401 and 403 as the real session-end verdict", () => { + expect(isAuthVerdictUnknown(SESSION_GONE)).toBe(false); + expect( + isAuthVerdictUnknown( + new ApiError("forbidden", 403, { errorCode: "http.forbidden" }), + ), + ).toBe(false); + }); + + it("treats a 401 or 403 without HealthLog's envelope as no verdict", () => { + // A forward-auth proxy or a firewall in front of the instance answers + // with its own page; the session behind it may be fine. + expect(isAuthVerdictUnknown(new ApiError("Unauthorized", 401))).toBe(true); + expect(isAuthVerdictUnknown(new ApiError("Forbidden", 403))).toBe(true); }); }); @@ -128,7 +144,7 @@ describe("useAuth — last-known state on a non-verdict failure", () => { it("reads a true 401 as session end — wipe and redirect may proceed", () => { store.set("healthlog-was-authenticated", "1"); queryState.isError = true; - queryState.error = new ApiError("unauthorized", 401); + queryState.error = SESSION_GONE; const auth = useAuth(); expect(auth.isAuthenticated).toBe(false); @@ -141,7 +157,7 @@ describe("useAuth — a verdict on a refetch", () => { store.set("healthlog-was-authenticated", "1"); queryState.data = { id: "u1", username: "someone" }; queryState.isError = true; - queryState.error = new ApiError("unauthorized", 401); + queryState.error = SESSION_GONE; const auth = useAuth(); expect(auth.isAuthenticated).toBe(false); @@ -149,6 +165,16 @@ describe("useAuth — a verdict on a refetch", () => { expect(auth.isAuthUnknown).toBe(false); }); + it("keeps the cached account through a 401 from something in front of the server", () => { + queryState.data = { id: "u1", username: "someone" }; + queryState.isError = true; + queryState.error = new ApiError("Unauthorized", 401); + + const auth = useAuth(); + expect(auth.isAuthenticated).toBe(true); + expect(auth.user).toEqual({ id: "u1", username: "someone" }); + }); + it("keeps the cached account through a network failure on a refetch", () => { queryState.data = { id: "u1", username: "someone" }; queryState.isError = true; diff --git a/src/hooks/__tests__/use-logout.test.ts b/src/hooks/__tests__/use-logout.test.ts index 592b28e79..49e14c26b 100644 --- a/src/hooks/__tests__/use-logout.test.ts +++ b/src/hooks/__tests__/use-logout.test.ts @@ -14,7 +14,7 @@ interface LogoutOptions { } const captured: { options: LogoutOptions | null } = { options: null }; -const push = vi.fn(); +const replace = vi.fn(); const clear = vi.fn(); const showErrorToast = vi.fn(); @@ -27,7 +27,7 @@ vi.mock("@tanstack/react-query", () => ({ }, })); vi.mock("next/navigation", () => ({ - useRouter: () => ({ push, replace: vi.fn() }), + useRouter: () => ({ push: vi.fn(), replace }), })); vi.mock("@/lib/i18n/context", () => ({ useTranslations: () => ({ t: (key: string) => key }), @@ -73,7 +73,7 @@ afterEach(() => { }); describe("useLogout", () => { - it("clears the caches and opens the sign-in page once the server signed out", async () => { + it("clears the caches and replaces the page with the sign-in page once the server signed out", async () => { fetchMock.mockResolvedValue(json(200, { data: { loggedOut: true } })); useLogout(); @@ -84,20 +84,22 @@ describe("useLogout", () => { expect.objectContaining({ method: "POST" }), ); expect(clear).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith("/auth/login"); + expect(replace).toHaveBeenCalledWith("/auth/login"); expect(showErrorToast).not.toHaveBeenCalled(); }); - it("removes this browser's push subscription before the session ends", async () => { - // Left in place, the browser kept showing the signed-out account's - // medication reminders, also after someone else signed in. - const order: string[] = []; + /** A browser holding a push subscription; returns the call order log. */ + function holdSubscription(order: string[]) { const unsubscribe = vi.fn(async () => { order.push("browser unsubscribe"); return true; }); const subscription = { endpoint: "https://push.example.com/send/abc", + toJSON: () => ({ + endpoint: "https://push.example.com/send/abc", + keys: { p256dh: "p256dh", auth: "auth" }, + }), unsubscribe, }; vi.stubGlobal("window", { PushManager: class {} }); @@ -108,6 +110,16 @@ describe("useLogout", () => { }), }, }); + return { subscription, unsubscribe }; + } + + it("takes this browser's push subscription off the account and out of the browser", async () => { + // Left in place, the browser kept showing the signed-out account's + // medication reminders, also after someone else signed in. The server + // row goes while the session can still name it; the browser's own only + // once the sign-out is confirmed. + const order: string[] = []; + const { subscription } = holdSubscription(order); fetchMock.mockImplementation(async (url, init) => { order.push(`${init?.method} ${url}`); return json(200, { data: {} }); @@ -118,14 +130,38 @@ describe("useLogout", () => { expect(order).toEqual([ "DELETE /api/notifications/web-push", - "browser unsubscribe", "POST /api/auth/logout", + "browser unsubscribe", ]); const deleteCall = fetchMock.mock.calls[0]; expect(JSON.parse(String(deleteCall[1]?.body))).toEqual({ endpoint: subscription.endpoint, }); - expect(push).toHaveBeenCalledWith("/auth/login"); + // A connection that carries nothing must not hold the sign-out. + expect(deleteCall[1]?.signal).toBeInstanceOf(AbortSignal); + expect(replace).toHaveBeenCalledWith("/auth/login"); + }); + + it("keeps push working when the server refuses the sign-out", async () => { + const order: string[] = []; + const { unsubscribe } = holdSubscription(order); + fetchMock.mockImplementation(async (url, init) => { + order.push(`${init?.method} ${url}`); + return url === "/api/auth/logout" + ? json(503, { data: null, error: "Unavailable" }) + : json(200, { data: {} }); + }); + + useLogout(); + await settle(captured.options!); + + expect(order).toEqual([ + "DELETE /api/notifications/web-push", + "POST /api/auth/logout", + "POST /api/notifications/web-push", + ]); + expect(unsubscribe).not.toHaveBeenCalled(); + expect(showErrorToast).toHaveBeenCalledTimes(1); }); it("still signs out when removing the subscription fails", async () => { @@ -146,7 +182,7 @@ describe("useLogout", () => { "/api/auth/logout", expect.objectContaining({ method: "POST" }), ); - expect(push).toHaveBeenCalledWith("/auth/login"); + expect(replace).toHaveBeenCalledWith("/auth/login"); }); it("stays on the page and says so when the server refuses", async () => { @@ -157,7 +193,7 @@ describe("useLogout", () => { useLogout(); await settle(captured.options!); - expect(push).not.toHaveBeenCalled(); + expect(replace).not.toHaveBeenCalled(); expect(clear).not.toHaveBeenCalled(); expect(showErrorToast).toHaveBeenCalledWith( "auth.signOutFailed", diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 7a162487a..0c84e4924 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -30,7 +30,7 @@ import type { TourProgress } from "@/lib/onboarding/tour-progress"; import { clearLastDay } from "@/lib/day/last-day"; import { clearCoachDeleteJournal } from "@/lib/ai/coach/delete-journal"; import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; -import { unsubscribeWebPush } from "@/lib/notifications/web-push-unsubscribe"; +import { signOutWithoutWebPush } from "@/lib/notifications/web-push-subscription"; import { setRecordScope, setRefusedRecordScope, @@ -338,18 +338,22 @@ function clearWasAuthenticated(): void { * True when an `/api/auth/me` failure carries NO verdict on the session — * a network-level failure (fetch `TypeError` offline, abort/timeout), the * service worker's synthetic 503 offline envelope (`meta.offline`), the - * browser reporting itself offline, or a server-side 5xx. None of these mean - * "logged out"; only an explicit 401/403 does. Treating them as session end - * used to redirect an offline relaunch to /auth/login and wipe the offline - * caches — destroying exactly the installed-PWA-opened-offline scenario the - * SW + query persister exist for. + * browser reporting itself offline, a server-side 5xx, or a 401/403 that + * HealthLog did not write. None of these mean "logged out"; only HealthLog's + * own 401/403 does. Treating them as session end used to redirect an offline + * relaunch to /auth/login and wipe the offline caches — destroying exactly + * the installed-PWA-opened-offline scenario the SW + query persister exist + * for. */ export function isAuthVerdictUnknown(error: unknown): boolean { if (error instanceof ApiError) { // 5xx (incl. the SW's synthetic 503 with `meta.offline: true`) and any - // other non-auth status: the server never ruled on the session. Only an - // explicit 401/403 is a verdict. - return error.status !== 401 && error.status !== 403; + // other non-auth status: the server never ruled on the session. + if (error.status !== 401 && error.status !== 403) return true; + // HealthLog's error envelope always names a code. A 401/403 without one + // comes from something in front of the instance (a forward-auth proxy, + // a firewall), which says nothing about this session. + return typeof error.meta?.errorCode !== "string"; } // Everything below the HTTP layer is transport-level by construction: // fetch `TypeError` (offline / DNS / connection refused), `AbortError` / @@ -528,7 +532,7 @@ export function useAuth() { }); // v1.27.x offline fix — classify a failed `/api/auth/me` before acting on - // it. Only an explicit 401/403 means "session ended"; a network-level + // it. Only HealthLog's own 401/403 means "session ended"; a network-level // failure (offline relaunch, SW 503 offline envelope, timeout) or a 5xx // carries no verdict, so the hook holds the LAST-KNOWN auth state (the // localStorage marker) instead of flipping to unauthenticated. Pre-fix, an @@ -536,8 +540,9 @@ export function useAuth() { // /auth/login and wiped every offline cache (data 7→0 entries, pages 1→0 // in the audit measurement), making the whole offline story unreachable. const verdictUnknown = query.isError && isAuthVerdictUnknown(query.error); - // A 401/403 on a refetch ends the session although the cache still holds - // the last good payload: React Query keeps `data` when a refetch fails. + // HealthLog's 401/403 on a refetch ends the session although the cache + // still holds the last good payload: React Query keeps `data` when a + // refetch fails. const sessionEnded = query.isError && !verdictUnknown; const user = sessionEnded ? null : (query.data ?? null); const isAuthenticated = !!user || (verdictUnknown && readWasAuthenticated()); @@ -621,10 +626,10 @@ export function useAccountOnceMounted(): AuthUser | null { * former explicit `setQueryData(null)` + `invalidateQueries(["auth"])`. * * Then `clearOfflineCachesForSessionEnd()` wipes the persisted layers — the - * IndexedDB query snapshot, the SW offline read-data cache (`healthlog-data-*`), - * and the SW page cache (`healthlog-pages-*`, cached navigation HTML). The static - * cache (hashed chunks, icons) carries no PII and stays intact to avoid a - * needless re-download. Best-effort; never blocks the redirect. + * IndexedDB query snapshot, the SW page cache (`healthlog-pages-*`, cached + * navigation HTML) and any API cache an older worker left (`healthlog-data-*`). + * The static cache (catalogs, icons, fonts, images) carries no PII and stays + * intact to avoid a needless re-download. Best-effort; never blocks the redirect. */ export function clearCachesForSessionEnd(queryClient: QueryClient): void { queryClient.clear(); @@ -646,23 +651,30 @@ export function clearCachesForSessionEnd(queryClient: QueryClient): void { void clearOfflineCachesForSessionEnd(); } +/** + * End the session on the server, for every sign-out button. Takes this + * browser's push subscription with it. Throws on any non-2xx answer: a + * refused sign-out leaves the session alive, and showing the sign-in page + * then would claim otherwise. + */ +export function signOutRequest(): Promise { + return signOutWithoutWebPush(() => + apiFetch("/api/auth/logout", { method: "POST" }), + ); +} + export function useLogout() { const queryClient = useQueryClient(); const router = useRouter(); const { t } = useTranslations(); return useMutation({ - mutationFn: async () => { - // Before the session ends: the server row can only be removed while - // it is still valid. A failure here does not block signing out. - await unsubscribeWebPush().catch(() => undefined); - // Throws on any non-2xx answer: a refused sign-out leaves the session - // alive, and showing the sign-in page then would claim otherwise. - await apiFetch("/api/auth/logout", { method: "POST" }); - }, + mutationFn: signOutRequest, onSuccess: () => { clearCachesForSessionEnd(queryClient); - router.push("/auth/login"); + // Replace, so Back does not reopen the last page signed out, and the + // next sign-in on this device is not sent there. + router.replace("/auth/login"); }, onError: (failure) => showErrorToast(t("auth.signOutFailed"), { cause: failure }), diff --git a/src/lib/api/__tests__/localized-error.test.ts b/src/lib/api/__tests__/localized-error.test.ts index 3be2a4630..1d04f19ef 100644 --- a/src/lib/api/__tests__/localized-error.test.ts +++ b/src/lib/api/__tests__/localized-error.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { ApiError } from "../api-fetch"; -import { localizedApiError } from "../localized-error"; +import { localizedApiError, localizedSignInError } from "../localized-error"; import de from "../../../../messages/de.json"; import en from "../../../../messages/en.json"; import es from "../../../../messages/es.json"; @@ -234,3 +234,82 @@ describe("localizedApiError", () => { }); }); }); + +describe("localizedSignInError", () => { + const enT = translator(en as Catalog); + + it("reads a wrong password or code as the step's failure, not an ended session", () => { + const error = new ApiError("Invalid credentials", 401, { + errorCode: "http.unauthenticated", + }); + expect(localizedSignInError(error, enT, "auth.loginFailed")).toBe( + "Login failed", + ); + }); + + it.each([ + ["de", de], + ["en", en], + ["es", es], + ["fr", fr], + ["it", itCatalog], + ["ko", ko], + ["nl", nl], + ["pl", pl], + ["pt-BR", ptBR], + ["ru", ru], + ])("reads a lockout as one in %s", (_, locale) => { + const localT = translator(locale as Catalog); + const error = new ApiError("Too many login attempts", 429, { + errorCode: "http.rate_limited", + }); + const sentence = localizedSignInError(error, localT, "auth.loginFailed"); + expect(sentence).toBe(localT("auth.signInRateLimited")); + expect(sentence).not.toBe("auth.signInRateLimited"); + }); + + it.each([ + ["de", de], + ["en", en], + ["es", es], + ["fr", fr], + ["it", itCatalog], + ["ko", ko], + ["nl", nl], + ["pl", pl], + ["pt-BR", ptBR], + ["ru", ru], + ])( + "names the passkey, single sign-on and registration refusals in %s", + (_, locale) => { + const localT = translator(locale as Catalog); + for (const [status, code] of [ + [401, "passkey.challenge.expired"], + [404, "passkey.unknown"], + [422, "passkey.response.invalid"], + [403, "oidc_only"], + [409, "auth.already_authenticated"], + [409, "auth.register.taken"], + [403, "auth.register.invite_invalid"], + ] as const) { + const error = new ApiError("raw English server prose", status, { + errorCode: code, + }); + expect( + localizedSignInError(error, localT, "auth.loginFailed"), + code, + ).toBe(localT(`apiErrors.${code}`)); + expect(localT(`apiErrors.${code}`), code).not.toBe(`apiErrors.${code}`); + } + }, + ); + + it("keeps the sentence of a code the step names itself", () => { + const error = new ApiError("raw English server prose", 401, { + errorCode: "auth.reproof.failed", + }); + expect(localizedSignInError(error, enT, "auth.loginFailed")).toMatch( + /did not verify/, + ); + }); +}); diff --git a/src/lib/api/localized-error.ts b/src/lib/api/localized-error.ts index ee102ad73..7e1b85403 100644 --- a/src/lib/api/localized-error.ts +++ b/src/lib/api/localized-error.ts @@ -24,3 +24,23 @@ export function localizedApiError( : defaultErrorCodeForStatus(error.status); return translateCode(t, "apiErrors", errorCode, fallbackKey); } + +/** + * A refused sign-in step (password, second factor, passkey). These refusals + * mostly carry only their status's generic code, and here a 401 means a + * wrong password or code, not an ended session, and a 429 a lockout of + * several minutes. A code of the step's own keeps its sentence. + */ +export function localizedSignInError( + error: unknown, + t: Translate, + fallbackKey: string, +): string { + if (error instanceof ApiError) { + const code = error.meta?.errorCode; + const generic = typeof code !== "string" || code.startsWith("http."); + if (generic && error.status === 429) return t("auth.signInRateLimited"); + if (generic && error.status === 401) return t(fallbackKey); + } + return localizedApiError(error, t, fallbackKey); +} diff --git a/src/lib/medications/outstanding-doses.ts b/src/lib/medications/outstanding-doses.ts deleted file mode 100644 index d94109953..000000000 --- a/src/lib/medications/outstanding-doses.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * v1.18.4 — server-authoritative count of a user's still-outstanding doses - * for the current local day. Drives the PWA app badge (`navigator.setAppBadge`) - * so the installed icon mirrors how many doses still need logging — the - * free-tier equivalent of the iOS widget's pending count. - * - * Outstanding = a today-scheduled `MedicationIntakeEvent` that is neither - * taken (`takenAt == null`) nor skipped (`skipped == false`) nor swept by the - * miss cutoff (`autoMissed == false`). Pending rows are minted by - * `projectTodayIntakesAndRecompute`; this is a read-only count over the same - * window, so callers that have just projected today's rows get an accurate - * total. - */ -import { prisma } from "@/lib/db"; -import { getUserTodayBounds } from "@/lib/tz/local-day"; -import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking"; - -/** - * Count the user's outstanding (pending) doses for today in their timezone. - * Best-effort: any failure returns 0 so a badge update never blocks a caller. - */ -export async function countOutstandingDosesToday( - userId: string, - tz: string, -): Promise { - try { - const { start, end } = getUserTodayBounds(new Date(), tz); - return await prisma.medicationIntakeEvent.count({ - where: { - userId, - scheduledFor: { gte: start, lte: end }, - takenAt: null, - skipped: false, - autoMissed: false, - // A tombstoned placeholder is gone for every read surface; it must - // not keep a badge count alive. - deletedAt: null, - // v1.39.1 (#1033) — nothing of a medication with intake tracking - // off is outstanding. - ...TRACKED_INTAKE_EVENT_WHERE, - }, - }); - } catch { - return 0; - } -} diff --git a/src/lib/notifications/__tests__/dispatcher.test.ts b/src/lib/notifications/__tests__/dispatcher.test.ts index e5b80df3c..c03eaf7b8 100644 --- a/src/lib/notifications/__tests__/dispatcher.test.ts +++ b/src/lib/notifications/__tests__/dispatcher.test.ts @@ -211,13 +211,15 @@ describe("dispatchNotification — nobody to send to", () => { // Turning push off on the last browser deletes its subscription and leaves // the channel on. Counted as failures, the next five reminders switched the // channel off for good, and subscribing again later did not bring it back. + // Not counted either, the jobs that retry until delivered asked again every + // minute, and a watchdog read the attempt as an announced alert. it.each([ ["WEB_PUSH", "web_push_no_subscriptions"], ["WEB_PUSH", "web_push_vapid_not_configured"], ["APNS", "apns_no_devices"], ["APNS", "apns_not_configured"], ] as const)( - "%s answering %s leaves the channel state alone", + "%s answering %s rests the channel without counting a failure or an attempt", async (type, reason) => { vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ { @@ -241,8 +243,25 @@ describe("dispatchNotification — nobody to send to", () => { }); expect(sender).toHaveBeenCalledTimes(1); - expect(outcome.dispatched).toBe(false); - expect(prisma.notificationChannel.update).not.toHaveBeenCalled(); + expect(outcome).toEqual({ + dispatched: false, + channelsAttempted: 0, + channelsSucceeded: 0, + }); + expect(prisma.notificationChannel.update).toHaveBeenCalledTimes(1); + const { data } = vi.mocked(prisma.notificationChannel.update).mock + .calls[0][0] as { data: Record }; + // The reason for the status screen and an hour's rest; the counter and + // the switch stay as they were. + expect(data).toEqual({ + lastFailureAt: expect.any(Date), + lastFailureReason: reason, + nextRetryAt: expect.any(Date), + }); + expect( + (data.nextRetryAt as Date).getTime() - + (data.lastFailureAt as Date).getTime(), + ).toBe(60 * 60 * 1000); expect(auditLog).not.toHaveBeenCalled(); }, ); diff --git a/src/lib/notifications/channel-state.ts b/src/lib/notifications/channel-state.ts index 62e7cc937..2e311cebb 100644 --- a/src/lib/notifications/channel-state.ts +++ b/src/lib/notifications/channel-state.ts @@ -6,8 +6,11 @@ * soft reject" rules unit-testable and audit-loggable in one place. */ import { prisma } from "@/lib/db"; +import type { Prisma } from "@/generated/prisma/client"; import { auditLog } from "@/lib/auth/audit"; +import { getEvent } from "@/lib/logging/context"; import { + NO_RECIPIENT_COOLDOWN_MS, nextRetryAt, shouldAutoDisableAfterTransient, type SendOutcome, @@ -129,6 +132,41 @@ export async function recordChannelTransientFailure( return { autoDisabled: false, nextRetryAt: retryAt }; } +/** + * Record a send that had nobody to go to. Not a failure: the counter stays + * where it is. The reason is kept, so the status screen says why nothing + * arrives, and the channel rests for `NO_RECIPIENT_COOLDOWN_MS`. + */ +export async function recordChannelNoRecipient( + channel: ChannelRef, + reason: string, + now: Date = new Date(), +): Promise { + getEvent()?.addMeta( + `notification_skip_${channel.type.toLowerCase()}_no_recipient`, + reason, + ); + await prisma.notificationChannel.update({ + where: { id: channel.id }, + data: { + lastFailureAt: now, + lastFailureReason: reason, + nextRetryAt: new Date(now.getTime() + NO_RECIPIENT_COOLDOWN_MS), + }, + }); +} + +/** Pairing an iPhone ends a rest that was waiting for one. */ +export async function endApnsNoDeviceRest( + db: Pick, + userId: string, +): Promise { + await db.notificationChannel.updateMany({ + where: { userId, type: "APNS", lastFailureReason: "apns_no_devices" }, + data: { nextRetryAt: null, lastFailureReason: null }, + }); +} + /** * Re-enable a channel that was previously auto-disabled. Clears the * disabled-reason + counter and the cooldown so the next dispatch diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index 4b102c7c2..75bada3d3 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -20,6 +20,7 @@ import type { SendOutcome } from "@/lib/notifications/retry-policy"; import { isChannelInCooldown, recordChannelHardReject, + recordChannelNoRecipient, recordChannelSuccess, recordChannelTransientFailure, } from "@/lib/notifications/channel-state"; @@ -49,7 +50,8 @@ import { * not set up. The sender writes a `skipped` ledger row. The channel has not * failed, so these leave its failure counter alone; counted as failures, * turning push off on the last device would switch the channel off for good - * after five reminders. + * after five reminders. The channel rests for a while instead + * (`recordChannelNoRecipient`), and the send does not count as an attempt. */ const NO_RECIPIENT_REASONS: ReadonlySet = new Set([ "web_push_no_subscriptions", @@ -74,8 +76,8 @@ const NO_RECIPIENT_REASONS: ReadonlySet = new Set([ * 6. Call the appropriate sender — which now returns a `SendOutcome` so * the dispatcher can classify hard rejects (410, blocked-by-user) vs * soft errors (5xx, 429, network) and update channel state accordingly. - * A send that had nobody to go to (`NO_RECIPIENT_REASONS`) changes no - * channel state. + * A send that had nobody to go to (`NO_RECIPIENT_REASONS`) pauses the + * channel without counting a failure. * * Also handles legacy Telegram config stored on the User model by * auto-migrating it to a NotificationChannel record on first dispatch. @@ -432,10 +434,8 @@ export async function dispatchNotification( } if (outcome.reason && NO_RECIPIENT_REASONS.has(outcome.reason)) { - getEvent()?.addMeta( - `notification_skip_${channel.type.toLowerCase()}_no_recipient`, - outcome.reason, - ); + channelsAttempted -= 1; // nothing went anywhere + await recordChannelNoRecipient(ref, outcome.reason, now); continue; } diff --git a/src/lib/notifications/dose-tag.ts b/src/lib/notifications/dose-tag.ts index 8b1af83fa..a955fde46 100644 --- a/src/lib/notifications/dose-tag.ts +++ b/src/lib/notifications/dose-tag.ts @@ -1,18 +1,10 @@ /** - * v1.18.4 — stable Web Push notification tag for a medication dose slot. + * Stable notification tag for a medication dose slot, `(medicationId, + * scheduledFor instant)`. A repeated reminder for the same dose replaces the + * one already shown instead of stacking a second. * - * A PWA self-hoster with no Apple Developer account has no Live Activity to - * end when a dose is logged. Instead the server pushes a `type: "clear"` - * web-push that the service worker turns into `getNotifications({ tag })` + - * `notification.close()`. For that to work the REMINDER push and the CLEAR - * push must agree on a single, stable tag per dose slot. - * - * The slot is uniquely identified by `(medicationId, scheduledFor-instant)`: - * - the dose-due reminder fires with `scheduledAt` = the slot's ISO instant, - * - the clear-on-taken fires with `scheduledFor` = the same ISO instant. - * - * `scheduledFor` is normalised through `Date` so a reminder ISO carrying - * milliseconds and a clear ISO without them still collapse to one tag. + * The instant is normalised through `Date`, so an ISO string with and one + * without milliseconds give the same tag. */ export function medicationDoseTag( medicationId: string, diff --git a/src/lib/notifications/retry-policy.ts b/src/lib/notifications/retry-policy.ts index 958024563..d6b493abe 100644 --- a/src/lib/notifications/retry-policy.ts +++ b/src/lib/notifications/retry-policy.ts @@ -76,6 +76,14 @@ export const BACKOFF_SCHEDULE_MS: readonly number[] = Object.freeze([ export const MAX_CONSECUTIVE_FAILURES = 5; +/** + * How long a channel rests after a send that had nobody to go to (no + * browser subscribed, no iPhone paired, a transport the operator has not set + * up). The jobs that retry until delivered would otherwise ask again every + * minute. Subscribing or pairing ends the rest at once. + */ +export const NO_RECIPIENT_COOLDOWN_MS = 60 * 60 * 1_000; + /** * Given the new `consecutiveFailures` value (1, 2, …) AFTER incrementing, * return the wall-clock time when a retry is allowed. Returns null once the diff --git a/src/lib/notifications/web-push-subscription.ts b/src/lib/notifications/web-push-subscription.ts new file mode 100644 index 000000000..654075eeb --- /dev/null +++ b/src/lib/notifications/web-push-subscription.ts @@ -0,0 +1,82 @@ +import { apiFetchRaw } from "@/lib/api/api-fetch"; + +/** This browser's push subscription, or null when it has none. */ +async function browserSubscription(): Promise { + if (!("serviceWorker" in navigator) || !("PushManager" in window)) { + return null; + } + const registration = await navigator.serviceWorker.getRegistration("/sw.js"); + return (await registration?.pushManager.getSubscription()) ?? null; +} + +/** + * Bounded: on a connection that is up but carries nothing, sign-out would + * otherwise wait out the browser's own timeout before it even starts. + */ +function deleteServerRow(subscription: PushSubscription): Promise { + return apiFetchRaw("/api/notifications/web-push", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint: subscription.endpoint }), + signal: AbortSignal.timeout(5_000), + }); +} + +/** + * Store this browser's subscription for the signed-in account. Also run for a + * subscription the browser already holds, so a row the server lost comes + * back and a channel switched off by failed sends is turned on again. + */ +export function saveWebPushSubscription( + subscription: PushSubscription, +): Promise { + const subJson = subscription.toJSON(); + return apiFetchRaw("/api/notifications/web-push", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + endpoint: subJson.endpoint, + keys: { + p256dh: subJson.keys?.p256dh ?? "", + auth: subJson.keys?.auth ?? "", + }, + }), + }); +} + +/** + * "Disable push": remove the server row, then the browser's own + * subscription. Does nothing when the browser has none. + */ +export async function unsubscribeWebPush(): Promise { + const subscription = await browserSubscription(); + if (!subscription) return; + await deleteServerRow(subscription); + await subscription.unsubscribe(); +} + +/** + * Run a sign-out with this browser's push subscription taken off the account. + * A subscription left behind keeps showing that account's reminders on this + * browser, to whoever uses it next. + * + * The server row goes first, while the session can still name it; the + * browser's subscription only once `signOut` resolved. A refused sign-out + * stores the row again, so the person who is still signed in keeps push. + * Failures around the subscription never block the sign-out. + */ +export async function signOutWithoutWebPush( + signOut: () => Promise, +): Promise { + const subscription = await browserSubscription().catch(() => null); + if (subscription) await deleteServerRow(subscription).catch(() => null); + try { + await signOut(); + } catch (failure) { + if (subscription) { + await saveWebPushSubscription(subscription).catch(() => null); + } + throw failure; + } + await subscription?.unsubscribe().catch(() => false); +} diff --git a/src/lib/notifications/web-push-unsubscribe.ts b/src/lib/notifications/web-push-unsubscribe.ts deleted file mode 100644 index ca2ba162a..000000000 --- a/src/lib/notifications/web-push-unsubscribe.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { apiFetchRaw } from "@/lib/api/api-fetch"; - -/** - * Remove this browser's Web Push subscription: the server row first, while - * the session can still name it, then the browser's own. Does nothing when - * the browser has none. - * - * Used by "Disable push" and by sign-out. A subscription left behind at - * sign-out keeps showing that account's reminders on this browser, to - * whoever uses it next. - */ -export async function unsubscribeWebPush(): Promise { - if (!("serviceWorker" in navigator) || !("PushManager" in window)) return; - const registration = await navigator.serviceWorker.getRegistration("/sw.js"); - const subscription = await registration?.pushManager.getSubscription(); - if (!subscription) return; - await apiFetchRaw("/api/notifications/web-push", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ endpoint: subscription.endpoint }), - }); - await subscription.unsubscribe(); -} diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 8a1df35c2..411c355a1 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -93,6 +93,8 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "auth.refresh.invalid", "auth.refresh.reuse", "auth.refresh.revoked", + "auth.register.invite_invalid", + "auth.register.taken", "auth.reproof.failed", "auth.reproof.required", "auth.reproof.too_weak", diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index d42f61ea1..1af5ecfac 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -2301,12 +2301,12 @@ export const authPaths: NonNullable = { }, "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.", + "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 (`auth.register.invite_invalid`).", content: { "application/json": { schema: 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.", + "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: errorEnvelope } }, }, ...stdResponses, diff --git a/src/lib/openapi/routes/notifications-transport.ts b/src/lib/openapi/routes/notifications-transport.ts index 1839477d9..6d3321680 100644 --- a/src/lib/openapi/routes/notifications-transport.ts +++ b/src/lib/openapi/routes/notifications-transport.ts @@ -191,7 +191,7 @@ export const notificationTransportPaths: NonNullable< summary: "Send a test push to every Web Push subscription", description: "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.\n\n" + - "Read `ok`, not the status. A push that every service rejects still answers 200, with `ok: false` and a `reason`. No endpoint is returned: the full endpoint is the routing secret for that subscription.", + "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: diff --git a/src/lib/openapi/routes/notifications.ts b/src/lib/openapi/routes/notifications.ts index d83d218e4..a226567f0 100644 --- a/src/lib/openapi/routes/notifications.ts +++ b/src/lib/openapi/routes/notifications.ts @@ -105,7 +105,7 @@ const channelDeliveryStatus = z state: z .enum(["active", "auto_disabled", "manually_disabled", "sending_paused"]) .describe( - "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.", + "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`, 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: z .string() diff --git a/src/lib/pwa/clear-before-leaving.ts b/src/lib/pwa/clear-before-leaving.ts new file mode 100644 index 000000000..460114f1c --- /dev/null +++ b/src/lib/pwa/clear-before-leaving.ts @@ -0,0 +1,12 @@ +import { settleWithin } from "@/lib/async/settle-within"; +import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; + +/** + * The session-end wipe before the page leaves for the identity provider, + * where the next session starts. The wait is capped: WebKit can leave an + * IndexedDB open hanging after the installed app returns from the + * background, and the button must still work then. + */ +export function clearOfflineCachesBeforeLeaving(): Promise { + return settleWithin(clearOfflineCachesForSessionEnd(), 1_500, undefined); +} diff --git a/tests/integration/routes/settings-channels.test.ts b/tests/integration/routes/settings-channels.test.ts index 81254cf79..e8c38ef1d 100644 --- a/tests/integration/routes/settings-channels.test.ts +++ b/tests/integration/routes/settings-channels.test.ts @@ -1060,4 +1060,25 @@ describe("the operator's instance-wide channel switches", () => { expect(decrypt(subscriptions[0].auth)).toBe(SUBSCRIPTION.keys.auth); expect((await channel(user.id, "WEB_PUSH"))?.enabled).toBe(true); }); + + it("hands a browser's subscription to the account that subscribed it last", async () => { + await switchGlobal({ webPushGlobal: true }); + const before = await makeUser(); + const after = await makeUser(); + for (const user of [before, after]) { + const res = await callRoute(subscribeWebPush, { + method: "POST", + route: "/api/notifications/web-push", + body: SUBSCRIPTION, + auth: await makeSession(user), + }); + expect(res.status).toBe(200); + } + + const rows = await prisma.pushSubscription.findMany({ + where: { endpoint: SUBSCRIPTION.endpoint }, + select: { userId: true }, + }); + expect(rows).toEqual([{ userId: after.id }]); + }); }); diff --git a/tests/integration/web-push-channel-re-enable-migration.test.ts b/tests/integration/web-push-channel-re-enable-migration.test.ts new file mode 100644 index 000000000..4f72f0ec7 --- /dev/null +++ b/tests/integration/web-push-channel-re-enable-migration.test.ts @@ -0,0 +1,151 @@ +/** + * Integration coverage for `0497_reenable_subscribed_web_push_channels`. + * + * The test container starts empty, so the migration has nothing to touch when + * it runs at boot. This seeds the states a live instance holds, applies the + * exact migration SQL from disk, and checks which channels came back, which + * stayed off, and that a second run changes nothing. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const MIGRATION_SQL = readFileSync( + resolve( + process.cwd(), + "prisma/migrations/0497_reenable_subscribed_web_push_channels/migration.sql", + ), + "utf8", +); + +async function seedUser(id: string, subscribed: boolean): Promise { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { id, username: id, email: `${id}@example.test` }, + }); + if (subscribed) { + await prisma.pushSubscription.create({ + data: { + userId: id, + endpoint: `https://push.example.test/${id}`, + p256dh: "p256dh", + auth: "auth", + }, + }); + } +} + +async function seedChannel( + userId: string, + type: string, + state: { enabled: boolean; disabledReason: string | null }, +): Promise { + const row = await getPrismaClient().notificationChannel.create({ + data: { + userId, + type, + config: "{}", + enabled: state.enabled, + disabledReason: state.disabledReason, + consecutiveFailures: 5, + lastFailureReason: "web_push_no_subscriptions", + nextRetryAt: state.enabled ? new Date(Date.now() + 3_600_000) : null, + }, + select: { id: true }, + }); + return row.id; +} + +function readChannel(id: string) { + return getPrismaClient().notificationChannel.findUniqueOrThrow({ + where: { id }, + select: { + enabled: true, + disabledReason: true, + consecutiveFailures: true, + lastFailureReason: true, + nextRetryAt: true, + updatedAt: true, + }, + }); +} + +describe("0497 re-enable subscribed Web Push channels", () => { + beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + }); + + it("turns an automatically disabled channel with a subscription back on", async () => { + await seedUser("gone-410", true); + await seedUser("gave-up", true); + const gone = await seedChannel("gone-410", "WEB_PUSH", { + enabled: false, + disabledReason: "web_push_410_gone", + }); + const gaveUp = await seedChannel("gave-up", "WEB_PUSH", { + enabled: false, + disabledReason: "give_up_after_5_failures", + }); + + await getPrismaClient().$executeRawUnsafe(MIGRATION_SQL); + + for (const id of [gone, gaveUp]) { + expect(await readChannel(id)).toMatchObject({ + enabled: true, + disabledReason: null, + consecutiveFailures: 0, + lastFailureReason: null, + nextRetryAt: null, + }); + } + }); + + it("leaves a channel switched off by hand, one without a subscription, and other channel types alone", async () => { + await seedUser("by-hand", true); + await seedUser("no-browser", false); + await seedUser("telegram", true); + await seedUser("healthy", true); + const byHand = await seedChannel("by-hand", "WEB_PUSH", { + enabled: false, + disabledReason: null, + }); + const noBrowser = await seedChannel("no-browser", "WEB_PUSH", { + enabled: false, + disabledReason: "web_push_410_gone", + }); + const telegram = await seedChannel("telegram", "TELEGRAM", { + enabled: false, + disabledReason: "telegram_blocked_by_user", + }); + const healthy = await seedChannel("healthy", "WEB_PUSH", { + enabled: true, + disabledReason: null, + }); + const before = await Promise.all( + [byHand, noBrowser, telegram, healthy].map(readChannel), + ); + + await getPrismaClient().$executeRawUnsafe(MIGRATION_SQL); + + const after = await Promise.all( + [byHand, noBrowser, telegram, healthy].map(readChannel), + ); + expect(after).toEqual(before); + }); + + it("changes nothing on a second run", async () => { + await seedUser("gone-410", true); + const id = await seedChannel("gone-410", "WEB_PUSH", { + enabled: false, + disabledReason: "web_push_410_gone", + }); + await getPrismaClient().$executeRawUnsafe(MIGRATION_SQL); + const first = await readChannel(id); + + await getPrismaClient().$executeRawUnsafe(MIGRATION_SQL); + + expect(await readChannel(id)).toEqual(first); + }); +});