880 lines
39 KiB
JavaScript
880 lines
39 KiB
JavaScript
// ==========================================
|
||
// 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'); }
|
||
}
|
||
|
||
// 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 .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');
|
||
}
|
||
}
|