// Settings panes (Account / Team / Integrations / Tommy AI / Notifications) // and the SettingsView container. All wired to real endpoints. // ---- Settings (Account / Team / Integrations / Tommy AI / Notifications) --- // Fallback used when /auth/role-config hasn't loaded yet or the request fails. function pageAccessFor(role) { switch ((role || 'staff').toLowerCase()) { case 'admin': return ['home', 'leads', 'analytics', 'calendar', 'tommy', 'settings', 'members']; case 'front_desk': return ['home', 'leads', 'calendar', 'tommy', 'members']; case 'trainer': return ['home', 'calendar', 'members']; case 'staff': default: return ['home', 'leads', 'calendar', 'tommy']; } } function prettyPageLabel(id) { return String(id || '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); } function SettingsToggle({ on, onClick, disabled }) { return ( ); } function AccountPane({ user, onSignOut }) { const [name, setName] = React.useState(user?.name || ''); const [editingName, setEditingName] = React.useState(false); const [saving, setSaving] = React.useState(false); React.useEffect(() => { if (user?.name) setName(user.name); }, [user?.name]); const roleCfg = useAsync(() => api.get('/api/v2/auth/role-config').catch(() => null), []); const pages = (roleCfg.data?.nav && Array.isArray(roleCfg.data.nav) && roleCfg.data.nav.length > 0) ? roleCfg.data.nav : pageAccessFor(user?.role); async function saveName(raw) { const next = (raw || '').trim(); if (!next || next === name) { setEditingName(false); return; } setSaving(true); try { const r = await api.patch('/api/v2/auth/update-profile', { name: next }); setName(r.name || next); if (window.CURRENT_USER) window.CURRENT_USER.name = r.name || next; setEditingName(false); } catch (e) { alert(e.message || 'Save failed'); } finally { setSaving(false); } } async function changePassword() { const current = prompt('Current password'); if (!current) return; const next = prompt('New password (min 8 chars)'); if (!next) return; try { await api.post('/api/v2/auth/change-password', { current_password: current, new_password: next }); alert('Password updated.'); } catch (e) { alert(e.message || 'Change failed'); } } async function signOutAll() { if (!confirm('This revokes every active session for your account. Continue?')) return; try { await api.post('/api/v2/auth/sign-out-all', {}); api.clearTokens(); window.location.reload(); } catch (e) { alert(e.message || 'Failed'); } } return ( <>
Profile
Name {!editingName && ( )}
{editingName ? ( { if (e.key === 'Enter') saveName(e.currentTarget.value); if (e.key === 'Escape') setEditingName(false); }} onBlur={e => saveName(e.currentTarget.value)} /> ) : (
{name || '—'}
)}
Email
{user?.email || '—'}
Role Active
{user?.role || 'staff'}
Page access
{pages.map(p => {prettyPageLabel(p)})}
Danger zone
Sign out everywhere
Revokes all active tokens
Sign out this browser
Drops this session only
); } // Mirrors `ALLOWED_ROLES` in app/auth.py:22 (and `available_roles` from // /api/v2/auth/role-config). Order matches the backend tuple so the dropdown // matches the canonical list — drift between the two sources is what shipped // privilege bugs in PR #3 (the inline role list in leads.py rejected the // "staff" role that auth.py accepted). Server-driven sourcing is the // long-term fix; this constant is the safe-on-page-load fallback for when // /role-config hasn't returned yet (modal opens before the request lands). const STAFF_ROLES = ['admin', 'front_desk', 'trainer', 'staff']; const ROLE_DESCRIPTIONS = { admin: 'Full access: home, leads, analytics, calendar, tommy, settings, members', front_desk: 'Front desk: home, leads, calendar, tommy, members', trainer: 'Trainer: home, calendar, members', staff: 'Default staff: home, leads, calendar, tommy', }; // Modal to create a new staff user. Posts to /api/v2/admin/users (admin-only), // then surfaces the server-generated temporary password in a dedicated success // view with copy-to-clipboard so it doesn't get lost in an alert() the user // might dismiss before reading. function InviteStaffModal({ open, onClose, onCreated }) { const [mode, setMode] = React.useState('form'); // 'form' | 'success' const [email, setEmail] = React.useState(''); const [name, setName] = React.useState(''); const [role, setRole] = React.useState('staff'); const [submitting, setSubmitting] = React.useState(false); const [error, setError] = React.useState(null); const [created, setCreated] = React.useState(null); const [copied, setCopied] = React.useState(false); const firstFieldRef = React.useRef(null); // Reset state and focus email field whenever the modal opens. React.useEffect(() => { if (!open) return; setMode('form'); setEmail(''); setName(''); setRole('staff'); setError(null); setSubmitting(false); setCreated(null); setCopied(false); setTimeout(() => firstFieldRef.current?.focus(), 0); }, [open]); React.useEffect(() => { if (!open) return; function onKey(e) { if (e.key === 'Escape' && !submitting) handleClose(); } document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, submitting, mode, created]); if (!open) return null; function handleClose() { if (mode === 'success' && onCreated) onCreated(); onClose(); } const canSubmit = email.trim() && name.trim() && !submitting; async function submit(e) { e?.preventDefault(); if (!canSubmit) return; setSubmitting(true); setError(null); try { const payload = { email: email.trim().toLowerCase(), name: name.trim(), role, }; const res = await api.post('/api/v2/admin/users', payload); setCreated({ email: res.email || payload.email, name: res.name || payload.name, role: res.role || payload.role, temp_password: res.temp_password || '', }); setMode('success'); } catch (err) { setError(err?.message || 'Failed to create user'); } finally { setSubmitting(false); } } async function copyPassword() { if (!created?.temp_password) return; try { await navigator.clipboard.writeText(created.temp_password); setCopied(true); setTimeout(() => setCopied(false), 1800); } catch (e) { // Clipboard may be blocked on http or in the browser config; the // password stays visible so the admin can still copy it manually. } } return (
{ if (e.target === e.currentTarget && !submitting) handleClose(); }} role="dialog" aria-modal="true" aria-labelledby="invite-modal-title" > {mode === 'form' && (
— Invite staff
A temporary password will be generated and shown next. Share it with the new user through a trusted channel — it can't be retrieved later.
{error &&
{error}
}
)} {mode === 'success' && created && (
— User created
Email
{created.email}
Name
{created.name}
Role
{created.role}
Temporary password
{created.temp_password || '—'}
Stored as a one-way hash on the server. Once this dialog closes, the password can't be retrieved — you'd have to issue a new one via Reset pw.
Next steps
  1. Share the credentials{' '} with {created.name || 'the new user'} through a trusted channel — Telegram, Signal, or in person. The system does not email them automatically.
  2. Tell them to sign in at{' '} {window.location.origin}{' '} with their email and the temporary password above.
  3. Right after signing in, they should open{' '} Settings → Account → Change password{' '} and pick their own.
  4. If they lose the temporary password before changing it, come back here and click{' '} Reset pw{' '} on their row to generate a new one.
)}
); } // Cryptographically random password for the reset flow. Excludes ambiguous // glyphs (0/O, 1/l/I) to reduce mistakes when the admin transcribes the // password to Telegram or reads it out loud. crypto.getRandomValues is the // only randomness source — Math.random is not safe for credentials. function generateTempPassword(length = 16) { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%'; const arr = new Uint32Array(length); crypto.getRandomValues(arr); let result = ''; for (let i = 0; i < length; i++) result += chars[arr[i] % chars.length]; return result; } // Two-step reset: confirm-with-context, then issue a freshly-generated // password and surface it to the admin with copy-to-clipboard. Same UX // shape as InviteStaffModal so the Team page feels coherent. function ResetPasswordModal({ member, onClose, onDone }) { const [mode, setMode] = React.useState('confirm'); // 'confirm' | 'success' const [submitting, setSubmitting] = React.useState(false); const [error, setError] = React.useState(null); const [newPw, setNewPw] = React.useState(''); const [copied, setCopied] = React.useState(false); React.useEffect(() => { if (!member) return; setMode('confirm'); setSubmitting(false); setError(null); setNewPw(''); setCopied(false); }, [member?.id]); React.useEffect(() => { if (!member) return; function onKey(e) { if (e.key === 'Escape' && !submitting) handleClose(); } document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, [member, submitting, mode]); if (!member) return null; function handleClose() { if (mode === 'success' && onDone) onDone(); onClose(); } async function confirm() { setSubmitting(true); setError(null); const pw = generateTempPassword(); try { await api.patch(`/api/v2/admin/users/${member.id}`, { password: pw }); setNewPw(pw); setMode('success'); } catch (err) { setError(err?.message || 'Reset failed'); } finally { setSubmitting(false); } } async function copyPassword() { if (!newPw) return; try { await navigator.clipboard.writeText(newPw); setCopied(true); setTimeout(() => setCopied(false), 1800); } catch (e) { /* clipboard blocked — code stays selectable */ } } return (
{ if (e.target === e.currentTarget && !submitting) handleClose(); }} role="dialog" aria-modal="true" aria-labelledby="reset-modal-title" > {mode === 'confirm' && (
— Reset password
User
{member.name || member.email.split('@')[0]} {member.email}
A new temporary password will be generated and shown next. The user's existing password stops working immediately. Their currently-active sessions are not revoked by this — an admin token they've already minted keeps working until expiry unless you also bump token_version.
{error &&
{error}
}
)} {mode === 'success' && (
— Password reset
User
{member.email}
New temporary password
{newPw}
The previous password no longer works. Once this dialog closes, this temporary password can't be retrieved — you'd have to reset again.
Next steps
  1. Share the new password{' '} with {member.name || 'the user'} through a trusted channel — Telegram, Signal, or in person.
  2. Ask them to sign in at{' '} {window.location.origin}{' '} with this password and immediately set their own via{' '} Settings → Account → Change password.
)}
); } // Single-step destructive confirm with the user's context (name, email, role) // and an explicit warning about what happens. Uses .btn.danger so it reads as // red/warn instead of looking like the same neutral confirm as Cancel. function RemoveUserModal({ member, onClose, onRemoved }) { const [submitting, setSubmitting] = React.useState(false); const [error, setError] = React.useState(null); React.useEffect(() => { if (!member) return; setSubmitting(false); setError(null); }, [member?.id]); React.useEffect(() => { if (!member) return; function onKey(e) { if (e.key === 'Escape' && !submitting) onClose(); } document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [member, submitting, onClose]); if (!member) return null; async function confirm() { setSubmitting(true); setError(null); try { await api.del(`/api/v2/admin/users/${member.id}`); if (onRemoved) onRemoved(); onClose(); } catch (err) { setError(err?.message || 'Remove failed'); setSubmitting(false); } } return (
{ if (e.target === e.currentTarget && !submitting) onClose(); }} role="dialog" aria-modal="true" aria-labelledby="remove-modal-title" >
— Remove team member
User
{member.name || member.email.split('@')[0]} {member.email} {member.role || 'staff'}
This deletes the staff record from this dashboard's user table. They will not be removed from GoHighLevel — contacts and SMS history are unaffected.
This action cannot be undone. If you remove the wrong user, you'll have to recreate them from scratch with a new password.
{error &&
{error}
}
); } function TeamPane({ user }) { const isAdmin = user?.role === 'admin'; const staff = useAsync(() => isAdmin ? api.get('/api/v2/staff') : Promise.resolve([]), [isAdmin]); const list = Array.isArray(staff.data) ? staff.data : []; const [editingId, setEditingId] = React.useState(null); const [draft, setDraft] = React.useState({ name: '', role: 'staff' }); const [saving, setSaving] = React.useState(false); const [editError, setEditError] = React.useState(null); const [inviteOpen, setInviteOpen] = React.useState(false); const [resetTarget, setResetTarget] = React.useState(null); const [removeTarget, setRemoveTarget] = React.useState(null); function startEdit(s) { setEditingId(s.id); setDraft({ name: s.name || '', role: s.role || 'staff' }); setEditError(null); } function cancelEdit() { setEditingId(null); setEditError(null); } async function saveEdit(id) { const next = { name: draft.name.trim(), role: draft.role }; // The Save button is disabled when name is empty so this is unreachable // through the UI, but keep a guard for keyboard/programmatic submit. if (!next.name) { setEditError('Name cannot be empty.'); return; } setSaving(true); setEditError(null); try { await api.patch(`/api/v2/admin/users/${id}`, next); await staff.reload(); setEditingId(null); } catch (e) { setEditError(e.message || 'Save failed'); } finally { setSaving(false); } } if (!isAdmin) { return (
Team
Admin only. Ask an admin to grant access.
); } return (
Team · {list.length}
setInviteOpen(false)} onCreated={() => staff.reload()} /> setResetTarget(null)} /> setRemoveTarget(null)} onRemoved={() => staff.reload()} />
{list.length === 0 && !staff.loading && ( )} {list.map(s => { const b = staffBadge(s.name || s.email); const isSelf = String(s.id) === String(user?.id); const isEditing = editingId === s.id; return ( ); })}
Name Role Email Created  
No staff records.
{b.initials} {isEditing ? (
setDraft(d => ({...d, name: e.target.value}))} disabled={saving} className="settings-input" style={{height:28, fontSize:13, padding:'0 8px'}} aria-invalid={!!editError} /> {editError && ( {editError} )}
) : ( {s.name || s.email.split('@')[0]} {isSelf && (you)} )}
{isEditing ? ( ) : ( {s.role || 'staff'} )} {s.email} {fmtShortDay(s.created_at)} {isEditing ? (
) : (
{!isSelf && ( )}
)}
); } function IntegrationsPane({ user }) { const isAdmin = user?.role === 'admin'; // Hard-gate the whole pane on admin. PR #21 already pinned require_admin // on /api/v2/admin/sync-* server-side, so a non-admin clicking the buttons // would 403 anyway — but rendering them and letting the request fail is // sloppy. Mirror the TeamPane gate pattern. const integ = useAsync( () => isAdmin ? api.get('/api/v2/system/integrations-status') : Promise.resolve({ integrations: [] }), [isAdmin] ); const services = (integ.data?.integrations || []); const configured = services.filter(s => s.configured).length; const [syncing, setSyncing] = React.useState(null); // null | 'full' | 'recent' async function runSync(kind) { setSyncing(kind); try { const endpoint = kind === 'recent' ? '/api/v2/admin/sync-recent' : '/api/v2/admin/sync-contacts'; await api.post(endpoint, {}); alert(kind === 'recent' ? 'Recent sync (7d) started.' : 'Full sync started in background.'); } catch (e) { alert(e.message || 'Sync failed'); } finally { setSyncing(null); } } if (!isAdmin) { return (
Integrations
Admin only. Ask an admin to grant access.
); } const jobs = [ { name:'GHL incremental sync', cadence:'every 15m', detail:'sync_single_contact' }, { name:'GHL full sweep', cadence:'daily · 03:00 UTC', detail:'_run_bulk_import' }, { name:'Pending-approval expire',cadence:'every 60m', detail:'auto_expire_cards' }, { name:'Tommy worker', cadence:'pg_notify', detail:'tommy_new_message listener' }, ]; return ( <>
Integrations · {configured}/{services.length} configured
{integ.loading &&
Loading…
} {!integ.loading && services.length === 0 &&
No integrations registered.
} {services.map(s => { const tone = s.coming_soon ? 'planned' : s.configured ? 'ok' : 'down'; return (
{s.name}
{s.description}
{s.status_message}
{s.last_activity_at &&
last · {fmtAgoFrom(s.last_activity_at)}
}
{s.coming_soon ? 'Planned' : s.configured ? 'Healthy' : 'Needs setup'}
); })}
Background jobs
{jobs.map(j => (
{j.name} {j.cadence} {j.detail}
))}
); } function TommyAIPane() { const config = useAsync(() => api.get('/api/v2/system/tommy-config'), []); const cfg = config.data || {}; return ( <>
Configuration
Model
{cfg.model || '—'}
Memory
{cfg.memory_source || '—'}
Tone
{cfg.tone || '—'}
Contact routing
{cfg.contact_routing || '—'}
Approval triggers
Force approval
{cfg.force_approval ? 'Enabled' : 'Disabled'}
Server flag TOMMY_FORCE_APPROVAL. When enabled, every Tommy draft waits for staff approval. Change via env var + redeploy.
{cfg.force_approval ? 'Active' : 'Off'}
); } function NotificationsPane() { const prefs = useAsync(() => api.get('/api/v2/notifications/prefs'), []); const [local, setLocal] = React.useState({ tommy_approval: true, new_lead: false, daily_digest: true }); React.useEffect(() => { if (prefs.data) setLocal(prefs.data); }, [prefs.data]); async function toggle(key) { const next = { ...local, [key]: !local[key] }; setLocal(next); try { await api.patch('/api/v2/notifications/prefs', { [key]: next[key] }); } catch (e) { alert(e.message || 'Save failed'); setLocal(local); } } const rows = [ { key: 'tommy_approval', label: 'Tommy approval', desc: 'A draft needs your approval' }, { key: 'new_lead', label: 'New lead', desc: 'A contact is created in GHL' }, { key: 'daily_digest', label: 'Daily digest', desc: 'One summary email each morning' }, ]; return (
Notifications
{rows.map(r => (
{r.label}
{r.desc}
toggle(r.key)} />
))}
Stored per-user in notification_prefs.
); } function SettingsView({ user, onSignOut }) { const isAdmin = user?.role === 'admin'; // Only show the tabs the user can actually use. Team + Integrations are // admin-only — the panes themselves render an "Admin only" empty state if // a non-admin gets here through deep-linking, but pre-filtering keeps the // sidebar from being a tease. const allTabs = [ { id: 'account', label: 'Account', ico: '◉', admin: false }, { id: 'team', label: 'Team', ico: '◍', admin: true }, { id: 'integrations', label: 'Integrations', ico: '◈', admin: true }, { id: 'tommy', label: 'Tommy AI', ico: '◩', admin: false }, { id: 'notifications', label: 'Notifications', ico: '⌖', admin: false }, ]; const tabs = allTabs.filter(t => !t.admin || isAdmin); const [tab, setTab] = React.useState('account'); const navRef = React.useRef(null); // If a non-admin somehow lands on a hidden tab (stale localStorage, etc), // bounce them back to Account. React.useEffect(() => { if (!tabs.some(t => t.id === tab)) setTab('account'); }, [tabs, tab]); // On phones the .settings-nav is a horizontal scroller; tabs past the // viewport edge are tappable but visually clipped (the user sees "TOM…"). // When the active tab changes, scroll it into view so the selection // is always anchored regardless of which end of the strip it sits at. React.useEffect(() => { if (!navRef.current) return; const active = navRef.current.querySelector('.settings-tab.active'); if (active && typeof active.scrollIntoView === 'function') { active.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' }); } }, [tab]); return (

Settings

Dashboard configuration, team, and Tommy AI
{tab === 'account' && } {tab === 'team' && } {tab === 'integrations' && } {tab === 'tommy' && } {tab === 'notifications' && }
); } // ---- System health ------------------------------------------------ Object.assign(window, { SettingsView });