feat(notifications): add Test button for ntfy and RSS with inline result
- POST /api/notifications/test/ntfy — sends a real push using current form values (not saved settings) so auth can be verified before saving; returns status + HTTP detail on success or error message on failure - POST /api/notifications/test/rss — confirms the feed token exists and returns event count; no bill FK required - NtfyTestRequest + NotificationTestResult schemas added - Frontend: Test button next to Save on both ntfy and RSS sections; result shown inline as a green/red pill; uses current form state for ntfy so the user can test before committing All future notification types should follow the same test-before-save pattern. Authored-By: Jack Levy
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Bell, Rss, CheckCircle, Copy, RefreshCw } from "lucide-react";
|
||||
import { notificationsAPI } from "@/lib/api";
|
||||
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" },
|
||||
@@ -36,6 +36,12 @@ export default function NotificationsPage() {
|
||||
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);
|
||||
@@ -71,6 +77,38 @@ export default function NotificationsPage() {
|
||||
);
|
||||
};
|
||||
|
||||
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 {
|
||||
setNtfyTestResult({ status: "error", detail: "Request failed — check your topic URL" });
|
||||
} finally {
|
||||
setNtfyTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testRss = async () => {
|
||||
setRssTesting(true);
|
||||
setRssTestResult(null);
|
||||
try {
|
||||
const result = await notificationsAPI.testRss();
|
||||
setRssTestResult(result);
|
||||
} catch {
|
||||
setRssTestResult({ status: "error", detail: "Feed check failed" });
|
||||
} finally {
|
||||
setRssTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rssUrl = settings?.rss_token
|
||||
? `${typeof window !== "undefined" ? window.location.origin : ""}/api/notifications/feed/${settings.rss_token}.xml`
|
||||
: null;
|
||||
@@ -185,23 +223,47 @@ export default function NotificationsPage() {
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3 pt-1 border-t border-border">
|
||||
<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>
|
||||
{settings?.ntfy_enabled && (
|
||||
<div className="space-y-3 pt-1 border-t border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => saveNtfy(false)}
|
||||
disabled={update.isPending}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
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"
|
||||
>
|
||||
Disable
|
||||
{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>
|
||||
@@ -245,35 +307,61 @@ export default function NotificationsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 pt-1 border-t border-border">
|
||||
{!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={() => 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 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>
|
||||
|
||||
@@ -165,6 +165,12 @@ export interface AnalysisStats {
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export interface NotificationTestResult {
|
||||
status: "ok" | "error";
|
||||
detail: string;
|
||||
event_count?: number;
|
||||
}
|
||||
|
||||
// Notifications
|
||||
export const notificationsAPI = {
|
||||
getSettings: () =>
|
||||
@@ -173,6 +179,16 @@ export const notificationsAPI = {
|
||||
apiClient.put<NotificationSettings>("/api/notifications/settings", data).then((r) => r.data),
|
||||
resetRssToken: () =>
|
||||
apiClient.post<NotificationSettings>("/api/notifications/settings/rss-reset").then((r) => r.data),
|
||||
testNtfy: (data: {
|
||||
ntfy_topic_url: string;
|
||||
ntfy_auth_method: string;
|
||||
ntfy_token: string;
|
||||
ntfy_username: string;
|
||||
ntfy_password: string;
|
||||
}) =>
|
||||
apiClient.post<NotificationTestResult>("/api/notifications/test/ntfy", data).then((r) => r.data),
|
||||
testRss: () =>
|
||||
apiClient.post<NotificationTestResult>("/api/notifications/test/rss").then((r) => r.data),
|
||||
};
|
||||
|
||||
// Admin
|
||||
|
||||
Reference in New Issue
Block a user