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-02-27 16:25:39 +00:00

801 lines
36 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,
// 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 = '<span class="text-muted">Loading teams...</span>';
form.style.display = '';
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:4px">Assign to teams:</label>' +
teams.map(t => `<label class="checkbox-label" style="font-size:13px"><input type="checkbox" value="${t.id}" class="approve-team-cb"> ${esc(t.name)}</label>`).join('');
}
} catch (e) { teamsEl.innerHTML = `<span class="text-muted">Could not load teams</span>`; }
}
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 = '<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', () => {
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'); }
}
function editAdminExtension(id) {
// Close any existing edit form
document.querySelectorAll('.ext-edit-form').forEach(el => 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>`
: '';
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>
<textarea id="extEdit-manifest-${id}" rows="8" style="width:100%;font-family:var(--mono);font-size:12px;tab-size:2">${esc(manifestJSON)}</textarea>
</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>
<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>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn-small" onclick="this.closest('.ext-edit-form').remove()">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 .admin-user-row');
for (const row of rows) {
if (row.querySelector(`[onclick*="editAdminExtension('${id}')"]`)) {
row.insertAdjacentHTML('afterend', formHTML);
// 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;
}
}
}
async function saveAdminExtension(id) {
const nameEl = document.getElementById(`extEdit-name-${id}`);
const descEl = document.getElementById(`extEdit-desc-${id}`);
const manifestEl = document.getElementById(`extEdit-manifest-${id}`);
const scriptEl = document.getElementById(`extEdit-script-${id}`);
if (!nameEl || !manifestEl || !scriptEl) return;
// Parse manifest and merge _script back in
let manifest;
try {
manifest = JSON.parse(manifestEl.value.trim() || '{}');
} catch (e) {
return UI.toast('Invalid manifest JSON: ' + e.message, 'error');
}
const script = scriptEl.value;
if (script.trim()) {
manifest._script = script;
}
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');
await UI.loadAdminExtensions();
} catch (e) {
UI.toast(e.message, 'error');
}
}