Files
HealthLog/src/components/settings/ntfy-card.tsx
T
Marc-André Bombeck 9c306f04e7 fix(notifications): name the never-grantable refusal and redact the grant log
A loopback, link-local or metadata target answered
private_origin_not_approved with a message telling the user to have the
operator list it, and the parser refuses such an entry forever: a dead
end the route test even pinned. Those targets now answer
private_origin_not_grantable (catalogued, on all four routes, translated
in seven locales) with a message that says no grant can open them and
to use the relay's LAN address. Both senders carry the reason into the
outcome so the test button forwards the same code.

The malformed-entry warning printed the entry verbatim, which is where
a pasted token or userinfo sits; the parser now hands callers scheme
and host only, plus the reason in words. The Nightscout list throws a
named error with that redacted entry and reason, and the connect route
answers it as a 422 with the message instead of a bare 500, so a
self-host that listed http://localhost:1337 reads what to change.

The two settings cards translate a coded save refusal like the test
button does; the docs and the changelog name the sub-host and sibling-
port narrowing; the dispatcher floor tests assert the policy marker so
a real NXDOMAIN cannot pass for a refusal.

Refs #947
2026-09-10 18:45:57 +02:00

229 lines
7.4 KiB
TypeScript

"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Loader2 } 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 { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
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";
interface NtfySettings {
enabled: boolean;
serverUrl: string;
topic: string;
hasAuthToken: boolean;
}
export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [serverUrl, setServerUrl] = useState("https://ntfy.sh");
const [topic, setTopic] = useState("");
const [authToken, setAuthToken] = useState("");
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
);
const { data: settings } = useQuery({
queryKey: queryKeys.settingsNtfy(),
queryFn: async () => {
return apiGet<NtfySettings>("/api/settings/ntfy");
},
enabled: isAuthenticated,
});
// React-recommended sync-from-server pattern (no setState-in-effect).
const settingsKey = settings
? `${settings.serverUrl}|${settings.topic}`
: null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey && settingsKey !== seededKey) {
setSeededKey(settingsKey);
setServerUrl(settings!.serverUrl);
setTopic(settings!.topic);
}
const save = useMutation({
mutationFn: async (enabled: boolean) => {
const res = await apiFetchRaw("/api/settings/ntfy", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
serverUrl,
topic,
authToken: authToken || undefined,
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.settingsNtfy() });
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
setAuthToken("");
},
onError: (err: Error) => {
setSaveMsg(err.message);
setSaveMsgType("error");
},
});
const toggleEnabled = useMutation<
void,
Error,
boolean,
NtfySettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled("/api/settings/ntfy", enabled, t("common.error")),
onMutate: async (enabled) => {
const queryKey = queryKeys.settingsNtfy();
await queryClient.cancelQueries({ queryKey });
setSaveMsg(null);
setSaveMsgType(null);
return optimisticallySetChannelEnabled<NtfySettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(queryClient, queryKeys.settingsNtfy(), previous);
setSaveMsg(err.message);
setSaveMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() });
},
});
return (
<SettingsCard>
<SettingsCardHeader
icon={Bell}
title={t("settings.ntfy")}
description={t("settings.ntfyDescription")}
/>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="ntfy-toggle">{t("settings.ntfyEnable")}</Label>
<Switch
id="ntfy-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">
<Label htmlFor="ntfy-server">{t("settings.ntfyServer")}</Label>
<Input
id="ntfy-server"
placeholder="https://ntfy.sh"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ntfy-topic">{t("settings.ntfyTopic")}</Label>
<Input
id="ntfy-topic"
placeholder="healthlog-mein-topic"
value={topic}
onChange={(e) => setTopic(e.target.value)}
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="ntfy-auth">{t("settings.ntfyAuthToken")}</Label>
<PasswordInput
id="ntfy-auth"
placeholder={
settings?.hasAuthToken
? t("settings.withingsCredentialsSavedPlaceholder")
: t("common.optional")
}
value={authToken}
onChange={(e) => setAuthToken(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
{t("settings.ntfyAuthTokenHint")}
</p>
</div>
</div>
{saveMsg && (
<p
role="alert"
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{saveMsg}
</p>
)}
<SettingsCardActions>
<TestConnectionButton
endpoint="/api/settings/ntfy/test"
disabled={!settings?.enabled}
/>
<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>
);
}