From d9449e01b516840047233ff8bdbcbcec1221b8df Mon Sep 17 00:00:00 2001
From: jtricerolph
Date: Thu, 22 Oct 2026 17:27:13 +0000
Subject: [PATCH] Settings: add Dev tab with Newbook API console
Co-Authored-By: Claude Sonnet 5.5
---
src/pages/AdminSettings.tsx | 164 +++++++++++++++++++++++++++++++++++-
1 file changed, 161 insertions(+), 3 deletions(-)
diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx
index 3fa4fc7..75e3cc8 100644
--- a/src/pages/AdminSettings.tsx
+++ b/src/pages/AdminSettings.tsx
@@ -57,7 +57,7 @@ interface AppRow {
}
export function AdminSettings({ user }: { user: User }) {
- const [tab, setTab] = useState<'integrations' | 'rooms' | 'apps' | 'device' | 'mqtt-clients' | 'kiosk'>('integrations')
+ const [tab, setTab] = useState<'integrations' | 'rooms' | 'apps' | 'device' | 'mqtt-clients' | 'kiosk' | 'dev'>('integrations')
const [integrations, setIntegrations] = useState([])
const [loading, setLoading] = useState(true)
@@ -76,7 +76,7 @@ export function AdminSettings({ user }: { user: User }) {
- {(['integrations', 'rooms', 'apps', 'device', 'mqtt-clients', 'kiosk'] as const).map(t => (
+ {(['integrations', 'rooms', 'apps', 'device', 'mqtt-clients', 'kiosk', 'dev'] as const).map(t => (
setTab(t)} style={{
background: 'none', border: 'none', padding: '0.5rem 1rem',
fontSize: '0.85rem', fontWeight: 600, cursor: 'pointer',
@@ -84,7 +84,7 @@ export function AdminSettings({ user }: { user: User }) {
borderBottom: tab === t ? '2px solid var(--navy)' : '2px solid transparent',
marginBottom: '-1px',
}}>
- {t === 'integrations' ? 'Integrations' : t === 'rooms' ? 'Rooms & Sites' : t === 'apps' ? 'App Settings' : t === 'device' ? 'This Device' : t === 'kiosk' ? 'Kiosk' : 'MQTT Clients'}
+ {t === 'integrations' ? 'Integrations' : t === 'rooms' ? 'Rooms & Sites' : t === 'apps' ? 'App Settings' : t === 'device' ? 'This Device' : t === 'kiosk' ? 'Kiosk' : t === 'dev' ? 'Dev' : 'MQTT Clients'}
))}
@@ -106,6 +106,7 @@ export function AdminSettings({ user }: { user: User }) {
{tab === 'device' && }
{tab === 'mqtt-clients' && }
{tab === 'kiosk' && }
+ {tab === 'dev' && }
)
}
@@ -1333,6 +1334,163 @@ function KioskTab() {
)
}
+interface DevEntry {
+ at: string; user: string; endpoint: string; write: boolean
+ request: Record; status: number | null; ms: number | null
+ response: unknown; error: string | null
+}
+
+const DEV_PRESETS: { label: string; endpoint: string; payload: Record }[] = [
+ { label: 'Contact history: booking 37951', endpoint: 'contact_documents_list', payload: { recipient_type: 'bookings', recipient_id: 37951 } },
+ { label: 'Booking 37951', endpoint: 'bookings_get', payload: { booking_id: 37951 } },
+ { label: 'Booking custom fields', endpoint: 'custom_fields', payload: { for: 'bookings' } },
+ { label: 'Contact templates', endpoint: 'contact_templates_list', payload: { return_all_types: true } },
+]
+
+function DevTab() {
+ const [endpoint, setEndpoint] = useState(DEV_PRESETS[0].endpoint)
+ const [payloadText, setPayloadText] = useState(JSON.stringify(DEV_PRESETS[0].payload, null, 2))
+ const [allowWrite, setAllowWrite] = useState(false)
+ const [busy, setBusy] = useState(false)
+ const [result, setResult] = useState(null)
+ const [error, setError] = useState('')
+ const [history, setHistory] = useState([])
+
+ function loadHistory() {
+ fetch('/settings/api/dev/log', { credentials: 'include' })
+ .then(r => r.json()).then(d => Array.isArray(d) && setHistory(d)).catch(() => {})
+ }
+ useEffect(loadHistory, [])
+
+ let parsed: Record | null = null
+ let parseError = ''
+ try {
+ const v = JSON.parse(payloadText || '{}')
+ if (v && typeof v === 'object' && !Array.isArray(v)) parsed = v
+ else parseError = 'Payload must be a JSON object'
+ } catch (e) { parseError = (e as Error).message }
+
+ const isRead = /(_list|_get|_options)$|^(custom_fields|auth_test|site_list|sites_list|users_list|note_types_list)$/.test(endpoint)
+ const preview = parsed ? { region: '(from Newbook integration)', api_key: '••••••••', ...parsed } : null
+
+ async function submit() {
+ if (!parsed) return
+ setBusy(true); setError(''); setResult(null)
+ try {
+ const res = await fetch('/settings/api/dev/newbook', {
+ method: 'POST', credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ endpoint, payload: parsed, confirm_write: !isRead && allowWrite }),
+ })
+ const data = await res.json().catch(() => ({}))
+ if (!res.ok) setError(data.error || `HTTP ${res.status}`)
+ else setResult(data)
+ loadHistory()
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const card: React.CSSProperties = {
+ background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: 'var(--radius)',
+ padding: '1.25rem', marginBottom: '1.25rem', boxShadow: 'var(--shadow-sm)',
+ }
+ const pre: React.CSSProperties = {
+ background: 'var(--body-bg)', border: '1px solid var(--card-border)', borderRadius: '6px',
+ padding: '0.75rem', fontSize: '0.78rem', overflow: 'auto', maxHeight: '420px', margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word',
+ }
+
+ return (
+
+
Newbook API console
+
+ Review the request, then press Send. Region and API key are added by the server from the Newbook integration; nothing is sent until you press the button.
+ Read-only requests (*_list, *_get) send straight away; anything else needs the write box ticked. Responses are kept in memory only (last 100) and are not written to the server log.
+
+
+
+
+ {DEV_PRESETS.map(p => (
+ { setEndpoint(p.endpoint); setPayloadText(JSON.stringify(p.payload, null, 2)); setAllowWrite(false) }}
+ style={{ background: 'var(--body-bg)', border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.3rem 0.7rem', fontSize: '0.76rem', cursor: 'pointer', color: 'var(--text-dark)' }}>
+ {p.label}
+
+ ))}
+
+
+
+ Request name
+ setEndpoint(e.target.value.trim())} style={inputStyle} spellCheck={false} />
+
+
+ Payload (JSON)
+
+ {parseError &&
{parseError}
}
+
+
What will be sent
+
+{`POST https://api.newbook.cloud/rest/${endpoint}\nAuthorization: Basic ••••••••\n\n${preview ? JSON.stringify(preview, null, 2) : '(fix the payload)'}`}
+
+
+ {!isRead && (
+
+ setAllowWrite(e.target.checked)} />
+ This request may change data or send an email in Newbook. I have checked it.
+
+ )}
+ {error &&
{error}
}
+
+ {busy ? 'Sending…' : 'Send request'}
+
+
+
+ {result &&
}
+
+
Recent requests (this session of the server)
+ {history.length === 0 ?
None yet.
: (
+
+ {history.map((h, i) => (
+
+
+ {new Date(h.at).toLocaleTimeString()} · {h.endpoint} {h.write ? ' (write)' : ''} · {h.error ? 'error' : `HTTP ${h.status}`} · {h.ms} ms · {h.user}
+
+
+
+ ))}
+
+ )}
+
+ )
+}
+
+function ResultBody({ entry, pre }: { entry: DevEntry; pre: React.CSSProperties }) {
+ return (
+ <>
+ Request
+ {JSON.stringify(entry.request, null, 2)}
+ Response
+ {entry.error ? `Error: ${entry.error}` : JSON.stringify(entry.response, null, 2)}
+ >
+ )
+}
+
+function ResultCard({ entry, pre, card }: { entry: DevEntry; pre: React.CSSProperties; card: React.CSSProperties }) {
+ const ok = !entry.error && entry.status !== null && entry.status < 400
+ return (
+
+
+ {entry.error ? `Error: ${entry.error}` : `HTTP ${entry.status}`} · {entry.ms} ms
+
+
+
+ )
+}
+
const inputStyle: React.CSSProperties = {
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.85rem',