1. Summary of changes
The request assumed a push-notification subsystem needed to be built — it already existed in full: the PushSubscription model, subscribe/unsubscribe service and Server Actions, the PushSubscribeToggle opt-in UI, WebPushProvider, and public/sw.js's push/notificationclick handlers were all already implemented and tested. The one real gap: NEXT_PUBLIC_VAPID_PUBLIC_KEY is a Next.js build-time-inlined variable, but every business runs the same prebuilt shared image — so on the actual deployment architecture, every business's browser bundle has always shipped with this key baked in empty, regardless of that business's .env. This was a genuine pre-existing bug, and centralizing VAPID globally is the fix for it, not just a tidiness improvement.
Added a Push Notifications section to the Manager's Settings page, mirroring the SMTP architecture exactly: same JSON storage, same MANAGER_SETTINGS_ENCRYPTION_KEY encryption, same rollout mechanism.
Keys are generate-only (web-push.generateVAPIDKeys()) — no hand-pasted key fields, avoiding a mismatched pair that would silently break every push send.
Regeneration requires explicit confirmation with a clear warning about the impact, shown live and verified in-browser.
Replaced the broken NEXT_PUBLIC_VAPID_PUBLIC_KEY mechanism with a new runtime API route, GET /api/push/vapid-public-key — this is what actually makes centralized VAPID config reach the browser, verified live end-to-end.
Fixed a related bug in web-push-provider.ts: a 401/403 (VAPID signature mismatch — exactly what happens to every subscription after a key rotation) wasn't being treated as an invalid subscription, so rotated-out subscriptions would have failed forever without ever being cleaned up.
Two additional real bugs found and fixed during live verification (not just written correctly on paper): the settings forms weren't refreshing their server-fetched data after a successful save, so the Public/Private Key fields and the "Reapply" button stayed stale-looking immediately after a successful Generate.
No Prisma/schema changes — the existing model already satisfied every field in the request.
2. Architecture used for sharing VAPID configuration
Identical to SMTP: Manager is the one place a human ever generates the key pair; it's stored in stacks/manager/global-settings.json, then pushed into every business's .env (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT, PUSH_PROVIDER="web-push") via the same upsertEnvValues + composeUp(dir, {forceRecreate:true}) mechanism, verified live to actually rewrite a scratch business's .env and recreate its container. New businesses get it baked in at provisioning time automatically.
3. How and where the VAPID private key is stored
AES-256-GCM encrypted (same key and mechanism as the SMTP password) inside stacks/manager/global-settings.json. Verified directly: the file contains an {iv, authTag, ciphertext} envelope, never plaintext. Never returned to the Manager UI after generation — only a boolean hasPrivateKey.
4. Prisma/database changes
None. The existing PushSubscription model already covers every field requested.
5. Service worker/PWA changes
None to the service worker itself — public/sw.js's push/notificationclick handlers already worked correctly. The only client change is push-subscribe-toggle.tsx, which now fetches the public key from the new API route instead of a dead build-time constant, and gained a new "unconfigured" state shown up front (before a wasted permission prompt) when push genuinely isn't configured.
6. How tenant push subscriptions are stored
Unchanged, and already correct: each business has its own separate database (confirmed throughout this session's work), so tenant isolation is enforced at the infrastructure level, not just a businessId column — there's no query path from one business's container into another's subscriptions at all.
7. How multiple devices are handled
Unchanged — already correct and already tested (endpoint unique, upsert-on-resubscribe, per-user userId/customerId fan-out in reminder-worker.ts).
8. What happens when a subscription becomes invalid
Unchanged for 404/410. Newly fixed: 401/403 (VAPID mismatch, e.g. after a key rotation) is now also treated as subscriptionInvalid — the stale subscription gets revoked automatically on the next delivery attempt rather than failing silently forever.
9. What happens if VAPID keys are regenerated
Documented in the Manager UI's own confirmation warning (verified live): every existing browser subscription across every business stops receiving pushes, and each device needs to click "Enable Push Notifications" again — but nothing needs to be manually cleaned up, since the app now detects and revokes the broken subscriptions automatically (see #8).
10. Files changed
New: manager/src/lib/push-rollout.ts, manager/src/app/dashboard/settings/push-settings-form.tsx, src/app/api/push/vapid-public-key/route.ts + its test.
Modified: manager/src/lib/global-settings.ts (push section + a fix ensuring email/push sections never clobber each other), manager/src/lib/provisioning.ts, manager/src/lib/compose-template.ts, manager/src/app/dashboard/settings/{actions.ts,page.tsx,email-settings-form.tsx} (the last one gained the same router.refresh() fix), push-subscribe-toggle.tsx, web-push-provider.ts + its test, .env.example, .env.business.example, manager/package.json/package-lock.json (added web-push).
11. New environment variables
None new. MANAGER_SETTINGS_ENCRYPTION_KEY already exists from the SMTP work and is reused. NEXT_PUBLIC_VAPID_PUBLIC_KEY is removed (dead — superseded by the API route).
12. VPS deployment/update steps
Rebuild/redeploy both images as usual (no build-arg needed, unlike a naive fix would have required). Open Manager → Settings → Push Notifications, set a contact email, click "Generate VAPID Keys" — this automatically rolls out to every existing business. Nothing else needs to run by hand.
13. Manual testing steps
EMAIL_PROVIDER=console PUSH_PROVIDER=console npm run dev
Generate keys in Manager, confirm the overwrite-warning on a second attempt, confirm a business's .env gains the 4 keys. Hit curl localhost:PORT/api/push/vapid-public-key — confirmed live to return exactly the configured public key. Sign in to a business and visit Settings → Notifications — confirmed live the toggle correctly reaches the real permission-check step (proving the key delivery works) rather than showing "not configured."
The global VAPID / Web Push feature is already fully designed, implemented, and verified — all three research agents finished, I synthesized their findings, wrote the plan, got it approved, built it, found and fixed three real bugs along the way (the broken build-time public-key delivery, the missing 401/403 invalidation handling, and the stale-form-data-after-save issue in both settings forms), and delivered the full report above. That work is done.
This commit is contained in:
17 files changed
+868
-36
No files matched your search
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { subscribeToPushAction, unsubscribeFromPushAction } from "../actions/push-subscriptions";
|
||||
import { urlBase64ToUint8Array } from "../lib/vapid";
|
||||
|
||||
type Status = "checking" | "unsupported" | "subscribed" | "not-subscribed" | "denied";
|
||||
type Status = "checking" | "unsupported" | "unconfigured" | "subscribed" | "not-subscribed" | "denied";
|
||||
|
||||
/**
|
||||
* The one control that ever asks for push permission — deliberately not
|
||||
@@ -19,6 +19,26 @@ function isPushSupported(): boolean {
|
||||
return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetched at runtime from the app's own API route rather than read from
|
||||
* `process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY` — every business shares one
|
||||
* prebuilt image (see manager/src/lib/compose-template.ts), so a
|
||||
* `NEXT_PUBLIC_*` var would be inlined at build time identically (and
|
||||
* blank) for every business regardless of its own `.env`. See
|
||||
* src/app/api/push/vapid-public-key/route.ts's own header for the full
|
||||
* reasoning.
|
||||
*/
|
||||
async function fetchVapidPublicKey(): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch("/api/push/vapid-public-key");
|
||||
if (!response.ok) return null;
|
||||
const data = (await response.json()) as { publicKey: string | null };
|
||||
return data.publicKey;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function PushSubscribeToggle() {
|
||||
// Always starts as "checking" (renders nothing) on both server and
|
||||
// client — `isPushSupported`/`Notification.permission` are
|
||||
@@ -29,6 +49,11 @@ export function PushSubscribeToggle() {
|
||||
const [status, setStatus] = useState<Status>("checking");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
// Cached from the one fetch in the effect below, so `subscribe()`
|
||||
// doesn't need to re-fetch it (and so the "not configured" state can be
|
||||
// shown up front, before the user ever clicks Enable — no wasted
|
||||
// permission prompt for a business that has no key configured).
|
||||
const vapidPublicKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "checking") return;
|
||||
@@ -37,6 +62,11 @@ export function PushSubscribeToggle() {
|
||||
Promise.resolve()
|
||||
.then(async () => {
|
||||
if (!isPushSupported()) return "unsupported" as const;
|
||||
|
||||
const vapidPublicKey = await fetchVapidPublicKey();
|
||||
vapidPublicKeyRef.current = vapidPublicKey;
|
||||
if (!vapidPublicKey) return "unconfigured" as const;
|
||||
|
||||
if (Notification.permission === "denied") return "denied" as const;
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
const sub = await reg?.pushManager.getSubscription();
|
||||
@@ -58,9 +88,9 @@ export function PushSubscribeToggle() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
||||
const vapidPublicKey = vapidPublicKeyRef.current ?? (await fetchVapidPublicKey());
|
||||
if (!vapidPublicKey) {
|
||||
setError("Push notifications aren't configured for this business yet.");
|
||||
setStatus("unconfigured");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -119,6 +149,10 @@ export function PushSubscribeToggle() {
|
||||
return <p className="text-muted text-sm">Push notifications aren't supported in this browser.</p>;
|
||||
}
|
||||
|
||||
if (status === "unconfigured") {
|
||||
return <p className="text-muted text-sm">Push notifications aren't configured for this business yet.</p>;
|
||||
}
|
||||
|
||||
if (status === "denied") {
|
||||
return (
|
||||
<p className="text-muted text-sm">
|
||||
|
||||
Reference in new issue
Block a user