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:
Marc-André Bombeck committed 2026-06-15 00:58:30 +02:00
1 parent 45ccdcb305
commit d0ba4b061b
9 files changed
+423 -1

No files matched your search

@@ -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}