/** * HostPanel.jsx — JAMA-HOST Control Panel * * Renders inside the main JAMA right-panel area (not a separate page/route). * Protected by: * 1. Only shown when is_host_domain === true (server-computed from HOST_DOMAIN) * 2. Only accessible to admin role users * 3. HOST_ADMIN_KEY prompt on first access per session */ import { useState, useEffect, useCallback } from 'react'; import { useAuth } from '../contexts/AuthContext.jsx'; import UserFooter from './UserFooter.jsx'; // ── Constants ───────────────────────────────────────────────────────────────── const PLANS = [ { value: 'chat', label: 'JAMA-Chat', desc: 'Chat only' }, { value: 'brand', label: 'JAMA-Brand', desc: 'Chat + Branding' }, { value: 'team', label: 'JAMA-Team', desc: 'Chat + Branding + Groups + Schedule' }, ]; const PLAN_COLOURS = { chat: { bg: 'var(--primary-light)', color: 'var(--primary)' }, brand: { bg: '#fef3c7', color: '#b45309' }, team: { bg: '#dcfce7', color: '#15803d' }, }; const STATUS_COLOURS = { active: { bg: '#dcfce7', color: '#15803d' }, suspended: { bg: '#fef3c7', color: '#b45309' }, }; // ── Helpers ─────────────────────────────────────────────────────────────────── function Badge({ value, map }) { const s = map[value] || { bg: 'var(--background)', color: 'var(--text-secondary)' }; return ( {value} ); } function FieldGroup({ label, children }) { return (
{label && } {children}
); } function Field({ label, value, onChange, placeholder, type = 'text', hint, required }) { return ( onChange(e.target.value)} autoComplete="new-password" placeholder={placeholder} required={required} autoComplete="new-password" autoCorrect="off" spellCheck={false} className="input" style={{ fontSize: 13 }} /> {hint && {hint}} ); } function FieldSelect({ label, value, onChange, options }) { return ( ); } // ── API calls using the stored host admin key ───────────────────────────────── function useHostApi(adminKey) { const call = useCallback(async (method, path, body) => { const res = await fetch(`/api/host${path}`, { method, headers: { 'Content-Type': 'application/json', 'X-Host-Admin-Key': adminKey }, body: body ? JSON.stringify(body) : undefined, }); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); return data; }, [adminKey]); return { getStatus: () => call('GET', '/status'), getTenants: () => call('GET', '/tenants'), createTenant: (b) => call('POST', '/tenants', b), updateTenant: (slug, b) => call('PATCH', `/tenants/${slug}`, b), deleteTenant: (slug) => call('DELETE', `/tenants/${slug}`, { confirm: `DELETE ${slug}` }), suspendTenant: (slug) => call('PATCH', `/tenants/${slug}`, { status: 'suspended' }), activateTenant:(slug) => call('PATCH', `/tenants/${slug}`, { status: 'active' }), migrateAll: () => call('POST', '/migrate-all'), }; } // ── Provision modal ─────────────────────────────────────────────────────────── function ProvisionModal({ api, baseDomain, onClose, onDone, toast }) { const [form, setForm] = useState({ slug:'', name:'', plan:'chat', adminEmail:'', adminName:'Admin User', adminPass:'', customDomain:'' }); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const set = k => v => setForm(f => ({ ...f, [k]: v })); const preview = form.slug ? `${form.slug.toLowerCase()}.${baseDomain}` : ''; const handle = async () => { if (!form.slug || !form.name) return setError('Slug and name are required'); setSaving(true); setError(''); try { const { tenant } = await api.createTenant({ slug: form.slug.toLowerCase().trim(), name: form.name.trim(), plan: form.plan, adminEmail: form.adminEmail || undefined, adminName: form.adminName || undefined, adminPass: form.adminPass || undefined, customDomain: form.customDomain || undefined, }); onDone(tenant); } catch (e) { setError(e.message); } finally { setSaving(false); } }; return (
e.target === e.currentTarget && onClose()}>

Provision New Tenant

{error &&
{error}
}
First Admin (optional)
); } // ── Edit modal ──────────────────────────────────────────────────────────────── function EditModal({ api, tenant, onClose, onDone }) { const [form, setForm] = useState({ name: tenant.name, plan: tenant.plan, customDomain: tenant.custom_domain || '' }); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const set = k => v => setForm(f => ({ ...f, [k]: v })); const handle = async () => { setSaving(true); setError(''); try { const { tenant: updated } = await api.updateTenant(tenant.slug, { name: form.name || undefined, plan: form.plan, customDomain: form.customDomain || null, }); onDone(updated); } catch (e) { setError(e.message); } finally { setSaving(false); } }; return (
e.target === e.currentTarget && onClose()}>

Edit — {tenant.slug}

{error &&
{error}
}
); } // ── Delete confirmation modal ───────────────────────────────────────────────── function DeleteModal({ api, tenant, onClose, onDone }) { const [confirm, setConfirm] = useState(''); const [deleting, setDeleting] = useState(false); const [error, setError] = useState(''); const expected = `DELETE ${tenant.slug}`; const handle = async () => { setDeleting(true); setError(''); try { await api.deleteTenant(tenant.slug); onDone(tenant.slug); } catch (e) { setError(e.message); setDeleting(false); } }; return (
e.target === e.currentTarget && onClose()}>

Delete Tenant

Permanent. Drops the Postgres schema and all tenant data — users, messages, events, uploads.

Type {expected} to confirm:

