/**
* HostPanel.jsx — RosterChirp-Host Control Panel
*
* Renders inside the main RosterChirp 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: '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_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}
}
);
}
// ── 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('rosterchirp-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('rosterchirp-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('rosterchirp-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 => (
))}
)}
{/* 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 => (
| {h} |
))}
{filtered.map(t => (
))}
)}
{/* 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 */}
);
}