Files
HealthLog/src/components/settings/webhook-card.tsx
T
Marc-André Bombeck d029e4e1dc fix(notifications): send content and text so Discord and Slack webhooks deliver
The generic webhook body carried title, message, eventType and priority
only. Discord refuses a body without content (400, code 50006 "Cannot
send an empty message") and a Slack incoming webhook one without text,
so the test button and every reminder failed against the targets the
settings card names.

The generic body now appends content (cut to Discord's 2000 characters)
and text, both the masked title and message on two lines, plus
allowed_mentions with an empty parse list so Discord resolves no
mentions. The four original fields keep their values and order for
Home Assistant and n8n rules. The Gotify body is unchanged. The card
names what works and shows a hint for the generic format.
2026-10-09 13:43:28 +02:00

277 lines
9.2 KiB
TypeScript

"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Webhook } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { PasswordInput } from "@/components/ui/password-input";
import { NativeSelect } from "@/components/ui/native-select";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { isWebhookFormDirty } from "@/components/settings/channel-form-dirty";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "@/components/settings/notification-channel-toggle";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import type { WebhookPayloadFormat } from "@/lib/notifications/types";
interface WebhookSettings {
enabled: boolean;
url: string;
headerName: string;
hasHeaderValue: boolean;
format?: WebhookPayloadFormat;
}
export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [url, setUrl] = useState("");
const [headerName, setHeaderName] = useState("");
const [headerValue, setHeaderValue] = useState("");
const [format, setFormat] = useState<WebhookPayloadFormat>("generic");
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
);
const { data: settings } = useQuery({
queryKey: queryKeys.settingsWebhook(),
queryFn: async () => {
return apiGet<WebhookSettings>("/api/settings/webhook");
},
enabled: isAuthenticated,
});
// React-recommended sync-from-server pattern (no setState-in-effect).
const settingsKey = settings
? `${settings.url}|${settings.headerName}|${settings.format ?? "generic"}`
: null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey && settingsKey !== seededKey) {
setSeededKey(settingsKey);
setUrl(settings!.url);
setHeaderName(settings!.headerName);
setFormat(settings!.format === "gotify" ? "gotify" : "generic");
}
const dirty = isWebhookFormDirty(
{ url, headerName, headerValue, format },
settings,
);
const save = useMutation({
mutationFn: async (enabled: boolean) => {
const res = await apiFetchRaw("/api/settings/webhook", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url,
headerName: headerName || undefined,
headerValue: headerValue || undefined,
format,
enabled,
}),
});
if (!res.ok) {
const json = (await res.json()) as {
error?: string;
meta?: { errorCode?: string };
};
// A coded refusal (the private-origin policy) translates like the
// test button's; anything else shows the server sentence.
const code = json.meta?.errorCode;
const key = code ? `settings.testConnection.errors.${code}` : null;
const translated = key ? t(key) : null;
throw new Error(
translated && translated !== key
? translated
: json.error || t("common.error"),
);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() });
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
setHeaderValue("");
},
onError: (err: Error) => {
setSaveMsg(err.message);
setSaveMsgType("error");
},
});
const toggleEnabled = useMutation<
void,
Error,
boolean,
WebhookSettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled(
"/api/settings/webhook",
enabled,
t("common.error"),
),
onMutate: async (enabled) => {
const queryKey = queryKeys.settingsWebhook();
await queryClient.cancelQueries({ queryKey });
setSaveMsg(null);
setSaveMsgType(null);
return optimisticallySetChannelEnabled<WebhookSettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(
queryClient,
queryKeys.settingsWebhook(),
previous,
);
setSaveMsg(err.message);
setSaveMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() });
},
});
return (
<SettingsCard>
<SettingsCardHeader
icon={Webhook}
title={t("settings.webhook")}
description={t("settings.webhookDescription")}
/>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="webhook-toggle">{t("settings.webhookEnable")}</Label>
<Switch
id="webhook-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={save.isPending || toggleEnabled.isPending}
/>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate(settings?.enabled ?? false);
}}
className="space-y-4"
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="webhook-url">{t("settings.webhookUrl")}</Label>
<Input
id="webhook-url"
placeholder="https://gotify.example.com/message?token=..."
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
{t("settings.webhookUrlHint")}
</p>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="webhook-format">
{t("settings.webhookFormat")}
</Label>
<NativeSelect
id="webhook-format"
value={format}
onChange={(e) =>
setFormat(e.target.value === "gotify" ? "gotify" : "generic")
}
>
<option value="generic">
{t("settings.webhookFormatGeneric")}
</option>
<option value="gotify">
{t("settings.webhookFormatGotify")}
</option>
</NativeSelect>
<p className="text-muted-foreground text-xs">
{format === "gotify"
? t("settings.webhookFormatGotifyHint")
: t("settings.webhookFormatGenericHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="webhook-header-name">
{t("settings.webhookHeaderName")}
</Label>
<Input
id="webhook-header-name"
placeholder="Authorization"
value={headerName}
onChange={(e) => setHeaderName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="webhook-header-value">
{t("settings.webhookHeaderValue")}
</Label>
<PasswordInput
id="webhook-header-value"
placeholder={
settings?.hasHeaderValue
? t("settings.withingsCredentialsSavedPlaceholder")
: t("common.optional")
}
value={headerValue}
onChange={(e) => setHeaderValue(e.target.value)}
/>
</div>
</div>
{saveMsg && (saveMsgType === "error" || !dirty) && (
<p
role="alert"
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{saveMsg}
</p>
)}
<SettingsCardActions>
<TestConnectionButton
endpoint="/api/settings/webhook/test"
disabled={!settings?.enabled}
unsavedChanges={dirty}
/>
<Button
type="submit"
disabled={save.isPending || toggleEnabled.isPending}
className="min-h-11"
>
{save.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</SettingsCardActions>
</form>
</div>
</SettingsCard>
);
}