From e9cd40c80acc05de32da23504fa99f40060ec8e5 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Wed, 1 Jul 2026 18:56:27 +0000 Subject: [PATCH] Add Settings admin page and settings service proxy Wire /admin/settings route and sidebar link; proxy /settings/api/ to the new settings service at 10.10.10.116. Admin can configure integration credentials and manage Newbook room ordering from the portal. Co-Authored-By: Claude Sonnet 4.6 --- nginx.conf | 8 + src/App.tsx | 2 + src/components/Sidebar.tsx | 6 +- src/pages/AdminSettings.tsx | 391 ++++++++++++++++++++++++++++++++++++ 4 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 src/pages/AdminSettings.tsx diff --git a/nginx.conf b/nginx.conf index fb34d21..d8979f3 100644 --- a/nginx.conf +++ b/nginx.conf @@ -18,6 +18,14 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + location /settings/api/ { + proxy_pass http://10.10.10.116:3080/settings/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + add_header Cache-Control "no-store"; + } + location /deploy/ { proxy_pass http://10.10.10.105:9000/; proxy_set_header Host $host; diff --git a/src/App.tsx b/src/App.tsx index da4e2e1..d190248 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { Dashboard } from './pages/Dashboard' import { AppFrame } from './pages/AppFrame' import { AdminUsers } from './pages/AdminUsers' import { AdminMonitor } from './pages/AdminMonitor' +import { AdminSettings } from './pages/AdminSettings' // The portal is only ever the top-level frame. If it finds itself loaded inside // an iframe, it means an app's path fell back to the portal (that app isn't @@ -44,6 +45,7 @@ export default function App() { } /> : } /> : } /> + : } /> } /> )} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 90ae400..1db749b 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { NavLink, useNavigate, useLocation } from 'react-router-dom' -import { LayoutGrid, Users, Activity, LogOut, ChevronDown } from 'lucide-react' +import { LayoutGrid, Users, Activity, Settings, LogOut, ChevronDown } from 'lucide-react' import { AppIcon } from './AppIcon' import type { User, App } from '../types' @@ -127,6 +127,10 @@ export function Sidebar({ user, activeSlug }: Props) { Monitor + navItem(isActive)}> + + Settings + )} diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx new file mode 100644 index 0000000..463feff --- /dev/null +++ b/src/pages/AdminSettings.tsx @@ -0,0 +1,391 @@ +import { useState, useEffect } from 'react' +import { CheckCircle, XCircle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react' +import { Sidebar } from '../components/Sidebar' +import type { User } from '../types' + +const MASKED = '••••••••' + +interface Integration { + slug: string + name: string + config: Record + secrets: Record + enabled: boolean + updated_at: string +} + +interface RoomCategory { + id: string + name: string + sort_order: number + colour: string | null +} + +interface RoomsData { + categories: RoomCategory[] + sites: { id: string; name: string; category_id: string }[] + synced_at: string +} + +const FIELD_LABELS: Record = { + region: 'Region', username: 'Username', api_key: 'API Key', password: 'Password', + base_url: 'Base URL', client_secret: 'Client Secret', tenant_id: 'Tenant ID', + client_id: 'Client ID', host: 'Host', port: 'Port', database: 'Database', + graphql_endpoint: 'GraphQL Endpoint', +} + +export function AdminSettings({ user }: { user: User }) { + const [tab, setTab] = useState<'integrations' | 'rooms'>('integrations') + const [integrations, setIntegrations] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetch('/settings/api/integrations', { credentials: 'include' }) + .then(r => r.json()).then(setIntegrations).finally(() => setLoading(false)) + }, []) + + return ( +
+ +
+

+ Settings +

+

+ Third-party integrations and global configuration +

+ +
+ {(['integrations', 'rooms'] as const).map(t => ( + + ))} +
+ + {tab === 'integrations' && ( + loading + ?

Loading…

+ :
+ {integrations.map(intg => ( + + setIntegrations(prev => prev.map(i => i.slug === updated.slug ? updated : i)) + } /> + ))} +
+ )} + + {tab === 'rooms' && } +
+
+ ) +} + +function IntegrationCard({ integration, onSaved }: { integration: Integration; onSaved: (i: Integration) => void }) { + const [open, setOpen] = useState(false) + const [form, setForm] = useState>({}) + const [changing, setChanging] = useState>(new Set()) + const [saving, setSaving] = useState(false) + const [testing, setTesting] = useState(false) + const [testResult, setTestResult] = useState<{ ok: boolean; error?: string } | null>(null) + const [enabled, setEnabled] = useState(integration.enabled) + + function openForm() { + const initial: Record = {} + for (const [k, v] of Object.entries(integration.config)) initial[k] = v ?? '' + setForm(initial) + setChanging(new Set()) + setTestResult(null) + setOpen(true) + } + + function setField(key: string, value: string) { + setForm(prev => ({ ...prev, [key]: value })) + } + + function startChangingSecret(key: string) { + setChanging(prev => new Set([...prev, key])) + setForm(prev => ({ ...prev, [key]: '' })) + } + + async function save() { + setSaving(true) + const body: Record = { ...form, enabled } + // Include secret fields only if user is actively changing them + for (const [k, v] of Object.entries(integration.secrets)) { + if (changing.has(k)) body[k] = form[k] ?? '' + // else omit — backend keeps existing encrypted value + } + const res = await fetch(`/settings/api/integrations/${integration.slug}`, { + method: 'PUT', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + setSaving(false) + if (res.ok) { onSaved(await res.json()); setOpen(false) } + } + + async function test() { + setTesting(true) + setTestResult(null) + const res = await fetch(`/settings/api/integrations/${integration.slug}/test`, { + method: 'POST', credentials: 'include', + }) + setTestResult(await res.json()) + setTesting(false) + } + + const isConfigured = Object.values(integration.secrets).some(v => v === MASKED) + + return ( +
+
+
+
+
{integration.name}
+
+ {isConfigured ? 'Configured' : 'Not configured'} +
+
+
+ +
+ + {open && ( +
+
+ + {/* Config fields (plaintext) */} + {Object.entries(integration.config).map(([k]) => ( + + ))} + + {/* Secret fields (encrypted) */} + {Object.entries(integration.secrets).map(([k, v]) => ( + + ))} + + {/* Enable toggle */} + +
+ +
+ + + {isConfigured && ( + + )} + + {testResult && ( + + {testResult.ok + ? <> Connected + : <> {testResult.error} + } + + )} +
+
+ )} +
+ ) +} + +function RoomsTab() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [syncing, setSyncing] = useState(false) + const [saving, setSaving] = useState(false) + + useEffect(() => { load() }, []) + + async function load() { + setLoading(true) + const res = await fetch('/settings/api/config/newbook.rooms', { credentials: 'include' }) + if (res.ok) setData((await res.json()).value) + setLoading(false) + } + + async function sync() { + setSyncing(true) + const res = await fetch('/settings/api/integrations/newbook/sync-rooms', { + method: 'POST', credentials: 'include', + }) + if (res.ok) setData(await res.json()) + setSyncing(false) + } + + async function saveOrder() { + if (!data) return + setSaving(true) + await fetch('/settings/api/integrations/newbook/rooms', { + method: 'PUT', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ categories: data.categories }), + }) + setSaving(false) + } + + function move(index: number, dir: -1 | 1) { + if (!data) return + const cats = [...data.categories] + const swap = index + dir + if (swap < 0 || swap >= cats.length) return + ;[cats[index], cats[swap]] = [cats[swap], cats[index]] + setData({ ...data, categories: cats.map((c, i) => ({ ...c, sort_order: i })) }) + } + + function setColour(index: number, colour: string) { + if (!data) return + const cats = [...data.categories] + cats[index] = { ...cats[index], colour } + setData({ ...data, categories: cats }) + } + + return ( +
+
+ + {data?.synced_at && ( + + Last synced {new Date(data.synced_at).toLocaleString()} + + )} +
+ + {loading &&

Loading…

} + + {!loading && !data && ( +

+ No room data yet. Configure Newbook credentials then sync. +

+ )} + + {data && ( + <> +

+ Room categories ({data.categories.length}) +

+
+ {data.categories.map((cat, i) => ( +
+ setColour(i, e.target.value)} + style={{ width: '28px', height: '28px', border: 'none', padding: 0, + borderRadius: '4px', cursor: 'pointer', background: 'none' }} /> + + {cat.name} + + + {data.sites.filter(s => s.category_id === cat.id).length} rooms + +
+ + +
+
+ ))} +
+ + + )} +
+ ) +} + +const inputStyle: React.CSSProperties = { + background: 'var(--body-bg)', border: '1px solid var(--card-border)', + borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.85rem', + color: 'var(--text-dark)', width: '100%', boxSizing: 'border-box', +} + +const arrowBtn: React.CSSProperties = { + background: 'var(--body-bg)', border: '1px solid var(--card-border)', + borderRadius: '3px', padding: '1px 3px', cursor: 'pointer', + color: 'var(--text-mid)', lineHeight: 1, display: 'flex', +}