v0.10.3 ui changes and bug fixes
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-backend",
|
"name": "jama-backend",
|
||||||
"version": "0.10.2",
|
"version": "0.10.3",
|
||||||
"description": "TeamChat backend server",
|
"description": "TeamChat backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ router.get('/', async (req, res) => {
|
|||||||
if (admin) obj.admin_email = admin.email;
|
if (admin) obj.admin_email = admin.email;
|
||||||
obj.app_version = process.env.JAMA_VERSION || 'dev';
|
obj.app_version = process.env.JAMA_VERSION || 'dev';
|
||||||
obj.user_pass = process.env.USER_PASS || 'user@1234';
|
obj.user_pass = process.env.USER_PASS || 'user@1234';
|
||||||
|
// Tell the frontend whether this request came from the HOST_DOMAIN.
|
||||||
|
// Used to show/hide the Control Panel menu item — only visible on the host's own domain.
|
||||||
|
const reqHost = (req.headers.host || '').toLowerCase().split(':')[0];
|
||||||
|
const hostDomain = (process.env.HOST_DOMAIN || '').toLowerCase();
|
||||||
|
obj.is_host_domain = (
|
||||||
|
process.env.APP_TYPE === 'host' &&
|
||||||
|
!!hostDomain &&
|
||||||
|
(reqHost === hostDomain || reqHost === `www.${hostDomain}` || reqHost === 'localhost')
|
||||||
|
) ? 'true' : 'false';
|
||||||
res.json({ settings: obj });
|
res.json({ settings: obj });
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.10.2}"
|
VERSION="${1:-0.10.3}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="jama"
|
IMAGE_NAME="jama"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-frontend",
|
"name": "jama-frontend",
|
||||||
"version": "0.10.2",
|
"version": "0.10.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { ToastProvider } from './contexts/ToastContext.jsx';
|
|||||||
import Login from './pages/Login.jsx';
|
import Login from './pages/Login.jsx';
|
||||||
import Chat from './pages/Chat.jsx';
|
import Chat from './pages/Chat.jsx';
|
||||||
import ChangePassword from './pages/ChangePassword.jsx';
|
import ChangePassword from './pages/ChangePassword.jsx';
|
||||||
import HostAdmin from './pages/HostAdmin.jsx';
|
|
||||||
|
|
||||||
function ProtectedRoute({ children }) {
|
function ProtectedRoute({ children }) {
|
||||||
const { user, loading, mustChangePassword } = useAuth();
|
const { user, loading, mustChangePassword } = useAuth();
|
||||||
@@ -38,10 +37,7 @@ export default function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* /host renders outside AuthProvider — has its own key-based auth */}
|
{/* All routes go through jama auth */}
|
||||||
<Route path="/host" element={<HostAdmin />} />
|
|
||||||
<Route path="/host/*" element={<HostAdmin />} />
|
|
||||||
{/* All other routes go through jama auth */}
|
|
||||||
<Route path="/*" element={
|
<Route path="/*" element={
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SocketProvider>
|
<SocketProvider>
|
||||||
|
|||||||
491
frontend/src/components/HostPanel.jsx
Normal file
491
frontend/src/components/HostPanel.jsx
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<span style={{ padding: '2px 8px', borderRadius: 12, fontSize: 11, fontWeight: 700,
|
||||||
|
background: s.bg, color: s.color, textTransform: 'uppercase', letterSpacing: '0.4px' }}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldGroup({ label, children }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{label && <label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{label}</label>}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, value, onChange, placeholder, type = 'text', hint, required }) {
|
||||||
|
return (
|
||||||
|
<FieldGroup label={label}>
|
||||||
|
<input type={type} value={value} onChange={e => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder} required={required}
|
||||||
|
autoComplete="new-password" autoCorrect="off" spellCheck={false}
|
||||||
|
className="input" style={{ fontSize: 13 }} />
|
||||||
|
{hint && <span style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>{hint}</span>}
|
||||||
|
</FieldGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldSelect({ label, value, onChange, options }) {
|
||||||
|
return (
|
||||||
|
<FieldGroup label={label}>
|
||||||
|
<select value={value} onChange={e => onChange(e.target.value)} className="input" style={{ fontSize: 13 }}>
|
||||||
|
{options.map(o => <option key={o.value} value={o.value}>{o.label}{o.desc ? ` — ${o.desc}` : ''}</option>)}
|
||||||
|
</select>
|
||||||
|
</FieldGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal" style={{ maxWidth: 520 }}>
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||||
|
<h2 className="modal-title" style={{ margin: 0 }}>Provision New Tenant</h2>
|
||||||
|
<button className="btn-icon" onClick={onClose}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <div style={{ padding:'10px 14px', background:'#fce8e6', color:'var(--error)', borderRadius:6, fontSize:13, marginBottom:16 }}>{error}</div>}
|
||||||
|
<div style={{ display:'flex', flexDirection:'column', gap:14 }}>
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:12 }}>
|
||||||
|
<Field label="Slug *" value={form.slug} onChange={set('slug')} placeholder="team-alpha"
|
||||||
|
hint={preview ? `→ ${preview}` : 'Subdomain + schema name'} />
|
||||||
|
<Field label="Display Name *" value={form.name} onChange={set('name')} placeholder="Team Alpha" />
|
||||||
|
</div>
|
||||||
|
<FieldSelect label="Plan" value={form.plan} onChange={set('plan')} options={PLANS} />
|
||||||
|
<div style={{ borderTop:'1px solid var(--border)', paddingTop:12 }}>
|
||||||
|
<div style={{ fontSize:11, fontWeight:700, color:'var(--text-tertiary)', textTransform:'uppercase', letterSpacing:'0.5px', marginBottom:10 }}>First Admin (optional)</div>
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:12 }}>
|
||||||
|
<Field label="Email" value={form.adminEmail} onChange={set('adminEmail')} placeholder="admin@teamalpha.com" type="email" />
|
||||||
|
<Field label="Name" value={form.adminName} onChange={set('adminName')} placeholder="Admin User" />
|
||||||
|
<Field label="Temp Password" value={form.adminPass} onChange={set('adminPass')} placeholder="Blank = .env default" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Field label="Custom Domain (optional)" value={form.customDomain} onChange={set('customDomain')} placeholder="chat.teamalpha.com" />
|
||||||
|
<div style={{ display:'flex', justifyContent:'flex-end', gap:8, paddingTop:4 }}>
|
||||||
|
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||||
|
<button className="btn btn-primary" onClick={handle} disabled={saving}>
|
||||||
|
{saving ? 'Provisioning…' : '+ New Tenant'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal">
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||||
|
<h2 className="modal-title" style={{ margin: 0 }}>Edit — {tenant.slug}</h2>
|
||||||
|
<button className="btn-icon" onClick={onClose}><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||||
|
</div>
|
||||||
|
{error && <div style={{ padding:'10px 14px', background:'#fce8e6', color:'var(--error)', borderRadius:6, fontSize:13, marginBottom:14 }}>{error}</div>}
|
||||||
|
<div style={{ display:'flex', flexDirection:'column', gap:14 }}>
|
||||||
|
<Field label="Display Name" value={form.name} onChange={set('name')} />
|
||||||
|
<FieldSelect label="Plan" value={form.plan} onChange={set('plan')} options={PLANS} />
|
||||||
|
<Field label="Custom Domain" value={form.customDomain} onChange={set('customDomain')} placeholder="chat.example.com" hint="Leave blank to remove" />
|
||||||
|
<div style={{ display:'flex', justifyContent:'flex-end', gap:8 }}>
|
||||||
|
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||||
|
<button className="btn btn-primary" onClick={handle} disabled={saving}>{saving ? 'Saving…' : 'Save Changes'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal">
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: 16 }}>
|
||||||
|
<h2 className="modal-title" style={{ margin: 0 }}>Delete Tenant</h2>
|
||||||
|
<button className="btn-icon" onClick={onClose}><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding:'12px 16px', background:'#fce8e6', borderRadius:8, fontSize:13, color:'var(--error)', marginBottom:16 }}>
|
||||||
|
<strong>Permanent.</strong> Drops the Postgres schema and all tenant data — users, messages, events, uploads.
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize:13, color:'var(--text-primary)', marginBottom:12 }}>
|
||||||
|
Type <code style={{ background:'var(--background)', padding:'2px 6px', borderRadius:4 }}>{expected}</code> to confirm:
|
||||||
|
</p>
|
||||||
|
{error && <div style={{ color:'var(--error)', fontSize:13, marginBottom:10 }}>{error}</div>}
|
||||||
|
<input className="input" value={confirm} onChange={e => setConfirm(e.target.value)} placeholder={expected} style={{ marginBottom:16 }} autoComplete="new-password" />
|
||||||
|
<div style={{ display:'flex', justifyContent:'flex-end', gap:8 }}>
|
||||||
|
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||||
|
<button className="btn btn-danger" onClick={handle} disabled={confirm !== expected || deleting}>
|
||||||
|
{deleting ? 'Deleting…' : 'Permanently Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<>
|
||||||
|
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<td style={{ padding: '10px 12px' }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13 }}>{tenant.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', fontFamily: 'monospace' }}>{tenant.slug}</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 12px' }}><Badge value={tenant.plan} map={PLAN_COLOURS} /></td>
|
||||||
|
<td style={{ padding: '10px 12px' }}><Badge value={tenant.status} map={STATUS_COLOURS} /></td>
|
||||||
|
<td style={{ padding: '10px 12px' }}>
|
||||||
|
<a href={url} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: 'var(--primary)', textDecoration: 'none' }}>{url} ↗</a>
|
||||||
|
{tenant.custom_domain && <div style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>{subUrl}</div>}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 12px', fontSize: 11, color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>
|
||||||
|
{new Date(tenant.created_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 12px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||||
|
<button className="btn btn-sm btn-secondary" onClick={() => setEditing(true)}>Edit</button>
|
||||||
|
<button className="btn btn-sm" style={{ background: tenant.status === 'active' ? 'var(--warning)' : 'var(--success)', color:'#fff' }}
|
||||||
|
onClick={toggleStatus} disabled={busy}>
|
||||||
|
{busy ? '…' : tenant.status === 'active' ? 'Suspend' : 'Activate'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-sm btn-danger" onClick={() => setDeleting(true)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{editing && <EditModal api={api} tenant={tenant} onClose={() => setEditing(false)} onDone={() => { setEditing(false); onRefresh(); onToast('Tenant updated','success'); }} />}
|
||||||
|
{deleting && <DeleteModal api={api} tenant={tenant} onClose={() => 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 (
|
||||||
|
<div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'center', padding:24 }}>
|
||||||
|
<div style={{ width:'100%', maxWidth:360, background:'var(--surface)', borderRadius:'var(--radius-lg)', padding:32, boxShadow:'var(--shadow-md)', textAlign:'center' }}>
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="1.5" style={{ marginBottom:12 }}>
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
</svg>
|
||||||
|
<h2 style={{ fontSize:18, fontWeight:700, margin:'0 0 4px' }}>Control Panel</h2>
|
||||||
|
<p style={{ color:'var(--text-secondary)', fontSize:13, margin:'0 0 20px' }}>Enter your host admin key to continue.</p>
|
||||||
|
{error && <div style={{ padding:'8px 12px', background:'#fce8e6', color:'var(--error)', borderRadius:6, fontSize:13, marginBottom:14 }}>{error}</div>}
|
||||||
|
<input type="password" className="input" value={key} onChange={e => setKey(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handle()} placeholder="Host admin key" autoFocus
|
||||||
|
style={{ marginBottom:12, textAlign:'center' }} />
|
||||||
|
<button className="btn btn-primary" onClick={handle} disabled={checking} style={{ width:'100%', justifyContent:'center' }}>
|
||||||
|
{checking ? 'Checking…' : 'Unlock'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main HostPanel ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function HostPanel() {
|
||||||
|
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 <div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'center', color:'var(--text-secondary)' }}>Access denied.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key entry screen
|
||||||
|
if (!adminKey) return <KeyEntry onSubmit={setAdminKey} />;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', background:'var(--background)' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ background:'var(--surface)', borderBottom:'1px solid var(--border)', padding:'0 24px', flexShrink:0 }}>
|
||||||
|
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', height:52 }}>
|
||||||
|
<div style={{ display:'flex', alignItems:'center', gap:10 }}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||||
|
<span style={{ fontWeight:700, fontSize:15 }}>Control Panel</span>
|
||||||
|
{baseDomain && <span style={{ fontSize:12, color:'var(--text-tertiary)' }}>· {baseDomain}</span>}
|
||||||
|
</div>
|
||||||
|
{status && (
|
||||||
|
<span style={{ fontSize:12, color:'var(--text-secondary)' }}>
|
||||||
|
{status.tenants.active} active · {status.tenants.total} total
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{status && (
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'repeat(3,1fr)', gap:12, padding:'16px 24px', flexShrink:0 }}>
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<div key={s.label} style={{ background:'var(--surface)', borderRadius:'var(--radius)', padding:'14px 16px', boxShadow:'var(--shadow-sm)' }}>
|
||||||
|
<div style={{ fontSize:24, fontWeight:700, color:s.colour }}>{s.value}</div>
|
||||||
|
<div style={{ fontSize:12, color:'var(--text-tertiary)' }}>{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div style={{ padding:'0 24px 12px', flexShrink:0, display:'flex', gap:8, alignItems:'center', flexWrap:'wrap' }}>
|
||||||
|
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search tenants…"
|
||||||
|
className="input" style={{ flex:1, minWidth:160, fontSize:13 }} autoComplete="new-password" />
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={load} disabled={loading}>{loading ? '…' : '↻ Refresh'}</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={handleMigrateAll} disabled={migrating}>{migrating ? 'Migrating…' : '⬆ Migrate All'}</button>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => setProvisioning(true)}>+ New Tenant</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div style={{ flex:1, overflowY:'auto', padding:'0 24px 24px' }}>
|
||||||
|
<div style={{ background:'var(--surface)', borderRadius:'var(--radius)', boxShadow:'var(--shadow-sm)', overflow:'hidden' }}>
|
||||||
|
{loading && tenants.length === 0 ? (
|
||||||
|
<div style={{ padding:40, textAlign:'center' }}><div className="spinner" /></div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div style={{ padding:40, textAlign:'center', color:'var(--text-tertiary)', fontSize:14 }}>
|
||||||
|
{search ? 'No tenants match your search.' : 'No tenants yet — provision your first one.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ overflowX:'auto' }}>
|
||||||
|
<table style={{ width:'100%', borderCollapse:'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom:'2px solid var(--border)' }}>
|
||||||
|
{['Tenant','Plan','Status','URL','Created','Actions'].map(h => (
|
||||||
|
<th key={h} style={{ padding:'8px 12px', textAlign: h==='Actions' ? 'right' : 'left',
|
||||||
|
fontSize:11, fontWeight:700, color:'var(--text-tertiary)', textTransform:'uppercase', letterSpacing:'0.5px', whiteSpace:'nowrap' }}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map(t => (
|
||||||
|
<TenantRow key={t.slug} tenant={t} baseDomain={baseDomain}
|
||||||
|
api={api} onRefresh={load} onToast={toast} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Provision modal */}
|
||||||
|
{provisioning && (
|
||||||
|
<ProvisionModal api={api} baseDomain={baseDomain} onClose={() => setProvisioning(false)}
|
||||||
|
onDone={tenant => { setProvisioning(false); load(); toast(`Tenant '${tenant.slug}' provisioned`, 'success'); }}
|
||||||
|
toast={toast} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toast notifications */}
|
||||||
|
<div style={{ position:'fixed', bottom:24, right:24, display:'flex', flexDirection:'column', gap:8, zIndex:2000 }}>
|
||||||
|
{toasts.map(t => (
|
||||||
|
<div key={t.id} style={{ padding:'10px 16px', borderRadius:'var(--radius)', fontSize:13, fontWeight:500,
|
||||||
|
background: t.type==='error' ? 'var(--error)' : 'var(--success)',
|
||||||
|
color:'#fff', boxShadow:'var(--shadow-md)', maxWidth:320 }}>
|
||||||
|
{t.msg}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,10 +8,11 @@ const NAV_ICON = {
|
|||||||
users: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
|
users: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
|
||||||
groups: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/><line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/></svg>,
|
groups: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/><line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/></svg>,
|
||||||
branding: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M12 2a10 10 0 1 0 10 10"/></svg>,
|
branding: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M12 2a10 10 0 1 0 10 10"/></svg>,
|
||||||
|
hostpanel: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>,
|
||||||
settings: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>,
|
settings: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NavDrawer({ open, onClose, onMessages, onSchedule, onScheduleManager, onBranding, onSettings, onUsers, onGroupManager, features = {}, currentPage = 'chat', isMobile = false }) {
|
export default function NavDrawer({ open, onClose, onMessages, onSchedule, onScheduleManager, onBranding, onSettings, onUsers, onGroupManager, onHostPanel, features = {}, currentPage = 'chat', isMobile = false }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const drawerRef = useRef(null);
|
const drawerRef = useRef(null);
|
||||||
const isAdmin = user?.role === 'admin';
|
const isAdmin = user?.role === 'admin';
|
||||||
@@ -70,6 +71,7 @@ export default function NavDrawer({ open, onClose, onMessages, onSchedule, onSch
|
|||||||
<div className="nav-drawer-section-label admin">Admin</div>
|
<div className="nav-drawer-section-label admin">Admin</div>
|
||||||
{features.branding && item(NAV_ICON.branding, 'Branding', onBranding)}
|
{features.branding && item(NAV_ICON.branding, 'Branding', onBranding)}
|
||||||
{item(NAV_ICON.settings, 'Settings', onSettings)}
|
{item(NAV_ICON.settings, 'Settings', onSettings)}
|
||||||
|
{features.isHostDomain && item(NAV_ICON.hostpanel, 'Control Panel', onHostPanel, { active: currentPage === 'hostpanel' })}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -222,12 +222,17 @@ export default function UserManagerModal({ onClose }) {
|
|||||||
const [userPass, setUserPass] = useState('user@1234');
|
const [userPass, setUserPass] = useState('user@1234');
|
||||||
|
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const load = () => {
|
const load = async () => {
|
||||||
setLoadError('');
|
setLoadError('');
|
||||||
api.getUsers()
|
setLoading(true);
|
||||||
.then(({ users }) => setUsers(users))
|
try {
|
||||||
.catch(e => setLoadError(e.message || 'Failed to load users'))
|
const { users } = await api.getUsers();
|
||||||
.finally(() => setLoading(false));
|
setUsers(users || []);
|
||||||
|
} catch (e) {
|
||||||
|
setLoadError(e.message || 'Failed to load users');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Sidebar from '../components/Sidebar.jsx';
|
|||||||
import ChatWindow from '../components/ChatWindow.jsx';
|
import ChatWindow from '../components/ChatWindow.jsx';
|
||||||
import ProfileModal from '../components/ProfileModal.jsx';
|
import ProfileModal from '../components/ProfileModal.jsx';
|
||||||
import UserManagerModal from '../components/UserManagerModal.jsx';
|
import UserManagerModal from '../components/UserManagerModal.jsx';
|
||||||
|
import HostPanel from '../components/HostPanel.jsx';
|
||||||
import SettingsModal from '../components/SettingsModal.jsx';
|
import SettingsModal from '../components/SettingsModal.jsx';
|
||||||
import BrandingModal from '../components/BrandingModal.jsx';
|
import BrandingModal from '../components/BrandingModal.jsx';
|
||||||
import NewChatModal from '../components/NewChatModal.jsx';
|
import NewChatModal from '../components/NewChatModal.jsx';
|
||||||
@@ -41,7 +42,7 @@ export default function Chat() {
|
|||||||
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help' | 'groupmanager'
|
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help' | 'groupmanager'
|
||||||
const [page, setPage] = useState('chat'); // 'chat' | 'schedule'
|
const [page, setPage] = useState('chat'); // 'chat' | 'schedule'
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'JAMA-Chat', teamToolManagers: [] });
|
const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'JAMA-Chat', teamToolManagers: [], isHostDomain: false });
|
||||||
const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
|
const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
|
||||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||||
const [showSidebar, setShowSidebar] = useState(true);
|
const [showSidebar, setShowSidebar] = useState(true);
|
||||||
@@ -82,6 +83,7 @@ export default function Chat() {
|
|||||||
scheduleManager: settings.feature_schedule_manager === 'true',
|
scheduleManager: settings.feature_schedule_manager === 'true',
|
||||||
appType: settings.app_type || 'JAMA-Chat',
|
appType: settings.app_type || 'JAMA-Chat',
|
||||||
teamToolManagers: JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'),
|
teamToolManagers: JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'),
|
||||||
|
isHostDomain: settings.is_host_domain === 'true',
|
||||||
}));
|
}));
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
api.getMyUserGroups().then(({ groupIds }) => {
|
api.getMyUserGroups().then(({ groupIds }) => {
|
||||||
@@ -332,6 +334,34 @@ export default function Chat() {
|
|||||||
|
|
||||||
const isToolManager = user?.role === 'admin' || (features.teamToolManagers || []).some(gid => (features.userGroupMemberships || []).includes(gid));
|
const isToolManager = user?.role === 'admin' || (features.teamToolManagers || []).some(gid => (features.userGroupMemberships || []).includes(gid));
|
||||||
|
|
||||||
|
if (page === 'hostpanel') {
|
||||||
|
return (
|
||||||
|
<div className="chat-layout">
|
||||||
|
<GlobalBar isMobile={isMobile} showSidebar={true} onBurger={() => setDrawerOpen(true)} />
|
||||||
|
<div className="chat-body" style={{ overflow: 'hidden' }}>
|
||||||
|
<HostPanel />
|
||||||
|
</div>
|
||||||
|
<NavDrawer
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
onMessages={() => { setDrawerOpen(false); setPage('chat'); }}
|
||||||
|
onSchedule={() => { setDrawerOpen(false); setPage('schedule'); }}
|
||||||
|
onScheduleManager={() => { setDrawerOpen(false); setPage('schedule'); }}
|
||||||
|
onGroupManager={() => { setDrawerOpen(false); if(isMobile) setModal('mobilegroupmanager'); else setModal('groupmanager'); }}
|
||||||
|
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
|
||||||
|
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
||||||
|
onUsers={() => { setDrawerOpen(false); setModal('users'); }}
|
||||||
|
onHostPanel={() => { setDrawerOpen(false); setPage('hostpanel'); }}
|
||||||
|
features={features}
|
||||||
|
currentPage={page}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
||||||
|
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (page === 'schedule') {
|
if (page === 'schedule') {
|
||||||
return (
|
return (
|
||||||
<div className="chat-layout">
|
<div className="chat-layout">
|
||||||
@@ -356,6 +386,7 @@ export default function Chat() {
|
|||||||
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
|
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
|
||||||
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
||||||
onUsers={() => { setDrawerOpen(false); setModal('users'); }}
|
onUsers={() => { setDrawerOpen(false); setModal('users'); }}
|
||||||
|
onHostPanel={() => { setDrawerOpen(false); setPage('hostpanel'); }}
|
||||||
features={features}
|
features={features}
|
||||||
currentPage={page}
|
currentPage={page}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
@@ -425,6 +456,7 @@ export default function Chat() {
|
|||||||
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
|
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
|
||||||
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
||||||
onUsers={() => { setDrawerOpen(false); setModal('users'); }}
|
onUsers={() => { setDrawerOpen(false); setModal('users'); }}
|
||||||
|
onHostPanel={() => { setDrawerOpen(false); setPage('hostpanel'); }}
|
||||||
features={features}
|
features={features}
|
||||||
currentPage={page}
|
currentPage={page}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
|||||||
Reference in New Issue
Block a user