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(); +}