mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +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
@@ -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