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

@@ -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();