From 32911fea3739055064403a172045914ba803370f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:44:24 +0100 Subject: [PATCH] fix(push): remove this browser's subscription on sign-out Signing out ended the session and wiped the caches but left the Web Push subscription in place, in the browser and on the server. The browser kept showing the signed-out account's medication reminders, also after someone else signed in on it, and the next account's Settings showed push as already on. Sign-out now runs the same steps as "Disable push" before the logout call: DELETE the server row while the session is still valid, then unsubscribe in the browser. Both callers share one helper. A failure there does not block signing out. With the dispatcher no longer counting "no subscription" as a failed send, signing out does not start the five-failure countdown on the account's channel. --- .../baselines/source-function-lines.json | 4 +- src/components/settings/web-push-card.tsx | 17 +----- src/hooks/__tests__/use-logout.test.ts | 61 +++++++++++++++++++ src/hooks/use-auth.ts | 4 ++ src/lib/notifications/web-push-unsubscribe.ts | 23 +++++++ 5 files changed, 92 insertions(+), 17 deletions(-) create mode 100644 src/lib/notifications/web-push-unsubscribe.ts diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 5dea39622..ad2d1a644 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": 344, - "total": 111507, + "total": 111493, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -224,7 +224,7 @@ "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": 215, + "components/settings/web-push-card.tsx :: WebPushCard": 201, "components/symptoms/symptom-entry-form.tsx :: SymptomEntryForm": 317, "components/symptoms/symptoms-section.tsx :: SymptomsSection": 205, "components/targets/range-bar.tsx :: RangeBar": 220, diff --git a/src/components/settings/web-push-card.tsx b/src/components/settings/web-push-card.tsx index 53fed1acf..c9515fc88 100644 --- a/src/components/settings/web-push-card.tsx +++ b/src/components/settings/web-push-card.tsx @@ -10,6 +10,7 @@ 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"; /** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */ function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer { @@ -136,21 +137,7 @@ export function WebPushCard() { setMsgType(null); try { - const registration = - await navigator.serviceWorker.getRegistration("/sw.js"); - if (registration) { - const subscription = await registration.pushManager.getSubscription(); - if (subscription) { - await apiFetchRaw("/api/notifications/web-push", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ endpoint: subscription.endpoint }), - }); - - await subscription.unsubscribe(); - } - } - + await unsubscribeWebPush(); setIsSubscribed(false); setMsg(t("settings.webPushUnsubscribed")); setMsgType("success"); diff --git a/src/hooks/__tests__/use-logout.test.ts b/src/hooks/__tests__/use-logout.test.ts index ed0ba5e6e..592b28e79 100644 --- a/src/hooks/__tests__/use-logout.test.ts +++ b/src/hooks/__tests__/use-logout.test.ts @@ -88,6 +88,67 @@ describe("useLogout", () => { 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[] = []; + const unsubscribe = vi.fn(async () => { + order.push("browser unsubscribe"); + return true; + }); + const subscription = { + endpoint: "https://push.example.com/send/abc", + unsubscribe, + }; + vi.stubGlobal("window", { PushManager: class {} }); + vi.stubGlobal("navigator", { + serviceWorker: { + getRegistration: async () => ({ + pushManager: { getSubscription: async () => subscription }, + }), + }, + }); + fetchMock.mockImplementation(async (url, init) => { + order.push(`${init?.method} ${url}`); + return json(200, { data: {} }); + }); + + useLogout(); + await settle(captured.options!); + + expect(order).toEqual([ + "DELETE /api/notifications/web-push", + "browser unsubscribe", + "POST /api/auth/logout", + ]); + const deleteCall = fetchMock.mock.calls[0]; + expect(JSON.parse(String(deleteCall[1]?.body))).toEqual({ + endpoint: subscription.endpoint, + }); + expect(push).toHaveBeenCalledWith("/auth/login"); + }); + + it("still signs out when removing the subscription fails", async () => { + vi.stubGlobal("window", { PushManager: class {} }); + vi.stubGlobal("navigator", { + serviceWorker: { + getRegistration: async () => { + throw new Error("no worker"); + }, + }, + }); + fetchMock.mockResolvedValue(json(200, { data: { loggedOut: true } })); + + useLogout(); + await settle(captured.options!); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/auth/logout", + expect.objectContaining({ method: "POST" }), + ); + expect(push).toHaveBeenCalledWith("/auth/login"); + }); + it("stays on the page and says so when the server refuses", async () => { // The demo instance answered 403 here; the page showed the sign-in form // while the session cookie still opened every record page. diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index aa8344cdb..63f8b056a 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -30,6 +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 { setRecordScope, setRefusedRecordScope, @@ -649,6 +650,9 @@ export function useLogout() { 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" }); diff --git a/src/lib/notifications/web-push-unsubscribe.ts b/src/lib/notifications/web-push-unsubscribe.ts new file mode 100644 index 000000000..ca2ba162a --- /dev/null +++ b/src/lib/notifications/web-push-unsubscribe.ts @@ -0,0 +1,23 @@ +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(); +}