diff --git a/src/lib/api.ts b/src/lib/api.ts index 84b0835..f4847f4 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -20,7 +20,7 @@ export class ApiError extends Error { export async function apiFetch(path: string, options: RequestInit = {}): Promise { const token = getToken() const headers: Record = { - 'Content-Type': 'application/json', + ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...(options.headers as Record | undefined), } if (token) headers.Authorization = `Bearer ${token}` diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 2655a49..7b2d294 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,4 +1,5 @@ -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import { api, ApiError, type Integration } from '../lib/api' import { User, Palette, @@ -34,16 +35,16 @@ const accentColors = [ { name: 'Red', color: '#E74C3C' }, ] -type IntegrationField = { label: string; value: string; secret?: boolean } +type FieldDef = { key: string; label: string; secret?: boolean } -const integrations: { name: string; online: boolean; fields: IntegrationField[] }[] = [ - { name: 'Proxmox', online: true, fields: [{ label: 'Host URL', value: 'https://pve1.local:8006' }, { label: 'API Token', value: '••••••••••••', secret: true }] }, - { name: 'Docker', online: true, fields: [{ label: 'Socket / Remote URL', value: 'unix:///var/run/docker.sock' }] }, - { name: 'NetBird', online: true, fields: [{ label: 'API Key', value: '••••••••••••', secret: true }] }, - { name: 'Cloudflare', online: true, fields: [{ label: 'API Token', value: '••••••••••••', secret: true }, { label: 'Zone ID', value: 'a1b2c3d4e5f6' }] }, - { name: 'AWS', online: true, fields: [{ label: 'Access Key', value: 'AKIA••••••••' }, { label: 'Secret', value: '••••••••••••', secret: true }, { label: 'Region', value: 'us-east-1' }] }, - { name: 'Uptime Kuma', online: false, fields: [{ label: 'URL', value: '' }, { label: 'API Key', value: '', secret: true }] }, - { name: 'Weather API', online: true, fields: [{ label: 'Location', value: 'Charlotte, NC' }, { label: 'Units', value: 'Imperial' }] }, +const integrationTypeDefs: { type: string; name: string; fields: FieldDef[] }[] = [ + { type: 'proxmox', name: 'Proxmox', fields: [{ key: 'baseUrl', label: 'Host URL' }, { key: 'apiKey', label: 'API Token', secret: true }] }, + { type: 'docker', name: 'Docker', fields: [{ key: 'baseUrl', label: 'Socket / Remote URL' }] }, + { type: 'netbird', name: 'NetBird', fields: [{ key: 'apiKey', label: 'API Key', secret: true }] }, + { type: 'cloudflare', name: 'Cloudflare', fields: [{ key: 'apiKey', label: 'API Token', secret: true }, { key: 'zoneId', label: 'Zone ID' }] }, + { type: 'aws', name: 'AWS', fields: [{ key: 'accessKey', label: 'Access Key' }, { key: 'secretKey', label: 'Secret', secret: true }, { key: 'region', label: 'Region' }] }, + { type: 'uptime_kuma', name: 'Uptime Kuma', fields: [{ key: 'baseUrl', label: 'URL' }, { key: 'apiKey', label: 'API Key', secret: true }] }, + { type: 'weather', name: 'Weather API', fields: [{ key: 'location', label: 'Location' }, { key: 'units', label: 'Units' }] }, ] const cardBase: React.CSSProperties = { @@ -315,7 +316,15 @@ function AppearanceSection() { } function IntegrationsSection() { + const [integrations, setIntegrations] = useState(null) const [revealed, setRevealed] = useState>(new Set()) + const [drafts, setDrafts] = useState>>({}) + const [statusMsg, setStatusMsg] = useState>({}) + const [busy, setBusy] = useState>(new Set()) + + useEffect(() => { + api.listIntegrations().then(({ integrations }) => setIntegrations(integrations)) + }, []) function toggleReveal(key: string) { setRevealed((prev) => { @@ -326,68 +335,171 @@ function IntegrationsSection() { }) } + function setBusyFlag(type: string, value: boolean) { + setBusy((prev) => { + const next = new Set(prev) + if (value) next.add(type) + else next.delete(type) + return next + }) + } + + function setDraftField(type: string, fieldKey: string, value: string) { + setDrafts((prev) => ({ ...prev, [type]: { ...prev[type], [fieldKey]: value } })) + } + + async function handleSave(def: (typeof integrationTypeDefs)[number], existing: Integration | undefined) { + setBusyFlag(def.type, true) + setStatusMsg((prev) => ({ ...prev, [def.type]: '' })) + try { + const draft = drafts[def.type] ?? {} + const config: Record = {} + const secrets: Record = {} + for (const f of def.fields) { + const value = draft[f.key] + if (value === undefined) continue + if (f.secret) secrets[f.key] = value + else config[f.key] = value + } + let integration: Integration + if (existing) { + ;({ integration } = await api.updateIntegration(existing.id, { config, secrets })) + } else { + ;({ integration } = await api.createIntegration({ type: def.type, name: def.name, config, secrets })) + } + setIntegrations((prev) => { + const others = (prev ?? []).filter((i) => i.id !== integration.id) + return [...others, integration] + }) + setStatusMsg((prev) => ({ ...prev, [def.type]: 'Saved' })) + } catch (err) { + setStatusMsg((prev) => ({ ...prev, [def.type]: err instanceof ApiError ? err.message : 'Save failed' })) + } finally { + setBusyFlag(def.type, false) + } + } + + async function handleTest(def: (typeof integrationTypeDefs)[number], existing: Integration | undefined) { + if (!existing) { + setStatusMsg((prev) => ({ ...prev, [def.type]: 'Save the integration before testing' })) + return + } + setBusyFlag(def.type, true) + try { + const result = await api.testIntegration(existing.id) + setStatusMsg((prev) => ({ ...prev, [def.type]: result.message })) + const { integrations } = await api.listIntegrations() + setIntegrations(integrations) + } catch (err) { + setStatusMsg((prev) => ({ ...prev, [def.type]: err instanceof ApiError ? err.message : 'Test failed' })) + } finally { + setBusyFlag(def.type, false) + } + } + + if (!integrations) { + return ( +
+

Loading integrations…

+
+ ) + } + return (
- {integrations.map((integ) => ( -
-
-
- - {integ.name} + {integrationTypeDefs.map((def) => { + const existing = integrations.find((i) => i.type === def.type) + const online = existing?.status === 'connected' + const draft = drafts[def.type] ?? {} + + return ( +
+
+
+ + {def.name} +
+
+ {statusMsg[def.type] && ( + {statusMsg[def.type]} + )} + + +
- -
-
- {integ.fields.map((f) => { - const key = `${integ.name}-${f.label}` - const isRevealed = revealed.has(key) - return ( -
- -
- - {f.secret && ( - - )} +
+ {def.fields.map((f) => { + const key = `${def.type}-${f.key}` + const isRevealed = revealed.has(key) + const savedValue = f.secret ? '' : existing?.config[f.key] ?? '' + const value = draft[f.key] ?? savedValue + return ( +
+ +
+ setDraftField(def.type, f.key, e.target.value)} + placeholder={f.secret && existing ? '••••••••••••' : 'Not configured'} + /> + {f.secret && ( + + )} +
-
- ) - })} + ) + })} +
-
- ))} + ) + })}
) }