From a072a13706970879a91e5bf607c91a1b0d4b28a6 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Fri, 20 Mar 2026 12:56:28 -0400 Subject: [PATCH] v0.10.3 ui changes and bug fixes --- backend/package.json | 2 +- backend/src/routes/settings.js | 9 + build.sh | 2 +- frontend/package.json | 2 +- frontend/src/App.jsx | 6 +- frontend/src/components/HostPanel.jsx | 491 +++++++++++++++++++ frontend/src/components/NavDrawer.jsx | 4 +- frontend/src/components/UserManagerModal.jsx | 15 +- frontend/src/pages/Chat.jsx | 34 +- 9 files changed, 550 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/HostPanel.jsx diff --git a/backend/package.json b/backend/package.json index 8571eff..98d9c86 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.10.2", + "version": "0.10.3", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index de0a2fc..20bb7f5 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -39,6 +39,15 @@ router.get('/', async (req, res) => { if (admin) obj.admin_email = admin.email; obj.app_version = process.env.JAMA_VERSION || 'dev'; 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 }); } catch (e) { res.status(500).json({ error: e.message }); } }); diff --git a/build.sh b/build.sh index c2f69d7..4129028 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.10.2}" +VERSION="${1:-0.10.3}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index 0161a7d..bd60ebc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.10.2", + "version": "0.10.3", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 394bf11..a74f156 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,7 +5,6 @@ import { ToastProvider } from './contexts/ToastContext.jsx'; import Login from './pages/Login.jsx'; import Chat from './pages/Chat.jsx'; import ChangePassword from './pages/ChangePassword.jsx'; -import HostAdmin from './pages/HostAdmin.jsx'; function ProtectedRoute({ children }) { const { user, loading, mustChangePassword } = useAuth(); @@ -38,10 +37,7 @@ export default function App() { - {/* /host renders outside AuthProvider — has its own key-based auth */} - } /> - } /> - {/* All other routes go through jama auth */} + {/* All routes go through jama auth */} diff --git a/frontend/src/components/HostPanel.jsx b/frontend/src/components/HostPanel.jsx new file mode 100644 index 0000000..fbd1e94 --- /dev/null +++ b/frontend/src/components/HostPanel.jsx @@ -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 ( + + {value} + + ); +} + +function FieldGroup({ label, children }) { + return ( +
+ {label && } + {children} +
+ ); +} + +function Field({ label, value, onChange, placeholder, type = 'text', hint, required }) { + return ( + + onChange(e.target.value)} + 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)} 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)} + onKeyDown={e => e.key === 'Enter' && handle()} placeholder="Host admin key" autoFocus + style={{ marginBottom:12, textAlign:'center' }} /> + +
+
+ ); +} + +// ── 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
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)} 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} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/NavDrawer.jsx b/frontend/src/components/NavDrawer.jsx index eac8ebb..ece9fd2 100644 --- a/frontend/src/components/NavDrawer.jsx +++ b/frontend/src/components/NavDrawer.jsx @@ -8,10 +8,11 @@ const NAV_ICON = { users: , groups: , branding: , + hostpanel: , settings: , }; -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 drawerRef = useRef(null); const isAdmin = user?.role === 'admin'; @@ -70,6 +71,7 @@ export default function NavDrawer({ open, onClose, onMessages, onSchedule, onSch
Admin
{features.branding && item(NAV_ICON.branding, 'Branding', onBranding)} {item(NAV_ICON.settings, 'Settings', onSettings)} + {features.isHostDomain && item(NAV_ICON.hostpanel, 'Control Panel', onHostPanel, { active: currentPage === 'hostpanel' })} )} diff --git a/frontend/src/components/UserManagerModal.jsx b/frontend/src/components/UserManagerModal.jsx index 984ea07..89fab27 100644 --- a/frontend/src/components/UserManagerModal.jsx +++ b/frontend/src/components/UserManagerModal.jsx @@ -222,12 +222,17 @@ export default function UserManagerModal({ onClose }) { const [userPass, setUserPass] = useState('user@1234'); const [loadError, setLoadError] = useState(''); - const load = () => { + const load = async () => { setLoadError(''); - api.getUsers() - .then(({ users }) => setUsers(users)) - .catch(e => setLoadError(e.message || 'Failed to load users')) - .finally(() => setLoading(false)); + setLoading(true); + try { + const { users } = await api.getUsers(); + setUsers(users || []); + } catch (e) { + setLoadError(e.message || 'Failed to load users'); + } finally { + setLoading(false); + } }; useEffect(() => { load(); diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx index ff709d2..db073cd 100644 --- a/frontend/src/pages/Chat.jsx +++ b/frontend/src/pages/Chat.jsx @@ -7,6 +7,7 @@ import Sidebar from '../components/Sidebar.jsx'; import ChatWindow from '../components/ChatWindow.jsx'; import ProfileModal from '../components/ProfileModal.jsx'; import UserManagerModal from '../components/UserManagerModal.jsx'; +import HostPanel from '../components/HostPanel.jsx'; import SettingsModal from '../components/SettingsModal.jsx'; import BrandingModal from '../components/BrandingModal.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 [page, setPage] = useState('chat'); // 'chat' | 'schedule' 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 [isMobile, setIsMobile] = useState(window.innerWidth < 768); const [showSidebar, setShowSidebar] = useState(true); @@ -82,6 +83,7 @@ export default function Chat() { scheduleManager: settings.feature_schedule_manager === 'true', appType: settings.app_type || 'JAMA-Chat', teamToolManagers: JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'), + isHostDomain: settings.is_host_domain === 'true', })); }).catch(() => {}); 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)); + if (page === 'hostpanel') { + return ( +
+ setDrawerOpen(true)} /> +
+ +
+ 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' && setModal(null)} />} + {modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />} +
+ ); + } + if (page === 'schedule') { return (
@@ -356,6 +386,7 @@ export default function Chat() { 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} @@ -425,6 +456,7 @@ export default function Chat() { 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}