mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
## 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
127 lines
3.6 KiB
TypeScript
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,
|
|
};
|
|
};
|