HTTP headers are ASCII-only; the em dash in "PocketVeto — Test Notification" caused a UnicodeEncodeError on every test attempt. Replaced with colon. Frontend catch blocks now extract the real server error detail from the axios response body instead of showing a generic fallback message. Authored-By: Jack Levy
377 lines
15 KiB
TypeScript
377 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
import { Bell, Rss, CheckCircle, Copy, RefreshCw, XCircle, FlaskConical } from "lucide-react";
|
|
import { notificationsAPI, type NotificationTestResult } from "@/lib/api";
|
|
|
|
const AUTH_METHODS = [
|
|
{ value: "none", label: "No authentication", hint: "Public ntfy.sh topics or open self-hosted servers" },
|
|
{ value: "token", label: "Access token", hint: "ntfy token (tk_...)" },
|
|
{ value: "basic", label: "Username & password", hint: "For servers behind HTTP basic auth or nginx ACL" },
|
|
];
|
|
|
|
export default function NotificationsPage() {
|
|
const { data: settings, refetch } = useQuery({
|
|
queryKey: ["notification-settings"],
|
|
queryFn: () => notificationsAPI.getSettings(),
|
|
});
|
|
|
|
const update = useMutation({
|
|
mutationFn: (data: Parameters<typeof notificationsAPI.updateSettings>[0]) =>
|
|
notificationsAPI.updateSettings(data),
|
|
onSuccess: () => refetch(),
|
|
});
|
|
|
|
const resetRss = useMutation({
|
|
mutationFn: () => notificationsAPI.resetRssToken(),
|
|
onSuccess: () => refetch(),
|
|
});
|
|
|
|
// ntfy form state
|
|
const [topicUrl, setTopicUrl] = useState("");
|
|
const [authMethod, setAuthMethod] = useState("none");
|
|
const [token, setToken] = useState("");
|
|
const [username, setUsername] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [ntfySaved, setNtfySaved] = useState(false);
|
|
|
|
// Test state
|
|
const [ntfyTesting, setNtfyTesting] = useState(false);
|
|
const [ntfyTestResult, setNtfyTestResult] = useState<NotificationTestResult | null>(null);
|
|
const [rssTesting, setRssTesting] = useState(false);
|
|
const [rssTestResult, setRssTestResult] = useState<NotificationTestResult | null>(null);
|
|
|
|
// RSS state
|
|
const [rssSaved, setRssSaved] = useState(false);
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
// Populate from loaded settings
|
|
useEffect(() => {
|
|
if (!settings) return;
|
|
setTopicUrl(settings.ntfy_topic_url ?? "");
|
|
setAuthMethod(settings.ntfy_auth_method ?? "none");
|
|
setToken(settings.ntfy_token ?? "");
|
|
setUsername(settings.ntfy_username ?? "");
|
|
setPassword(settings.ntfy_password ?? "");
|
|
}, [settings]);
|
|
|
|
const saveNtfy = (enabled: boolean) => {
|
|
update.mutate(
|
|
{
|
|
ntfy_topic_url: topicUrl,
|
|
ntfy_auth_method: authMethod,
|
|
ntfy_token: authMethod === "token" ? token : "",
|
|
ntfy_username: authMethod === "basic" ? username : "",
|
|
ntfy_password: authMethod === "basic" ? password : "",
|
|
ntfy_enabled: enabled,
|
|
},
|
|
{ onSuccess: () => { setNtfySaved(true); setTimeout(() => setNtfySaved(false), 2000); } }
|
|
);
|
|
};
|
|
|
|
const toggleRss = (enabled: boolean) => {
|
|
update.mutate(
|
|
{ rss_enabled: enabled },
|
|
{ onSuccess: () => { setRssSaved(true); setTimeout(() => setRssSaved(false), 2000); } }
|
|
);
|
|
};
|
|
|
|
const testNtfy = async () => {
|
|
setNtfyTesting(true);
|
|
setNtfyTestResult(null);
|
|
try {
|
|
const result = await notificationsAPI.testNtfy({
|
|
ntfy_topic_url: topicUrl,
|
|
ntfy_auth_method: authMethod,
|
|
ntfy_token: authMethod === "token" ? token : "",
|
|
ntfy_username: authMethod === "basic" ? username : "",
|
|
ntfy_password: authMethod === "basic" ? password : "",
|
|
});
|
|
setNtfyTestResult(result);
|
|
} catch (e: unknown) {
|
|
const detail =
|
|
(e as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
|
?? (e instanceof Error ? e.message : "Request failed");
|
|
setNtfyTestResult({ status: "error", detail });
|
|
} finally {
|
|
setNtfyTesting(false);
|
|
}
|
|
};
|
|
|
|
const testRss = async () => {
|
|
setRssTesting(true);
|
|
setRssTestResult(null);
|
|
try {
|
|
const result = await notificationsAPI.testRss();
|
|
setRssTestResult(result);
|
|
} catch (e: unknown) {
|
|
const detail =
|
|
(e as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
|
?? (e instanceof Error ? e.message : "Feed check failed");
|
|
setRssTestResult({ status: "error", detail });
|
|
} finally {
|
|
setRssTesting(false);
|
|
}
|
|
};
|
|
|
|
const rssUrl = settings?.rss_token
|
|
? `${typeof window !== "undefined" ? window.location.origin : ""}/api/notifications/feed/${settings.rss_token}.xml`
|
|
: null;
|
|
|
|
return (
|
|
<div className="space-y-8 max-w-2xl">
|
|
<div>
|
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
|
<Bell className="w-5 h-5" /> Notifications
|
|
</h1>
|
|
<p className="text-muted-foreground text-sm mt-1">
|
|
Get alerted when bills you follow are updated, new text is published, or amendments are filed.
|
|
</p>
|
|
</div>
|
|
|
|
{/* ntfy */}
|
|
<section className="bg-card border border-border rounded-lg p-6 space-y-5">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h2 className="font-semibold flex items-center gap-2">
|
|
<Bell className="w-4 h-4" /> Push Notifications (ntfy)
|
|
</h2>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Uses <a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">ntfy</a> — a free, open-source push notification service.
|
|
Use the public <code className="bg-muted px-1 rounded">ntfy.sh</code> server or your own self-hosted instance.
|
|
</p>
|
|
</div>
|
|
{settings?.ntfy_enabled && (
|
|
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400 shrink-0">
|
|
<CheckCircle className="w-3.5 h-3.5" /> Active
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Topic URL */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium">Topic URL</label>
|
|
<p className="text-xs text-muted-foreground">
|
|
The full URL to your ntfy topic, e.g.{" "}
|
|
<code className="bg-muted px-1 rounded">https://ntfy.sh/my-pocketveto-alerts</code>
|
|
</p>
|
|
<input
|
|
type="url"
|
|
placeholder="https://ntfy.sh/your-topic"
|
|
value={topicUrl}
|
|
onChange={(e) => setTopicUrl(e.target.value)}
|
|
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
|
/>
|
|
</div>
|
|
|
|
{/* Auth method */}
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Authentication</label>
|
|
<div className="space-y-2">
|
|
{AUTH_METHODS.map(({ value, label, hint }) => (
|
|
<label key={value} className="flex items-start gap-3 cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="ntfy-auth"
|
|
value={value}
|
|
checked={authMethod === value}
|
|
onChange={() => setAuthMethod(value)}
|
|
className="mt-0.5"
|
|
/>
|
|
<div>
|
|
<div className="text-sm font-medium">{label}</div>
|
|
<div className="text-xs text-muted-foreground">{hint}</div>
|
|
</div>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Token input */}
|
|
{authMethod === "token" && (
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium">Access Token</label>
|
|
<input
|
|
type="password"
|
|
placeholder="tk_..."
|
|
value={token}
|
|
onChange={(e) => setToken(e.target.value)}
|
|
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Basic auth inputs */}
|
|
{authMethod === "basic" && (
|
|
<div className="space-y-3">
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium">Username</label>
|
|
<input
|
|
type="text"
|
|
placeholder="your-username"
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium">Password</label>
|
|
<input
|
|
type="password"
|
|
placeholder="your-password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="space-y-3 pt-1 border-t border-border">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => saveNtfy(true)}
|
|
disabled={!topicUrl.trim() || update.isPending}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
|
>
|
|
{ntfySaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Bell className="w-3.5 h-3.5" />}
|
|
{ntfySaved ? "Saved!" : "Save & Enable"}
|
|
</button>
|
|
<button
|
|
onClick={testNtfy}
|
|
disabled={!topicUrl.trim() || ntfyTesting}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 transition-colors"
|
|
>
|
|
{ntfyTesting
|
|
? <RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
|
: <FlaskConical className="w-3.5 h-3.5" />}
|
|
{ntfyTesting ? "Sending…" : "Test"}
|
|
</button>
|
|
{settings?.ntfy_enabled && (
|
|
<button
|
|
onClick={() => saveNtfy(false)}
|
|
disabled={update.isPending}
|
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
Disable
|
|
</button>
|
|
)}
|
|
</div>
|
|
{ntfyTestResult && (
|
|
<div className={`flex items-start gap-2 text-xs rounded-md px-3 py-2 ${
|
|
ntfyTestResult.status === "ok"
|
|
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400"
|
|
: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400"
|
|
}`}>
|
|
{ntfyTestResult.status === "ok"
|
|
? <CheckCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
|
: <XCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />}
|
|
{ntfyTestResult.detail}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{/* RSS */}
|
|
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h2 className="font-semibold flex items-center gap-2">
|
|
<Rss className="w-4 h-4" /> RSS Feed
|
|
</h2>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
A private, tokenized RSS feed of your bill alerts — subscribe in any RSS reader.
|
|
Independent of ntfy; enable either or both.
|
|
</p>
|
|
</div>
|
|
{settings?.rss_enabled && (
|
|
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400 shrink-0">
|
|
<CheckCircle className="w-3.5 h-3.5" /> Active
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{rssUrl && (
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Your feed URL</label>
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 text-xs bg-muted px-2 py-2 rounded truncate">{rssUrl}</code>
|
|
<button
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(rssUrl);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
}}
|
|
className="shrink-0 p-1.5 rounded hover:bg-accent transition-colors"
|
|
title="Copy RSS URL"
|
|
>
|
|
{copied ? <CheckCircle className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-muted-foreground" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-3 pt-1 border-t border-border">
|
|
<div className="flex items-center gap-3">
|
|
{!settings?.rss_enabled ? (
|
|
<button
|
|
onClick={() => toggleRss(true)}
|
|
disabled={update.isPending}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
|
>
|
|
{rssSaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Rss className="w-3.5 h-3.5" />}
|
|
{rssSaved ? "Enabled!" : "Enable RSS"}
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => toggleRss(false)}
|
|
disabled={update.isPending}
|
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
Disable RSS
|
|
</button>
|
|
)}
|
|
{rssUrl && (
|
|
<>
|
|
<button
|
|
onClick={testRss}
|
|
disabled={rssTesting}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 transition-colors"
|
|
>
|
|
{rssTesting
|
|
? <RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
|
: <FlaskConical className="w-3.5 h-3.5" />}
|
|
{rssTesting ? "Checking…" : "Test"}
|
|
</button>
|
|
<button
|
|
onClick={() => resetRss.mutate()}
|
|
disabled={resetRss.isPending}
|
|
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
title="Generate a new URL — old URL will stop working"
|
|
>
|
|
<RefreshCw className="w-3 h-3" />
|
|
Regenerate URL
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
{rssTestResult && (
|
|
<div className={`flex items-start gap-2 text-xs rounded-md px-3 py-2 ${
|
|
rssTestResult.status === "ok"
|
|
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400"
|
|
: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400"
|
|
}`}>
|
|
{rssTestResult.status === "ok"
|
|
? <CheckCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
|
: <XCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />}
|
|
{rssTestResult.detail}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|