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:
parent
affb1368ac
commit
e9cd40c80a
4 changed files with 406 additions and 1 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
391
src/pages/AdminSettings.tsx
Normal 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',
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue