diff --git a/.env.production.example b/.env.production.example index 258db482e..3b94d3963 100644 --- a/.env.production.example +++ b/.env.production.example @@ -74,13 +74,35 @@ API_TOKEN_HMAC_KEY="" # OURA_REDIRECT_URI="" +# ----------------------------------------------------------------------------- +# Web Push -- VAPID keys (optional; the admin panel is the easier path) +# ----------------------------------------------------------------------------- +# Web Push needs NO Apple account and no paid service -- just a VAPID keypair. +# The EASIEST path is the admin panel: /admin -> Web Push VAPID -> "Generate +# keys" mints and stores the pair for you (private key encrypted at rest). Use +# these env vars only if you prefer env-config over the DB. The loader reads +# DB first, then env (src/lib/notifications/vapid-config.ts). NOTE: these are +# NOT in docker-compose.yml's `environment:` whitelist -- if you go the env +# route under compose, add them there too or the values never reach the +# container (see docs/self-hosting/getting-started.md Troubleshooting). +# VAPID_PUBLIC_KEY="" +# VAPID_PRIVATE_KEY="" +# VAPID_SUBJECT="mailto:you@example.com" + + # ----------------------------------------------------------------------------- # APNs -- iOS push (optional, all-or-none) # ----------------------------------------------------------------------------- +# Only the native iOS app uses APNs, and it needs a paid Apple Developer +# Program membership. Web Push (above) needs NO Apple account -- it covers +# every browser and the installed PWA on iPhone. Leave this whole block unset +# unless you ship the native app. Key-source precedence when more than one is +# set: APNS_KEY_B64 > APNS_KEY > APNS_KEY_FILE. # APNS_KEY_ID="" # APNS_TEAM_ID="" # APNS_BUNDLE_ID="" -# APNS_KEY="" # OR APNS_KEY_FILE -- either one satisfies the manifest +# APNS_KEY_B64="" # PEM body base64-encoded -- recommended (escapes newlines) +# APNS_KEY="" # OR APNS_KEY (raw PEM) OR APNS_KEY_FILE -- one satisfies the manifest # APNS_KEY_FILE="" diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index ca8abe9a6..5bee1db7d 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -175,3 +175,51 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build - **OAuth callbacks loop back to localhost.** Confirm `APP_URL` and `NEXT_PUBLIC_APP_URL` both point at the public hostname, not at `localhost`, and restart `app` after the change. +- **An env var in `.env` seems ignored / a feature stays "off".** The + compose `environment:` block is a **whitelist** — a variable set in + `.env` only reaches the container if `docker-compose.yml` lists it + under `environment:`. Vars not listed never propagate, even with + `${VAR}` substitution at compose-up. If a setting looks configured but + is silently dead, check the var is in that block (add it if you run a + custom compose); the supported alternative for most secrets (VAPID, + GitHub token, GlitchTip DSN) is the **admin panel**, which stores them + in the database and sidesteps the whitelist entirely. +- **A new image doesn't pick up / `:latest` looks stale.** `docker-compose.yml` + sets `pull_policy: always`, which is load-bearing: without it Docker + re-uses the cached `:latest` digest and silently skips the registry + round-trip on `compose up`. If you pin a custom compose, keep + `pull_policy: always` on the `app` service, and verify the running + build with `GET /api/version` (it returns `version` + `buildSha`). + +## Backup and restore + +Your database is the only stateful piece — back it up, and back up the +key that decrypts it. + +**Local snapshot (the common single-box case).** Dump the `db` service +with `pg_dump`: + +```bash +# The bundled db service uses user `healthlog` and database `healthlog`. +docker compose exec -T db pg_dump -U healthlog healthlog \ + | gzip > healthlog-$(date +%F).sql.gz +``` + +Restore into a fresh database the same way: + +```bash +gunzip -c healthlog-2026-01-01.sql.gz \ + | docker compose exec -T db psql -U healthlog healthlog +``` + +> **Back up your `ENCRYPTION_KEYS` / `ENCRYPTION_KEY` alongside the dump.** +> HealthLog encrypts sensitive columns at rest (AES-256-GCM). A database +> dump **without the encryption key is unrecoverable** — the encrypted +> data cannot be read back. Store the key (and any retired keys still in +> the rotation map) somewhere separate from the dump but equally durable. +> Losing the key means losing the encrypted data, full stop. + +For off-host encrypted backups to S3/R2/B2 see +[`docs/ops/backup-restore.md`](../ops/backup-restore.md); for rotating the +encryption key safely (keep the old key until zero rows remain on it) see +[`docs/ops/encryption-key-rotation.md`](../ops/encryption-key-rotation.md). diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index affe53abe..7abf16df0 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -34,35 +34,46 @@ mobile — even when the HealthLog tab is closed, as long as the browser or installed PWA is alive. It is the broadest-reach channel and the one to set up first. -### 1. Generate a VAPID keypair +### 1. Generate and store a VAPID keypair VAPID is the signing scheme that lets a push service trust your server. -Generate a keypair once with the bundled `web-push` CLI: +You also choose a **subject** — a `mailto:` address the push service can +contact about your sender, e.g. `mailto:you@example.com`. + +The server loads VAPID config from the database first and falls back to +environment variables (`src/lib/notifications/vapid-config.ts`). The admin +panel is the easiest path because it survives without touching `.env`. + +**Admin panel — Generate keys (easiest).** Sign in as the admin user, +open `/admin`, and find the **Web Push VAPID** card +(`src/components/admin/web-push-vapid-section.tsx`). Click **Generate +keys**: the server mints a fresh keypair, stores the private key encrypted +at rest with your `ENCRYPTION_KEY`, seeds a placeholder subject, and fills +in the public key. Edit the subject to your real `mailto:` address and +save. The card shows a "configured" badge once all three fields are set — +no shell, no copy-paste. + +> If a keypair already exists, **Generate keys** asks you to confirm before +> replacing it. Regenerating invalidates every existing browser +> subscription, so each device has to re-subscribe afterwards. Only +> regenerate when you mean to. + +**Admin panel — paste an existing pair.** If you already have a keypair +(e.g. copied from another deployment), paste the public key, the private +key, and the subject into the same card and save instead of generating. + +**CLI — generate a pair yourself.** You can also mint one with the bundled +`web-push` CLI and paste the result into the card or your `.env`: ```bash npx web-push generate-vapid-keys ``` -That prints a **public key** and a **private key** (both Base64URL). You -also choose a **subject** — a `mailto:` address the push service can -contact about your sender, e.g. `mailto:you@example.com`. +That prints a **public key** and a **private key** (both Base64URL). -### 2. Store the keys +### 2. Store the keys via environment variables (alternative) -The server loads VAPID config from the database first and falls back to -environment variables (`src/lib/notifications/vapid-config.ts`). Either -path works; the admin panel is the easier one because it survives without -touching `.env`. - -**Admin panel (preferred).** Sign in as the admin user, open -`/admin`, and find the **Web Push VAPID** card -(`src/components/admin/web-push-vapid-section.tsx`). Paste the public key, -the private key, and the subject, then save. The private key is encrypted -at rest with your `ENCRYPTION_KEY`, exactly like the other secrets in the -admin panel. The card shows a "configured" badge once all three fields -are set. - -**Environment variables (alternative).** If you would rather keep +If you would rather keep secrets out of the database, set them in `.env` / the compose `environment:` block instead: @@ -179,6 +190,14 @@ Two gotchas worth knowing before you blame a stale token: `BadEnvironmentKeyInToken`. Confirm the key shows **Sandbox & Production**; a wrongly scoped key cannot be reconfigured — re-issue it. +> **`clientManaged` is iOS-app-only.** The native iOS app can opt to run +> its own local medication reminders and ask the server to stop sending +> the duplicate `MEDICATION_REMINDER` push (the `clientManaged` flag on +> `PATCH /api/auth/me/notification-prefs`). This is a native-app contract: +> a web/PWA-only self-hoster never needs it and there is no toggle for it +> in the web UI. If you only run the PWA, ignore it entirely — your +> medication reminders come from the server cron over the channels above. + ## Which channel should I pick? | Your setup | Recommended channel | diff --git a/messages/de.json b/messages/de.json index bb1026aa7..8142d82d1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4896,6 +4896,11 @@ "webPushVapidPrivateKeyPlaceholder": "Optional: neuen Private Key eintragen", "webPushVapidSubject": "VAPID Subject", "webPushVapidSubjectPlaceholder": "mailto:deine-adresse@example.com", + "webPushVapidGenerate": "Schlüssel erzeugen", + "webPushVapidGenerating": "Wird erzeugt…", + "webPushVapidGenerated": "VAPID-Schlüssel erzeugt. Betreff speichern, um abzuschließen.", + "webPushVapidGenerateFailed": "VAPID-Schlüssel konnten nicht erzeugt werden.", + "webPushVapidGenerateConfirm": "VAPID-Schlüssel sind bereits konfiguriert. Ein neues Schlüsselpaar macht alle Browser-Push-Abos ungültig — jedes Gerät muss sich neu anmelden. Fortfahren?", "configured": "Konfiguriert", "monitoringTestSuccess": "Testevent wurde gesendet", "monitoringTestFailed": "Monitoring-Test fehlgeschlagen", diff --git a/messages/en.json b/messages/en.json index 63210fddf..1e7058c04 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4896,6 +4896,11 @@ "webPushVapidPrivateKeyPlaceholder": "Optional: enter a new private key", "webPushVapidSubject": "VAPID subject", "webPushVapidSubjectPlaceholder": "mailto:you@example.com", + "webPushVapidGenerate": "Generate keys", + "webPushVapidGenerating": "Generating…", + "webPushVapidGenerated": "VAPID keys generated. Save the subject to finish.", + "webPushVapidGenerateFailed": "Could not generate VAPID keys.", + "webPushVapidGenerateConfirm": "VAPID keys are already configured. Generating a new pair invalidates every browser push subscription — each device must re-subscribe. Continue?", "configured": "Configured", "monitoringTestSuccess": "Test event sent", "monitoringTestFailed": "Monitoring test failed", diff --git a/messages/es.json b/messages/es.json index 2da94f8f0..4988d8cd5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4896,6 +4896,11 @@ "webPushVapidPrivateKeyPlaceholder": "Opcional: introduce una nueva clave privada", "webPushVapidSubject": "Subject de VAPID", "webPushVapidSubjectPlaceholder": "mailto:you@example.com", + "webPushVapidGenerate": "Generar claves", + "webPushVapidGenerating": "Generando…", + "webPushVapidGenerated": "Claves VAPID generadas. Guarda el asunto para terminar.", + "webPushVapidGenerateFailed": "No se pudieron generar las claves VAPID.", + "webPushVapidGenerateConfirm": "Las claves VAPID ya están configuradas. Generar un par nuevo invalida todas las suscripciones push del navegador: cada dispositivo debe volver a suscribirse. ¿Continuar?", "configured": "Configurado", "monitoringTestSuccess": "Evento de prueba enviado", "monitoringTestFailed": "La prueba de monitorización falló", diff --git a/messages/fr.json b/messages/fr.json index 21461fbc4..c47bf151d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4896,6 +4896,11 @@ "webPushVapidPrivateKeyPlaceholder": "Facultatif : saisissez une nouvelle clé privée", "webPushVapidSubject": "Subject VAPID", "webPushVapidSubjectPlaceholder": "mailto:you@example.com", + "webPushVapidGenerate": "Générer les clés", + "webPushVapidGenerating": "Génération…", + "webPushVapidGenerated": "Clés VAPID générées. Enregistrez le sujet pour terminer.", + "webPushVapidGenerateFailed": "Impossible de générer les clés VAPID.", + "webPushVapidGenerateConfirm": "Les clés VAPID sont déjà configurées. Générer une nouvelle paire invalide tous les abonnements push du navigateur — chaque appareil doit se réabonner. Continuer ?", "configured": "Configuré", "monitoringTestSuccess": "Événement de test envoyé", "monitoringTestFailed": "Échec du test de surveillance", diff --git a/messages/it.json b/messages/it.json index 265885255..cd103de51 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4896,6 +4896,11 @@ "webPushVapidPrivateKeyPlaceholder": "Facoltativo: inserisci una nuova chiave privata", "webPushVapidSubject": "Subject VAPID", "webPushVapidSubjectPlaceholder": "mailto:you@example.com", + "webPushVapidGenerate": "Genera chiavi", + "webPushVapidGenerating": "Generazione…", + "webPushVapidGenerated": "Chiavi VAPID generate. Salva l'oggetto per completare.", + "webPushVapidGenerateFailed": "Impossibile generare le chiavi VAPID.", + "webPushVapidGenerateConfirm": "Le chiavi VAPID sono già configurate. Generare una nuova coppia invalida tutte le sottoscrizioni push del browser: ogni dispositivo deve ri-sottoscriversi. Continuare?", "configured": "Configurato", "monitoringTestSuccess": "Evento di prova inviato", "monitoringTestFailed": "Test di monitoraggio non riuscito", diff --git a/messages/pl.json b/messages/pl.json index a969c66f2..19cfeb26e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4896,6 +4896,11 @@ "webPushVapidPrivateKeyPlaceholder": "Opcjonalnie: wprowadź nowy klucz prywatny", "webPushVapidSubject": "Subject VAPID", "webPushVapidSubjectPlaceholder": "mailto:you@example.com", + "webPushVapidGenerate": "Wygeneruj klucze", + "webPushVapidGenerating": "Generowanie…", + "webPushVapidGenerated": "Klucze VAPID wygenerowane. Zapisz temat, aby zakończyć.", + "webPushVapidGenerateFailed": "Nie udało się wygenerować kluczy VAPID.", + "webPushVapidGenerateConfirm": "Klucze VAPID są już skonfigurowane. Wygenerowanie nowej pary unieważnia wszystkie subskrypcje push w przeglądarce — każde urządzenie musi zasubskrybować ponownie. Kontynuować?", "configured": "Skonfigurowano", "monitoringTestSuccess": "Wysłano zdarzenie testowe", "monitoringTestFailed": "Test monitorowania nie powiódł się", diff --git a/src/app/api/admin/settings/web-push-vapid/generate/__tests__/route.test.ts b/src/app/api/admin/settings/web-push-vapid/generate/__tests__/route.test.ts new file mode 100644 index 000000000..105287ede --- /dev/null +++ b/src/app/api/admin/settings/web-push-vapid/generate/__tests__/route.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>( + h: T, + ): T => h, + requireAdmin: vi.fn(), + }; +}); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => null), +})); + +vi.mock("@/lib/crypto", () => ({ + encrypt: vi.fn((value: string) => `enc(${value})`), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateAppSettings: vi.fn(), +})); + +vi.mock("web-push", () => ({ + generateVAPIDKeys: vi.fn(() => ({ + publicKey: "GENERATED_PUBLIC", + privateKey: "GENERATED_PRIVATE", + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { requireAdmin, HttpError } from "@/lib/api-handler"; +import { encrypt } from "@/lib/crypto"; +import { auditLog } from "@/lib/auth/audit"; + +const ADMIN_CTX = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "admin-1", username: "admin", role: "ADMIN" } as never, +}; + +function jsonReq(body: unknown): NextRequest { + return new NextRequest( + "http://localhost/api/admin/settings/web-push-vapid/generate", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + id: "singleton", + } as never); +}); + +describe("POST /api/admin/settings/web-push-vapid/generate", () => { + it("rejects with 401 when no session (admin-gated)", async () => { + vi.mocked(requireAdmin).mockRejectedValue( + new HttpError(401, "Not authenticated"), + ); + await expect(POST(jsonReq({}))).rejects.toThrow("Not authenticated"); + }); + + it("rejects with 403 for a non-admin (cookie-only boundary)", async () => { + vi.mocked(requireAdmin).mockRejectedValue( + new HttpError(403, "Admin access required"), + ); + await expect(POST(jsonReq({}))).rejects.toThrow("Admin access required"); + }); + + it("generates a keypair and stores the private key encrypted", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null); + + const res = await POST(jsonReq({})); + expect(res.status).toBe(200); + + const body = (await res.json()) as { + data: { + webPushVapidPublicKey: string; + webPushVapidSubject: string; + webPushVapidConfigured: boolean; + }; + }; + expect(body.data.webPushVapidPublicKey).toBe("GENERATED_PUBLIC"); + expect(body.data.webPushVapidConfigured).toBe(true); + // Default placeholder subject seeded so the keypair is immediately valid. + expect(body.data.webPushVapidSubject).toBe("mailto:admin@example.com"); + + // Private key encrypted before persistence; never returned in the body. + expect(encrypt).toHaveBeenCalledWith("GENERATED_PRIVATE"); + expect(JSON.stringify(body.data)).not.toContain("GENERATED_PRIVATE"); + + const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0]; + expect(upsert?.where).toEqual({ id: "singleton" }); + expect(upsert?.update).toMatchObject({ + webPushVapidPublicKey: "GENERATED_PUBLIC", + webPushVapidPrivateKeyEncrypted: "enc(GENERATED_PRIVATE)", + }); + }); + + it("never logs the plaintext private key in the audit detail", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null); + await POST(jsonReq({})); + expect(auditLog).toHaveBeenCalled(); + const details = vi.mocked(auditLog).mock.calls[0]?.[1]?.details as Record< + string, + unknown + >; + expect(JSON.stringify(details)).not.toContain("GENERATED_PRIVATE"); + expect(details.webPushVapidPrivateKeyUpdated).toBe(true); + expect(details.replacedExisting).toBe(false); + }); + + it("refuses with 409 when keys already exist and force is absent", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + webPushVapidPublicKey: "OLD_PUBLIC", + webPushVapidPrivateKeyEncrypted: "OLD_ENC", + webPushVapidSubject: "mailto:old@example.com", + } as never); + + const res = await POST(jsonReq({})); + expect(res.status).toBe(409); + expect(prisma.appSettings.upsert).not.toHaveBeenCalled(); + }); + + it("overwrites existing keys when force is true and keeps the subject", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + webPushVapidPublicKey: "OLD_PUBLIC", + webPushVapidPrivateKeyEncrypted: "OLD_ENC", + webPushVapidSubject: "mailto:keep@example.com", + } as never); + + const res = await POST(jsonReq({ force: true })); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { webPushVapidSubject: string }; + }; + // Existing subject preserved when no override is supplied. + expect(body.data.webPushVapidSubject).toBe("mailto:keep@example.com"); + + const details = vi.mocked(auditLog).mock.calls[0]?.[1]?.details as Record< + string, + unknown + >; + expect(details.replacedExisting).toBe(true); + }); + + it("rejects an invalid subject override with 422", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null); + const res = await POST(jsonReq({ subject: "not-a-mailto" })); + expect(res.status).toBe(422); + expect(prisma.appSettings.upsert).not.toHaveBeenCalled(); + }); + + it("returns 415 when content-type is not JSON", async () => { + const r = new NextRequest( + "http://localhost/api/admin/settings/web-push-vapid/generate", + { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "{}", + }, + ); + const res = await POST(r); + expect(res.status).toBe(415); + }); +}); diff --git a/src/app/api/admin/settings/web-push-vapid/generate/route.ts b/src/app/api/admin/settings/web-push-vapid/generate/route.ts new file mode 100644 index 000000000..10cf0bd33 --- /dev/null +++ b/src/app/api/admin/settings/web-push-vapid/generate/route.ts @@ -0,0 +1,132 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { encrypt } from "@/lib/crypto"; +import { invalidateAppSettings } from "@/lib/cache/invalidate"; +import { z } from "zod/v4"; +import { NextRequest } from "next/server"; + +export const dynamic = "force-dynamic"; + +const generateSchema = z + .object({ + // Overwrite guard — the client must opt in to replacing an existing + // keypair. Regenerating invalidates every browser subscription signed + // against the old public key, so the UI confirms before sending this. + force: z.boolean().optional(), + // Optional subject override. When omitted the existing subject is kept, + // or a neutral placeholder seeded so the keypair is immediately usable. + subject: z + .string() + .refine((v) => !v.trim() || /^mailto:.+@.+$/.test(v.trim()), { + message: "subject must be in mailto:address@example.com format", + }) + .optional(), + }) + .strict(); + +/** + * POST /api/admin/settings/web-push-vapid/generate + * + * Server-side VAPID keypair generation — the self-hoster DX win. Calls + * `web-push`'s `generateVAPIDKeys()`, encrypts the private key at rest + * (`src/lib/crypto.ts`), and persists the pair onto the AppSettings + * singleton. Returns only the public key + subject; the private key never + * leaves the server and is never logged (the egress redactor covers the + * encrypted blob, and the audit detail carries only a "generated" marker). + * + * Cookie-only via `requireAdmin()` — a Bearer token can never reach it. + * + * Overwrite guard: if a keypair already exists, the call refuses with 409 + * unless `force: true` is supplied. Regenerating invalidates existing + * browser subscriptions, so the operator must opt in. + */ +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAdmin(); + annotate({ action: { name: "admin.webPushVapid.generate" } }); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = generateSchema.safeParse(body ?? {}); + if (!parsed.success) { + return apiError("Invalid request", 422); + } + + const existing = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { + webPushVapidPublicKey: true, + webPushVapidPrivateKeyEncrypted: true, + webPushVapidSubject: true, + }, + }); + + const alreadyConfigured = Boolean( + existing?.webPushVapidPublicKey && + existing?.webPushVapidPrivateKeyEncrypted, + ); + + if (alreadyConfigured && parsed.data.force !== true) { + // Overwrite guard — surface the existing public key so the UI can warn + // the operator that regenerating invalidates current subscriptions. + return apiError( + "VAPID keys already configured. Regenerating invalidates existing browser subscriptions. Resend with force to replace them.", + 409, + ); + } + + // Lazy import — mirrors the sender so a build without `web-push` still + // type-checks. `generateVAPIDKeys()` returns Base64URL public + private. + const webpush = await import("web-push"); + const { publicKey, privateKey } = webpush.generateVAPIDKeys(); + + // Subject precedence: explicit override → existing subject → placeholder. + // The placeholder keeps the keypair immediately valid; the admin edits + // the real contact address inline in the card afterwards. + const subjectOverride = parsed.data.subject?.trim(); + const subject = + (subjectOverride && subjectOverride.length > 0 + ? subjectOverride + : existing?.webPushVapidSubject) || "mailto:admin@example.com"; + + await prisma.appSettings.upsert({ + where: { id: "singleton" }, + update: { + webPushVapidPublicKey: publicKey, + webPushVapidPrivateKeyEncrypted: encrypt(privateKey), + webPushVapidSubject: subject, + }, + create: { + id: "singleton", + webPushVapidPublicKey: publicKey, + webPushVapidPrivateKeyEncrypted: encrypt(privateKey), + webPushVapidSubject: subject, + }, + }); + + invalidateAppSettings(); + + await auditLog("admin.web_push_vapid.generate", { + userId: user.id, + ipAddress: getClientIp(request), + // Never the private key — only a marker that a fresh pair was minted + // and whether it replaced an existing one. + details: { + webPushVapidPrivateKeyUpdated: true, + replacedExisting: alreadyConfigured, + webPushVapidSubject: subject, + }, + }); + + // Public key + subject only; the private key stays server-side. + return apiSuccess({ + webPushVapidPublicKey: publicKey, + webPushVapidSubject: subject, + webPushVapidConfigured: true, + }); +}); diff --git a/src/components/admin/web-push-vapid-section.tsx b/src/components/admin/web-push-vapid-section.tsx index f0829e8ce..e409d96f8 100644 --- a/src/components/admin/web-push-vapid-section.tsx +++ b/src/components/admin/web-push-vapid-section.tsx @@ -1,12 +1,16 @@ "use client"; import { useState } from "react"; -import { BellRing, Loader2 } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { BellRing, KeyRound, Loader2 } from "lucide-react"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useTranslations } from "@/lib/i18n/context"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; import { ConfiguredBadge, PasswordInput, @@ -16,6 +20,7 @@ import { export function WebPushVapidSection() { const { t } = useTranslations(); + const queryClient = useQueryClient(); const { data: settings } = useAdminSettings(); const updateSettings = useUpdateSettings(); const [webPushVapidPublicKeyDraft, setWebPushVapidPublicKeyDraft] = useState< @@ -26,6 +31,7 @@ export function WebPushVapidSection() { const [webPushVapidSubjectDraft, setWebPushVapidSubjectDraft] = useState< string | null >(null); + const [generating, setGenerating] = useState(false); const webPushVapidPublicKeyValue = webPushVapidPublicKeyDraft ?? settings?.webPushVapidPublicKey ?? ""; @@ -52,6 +58,53 @@ export function WebPushVapidSection() { }); } + async function generateVapidKeys(force: boolean) { + setGenerating(true); + try { + const res = await apiFetchRaw( + "/api/admin/settings/web-push-vapid/generate", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(force ? { force: true } : {}), + }, + ); + + if (res.status === 409) { + // Overwrite guard — existing keys would be replaced. Confirm with + // the operator (regenerating invalidates current subscriptions), + // then retry with force. + if (window.confirm(t("admin.webPushVapidGenerateConfirm"))) { + await generateVapidKeys(true); + } + return; + } + + if (!res.ok) { + toast.error(t("admin.webPushVapidGenerateFailed")); + return; + } + + const json = (await res.json()) as { + data: { webPushVapidPublicKey: string; webPushVapidSubject: string }; + }; + // The private key was minted and encrypted server-side; only the + // public key + subject come back. Populate the visible fields and + // leave the private-key input empty (it stays "configured"). + setWebPushVapidPublicKeyDraft(json.data.webPushVapidPublicKey); + setWebPushVapidSubjectDraft(json.data.webPushVapidSubject); + setWebPushVapidPrivateKeyDraft(""); + await queryClient.invalidateQueries({ + queryKey: queryKeys.adminSettings(), + }); + toast.success(t("admin.webPushVapidGenerated")); + } catch { + toast.error(t("admin.webPushVapidGenerateFailed")); + } finally { + setGenerating(false); + } + } + return (
+