Changeset 0.10.3 (#59)

This commit is contained in:
2026-02-24 21:32:07 +00:00
parent 4061e4a145
commit ba2cd42428
17 changed files with 5723 additions and 2458 deletions

645
src/js/ui-admin.js Normal file
View File

@@ -0,0 +1,645 @@
// ==========================================
// Chat Switchboard UI Admin
// ==========================================
// Extends UI with admin modal tabs: users, stats, roles,
// usage, audit, providers, models, presets, teams, settings.
Object.assign(UI, {
// ── Admin Modal ──────────────────────────
openAdmin() { openModal('adminModal'); UI.switchAdminTab('users'); },
closeAdmin() { closeModal('adminModal'); },
openTeamAdmin() {
const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin');
if (adminTeams.length === 0) {
UI.toast('You are not an admin of any teams', 'error');
return;
}
openModal('teamAdminModal');
if (adminTeams.length === 1) {
// Skip picker, go directly to the team
UI.openTeamManage(adminTeams[0].id, adminTeams[0].name);
} else {
// Show team picker
document.getElementById('teamAdminTitle').textContent = 'Team Management';
document.getElementById('teamAdminPicker').style.display = '';
document.getElementById('teamAdminContent').style.display = 'none';
document.getElementById('teamAdminPickerList').innerHTML = adminTeams.map(t => `
<div class="team-card" style="cursor:pointer" onclick="UI.openTeamManage('${t.id}', '${esc(t.name)}')">
<div class="team-card-info">
<strong>${esc(t.name)}</strong>
<span class="badge-admin">admin</span>
<span style="margin-left:auto;color:var(--text-3);font-size:12px">Manage →</span>
</div>
${t.description ? `<div class="text-muted" style="font-size:12px">${esc(t.description)}</div>` : ''}
<div class="text-muted" style="font-size:11px">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
</div>
`).join('');
}
},
closeTeamAdmin() { closeModal('teamAdminModal'); },
switchTeamTab(tab) {
document.querySelectorAll('#teamAdminTabs .admin-tab').forEach(t => t.classList.toggle('active', t.dataset.ttab === tab));
document.querySelectorAll('.team-tab-content').forEach(c => c.style.display = 'none');
const panel = document.getElementById(`teamTab${tab.charAt(0).toUpperCase() + tab.slice(1)}`);
if (panel) panel.style.display = '';
// Lazy-load tab data
const teamId = UI._managingTeamId;
if (tab === 'members') UI.loadTeamManageMembers(teamId);
if (tab === 'providers') UI.loadTeamManageProviders(teamId);
if (tab === 'presets') UI.loadTeamManagePresets(teamId);
if (tab === 'usage') UI.loadTeamUsage();
if (tab === 'activity') UI.loadTeamAuditLog(1);
},
async switchAdminTab(tab) {
document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('.admin-tab-content').forEach(c => c.style.display = 'none');
const panel = document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
if (panel) panel.style.display = '';
if (tab === 'users') await this.loadAdminUsers();
if (tab === 'stats') await this.loadAdminStats();
if (tab === 'audit') await this.loadAuditLog();
if (tab === 'providers') await this.loadAdminProviders();
if (tab === 'models') await this.loadAdminModels();
if (tab === 'presets') await this.loadAdminPresets();
if (tab === 'teams') await this.loadAdminTeams();
if (tab === 'settings') await this.loadAdminSettings();
if (tab === 'roles') await this.loadAdminRoles();
if (tab === 'usage') await this.loadAdminUsage();
},
async loadAdminUsers(quiet) {
const el = document.getElementById('adminUserList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.adminListUsers();
const users = resp.users || resp.data || [];
el.innerHTML = users.map(u => {
const teamBadges = (u.teams || []).map(t =>
`<span class="badge-team" title="${esc(t.role)}">${esc(t.team_name)}</span>`
).join(' ');
const isPending = !u.is_active;
return `
<div class="admin-user-row${isPending ? ' user-inactive' : ''}">
<div class="admin-user-info">
<div><strong>${esc(u.username)}</strong> <span class="badge-${u.role}">${u.role}</span>
${isPending ? '<span class="badge-pending">pending</span>' : ''}
${teamBadges}</div>
<div class="admin-user-email">${esc(u.email)}</div>
</div>
<div class="admin-user-actions">
${isPending
? `<button class="btn-approve" onclick="showApproveForm('${u.id}', '${esc(u.username)}')">Approve</button>`
: `<button onclick="toggleUserActive('${u.id}', false)">Disable</button>`
}
<button onclick="toggleUserRole('${u.id}', '${u.role === 'admin' ? 'user' : 'admin'}')">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
<button onclick="adminResetUserPassword('${u.id}', '${esc(u.username)}')">Reset PW</button>
<button class="btn-danger" onclick="deleteUser('${u.id}', '${esc(u.username)}')">Delete</button>
</div>
</div>
<div class="admin-approve-form" id="approveForm-${u.id}" style="display:none">
<div class="approve-teams" id="approveTeams-${u.id}"></div>
<div class="form-row" style="margin-top:8px">
<button class="btn-small btn-primary" onclick="submitApproval('${u.id}')">Activate & Assign</button>
<button class="btn-small" onclick="hideApproveForm('${u.id}')">Cancel</button>
</div>
</div>`;
}).join('') || '<div class="empty-hint">No users</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminStats() {
const el = document.getElementById('adminStats');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const s = await API.adminGetStats();
const labels = { users: 'Users', channels: 'Channels', messages: 'Messages' };
el.innerHTML = '<div class="stats-grid">' +
Object.entries(s).map(([k, v]) => `
<div class="stat-card">
<div class="stat-value">${typeof v === 'number' ? v.toLocaleString() : esc(String(v))}</div>
<div class="stat-label">${labels[k] || k.replace(/_/g, ' ')}</div>
</div>`).join('') +
'</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
// ── Admin: Roles ────────────────────────
async loadAdminRoles() {
const el = document.getElementById('adminRolesContent');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const roles = await API.adminListRoles();
const configs = await API.adminListGlobalConfigs();
const providers = configs.configs || configs || [];
const models = await API.adminListModels();
const modelList = models.models || models.data || models || [];
const roleNames = ['utility', 'embedding'];
el.innerHTML = roleNames.map(role => {
const cfg = roles[role] || { primary: null, fallback: null };
return `
<div class="settings-section" style="margin-bottom:16px">
<h3 style="font-size:14px;margin:0 0 8px;text-transform:capitalize">${role}</h3>
<div class="form-row" style="gap:8px;align-items:center">
<label style="min-width:60px;font-size:12px">Primary</label>
<select id="role-${role}-primary-provider" onchange="adminRoleProviderChanged('${role}','primary')" style="min-width:140px">
<option value="">— none —</option>
${providers.map(p => `<option value="${p.id}" ${cfg.primary?.provider_config_id === p.id ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
</select>
<select id="role-${role}-primary-model" style="min-width:200px">
<option value="">— select model —</option>
${cfg.primary ? modelList.filter(m => m.provider_config_id === cfg.primary.provider_config_id).map(m => `<option value="${m.model_id}" ${m.model_id === cfg.primary.model_id ? 'selected' : ''}>${esc(m.display_name || m.model_id)}</option>`).join('') : ''}
</select>
</div>
<div class="form-row" style="gap:8px;align-items:center;margin-top:6px">
<label style="min-width:60px;font-size:12px">Fallback</label>
<select id="role-${role}-fallback-provider" onchange="adminRoleProviderChanged('${role}','fallback')" style="min-width:140px">
<option value="">— none —</option>
${providers.map(p => `<option value="${p.id}" ${cfg.fallback?.provider_config_id === p.id ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
</select>
<select id="role-${role}-fallback-model" style="min-width:200px">
<option value="">— select model —</option>
${cfg.fallback ? modelList.filter(m => m.provider_config_id === cfg.fallback.provider_config_id).map(m => `<option value="${m.model_id}" ${m.model_id === cfg.fallback.model_id ? 'selected' : ''}>${esc(m.display_name || m.model_id)}</option>`).join('') : ''}
</select>
</div>
<div class="form-row" style="gap:8px;margin-top:8px">
<button class="btn-primary btn-small" onclick="adminSaveRole('${role}')">Save</button>
<button class="btn-secondary btn-small" onclick="adminTestRole('${role}')">Test</button>
<span id="role-${role}-status" style="font-size:12px"></span>
</div>
</div>`;
}).join('');
// Store models list for provider change handler
window._adminModelList = modelList;
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
// ── Admin: Usage ────────────────────────
async loadAdminUsage() {
const period = document.getElementById('usagePeriod')?.value || '30d';
const groupBy = document.getElementById('usageGroupBy')?.value || 'model';
const totalsEl = document.getElementById('adminUsageTotals');
const resultsEl = document.getElementById('adminUsageResults');
const pricingEl = document.getElementById('adminPricingTable');
if (!totalsEl) return;
totalsEl.innerHTML = '<div class="loading">Loading...</div>';
resultsEl.innerHTML = '';
try {
const data = await API.adminGetUsage({ period, group_by: groupBy });
const t = data.totals || {};
totalsEl.innerHTML = `
<div class="stats-grid">
<div class="stat-card"><div class="stat-value">${(t.requests || 0).toLocaleString()}</div><div class="stat-label">Requests</div></div>
<div class="stat-card"><div class="stat-value">${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label">Input Tokens</div></div>
<div class="stat-card"><div class="stat-value">${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label">Output Tokens</div></div>
<div class="stat-card"><div class="stat-value">$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label">Est. Cost</div></div>
</div>`;
const results = data.results || [];
if (results.length === 0) {
resultsEl.innerHTML = '<div class="empty-hint">No usage data for this period</div>';
} else {
resultsEl.innerHTML = `
<table class="admin-table" style="margin-top:12px">
<thead><tr>
<th>${groupBy === 'day' ? 'Date' : groupBy === 'user' ? 'User' : 'Model'}</th>
<th style="text-align:right">Requests</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Cost</th>
</tr></thead>
<tbody>${results.map(r => `<tr>
<td>${esc(r.label || r.group_key)}</td>
<td style="text-align:right">${(r.requests || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.input_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.output_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">$${(r.total_cost || 0).toFixed(4)}</td>
</tr>`).join('')}</tbody>
</table>`;
}
} catch (e) { totalsEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
// Load pricing table
try {
const pricing = await API.adminListPricing();
const entries = pricing || [];
if (entries.length === 0) {
pricingEl.innerHTML = '<div class="empty-hint">No pricing configured. Sync providers to populate from catalog.</div>';
} else {
pricingEl.innerHTML = `
<table class="admin-table" style="margin-top:8px">
<thead><tr>
<th>Model</th>
<th style="text-align:right">Input $/M</th>
<th style="text-align:right">Output $/M</th>
<th>Source</th>
</tr></thead>
<tbody>${entries.map(p => `<tr>
<td>${esc(p.model_id)}</td>
<td style="text-align:right">${p.input_per_m != null ? '$' + Number(p.input_per_m).toFixed(4) : '—'}</td>
<td style="text-align:right">${p.output_per_m != null ? '$' + Number(p.output_per_m).toFixed(4) : '—'}</td>
<td><span class="badge-${p.source === 'manual' ? 'admin' : 'user'}">${p.source}</span></td>
</tr>`).join('')}</tbody>
</table>`;
}
} catch (e) { pricingEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
_auditPage: 1,
_auditPerPage: 30,
async loadAuditLog(page) {
if (page) UI._auditPage = page;
const el = document.getElementById('adminAuditList');
el.innerHTML = '<div class="loading">Loading...</div>';
// Populate action filter on first load
const actionSel = document.getElementById('auditFilterAction');
if (actionSel && actionSel.options.length <= 1) {
try {
const resp = await API.adminListAuditActions();
(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._auditPage,
per_page: UI._auditPerPage,
action: document.getElementById('auditFilterAction')?.value || '',
resource_type: document.getElementById('auditFilterResource')?.value || '',
};
try {
const resp = await API.adminListAudit(params);
const entries = resp.data || [];
const total = resp.total || 0;
const totalPages = Math.ceil(total / UI._auditPerPage);
if (entries.length === 0) {
el.innerHTML = '<div class="empty-hint">No audit entries found</div>';
} 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 `<div class="audit-row">
<div class="audit-main">
<span class="audit-action">${esc(e.action)}</span>
<span class="audit-actor">${esc(actor)}</span>
<span class="audit-resource">${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}</span>
</div>
<div class="audit-meta">
${meta ? `<span class="audit-details">${meta}</span>` : ''}
<span class="audit-time">${timeStr}</span>
${e.ip_address ? `<span class="audit-ip">${esc(e.ip_address)}</span>` : ''}
</div>
</div>`;
}).join('');
}
// Pagination
const pgEl = document.getElementById('auditPagination');
if (totalPages > 1) {
pgEl.style.display = 'flex';
document.getElementById('auditPageInfo').textContent = `Page ${UI._auditPage} of ${totalPages} (${total} entries)`;
document.getElementById('auditPrevBtn').disabled = UI._auditPage <= 1;
document.getElementById('auditNextBtn').disabled = UI._auditPage >= totalPages;
} else {
pgEl.style.display = 'none';
}
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
_formatAuditMeta(metaStr) {
try {
const m = typeof metaStr === 'string' ? JSON.parse(metaStr) : metaStr;
if (!m || Object.keys(m).length === 0) return '';
return Object.entries(m).map(([k,v]) => `${esc(k)}=${esc(String(v))}`).join(' · ');
} catch { return ''; }
},
async loadAdminProviders(quiet) {
const el = document.getElementById('adminProviderList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListGlobalConfigs();
const list = data.configs || data.data || data || [];
const arr = Array.isArray(list) ? list : [];
UI._providerCache = {};
el.innerHTML = arr.map(c => {
UI._providerCache[c.id] = c;
return `<div class="admin-provider-row">
<span class="provider-name">${esc(c.name)}</span>
<span class="provider-meta">${esc(c.provider)}</span>
${c.is_private ? '<span class="badge-private">private</span>' : ''}
<span class="provider-endpoint">${esc(c.endpoint || '')}</span>
<button class="btn-edit" onclick="editGlobalProvider('${c.id}')" title="Edit">✎</button>
<button class="btn-delete" onclick="deleteGlobalProvider('${c.id}')" title="Delete">✕</button>
</div>`;
}).join('') || '<div class="empty-hint">No global providers — add one above</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminModels(quiet) {
const el = document.getElementById('adminModelList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListModels();
const list = data.models || data.data || data || [];
const arr = Array.isArray(list) ? list : [];
el.innerHTML = arr.map(m => {
const caps = m.capabilities || {};
const badges = [];
if (caps.max_output_tokens > 0) badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K</span>`);
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
// Support both old is_enabled (bool) and new visibility (string)
const vis = m.visibility || (m.is_enabled === true ? 'enabled' : m.is_enabled === false ? 'disabled' : 'disabled');
const visLabel = vis === 'enabled' ? '✓ Enabled' : vis === 'team' ? '👥 Team' : 'Disabled';
const visClass = vis === 'enabled' ? 'enabled' : vis === 'team' ? 'team' : '';
return `<div class="admin-model-row">
<span class="model-name">${esc(m.model_id || m.id)}</span>
<span class="model-caps-inline">${badges.join('')}</span>
<span class="provider-meta">${esc(m.provider_name || '')}</span>
<button class="admin-model-toggle ${visClass}" onclick="cycleModelVisibility('${m.id}', '${vis}')">${visLabel}</button>
</div>`;
}).join('') || '<div class="empty-hint">No models — add a provider first, then click Fetch Models</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminPresets(quiet) {
const el = document.getElementById('adminPresetList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListPresets();
const list = data.presets || [];
// Ensure form is initialized for dropdown population
ensureAdminPresetForm();
// Populate model dropdown from admin model list (all synced models)
const modelSel = _adminPresetForm?.getModelSelect();
if (modelSel) {
modelSel.innerHTML = '<option value="">Select base model...</option>';
try {
const modelData = await API.adminListModels();
const allModels = modelData.models || modelData.data || [];
const arr = Array.isArray(allModels) ? allModels : [];
arr.forEach(m => {
const opt = document.createElement('option');
const mid = m.model_id || m.id;
opt.value = mid;
opt.textContent = mid + (m.provider_name ? ` (${m.provider_name})` : '');
modelSel.appendChild(opt);
});
} catch (e) { console.warn('Failed to load models for preset form:', e.message); }
}
// Populate config dropdown
const cfgSel = _adminPresetForm?.getConfigSelect();
if (cfgSel && cfgSel.options.length <= 1) {
try {
const cfgData = await API.adminListGlobalConfigs();
const cfgs = cfgData.configs || cfgData.data || [];
(Array.isArray(cfgs) ? cfgs : []).forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name + ' (' + c.provider + ')';
cfgSel.appendChild(opt);
});
} catch (e) { /* optional */ }
}
UI._presetCache = {};
el.innerHTML = list.map(p => {
UI._presetCache[p.id] = p;
const avatarEl = p.avatar
? `<img src="${p.avatar}" class="preset-row-avatar" alt="">`
: '';
// Scope badge with attribution
let scopeBadge = '';
if (p.scope === 'global') {
scopeBadge = '<span class="badge-admin">global</span>';
} else if (p.scope === 'team' && p.team_name) {
scopeBadge = `<span class="badge-team">👥 ${esc(p.team_name)}</span>`;
} else if (p.scope === 'personal') {
scopeBadge = '<span class="badge-user">personal</span>';
}
const creator = p.creator_name ? `<span class="text-muted" style="font-size:11px">by ${esc(p.creator_name)}</span>` : '';
const status = p.is_active ? '' : '<span class="badge-pending">inactive</span>';
return `<div class="admin-preset-row">
<div class="preset-info">
<strong>${avatarEl}${esc(p.name)}</strong> ${scopeBadge} ${creator} ${status}
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
${p.description ? `<div class="preset-desc">${esc(p.description)}</div>` : ''}
</div>
<div class="preset-actions">
<button class="btn-edit" onclick="editAdminPreset('${p.id}')" title="Edit">✎</button>
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="toggleAdminPreset('${p.id}', ${!p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
<button class="btn-delete" onclick="deleteAdminPreset('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
</div>
</div>`;
}).join('') || '<div class="empty-hint">No presets — create one to give users preconfigured model experiences</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
// ── Teams Admin ─────────────────────────
_teamEditId: null,
async loadAdminTeams(quiet) {
const el = document.getElementById('adminTeamList');
const detail = document.getElementById('adminTeamDetail');
if (!quiet) {
el.innerHTML = '<div class="loading">Loading...</div>';
detail.style.display = 'none';
el.style.display = '';
document.getElementById('adminAddTeamForm').style.display = 'none';
}
try {
const resp = await API.adminListTeams();
const teams = resp.data || [];
el.innerHTML = teams.map(t => `
<div class="admin-user-row">
<div class="admin-user-info">
<div><strong>${esc(t.name)}</strong>
${t.is_active ? '' : '<span class="badge-pending">inactive</span>'}
</div>
<div class="text-muted">${esc(t.description || 'No description')} · ${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
</div>
<div class="admin-user-actions">
<button class="btn-small" onclick="UI.openTeamDetail('${t.id}', '${esc(t.name)}')">Members</button>
<button class="admin-model-toggle ${t.is_active ? 'enabled' : ''}" onclick="toggleTeamActive('${t.id}', ${!t.is_active})">${t.is_active ? '✓ Active' : 'Inactive'}</button>
<button class="btn-delete" onclick="deleteTeam('${t.id}', '${esc(t.name)}')" title="Delete">✕</button>
</div>
</div>
`).join('') || '<div class="empty-hint">No teams yet — create one to organize users and set policies</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async openTeamDetail(teamId, teamName) {
this._teamEditId = teamId;
document.getElementById('adminTeamList').style.display = 'none';
document.getElementById('adminAddTeamForm').style.display = 'none';
const detail = document.getElementById('adminTeamDetail');
detail.style.display = '';
document.getElementById('adminTeamDetailName').textContent = teamName;
document.getElementById('adminAddMemberForm').style.display = 'none';
// Load team settings for policy checkboxes
try {
const team = await API.adminGetTeam(teamId);
const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {});
document.getElementById('adminTeamPrivatePolicy').checked = !!settings.require_private_providers;
// allow_team_providers defaults to true if not explicitly set
document.getElementById('adminTeamAllowProviders').checked = settings.allow_team_providers !== false;
} catch (e) { /* proceed with defaults */ }
await this.loadTeamMembers(teamId);
},
async loadTeamMembers(teamId) {
const el = document.getElementById('adminMemberList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.adminListMembers(teamId);
const members = resp.data || [];
el.innerHTML = members.map(m => `
<div class="admin-user-row">
<div class="admin-user-info">
<div><strong>${esc(m.display_name || m.email)}</strong>
<span class="badge-${m.role === 'admin' ? 'admin' : 'user'}">${m.role}</span>
${m.user_role === 'admin' ? '<span class="badge-admin">sys-admin</span>' : ''}
</div>
<div class="text-muted">${esc(m.email)}</div>
</div>
<div class="admin-user-actions">
<select onchange="updateTeamMember('${teamId}', '${m.id}', this.value)" class="inline-select">
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Team Admin</option>
</select>
<button class="btn-delete" onclick="removeTeamMember('${teamId}', '${m.id}', '${esc(m.email)}')" title="Remove">✕</button>
</div>
</div>
`).join('') || '<div class="empty-hint">No members — add users to this team</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadMemberUserDropdown(teamId) {
const sel = document.getElementById('adminMemberUser');
sel.innerHTML = '<option value="">Loading...</option>';
try {
const resp = await API.adminListUsers(1, 200);
const users = resp.users || resp.data || [];
// Also get existing members to exclude
const memberResp = await API.adminListMembers(teamId);
const existingIds = new Set((memberResp.data || []).map(m => m.user_id));
const available = users.filter(u => u.is_active && !existingIds.has(u.id));
sel.innerHTML = '<option value="">Select user...</option>' +
available.map(u => `<option value="${u.id}">${esc(u.username || u.email)}</option>`).join('');
} catch (e) { sel.innerHTML = `<option value="">Error loading users</option>`; }
},
async loadAdminSettings() {
try {
const data = await API.adminGetSettings();
// v0.9: { settings: { banner: {...}, ... }, policies: { allow_registration: "true", ... } }
const settings = data.settings || {};
const policies = data.policies || {};
// Helper to read from settings map (JSONB values)
const getSetting = (key, fallback) => {
const v = settings[key];
if (v === undefined || v === null) return fallback;
// Unwrap {value: X} wrapper if present
return (v && typeof v === 'object' && 'value' in v) ? v.value : v;
};
// Registration (policy: allow_registration)
document.getElementById('adminRegToggle').checked = policies.allow_registration === 'true';
// Admin system prompt (global_settings)
const sysPrompt = getSetting('system_prompt', {}) || {};
document.getElementById('adminSystemPrompt').value = sysPrompt.content || '';
// Registration default state (policy: default_user_active → 'true' means auto-active)
const defaultActive = policies.default_user_active === 'true';
document.getElementById('adminRegDefaultState').value = defaultActive ? 'active' : 'pending';
// User providers / BYOK (policy: allow_user_byok)
document.getElementById('adminUserProvidersToggle').checked = policies.allow_user_byok === 'true';
// User presets / personas (policy: allow_user_personas)
document.getElementById('adminUserPresetsToggle').checked = policies.allow_user_personas === 'true';
// Default model (policy: default_model) — populate from current model list
const defModelSel = document.getElementById('adminDefaultModel');
if (defModelSel) {
defModelSel.innerHTML = '<option value="">— None (first visible) —</option>';
App.models.filter(m => !m.hidden).forEach(m => {
const opt = document.createElement('option');
opt.value = m.baseModelId || m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
defModelSel.appendChild(opt);
});
defModelSel.value = policies.default_model || '';
}
// Banner (global_settings)
const banner = getSetting('banner', {}) || {};
document.getElementById('adminBannerEnabled').checked = !!banner.enabled;
document.getElementById('adminBannerText').value = banner.text || '';
document.getElementById('adminBannerPosition').value = banner.position || 'both';
document.getElementById('adminBannerBg').value = banner.bg || '#007a33';
document.getElementById('adminBannerBgHex').value = banner.bg || '#007a33';
document.getElementById('adminBannerFg').value = banner.fg || '#ffffff';
document.getElementById('adminBannerFgHex').value = banner.fg || '#ffffff';
document.getElementById('bannerConfigFields').style.display = banner.enabled ? '' : 'none';
UI.updateBannerPreview();
// Load banner presets (global_settings)
const presets = getSetting('banner_presets', {}) || {};
const sel = document.getElementById('adminBannerPreset');
sel.innerHTML = '<option value="">Custom</option>';
Object.entries(presets).forEach(([key, p]) => {
sel.innerHTML += `<option value="${esc(key)}">${esc(p.text || key)}</option>`;
});
UI._bannerPresets = presets;
} catch (e) { console.debug('Failed to load admin settings:', e); }
},
_bannerPresets: {},
updateBannerPreview() {
const prev = document.getElementById('bannerPreview');
if (!prev) return;
const text = document.getElementById('adminBannerText').value || 'PREVIEW';
const bg = document.getElementById('adminBannerBg').value;
const fg = document.getElementById('adminBannerFg').value;
prev.textContent = text;
prev.style.background = bg;
prev.style.color = fg;
},
});