fix(notifications): say when the browser's push service refuses

Enabling Browser Push showed only "Activation failed" whenever
pushManager.subscribe() rejected. Brave keeps push messaging off by
default and rejects with "AbortError: Registration failed - push
service error", which left people with nothing to act on.

The browser's own refusal is now classified apart from the server
answers around it. An AbortError from subscribe reads as the push
service refusing and links the Browser Push docs; in Brave the message
names the setting under brave://settings/privacy. A blocked permission
stays blocked, and a dismissed prompt or a rejected key keeps the
generic message. The subscribe flow moves out of the card component so
it stays under the function size baseline.

Refs Discussion #1378
This commit is contained in:
Marc-André Bombeck committed 2026-11-14 08:56:25 +01:00
1 parent cfa834510e
commit 422decc9d9
15 files changed
+412 -42

No files matched your search

@@ -11,7 +11,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { stubFetch } from "../../../../tests/browser/fetch-stub";
import { renderWithApp } from "../../../../tests/browser/render";
import { WebPushCard } from "../web-push-card";
import { WEB_PUSH_DOCS_HREF, WebPushCard } from "../web-push-card";
const SUBSCRIPTION = {
endpoint: "https://push.example.test/send/abc",
@@ -62,3 +62,69 @@ describe("WebPushCard", () => {
expect(document.querySelector('[role="alert"]')).toBeNull();
});
});
/**
* A browser whose push service refuses the registration, as Brave does with
* push messaging switched off: `subscribe()` rejects with an `AbortError`.
*/
function refuseAtPushService(): void {
vi.spyOn(Notification, "permission", "get").mockReturnValue("granted");
vi.spyOn(navigator.serviceWorker, "getRegistration").mockResolvedValue(
undefined,
);
const refusal = new DOMException(
"Registration failed - push service error",
"AbortError",
);
vi.spyOn(navigator.serviceWorker, "ready", "get").mockReturnValue(
Promise.resolve({
pushManager: { subscribe: () => Promise.reject(refusal) },
} as unknown as ServiceWorkerRegistration),
);
}
function alertText(): string {
return document.querySelector('[role="alert"]')?.textContent ?? "";
}
describe("WebPushCard when the push service refuses", () => {
afterEach(() => {
delete (navigator as Navigator & { brave?: unknown }).brave;
});
it("says the push service refused and links the docs", async () => {
refuseAtPushService();
const api = stubFetch().on("GET", "/api/notifications/vapid", {
data: { publicKey: "BAAAAAAA" },
});
const { screen } = await renderWithApp(<WebPushCard />);
await screen.getByRole("button", { name: "Enable push" }).click();
await expect
.poll(() => alertText())
.toContain("Your browser's push service refused the registration");
await expect
.element(screen.getByRole("link", { name: "Help with browser push" }))
.toHaveAttribute("href", WEB_PUSH_DOCS_HREF);
// Nothing reached the server: the browser refused before a save.
expect(api.calls("POST", "/api/notifications/web-push")).toHaveLength(0);
});
it("names the setting in Brave", async () => {
refuseAtPushService();
Object.defineProperty(navigator, "brave", {
value: { isBrave: async () => true },
configurable: true,
});
stubFetch().on("GET", "/api/notifications/vapid", {
data: { publicKey: "BAAAAAAA" },
});
const { screen } = await renderWithApp(<WebPushCard />);
await screen.getByRole("button", { name: "Enable push" }).click();
await expect.poll(() => alertText()).toContain("brave://settings/privacy");
expect(alertText()).toContain("Use Google services for push messaging");
});
});
+96 -41
View File
@@ -1,9 +1,11 @@
"use client";
import { useEffect, useState } from "react";
import { BellRing, Loader2 } from "lucide-react";
import { BellRing, ExternalLink, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { InlineAction } from "@/components/ui/inline-action";
import { DOCS_GUIDES_BASE } from "@/components/settings/integrations/setup-guide-link";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
@@ -14,6 +16,14 @@ import {
saveWebPushSubscription,
unsubscribeWebPush,
} from "@/lib/notifications/web-push-subscription";
import {
classifyWebPushSubscribeFailure,
isBraveBrowser,
type WebPushSubscribeFailure,
} from "@/lib/notifications/web-push-subscribe-failure";
/** The docs section on Browser Push, linked when the browser refused. */
export const WEB_PUSH_DOCS_HREF = `${DOCS_GUIDES_BASE}/reminder-channels/#web-push-vapid`;
/** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */
function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer {
@@ -43,6 +53,67 @@ export function needsHomeScreen(): boolean {
type Unsupported = "browser" | "install" | null;
/** What one press of "Enable push" ended in. */
type SubscribeOutcome =
"subscribed" | "not-configured" | "save-failed" | WebPushSubscribeFailure;
/**
* Subscribe this browser and store the subscription on the server. The
* browser's own refusal is classified apart from the server's answers around
* it: Brave, for one, rejects with "push service error" until push messaging
* is switched on in its privacy settings, and "Activation failed" told nobody
* that.
*/
async function subscribeThisBrowser(): Promise<SubscribeOutcome> {
const vapidRes = await apiFetchRaw("/api/notifications/vapid");
if (!vapidRes.ok) return "not-configured";
const vapidData = await vapidRes.json();
const vapidPublicKey = vapidData.data.publicKey;
// Defer to the app-wide registration (`<ServiceWorkerRegistrar>` in the
// root providers) rather than re-registering here — same scope, so a second
// `register("/sw.js")` would only return the existing worker. `ready`
// resolves with the active registration once it is controlling the page.
const registration = await navigator.serviceWorker.ready;
let subscription: PushSubscription;
try {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey),
});
} catch (error) {
return classifyWebPushSubscribeFailure(error, {
permission: Notification.permission,
isBrave: isBraveBrowser(),
});
}
const res = await saveWebPushSubscription(subscription);
return res.ok ? "subscribed" : "save-failed";
}
function subscribeOutcomeMessage(
outcome: SubscribeOutcome,
t: ReturnType<typeof useTranslations>["t"],
): string {
switch (outcome) {
case "subscribed":
return t("settings.webPushSubscribed");
case "not-configured":
return t("settings.webPushNotConfigured");
case "denied":
return t("settings.webPushDenied");
case "push-service":
return t("settings.webPushServiceRefused");
case "push-service-brave":
return t("settings.webPushServiceRefusedBrave");
case "save-failed":
case "unknown":
return t("settings.webPushSubscribeFailed");
}
}
export function WebPushCard() {
const { t } = useTranslations();
const [isSubscribed, setIsSubscribed] = useState(false);
@@ -52,6 +123,7 @@ export function WebPushCard() {
const [actionLoading, setActionLoading] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const [showHelp, setShowHelp] = useState(false);
useEffect(() => {
// Async I/O (browser API + service worker registration). The lint rule
@@ -95,50 +167,21 @@ export function WebPushCard() {
setActionLoading(true);
setMsg(null);
setMsgType(null);
setShowHelp(false);
let outcome: SubscribeOutcome;
try {
const vapidRes = await apiFetchRaw("/api/notifications/vapid");
if (!vapidRes.ok) {
setMsg(t("settings.webPushNotConfigured"));
setMsgType("error");
setActionLoading(false);
return;
}
const vapidData = await vapidRes.json();
const vapidPublicKey = vapidData.data.publicKey;
// Defer to the app-wide registration (`<ServiceWorkerRegistrar>` in
// the root providers) rather than re-registering here — same scope,
// so a second `register("/sw.js")` would only return the existing
// worker. `ready` resolves with the active registration once it is
// controlling the page.
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey),
});
const res = await saveWebPushSubscription(subscription);
if (res.ok) {
setIsSubscribed(true);
setMsg(t("settings.webPushSubscribed"));
setMsgType("success");
} else {
setMsg(t("settings.webPushSubscribeFailed"));
setMsgType("error");
}
outcome = await subscribeThisBrowser();
} catch {
if (Notification.permission === "denied") {
setIsDenied(true);
setMsg(t("settings.webPushDenied"));
} else {
setMsg(t("settings.webPushSubscribeFailed"));
}
setMsgType("error");
} finally {
setActionLoading(false);
outcome = Notification.permission === "denied" ? "denied" : "unknown";
}
if (outcome === "subscribed") setIsSubscribed(true);
if (outcome === "denied") setIsDenied(true);
setMsg(subscribeOutcomeMessage(outcome, t));
setMsgType(outcome === "subscribed" ? "success" : "error");
// The push service refused: the fix is in the browser, so link the docs.
setShowHelp(outcome === "push-service" || outcome === "push-service-brave");
setActionLoading(false);
}
async function handleUnsubscribe() {
@@ -232,6 +275,18 @@ export function WebPushCard() {
{msg}
</p>
)}
{msg && showHelp && (
<InlineAction asChild tone="primary" size="sm">
<a
href={WEB_PUSH_DOCS_HREF}
target="_blank"
rel="noopener noreferrer"
>
{t("settings.webPushHelpLink")}
<ExternalLink aria-hidden="true" />
</a>
</InlineAction>
)}
</div>
) : null}
@@ -0,0 +1,146 @@
/**
* How the Browser Push card reads a refusal from `pushManager.subscribe()`.
*
* The reported case is Brave with push messaging switched off, which rejects
* with `AbortError: Registration failed - push service error` and used to
* show only "Activation failed".
*/
import { afterEach, describe, expect, it } from "vitest";
import {
classifyWebPushSubscribeFailure,
isBraveBrowser,
} from "../web-push-subscribe-failure";
function domError(name: string, message: string): Error {
const error = new Error(message);
error.name = name;
return error;
}
const PUSH_SERVICE_ERROR = domError(
"AbortError",
"Registration failed - push service error",
);
describe("classifyWebPushSubscribeFailure", () => {
it("names the push service when the browser could not register with it", () => {
expect(
classifyWebPushSubscribeFailure(PUSH_SERVICE_ERROR, {
permission: "granted",
isBrave: false,
}),
).toBe("push-service");
});
it("names Brave's setting when the browser is Brave", () => {
expect(
classifyWebPushSubscribeFailure(PUSH_SERVICE_ERROR, {
permission: "granted",
isBrave: true,
}),
).toBe("push-service-brave");
});
it("reads any other AbortError from subscribe as the push service", () => {
// Chromium also says "push service not available"; Firefox says
// "Error retrieving push subscription." Both are the push service.
for (const message of [
"Registration failed - push service not available",
"Error retrieving push subscription.",
]) {
expect(
classifyWebPushSubscribeFailure(domError("AbortError", message), {
permission: "granted",
isBrave: false,
}),
).toBe("push-service");
}
});
it("keeps a blocked permission as blocked, whatever the error said", () => {
expect(
classifyWebPushSubscribeFailure(PUSH_SERVICE_ERROR, {
permission: "denied",
isBrave: true,
}),
).toBe("denied");
});
it("does not blame the push service for a dismissed permission prompt", () => {
expect(
classifyWebPushSubscribeFailure(
domError("NotAllowedError", "Registration failed - permission denied"),
{ permission: "default", isBrave: true },
),
).toBe("unknown");
expect(
classifyWebPushSubscribeFailure(
domError("AbortError", "Registration failed - permission denied"),
{ permission: "default", isBrave: true },
),
).toBe("unknown");
});
it("does not blame the push service for a key the browser would not take", () => {
expect(
classifyWebPushSubscribeFailure(
domError(
"AbortError",
"Registration failed - missing applicationServerKey, and manifest empty or missing",
),
{ permission: "granted", isBrave: true },
),
).toBe("unknown");
expect(
classifyWebPushSubscribeFailure(
domError(
"InvalidAccessError",
"The provided applicationServerKey is not valid.",
),
{ permission: "granted", isBrave: false },
),
).toBe("unknown");
});
it("keeps the generic message for anything that is not an error object", () => {
expect(
classifyWebPushSubscribeFailure("boom", {
permission: "granted",
isBrave: false,
}),
).toBe("unknown");
expect(
classifyWebPushSubscribeFailure(new TypeError("Failed to fetch"), {
permission: "granted",
isBrave: false,
}),
).toBe("unknown");
});
});
describe("isBraveBrowser", () => {
const original = Object.getOwnPropertyDescriptor(globalThis, "navigator");
afterEach(() => {
if (original) Object.defineProperty(globalThis, "navigator", original);
});
function withNavigator(value: unknown): void {
Object.defineProperty(globalThis, "navigator", {
value,
configurable: true,
writable: true,
});
}
it("is true when navigator.brave is present", () => {
withNavigator({ brave: { isBrave: async () => true } });
expect(isBraveBrowser()).toBe(true);
});
it("is false for a Chromium without navigator.brave", () => {
withNavigator({ userAgent: "Mozilla/5.0 Chrome/130" });
expect(isBraveBrowser()).toBe(false);
});
});
@@ -0,0 +1,67 @@
/**
* Why `pushManager.subscribe()` refused, in the terms the Browser Push card
* explains to a person.
*
* Only the browser's own refusal is classified here. A missing VAPID key and
* a failed save are server answers and keep their own messages in the card.
*
* - `denied`: notifications are blocked for this site
* (`Notification.permission === "denied"`), whatever the error said.
* - `push-service` / `push-service-brave`: the browser could not register
* with its push service ("AbortError: Registration failed - push service
* error"). The page did nothing wrong and cannot fix it; the browser has to
* reach its push service. Brave ships with that switched off, which is the
* common case, so Brave gets the setting named.
* - `unknown`: anything else, which keeps the generic message. That includes
* a dismissed permission prompt (`NotAllowedError`, or an `AbortError`
* about permission in older Chromium builds): the permission is still
* "default", the person can try again, and calling it a push-service fault
* would send them to the wrong setting.
*/
export type WebPushSubscribeFailure =
"denied" | "push-service" | "push-service-brave" | "unknown";
export interface WebPushSubscribeContext {
/** `Notification.permission` after the attempt. */
permission: NotificationPermission;
/** The browser announces itself as Brave (`navigator.brave`). */
isBrave: boolean;
}
function errorName(error: unknown): string {
return typeof error === "object" && error !== null && "name" in error
? String((error as { name: unknown }).name)
: "";
}
function errorMessage(error: unknown): string {
return typeof error === "object" && error !== null && "message" in error
? String((error as { message: unknown }).message)
: "";
}
export function classifyWebPushSubscribeFailure(
error: unknown,
{ permission, isBrave }: WebPushSubscribeContext,
): WebPushSubscribeFailure {
const name = errorName(error);
const message = errorMessage(error);
if (permission === "denied") return "denied";
if (name !== "AbortError") return "unknown";
if (/permission/i.test(message)) return "unknown";
// A key the server handed out that the browser would not take is the
// server's problem, not the push service's.
if (/applicationServerKey/i.test(message)) return "unknown";
return isBrave ? "push-service-brave" : "push-service";
}
/**
* Brave exposes `navigator.brave` so a page can tell it apart from Chrome,
* whose user agent it otherwise copies.
*/
export function isBraveBrowser(): boolean {
if (typeof navigator === "undefined") return false;
const brave = (navigator as Navigator & { brave?: unknown }).brave;
return typeof brave === "object" && brave !== null;
}