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

@@ -117,6 +117,42 @@ describe("ClaimProfile", () => {
});
describe("ClaimProfile, already signed in", () => {
it("takes this browser's push subscription with it on sign-out", async () => {
// The button exists so the profile's person can take over on this
// browser; a subscription left behind would show them the last
// account's reminders.
const unsubscribe = vi.fn(async () => true);
const registration = vi
.spyOn(navigator.serviceWorker, "getRegistration")
.mockResolvedValue({
pushManager: {
getSubscription: async () => ({
endpoint: "https://push.example.test/send/abc",
unsubscribe,
}),
},
} as unknown as ServiceWorkerRegistration);
api
.on("DELETE", "/api/notifications/web-push", { data: {} })
.on("POST", "/api/auth/logout", { data: { loggedOut: true } });
await renderWithApp(<ClaimProfile token={TOKEN} />, {
user: { username: "signed-in" },
});
await click("claim-sign-out");
await expect.poll(() => unsubscribe.mock.calls.length).toBe(1);
const order = api.requests
.map((r) => `${r.method} ${r.path.split("?")[0]}`)
.filter((call) => !call.startsWith("GET "));
// The signed-out page then loads the link's preview.
expect(order.slice(0, 2)).toEqual([
"DELETE /api/notifications/web-push",
"POST /api/auth/logout",
]);
registration.mockRestore();
});
it("keeps the session's page and says so when the server refuses the sign-out", async () => {
api.on("POST", "/api/auth/logout", {
status: 403,
+7 -3
View File
@@ -13,8 +13,12 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Logo } from "@/components/ui/logo";
import { PasswordStrength } from "@/components/ui/password-strength";
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
import { ApiError, apiFetch } from "@/lib/api/api-fetch";
import {
clearCachesForSessionEnd,
signOutRequest,
useAuth,
} from "@/hooks/use-auth";
import { ApiError } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { loadDocument } from "@/lib/navigation/load-document";
@@ -79,7 +83,7 @@ export function ClaimProfile({
async function signOut() {
setSigningOut(true);
try {
await apiFetch("/api/auth/logout", { method: "POST" });
await signOutRequest();
clearCachesForSessionEnd(queryClient);
// Back to this page with the link intact: the token only rides the URL.
router.replace(