Settings: add Dev tab with Newbook API console
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c7676ace42
commit
d9449e01b5
1 changed files with 161 additions and 3 deletions
|
|
@ -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<Integration[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ export function AdminSettings({ user }: { user: User }) {
|
|||
</p>
|
||||
|
||||
<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={{
|
||||
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'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -106,6 +106,7 @@ export function AdminSettings({ user }: { user: User }) {
|
|||
{tab === 'device' && <DeviceTab />}
|
||||
{tab === 'mqtt-clients' && <MqttClientsTab />}
|
||||
{tab === 'kiosk' && <KioskTab />}
|
||||
{tab === 'dev' && <DevTab />}
|
||||
</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 = {
|
||||
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
|
||||
borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.85rem',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue