// ========================================== // Chat Switchboard – Admin Handlers // ========================================== // Admin actions: user management, roles, model visibility, // presets, 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) { const form = document.getElementById(`approveForm-${userId}`); const teamsEl = document.getElementById(`approveTeams-${userId}`); if (!form || !teamsEl) return; // Load teams for checkboxes teamsEl.innerHTML = 'Loading teams...'; form.style.display = ''; try { const resp = await API.adminListTeams(); const teams = (resp.data || []).filter(t => t.is_active); if (teams.length === 0) { teamsEl.innerHTML = 'No teams — user will be activated without team assignment'; } else { teamsEl.innerHTML = '' + teams.map(t => ``).join(''); } } catch (e) { teamsEl.innerHTML = `Could not load teams`; } } function hideApproveForm(userId) { const form = document.getElementById(`approveForm-${userId}`); if (form) form.style.display = 'none'; } async function submitApproval(userId) { const form = document.getElementById(`approveForm-${userId}`); const teamIds = [...(form?.querySelectorAll('.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'); 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); document.getElementById('adminAddUserForm').style.display = 'none'; UI.toast('User created', 'success'); await UI.loadAdminUsers(); } catch (e) { UI.toast(e.message, 'error'); } } async function adminResetUserPassword(id, username) { const pw = prompt( `Reset password for "${username}"?\n\n` + `⚠️ WARNING: This will DESTROY the user's personal vault.\n` + `All personal API keys (BYOK) will be permanently deleted.\n` + `The user will need to re-add any personal provider keys.\n\n` + `Enter new password (min 8 chars):` ); if (!pw) return; if (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); 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, currentlyHidden) { try { await API.setModelPreference(modelId, !currentlyHidden); if (currentlyHidden) App.hiddenModels.delete(modelId); else App.hiddenModels.add(modelId); 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.isPreset); if (!models.length) return; const shouldHide = !show; // Only update models not already in the desired state const toUpdate = models .map(m => m.baseModelId || m.id) .filter(mid => App.hiddenModels.has(mid) !== shouldHide); if (!toUpdate.length) { UI.toast(show ? 'All already visible' : 'All already hidden'); return; } try { await API.bulkSetModelPreferences(toUpdate, shouldHide); toUpdate.forEach(mid => shouldHide ? App.hiddenModels.add(mid) : App.hiddenModels.delete(mid)); 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 deleteUserPreset(id, name) { if (!await showConfirm(`Delete preset "${name}"?`)) return; try { await API.deleteUserPreset(id); UI.toast('Preset deleted'); await UI.loadUserPresets(); await fetchModels(); } catch (e) { UI.toast(e.message, 'error'); } } // ── Admin Presets ──────────────────────────── var _adminPresetForm = null; function ensureAdminPresetForm() { if (_adminPresetForm) return _adminPresetForm; const container = document.getElementById('adminAddPresetForm'); if (!container) return null; _adminPresetForm = renderPresetForm(container, { prefix: 'adminPreset', showAvatar: true, showProviderConfig: true, showKBPicker: true, kbScope: 'admin', onSubmit: (vals) => createAdminPreset(vals), onCancel: () => { container.style.display = 'none'; _editingPresetId = null; _adminPresetForm.setSubmitLabel('Create'); _adminPresetForm.clearForm(); } }); // Override avatar handlers for edit-mode (upload immediately for existing presets) const fileInput = document.getElementById('adminPreset_avatarFileInput'); if (fileInput) { // Remove default handler set by renderPresetForm, 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 (_editingPresetId) { try { const data = await API.adminUploadPresetAvatar(_editingPresetId, reader.result); if (data.avatar) { _adminPresetForm.updateAvatarPreview(data.avatar); await UI.loadAdminPresets(true); await fetchModels(); UI.toast('Preset avatar updated'); } } catch (e) { UI.toast(e.message || 'Upload failed', 'error'); } } else { _adminPresetForm.updateAvatarPreview(reader.result); } }; reader.readAsDataURL(file); this.value = ''; }); document.getElementById('adminPreset_avatarUploadBtn')?.addEventListener('click', () => newInput.click()); } const removeBtn = document.getElementById('adminPreset_avatarRemoveBtn'); if (removeBtn) { const newBtn = removeBtn.cloneNode(true); removeBtn.parentNode.replaceChild(newBtn, removeBtn); newBtn.addEventListener('click', async () => { if (_editingPresetId) { try { await API.adminDeletePresetAvatar(_editingPresetId); _adminPresetForm.updateAvatarPreview(null); await UI.loadAdminPresets(true); await fetchModels(); UI.toast('Preset avatar removed'); } catch (e) { UI.toast(e.message || 'Remove failed', 'error'); } } else { _adminPresetForm.updateAvatarPreview(null); } }); } return _adminPresetForm; } var _editingPresetId = null; function editAdminPreset(id) { const p = UI._presetCache?.[id]; if (!p) return; _editingPresetId = id; ensureAdminPresetForm(); const form = _adminPresetForm; if (!form) return; document.getElementById('adminAddPresetForm').style.display = ''; form.setValues(p); form.setSubmitLabel('Update'); // Load KB bindings (v0.17.0) if (form.kbPicker) { loadPersonaKBs(id, form.kbPicker, '/api/v1/admin'); } } async function createAdminPreset(vals) { if (!vals) { if (_adminPresetForm) vals = _adminPresetForm.getValues(); else return; } if (!vals.name || !vals.base_model_id) { UI.toast('Name and base model are required', 'warning'); return; } const preset = { name: vals.name, base_model_id: vals.base_model_id, description: vals.description || '', system_prompt: vals.system_prompt || '', }; if (vals.provider_config_id) preset.provider_config_id = vals.provider_config_id; if (vals.temperature != null) preset.temperature = vals.temperature; if (vals.max_tokens != null) preset.max_tokens = vals.max_tokens; try { let presetId = _editingPresetId; if (_editingPresetId) { await API.adminUpdatePreset(_editingPresetId, preset); UI.toast('Preset updated', 'success'); } else { const result = await API.adminCreatePreset(preset); presetId = result?.id || result?.preset?.id; UI.toast('Preset created', 'success'); } // Upload pending avatar for new presets if (!_editingPresetId && presetId && vals._pendingAvatar) { try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); } catch (e) { console.warn('Preset avatar upload failed:', e.message); } } // Save KB bindings (v0.17.0) if (presetId && vals._kbValues && _adminPresetForm?.kbPicker) { try { await API.adminSetPresetKBs(presetId, vals._kbValues); } catch (e) { console.warn('Preset KB binding failed:', e.message); } } _editingPresetId = null; document.getElementById('adminAddPresetForm').style.display = 'none'; if (_adminPresetForm) { _adminPresetForm.setSubmitLabel('Create'); _adminPresetForm.clearForm(); } await UI.loadAdminPresets(); await fetchModels(); } catch (e) { UI.toast(e.message, 'error'); } } async function toggleAdminPreset(id, active) { try { const el = _adminScroll(), pos = el?.scrollTop || 0; await API.adminUpdatePreset(id, { is_active: active }); await UI.loadAdminPresets(true); _restoreScroll(el, pos); await fetchModels(); } catch (e) { UI.toast(e.message, 'error'); } } async function deleteAdminPreset(id, name) { if (!await showConfirm(`Delete preset "${name}"?`)) return; try { await API.adminDeletePreset(id); UI.toast('Preset deleted', 'success'); await UI.loadAdminPresets(); 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 settingsDeleteTeamPreset(teamId, presetId, name) { if (!await showConfirm(`Delete team preset "${name}"?`)) return; try { await API.teamDeletePreset(teamId, presetId); UI.toast('Preset deleted'); await UI.loadTeamManagePresets(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() { // Admin panel navigation (elements may not exist for non-admin users) document.getElementById('adminBackBtn')?.addEventListener('click', UI.closeAdmin); document.querySelectorAll('.admin-cat').forEach(btn => { btn.addEventListener('click', () => UI.switchAdminCategory(btn.dataset.cat)); }); // Esc closes admin panel document.addEventListener('keydown', e => { if (e.key === 'Escape' && document.getElementById('adminPanel')?.style.display === 'flex') { UI.closeAdmin(); e.stopPropagation(); } }); document.getElementById('adminSaveSettings')?.addEventListener('click', handleSaveAdminSettings); // Admin — users document.getElementById('adminAddUserBtn')?.addEventListener('click', () => { const f = document.getElementById('adminAddUserForm'); f.style.display = f.style.display === 'none' ? '' : 'none'; }); document.getElementById('adminCancelUserBtn')?.addEventListener('click', () => { document.getElementById('adminAddUserForm').style.display = 'none'; }); document.getElementById('adminCreateUserBtn')?.addEventListener('click', createAdminUser); // Admin — providers (form managed by primitives in loadAdminProviders) document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => { const f = document.getElementById('adminAddProviderForm'); if (UI._adminProvForm) UI._adminProvForm.setCreateMode(); f.style.display = f.style.display === 'none' ? '' : 'none'; }); // Admin — models document.getElementById('adminFetchModelsBtn')?.addEventListener('click', fetchAdminModels); // Admin — presets (shared form) document.getElementById('adminAddPresetBtn')?.addEventListener('click', () => { const form = ensureAdminPresetForm(); if (!form) return; _editingPresetId = null; form.setSubmitLabel('Create'); form.clearForm(); const f = document.getElementById('adminAddPresetForm'); f.style.display = f.style.display === 'none' ? '' : 'none'; }); // Admin — Teams document.getElementById('adminAddTeamBtn')?.addEventListener('click', () => { document.getElementById('adminAddTeamForm').style.display = ''; document.getElementById('adminTeamName').focus(); }); document.getElementById('adminCancelTeamBtn')?.addEventListener('click', () => { document.getElementById('adminAddTeamForm').style.display = 'none'; document.getElementById('adminTeamName').value = ''; document.getElementById('adminTeamDesc').value = ''; }); 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); document.getElementById('adminAddTeamForm').style.display = 'none'; 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 () => { document.getElementById('adminAddGroupForm').style.display = ''; 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 = '' + teams.map(t => ``).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', () => { document.getElementById('adminAddGroupForm').style.display = 'none'; document.getElementById('adminGroupName').value = ''; document.getElementById('adminGroupDesc').value = ''; }); 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); document.getElementById('adminAddGroupForm').style.display = 'none'; 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 ? `
⚠️ System extension — local edits will be overwritten on next deploy. Copy the code first if patching.
` : ''; // Use container divs instead of textareas when CM6 is available const useCM6 = !!window.CM?.codeEditor; const manifestEditor = useCM6 ? `
` : ``; const scriptEditor = useCM6 ? `
` : ``; const formHTML = `
${systemWarning}
${manifestEditor}
${scriptEditor}
`; // Find the extension row and insert the form after it const rows = document.querySelectorAll('#adminExtensionsList .admin-user-row'); 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'); } }