mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
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:
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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user