Changeset 0.16.0 (#74)

This commit is contained in:
2026-02-27 02:38:35 +00:00
parent 1370d701af
commit 8bb77710b9
28 changed files with 3050 additions and 492 deletions

View File

@@ -400,6 +400,42 @@ async function settingsDeleteTeamPreset(teamId, presetId, name) {
// ── 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() {
@@ -509,6 +545,66 @@ function _initAdminListeners() {
} 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 = '';

View File

@@ -413,6 +413,30 @@ const API = {
adminUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/admin/teams/${teamId}/members/${memberId}`, { role }); },
adminRemoveMember(teamId, memberId) { return this._del(`/api/v1/admin/teams/${teamId}/members/${memberId}`); },
// ── Admin Groups ────────────────────────
adminListGroups() { return this._get('/api/v1/admin/groups'); },
adminCreateGroup(name, description, scope, teamId) {
const body = { name, description, scope };
if (teamId) body.team_id = teamId;
return this._post('/api/v1/admin/groups', body);
},
adminGetGroup(id) { return this._get(`/api/v1/admin/groups/${id}`); },
adminUpdateGroup(id, updates) { return this._put(`/api/v1/admin/groups/${id}`, updates); },
adminDeleteGroup(id) { return this._del(`/api/v1/admin/groups/${id}`); },
adminListGroupMembers(groupId) { return this._get(`/api/v1/admin/groups/${groupId}/members`); },
adminAddGroupMember(groupId, userId) { return this._post(`/api/v1/admin/groups/${groupId}/members`, { user_id: userId }); },
adminRemoveGroupMember(groupId, userId) { return this._del(`/api/v1/admin/groups/${groupId}/members/${userId}`); },
// ── Admin Resource Grants ───────────────
adminGetGrant(type, id) { return this._get(`/api/v1/admin/grants/${type}/${id}`); },
adminSetGrant(type, id, grantScope, grantedGroups) {
return this._put(`/api/v1/admin/grants/${type}/${id}`, { grant_scope: grantScope, granted_groups: grantedGroups || [] });
},
adminDeleteGrant(type, id) { return this._del(`/api/v1/admin/grants/${type}/${id}`); },
// ── User Groups ─────────────────────────
listMyGroups() { return this._get('/api/v1/groups/mine'); },
// ── User Teams ──────────────────────────
listMyTeams() { return this._get('/api/v1/teams/mine'); },
@@ -425,6 +449,7 @@ const API = {
teamCreatePreset(teamId, preset) { return this._post(`/api/v1/teams/${teamId}/presets`, preset); },
teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); },
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
teamListGroups(teamId) { return this._get(`/api/v1/teams/${teamId}/groups`); },
// ── Team Providers ──────────────────────
teamListProviders(teamId) { return this._get(`/api/v1/teams/${teamId}/providers`); },

View File

@@ -182,13 +182,14 @@ const KnowledgeUI = (() => {
const chunks = kb.chunk_count === 1 ? '1 chunk' : `${kb.chunk_count} chunks`;
const size = kb.total_bytes > 0 ? ` · ${_formatSize(kb.total_bytes)}` : '';
html += `
<div class="kb-manage-item" data-kb-id="${kb.id}">
<div class="kb-manage-item" data-kb-id="${kb.id}" data-grant-kb="${kb.id}">
<div class="kb-manage-info">
<span class="kb-manage-name">${scope} ${_esc(kb.name)}</span>
<span class="kb-manage-desc">${_esc(kb.description || '')}</span>
<span class="kb-manage-stats">${docs} · ${chunks}${size}${status}</span>
</div>
<div class="kb-manage-actions">
${kb.scope !== 'personal' ? `<button class="btn-small" data-kb-grant="${kb.id}" data-kb-name="${_esc(kb.name)}" title="Access">🔒</button>` : ''}
<button class="btn-small" data-kb-docs="${kb.id}" title="Documents">📄</button>
<button class="btn-small btn-danger" data-kb-del="${kb.id}" title="Delete">✕</button>
</div>
@@ -211,6 +212,14 @@ const KnowledgeUI = (() => {
panel.querySelectorAll('[data-kb-del]').forEach(btn => {
btn.addEventListener('click', () => _deleteKB(btn.dataset.kbDel));
});
// Wire grant access buttons (admin only, non-personal KBs)
panel.querySelectorAll('[data-kb-grant]').forEach(btn => {
btn.addEventListener('click', () => {
if (typeof UI !== 'undefined' && UI.openGrantPicker) {
UI.openGrantPicker('knowledge_base', btn.dataset.kbGrant, btn.dataset.kbName, `[data-grant-kb="${btn.dataset.kbGrant}"]`);
}
});
});
}
// ── Create KB Dialog ─────────────────────

View File

@@ -6,14 +6,14 @@
// ── Category → Section mapping ────────────
const ADMIN_SECTIONS = {
people: ['users', 'teams'],
people: ['users', 'teams', 'groups'],
ai: ['providers', 'models', 'presets', 'roles', 'knowledgeBases'],
system: ['settings', 'storage', 'extensions'],
monitoring: ['usage', 'audit', 'stats'],
};
const ADMIN_LABELS = {
users: 'Users', teams: 'Teams',
users: 'Users', teams: 'Teams', groups: 'Groups',
providers: 'Providers', models: 'Models', presets: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge',
settings: 'Settings', storage: 'Storage', extensions: 'Extensions',
usage: 'Usage', audit: 'Audit', stats: 'Stats',
@@ -23,6 +23,7 @@ const ADMIN_LABELS = {
const ADMIN_LOADERS = {
users: () => UI.loadAdminUsers(),
teams: () => UI.loadAdminTeams(),
groups: () => UI.loadAdminGroups(),
roles: () => UI.loadAdminRoles(),
providers: () => UI.loadAdminProviders(),
models: () => UI.loadAdminModels(),
@@ -187,6 +188,7 @@ Object.assign(UI, {
KnowledgeUI.openTeamPanel(teamId);
}
}
if (tab === 'groups') UI.loadTeamManageGroups(teamId);
},
async loadAdminUsers(quiet) {
@@ -612,13 +614,14 @@ Object.assign(UI, {
}
const creator = p.creator_name ? `<span class="text-muted" style="font-size:11px">by ${esc(p.creator_name)}</span>` : '';
const status = p.is_active ? '' : '<span class="badge-pending">inactive</span>';
return `<div class="admin-preset-row">
return `<div class="admin-preset-row" data-grant-persona="${p.id}">
<div class="preset-info">
<strong>${avatarEl}${esc(p.name)}</strong> ${scopeBadge} ${creator} ${status}
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
${p.description ? `<div class="preset-desc">${esc(p.description)}</div>` : ''}
</div>
<div class="preset-actions">
<button class="btn-small" onclick="UI.openGrantPicker('persona', '${p.id}', '${esc(p.name)}', '[data-grant-persona=&quot;${p.id}&quot;]')" title="Manage access">🔒 Access</button>
<button class="btn-edit" onclick="editAdminPreset('${p.id}')" title="Edit">✎</button>
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="toggleAdminPreset('${p.id}', ${!p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
<button class="btn-delete" onclick="deleteAdminPreset('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
@@ -725,6 +728,146 @@ Object.assign(UI, {
} catch (e) { sel.innerHTML = `<option value="">Error loading users</option>`; }
},
// ── Groups Admin ────────────────────────
_groupEditId: null,
async loadAdminGroups(quiet) {
const el = document.getElementById('adminGroupList');
const detail = document.getElementById('adminGroupDetail');
if (!quiet) {
el.innerHTML = '<div class="loading">Loading...</div>';
detail.style.display = 'none';
el.style.display = '';
document.getElementById('adminAddGroupForm').style.display = 'none';
}
try {
const resp = await API.adminListGroups();
const groups = resp.data || [];
el.innerHTML = groups.map(g => {
const scopeBadge = g.scope === 'global'
? '<span class="badge-admin">global</span>'
: `<span class="badge-team">team</span>`;
return `<div class="admin-user-row">
<div class="admin-user-info">
<div><strong>${esc(g.name)}</strong> ${scopeBadge}</div>
<div class="text-muted">${esc(g.description || 'No description')} · ${g.member_count} member${g.member_count !== 1 ? 's' : ''}</div>
</div>
<div class="admin-user-actions">
<button class="btn-small" onclick="UI.openGroupDetail('${g.id}', '${esc(g.name)}')">Members</button>
<button class="btn-delete" onclick="deleteGroup('${g.id}', '${esc(g.name)}')" title="Delete">✕</button>
</div>
</div>`;
}).join('') || '<div class="empty-hint">No groups yet — create one to control access to personas and knowledge bases</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async openGroupDetail(groupId, groupName) {
this._groupEditId = groupId;
document.getElementById('adminGroupList').style.display = 'none';
document.getElementById('adminAddGroupForm').style.display = 'none';
const detail = document.getElementById('adminGroupDetail');
detail.style.display = '';
document.getElementById('adminGroupDetailName').textContent = groupName;
document.getElementById('adminAddGroupMemberForm').style.display = 'none';
// Load group details for meta line
try {
const g = await API.adminGetGroup(groupId);
const scope = g.scope === 'global' ? 'Global group' : 'Team-scoped group';
document.getElementById('adminGroupDetailMeta').textContent = `${scope} · ${g.member_count} member${g.member_count !== 1 ? 's' : ''}`;
} catch (e) { /* proceed */ }
await this.loadGroupMembers(groupId);
},
async loadGroupMembers(groupId) {
const el = document.getElementById('adminGroupMemberList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.adminListGroupMembers(groupId);
const members = resp.data || [];
el.innerHTML = members.map(m => `
<div class="admin-user-row">
<div class="admin-user-info">
<div><strong>${esc(m.display_name || m.username || m.email)}</strong></div>
<div class="text-muted">${esc(m.email)} · added ${new Date(m.added_at).toLocaleDateString()}</div>
</div>
<div class="admin-user-actions">
<button class="btn-delete" onclick="removeGroupMember('${groupId}', '${m.user_id}', '${esc(m.username || m.email)}')" title="Remove">✕</button>
</div>
</div>
`).join('') || '<div class="empty-hint">No members — add users to this group</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadGroupMemberDropdown(groupId) {
const sel = document.getElementById('adminGroupMemberUser');
sel.innerHTML = '<option value="">Loading...</option>';
try {
const resp = await API.adminListUsers(1, 200);
const users = resp.users || resp.data || [];
// Exclude existing members
const memberResp = await API.adminListGroupMembers(groupId);
const existingIds = new Set((memberResp.data || []).map(m => m.user_id));
const available = users.filter(u => u.is_active && !existingIds.has(u.id));
sel.innerHTML = '<option value="">Select user...</option>' +
available.map(u => `<option value="${u.id}">${esc(u.username || u.email)}</option>`).join('');
} catch (e) { sel.innerHTML = `<option value="">Error loading users</option>`; }
},
// ── Grant Picker (shared for Personas + KBs) ──
async openGrantPicker(resourceType, resourceId, resourceName, anchorSelector) {
// Fetch current grant + all groups for the picker
const [grantResp, groupsResp] = await Promise.all([
API.adminGetGrant(resourceType, resourceId),
API.adminListGroups(),
]);
const grant = grantResp;
const groups = (groupsResp.data || []);
const currentScope = grant.grant_scope || 'team_only';
const currentGroups = new Set(grant.granted_groups || []);
const html = `<div class="admin-inline-form" style="margin:8px 0;padding:10px;background:rgba(255,255,255,0.03);border-radius:8px">
<div style="font-size:13px;font-weight:600;margin-bottom:8px">Access: ${esc(resourceName)}</div>
<div class="form-group" style="margin-bottom:8px">
<label style="font-size:12px">Scope</label>
<select id="grantScope_${resourceId}" class="inline-select" style="width:auto">
<option value="team_only" ${currentScope === 'team_only' ? 'selected' : ''}>Team Only (default)</option>
<option value="global" ${currentScope === 'global' ? 'selected' : ''}>Global (all users)</option>
<option value="groups" ${currentScope === 'groups' ? 'selected' : ''}>Specific Groups</option>
</select>
</div>
<div id="grantGroups_${resourceId}" style="${currentScope === 'groups' ? '' : 'display:none'}">
${groups.length ? groups.map(g => `<label class="checkbox-label" style="font-size:12px">
<input type="checkbox" class="grant-group-cb" value="${g.id}" ${currentGroups.has(g.id) ? 'checked' : ''}> ${esc(g.name)}
<span class="text-muted" style="font-size:11px">${g.scope === 'global' ? '(global)' : '(team)'}</span>
</label>`).join('') : '<span class="text-muted" style="font-size:12px">No groups exist yet — create one in People → Groups</span>'}
</div>
<div class="form-row" style="margin-top:8px">
<button class="btn-small btn-primary" onclick="saveGrant('${resourceType}', '${resourceId}')">Save</button>
<button class="btn-small" onclick="this.closest('.admin-inline-form').remove()">Cancel</button>
</div>
</div>`;
// Remove any existing picker, then insert after the anchor row
document.querySelectorAll('.grant-picker-active').forEach(el => el.remove());
const row = document.querySelector(anchorSelector);
if (row) {
const wrapper = document.createElement('div');
wrapper.className = 'grant-picker-active';
wrapper.innerHTML = html;
row.after(wrapper);
// Wire scope toggle
const scopeSel = document.getElementById(`grantScope_${resourceId}`);
const groupsDiv = document.getElementById(`grantGroups_${resourceId}`);
scopeSel?.addEventListener('change', () => {
groupsDiv.style.display = scopeSel.value === 'groups' ? '' : 'none';
});
}
},
async loadAdminSettings() {
try {
const data = await API.adminGetSettings();

View File

@@ -301,6 +301,24 @@ Object.assign(UI, {
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadTeamManageGroups(teamId) {
const el = document.getElementById('settingsTeamGroups');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.teamListGroups(teamId);
const groups = resp.data || [];
el.innerHTML = groups.map(g => `
<div class="admin-user-row" style="padding:6px 0">
<div class="admin-user-info">
<div><strong>${esc(g.name)}</strong></div>
<div class="text-muted" style="font-size:11px">${esc(g.description || 'No description')} · ${g.member_count} member${g.member_count !== 1 ? 's' : ''}</div>
</div>
</div>
`).join('') || '<div class="empty-hint">No groups assigned to this team yet</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadMyUsage() {
const el = document.getElementById('myUsageTotals')?.parentElement;
if (!el) return;