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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:44:24 +01:00
1 parent 6d8f533deb
commit 32911fea37
5 files changed
+92 -17

No files matched your search

@@ -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,
+2 -15
View File
@@ -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");
+61
View File
@@ -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.
+4
View File
@@ -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" });
@@ -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<void> {
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();
}