From 55fb221faab203d1f512aba6f19bda577b1443b8 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sun, 3 May 2026 21:03:16 -0500 Subject: [PATCH] feat(settings): per-integration Test button with live API checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each integration card now has a Test button that re-runs its connectivity check on demand and updates the badge in place. - Refactors checkIntegrationStatus into checkIntegration(name) so a single integration can be tested without firing the others. - Adds POST /api/settings/test for the on-demand check. - Replaces the "key exists ⇒ connected" placeholder for Tavily, Gemini, and Nextdoor with real API calls (Tavily search, Gemini models list, Nextdoor advertiser fetch). 401/403 surface as "Invalid API key" / "Token expired or invalid" so a stale credential is obvious instead of pretending to be healthy. - Fixes Postiz auth header (was Bearer, the rest of the codebase passes the API key bare — matches lib/postiz.ts now). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/(dashboard)/settings/page.tsx | 69 ++++++++-- app/api/settings/test/route.ts | 19 +++ lib/settings.ts | 202 +++++++++++++++++++----------- 3 files changed, 203 insertions(+), 87 deletions(-) create mode 100644 app/api/settings/test/route.ts diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 8ec890c..cad2ea9 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -12,7 +12,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { CheckCircle2, XCircle, Loader2, ExternalLink } from "lucide-react"; +import { CheckCircle2, XCircle, Loader2, ExternalLink, FlaskConical } from "lucide-react"; interface SettingsGroup { name: string; @@ -110,6 +110,7 @@ export default function SettingsPage() { const [saving, setSaving] = useState(null); const [saved, setSaved] = useState(null); const [loading, setLoading] = useState(true); + const [testing, setTesting] = useState(null); useEffect(() => { fetch("/api/settings?status=true") @@ -159,6 +160,27 @@ export default function SettingsPage() { return status[key] || { connected: false, error: "Unknown" }; } + async function handleTest(group: SettingsGroup) { + const integration = group.name.toLowerCase(); + setTesting(integration); + try { + const res = await fetch("/api/settings/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ integration }), + }); + const result = await res.json(); + setStatus((prev) => ({ ...prev, [integration]: result })); + } catch { + setStatus((prev) => ({ + ...prev, + [integration]: { connected: false, error: "Test request failed" }, + })); + } finally { + setTesting(null); + } + } + if (loading) { return (
@@ -179,6 +201,8 @@ export default function SettingsPage() { {SETTINGS_GROUPS.map((group) => { const groupStatus = getGroupStatus(group); + const integrationKey = group.name.toLowerCase(); + const isTesting = testing === integrationKey; return ( @@ -186,7 +210,15 @@ export default function SettingsPage() {
{group.name} - {groupStatus.connected ? ( + {isTesting ? ( + + + Testing… + + ) : groupStatus.connected ? ( )}
- - Docs - - +
+ + + Docs + + +
{group.description} diff --git a/app/api/settings/test/route.ts b/app/api/settings/test/route.ts new file mode 100644 index 0000000..d94f662 --- /dev/null +++ b/app/api/settings/test/route.ts @@ -0,0 +1,19 @@ +import { auth } from "@/lib/auth"; +import { checkIntegration, type IntegrationName } from "@/lib/settings"; + +const VALID: IntegrationName[] = ["claude", "postiz", "tavily", "gemini", "nextdoor"]; + +export async function POST(request: Request) { + const session = await auth(); + if (!session) return new Response("Unauthorized", { status: 401 }); + + const body = await request.json().catch(() => ({})); + const name = body?.integration as IntegrationName | undefined; + + if (!name || !VALID.includes(name)) { + return Response.json({ error: "integration must be one of: " + VALID.join(", ") }, { status: 400 }); + } + + const status = await checkIntegration(name); + return Response.json(status); +} diff --git a/lib/settings.ts b/lib/settings.ts index fd55133..5bda1c3 100644 --- a/lib/settings.ts +++ b/lib/settings.ts @@ -102,82 +102,132 @@ export async function saveSetting(key: SettingKey, value: string) { }); } -/** - * Check connectivity status for each integration. - */ -export async function checkIntegrationStatus(): Promise> { - const settings = await getAllSettings(); - const status: Record = {}; +export type IntegrationName = "claude" | "postiz" | "tavily" | "gemini" | "nextdoor"; +export type IntegrationStatus = { connected: boolean; error?: string }; - // Claude OAuth token — validate by hitting the messages API with a 1-token call. - // A valid OAuth token returns 200; an expired/invalid one returns 401. - if (settings.CLAUDE_CODE_OAUTH_TOKEN) { - try { - const res = await fetch("https://api.anthropic.com/v1/messages", { - method: "POST", - headers: { - "Content-Type": "application/json", - "anthropic-version": "2023-06-01", - "anthropic-beta": "oauth-2025-04-20", - Authorization: `Bearer ${settings.CLAUDE_CODE_OAUTH_TOKEN}`, - }, - body: JSON.stringify({ - model: "claude-haiku-4-5-20251001", - max_tokens: 1, - messages: [{ role: "user", content: "." }], - }), - signal: AbortSignal.timeout(10000), - }); - if (res.ok) { - status.claude = { connected: true }; - } else if (res.status === 401) { - status.claude = { connected: false, error: "Token expired or invalid" }; - } else { - status.claude = { connected: false, error: `HTTP ${res.status}` }; - } - } catch (e) { - status.claude = { connected: false, error: e instanceof Error ? e.message : "Connection failed" }; - } - } else { - status.claude = { connected: false, error: "Not configured" }; +function errorMessage(e: unknown): string { + if (e instanceof Error) { + if (e.name === "TimeoutError" || e.name === "AbortError") return "Timed out"; + return e.message; } - - // Postiz - if (settings.POSTIZ_URL && settings.POSTIZ_API_KEY) { - try { - const res = await fetch(`${settings.POSTIZ_URL}/public/v1/integrations`, { - headers: { Authorization: `Bearer ${settings.POSTIZ_API_KEY}` }, - signal: AbortSignal.timeout(5000), - }); - status.postiz = { connected: res.ok }; - if (!res.ok) status.postiz.error = `HTTP ${res.status}`; - } catch (e) { - status.postiz = { connected: false, error: e instanceof Error ? e.message : "Connection failed" }; - } - } else { - status.postiz = { connected: false, error: "Not configured" }; - } - - // Tavily - if (settings.TAVILY_API_KEY) { - status.tavily = { connected: true }; // No ping endpoint, just check if key exists - } else { - status.tavily = { connected: false, error: "Not configured" }; - } - - // Gemini - if (settings.GEMINI_API_KEY) { - status.gemini = { connected: true }; - } else { - status.gemini = { connected: false, error: "Not configured" }; - } - - // Nextdoor - if (settings.NEXTDOOR_API_TOKEN && settings.NEXTDOOR_ADVERTISER_ID) { - status.nextdoor = { connected: true }; - } else { - status.nextdoor = { connected: false, error: "Not configured" }; - } - - return status; + return "Connection failed"; +} + +/** + * Live-test a single integration by making a real API call. + */ +export async function checkIntegration(name: IntegrationName): Promise { + const settings = await getAllSettings(); + + switch (name) { + case "claude": { + // OAuth token — validate by hitting the messages API with a 1-token call. + if (!settings.CLAUDE_CODE_OAUTH_TOKEN) return { connected: false, error: "Not configured" }; + try { + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + "anthropic-beta": "oauth-2025-04-20", + Authorization: `Bearer ${settings.CLAUDE_CODE_OAUTH_TOKEN}`, + }, + body: JSON.stringify({ + model: "claude-haiku-4-5-20251001", + max_tokens: 1, + messages: [{ role: "user", content: "." }], + }), + signal: AbortSignal.timeout(10000), + }); + if (res.ok) return { connected: true }; + if (res.status === 401) return { connected: false, error: "Token expired or invalid" }; + return { connected: false, error: `HTTP ${res.status}` }; + } catch (e) { + return { connected: false, error: errorMessage(e) }; + } + } + + case "postiz": { + if (!settings.POSTIZ_URL || !settings.POSTIZ_API_KEY) return { connected: false, error: "Not configured" }; + try { + const res = await fetch(`${settings.POSTIZ_URL}/public/v1/integrations`, { + headers: { Authorization: settings.POSTIZ_API_KEY }, + signal: AbortSignal.timeout(5000), + }); + if (res.ok) return { connected: true }; + return { connected: false, error: `HTTP ${res.status}` }; + } catch (e) { + return { connected: false, error: errorMessage(e) }; + } + } + + case "tavily": { + if (!settings.TAVILY_API_KEY) return { connected: false, error: "Not configured" }; + try { + const res = await fetch("https://api.tavily.com/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + api_key: settings.TAVILY_API_KEY, + query: "ping", + max_results: 1, + }), + signal: AbortSignal.timeout(8000), + }); + if (res.ok) return { connected: true }; + if (res.status === 401 || res.status === 403) return { connected: false, error: "Invalid API key" }; + return { connected: false, error: `HTTP ${res.status}` }; + } catch (e) { + return { connected: false, error: errorMessage(e) }; + } + } + + case "gemini": { + if (!settings.GEMINI_API_KEY) return { connected: false, error: "Not configured" }; + try { + const res = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(settings.GEMINI_API_KEY)}`, + { signal: AbortSignal.timeout(8000) } + ); + if (res.ok) return { connected: true }; + if (res.status === 400 || res.status === 403) return { connected: false, error: "Invalid API key" }; + return { connected: false, error: `HTTP ${res.status}` }; + } catch (e) { + return { connected: false, error: errorMessage(e) }; + } + } + + case "nextdoor": { + if (!settings.NEXTDOOR_API_TOKEN || !settings.NEXTDOOR_ADVERTISER_ID) { + return { connected: false, error: "Not configured" }; + } + try { + const res = await fetch( + `https://api.nextdoor.com/v1/advertiser/${encodeURIComponent(settings.NEXTDOOR_ADVERTISER_ID)}`, + { + headers: { Authorization: `Bearer ${settings.NEXTDOOR_API_TOKEN}` }, + signal: AbortSignal.timeout(8000), + } + ); + if (res.ok) return { connected: true }; + if (res.status === 401 || res.status === 403) return { connected: false, error: "Token expired or invalid" }; + if (res.status === 404) return { connected: false, error: "Advertiser ID not found" }; + return { connected: false, error: `HTTP ${res.status}` }; + } catch (e) { + return { connected: false, error: errorMessage(e) }; + } + } + } +} + +const ALL_INTEGRATIONS: IntegrationName[] = ["claude", "postiz", "tavily", "gemini", "nextdoor"]; + +/** + * Check connectivity status for every integration in parallel. + */ +export async function checkIntegrationStatus(): Promise> { + const results = await Promise.all( + ALL_INTEGRATIONS.map(async (name) => [name, await checkIntegration(name)] as const) + ); + return Object.fromEntries(results); }