Files
coder/site/src/contexts/useWebpushNotifications.ts
T
Kyle Carberry 4ee5306eca fix(site): request notification permission before push subscription (#22576)
## Problem

The subscribe flow in `useWebpushNotifications` called
`pushManager.subscribe()` without first requesting the `Notification`
permission. When the browser permission state is `"denied"` (e.g. from a
previous prompt dismissal), the browser throws:

```
DOMException: Registration failed - permission denied
```

This surfaced as a confusing error toast on the agents page. The error
has nothing to do with Coder RBAC roles — it's the browser denying the
push subscription because notification permission was previously
declined. An admin who had granted browser permission wouldn't see this;
a user who previously dismissed or denied the prompt would.

## Fix

Added an explicit `Notification.requestPermission()` call before
`pushManager.subscribe()`. This:

1. **Re-prompts** the user if the permission state is `"default"` (not
yet decided)
2. **Throws a clear, actionable error** if the permission is `"denied"`:
*"Notifications are blocked by your browser. Please allow notifications
for this site in your browser settings."*
3. **Only proceeds** to `pushManager.subscribe()` after permission is
confirmed as `"granted"`

## Tests

New test file `useWebpushNotifications.jest.ts`:
- **requests notification permission before subscribing** — verifies
`requestPermission()` is called before `pushManager.subscribe()`
- **throws a clear error when permission is denied** — verifies the
user-friendly error message
- **does not call pushManager.subscribe when permission is denied** —
verifies we bail out early
2026-03-03 17:13:31 -05:00

127 lines
3.6 KiB
TypeScript

import { API } from "api/api";
import { buildInfo } from "api/queries/buildInfo";
import { experiments } from "api/queries/experiments";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { useEffect, useState } from "react";
import { useQuery } from "react-query";
interface WebpushNotifications {
readonly enabled: boolean;
readonly subscribed: boolean;
readonly loading: boolean;
subscribe(): Promise<void>;
unsubscribe(): Promise<void>;
}
export const useWebpushNotifications = (): WebpushNotifications => {
const { metadata } = useEmbeddedMetadata();
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
const enabledExperimentsQuery = useQuery(experiments(metadata.experiments));
const [subscribed, setSubscribed] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
const [enabled, setEnabled] = useState<boolean>(false);
useEffect(() => {
// Check if the experiment is enabled.
if (enabledExperimentsQuery.data?.includes("web-push")) {
setEnabled(true);
}
// Check if browser supports push notifications
if (!("Notification" in window) || !("serviceWorker" in navigator)) {
setSubscribed(false);
setLoading(false);
return;
}
const checkSubscription = async () => {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
setSubscribed(!!subscription);
} catch (error) {
console.error("Error checking push subscription:", error);
setSubscribed(false);
} finally {
setLoading(false);
}
};
checkSubscription();
}, [enabledExperimentsQuery.data]);
const subscribe = async (): Promise<void> => {
try {
setLoading(true);
// Explicitly request notification permission before subscribing
// to the push manager. Without this, pushManager.subscribe()
// throws a generic "Registration failed - permission denied"
// DOMException when the permission state is "denied", which
// gives no opportunity to show a clear message to the user.
const permission = await Notification.requestPermission();
if (permission !== "granted") {
throw new Error(
"Notifications are blocked by your browser. Please allow notifications for this site in your browser settings.",
);
}
const registration = await navigator.serviceWorker.ready;
const vapidPublicKey = buildInfoQuery.data?.webpush_public_key;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidPublicKey,
});
const json = subscription.toJSON();
if (!json.keys || !json.endpoint) {
throw new Error("No keys or endpoint found");
}
await API.createWebPushSubscription("me", {
endpoint: json.endpoint,
auth_key: json.keys.auth,
p256dh_key: json.keys.p256dh,
});
setSubscribed(true);
} catch (error) {
console.error("Subscription failed:", error);
throw error;
} finally {
setLoading(false);
}
};
const unsubscribe = async (): Promise<void> => {
try {
setLoading(true);
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await API.deleteWebPushSubscription("me", {
endpoint: subscription.endpoint,
});
await subscription.unsubscribe();
setSubscribed(false);
}
} catch (error) {
console.error("Unsubscription failed:", error);
throw error;
} finally {
setLoading(false);
}
};
return {
subscribed,
enabled,
loading: loading || buildInfoQuery.isLoading,
subscribe,
unsubscribe,
};
};