This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/admin-handlers.js
2026-03-09 20:17:46 +00:00

886 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==========================================
// Chat Switchboard Admin Handlers
// ==========================================
// Admin actions: user management, roles, model visibility,
// personas, team management, global providers.
// ── Admin Actions ────────────────────────────
function _adminScroll() { return document.getElementById('adminMain'); }
function _restoreScroll(el, pos) { if (el) requestAnimationFrame(() => { el.scrollTop = pos; }); }
async function toggleUserActive(id, active) {
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
await API.adminToggleActive(id, active); UI.toast(`User ${active ? 'enabled' : 'disabled'}`, 'success'); await UI.loadAdminUsers();
_restoreScroll(el, pos);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function showApproveForm(userId, username) {
document.getElementById('approveUserId').value = userId;
document.getElementById('approveUserTitle').textContent = `Approve "${username}"`;
const teamsEl = document.getElementById('approveTeamsList');
teamsEl.innerHTML = '<span class="text-muted">Loading teams...</span>';
openModal('approveUserModal');
try {
const resp = await API.adminListTeams();
const teams = (resp.data || []).filter(t => t.is_active);
if (teams.length === 0) {
teamsEl.innerHTML = '<span class="text-muted">No teams — user will be activated without team assignment</span>';
} else {
teamsEl.innerHTML = '<label class="form-label" style="font-size:12px;margin-bottom:8px;display:block">Assign to teams:</label>' +
teams.map(t => `<label class="toggle-label" style="margin-bottom:6px"><input type="checkbox" value="${t.id}" class="approve-team-cb"> <span>${esc(t.name)}</span></label>`).join('');
}
} catch (e) { teamsEl.innerHTML = `<span class="text-muted">Could not load teams</span>`; }
}
function hideApproveForm(userId) {
closeModal('approveUserModal');
}
async function submitApproval(userId) {
if (!userId) userId = document.getElementById('approveUserId').value;
const teamIds = [...(document.querySelectorAll('#approveTeamsList .approve-team-cb:checked') || [])].map(cb => cb.value);
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
await API.adminToggleActive(userId, true, teamIds, 'member');
const msg = teamIds.length ? `User activated & assigned to ${teamIds.length} team(s)` : 'User activated';
UI.toast(msg, 'success');
closeModal('approveUserModal');
await UI.loadAdminUsers();
_restoreScroll(el, pos);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function toggleUserRole(id, role) {
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
await API.adminUpdateRole(id, role); UI.toast(`Role updated to ${role}`, 'success'); await UI.loadAdminUsers();
_restoreScroll(el, pos);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteUser(id, username) {
if (!await showConfirm(`Delete user "${username}"? This cannot be undone.`)) return;
try { await API.adminDeleteUser(id); UI.toast('User deleted', 'success'); await UI.loadAdminUsers(); }
catch (e) { UI.toast(e.message, 'error'); }
}
async function createAdminUser() {
try {
const u = document.getElementById('adminNewUsername').value.trim();
const e = document.getElementById('adminNewEmail').value.trim();
const p = document.getElementById('adminNewPassword').value;
const r = document.getElementById('adminNewRole').value;
if (!u || !e || !p) { UI.toast('All fields required', 'warning'); return; }
await API.adminCreateUser(u, e, p, r);
closeModal('createUserModal');
// Clear form fields
document.getElementById('adminNewUsername').value = '';
document.getElementById('adminNewEmail').value = '';
document.getElementById('adminNewPassword').value = '';
document.getElementById('adminNewRole').value = 'user';
UI.toast('User created', 'success');
await UI.loadAdminUsers();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function adminResetUserPassword(id, username) {
// Show the reset password modal
document.getElementById('resetPwUser').textContent = username;
document.getElementById('resetPwInput').value = '';
openModal('resetPwModal');
document.getElementById('resetPwInput').focus();
// Wire up the confirm button (remove old listener, add new)
const btn = document.getElementById('resetPwConfirmBtn');
const newBtn = btn.cloneNode(true);
btn.parentNode.replaceChild(newBtn, btn);
newBtn.addEventListener('click', async () => {
const pw = document.getElementById('resetPwInput').value;
if (!pw || pw.length < 8) { UI.toast('Password must be at least 8 characters', 'warning'); return; }
if (!await showConfirm(`Final confirmation: Reset password AND destroy personal vault for "${username}"?`)) return;
try {
await API.adminResetPassword(id, pw);
closeModal('resetPwModal');
UI.toast(`Password reset for ${username}. Vault destroyed.`, 'success');
} catch (e) { UI.toast(e.message, 'error'); }
});
}
// ── Admin Provider / Role handlers — now managed by UI primitives in ui-admin.js ──
async function fetchAdminModels() {
const hint = document.getElementById('adminModelsHint');
hint.textContent = 'Fetching...';
try {
const resp = await API.adminFetchModels();
const errs = resp.errors || [];
if (errs.length > 0) {
UI.toast('Fetch errors: ' + errs.join('; '), 'error');
} else {
UI.toast(`Models synced — ${resp.added || 0} added, ${resp.updated || 0} updated`, 'success');
}
hint.textContent = '';
await UI.loadAdminModels();
}
catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
}
async function cycleModelVisibility(id, current) {
// Cycle: disabled → enabled → team → disabled
const next = current === 'disabled' ? 'enabled' : current === 'enabled' ? 'team' : 'disabled';
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
// Send both for backward compat with pre-0.8.3 backends
await API.adminUpdateModel(id, { visibility: next, is_enabled: next === 'enabled' });
await UI.loadAdminModels();
_restoreScroll(el, pos);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function bulkSetVisibility(visibility) {
const hint = document.getElementById('adminModelsHint');
const labels = { enabled: 'Enabling', disabled: 'Disabling', team: 'Setting team-only for' };
hint.textContent = `${labels[visibility] || 'Updating'} all...`;
try {
// Send both for backward compat with pre-0.8.3 backends
const resp = await API.adminBulkUpdateModels(visibility);
const doneLabels = { enabled: 'enabled', disabled: 'disabled', team: 'set to team-only' };
hint.textContent = `${resp.count || 'All'} models ${doneLabels[visibility]}`;
setTimeout(() => { hint.textContent = ''; }, 3000);
await UI.loadAdminModels();
} catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
}
// ── Admin Roles — now managed by renderRoleConfig primitive in ui-admin.js ──
// ── User Model Preferences ──────────────────
async function toggleUserModelVisibility(modelId, providerConfigId, currentlyHidden) {
const compositeKey = (providerConfigId || '') + ':' + modelId;
try {
await API.setModelPreference(modelId, providerConfigId || null, !currentlyHidden);
if (currentlyHidden) App.hiddenModels.delete(compositeKey);
else App.hiddenModels.add(compositeKey);
await UI.loadUserModels();
await fetchModels(); // refresh model selector
} catch (e) { UI.toast(e.message, 'error'); }
}
async function bulkSetUserModelVisibility(show) {
const models = App.models.filter(m => !m.isPersona);
if (!models.length) return;
const shouldHide = !show;
// Only update models not already in the desired state
const toUpdate = models
.filter(m => {
const compositeKey = (m.configId || '') + ':' + (m.baseModelId || m.id);
return App.hiddenModels.has(compositeKey) !== shouldHide;
})
.map(m => ({ model_id: m.baseModelId || m.id, provider_config_id: m.configId || '' }));
if (!toUpdate.length) { UI.toast(show ? 'All already visible' : 'All already hidden'); return; }
try {
await API.bulkSetModelPreferences(toUpdate, shouldHide);
toUpdate.forEach(e => {
const key = (e.provider_config_id || '') + ':' + e.model_id;
shouldHide ? App.hiddenModels.add(key) : App.hiddenModels.delete(key);
});
await UI.loadUserModels();
await fetchModels();
UI.toast(show ? `${toUpdate.length} model(s) shown` : `${toUpdate.length} model(s) hidden`);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteUserPersona(id, name) {
if (!await showConfirm(`Delete persona "${name}"?`)) return;
try {
await API.deleteUserPersona(id);
UI.toast('Persona deleted');
await UI.loadUserPersonas();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Admin Personas ────────────────────────────
var _adminPersonaForm = null;
function ensureAdminPersonaForm() {
if (_adminPersonaForm) return _adminPersonaForm;
const container = document.getElementById('adminAddPersonaForm');
if (!container) return null;
_adminPersonaForm = renderPersonaForm(container, {
prefix: 'adminPersona',
showAvatar: true,
showProviderConfig: true,
showKBPicker: true,
kbScope: 'admin',
onSubmit: (vals) => createAdminPersona(vals),
onCancel: () => {
closeModal('personaFormModal');
_editingPersonaId = null;
_adminPersonaForm.setSubmitLabel('Create');
_adminPersonaForm.clearForm();
}
});
// Override avatar handlers for edit-mode (upload immediately for existing personas)
const fileInput = document.getElementById('adminPersona_avatarFileInput');
if (fileInput) {
// Remove default handler set by renderPersonaForm, add edit-aware one
const newInput = fileInput.cloneNode(true);
fileInput.parentNode.replaceChild(newInput, fileInput);
newInput.addEventListener('change', async function() {
const file = this.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
if (_editingPersonaId) {
try {
const data = await API.adminUploadPersonaAvatar(_editingPersonaId, reader.result);
if (data.avatar) {
_adminPersonaForm.updateAvatarPreview(data.avatar);
await UI.loadAdminPersonas(true);
await fetchModels();
UI.toast('Persona avatar updated');
}
} catch (e) { UI.toast(e.message || 'Upload failed', 'error'); }
} else {
_adminPersonaForm.updateAvatarPreview(reader.result);
}
};
reader.readAsDataURL(file);
this.value = '';
});
document.getElementById('adminPersona_avatarUploadBtn')?.addEventListener('click', () => newInput.click());
}
const removeBtn = document.getElementById('adminPersona_avatarRemoveBtn');
if (removeBtn) {
const newBtn = removeBtn.cloneNode(true);
removeBtn.parentNode.replaceChild(newBtn, removeBtn);
newBtn.addEventListener('click', async () => {
if (_editingPersonaId) {
try {
await API.adminDeletePersonaAvatar(_editingPersonaId);
_adminPersonaForm.updateAvatarPreview(null);
await UI.loadAdminPersonas(true);
await fetchModels();
UI.toast('Persona avatar removed');
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
} else {
_adminPersonaForm.updateAvatarPreview(null);
}
});
}
return _adminPersonaForm;
}
var _editingPersonaId = null;
function editAdminPersona(id) {
const p = UI._personaCache?.[id];
if (!p) return;
_editingPersonaId = id;
ensureAdminPersonaForm();
const form = _adminPersonaForm;
if (!form) return;
document.getElementById('personaFormTitle').textContent = 'Edit Persona';
openModal('personaFormModal');
form.setValues(p);
form.setSubmitLabel('Update');
// Load KB bindings (v0.17.0)
if (form.kbPicker) {
loadPersonaKBs(id, form.kbPicker, '/api/v1/admin');
}
}
async function createAdminPersona(vals) {
if (!vals) {
if (_adminPersonaForm) vals = _adminPersonaForm.getValues();
else return;
}
if (!vals.name || !vals.base_model_id) { UI.toast('Name and base model are required', 'warning'); return; }
const persona = {
name: vals.name,
base_model_id: vals.base_model_id,
description: vals.description || '',
system_prompt: vals.system_prompt || '',
};
if (vals.provider_config_id) persona.provider_config_id = vals.provider_config_id;
if (vals.temperature != null) persona.temperature = vals.temperature;
if (vals.max_tokens != null) persona.max_tokens = vals.max_tokens;
try {
let personaId = _editingPersonaId;
if (_editingPersonaId) {
await API.adminUpdatePersona(_editingPersonaId, persona);
UI.toast('Persona updated', 'success');
} else {
const result = await API.adminCreatePersona(persona);
personaId = result?.id || result?.persona?.id;
UI.toast('Persona created', 'success');
}
// Upload pending avatar for new personas
if (!_editingPersonaId && personaId && vals._pendingAvatar) {
try { await API.adminUploadPersonaAvatar(personaId, vals._pendingAvatar); }
catch (e) { console.warn('Persona avatar upload failed:', e.message); }
}
// Save KB bindings (v0.17.0)
if (personaId && vals._kbValues && _adminPersonaForm?.kbPicker) {
try { await API.adminSetPersonaKBs(personaId, vals._kbValues); }
catch (e) { console.warn('Persona KB binding failed:', e.message); }
}
_editingPersonaId = null;
closeModal('personaFormModal');
if (_adminPersonaForm) {
_adminPersonaForm.setSubmitLabel('Create');
_adminPersonaForm.clearForm();
}
await UI.loadAdminPersonas();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function toggleAdminPersona(id, active) {
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
await API.adminUpdatePersona(id, { is_active: active });
await UI.loadAdminPersonas(true);
_restoreScroll(el, pos);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteAdminPersona(id, name) {
if (!await showConfirm(`Delete persona "${name}"?`)) return;
try {
await API.adminDeletePersona(id);
UI.toast('Persona deleted', 'success');
await UI.loadAdminPersonas();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Team Admin Functions ────────────────────
async function toggleTeamActive(id, active) {
try {
await API.adminUpdateTeam(id, { is_active: active });
UI.toast(active ? 'Team activated' : 'Team deactivated');
await UI.loadAdminTeams(true);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteTeam(id, name) {
if (!await showConfirm(`Delete team "${name}"? All members will be removed.`)) return;
try {
await API.adminDeleteTeam(id);
UI.toast('Team deleted');
await UI.loadAdminTeams(true);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function updateTeamMember(teamId, memberId, role) {
try {
await API.adminUpdateMember(teamId, memberId, role);
UI.toast('Role updated');
} catch (e) {
UI.toast(e.message, 'error');
await UI.loadTeamMembers(teamId);
}
}
async function removeTeamMember(teamId, memberId, email) {
if (!await showConfirm(`Remove ${email} from team?`)) return;
try {
await API.adminRemoveMember(teamId, memberId);
UI.toast('Member removed');
await UI.loadTeamMembers(teamId);
} catch (e) { UI.toast(e.message, 'error'); }
}
// Settings-side team management (uses team admin API, not sys-admin)
async function settingsUpdateTeamMember(teamId, memberId, role) {
try {
await API.teamUpdateMember(teamId, memberId, role);
UI.toast('Role updated');
} catch (e) {
UI.toast(e.message, 'error');
await UI.loadTeamManageMembers(teamId);
}
}
async function settingsRemoveTeamMember(teamId, memberId, email) {
if (!await showConfirm(`Remove ${email} from team?`)) return;
try {
await API.teamRemoveMember(teamId, memberId);
UI.toast('Member removed');
await UI.loadTeamManageMembers(teamId);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function settingsDeleteTeamPersona(teamId, personaId, name) {
if (!await showConfirm(`Delete team persona "${name}"?`)) return;
try {
await API.teamDeletePersona(teamId, personaId);
UI.toast('Persona deleted');
await UI.loadTeamManagePersonas(teamId);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Team Provider handlers — now managed by UI primitives in ui-settings.js ──
// ── Group Actions ───────────────────────────
async function deleteGroup(id, name) {
if (!await showConfirm(`Delete group "${name}"? Members will lose access to any resources granted via this group.`)) return;
try { await API.adminDeleteGroup(id); UI.toast('Group deleted'); await UI.loadAdminGroups(); }
catch (e) { UI.toast(e.message, 'error'); }
}
async function removeGroupMember(groupId, userId, label) {
if (!await showConfirm(`Remove ${label} from group?`)) return;
try {
await API.adminRemoveGroupMember(groupId, userId);
UI.toast('Member removed');
await UI.loadGroupMembers(groupId);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function saveGrant(resourceType, resourceId) {
const scopeSel = document.getElementById(`grantScope_${resourceId}`);
if (!scopeSel) return;
const scope = scopeSel.value;
const groupsDiv = document.getElementById(`grantGroups_${resourceId}`);
const selectedGroups = [...(groupsDiv?.querySelectorAll('.grant-group-cb:checked') || [])].map(cb => cb.value);
if (scope === 'groups' && selectedGroups.length === 0) {
return UI.toast('Select at least one group', 'error');
}
try {
await API.adminSetGrant(resourceType, resourceId, scope, selectedGroups);
// Remove the picker
document.querySelectorAll('.grant-picker-active').forEach(el => el.remove());
UI.toast(scope === 'team_only' ? 'Access reverted to team only' : `Access set to ${scope}`);
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Admin Listeners (extracted from initListeners) ──
function _initAdminListeners() {
// Back button and category tab navigation are handled by the inline
// script in admin.html (sessionStorage return URL + location.replace).
// Only wire up section-specific action handlers here.
document.getElementById('adminSaveSettings')?.addEventListener('click', handleSaveAdminSettings);
// Admin — users
document.getElementById('adminAddUserBtn')?.addEventListener('click', () => {
openModal('createUserModal');
});
document.getElementById('adminCreateUserBtn')?.addEventListener('click', createAdminUser);
// Admin — approve user modal
document.getElementById('approveUserSubmitBtn')?.addEventListener('click', () => submitApproval());
// Admin — providers (form managed by primitives in loadAdminProviders)
document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => {
if (UI._adminProvForm) UI._adminProvForm.setCreateMode();
document.getElementById('providerFormTitle').textContent = 'Add Provider';
openModal('providerFormModal');
});
// Admin — models
document.getElementById('adminFetchModelsBtn')?.addEventListener('click', fetchAdminModels);
// Admin — personas (shared form)
document.getElementById('adminAddPersonaBtn')?.addEventListener('click', () => {
const form = ensureAdminPersonaForm();
if (!form) return;
_editingPersonaId = null;
form.setSubmitLabel('Create');
form.clearForm();
document.getElementById('personaFormTitle').textContent = 'Create Persona';
openModal('personaFormModal');
});
// Admin — Teams
document.getElementById('adminAddTeamBtn')?.addEventListener('click', () => {
openModal('createTeamModal');
document.getElementById('adminTeamName').focus();
});
document.getElementById('adminCancelTeamBtn')?.addEventListener('click', () => {
closeModal('createTeamModal');
});
document.getElementById('adminCreateTeamBtn')?.addEventListener('click', async () => {
const name = document.getElementById('adminTeamName').value.trim();
const desc = document.getElementById('adminTeamDesc').value.trim();
if (!name) return UI.toast('Team name required', 'error');
try {
await API.adminCreateTeam(name, desc);
closeModal('createTeamModal');
document.getElementById('adminTeamName').value = '';
document.getElementById('adminTeamDesc').value = '';
UI.toast('Team created');
await UI.loadAdminTeams(true);
} catch (e) { UI.toast(e.message, 'error'); }
});
document.getElementById('adminTeamBackBtn')?.addEventListener('click', () => {
document.getElementById('adminTeamDetail').style.display = 'none';
document.getElementById('adminTeamList').style.display = '';
UI.loadAdminTeams(true);
});
document.getElementById('adminAddMemberBtn')?.addEventListener('click', async () => {
document.getElementById('adminAddMemberForm').style.display = '';
await UI.loadMemberUserDropdown(UI._teamEditId);
});
document.getElementById('adminTeamPrivatePolicy')?.addEventListener('change', async function() {
try {
await API.adminUpdateTeam(UI._teamEditId, {
settings: JSON.stringify({ require_private_providers: this.checked })
});
UI.toast(this.checked ? 'Private providers required' : 'Private provider policy removed');
} catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; }
});
document.getElementById('adminTeamAllowProviders')?.addEventListener('change', async function() {
try {
await API.adminUpdateTeam(UI._teamEditId, {
settings: JSON.stringify({ allow_team_providers: this.checked })
});
UI.toast(this.checked ? 'Team providers enabled' : 'Team providers disabled');
} catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; }
});
document.getElementById('adminCancelMemberBtn')?.addEventListener('click', () => {
document.getElementById('adminAddMemberForm').style.display = 'none';
});
document.getElementById('adminAddMemberSubmit')?.addEventListener('click', async () => {
const userId = document.getElementById('adminMemberUser').value;
const role = document.getElementById('adminMemberRole').value;
if (!userId) return UI.toast('Select a user', 'error');
try {
await API.adminAddMember(UI._teamEditId, userId, role);
document.getElementById('adminAddMemberForm').style.display = 'none';
UI.toast('Member added');
await UI.loadTeamMembers(UI._teamEditId);
} catch (e) { UI.toast(e.message, 'error'); }
});
// ── Group management ────────────────────────
document.getElementById('adminAddGroupBtn')?.addEventListener('click', async () => {
openModal('createGroupModal');
document.getElementById('adminGroupName').focus();
// Populate team dropdown
try {
const resp = await API.adminListTeams();
const teams = (resp.data || []).filter(t => t.is_active);
const sel = document.getElementById('adminGroupTeam');
sel.innerHTML = '<option value="">Select team...</option>' +
teams.map(t => `<option value="${t.id}">${esc(t.name)}</option>`).join('');
} catch (e) { /* proceed without teams */ }
});
document.getElementById('adminGroupScope')?.addEventListener('change', function() {
document.getElementById('adminGroupTeamRow').style.display = this.value === 'team' ? '' : 'none';
});
document.getElementById('adminCancelGroupBtn')?.addEventListener('click', () => {
closeModal('createGroupModal');
});
document.getElementById('adminCreateGroupBtn')?.addEventListener('click', async () => {
const name = document.getElementById('adminGroupName').value.trim();
const desc = document.getElementById('adminGroupDesc').value.trim();
const scope = document.getElementById('adminGroupScope').value;
const teamId = scope === 'team' ? document.getElementById('adminGroupTeam').value : null;
if (!name) return UI.toast('Group name required', 'error');
if (scope === 'team' && !teamId) return UI.toast('Select a team for team-scoped groups', 'error');
try {
await API.adminCreateGroup(name, desc, scope, teamId);
closeModal('createGroupModal');
document.getElementById('adminGroupName').value = '';
document.getElementById('adminGroupDesc').value = '';
UI.toast('Group created');
await UI.loadAdminGroups(true);
} catch (e) { UI.toast(e.message, 'error'); }
});
document.getElementById('adminGroupBackBtn')?.addEventListener('click', () => {
document.getElementById('adminGroupDetail').style.display = 'none';
document.getElementById('adminGroupList').style.display = '';
UI.loadAdminGroups(true);
});
document.getElementById('adminAddGroupMemberBtn')?.addEventListener('click', async () => {
document.getElementById('adminAddGroupMemberForm').style.display = '';
await UI.loadGroupMemberDropdown(UI._groupEditId);
});
document.getElementById('adminCancelGroupMemberBtn')?.addEventListener('click', () => {
document.getElementById('adminAddGroupMemberForm').style.display = 'none';
});
document.getElementById('adminAddGroupMemberSubmit')?.addEventListener('click', async () => {
const userId = document.getElementById('adminGroupMemberUser').value;
if (!userId) return UI.toast('Select a user', 'error');
try {
await API.adminAddGroupMember(UI._groupEditId, userId);
document.getElementById('adminAddGroupMemberForm').style.display = 'none';
UI.toast('Member added');
await UI.loadGroupMembers(UI._groupEditId);
} catch (e) { UI.toast(e.message, 'error'); }
});
// ── Extension management ────────────────────
document.getElementById('adminInstallExtBtn')?.addEventListener('click', () => {
document.getElementById('adminInstallExtForm').style.display = '';
});
document.getElementById('adminInstallExtSubmit')?.addEventListener('click', async () => {
const extId = document.getElementById('extInstallId').value.trim();
const name = document.getElementById('extInstallName').value.trim();
if (!extId || !name) return UI.toast('ID and Name are required', 'error');
// Build manifest: merge user-provided JSON with _script
let manifest = {};
const manifestRaw = document.getElementById('extInstallManifest').value.trim();
if (manifestRaw) {
try { manifest = JSON.parse(manifestRaw); }
catch { return UI.toast('Invalid manifest JSON', 'error'); }
}
const script = document.getElementById('extInstallScript').value;
if (script) manifest._script = script;
try {
await API._post('/api/v1/admin/extensions', {
ext_id: extId,
name: name,
version: document.getElementById('extInstallVersion').value.trim() || '1.0.0',
author: document.getElementById('extInstallAuthor').value.trim(),
description: document.getElementById('extInstallDesc').value.trim(),
manifest: manifest,
is_system: document.getElementById('extInstallSystem').checked,
is_enabled: document.getElementById('extInstallEnabled').checked,
});
document.getElementById('adminInstallExtForm').style.display = 'none';
// Clear form
['extInstallId','extInstallName','extInstallVersion','extInstallAuthor','extInstallDesc','extInstallManifest','extInstallScript'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = el.type === 'checkbox' ? '' : (el.tagName === 'TEXTAREA' ? '' : '');
});
document.getElementById('extInstallVersion').value = '1.0.0';
UI.toast('Extension installed');
await UI.loadAdminExtensions();
} catch (e) { UI.toast(e.message, 'error'); }
});
// Role fallback alerts (v0.17.0) — show admin banner when fallback fires
if (typeof Events !== 'undefined') {
Events.on('role.fallback', (payload) => {
const banner = document.getElementById('roleFallbackBanner');
if (!banner) return;
const msg = payload?.message || 'A model role is using its fallback provider.';
banner.querySelector('.fallback-msg').textContent = msg;
banner.style.display = '';
// Auto-dismiss after 60s
clearTimeout(banner._dismissTimer);
banner._dismissTimer = setTimeout(() => { banner.style.display = 'none'; }, 60000);
});
}
}
// ── Extension admin actions (global scope for onclick) ──
async function toggleAdminExtension(id, enabled) {
try {
await API._put(`/api/v1/admin/extensions/${id}`, { is_enabled: enabled });
UI.toast(enabled ? 'Extension enabled' : 'Extension disabled');
await UI.loadAdminExtensions();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteAdminExtension(id, name) {
if (!await showConfirm(`Uninstall extension "${name}"? This cannot be undone.`, { danger: true })) return;
try {
await API._del(`/api/v1/admin/extensions/${id}`);
UI.toast('Extension uninstalled');
await UI.loadAdminExtensions();
} catch (e) { UI.toast(e.message, 'error'); }
}
// Track CM6 editor instances per extension edit form
var _extEditors = {};
// Listen for theme changes to update CM6 editors
if (typeof Events !== 'undefined') {
Events.on('theme.changed', (data) => {
const isDark = data.resolved === 'dark';
for (const editors of Object.values(_extEditors)) {
editors.manifest?.setDarkMode(isDark);
editors.script?.setDarkMode(isDark);
}
});
// Listen for keymap changes to update CM6 editors in real time
Events.on('keymap.changed', (data) => {
const mode = data.mode || 'standard';
for (const editors of Object.values(_extEditors)) {
editors.manifest?.setKeymap(mode);
editors.script?.setKeymap(mode);
}
});
}
function editAdminExtension(id) {
// Close any existing edit form (and destroy CM6 instances)
document.querySelectorAll('.ext-edit-form').forEach(el => {
const eid = el.dataset.extId;
if (_extEditors[eid]) {
_extEditors[eid].manifest?.destroy();
_extEditors[eid].script?.destroy();
delete _extEditors[eid];
}
el.remove();
});
const ext = (UI._adminExtensions || []).find(e => e.id === id);
if (!ext) return UI.toast('Extension not found', 'error');
// Extract manifest and _script separately
let manifest = {};
try { manifest = typeof ext.manifest === 'string' ? JSON.parse(ext.manifest) : (ext.manifest || {}); }
catch { manifest = {}; }
const script = manifest._script || '';
// Show manifest without _script for cleaner editing
const manifestClean = { ...manifest };
delete manifestClean._script;
const manifestJSON = JSON.stringify(manifestClean, null, 2);
const systemWarning = ext.is_system
? `<div style="background:var(--bg-3);border:1px solid var(--warning,#f39c12);border-radius:6px;padding:8px 12px;font-size:12px;margin-bottom:8px;color:var(--warning,#f39c12)">
⚠️ System extension — local edits will be overwritten on next deploy. Copy the code first if patching.
</div>`
: '';
// Use container divs instead of textareas when CM6 is available
const useCM6 = !!window.CM?.codeEditor;
const manifestEditor = useCM6
? `<div id="extEdit-manifest-${id}" style="width:100%;min-height:180px;border:1px solid var(--border);border-radius:var(--radius,4px);overflow:hidden"></div>`
: `<textarea id="extEdit-manifest-${id}" rows="8" style="width:100%;font-family:var(--mono);font-size:12px;tab-size:2">${esc(manifestJSON)}</textarea>`;
const scriptEditor = useCM6
? `<div id="extEdit-script-${id}" style="width:100%;min-height:320px;border:1px solid var(--border);border-radius:var(--radius,4px);overflow:hidden"></div>`
: `<textarea id="extEdit-script-${id}" rows="16" style="width:100%;font-family:var(--mono);font-size:12px;tab-size:2;white-space:pre">${esc(script)}</textarea>`;
const formHTML = `
<div class="ext-edit-form" data-ext-id="${id}" style="border-top:1px solid var(--border);padding:12px 0;margin-top:8px">
${systemWarning}
<div class="form-group" style="margin-bottom:8px">
<label style="font-size:12px;font-weight:600">Name</label>
<input type="text" id="extEdit-name-${id}" value="${esc(ext.name)}" style="width:100%">
</div>
<div class="form-group" style="margin-bottom:8px">
<label style="font-size:12px;font-weight:600">Description</label>
<input type="text" id="extEdit-desc-${id}" value="${esc(ext.description || '')}" style="width:100%">
</div>
<div class="form-group" style="margin-bottom:8px">
<label style="font-size:12px;font-weight:600">Manifest JSON <span class="text-muted">(without _script)</span></label>
${manifestEditor}
</div>
<div class="form-group" style="margin-bottom:8px">
<label style="font-size:12px;font-weight:600">Script <span class="text-muted">(${script.length.toLocaleString()} chars)</span></label>
${scriptEditor}
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn-small" onclick="_closeExtEditForm('${id}')">Cancel</button>
<button class="btn-small btn-primary" onclick="saveAdminExtension('${id}')">Save Changes</button>
</div>
</div>
`;
// Find the extension row and insert the form after it
const rows = document.querySelectorAll('#adminExtensionsList tbody tr');
for (const row of rows) {
if (row.querySelector(`[onclick*="editAdminExtension('${id}')"]`)) {
row.insertAdjacentHTML('afterend', formHTML);
if (useCM6) {
// Get user keybinding preference
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
const keymapMode = prefs.editorKeymap || 'standard';
// Initialize CM6 editors
_extEditors[id] = {
manifest: CM.codeEditor(document.getElementById(`extEdit-manifest-${id}`), {
language: 'json',
value: manifestJSON,
lineNumbers: true,
keymap: keymapMode,
}),
script: CM.codeEditor(document.getElementById(`extEdit-script-${id}`), {
language: 'javascript',
value: script,
lineNumbers: true,
keymap: keymapMode,
}),
};
} else {
// Fallback: Tab key inserts tab in script textarea
const scriptEl = document.getElementById(`extEdit-script-${id}`);
if (scriptEl) {
scriptEl.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const start = scriptEl.selectionStart;
const end = scriptEl.selectionEnd;
scriptEl.value = scriptEl.value.substring(0, start) + ' ' + scriptEl.value.substring(end);
scriptEl.selectionStart = scriptEl.selectionEnd = start + 4;
}
});
}
}
break;
}
}
}
function _closeExtEditForm(id) {
if (_extEditors[id]) {
_extEditors[id].manifest?.destroy();
_extEditors[id].script?.destroy();
delete _extEditors[id];
}
document.querySelector(`.ext-edit-form[data-ext-id="${id}"]`)?.remove();
}
async function saveAdminExtension(id) {
const nameEl = document.getElementById(`extEdit-name-${id}`);
const descEl = document.getElementById(`extEdit-desc-${id}`);
// Read from CM6 if available, otherwise from textarea
const editors = _extEditors[id];
const manifestText = editors?.manifest
? editors.manifest.getValue()
: document.getElementById(`extEdit-manifest-${id}`)?.value;
const scriptText = editors?.script
? editors.script.getValue()
: document.getElementById(`extEdit-script-${id}`)?.value;
if (!nameEl || manifestText == null || scriptText == null) return;
// Parse manifest and merge _script back in
let manifest;
try {
manifest = JSON.parse((manifestText || '').trim() || '{}');
} catch (e) {
return UI.toast('Invalid manifest JSON: ' + e.message, 'error');
}
if (scriptText.trim()) {
manifest._script = scriptText;
}
try {
await API._put(`/api/v1/admin/extensions/${id}`, {
name: nameEl.value.trim(),
description: descEl.value.trim(),
manifest: manifest,
});
UI.toast('Extension updated — reload page to apply changes');
_closeExtEditForm(id);
await UI.loadAdminExtensions();
} catch (e) {
UI.toast(e.message, 'error');
}
}