Files
HealthLog/src/components/settings/webhook-card.tsx
T
Marc-André Bombeck 9cfc72fe1f fix(settings): channel switches act on tap again, beside their form
The Telegram, ntfy, webhook and email cards had folded the enable
switch into the form, so turning a channel off took a Save as well.
The switch saves on tap again, optimistically with a rollback, and sits
outside the form; the form's Save covers the channel's fields and sends
the stored enable state unchanged.

The save-model guard now splits a card into save units, each form and
the rest of the card, and refuses a unit that mixes both models: an
instant switch inside the form its Save submits, or a Save outside any
form next to one, still fails. A form shows its refusal inline only,
no longer as a toast on top.
2026-10-21 21:58:22 +02:00

233 lines
8.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 { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useChannelEnabledToggle } 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";
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
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 [saveError, setSaveError] = useState<string | 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,
);
// The enable switch saves on tap; the form's Save covers the fields below.
const toggle = useChannelEnabledToggle<WebhookSettings>(
"/api/settings/webhook",
queryKeys.settingsWebhook(),
);
const save = useMutation({
mutationFn: async () => {
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: settings?.enabled ?? false,
}),
});
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.
throw new Error(
translateCodeOrNull(
t,
"settings.testConnection.errors",
json.meta?.errorCode,
) ??
(json.error || t("common.error")),
);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() });
setSaveError(null);
toastWrittenOutcome("success", t("settings.saved"));
setHeaderValue("");
},
// A form shows its refusal inline, next to the fields it is about.
onError: (err: Error) => {
setSaveError(err.message);
},
});
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) => toggle.mutate(checked)}
disabled={!settings || save.isPending || toggle.isPending}
/>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate();
}}
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>
{saveError && (
<p role="alert" className="text-destructive text-sm">
{saveError}
</p>
)}
<SettingsCardActions>
<TestConnectionButton
endpoint="/api/settings/webhook/test"
disabled={!settings?.enabled}
unsavedChanges={dirty}
/>
<Button
type="submit"
disabled={save.isPending || toggle.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>
);
}