728 lines
38 KiB
JavaScript
728 lines
38 KiB
JavaScript
// ==========================================
|
||
// 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) {
|
||
const tabs = document.querySelectorAll('#teamAdminTabs .admin-tab');
|
||
tabs.forEach(t => {
|
||
t.classList.remove('active');
|
||
if (t.dataset.ttab === tab) t.classList.add('active');
|
||
});
|
||
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('#adminModal .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();
|
||
if (tab === 'extensions') await this.loadAdminExtensions();
|
||
if (tab === 'storage' && typeof loadAdminStorage === 'function') await loadAdminStorage();
|
||
},
|
||
|
||
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;
|
||
|
||
if (!UI._adminRoleConfig) {
|
||
UI._adminRoleConfig = renderRoleConfig(el, {
|
||
scope: 'admin',
|
||
showFallback: true,
|
||
modelIdField: 'model_id',
|
||
modelNameField: 'display_name',
|
||
providerIdField: 'provider_config_id',
|
||
fetchProviders: async () => {
|
||
const configs = await API.adminListGlobalConfigs();
|
||
return configs.configs || configs || [];
|
||
},
|
||
fetchModels: async () => {
|
||
const models = await API.adminListModels();
|
||
return models.models || models.data || models || [];
|
||
},
|
||
fetchRoles: () => API.adminListRoles(),
|
||
onSave: async (roleId, config) => {
|
||
await API.adminUpdateRole(roleId, config);
|
||
},
|
||
onTest: (roleId) => API.adminTestRole(roleId),
|
||
});
|
||
}
|
||
|
||
await UI._adminRoleConfig.refresh();
|
||
},
|
||
|
||
// ── Admin: Usage ────────────────────────
|
||
|
||
async loadAdminUsage() {
|
||
// Usage stats via primitive
|
||
const totalsEl = document.getElementById('adminUsageTotals');
|
||
if (!totalsEl) return;
|
||
// Wrap totals+results in a container for the primitive (first time only)
|
||
let usageContainer = document.getElementById('_adminUsageContainer');
|
||
if (!usageContainer) {
|
||
usageContainer = document.createElement('div');
|
||
usageContainer.id = '_adminUsageContainer';
|
||
const resultsEl = document.getElementById('adminUsageResults');
|
||
totalsEl.parentElement.insertBefore(usageContainer, totalsEl);
|
||
usageContainer.appendChild(totalsEl);
|
||
if (resultsEl) usageContainer.appendChild(resultsEl);
|
||
}
|
||
|
||
if (!UI._adminUsageDash) {
|
||
UI._adminUsageDash = renderUsageDashboard(usageContainer, {
|
||
apiFetch: (p) => API.adminGetUsage(p),
|
||
periodElId: 'usagePeriod',
|
||
groupByElId: 'usageGroupBy',
|
||
compact: false,
|
||
showUserColumn: true,
|
||
});
|
||
}
|
||
await UI._adminUsageDash.refresh();
|
||
|
||
// Pricing table (admin-only, separate from usage primitive)
|
||
const pricingEl = document.getElementById('adminPricingTable');
|
||
if (pricingEl) {
|
||
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>`; }
|
||
}
|
||
},
|
||
|
||
async loadAdminExtensions() {
|
||
const el = document.getElementById('adminExtensionsList');
|
||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||
try {
|
||
const resp = await API._get('/api/v1/admin/extensions');
|
||
const exts = resp.data || [];
|
||
this._adminExtensions = exts; // store for edit access
|
||
if (exts.length === 0) {
|
||
el.innerHTML = '<div class="text-muted" style="padding:16px">No extensions installed</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = exts.map(ext => {
|
||
const statusBadge = ext.is_enabled
|
||
? '<span class="badge-admin" style="font-size:11px">enabled</span>'
|
||
: '<span class="badge-user" style="font-size:11px;opacity:0.5">disabled</span>';
|
||
const systemBadge = ext.is_system
|
||
? ' <span class="badge-user" style="font-size:11px">system</span>'
|
||
: '';
|
||
return `
|
||
<div class="admin-user-row" style="padding:10px 0;align-items:flex-start">
|
||
<div class="admin-user-info" style="flex:1">
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<strong>${esc(ext.name)}</strong>
|
||
<code style="font-size:11px;color:var(--text-3)">${esc(ext.ext_id)}</code>
|
||
${statusBadge}${systemBadge}
|
||
</div>
|
||
<div class="text-muted" style="font-size:12px;margin-top:2px">
|
||
${esc(ext.description || 'No description')}
|
||
${ext.author ? ` · by ${esc(ext.author)}` : ''}
|
||
· v${esc(ext.version)} · ${ext.tier}
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:6px;flex-shrink:0">
|
||
<button class="btn-small" onclick="editAdminExtension('${ext.id}')">
|
||
Edit
|
||
</button>
|
||
<button class="btn-small" onclick="toggleAdminExtension('${ext.id}', ${!ext.is_enabled})">
|
||
${ext.is_enabled ? 'Disable' : 'Enable'}
|
||
</button>
|
||
<button class="btn-small btn-danger" onclick="deleteAdminExtension('${ext.id}', '${esc(ext.name)}')">
|
||
Uninstall
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
} catch (e) {
|
||
el.innerHTML = '<div class="text-muted" style="padding:16px">Failed to load extensions</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');
|
||
const formEl = document.getElementById('adminAddProviderForm');
|
||
|
||
// Initialize admin provider form primitive (once)
|
||
if (!UI._adminProvForm && formEl) {
|
||
UI._adminProvForm = renderProviderForm(formEl, {
|
||
prefix: 'adminProv',
|
||
showPrivate: true,
|
||
showDefaultModel: true,
|
||
onSubmit: async (vals, isEdit) => {
|
||
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;
|
||
updates.model_default = vals.model_default;
|
||
updates.is_private = vals.is_private || false;
|
||
await API.adminUpdateGlobalConfig(vals._editId, updates);
|
||
UI.toast('Provider updated', 'success');
|
||
} else {
|
||
if (!vals.name || !vals.endpoint || !vals.api_key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; }
|
||
await API.adminCreateGlobalConfig(vals.name, vals.provider, vals.endpoint, vals.api_key, vals.model_default, vals.is_private || false);
|
||
UI.toast('Provider added', 'success');
|
||
}
|
||
formEl.style.display = 'none';
|
||
UI._adminProvForm.setCreateMode();
|
||
UI._adminProvList.refresh();
|
||
} catch (e) { UI.toast(e.message, 'error'); }
|
||
},
|
||
onCancel: () => {
|
||
formEl.style.display = 'none';
|
||
UI._adminProvForm.setCreateMode();
|
||
},
|
||
});
|
||
}
|
||
|
||
// Initialize admin provider list primitive (once)
|
||
if (!UI._adminProvList) {
|
||
UI._adminProvList = renderProviderList(el, {
|
||
apiFetch: () => API.adminListGlobalConfigs(),
|
||
showEndpoint: true,
|
||
showPrivate: true,
|
||
emptyMsg: 'No global providers — add one above',
|
||
onEdit: (prov) => {
|
||
if (UI._adminProvForm) {
|
||
UI._adminProvForm.setEditMode(prov.id, prov);
|
||
formEl.style.display = '';
|
||
}
|
||
},
|
||
onDelete: async (prov) => {
|
||
if (!await showConfirm('Delete this global provider?')) return;
|
||
try {
|
||
await API.adminDeleteGlobalConfig(prov.id);
|
||
UI.toast('Provider deleted', 'success');
|
||
UI._adminProvList.refresh();
|
||
} catch (e) { UI.toast(e.message, 'error'); }
|
||
},
|
||
});
|
||
}
|
||
|
||
await UI._adminProvList.refresh(quiet);
|
||
},
|
||
|
||
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 = renderCapBadges(caps, { compact: true });
|
||
// 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}</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;
|
||
|
||
// Vault / Encryption status
|
||
const vaultEl = document.getElementById('adminVaultStatus');
|
||
if (vaultEl) {
|
||
try {
|
||
const vault = await API.adminGetVaultStatus();
|
||
const keyBadge = vault.encryption_key_set
|
||
? '<span class="badge badge-success">Active</span>'
|
||
: '<span class="badge badge-warning">Not Set</span>';
|
||
vaultEl.innerHTML = `
|
||
<div class="admin-storage-grid" style="margin-top:6px">
|
||
<div class="admin-storage-item">
|
||
<span class="admin-storage-label">Encryption</span>
|
||
${keyBadge}
|
||
</div>
|
||
<div class="admin-storage-item">
|
||
<span class="admin-storage-label">Encrypted Keys</span>
|
||
<span class="admin-storage-value">${vault.encrypted_keys}</span>
|
||
</div>
|
||
<div class="admin-storage-item">
|
||
<span class="admin-storage-label">Vault Users</span>
|
||
<span class="admin-storage-value">${vault.vault_users}</span>
|
||
</div>
|
||
</div>`;
|
||
} catch (e) {
|
||
vaultEl.innerHTML = '<span class="empty-hint">Could not load vault status</span>';
|
||
}
|
||
}
|
||
} 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;
|
||
},
|
||
|
||
});
|