Settings: add Dev tab with Newbook API console

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-10-22 17:27:13 +00:00
parent c7676ace42
commit d9449e01b5

View file

@ -57,7 +57,7 @@ interface AppRow {
} }
export function AdminSettings({ user }: { user: User }) { 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<Integration[]>([]) const [integrations, setIntegrations] = useState<Integration[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -76,7 +76,7 @@ export function AdminSettings({ user }: { user: User }) {
</p> </p>
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.75rem', borderBottom: '1px solid var(--card-border)', paddingBottom: '0' }}> <div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.75rem', borderBottom: '1px solid var(--card-border)', paddingBottom: '0' }}>
{(['integrations', 'rooms', 'apps', 'device', 'mqtt-clients', 'kiosk'] as const).map(t => ( {(['integrations', 'rooms', 'apps', 'device', 'mqtt-clients', 'kiosk', 'dev'] as const).map(t => (
<button key={t} onClick={() => setTab(t)} style={{ <button key={t} onClick={() => setTab(t)} style={{
background: 'none', border: 'none', padding: '0.5rem 1rem', background: 'none', border: 'none', padding: '0.5rem 1rem',
fontSize: '0.85rem', fontWeight: 600, cursor: 'pointer', 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', borderBottom: tab === t ? '2px solid var(--navy)' : '2px solid transparent',
marginBottom: '-1px', 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'}
</button> </button>
))} ))}
</div> </div>
@ -106,6 +106,7 @@ export function AdminSettings({ user }: { user: User }) {
{tab === 'device' && <DeviceTab />} {tab === 'device' && <DeviceTab />}
{tab === 'mqtt-clients' && <MqttClientsTab />} {tab === 'mqtt-clients' && <MqttClientsTab />}
{tab === 'kiosk' && <KioskTab />} {tab === 'kiosk' && <KioskTab />}
{tab === 'dev' && <DevTab />}
</PageShell> </PageShell>
) )
} }
@ -1333,6 +1334,163 @@ function KioskTab() {
) )
} }
interface DevEntry {
at: string; user: string; endpoint: string; write: boolean
request: Record<string, unknown>; status: number | null; ms: number | null
response: unknown; error: string | null
}
const DEV_PRESETS: { label: string; endpoint: string; payload: Record<string, unknown> }[] = [
{ 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<DevEntry | null>(null)
const [error, setError] = useState('')
const [history, setHistory] = useState<DevEntry[]>([])
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<string, unknown> | 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 (
<div style={{ maxWidth: '860px' }}>
<h2 style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--text-dark)', marginBottom: '0.15rem' }}>Newbook API console</h2>
<p style={{ fontSize: '0.8rem', color: 'var(--text-mid)', marginBottom: '1.25rem' }}>
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 (<code>*_list</code>, <code>*_get</code>) 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.
</p>
<div style={card}>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginBottom: '0.9rem' }}>
{DEV_PRESETS.map(p => (
<button key={p.label} type="button" onClick={() => { 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}
</button>
))}
</div>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem', marginBottom: '0.75rem' }}>
<span style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-mid)' }}>Request name</span>
<input value={endpoint} onChange={e => setEndpoint(e.target.value.trim())} style={inputStyle} spellCheck={false} />
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem', marginBottom: '0.9rem' }}>
<span style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-mid)' }}>Payload (JSON)</span>
<textarea value={payloadText} onChange={e => setPayloadText(e.target.value)} rows={7} spellCheck={false}
style={{ ...inputStyle, fontFamily: 'monospace', fontSize: '0.8rem' }} />
</label>
{parseError && <div style={{ fontSize: '0.8rem', color: '#dc2626', marginBottom: '0.75rem' }}>{parseError}</div>}
<div style={{ fontSize: '0.76rem', fontWeight: 600, color: 'var(--text-mid)', marginBottom: '0.3rem' }}>What will be sent</div>
<pre style={{ ...pre, maxHeight: '220px', marginBottom: '0.9rem' }}>
{`POST https://api.newbook.cloud/rest/${endpoint}\nAuthorization: Basic ••••••••\n\n${preview ? JSON.stringify(preview, null, 2) : '(fix the payload)'}`}
</pre>
{!isRead && (
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.82rem', color: '#b45309', marginBottom: '0.75rem' }}>
<input type="checkbox" checked={allowWrite} onChange={e => setAllowWrite(e.target.checked)} />
This request may change data or send an email in Newbook. I have checked it.
</label>
)}
{error && <div style={{ fontSize: '0.82rem', color: '#dc2626', marginBottom: '0.75rem' }}>{error}</div>}
<button type="button" onClick={submit} disabled={busy || !parsed || !endpoint || (!isRead && !allowWrite)} style={{
background: 'var(--gold)', color: 'var(--navy)', border: 'none', borderRadius: '6px', padding: '0.45rem 1.1rem',
fontSize: '0.82rem', fontWeight: 700, cursor: busy ? 'wait' : 'pointer', opacity: busy || !parsed || (!isRead && !allowWrite) ? 0.5 : 1,
}}>
{busy ? 'Sending…' : 'Send request'}
</button>
</div>
{result && <ResultCard entry={result} pre={pre} card={card} />}
<h3 style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-dark)', marginBottom: '0.5rem' }}>Recent requests (this session of the server)</h3>
{history.length === 0 ? <p style={{ color: 'var(--text-mid)', fontSize: '0.82rem' }}>None yet.</p> : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{history.map((h, i) => (
<details key={i} style={{ ...card, padding: '0.6rem 0.9rem', marginBottom: 0 }}>
<summary style={{ cursor: 'pointer', fontSize: '0.8rem', color: 'var(--text-dark)' }}>
{new Date(h.at).toLocaleTimeString()} · <b>{h.endpoint}</b>{h.write ? ' (write)' : ''} · {h.error ? 'error' : `HTTP ${h.status}`} · {h.ms} ms · {h.user}
</summary>
<div style={{ marginTop: '0.6rem' }}><ResultBody entry={h} pre={pre} /></div>
</details>
))}
</div>
)}
</div>
)
}
function ResultBody({ entry, pre }: { entry: DevEntry; pre: React.CSSProperties }) {
return (
<>
<div style={{ fontSize: '0.76rem', fontWeight: 600, color: 'var(--text-mid)', margin: '0 0 0.3rem' }}>Request</div>
<pre style={{ ...pre, maxHeight: '160px', marginBottom: '0.6rem' }}>{JSON.stringify(entry.request, null, 2)}</pre>
<div style={{ fontSize: '0.76rem', fontWeight: 600, color: 'var(--text-mid)', margin: '0 0 0.3rem' }}>Response</div>
<pre style={pre}>{entry.error ? `Error: ${entry.error}` : JSON.stringify(entry.response, null, 2)}</pre>
</>
)
}
function ResultCard({ entry, pre, card }: { entry: DevEntry; pre: React.CSSProperties; card: React.CSSProperties }) {
const ok = !entry.error && entry.status !== null && entry.status < 400
return (
<div style={card}>
<div style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '0.6rem', color: ok ? '#16a34a' : '#dc2626' }}>
{entry.error ? `Error: ${entry.error}` : `HTTP ${entry.status}`} · {entry.ms} ms
</div>
<ResultBody entry={entry} pre={pre} />
</div>
)
}
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
background: 'var(--body-bg)', border: '1px solid var(--card-border)', background: 'var(--body-bg)', border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.85rem', borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.85rem',