mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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.
207 lines
7.1 KiB
TypeScript
207 lines
7.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "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,
|
|
useAdminSettings,
|
|
useUpdateSettings,
|
|
} from "./_shared";
|
|
|
|
export function WebPushVapidSection() {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
const { data: settings } = useAdminSettings();
|
|
const updateSettings = useUpdateSettings();
|
|
const [webPushVapidPublicKeyDraft, setWebPushVapidPublicKeyDraft] = useState<
|
|
string | null
|
|
>(null);
|
|
const [webPushVapidPrivateKeyDraft, setWebPushVapidPrivateKeyDraft] =
|
|
useState("");
|
|
const [webPushVapidSubjectDraft, setWebPushVapidSubjectDraft] = useState<
|
|
string | null
|
|
>(null);
|
|
const [generating, setGenerating] = useState(false);
|
|
|
|
const webPushVapidPublicKeyValue =
|
|
webPushVapidPublicKeyDraft ?? settings?.webPushVapidPublicKey ?? "";
|
|
const webPushVapidSubjectValue =
|
|
webPushVapidSubjectDraft ?? settings?.webPushVapidSubject ?? "";
|
|
|
|
const configured = settings?.webPushVapidConfigured ?? false;
|
|
|
|
function saveWebPushVapidSettings() {
|
|
const payload: Record<string, unknown> = {
|
|
webPushVapidPublicKey: webPushVapidPublicKeyValue,
|
|
webPushVapidSubject: webPushVapidSubjectValue,
|
|
};
|
|
if (webPushVapidPrivateKeyDraft.trim().length > 0) {
|
|
payload.webPushVapidPrivateKey = webPushVapidPrivateKeyDraft.trim();
|
|
}
|
|
|
|
updateSettings.mutate(payload, {
|
|
onSuccess: () => {
|
|
setWebPushVapidPublicKeyDraft(null);
|
|
setWebPushVapidPrivateKeyDraft("");
|
|
setWebPushVapidSubjectDraft(null);
|
|
},
|
|
});
|
|
}
|
|
|
|
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
|
|
icon={BellRing}
|
|
title={t("admin.webPushVapidTitle")}
|
|
description={t("admin.webPushVapidDescription")}
|
|
status={configured ? <ConfiguredBadge /> : null}
|
|
/>
|
|
|
|
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2">
|
|
<div className="space-y-1.5 sm:col-span-2">
|
|
<Label htmlFor="admin-web-push-public-key" className="text-xs">
|
|
{t("admin.webPushVapidPublicKey")}
|
|
</Label>
|
|
<Input
|
|
id="admin-web-push-public-key"
|
|
name="admin-web-push-public-key"
|
|
value={webPushVapidPublicKeyValue}
|
|
onChange={(event) =>
|
|
setWebPushVapidPublicKeyDraft(event.target.value)
|
|
}
|
|
placeholder={t("admin.webPushVapidPublicKeyPlaceholder")}
|
|
autoComplete="new-password"
|
|
spellCheck={false}
|
|
data-lpignore="true"
|
|
data-1p-ignore="true"
|
|
disabled={updateSettings.isPending}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5 sm:col-span-2">
|
|
<Label htmlFor="admin-web-push-private-key" className="text-xs">
|
|
{t("admin.webPushVapidPrivateKey")}
|
|
</Label>
|
|
<PasswordInput
|
|
id="admin-web-push-private-key"
|
|
name="admin-web-push-private-key"
|
|
value={webPushVapidPrivateKeyDraft}
|
|
onChange={(event) =>
|
|
setWebPushVapidPrivateKeyDraft(event.target.value)
|
|
}
|
|
placeholder={t("admin.webPushVapidPrivateKeyPlaceholder")}
|
|
autoComplete="new-password"
|
|
spellCheck={false}
|
|
data-lpignore="true"
|
|
data-1p-ignore="true"
|
|
disabled={updateSettings.isPending}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5 sm:col-span-2">
|
|
<Label htmlFor="admin-web-push-subject" className="text-xs">
|
|
{t("admin.webPushVapidSubject")}
|
|
</Label>
|
|
<Input
|
|
id="admin-web-push-subject"
|
|
name="admin-web-push-subject"
|
|
value={webPushVapidSubjectValue}
|
|
onChange={(event) =>
|
|
setWebPushVapidSubjectDraft(event.target.value)
|
|
}
|
|
placeholder={t("admin.webPushVapidSubjectPlaceholder")}
|
|
autoComplete="new-password"
|
|
spellCheck={false}
|
|
data-lpignore="true"
|
|
data-1p-ignore="true"
|
|
disabled={updateSettings.isPending}
|
|
/>
|
|
</div>
|
|
</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}
|
|
disabled={updateSettings.isPending}
|
|
>
|
|
{updateSettings.isPending && (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
)}
|
|
{t("common.save")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|