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 (
{children}
);
}
function Input({ label, value, onChange, placeholder, type = 'text', required, hint, autoComplete }) {
return (
{label && (
{label}{required && * }
)}
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 && {label}{required && * } }
onChange(e.target.value)}
style={{ padding: '8px 10px', border: '1px solid #e0e0e0', borderRadius: 6,
fontSize: 14, outline: 'none', background: '#fff', color: '#202124' }}>
{options.map(o => {o.label}{o.desc ? ` — ${o.desc}` : ''} )}
);
}
function Modal({ title, onClose, children, width = 480 }) {
return (
e.target === e.currentTarget && onClose()}>
);
}
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 (
);
}
// ── 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 (
);
}
// ── 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 => (
))}
)}
{/* Toolbar */}
{/* 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 => (
{h}
))}
{filtered.map(t => (
))}
)}
{/* 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');
}} />
)}
);
}