import { useState, useEffect, useCallback } from 'react'; // ── Constants ───────────────────────────────────────────────────────────────── const PLANS = [ { value: 'chat', label: 'RosterChirp-Chat', desc: 'Chat only' }, { value: 'brand', label: 'RosterChirp-Brand', desc: 'Chat + Branding' }, { value: 'team', label: 'RosterChirp-Team', desc: 'Chat + Branding + Groups + Schedule' }, ]; const PLAN_BADGE = { chat: { bg: '#e8f0fe', color: '#1a73e8', label: 'Chat' }, brand: { bg: '#fce8b2', color: '#e37400', label: 'Brand' }, team: { bg: '#e6f4ea', color: '#188038', label: 'Team' }, }; const STATUS_BADGE = { active: { bg: '#e6f4ea', color: '#188038' }, suspended: { bg: '#fce8b2', color: '#e37400' }, }; // ── API helpers ─────────────────────────────────────────────────────────────── 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: (body) => call('POST', '/tenants', body), 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'), }; } // ── Small reusable components ───────────────────────────────────────────────── function Badge({ value, map }) { const s = map[value] || { bg: '#f1f3f4', color: '#5f6368' }; return ( {s.label || value} ); } function Btn({ onClick, children, variant = 'secondary', size = 'md', disabled, style = {} }) { const base = { border: 'none', borderRadius: 6, cursor: disabled ? 'not-allowed' : 'pointer', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 6, opacity: disabled ? 0.5 : 1, transition: 'opacity 0.15s', padding: size === 'sm' ? '5px 12px' : '9px 18px', fontSize: size === 'sm' ? 12 : 14, }; const variants = { primary: { background: '#1a73e8', color: '#fff' }, danger: { background: '#d93025', color: '#fff' }, warning: { background: '#e37400', color: '#fff' }, success: { background: '#188038', color: '#fff' }, secondary:{ background: '#f1f3f4', color: '#202124' }, ghost: { background: 'transparent', color: '#5f6368', padding: size === 'sm' ? '4px 8px' : '8px 12px' }, }; return ( ); } function Input({ label, value, onChange, placeholder, type = 'text', required, hint, autoComplete }) { return (
{label && ( )} onChange(e.target.value)} placeholder={placeholder} required={required} autoComplete={autoComplete || 'new-password'} autoCorrect="off" spellCheck={false} style={{ padding: '8px 10px', border: '1px solid #e0e0e0', borderRadius: 6, fontSize: 14, outline: 'none', background: '#fff', color: '#202124', transition: 'border-color 0.15s' }} onFocus={e => e.target.style.borderColor = '#1a73e8'} onBlur={e => e.target.style.borderColor = '#e0e0e0'} /> {hint && {hint}}
); } function Select({ label, value, onChange, options, required }) { return (
{label && }
); } function Modal({ title, onClose, children, width = 480 }) { return (
e.target === e.currentTarget && onClose()}>
{title}
{children}
); } function Toast({ toasts }) { return (
{toasts.map(t => (
{t.msg}
))}
); } // ── Provision tenant modal ───────────────────────────────────────────────────── function ProvisionModal({ api, baseDomain, onClose, onDone }) { 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 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); } }; const preview = form.slug ? `${form.slug.toLowerCase()}.${baseDomain}` : ''; return (
{error &&
{error}
}
Cancel {saving ? 'Provisioning…' : '✦ Provision Tenant'}
); } // ── Edit tenant 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 (
{error &&
{error}
}
Cancel {saving ? 'Saving…' : 'Save Changes'}
); } // ── 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); } finally { setDeleting(false); } }; return (
This is permanent. The tenant's Postgres schema and all data — messages, events, users, uploads — will be deleted and cannot be recovered.
To confirm, type {expected} below:
{error &&
{error}
}
Cancel {deleting ? 'Deleting…' : 'Permanently Delete'}
); } // ── 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 subdomainUrl = `https://${tenant.slug}.${baseDomain}`; const url = tenant.custom_domain ? `https://${tenant.custom_domain}` : subdomainUrl; const toggleStatus = async () => { setBusy(true); try { if (tenant.status === 'active') await api.suspendTenant(tenant.slug); else await api.activateTenant(tenant.slug); onRefresh(); onToast(`Tenant ${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 && (
{subdomainUrl}
)} {new Date(tenant.created_at).toLocaleDateString()}
setEditing(true)}>Edit {busy ? '…' : tenant.status === 'active' ? 'Suspend' : 'Activate'} setDeleting(true)}>Delete
{editing && ( setEditing(false)} onDone={() => { setEditing(false); onRefresh(); onToast('Tenant updated', 'success'); }} /> )} {deleting && ( setDeleting(false)} onDone={() => { setDeleting(false); onRefresh(); onToast('Tenant deleted', 'success'); }} /> )} ); } // ── Key entry screen ────────────────────────────────────────────────────────── function KeyEntry({ onSubmit }) { const [key, setKey] = useState(''); const [error, setError] = useState(''); const handle = async () => { if (!key.trim()) return setError('Admin key required'); setError(''); const res = await fetch('/api/host/status', { headers: { 'X-Host-Admin-Key': key.trim() }, }); if (res.ok) { sessionStorage.setItem('rosterchirp-host-key', key.trim()); onSubmit(key.trim()); } else { setError('Invalid admin key'); } }; return (
🏠

