Wire Settings Integrations to real backend API
Replaces mock integration data in Settings.tsx with live calls to api.listIntegrations/createIntegration/updateIntegration/testIntegration. Also fixes apiFetch sending Content-Type: application/json on bodyless requests, which made Fastify reject Test Connection calls with 400. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
This commit is contained in:
parent
e2793b06fe
commit
5c1fc911c9
2 changed files with 180 additions and 68 deletions
|
|
@ -20,7 +20,7 @@ export class ApiError extends Error {
|
||||||
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
...(options.headers as Record<string, string> | undefined),
|
...(options.headers as Record<string, string> | undefined),
|
||||||
}
|
}
|
||||||
if (token) headers.Authorization = `Bearer ${token}`
|
if (token) headers.Authorization = `Bearer ${token}`
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { api, ApiError, type Integration } from '../lib/api'
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
Palette,
|
Palette,
|
||||||
|
|
@ -34,16 +35,16 @@ const accentColors = [
|
||||||
{ name: 'Red', color: '#E74C3C' },
|
{ 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[] }[] = [
|
const integrationTypeDefs: { type: string; name: string; fields: FieldDef[] }[] = [
|
||||||
{ name: 'Proxmox', online: true, fields: [{ label: 'Host URL', value: 'https://pve1.local:8006' }, { label: 'API Token', value: '••••••••••••', secret: true }] },
|
{ type: 'proxmox', name: 'Proxmox', fields: [{ key: 'baseUrl', label: 'Host URL' }, { key: 'apiKey', label: 'API Token', secret: true }] },
|
||||||
{ name: 'Docker', online: true, fields: [{ label: 'Socket / Remote URL', value: 'unix:///var/run/docker.sock' }] },
|
{ type: 'docker', name: 'Docker', fields: [{ key: 'baseUrl', label: 'Socket / Remote URL' }] },
|
||||||
{ name: 'NetBird', online: true, fields: [{ label: 'API Key', value: '••••••••••••', secret: true }] },
|
{ type: 'netbird', name: 'NetBird', fields: [{ key: 'apiKey', label: 'API Key', secret: true }] },
|
||||||
{ name: 'Cloudflare', online: true, fields: [{ label: 'API Token', value: '••••••••••••', secret: true }, { label: 'Zone ID', value: 'a1b2c3d4e5f6' }] },
|
{ type: 'cloudflare', name: 'Cloudflare', fields: [{ key: 'apiKey', label: 'API Token', secret: true }, { key: 'zoneId', label: 'Zone ID' }] },
|
||||||
{ name: 'AWS', online: true, fields: [{ label: 'Access Key', value: 'AKIA••••••••' }, { label: 'Secret', value: '••••••••••••', secret: true }, { label: 'Region', value: 'us-east-1' }] },
|
{ type: 'aws', name: 'AWS', fields: [{ key: 'accessKey', label: 'Access Key' }, { key: 'secretKey', label: 'Secret', secret: true }, { key: 'region', label: 'Region' }] },
|
||||||
{ name: 'Uptime Kuma', online: false, fields: [{ label: 'URL', value: '' }, { label: 'API Key', value: '', secret: true }] },
|
{ type: 'uptime_kuma', name: 'Uptime Kuma', fields: [{ key: 'baseUrl', label: 'URL' }, { key: 'apiKey', label: 'API Key', secret: true }] },
|
||||||
{ name: 'Weather API', online: true, fields: [{ label: 'Location', value: 'Charlotte, NC' }, { label: 'Units', value: 'Imperial' }] },
|
{ type: 'weather', name: 'Weather API', fields: [{ key: 'location', label: 'Location' }, { key: 'units', label: 'Units' }] },
|
||||||
]
|
]
|
||||||
|
|
||||||
const cardBase: React.CSSProperties = {
|
const cardBase: React.CSSProperties = {
|
||||||
|
|
@ -315,7 +316,15 @@ function AppearanceSection() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function IntegrationsSection() {
|
function IntegrationsSection() {
|
||||||
|
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
|
||||||
const [revealed, setRevealed] = useState<Set<string>>(new Set())
|
const [revealed, setRevealed] = useState<Set<string>>(new Set())
|
||||||
|
const [drafts, setDrafts] = useState<Record<string, Record<string, string>>>({})
|
||||||
|
const [statusMsg, setStatusMsg] = useState<Record<string, string>>({})
|
||||||
|
const [busy, setBusy] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
|
||||||
|
}, [])
|
||||||
|
|
||||||
function toggleReveal(key: string) {
|
function toggleReveal(key: string) {
|
||||||
setRevealed((prev) => {
|
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<string, string> = {}
|
||||||
|
const secrets: Record<string, string> = {}
|
||||||
|
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 (
|
||||||
|
<div style={cardBase}>
|
||||||
|
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Loading integrations…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{integrations.map((integ) => (
|
{integrationTypeDefs.map((def) => {
|
||||||
<div key={integ.name} style={cardBase}>
|
const existing = integrations.find((i) => i.type === def.type)
|
||||||
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
const online = existing?.status === 'connected'
|
||||||
<div className="flex items-center gap-2.5">
|
const draft = drafts[def.type] ?? {}
|
||||||
<span
|
|
||||||
style={{
|
return (
|
||||||
width: '8px',
|
<div key={def.type} style={cardBase}>
|
||||||
height: '8px',
|
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
||||||
borderRadius: '50%',
|
<div className="flex items-center gap-2.5">
|
||||||
backgroundColor: integ.online ? '#2ECC71' : '#4A4D55',
|
<span
|
||||||
boxShadow: integ.online ? '0 0 6px rgba(46,204,113,0.6)' : 'none',
|
style={{
|
||||||
}}
|
width: '8px',
|
||||||
/>
|
height: '8px',
|
||||||
<span style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 600 }}>{integ.name}</span>
|
borderRadius: '50%',
|
||||||
|
backgroundColor: online ? '#2ECC71' : '#4A4D55',
|
||||||
|
boxShadow: online ? '0 0 6px rgba(46,204,113,0.6)' : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 600 }}>{def.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{statusMsg[def.type] && (
|
||||||
|
<span style={{ fontSize: '11px', color: '#7A7D85' }}>{statusMsg[def.type]}</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleSave(def, existing)}
|
||||||
|
disabled={busy.has(def.type)}
|
||||||
|
className="cursor-pointer border-none"
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#0A0B0D',
|
||||||
|
backgroundColor: '#C8A434',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
opacity: busy.has(def.type) ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleTest(def, existing)}
|
||||||
|
disabled={busy.has(def.type)}
|
||||||
|
className="cursor-pointer border-none"
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#C8A434',
|
||||||
|
backgroundColor: 'rgba(200,164,52,0.08)',
|
||||||
|
border: '1px solid rgba(200,164,52,0.2)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
opacity: busy.has(def.type) ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Test Connection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="grid grid-cols-3 gap-4">
|
||||||
className="cursor-pointer border-none"
|
{def.fields.map((f) => {
|
||||||
style={{
|
const key = `${def.type}-${f.key}`
|
||||||
fontSize: '11px',
|
const isRevealed = revealed.has(key)
|
||||||
fontWeight: 600,
|
const savedValue = f.secret ? '' : existing?.config[f.key] ?? ''
|
||||||
color: '#C8A434',
|
const value = draft[f.key] ?? savedValue
|
||||||
backgroundColor: 'rgba(200,164,52,0.08)',
|
return (
|
||||||
border: '1px solid rgba(200,164,52,0.2)',
|
<div key={key}>
|
||||||
borderRadius: '6px',
|
<label style={labelStyle}>{f.label}</label>
|
||||||
padding: '6px 12px',
|
<div className="relative">
|
||||||
}}
|
<input
|
||||||
>
|
style={inputStyle}
|
||||||
Test Connection
|
type={f.secret && !isRevealed ? 'password' : 'text'}
|
||||||
</button>
|
value={value}
|
||||||
</div>
|
onChange={(e) => setDraftField(def.type, f.key, e.target.value)}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
placeholder={f.secret && existing ? '••••••••••••' : 'Not configured'}
|
||||||
{integ.fields.map((f) => {
|
/>
|
||||||
const key = `${integ.name}-${f.label}`
|
{f.secret && (
|
||||||
const isRevealed = revealed.has(key)
|
<button
|
||||||
return (
|
onClick={() => toggleReveal(key)}
|
||||||
<div key={key}>
|
className="absolute cursor-pointer border-none bg-transparent"
|
||||||
<label style={labelStyle}>{f.label}</label>
|
style={{ right: '8px', top: '50%', transform: 'translateY(-50%)', color: '#7A7D85' }}
|
||||||
<div className="relative">
|
>
|
||||||
<input
|
{isRevealed ? <EyeOff size={13} /> : <Eye size={13} />}
|
||||||
style={inputStyle}
|
</button>
|
||||||
type={f.secret && !isRevealed ? 'password' : 'text'}
|
)}
|
||||||
defaultValue={f.value}
|
</div>
|
||||||
placeholder={f.value ? undefined : 'Not configured'}
|
|
||||||
/>
|
|
||||||
{f.secret && (
|
|
||||||
<button
|
|
||||||
onClick={() => toggleReveal(key)}
|
|
||||||
className="absolute cursor-pointer border-none bg-transparent"
|
|
||||||
style={{ right: '8px', top: '50%', transform: 'translateY(-50%)', color: '#7A7D85' }}
|
|
||||||
>
|
|
||||||
{isRevealed ? <EyeOff size={13} /> : <Eye size={13} />}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue