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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-01 18:56:27 +00:00
parent affb1368ac
commit e9cd40c80a
4 changed files with 406 additions and 1 deletions

View file

@ -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;

View file

@ -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() {
<Route path="/app/:slug" element={<AppFrame user={user} />} />
<Route path="/admin/users" element={user.is_admin ? <AdminUsers user={user} /> : <Navigate to="/" />} />
<Route path="/admin/monitor" element={user.is_admin ? <AdminMonitor user={user} /> : <Navigate to="/" />} />
<Route path="/admin/settings" element={user.is_admin ? <AdminSettings user={user} /> : <Navigate to="/" />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
)}

View file

@ -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) {
<Activity size={14} strokeWidth={1.75} />
Monitor
</NavLink>
<NavLink to="/admin/settings" style={({ isActive }) => navItem(isActive)}>
<Settings size={14} strokeWidth={1.75} />
Settings
</NavLink>
</>
)}
</nav>

391
src/pages/AdminSettings.tsx Normal file
View file

@ -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<string, string>
secrets: Record<string, string | null>
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<string, string> = {
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<Integration[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/settings/api/integrations', { credentials: 'include' })
.then(r => r.json()).then(setIntegrations).finally(() => setLoading(false))
}, [])
return (
<div style={{ display: 'flex', height: '100dvh' }}>
<Sidebar user={user} />
<main style={{ flex: 1, overflowY: 'auto', padding: '2rem' }}>
<h1 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--text-dark)', marginBottom: '0.25rem' }}>
Settings
</h1>
<p style={{ fontSize: '0.85rem', color: 'var(--text-mid)', marginBottom: '1.75rem' }}>
Third-party integrations and global configuration
</p>
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.75rem', borderBottom: '1px solid var(--card-border)', paddingBottom: '0' }}>
{(['integrations', 'rooms'] as const).map(t => (
<button key={t} onClick={() => setTab(t)} style={{
background: 'none', border: 'none', padding: '0.5rem 1rem',
fontSize: '0.85rem', fontWeight: 600, cursor: 'pointer',
color: tab === t ? 'var(--navy)' : 'var(--text-mid)',
borderBottom: tab === t ? '2px solid var(--navy)' : '2px solid transparent',
marginBottom: '-1px',
}}>
{t === 'integrations' ? 'Integrations' : 'Rooms & Sites'}
</button>
))}
</div>
{tab === 'integrations' && (
loading
? <p style={{ color: 'var(--text-mid)' }}>Loading</p>
: <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxWidth: '640px' }}>
{integrations.map(intg => (
<IntegrationCard key={intg.slug} integration={intg} onSaved={updated =>
setIntegrations(prev => prev.map(i => i.slug === updated.slug ? updated : i))
} />
))}
</div>
)}
{tab === 'rooms' && <RoomsTab />}
</main>
</div>
)
}
function IntegrationCard({ integration, onSaved }: { integration: Integration; onSaved: (i: Integration) => void }) {
const [open, setOpen] = useState(false)
const [form, setForm] = useState<Record<string, string>>({})
const [changing, setChanging] = useState<Set<string>>(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<string, string> = {}
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<string, unknown> = { ...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 (
<div style={{
background: 'var(--card-bg)', border: '1px solid var(--card-border)',
borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-sm)', overflow: 'hidden',
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '1rem 1.25rem',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.9rem', color: 'var(--text-dark)' }}>{integration.name}</div>
<div style={{ fontSize: '0.75rem', color: isConfigured ? '#16a34a' : 'var(--text-mid)', marginTop: '0.1rem' }}>
{isConfigured ? 'Configured' : 'Not configured'}
</div>
</div>
</div>
<button onClick={open ? () => setOpen(false) : openForm} style={{
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.35rem 0.85rem', fontSize: '0.8rem',
fontWeight: 600, color: 'var(--text-dark)', cursor: 'pointer',
}}>
{open ? 'Cancel' : 'Configure'}
</button>
</div>
{open && (
<div style={{ borderTop: '1px solid var(--card-border)', padding: '1.25rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginBottom: '1rem' }}>
{/* Config fields (plaintext) */}
{Object.entries(integration.config).map(([k]) => (
<label key={k} style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<span style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-mid)' }}>
{FIELD_LABELS[k] ?? k}
</span>
<input value={form[k] ?? ''} onChange={e => setField(k, e.target.value)}
style={inputStyle} />
</label>
))}
{/* Secret fields (encrypted) */}
{Object.entries(integration.secrets).map(([k, v]) => (
<label key={k} style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<span style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-mid)' }}>
{FIELD_LABELS[k] ?? k}
</span>
{v === MASKED && !changing.has(k)
? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.85rem', color: '#16a34a', letterSpacing: '0.05em' }}>Configured</span>
<button onClick={() => startChangingSecret(k)} style={{
background: 'none', border: '1px solid var(--card-border)', borderRadius: '4px',
padding: '0.2rem 0.6rem', fontSize: '0.75rem', cursor: 'pointer', color: 'var(--text-mid)',
}}>Change</button>
</div>
)
: (
<input type="password" value={form[k] ?? ''} onChange={e => setField(k, e.target.value)}
placeholder={v === null ? 'Not set' : 'Enter new value'}
style={inputStyle} />
)
}
</label>
))}
{/* Enable toggle */}
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.25rem' }}>
<input type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)} />
<span style={{ fontSize: '0.85rem', color: 'var(--text-dark)' }}>Enabled</span>
</label>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<button onClick={save} disabled={saving} style={{
background: 'var(--navy)', color: '#fff', border: 'none',
borderRadius: '6px', padding: '0.45rem 1.1rem', fontSize: '0.82rem', fontWeight: 600,
cursor: saving ? 'wait' : 'pointer',
}}>
{saving ? 'Saving…' : 'Save'}
</button>
{isConfigured && (
<button onClick={test} disabled={testing} style={{
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.45rem 1rem', fontSize: '0.82rem', fontWeight: 600,
cursor: testing ? 'wait' : 'pointer', color: 'var(--text-dark)',
display: 'flex', alignItems: 'center', gap: '0.4rem',
}}>
<RefreshCw size={13} strokeWidth={1.75} style={{ animation: testing ? 'spin 1s linear infinite' : 'none' }} />
Test connection
</button>
)}
{testResult && (
<span style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', fontSize: '0.82rem',
color: testResult.ok ? '#16a34a' : '#dc2626' }}>
{testResult.ok
? <><CheckCircle size={14} strokeWidth={1.75} /> Connected</>
: <><XCircle size={14} strokeWidth={1.75} /> {testResult.error}</>
}
</span>
)}
</div>
</div>
)}
</div>
)
}
function RoomsTab() {
const [data, setData] = useState<RoomsData | null>(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 (
<div style={{ maxWidth: '560px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1.5rem' }}>
<button onClick={sync} disabled={syncing} style={{
background: 'var(--navy)', color: '#fff', border: 'none',
borderRadius: '6px', padding: '0.45rem 1.1rem', fontSize: '0.82rem', fontWeight: 600,
cursor: syncing ? 'wait' : 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem',
}}>
<RefreshCw size={13} strokeWidth={1.75} style={{ animation: syncing ? 'spin 1s linear infinite' : 'none' }} />
{syncing ? 'Syncing…' : 'Sync from Newbook'}
</button>
{data?.synced_at && (
<span style={{ fontSize: '0.78rem', color: 'var(--text-mid)' }}>
Last synced {new Date(data.synced_at).toLocaleString()}
</span>
)}
</div>
{loading && <p style={{ color: 'var(--text-mid)' }}>Loading</p>}
{!loading && !data && (
<p style={{ color: 'var(--text-mid)', fontSize: '0.85rem' }}>
No room data yet. Configure Newbook credentials then sync.
</p>
)}
{data && (
<>
<p style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-mid)',
textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.75rem' }}>
Room categories ({data.categories.length})
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1.25rem' }}>
{data.categories.map((cat, i) => (
<div key={cat.id} style={{
background: 'var(--card-bg)', border: '1px solid var(--card-border)',
borderRadius: 'var(--radius)', padding: '0.75rem 1rem',
display: 'flex', alignItems: 'center', gap: '0.75rem',
boxShadow: 'var(--shadow-sm)',
}}>
<input type="color" value={cat.colour ?? '#1e3a5f'}
onChange={e => setColour(i, e.target.value)}
style={{ width: '28px', height: '28px', border: 'none', padding: 0,
borderRadius: '4px', cursor: 'pointer', background: 'none' }} />
<span style={{ flex: 1, fontSize: '0.88rem', fontWeight: 500, color: 'var(--text-dark)' }}>
{cat.name}
</span>
<span style={{ fontSize: '0.75rem', color: 'var(--text-mid)' }}>
{data.sites.filter(s => s.category_id === cat.id).length} rooms
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1px' }}>
<button onClick={() => move(i, -1)} disabled={i === 0} style={arrowBtn}>
<ChevronUp size={12} strokeWidth={2} />
</button>
<button onClick={() => move(i, 1)} disabled={i === data.categories.length - 1} style={arrowBtn}>
<ChevronDown size={12} strokeWidth={2} />
</button>
</div>
</div>
))}
</div>
<button onClick={saveOrder} disabled={saving} style={{
background: 'var(--navy)', color: '#fff', border: 'none',
borderRadius: '6px', padding: '0.45rem 1.1rem', fontSize: '0.82rem', fontWeight: 600,
cursor: saving ? 'wait' : 'pointer',
}}>
{saving ? 'Saving…' : 'Save order'}
</button>
</>
)}
</div>
)
}
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',
}