{error &&
{error}
} setConfirm(e.target.value)} autoComplete="new-password" placeholder={expected} style={{ marginBottom:16 }} autoComplete="new-password" />
); } // ── Tenant row ──────────────────────────────────────────────────────────────── function TenantRow({ tenant, baseDomain, api, onRefresh, onToast }) { const [editing, setEditing] = useState(false); const [deleting, setDeleting] = useState(false); const [busy, setBusy] = useState(false); const subUrl = `https://${tenant.slug}.${baseDomain}`; const url = tenant.custom_domain ? `https://${tenant.custom_domain}` : subUrl; const toggleStatus = async () => { setBusy(true); try { if (tenant.status === 'active') await api.suspendTenant(tenant.slug); else await api.activateTenant(tenant.slug); onRefresh(); onToast(`${tenant.slug} ${tenant.status === 'active' ? 'suspended' : 'activated'}`, 'success'); } catch (e) { onToast(e.message, 'error'); } finally { setBusy(false); } }; return ( <>
{tenant.name}
{tenant.slug}
{url} ↗ {tenant.custom_domain &&
{subUrl}
} {new Date(tenant.created_at).toLocaleDateString()}
{editing && setEditing(false)} onDone={() => { setEditing(false); onRefresh(); onToast('Tenant updated','success'); }} />} {deleting && setDeleting(false)} onDone={() => { setDeleting(false); onRefresh(); onToast('Tenant deleted','success'); }} />} ); } // ── Key entry ───────────────────────────────────────────────────────────────── function KeyEntry({ onSubmit }) { const [key, setKey] = useState(''); const [error, setError] = useState(''); const [checking, setChecking] = useState(false); const handle = async () => { if (!key.trim()) return setError('Admin key required'); setChecking(true); setError(''); try { const res = await fetch('/api/host/status', { headers: { 'X-Host-Admin-Key': key.trim() } }); if (res.ok) { sessionStorage.setItem('jama-host-key', key.trim()); onSubmit(key.trim()); } else setError('Invalid admin key'); } catch { setError('Connection error'); } finally { setChecking(false); } }; return (

Control Panel

Enter your host admin key to continue.

{error &&
{error}
} setKey(e.target.value)} autoComplete="new-password" onKeyDown={e => e.key === 'Enter' && handle()} placeholder="Host admin key" autoFocus style={{ marginBottom:12, textAlign:'center' }} />
); } // ── Main HostPanel ──────────────────────────────────────────────────────────── export default function HostPanel({ onProfile, onHelp, onAbout }) { const { user } = useAuth(); const [adminKey, setAdminKey] = useState(() => sessionStorage.getItem('jama-host-key') || ''); const [status, setStatus] = useState(null); const [tenants, setTenants] = useState([]); const [loading, setLoading] = useState(false); const [search, setSearch] = useState(''); const [provisioning, setProvisioning] = useState(false); const [migrating, setMigrating] = useState(false); const [toasts, setToasts] = useState([]); const api = useHostApi(adminKey); const toast = useCallback((msg, type = 'success') => { const id = Date.now(); setToasts(t => [...t, { id, msg, type }]); setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 4000); }, []); const load = useCallback(async () => { setLoading(true); try { const [s, t] = await Promise.all([api.getStatus(), api.getTenants()]); setStatus(s); setTenants(t.tenants); } catch (e) { toast(e.message, 'error'); // Key is invalid — clear it so the prompt shows again if (e.message.includes('401') || e.message.includes('Invalid') || e.message.includes('401')) { sessionStorage.removeItem('jama-host-key'); setAdminKey(''); } } finally { setLoading(false); } }, [api, toast]); useEffect(() => { if (adminKey) load(); }, [adminKey]); // Guard: must be admin if (user?.role !== 'admin') { return
Access denied.
; } // Key entry screen if (!adminKey) return ; const baseDomain = status?.baseDomain || ''; const filtered = tenants.filter(t => !search || t.name.toLowerCase().includes(search.toLowerCase()) || t.slug.toLowerCase().includes(search.toLowerCase())); const handleMigrateAll = async () => { setMigrating(true); try { const { results } = await api.migrateAll(); const errors = results.filter(r => r.status === 'error'); if (errors.length) toast(`${errors.length} migration(s) failed`, 'error'); else toast(`Migrations applied to ${results.length} tenant(s)`, 'success'); } catch (e) { toast(e.message, 'error'); } finally { setMigrating(false); } }; return (
{/* Header */}
Control Panel {baseDomain && · {baseDomain}}
{status && ( {status.tenants.active} active · {status.tenants.total} total )}
{/* Stats */} {status && (
{[ { label:'Total', value: status.tenants.total, colour:'var(--primary)' }, { label:'Active', value: status.tenants.active, colour:'var(--success)' }, { label:'Suspended', value: status.tenants.total - status.tenants.active, colour:'var(--warning)' }, ].map(s => (
{s.value}
{s.label}
))}
)} {/* Toolbar */}
setSearch(e.target.value)} autoComplete="new-password" placeholder="Search tenants…" className="input" style={{ flex:1, minWidth:160, fontSize:13 }} autoComplete="new-password" />
{/* Table */}
{loading && tenants.length === 0 ? (
) : filtered.length === 0 ? (
{search ? 'No tenants match your search.' : 'No tenants yet — provision your first one.'}
) : (
{['Tenant','Plan','Status','URL','Created','Actions'].map(h => ( ))} {filtered.map(t => ( ))}
{h}
)}
{/* Provision modal */} {provisioning && ( setProvisioning(false)} onDone={tenant => { setProvisioning(false); load(); toast(`Tenant '${tenant.slug}' provisioned`, 'success'); }} toast={toast} /> )} {/* Toast notifications */}
{toasts.map(t => (
{t.msg}
))}
{/* User footer */}
); }