From d0ba4b061bf5624668905120f2af3356c15ea50f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 00:58:30 +0200 Subject: [PATCH 1/2] feat(notifications): generate VAPID keys from the admin panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a server-side VAPID keypair generator so a self-hoster no longer has to run a shell command to enable Web Push. A new admin-only route (POST /api/admin/settings/web-push-vapid/generate) calls web-push's generateVAPIDKeys(), stores the private key encrypted at rest, seeds a placeholder mailto: subject the operator can edit, and returns only the public key. The route is cookie-only via requireAdmin(); a Bearer token cannot reach it. The private key is never returned and never logged — the audit detail carries a marker only. The admin Web Push VAPID card gains a Generate keys button that fills the public key and subject fields on success. When a keypair already exists the route refuses with 409 and the card confirms before retrying with force, warning that regenerating invalidates existing browser subscriptions. --- messages/de.json | 5 + messages/en.json | 5 + messages/es.json | 5 + messages/fr.json | 5 + messages/it.json | 5 + messages/pl.json | 5 + .../generate/__tests__/route.test.ts | 192 ++++++++++++++++++ .../settings/web-push-vapid/generate/route.ts | 132 ++++++++++++ .../admin/web-push-vapid-section.tsx | 70 ++++++- 9 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 src/app/api/admin/settings/web-push-vapid/generate/__tests__/route.test.ts create mode 100644 src/app/api/admin/settings/web-push-vapid/generate/route.ts 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 (
+