// ==========================================
// Chat Switchboard – UI Settings & Teams
// ==========================================
// Extends UI with settings modal tabs, team management,
// providers, usage, model roles, and user preferences.
Object.assign(UI, {
// ── Appearance Settings ─────────────────
loadAppearanceSettings() {
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
const scale = prefs.scale || 100;
const msgFont = prefs.msgFont || 14;
const scaleEl = document.getElementById('settingsScale');
const msgFontEl = document.getElementById('settingsMsgFont');
if (scaleEl) { scaleEl.value = scale; document.getElementById('scaleValue').textContent = scale + '%'; }
if (msgFontEl) { msgFontEl.value = msgFont; document.getElementById('msgFontValue').textContent = msgFont + 'px'; }
},
initAppearance() {
// Load saved prefs on startup
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
UI.applyAppearance(prefs.scale || 100, prefs.msgFont || 14);
// Live preview on slider change
const scaleEl = document.getElementById('settingsScale');
const msgFontEl = document.getElementById('settingsMsgFont');
if (scaleEl) scaleEl.addEventListener('input', () => {
const v = parseInt(scaleEl.value);
document.getElementById('scaleValue').textContent = v + '%';
UI.applyAppearance(v, parseInt(msgFontEl?.value || 14));
});
if (msgFontEl) msgFontEl.addEventListener('input', () => {
const v = parseInt(msgFontEl.value);
document.getElementById('msgFontValue').textContent = v + 'px';
UI.applyAppearance(parseInt(scaleEl?.value || 100), v);
});
},
applyAppearance(scale, msgFont) {
const z = scale === 100 ? '' : scale / 100;
// Zoom content areas + modals + panels (but NOT .app or banners)
document.querySelectorAll('.sidebar, .chat-area, .modal-overlay, .side-panel, .admin-panel').forEach(el => el.style.zoom = z);
const splash = document.getElementById('splashGate');
if (splash) splash.style.zoom = z;
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
// Recheck tab overflow after layout settles with new zoom
requestAnimationFrame(() => {
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(checkTabsOverflow);
});
},
async checkUserProvidersAllowed() {
const notice = document.getElementById('userProvidersDisabled');
const addBtn = document.getElementById('providerShowAddBtn');
const tabBtn = document.getElementById('settingsProvidersTabBtn');
const usageTabBtn = document.getElementById('settingsUsageTabBtn');
const rolesTabBtn = document.getElementById('settingsRolesTabBtn');
const kbTabBtn = document.getElementById('settingsKBTabBtn');
if (!notice) return;
const allowed = App.policies?.allow_user_byok === 'true';
if (!kbTabBtn) console.warn('[Settings] settingsKBTabBtn not found in DOM');
notice.style.display = allowed ? 'none' : '';
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
if (tabBtn) tabBtn.style.display = allowed ? '' : 'none';
if (usageTabBtn) usageTabBtn.style.display = allowed ? '' : 'none';
if (rolesTabBtn) rolesTabBtn.style.display = allowed ? '' : 'none';
if (kbTabBtn) kbTabBtn.style.display = allowed ? '' : 'none';
},
checkUserPresetsAllowed() {
const addBtn = document.getElementById('userAddPresetBtn');
const addForm = document.getElementById('userAddPresetForm');
const tabBtn = document.getElementById('settingsPersonasTabBtn');
const allowed = App.policies?.allow_user_personas === 'true';
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
if (addForm && !allowed) addForm.style.display = 'none';
if (tabBtn) tabBtn.style.display = allowed ? '' : 'none';
},
async loadProfileIntoSettings() {
try {
const p = await API.getProfile();
document.getElementById('profileDisplayName').value = p.display_name || '';
document.getElementById('profileEmail').value = p.email || '';
updateAvatarPreview(p.avatar || null);
} catch (e) { /* optional */ }
},
async loadMyTeams() {
const section = document.getElementById('settingsTeamsSection');
const el = document.getElementById('settingsTeamsList');
const menuBtn = document.getElementById('menuTeamAdmin');
if (!section || !el) return;
try {
const resp = await API.listMyTeams();
const teams = resp.data || [];
UI._myTeams = teams;
// Show/hide the Team Management flyout item for team admins
const isTeamAdmin = teams.some(t => t.my_role === 'admin');
if (menuBtn) menuBtn.style.display = isTeamAdmin ? '' : 'none';
if (teams.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = '';
el.innerHTML = teams.map(t => `
${esc(t.name)}
${t.my_role}
${t.my_role === 'admin' ? 'Manage → ' : ''}
${t.description ? `
${esc(t.description)}
` : ''}
${t.member_count} member${t.member_count !== 1 ? 's' : ''}
`).join('');
} catch (e) { section.style.display = 'none'; if (menuBtn) menuBtn.style.display = 'none'; }
},
_myTeams: [],
_managingTeamId: null,
loadTeamsTab() {
// No longer used — picker is in team admin modal
},
async openTeamManage(teamId, teamName) {
UI._managingTeamId = teamId;
UI._teamAuditPage = 1;
// If the modal isn't open yet (called from settings "Manage →"), open it
if (!document.getElementById('teamAdminModal').classList.contains('active')) {
openModal('teamAdminModal');
}
// Show tabbed content, hide picker
const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin');
const titleEl = document.getElementById('teamAdminTitle');
if (adminTeams.length > 1) {
titleEl.innerHTML = `← ${esc(teamName)}`;
} else {
titleEl.textContent = teamName;
}
document.getElementById('teamAdminPicker').style.display = 'none';
document.getElementById('teamAdminContent').style.display = '';
// Reset forms (null-safe — not all form containers may exist)
['settingsTeamAddMember', 'settingsTeamAddPreset', 'settingsTeamProviderForm'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
const af = document.getElementById('teamAuditFilterAction');
if (af) { af.innerHTML = 'All actions '; }
// Show Members tab first, load its data
UI.switchTeamTab('members');
},
showTeamPicker() {
document.getElementById('teamAdminTitle').textContent = 'Team Management';
document.getElementById('teamAdminContent').style.display = 'none';
document.getElementById('teamAdminPicker').style.display = '';
},
async loadTeamManageMembers(teamId) {
const el = document.getElementById('settingsTeamMembers');
el.innerHTML = 'Loading...
';
try {
const resp = await API.teamListMembers(teamId);
const members = resp.data || [];
el.innerHTML = members.map(m => `
${esc(m.display_name || m.email)}
${m.role}
Member
Admin
✕
`).join('') || 'No members
';
} catch (e) { el.innerHTML = `${esc(e.message)}
`; }
},
async loadTeamManageProviders(teamId) {
const el = document.getElementById('settingsTeamProviders');
if (!el) return;
const addBtn = document.getElementById('settingsTeamAddProviderBtn');
const formEl = document.getElementById('settingsTeamProviderForm');
// Initialize team provider form primitive (once)
if (!UI._teamProvForm && formEl) {
UI._teamProvForm = renderProviderForm(formEl, {
prefix: 'teamProv',
showPrivate: true,
showDefaultModel: true,
onSubmit: async (vals, isEdit) => {
const tid = UI._managingTeamId;
try {
if (isEdit) {
const updates = {};
if (vals.name) updates.name = vals.name;
if (vals.endpoint) updates.endpoint = vals.endpoint;
if (vals.api_key) updates.api_key = vals.api_key;
if (vals.model_default) updates.model_default = vals.model_default;
updates.is_private = vals.is_private || false;
await API.teamUpdateProvider(tid, vals._editId, updates);
UI.toast('Provider updated');
} else {
if (!vals.name) return UI.toast('Name required', 'error');
if (!vals.endpoint) return UI.toast('Endpoint required', 'error');
await API.teamCreateProvider(tid, {
name: vals.name, provider: vals.provider, endpoint: vals.endpoint,
api_key: vals.api_key, is_private: vals.is_private || false,
});
UI.toast('Provider added');
}
formEl.style.display = 'none';
UI._teamProvForm.setCreateMode();
UI._teamProvList.refresh();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => {
formEl.style.display = 'none';
UI._teamProvForm.setCreateMode();
},
});
}
// Initialize team provider list primitive (once)
if (!UI._teamProvList) {
UI._teamProvList = renderProviderList(el, {
apiFetch: async () => {
const resp = await API.teamListProviders(UI._managingTeamId);
// Handle policy check
const allowed = resp.allow_team_providers !== false;
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
if (!allowed && !(resp.providers || []).length) {
throw { message: 'Team provider management is disabled by your administrator', _empty: true };
}
return resp;
},
showPrivate: true,
showActive: true,
emptyMsg: 'No team providers — add one to give your team access to additional models',
onEdit: (prov) => {
if (UI._teamProvForm) {
UI._teamProvForm.setEditMode(prov.id, prov);
formEl.style.display = '';
}
},
onDelete: async (prov) => {
if (!await showConfirm(`Delete team provider "${prov.name}"? This will remove all models from this provider for your team.`)) return;
try {
await API.teamDeleteProvider(UI._managingTeamId, prov.id);
UI.toast('Provider deleted');
UI._teamProvList.refresh();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
onToggleActive: async (prov) => {
try {
await API.teamUpdateProvider(UI._managingTeamId, prov.id, { is_active: !prov.is_active });
UI.toast(prov.is_active ? 'Provider deactivated' : 'Provider activated');
UI._teamProvList.refresh();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
});
}
await UI._teamProvList.refresh();
},
async loadTeamManagePresets(teamId) {
const el = document.getElementById('settingsTeamPresets');
el.innerHTML = 'Loading...
';
try {
const resp = await API.teamListPresets(teamId);
const presets = resp.presets || [];
el.innerHTML = presets.map(p => {
const icon = p.icon || '';
return `
${icon ? icon + ' ' : ''}${esc(p.name)}
${esc(p.base_model_id)}
✕
`;
}).join('') || 'No team presets — create one to give your team preconfigured models
';
} catch (e) { el.innerHTML = `${esc(e.message)}
`; }
},
async loadTeamManageGroups(teamId) {
const el = document.getElementById('settingsTeamGroups');
if (!el) return;
el.innerHTML = 'Loading...
';
try {
const resp = await API.teamListGroups(teamId);
const groups = resp.data || [];
el.innerHTML = groups.map(g => `
${esc(g.name)}
${esc(g.description || 'No description')} · ${g.member_count} member${g.member_count !== 1 ? 's' : ''}
`).join('') || 'No groups assigned to this team yet
';
} catch (e) { el.innerHTML = `${esc(e.message)}
`; }
},
async loadMyUsage() {
const el = document.getElementById('myUsageTotals')?.parentElement;
if (!el) return;
if (!UI._myUsageDash) {
UI._myUsageDash = renderUsageDashboard(el, {
apiFetch: (p) => API.getMyUsage(p),
periodElId: 'myUsagePeriod',
groupByElId: 'myUsageGroupBy',
compact: true,
});
}
await UI._myUsageDash.refresh();
},
async loadTeamUsage() {
const teamId = UI._managingTeamId;
if (!teamId) return;
const el = document.getElementById('settingsTeamUsageTotals')?.parentElement;
if (!el) return;
if (!UI._teamUsageDash) {
UI._teamUsageDash = renderUsageDashboard(el, {
apiFetch: (p) => API.teamGetUsage(teamId, p),
periodElId: 'teamUsagePeriod',
groupByElId: 'teamUsageGroupBy',
compact: true,
showUserColumn: true,
});
}
await UI._teamUsageDash.refresh();
},
_teamAuditPage: 1,
_teamAuditPerPage: 20,
// ── User Model Roles (BYOK overrides) ──
async loadUserRoles() {
const el = document.getElementById('userRolesConfig');
const notice = document.getElementById('userRolesDisabled');
const tabBtn = document.getElementById('settingsRolesTabBtn');
if (!el) return;
// Only show if BYOK is enabled
const byokAllowed = App.policies?.allow_user_byok === 'true';
if (tabBtn) tabBtn.style.display = byokAllowed ? '' : 'none';
if (!byokAllowed) return;
// Check if user has personal providers
try {
const configs = await API.listConfigs();
const configList = configs.configs || configs.data || [];
const personalProviders = configList.filter(c => c.scope === 'personal');
if (personalProviders.length === 0) {
el.style.display = 'none';
if (notice) notice.style.display = '';
return;
}
el.style.display = '';
if (notice) notice.style.display = 'none';
} catch (e) {
el.innerHTML = `${esc(e.message)}
`;
return;
}
if (_userRoleConfig) await _userRoleConfig.refresh();
},
async loadTeamAuditLog(page) {
const teamId = UI._managingTeamId;
if (!teamId) return;
if (page) UI._teamAuditPage = page;
const el = document.getElementById('settingsTeamAudit');
if (!el) return;
el.innerHTML = 'Loading...
';
// Populate action filter on first load
const actionSel = document.getElementById('teamAuditFilterAction');
if (actionSel && actionSel.options.length <= 1) {
try {
const resp = await API.teamListAuditActions(teamId);
(resp.actions || []).forEach(a => {
const opt = document.createElement('option');
opt.value = a;
opt.textContent = a;
actionSel.appendChild(opt);
});
} catch (e) { /* optional */ }
}
const params = {
page: UI._teamAuditPage,
per_page: UI._teamAuditPerPage,
action: document.getElementById('teamAuditFilterAction')?.value || '',
};
try {
const resp = await API.teamListAudit(teamId, params);
const entries = resp.data || [];
const total = resp.total || 0;
const totalPages = Math.ceil(total / UI._teamAuditPerPage);
if (entries.length === 0) {
el.innerHTML = 'No activity recorded for this team
';
} else {
el.innerHTML = entries.map(e => {
const ts = new Date(e.created_at);
const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
const actor = e.actor_name || 'system';
const meta = UI._formatAuditMeta(e.metadata);
return `
${esc(e.action)}
${esc(actor)}
${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}
${meta ? `${meta} ` : ''}
${timeStr}
`;
}).join('');
}
// Pagination
const pgEl = document.getElementById('teamAuditPagination');
if (totalPages > 1) {
pgEl.style.display = 'flex';
document.getElementById('teamAuditPageInfo').textContent = `Page ${UI._teamAuditPage} of ${totalPages} (${total} entries)`;
document.getElementById('teamAuditPrevBtn').disabled = UI._teamAuditPage <= 1;
document.getElementById('teamAuditNextBtn').disabled = UI._teamAuditPage >= totalPages;
} else {
pgEl.style.display = 'none';
}
} catch (e) { el.innerHTML = `${esc(e.message)}
`; }
},
async loadTeamPresetModelDropdown(teamId) {
const sel = document.getElementById('teamPreset_model');
if (!sel) return;
sel.innerHTML = 'Loading models... ';
try {
const resp = await API.teamListAvailableModels(teamId);
const models = resp.models || [];
sel.innerHTML = 'Select base model... ';
// Group by source for clarity
const globalModels = models.filter(m => m.source !== 'team');
const teamModels = models.filter(m => m.source === 'team');
const addGroup = (label, items) => {
if (!items.length) return;
const group = document.createElement('optgroup');
group.label = label;
items.forEach(m => {
const opt = document.createElement('option');
opt.value = m.model_id || m.id;
const vis = m.visibility === 'team' ? ' [team-only]' : '';
opt.textContent = (m.model_id || m.id) + (m.provider_name ? ` (${m.provider_name})` : '') + vis;
group.appendChild(opt);
});
sel.appendChild(group);
};
addGroup('Global Models', globalModels);
addGroup('Team Provider Models', teamModels);
} catch (e) {
// Fallback to App.models if team endpoint fails
sel.innerHTML = 'Select base model... ';
App.models.filter(m => !m.isPreset).forEach(m => {
const opt = document.createElement('option');
opt.value = m.baseModelId || m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
sel.appendChild(opt);
});
}
},
// ── Providers ────────────────────────────
async loadProviderList() {
if (_userProvList) { await _userProvList.refresh(); return; }
// Fallback if primitives not yet initialized
const el = document.getElementById('providerList');
el.innerHTML = 'Loading...
';
},
showProviderForm() { document.getElementById('providerAddForm').style.display = ''; },
hideProviderForm() {
document.getElementById('providerAddForm').style.display = 'none';
if (_userProvForm) _userProvForm.setCreateMode();
},
// ── User Model List ─────────────────────
async loadUserModels() {
const el = document.getElementById('userModelList');
if (!el) return;
el.innerHTML = 'Loading models...
';
try {
// Load preferences if not already loaded
if (!App.hiddenModels) {
try {
const prefData = await API.getModelPreferences();
App.hiddenModels = new Set(
(prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id)
);
} catch (e) {
if (!App.hiddenModels) App.hiddenModels = new Set();
}
}
const data = await API.listEnabledModels();
const models = (data.models || []).filter(m => !m.is_preset);
if (!models.length) {
el.innerHTML = 'No models available
';
return;
}
el.innerHTML = models.map(m => {
const mid = m.model_id || m.id;
const caps = m.capabilities || {};
const badges = renderCapBadges(caps, { compact: true });
const src = m.source === 'personal' ? 'personal '
: m.source === 'team' ? `👥 ${esc(m.team_name || 'team')} ` : '';
const hidden = App.hiddenModels.has(mid);
const toggleCls = hidden ? '' : 'enabled';
const toggleLabel = hidden ? 'Hidden' : '✓ Visible';
return `
${esc(mid)}
${badges}
${esc(m.provider_name || m.provider || '')}
${src}
${toggleLabel}
`;
}).join('');
} catch (e) { el.innerHTML = `${esc(e.message)}
`; }
},
async loadUserPresets() {
const el = document.getElementById('userPresetList');
if (!el) return;
try {
const data = await API.listUserPresets();
// Only show personal presets owned by user
const presets = (data.presets || []).filter(p => p.scope === 'personal');
if (!presets.length) {
el.innerHTML = 'No personal presets yet
';
return;
}
el.innerHTML = presets.map(p => {
const avatarEl = p.avatar ? ` ` : '';
return `
${avatarEl}${esc(p.name)}
${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}
✕
`;
}).join('');
} catch (e) { el.innerHTML = `${esc(e.message)}
`; }
},
});