mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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
229 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|