63698333ff
The OAuth token that authenticates the spawned `claude` CLI was only readable from the container env, so an expired token meant editing .env on the Unraid host and rebuilding. Now it can be rotated from the Settings page like every other key. - Adds CLAUDE_CODE_OAUTH_TOKEN to the settings registry and a "Claude" card at the top of the settings UI. - loadPipelineEnv() injects the DB value into every spawned subprocess env (overrides the container env), covering both campaign launches and chat sessions. - checkIntegrationStatus() validates the token by hitting the Anthropic messages API with a 1-token call, surfacing 401s as "Token expired or invalid" instead of a generic "Not connected". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
294 lines
9.2 KiB
TypeScript
294 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { CheckCircle2, XCircle, Loader2, ExternalLink } from "lucide-react";
|
|
|
|
interface SettingsGroup {
|
|
name: string;
|
|
description: string;
|
|
docsUrl: string;
|
|
keys: string[];
|
|
}
|
|
|
|
interface SettingConfig {
|
|
label: string;
|
|
placeholder: string;
|
|
secret?: boolean;
|
|
}
|
|
|
|
const SETTINGS_GROUPS: SettingsGroup[] = [
|
|
{
|
|
name: "Claude",
|
|
description:
|
|
"OAuth access token for the Claude Code CLI subprocess that drives every pipeline agent. Tokens expire — refresh here when launches start failing with auth errors.",
|
|
docsUrl: "https://docs.claude.com/en/docs/claude-code/setup",
|
|
keys: ["CLAUDE_CODE_OAUTH_TOKEN"],
|
|
},
|
|
{
|
|
name: "Postiz",
|
|
description:
|
|
"Self-hosted social media scheduling. Handles Instagram and TikTok publishing.",
|
|
docsUrl: "https://postiz.com",
|
|
keys: ["POSTIZ_URL", "POSTIZ_API_KEY"],
|
|
},
|
|
{
|
|
name: "Tavily",
|
|
description:
|
|
"AI-powered web research. Used by the Trend Scout and Research agents.",
|
|
docsUrl: "https://tavily.com",
|
|
keys: ["TAVILY_API_KEY"],
|
|
},
|
|
{
|
|
name: "Gemini",
|
|
description:
|
|
"Google Gemini powers NanoBanana MCP for AI image generation in static ads. ~$0.04-0.13/image.",
|
|
docsUrl: "https://aistudio.google.com/apikey",
|
|
keys: ["GEMINI_API_KEY"],
|
|
},
|
|
{
|
|
name: "Nextdoor",
|
|
description:
|
|
"Direct Nextdoor Ads API integration for local advertising.",
|
|
docsUrl: "https://developer.nextdoor.com",
|
|
keys: ["NEXTDOOR_API_TOKEN", "NEXTDOOR_ADVERTISER_ID"],
|
|
},
|
|
];
|
|
|
|
const SETTINGS_CONFIG: Record<string, SettingConfig> = {
|
|
CLAUDE_CODE_OAUTH_TOKEN: {
|
|
label: "Claude Code OAuth Token",
|
|
placeholder: "sk-ant-oat01-...",
|
|
secret: true,
|
|
},
|
|
POSTIZ_URL: { label: "Postiz URL", placeholder: "http://localhost:5000" },
|
|
POSTIZ_API_KEY: {
|
|
label: "Postiz API Key",
|
|
placeholder: "your-postiz-api-key",
|
|
secret: true,
|
|
},
|
|
TAVILY_API_KEY: {
|
|
label: "Tavily API Key",
|
|
placeholder: "tvly-...",
|
|
secret: true,
|
|
},
|
|
GEMINI_API_KEY: {
|
|
label: "Google Gemini API Key",
|
|
placeholder: "AIza...",
|
|
secret: true,
|
|
},
|
|
NEXTDOOR_API_TOKEN: {
|
|
label: "Nextdoor API Token",
|
|
placeholder: "your-nextdoor-token",
|
|
secret: true,
|
|
},
|
|
NEXTDOOR_ADVERTISER_ID: {
|
|
label: "Nextdoor Advertiser ID",
|
|
placeholder: "your-advertiser-id",
|
|
},
|
|
};
|
|
|
|
type IntegrationStatus = Record<
|
|
string,
|
|
{ connected: boolean; error?: string }
|
|
>;
|
|
|
|
export default function SettingsPage() {
|
|
const [settings, setSettings] = useState<Record<string, string>>({});
|
|
const [status, setStatus] = useState<IntegrationStatus>({});
|
|
const [editValues, setEditValues] = useState<Record<string, string>>({});
|
|
const [saving, setSaving] = useState<string | null>(null);
|
|
const [saved, setSaved] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
fetch("/api/settings?status=true")
|
|
.then((r) => r.json())
|
|
.then((data) => {
|
|
setSettings(data.settings || {});
|
|
setStatus(data.status || {});
|
|
setLoading(false);
|
|
})
|
|
.catch(() => setLoading(false));
|
|
}, []);
|
|
|
|
async function handleSave(key: string) {
|
|
const value = editValues[key];
|
|
if (value === undefined) return;
|
|
|
|
setSaving(key);
|
|
const res = await fetch("/api/settings", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ key, value }),
|
|
});
|
|
|
|
if (res.ok) {
|
|
setSaved(key);
|
|
// Refresh settings
|
|
const data = await fetch("/api/settings?status=true").then((r) =>
|
|
r.json()
|
|
);
|
|
setSettings(data.settings || {});
|
|
setStatus(data.status || {});
|
|
setEditValues((prev) => {
|
|
const next = { ...prev };
|
|
delete next[key];
|
|
return next;
|
|
});
|
|
setTimeout(() => setSaved(null), 2000);
|
|
}
|
|
setSaving(null);
|
|
}
|
|
|
|
function getGroupStatus(group: SettingsGroup): {
|
|
connected: boolean;
|
|
error?: string;
|
|
} {
|
|
const key = group.name.toLowerCase();
|
|
return status[key] || { connected: false, error: "Unknown" };
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-20">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">Settings</h1>
|
|
<p className="text-muted-foreground mt-1">
|
|
Configure your third-party integrations. Values are stored securely in
|
|
the database and override environment variables.
|
|
</p>
|
|
</div>
|
|
|
|
{SETTINGS_GROUPS.map((group) => {
|
|
const groupStatus = getGroupStatus(group);
|
|
|
|
return (
|
|
<Card key={group.name}>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<CardTitle>{group.name}</CardTitle>
|
|
{groupStatus.connected ? (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-green-600 border-green-200 bg-green-50"
|
|
>
|
|
<CheckCircle2 className="h-3 w-3 mr-1" />
|
|
Connected
|
|
</Badge>
|
|
) : (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-amber-600 border-amber-200 bg-amber-50"
|
|
>
|
|
<XCircle className="h-3 w-3 mr-1" />
|
|
{groupStatus.error || "Not connected"}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<a
|
|
href={group.docsUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
|
>
|
|
Docs
|
|
<ExternalLink className="h-3 w-3" />
|
|
</a>
|
|
</div>
|
|
<CardDescription>{group.description}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{group.keys.map((key) => {
|
|
const config = SETTINGS_CONFIG[key];
|
|
const currentValue = settings[key] || "";
|
|
const isEditing = key in editValues;
|
|
const editValue = editValues[key] ?? "";
|
|
|
|
return (
|
|
<div key={key} className="space-y-2">
|
|
<Label htmlFor={key}>{config.label}</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id={key}
|
|
type={config.secret && !isEditing ? "password" : "text"}
|
|
placeholder={config.placeholder}
|
|
value={isEditing ? editValue : currentValue}
|
|
onChange={(e) =>
|
|
setEditValues((prev) => ({
|
|
...prev,
|
|
[key]: e.target.value,
|
|
}))
|
|
}
|
|
onFocus={() => {
|
|
if (!isEditing) {
|
|
// Clear masked value on focus for secret fields
|
|
setEditValues((prev) => ({
|
|
...prev,
|
|
[key]: "",
|
|
}));
|
|
}
|
|
}}
|
|
/>
|
|
{isEditing && (
|
|
<Button
|
|
onClick={() => handleSave(key)}
|
|
disabled={saving === key}
|
|
size="sm"
|
|
className="shrink-0"
|
|
>
|
|
{saving === key ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : saved === key ? (
|
|
<CheckCircle2 className="h-4 w-4" />
|
|
) : (
|
|
"Save"
|
|
)}
|
|
</Button>
|
|
)}
|
|
{isEditing && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="shrink-0"
|
|
onClick={() =>
|
|
setEditValues((prev) => {
|
|
const next = { ...prev };
|
|
delete next[key];
|
|
return next;
|
|
})
|
|
}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|