All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m12s
CI/CD / test-sqlite (pull_request) Successful in 2m45s
CI/CD / build-and-deploy (pull_request) Successful in 1m18s
Provider-era cruft: token_budget_daily, token_budget_monthly, and allowed_models columns removed from groups table (both dialects). ResolveTokenBudget() and ResolveModelAllowlist() deleted. GroupPatch trimmed to name/description/permissions. Admin groups UI simplified to permissions-only. -283 lines. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
208 lines
8.5 KiB
JavaScript
208 lines
8.5 KiB
JavaScript
/**
|
|
* Admin > People > Groups
|
|
*/
|
|
const { html } = window;
|
|
const { useState, useEffect, useCallback } = hooks;
|
|
|
|
export default function GroupsSection() {
|
|
const [groups, setGroups] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [detail, setDetail] = useState(null);
|
|
const [members, setMembers] = useState([]);
|
|
const [allPerms, setAllPerms] = useState([]);
|
|
const [allUsers, setAllUsers] = useState([]);
|
|
const [groupData, setGroupData] = useState({});
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [showAddMember, setShowAddMember] = useState(false);
|
|
|
|
const loadGroups = useCallback(async () => {
|
|
try {
|
|
const data = await sw.api.admin.groups.list();
|
|
setGroups(data || []);
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
finally { setLoading(false); }
|
|
}, []);
|
|
|
|
useEffect(() => { loadGroups(); }, []);
|
|
|
|
async function openDetail(group) {
|
|
setDetail(group);
|
|
setGroupData({
|
|
permissions: group.permissions || [],
|
|
});
|
|
try {
|
|
const [m, p, u] = await Promise.all([
|
|
sw.api.admin.groups.members(group.id),
|
|
sw.api.admin.permissions.list(),
|
|
sw.api.admin.users.list(),
|
|
]);
|
|
setMembers(m || []);
|
|
setAllPerms(p.permissions || []);
|
|
setAllUsers(u || []);
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
}
|
|
|
|
async function createGroup(e) {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
try {
|
|
await sw.api.admin.groups.create({
|
|
name: form.name.value.trim(),
|
|
description: form.desc.value.trim(),
|
|
scope: form.scope.value,
|
|
});
|
|
sw.toast('Group created', 'success');
|
|
setShowCreate(false);
|
|
loadGroups();
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
}
|
|
|
|
async function savePermissions() {
|
|
try {
|
|
await sw.api.admin.groups.update(detail.id, {
|
|
permissions: groupData.permissions,
|
|
});
|
|
sw.toast('Saved', 'success');
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
}
|
|
|
|
function togglePerm(perm) {
|
|
setGroupData(prev => {
|
|
const perms = prev.permissions.includes(perm)
|
|
? prev.permissions.filter(p => p !== perm)
|
|
: [...prev.permissions, perm];
|
|
return { ...prev, permissions: perms };
|
|
});
|
|
}
|
|
|
|
async function addMember(e) {
|
|
e.preventDefault();
|
|
const uid = e.target.userId.value;
|
|
if (!uid) return;
|
|
try {
|
|
await sw.api.admin.groups.addMember(detail.id, uid);
|
|
sw.toast('Member added', 'success');
|
|
setShowAddMember(false);
|
|
const m = await sw.api.admin.groups.members(detail.id);
|
|
setMembers(m || []);
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
}
|
|
|
|
async function removeMember(uid) {
|
|
try {
|
|
await sw.api.admin.groups.removeMember(detail.id, uid);
|
|
sw.toast('Member removed', 'success');
|
|
setMembers(prev => prev.filter(m => (m.user_id || m.id) !== uid));
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
}
|
|
|
|
async function deleteGroup() {
|
|
const ok = await sw.confirm('Delete this group?', true);
|
|
if (!ok) return;
|
|
try {
|
|
await sw.api.admin.groups.del(detail.id);
|
|
sw.toast('Group deleted', 'success');
|
|
setDetail(null);
|
|
loadGroups();
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
}
|
|
|
|
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
|
|
|
if (detail) return html`
|
|
<div>
|
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
|
<button class="btn-small" onClick=${() => setDetail(null)}>\u2190 Back</button>
|
|
<h4 style="margin:0;">${detail.name}</h4>
|
|
<span class="text-muted" style="font-size:12px;">${detail.scope}</span>
|
|
<div style="flex:1"></div>
|
|
<button class="btn-small btn-danger" onClick=${deleteGroup}>Delete</button>
|
|
</div>
|
|
|
|
<div class="settings-section" style="margin-bottom:16px;">
|
|
<h5 style="margin:0 0 8px;">Permissions</h5>
|
|
<div class="admin-permissions-grid">
|
|
${allPerms.map(p => html`
|
|
<label class="toggle-label" key=${p}>
|
|
<input type="checkbox" checked=${groupData.permissions.includes(p)}
|
|
onChange=${() => togglePerm(p)} />
|
|
<span class="toggle-track"></span>
|
|
<span>${p}</span>
|
|
</label>
|
|
`)}
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-bottom:16px;">
|
|
<button class="btn-small btn-primary" onClick=${savePermissions}>Save Permissions</button>
|
|
</div>
|
|
|
|
<hr style="border:none;border-top:1px solid var(--border);margin:16px 0;" />
|
|
|
|
<h5 style="margin:0 0 8px;">Members</h5>
|
|
<div class="admin-list">
|
|
${members.map(m => html`
|
|
<div class="admin-surface-row" key=${m.id || m.user_id}>
|
|
<span style="flex:1;">${m.username || m.user_id}</span>
|
|
<button class="btn-small btn-danger" onClick=${() => removeMember(m.user_id || m.id)}>Remove</button>
|
|
</div>
|
|
`)}
|
|
</div>
|
|
${showAddMember
|
|
? html`
|
|
<form class="admin-inline-form" style="margin-top:8px;" onSubmit=${addMember}>
|
|
<div class="form-row">
|
|
<div class="form-group"><label>User</label>
|
|
<select name="userId">
|
|
<option value="">Select\u2026</option>
|
|
${allUsers.map(u => html`<option key=${u.id} value=${u.id}>${u.username}</option>`)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-row" style="margin-top:8px;gap:8px;">
|
|
<button type="submit" class="btn-small btn-primary">Add</button>
|
|
<button type="button" class="btn-small" onClick=${() => setShowAddMember(false)}>Cancel</button>
|
|
</div>
|
|
</form>
|
|
`
|
|
: html`<button class="btn-small" style="margin-top:8px;" onClick=${() => setShowAddMember(true)}>+ Add Member</button>`
|
|
}
|
|
</div>
|
|
`;
|
|
|
|
return html`
|
|
<div>
|
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
|
<div style="flex:1"></div>
|
|
<button class="btn-md btn-primary" onClick=${() => setShowCreate(true)}>+ Add</button>
|
|
</div>
|
|
|
|
${showCreate && html`
|
|
<form class="admin-inline-form" style="margin-bottom:16px;" onSubmit=${createGroup}>
|
|
<div class="form-row">
|
|
<div class="form-group"><label>Name</label><input name="name" placeholder="Group name" /></div>
|
|
<div class="form-group"><label>Description</label><input name="desc" placeholder="Optional" /></div>
|
|
<div class="form-group"><label>Scope</label>
|
|
<select name="scope"><option value="global">Global</option><option value="team">Team</option></select>
|
|
</div>
|
|
</div>
|
|
<div class="form-row" style="margin-top:8px;gap:8px;">
|
|
<button type="submit" class="btn-small btn-primary">Create</button>
|
|
<button type="button" class="btn-small" onClick=${() => setShowCreate(false)}>Cancel</button>
|
|
</div>
|
|
</form>
|
|
`}
|
|
|
|
<div class="admin-list">
|
|
${groups.length === 0 && html`<div class="empty-hint">No groups</div>`}
|
|
${groups.map(g => html`
|
|
<div class="admin-surface-row" key=${g.id} style="cursor:pointer;" onClick=${() => openDetail(g)}>
|
|
<strong style="flex:1;">${g.name}</strong>
|
|
<span class="text-muted" style="font-size:12px;">${g.scope}</span>
|
|
</div>
|
|
`)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|