merge(admin): generate VAPID keys in-app and self-hoster backup/env docs

This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:59:22 +02:00
commit a8ff52e998
12 files changed
+533 -22

No files matched your search

+23 -1
View File
@@ -74,13 +74,35 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# 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=""
+48
View File
@@ -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).
+39 -20
View File
@@ -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 |
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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ó",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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ę",
@@ -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<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
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);
});
});
@@ -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,
});
});
@@ -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 (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
@@ -122,6 +175,21 @@ export function WebPushVapidSection() {
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void generateVapidKeys(false)}
disabled={generating || updateSettings.isPending}
>
{generating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="h-3.5 w-3.5" />
)}
{generating
? t("admin.webPushVapidGenerating")
: t("admin.webPushVapidGenerate")}
</Button>
<Button
size="sm"
onClick={saveWebPushVapidSettings}