RosterChirp-Host

Host Administration Panel

{error &&
{error}
} setKey(e.target.value)} onKeyDown={e => e.key === 'Enter' && handle()} placeholder="Host admin key" autoFocus style={{ width: '100%', padding: '10px 12px', border: '1px solid #e0e0e0', borderRadius: 6, fontSize: 14, outline: 'none', boxSizing: 'border-box', marginBottom: 12 }} autoComplete="new-password" /> Sign In
); } // ── Main host admin panel ───────────────────────────────────────────────────── export default function HostAdmin() { const [adminKey, setAdminKey] = useState(() => sessionStorage.getItem('rosterchirp-host-key') || ''); const [status, setStatus] = useState(null); const [tenants, setTenants] = useState([]); const [loading, setLoading] = useState(false); const [provisioning, setProvisioning] = useState(false); const [migrating, setMigrating] = useState(false); const [toasts, setToasts] = useState([]); const [search, setSearch] = 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'); if (e.message.includes('Invalid') || e.message.includes('401')) { sessionStorage.removeItem('rosterchirp-host-key'); setAdminKey(''); } } finally { setLoading(false); } }, [api, toast]); useEffect(() => { if (adminKey) load(); }, [adminKey]); 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 — check logs`, 'error'); else toast(`Migrations applied to ${results.length} tenant(s)`, 'success'); } catch (e) { toast(e.message, 'error'); } finally { setMigrating(false); } }; if (!adminKey) return ; const filtered = tenants.filter(t => !search || t.name.toLowerCase().includes(search.toLowerCase()) || t.slug.toLowerCase().includes(search.toLowerCase()) ); const baseDomain = status?.baseDomain || 'rosterchirp.com'; return (
{/* Header */}
🏠 RosterChirp-Host / {baseDomain}
{status && ( {status.tenants.active} active · {status.tenants.total} total )} { sessionStorage.removeItem('rosterchirp-host-key'); setAdminKey(''); }}> Sign Out
{/* Main */}
{/* Stat cards */} {status && (
{[ { label: 'Total Tenants', value: status.tenants.total, color: '#1a73e8' }, { label: 'Active', value: status.tenants.active, color: '#188038' }, { label: 'Suspended', value: status.tenants.total - status.tenants.active, color: '#e37400' }, { label: 'Mode', value: status.appType, color: '#5f6368' }, ].map(s => (
{s.value}
{s.label}
))}
)} {/* Toolbar */}
Tenants
setSearch(e.target.value)} placeholder="Search tenants…" autoComplete="off" style={{ padding: '7px 10px', border: '1px solid #e0e0e0', borderRadius: 6, fontSize: 13, outline: 'none', width: 200 }} /> {loading ? '…' : '↻ Refresh'} {migrating ? 'Migrating…' : '⬆ Migrate All'} setProvisioning(true)}> ✦ New Tenant
{/* Table */} {loading && tenants.length === 0 ? (
Loading…
) : 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}
)}
{/* Footer */}
RosterChirp-Host Control Plane · {baseDomain}
{/* Provision modal */} {provisioning && ( setProvisioning(false)} onDone={tenant => { setProvisioning(false); load(); toast(`Tenant '${tenant.slug}' provisioned at https://${tenant.slug}.${baseDomain}`, 'success'); }} /> )}
); }