fix(auth): take the push subscription off on every sign-out button

Only the menu's sign-out removed this browser's push subscription. The
sign-out buttons on the registration page and the profile take-over page,
which exist so someone else can use the browser next, left it behind, and
the next person kept seeing the last account's reminders. All three now
go through one signOutRequest.

The browser's own subscription is now removed only once the server has
confirmed the sign-out; the server row still goes first, while the
session can name it. A refused sign-out stores the row again, so the
person who is still signed in keeps push. The logout test pins the new
order and the refusal.

The helper file is renamed web-push-subscription.ts, since it now also
saves a subscription for the settings card.
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 13:51:44 +01:00
1 parent 6914f844be
commit 367ba65bf6
10 files changed
+231 -69

No files matched your search

@@ -0,0 +1,77 @@
import { apiFetchRaw } from "@/lib/api/api-fetch";
/** This browser's push subscription, or null when it has none. */
async function browserSubscription(): Promise<PushSubscription | null> {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
return null;
}
const registration = await navigator.serviceWorker.getRegistration("/sw.js");
return (await registration?.pushManager.getSubscription()) ?? null;
}
function deleteServerRow(subscription: PushSubscription): Promise<Response> {
return apiFetchRaw("/api/notifications/web-push", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
}
/**
* 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<Response> {
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<void> {
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<unknown>,
): Promise<void> {
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);
}
@@ -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<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();
}