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
Generated
+149
-4
@@ -13,7 +13,8 @@
|
||||
"next": "^16.2.12",
|
||||
"nodemailer": "^8.0.11",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
"react-dom": "^19.2.8",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
@@ -22,6 +23,7 @@
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^16.2.12",
|
||||
"tailwindcss": "^4.3.3",
|
||||
@@ -1765,6 +1767,16 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/web-push": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz",
|
||||
@@ -2437,6 +2449,15 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
@@ -2647,6 +2668,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types-flow": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
||||
@@ -2728,6 +2761,12 @@
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.5",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
|
||||
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
@@ -2786,6 +2825,12 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
@@ -3010,7 +3055,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -3114,6 +3158,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.422",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz",
|
||||
@@ -4220,6 +4273,28 @@
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
@@ -4273,6 +4348,12 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
@@ -4854,6 +4935,27 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -5286,6 +5388,12 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
@@ -5303,7 +5411,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -5313,7 +5420,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@@ -5990,6 +6096,26 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-push-apply": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||
@@ -6834,6 +6960,25 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"next": "^16.2.12",
|
||||
"nodemailer": "^8.0.11",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
"react-dom": "^19.2.8",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
@@ -25,6 +26,7 @@
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^16.2.12",
|
||||
"tailwindcss": "^4.3.3",
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
"use server";
|
||||
|
||||
import { applyEmailSettingsToAllBusinesses, type EmailRolloutResult } from "@/lib/email-rollout";
|
||||
import { applyPushSettingsToAllBusinesses, type PushRolloutResult } from "@/lib/push-rollout";
|
||||
import {
|
||||
generateVapidKeys,
|
||||
isEmailConfigComplete,
|
||||
isPushConfigComplete,
|
||||
recordLastTestResult,
|
||||
resolveEmailSettings,
|
||||
resolvePushSettings,
|
||||
saveEmailSettings,
|
||||
savePushSubject,
|
||||
VapidKeysAlreadyExistError,
|
||||
type EmailEncryptionMode,
|
||||
type EmailSettingsInput,
|
||||
} from "@/lib/global-settings";
|
||||
@@ -141,3 +147,94 @@ export async function sendTestEmailAction(
|
||||
|
||||
return { error: null, result };
|
||||
}
|
||||
|
||||
// ── Push / VAPID ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface GenerateVapidKeysState {
|
||||
error: string | null;
|
||||
needsConfirmation: boolean;
|
||||
generated: boolean;
|
||||
rollout: PushRolloutResult[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a brand-new VAPID key pair. If one already exists, the first
|
||||
* call (no `confirmOverwrite` field in the form) is rejected with
|
||||
* `needsConfirmation: true` instead of an error — the form re-renders a
|
||||
* warning + a second, explicit "Yes, generate new keys" button (which
|
||||
* submits again with `confirmOverwrite=true`) rather than ever
|
||||
* regenerating silently. See global-settings.ts's generateVapidKeys for
|
||||
* why keys are generate-only (no hand-pasted key fields exist).
|
||||
*/
|
||||
export async function generateVapidKeysAction(
|
||||
_prevState: GenerateVapidKeysState,
|
||||
formData: FormData,
|
||||
): Promise<GenerateVapidKeysState> {
|
||||
const subject = String(formData.get("subject") ?? "").trim();
|
||||
const confirmOverwrite = formData.get("confirmOverwrite") === "true";
|
||||
|
||||
if (subject && !/^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/.test(subject)) {
|
||||
return { error: "Enter a valid contact address, e.g. mailto:support@example.com.", needsConfirmation: false, generated: false, rollout: null };
|
||||
}
|
||||
|
||||
let resolved;
|
||||
try {
|
||||
resolved = await generateVapidKeys({ subject, confirmOverwrite });
|
||||
} catch (error) {
|
||||
if (error instanceof VapidKeysAlreadyExistError) {
|
||||
return { error: null, needsConfirmation: true, generated: false, rollout: null };
|
||||
}
|
||||
return { error: error instanceof Error ? error.message : "Couldn't generate VAPID keys.", needsConfirmation: false, generated: false, rollout: null };
|
||||
}
|
||||
|
||||
if (!isPushConfigComplete(resolved)) {
|
||||
// No subject yet — saved as a draft, nothing rolled out (mirrors
|
||||
// saveEmailSettingsAction's incomplete-config behavior).
|
||||
return { error: null, needsConfirmation: false, generated: true, rollout: null };
|
||||
}
|
||||
|
||||
const rollout = await applyPushSettingsToAllBusinesses(resolved);
|
||||
return { error: null, needsConfirmation: false, generated: true, rollout };
|
||||
}
|
||||
|
||||
export interface SavePushSubjectState {
|
||||
error: string | null;
|
||||
saved: boolean;
|
||||
rollout: PushRolloutResult[] | null;
|
||||
}
|
||||
|
||||
export async function savePushSubjectAction(
|
||||
_prevState: SavePushSubjectState,
|
||||
formData: FormData,
|
||||
): Promise<SavePushSubjectState> {
|
||||
const subject = String(formData.get("subject") ?? "").trim();
|
||||
if (!/^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/.test(subject)) {
|
||||
return { error: "Enter a valid contact address, e.g. mailto:support@example.com.", saved: false, rollout: null };
|
||||
}
|
||||
|
||||
const resolved = await savePushSubject(subject);
|
||||
if (!resolved || !isPushConfigComplete(resolved)) {
|
||||
// No key pair generated yet — the subject is saved for next time, but there's nothing to roll out.
|
||||
return { error: null, saved: true, rollout: null };
|
||||
}
|
||||
|
||||
const rollout = await applyPushSettingsToAllBusinesses(resolved);
|
||||
return { error: null, saved: true, rollout };
|
||||
}
|
||||
|
||||
export interface ReapplyPushSettingsState {
|
||||
error: string | null;
|
||||
rollout: PushRolloutResult[] | null;
|
||||
}
|
||||
|
||||
export async function reapplyPushSettingsAction(
|
||||
_prevState: ReapplyPushSettingsState,
|
||||
_formData: FormData,
|
||||
): Promise<ReapplyPushSettingsState> {
|
||||
const resolved = await resolvePushSettings();
|
||||
if (!resolved || !isPushConfigComplete(resolved)) {
|
||||
return { error: "Push settings aren't fully configured yet — nothing to reapply.", rollout: null };
|
||||
}
|
||||
const rollout = await applyPushSettingsToAllBusinesses(resolved);
|
||||
return { error: null, rollout };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import type { EmailEncryptionMode, EmailSettingsView } from "@/lib/global-settings";
|
||||
import {
|
||||
reapplyEmailSettingsAction,
|
||||
@@ -60,10 +61,22 @@ function StatusBadge({ initial, saveState, testState }: { initial: EmailSettings
|
||||
* clicking "Save"). Controlled state survives that re-render untouched.
|
||||
*/
|
||||
export function EmailSettingsForm({ initial }: { initial: EmailSettingsView }) {
|
||||
const router = useRouter();
|
||||
const [saveState, saveAction, isSaving] = useActionState(saveEmailSettingsAction, saveInitialState);
|
||||
const [testState, testAction, isTesting] = useActionState(sendTestEmailAction, testInitialState);
|
||||
const [reapplyState, reapplyAction, isReapplying] = useActionState(reapplyEmailSettingsAction, reapplyInitialState);
|
||||
|
||||
// `initial` is a server-fetched snapshot from page load — after a
|
||||
// successful save, refresh it so fields that reflect *stored* state
|
||||
// (hasPassword's placeholder text, the Reapply button's disabled state)
|
||||
// don't keep showing the pre-save snapshot even though the save
|
||||
// genuinely succeeded (confirmed live: without this, "Reapply to all
|
||||
// businesses" stayed disabled and the password hint still said "No
|
||||
// password saved yet." right after a successful first save).
|
||||
useEffect(() => {
|
||||
if (saveState.saved) router.refresh();
|
||||
}, [saveState, router]);
|
||||
|
||||
const [host, setHost] = useState(initial.host);
|
||||
const [port, setPort] = useState(initial.port);
|
||||
const [username, setUsername] = useState(initial.username);
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import { readEmailSettingsView } from "@/lib/global-settings";
|
||||
import { readEmailSettingsView, readPushSettingsView } from "@/lib/global-settings";
|
||||
import { EmailSettingsForm } from "./email-settings-form";
|
||||
import { PushSettingsForm } from "./push-settings-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Settings" };
|
||||
|
||||
/**
|
||||
* Platform-wide settings shared by every business on this installation —
|
||||
* see src/lib/global-settings.ts's own header for the storage design.
|
||||
* Only "Email" exists today; a later section (SMS, storage, backups,
|
||||
* branding, locale, monitoring) is a sibling here, not a rewrite.
|
||||
* "Email" and "Push Notifications" exist today; a later section (SMS,
|
||||
* storage, backups, branding, locale, monitoring) is a sibling here, not
|
||||
* a rewrite.
|
||||
*/
|
||||
export default async function SettingsPage() {
|
||||
const email = await readEmailSettingsView();
|
||||
const [email, push] = await Promise.all([readEmailSettingsView(), readPushSettingsView()]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -20,6 +22,7 @@ export default async function SettingsPage() {
|
||||
<p className="text-sm text-muted">Platform-wide configuration shared by every business on this installation.</p>
|
||||
</div>
|
||||
<EmailSettingsForm initial={email} />
|
||||
<PushSettingsForm initial={push} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import type { PushSettingsView } from "@/lib/global-settings";
|
||||
import {
|
||||
generateVapidKeysAction,
|
||||
reapplyPushSettingsAction,
|
||||
savePushSubjectAction,
|
||||
type GenerateVapidKeysState,
|
||||
type ReapplyPushSettingsState,
|
||||
type SavePushSubjectState,
|
||||
} from "./actions";
|
||||
|
||||
const generateInitialState: GenerateVapidKeysState = { error: null, needsConfirmation: false, generated: false, rollout: null };
|
||||
const saveInitialState: SavePushSubjectState = { error: null, saved: false, rollout: null };
|
||||
const reapplyInitialState: ReapplyPushSettingsState = { error: null, rollout: null };
|
||||
|
||||
function StatusBadge({ initial, generateState, saveState }: { initial: PushSettingsView; generateState: GenerateVapidKeysState; saveState: SavePushSubjectState }) {
|
||||
const isConfigured =
|
||||
(generateState.generated && generateState.rollout !== null) ||
|
||||
(saveState.saved && saveState.rollout !== null) ||
|
||||
(initial.hasPrivateKey && Boolean(initial.subject));
|
||||
return isConfigured ? (
|
||||
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Configured</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-stone px-3 py-1 text-xs font-semibold text-muted">Not configured</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* No hand-pasted key fields — see global-settings.ts's generateVapidKeys
|
||||
* for why (a mismatched pair would let browsers subscribe successfully
|
||||
* while every server-side send silently fails). All inputs are controlled
|
||||
* (useState), same reasoning as email-settings-form.tsx: this form has
|
||||
* three separate actions sharing one Subject field, and an uncontrolled
|
||||
* `defaultValue` input would reset after any one of them resolves.
|
||||
*
|
||||
* No `<Modal>` here — Manager has no shared dialog primitive (no UI kit
|
||||
* at all, by design; see edit-business-form.tsx's plain-HTML convention).
|
||||
* The overwrite-protection ask is met with an inline warning panel
|
||||
* instead: "Generate" is rejected server-side the first time
|
||||
* (`needsConfirmation: true`) rather than acting immediately, and only a
|
||||
* second, explicit click on "Yes, Generate New Keys" resubmits with
|
||||
* `confirmOverwrite=true`.
|
||||
*/
|
||||
export function PushSettingsForm({ initial }: { initial: PushSettingsView }) {
|
||||
const router = useRouter();
|
||||
const [generateState, generateAction, isGenerating] = useActionState(generateVapidKeysAction, generateInitialState);
|
||||
const [saveState, saveAction, isSaving] = useActionState(savePushSubjectAction, saveInitialState);
|
||||
const [reapplyState, reapplyAction, isReapplying] = useActionState(reapplyPushSettingsAction, reapplyInitialState);
|
||||
|
||||
// See email-settings-form.tsx's identical effect for why: `initial` is
|
||||
// a server snapshot from page load, and fields like the Public
|
||||
// Key/Private Key display and the Reapply button's disabled state
|
||||
// depend on it directly — without refreshing, they'd keep showing
|
||||
// "Not generated yet" even immediately after a successful Generate.
|
||||
useEffect(() => {
|
||||
if (generateState.generated || saveState.saved) router.refresh();
|
||||
}, [generateState, saveState, router]);
|
||||
|
||||
const [subject, setSubject] = useState(initial.subject || "mailto:");
|
||||
const [confirmOverwrite, setConfirmOverwrite] = useState(false);
|
||||
|
||||
const hasExistingKeys = initial.hasPrivateKey || generateState.generated;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Push Notifications</h2>
|
||||
<StatusBadge initial={initial} generateState={generateState} saveState={saveState} />
|
||||
</div>
|
||||
<p className="text-sm text-muted">
|
||||
Shared by every business on this installation — lets any business send browser push notifications without
|
||||
generating its own key pair. Individual businesses and users still separately control whether they use push
|
||||
at all; this only controls whether the platform is technically able to.
|
||||
</p>
|
||||
|
||||
<form className="space-y-4 rounded-md border border-line bg-surface p-5">
|
||||
<div>
|
||||
<label htmlFor="publicKey" className="block text-sm font-medium text-muted">
|
||||
VAPID Public Key
|
||||
</label>
|
||||
<input
|
||||
id="publicKey"
|
||||
type="text"
|
||||
readOnly
|
||||
value={initial.publicKey || "Not generated yet"}
|
||||
className="mt-1 w-full rounded-md border border-line bg-stone px-3 py-2 font-mono text-xs text-muted"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">Not a secret — browsers need this to create a push subscription.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="privateKeyDisplay" className="block text-sm font-medium text-muted">
|
||||
VAPID Private Key
|
||||
</label>
|
||||
<input
|
||||
id="privateKeyDisplay"
|
||||
type="text"
|
||||
readOnly
|
||||
value={initial.hasPrivateKey ? "•••••••••••••••••••••••••••••••••••••" : "Not generated yet"}
|
||||
className="mt-1 w-full rounded-md border border-line bg-stone px-3 py-2 font-mono text-xs text-muted"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">Never shown, logged, or returned to the browser once generated.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="subject" className="block text-sm font-medium text-muted">
|
||||
Contact / Subject Email
|
||||
</label>
|
||||
<input
|
||||
id="subject"
|
||||
name="subject"
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="mailto:support@example.com"
|
||||
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
A contact address push services may use if there's an abuse concern — must start with{" "}
|
||||
<code>mailto:</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{generateState.error && (
|
||||
<p role="alert" className="text-sm font-medium text-danger">
|
||||
{generateState.error}
|
||||
</p>
|
||||
)}
|
||||
{saveState.error && (
|
||||
<p role="alert" className="text-sm font-medium text-danger">
|
||||
{saveState.error}
|
||||
</p>
|
||||
)}
|
||||
{(generateState.generated || saveState.saved) && !generateState.error && !saveState.error && (
|
||||
<div className="text-sm text-green-700">
|
||||
{(() => {
|
||||
const rollout = generateState.rollout ?? saveState.rollout;
|
||||
if (rollout === null) return <p>Saved as a draft — add a contact address and keys to enable delivery.</p>;
|
||||
return (
|
||||
<p>
|
||||
Applied to {rollout.filter((r) => r.ok).length} of {rollout.length} business
|
||||
{rollout.length === 1 ? "" : "es"}.
|
||||
{rollout.some((r) => !r.ok) && (
|
||||
<> Failed: {rollout.filter((r) => !r.ok).map((r) => `${r.slug} (${r.error})`).join(", ")}</>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generateState.needsConfirmation && (
|
||||
<div className="space-y-3 rounded-md border border-amber-300 bg-amber-50 p-4">
|
||||
<p className="text-sm font-semibold text-amber-900">A VAPID key pair already exists.</p>
|
||||
<p className="text-sm text-amber-900">
|
||||
Every browser push subscription across every business was created using the current public key.
|
||||
Generating a new pair will make those subscriptions stop receiving pushes — each device will need to
|
||||
re-enable push notifications afterward (the app already detects and cleans up the broken subscriptions
|
||||
automatically the next time a push is attempted; nothing needs to be deleted by hand).
|
||||
</p>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-amber-900">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="confirmOverwrite"
|
||||
value="true"
|
||||
checked={confirmOverwrite}
|
||||
onChange={(e) => setConfirmOverwrite(e.target.checked)}
|
||||
/>
|
||||
I understand this will require every existing subscription to be re-created.
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
formAction={generateAction}
|
||||
disabled={isGenerating || (generateState.needsConfirmation && !confirmOverwrite)}
|
||||
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{isGenerating
|
||||
? "Generating…"
|
||||
: generateState.needsConfirmation
|
||||
? "Yes, Generate New Keys"
|
||||
: hasExistingKeys
|
||||
? "Regenerate VAPID Keys"
|
||||
: "Generate VAPID Keys"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
formAction={saveAction}
|
||||
disabled={isSaving}
|
||||
className="rounded-md border border-line px-4 py-2 text-sm font-semibold disabled:opacity-60"
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save Changes"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
formAction={reapplyAction}
|
||||
disabled={isReapplying || !initial.hasPrivateKey}
|
||||
title={!initial.hasPrivateKey ? "Generate keys first" : undefined}
|
||||
className="ml-auto text-sm font-medium text-muted hover:text-ink disabled:opacity-40"
|
||||
>
|
||||
{isReapplying ? "Reapplying…" : "Reapply to all businesses"}
|
||||
</button>
|
||||
</div>
|
||||
{reapplyState.error && (
|
||||
<p role="alert" className="text-sm font-medium text-danger">
|
||||
{reapplyState.error}
|
||||
</p>
|
||||
)}
|
||||
{reapplyState.rollout && (
|
||||
<p className="text-sm text-green-700">
|
||||
Reapplied to {reapplyState.rollout.filter((r) => r.ok).length} of {reapplyState.rollout.length} business
|
||||
{reapplyState.rollout.length === 1 ? "" : "es"}.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,12 @@ export interface BusinessEnvEmailValues {
|
||||
replyTo: string;
|
||||
}
|
||||
|
||||
export interface BusinessEnvPushValues {
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
export interface BusinessEnvValues {
|
||||
name: string;
|
||||
domain: string;
|
||||
@@ -75,6 +81,8 @@ export interface BusinessEnvValues {
|
||||
* provisioned before global settings exist behaves exactly as before.
|
||||
*/
|
||||
email?: BusinessEnvEmailValues;
|
||||
/** Global platform VAPID/Web Push settings (see src/lib/global-settings.ts) — same pattern as `email` above. Omitted falls back to today's blank/"console" defaults. */
|
||||
push?: BusinessEnvPushValues;
|
||||
}
|
||||
|
||||
/** Same shape as .env.business.example, filled in with generated/collected values instead of placeholders. */
|
||||
@@ -100,6 +108,17 @@ EMAIL_FROM_ADDRESS=""
|
||||
EMAIL_FROM_NAME=""
|
||||
EMAIL_REPLY_TO=""`;
|
||||
|
||||
const push = values.push;
|
||||
const pushBlock = push
|
||||
? `PUSH_PROVIDER="web-push"
|
||||
VAPID_PUBLIC_KEY="${push.publicKey}"
|
||||
VAPID_PRIVATE_KEY="${push.privateKey}"
|
||||
VAPID_SUBJECT="${push.subject}"`
|
||||
: `PUSH_PROVIDER="console"
|
||||
VAPID_PUBLIC_KEY=""
|
||||
VAPID_PRIVATE_KEY=""
|
||||
VAPID_SUBJECT="mailto:owner@example.com"`;
|
||||
|
||||
return `# Generated by the management UI. See .env.business.example for what
|
||||
# each of these does.
|
||||
# BUSINESS_NAME is Manager-only bookkeeping (shown on the dashboard/edit
|
||||
@@ -140,11 +159,13 @@ GOOGLE_PLACES_API_KEY=""
|
||||
# to hand-edit these for one business only.
|
||||
${emailBlock}
|
||||
|
||||
PUSH_PROVIDER="console"
|
||||
VAPID_PUBLIC_KEY=""
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
|
||||
VAPID_PRIVATE_KEY=""
|
||||
VAPID_SUBJECT="mailto:owner@example.com"
|
||||
# Managed centrally — see the Manager's Settings > Push Notifications page.
|
||||
# NEXT_PUBLIC_VAPID_PUBLIC_KEY intentionally does not exist here — the app
|
||||
# serves its public key at runtime via GET /api/push/vapid-public-key
|
||||
# instead (a build-time NEXT_PUBLIC_* var can't work here: every business
|
||||
# shares one prebuilt image, so it would be baked in identically — and
|
||||
# blank — for all of them).
|
||||
${pushBlock}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import webpush from "web-push";
|
||||
import { stacksRoot } from "./stacks";
|
||||
|
||||
/**
|
||||
@@ -15,10 +16,13 @@ import { stacksRoot } from "./stacks";
|
||||
* restarts/redeploys with zero new volumes — the same guarantee every
|
||||
* business's own `.env` already relies on.
|
||||
*
|
||||
* Shaped as `{ email: EmailSettings | null, ... }` so a later section
|
||||
* (SMS, storage, backups, branding, locale, monitoring) is just one more
|
||||
* top-level key sharing these same read/write/encrypt helpers, not a new
|
||||
* storage decision each time.
|
||||
* Shaped as `{ email: EmailSettings | null, push: PushSettings | null, ... }`
|
||||
* so a later section (SMS, storage, backups, branding, locale, monitoring)
|
||||
* is just one more top-level key sharing these same read/write/encrypt
|
||||
* helpers, not a new storage decision each time. Every write function
|
||||
* spreads `current` before overriding its own key — deliberately, so
|
||||
* saving one section (e.g. email) can never clobber another (e.g. push)
|
||||
* that happens to already be stored in the same file.
|
||||
*/
|
||||
|
||||
export type EmailEncryptionMode = "none" | "starttls" | "tls";
|
||||
@@ -36,8 +40,17 @@ interface StoredEmailSettings {
|
||||
lastTest: LastTestResult | null;
|
||||
}
|
||||
|
||||
/** Persisted shape — the private key field only ever holds the encrypted envelope, never plaintext. */
|
||||
interface StoredPushSettings {
|
||||
publicKey: string;
|
||||
encryptedPrivateKey: EncryptedPayload | null;
|
||||
subject: string;
|
||||
generatedAt: string | null;
|
||||
}
|
||||
|
||||
interface StoredGlobalSettings {
|
||||
email: StoredEmailSettings | null;
|
||||
push: StoredPushSettings | null;
|
||||
}
|
||||
|
||||
export interface LastTestResult {
|
||||
@@ -83,6 +96,21 @@ export interface ResolvedEmailSettings {
|
||||
replyTo: string;
|
||||
}
|
||||
|
||||
/** Safe-to-render shape — never carries the real private key, only whether one is set. The public key is NOT a secret (browsers need it to create subscriptions) and is shown in full. */
|
||||
export interface PushSettingsView {
|
||||
publicKey: string;
|
||||
hasPrivateKey: boolean;
|
||||
subject: string;
|
||||
generatedAt: string | null;
|
||||
}
|
||||
|
||||
/** The fully-resolved shape (real plaintext private key included) — used only server-side, for the actual .env push, never rendered or logged. */
|
||||
export interface ResolvedPushSettings {
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
function settingsFilePath(): string {
|
||||
return path.join(stacksRoot(), "manager", "global-settings.json");
|
||||
}
|
||||
@@ -91,10 +119,10 @@ async function readStoredSettings(): Promise<StoredGlobalSettings> {
|
||||
try {
|
||||
const raw = await readFile(settingsFilePath(), "utf8");
|
||||
const parsed = JSON.parse(raw) as StoredGlobalSettings;
|
||||
return { email: parsed.email ?? null };
|
||||
return { email: parsed.email ?? null, push: parsed.push ?? null };
|
||||
} catch {
|
||||
// No file yet (first run) or unreadable — treat as "nothing configured" rather than throwing, so the Settings page can always render.
|
||||
return { email: null };
|
||||
return { email: null, push: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +172,7 @@ function decrypt(payload: EncryptedPayload): string {
|
||||
return plaintext.toString("utf8");
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────
|
||||
// ── Email ────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_EMAIL_VIEW: EmailSettingsView = {
|
||||
host: "",
|
||||
@@ -226,7 +254,7 @@ export async function saveEmailSettings(input: EmailSettingsInput): Promise<Reso
|
||||
replyTo: input.replyTo.trim(),
|
||||
lastTest: current.email?.lastTest ?? null,
|
||||
};
|
||||
await writeStoredSettings({ email: next });
|
||||
await writeStoredSettings({ ...current, email: next });
|
||||
|
||||
return {
|
||||
host: next.host,
|
||||
@@ -243,5 +271,96 @@ export async function saveEmailSettings(input: EmailSettingsInput): Promise<Reso
|
||||
export async function recordLastTestResult(result: LastTestResult): Promise<void> {
|
||||
const current = await readStoredSettings();
|
||||
if (!current.email) return; // nothing to attach the result to
|
||||
await writeStoredSettings({ email: { ...current.email, lastTest: result } });
|
||||
await writeStoredSettings({ ...current, email: { ...current.email, lastTest: result } });
|
||||
}
|
||||
|
||||
// ── Push / VAPID ─────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_PUSH_VIEW: PushSettingsView = {
|
||||
publicKey: "",
|
||||
hasPrivateKey: false,
|
||||
subject: "",
|
||||
generatedAt: null,
|
||||
};
|
||||
|
||||
/** Safe for the Settings page to render directly — the private key is never included, only whether one is set. The public key is not a secret. */
|
||||
export async function readPushSettingsView(): Promise<PushSettingsView> {
|
||||
const { push } = await readStoredSettings();
|
||||
if (!push) return DEFAULT_PUSH_VIEW;
|
||||
return {
|
||||
publicKey: push.publicKey,
|
||||
hasPrivateKey: push.encryptedPrivateKey !== null,
|
||||
subject: push.subject,
|
||||
generatedAt: push.generatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Server-side only — decrypts the real private key. Used for the .env push. Returns null if no key pair has been generated yet. */
|
||||
export async function resolvePushSettings(): Promise<ResolvedPushSettings | null> {
|
||||
const { push } = await readStoredSettings();
|
||||
if (!push || !push.encryptedPrivateKey) return null;
|
||||
return {
|
||||
publicKey: push.publicKey,
|
||||
privateKey: decrypt(push.encryptedPrivateKey),
|
||||
subject: push.subject,
|
||||
};
|
||||
}
|
||||
|
||||
export function isPushConfigComplete(settings: Pick<ResolvedPushSettings, "publicKey" | "privateKey" | "subject"> | null): boolean {
|
||||
return Boolean(settings && settings.publicKey.trim() && settings.privateKey.trim() && settings.subject.trim());
|
||||
}
|
||||
|
||||
export class VapidKeysAlreadyExistError extends Error {
|
||||
constructor() {
|
||||
super("A VAPID key pair already exists. Regenerating requires explicit confirmation.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a brand-new VAPID key pair via the `web-push` package and
|
||||
* stores it — the *only* way a key pair is ever set (no hand-pasted
|
||||
* public/private key fields exist in the UI). This avoids a mismatched
|
||||
* pair ever being stored: a "public key" that doesn't correspond to the
|
||||
* stored private key would let browsers subscribe successfully while
|
||||
* every server-side send silently fails signature verification, a
|
||||
* confusing failure mode with no clear cause.
|
||||
*
|
||||
* Refuses to overwrite an existing key pair unless `confirmOverwrite` is
|
||||
* explicitly true — existing browser subscriptions were created against
|
||||
* the current public key and will stop being deliverable once it changes
|
||||
* (see docs on `web-push-provider.ts`'s 401/403 handling for what happens
|
||||
* to them next), so this must never happen silently.
|
||||
*/
|
||||
export async function generateVapidKeys(params: { subject: string; confirmOverwrite?: boolean }): Promise<ResolvedPushSettings> {
|
||||
const current = await readStoredSettings();
|
||||
if (current.push?.encryptedPrivateKey && !params.confirmOverwrite) {
|
||||
throw new VapidKeysAlreadyExistError();
|
||||
}
|
||||
|
||||
const { publicKey, privateKey } = webpush.generateVAPIDKeys();
|
||||
const subject = params.subject.trim();
|
||||
|
||||
const next: StoredPushSettings = {
|
||||
publicKey,
|
||||
encryptedPrivateKey: encrypt(privateKey),
|
||||
subject,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
await writeStoredSettings({ ...current, push: next });
|
||||
|
||||
return { publicKey, privateKey, subject };
|
||||
}
|
||||
|
||||
/** Updates only the contact/subject email — the key pair itself is untouched (see generateVapidKeys for why keys are generate-only). */
|
||||
export async function savePushSubject(subject: string): Promise<ResolvedPushSettings | null> {
|
||||
const current = await readStoredSettings();
|
||||
if (!current.push) {
|
||||
// No key pair generated yet — nothing to attach a subject to server-side; the caller should generate keys first. Still record the subject so it's pre-filled once keys are generated.
|
||||
await writeStoredSettings({ ...current, push: { publicKey: "", encryptedPrivateKey: null, subject: subject.trim(), generatedAt: null } });
|
||||
return null;
|
||||
}
|
||||
const next: StoredPushSettings = { ...current.push, subject: subject.trim() };
|
||||
await writeStoredSettings({ ...current, push: next });
|
||||
if (!next.encryptedPrivateKey) return null;
|
||||
return { publicKey: next.publicKey, privateKey: decrypt(next.encryptedPrivateKey), subject: next.subject };
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import mariadb, { type Connection } from "mariadb";
|
||||
import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./compose-template";
|
||||
import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker";
|
||||
import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file";
|
||||
import { isEmailConfigComplete, resolveEmailSettings } from "./global-settings";
|
||||
import { isEmailConfigComplete, isPushConfigComplete, resolveEmailSettings, resolvePushSettings } from "./global-settings";
|
||||
import { isValidSlug, listBusinessStacks, stackDir, stackExists, stacksRoot } from "./stacks";
|
||||
|
||||
/** Hard safety check on top of isValidSlug (which already constrains the slug itself) — this is the one place that string ends up inside a raw SQL statement, where an identifier can't be parameter-bound the way a value can. */
|
||||
@@ -163,6 +163,15 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise<
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Same pattern as email above — global VAPID keys (Manager's Settings >
|
||||
// Push Notifications page) baked in at creation, only when a complete
|
||||
// key pair actually exists.
|
||||
const globalPushSettings = await resolvePushSettings();
|
||||
const push =
|
||||
globalPushSettings && isPushConfigComplete(globalPushSettings)
|
||||
? { publicKey: globalPushSettings.publicKey, privateKey: globalPushSettings.privateKey, subject: globalPushSettings.subject }
|
||||
: undefined;
|
||||
|
||||
const dir = stackDir(slug);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8");
|
||||
@@ -178,6 +187,7 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise<
|
||||
installationToken,
|
||||
cronSecret,
|
||||
email,
|
||||
push,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import path from "node:path";
|
||||
import { composeUp } from "./docker";
|
||||
import { upsertEnvValues } from "./env-file";
|
||||
import type { ResolvedPushSettings } from "./global-settings";
|
||||
import { listBusinessStacks, stackDir } from "./stacks";
|
||||
|
||||
export interface PushRolloutResult {
|
||||
slug: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes the global VAPID key pair into every business's own `.env` (same
|
||||
* mechanism as email-rollout.ts's applyEmailSettingsToAllBusinesses — the
|
||||
* surgical, non-destructive `upsertEnvValues` helper, then a
|
||||
* force-recreate so the change actually takes effect). Unlike email's
|
||||
* reply-to, every one of these fields is always overwritten: VAPID
|
||||
* identity is platform-wide infrastructure, never a business preference
|
||||
* (a business has no legitimate reason to run its own key pair — see
|
||||
* global-settings.ts's own header).
|
||||
*
|
||||
* Deliberately does NOT write NEXT_PUBLIC_VAPID_PUBLIC_KEY — that
|
||||
* variable is retired (see src/app/api/push/vapid-public-key/route.ts in
|
||||
* the main app, which now serves the public key at runtime instead of it
|
||||
* being build-time-inlined). Recreating the container is still required
|
||||
* for the *server-side* VAPID_* vars to take effect, exactly like email.
|
||||
*
|
||||
* Runs sequentially, not in parallel, for the same reason
|
||||
* email-rollout.ts does — see that file's own header.
|
||||
*/
|
||||
export async function applyPushSettingsToAllBusinesses(settings: ResolvedPushSettings): Promise<PushRolloutResult[]> {
|
||||
const businesses = await listBusinessStacks();
|
||||
const results: PushRolloutResult[] = [];
|
||||
|
||||
for (const business of businesses) {
|
||||
try {
|
||||
const dir = stackDir(business.slug);
|
||||
await upsertEnvValues(path.join(dir, ".env"), {
|
||||
PUSH_PROVIDER: "web-push",
|
||||
VAPID_PUBLIC_KEY: settings.publicKey,
|
||||
VAPID_PRIVATE_KEY: settings.privateKey,
|
||||
VAPID_SUBJECT: settings.subject,
|
||||
});
|
||||
await composeUp(dir, { forceRecreate: true });
|
||||
results.push({ slug: business.slug, ok: true });
|
||||
} catch (error) {
|
||||
results.push({ slug: business.slug, ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
Reference in new issue
Block a user