mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(notifications): generate VAPID keys from the admin panel
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.
This commit is contained in:
1 parent
45ccdcb305
commit
d0ba4b061b
9 files changed
+423
-1
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ó",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user