Feat admin rbac migration (#1)
Some checks failed
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / build-and-deploy (push) Has been cancelled
CI/CD / test-sqlite (push) Has been cancelled
CI/CD / test-go-pg (push) Has been cancelled

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-03-26 17:30:36 +00:00
committed by xcaliber
parent b9180d4184
commit 5836ddad21
37 changed files with 381 additions and 547 deletions

View File

@@ -166,7 +166,7 @@ export function createDomains(restClient) {
create: (data) => rc.post('/api/v1/admin/users', data),
del: (id) => rc.del(`/api/v1/admin/users/${id}`),
resetPassword: (id, pw) => rc.post(`/api/v1/admin/users/${id}/reset-password`, { password: pw }),
updateRole: (id, role) => rc.put(`/api/v1/admin/users/${id}/role`, { role }),
updateRole: (id, isAdmin) => rc.put(`/api/v1/admin/users/${id}/role`, { is_admin: isAdmin }),
toggleActive: (id, active, opts) => rc.put(`/api/v1/admin/users/${id}/active`, { is_active: active, ...opts }),
permissions: (id) => rc.get(`/api/v1/admin/users/${id}/permissions`),
resetVault: (id) => rc.post(`/api/v1/admin/users/${id}/vault/reset`, {}),

View File

@@ -11,7 +11,6 @@ export default function GroupsSection() {
const [members, setMembers] = useState([]);
const [allPerms, setAllPerms] = useState([]);
const [allUsers, setAllUsers] = useState([]);
const [allModels, setAllModels] = useState([]);
const [groupData, setGroupData] = useState({});
const [showCreate, setShowCreate] = useState(false);
const [showAddMember, setShowAddMember] = useState(false);
@@ -30,21 +29,16 @@ export default function GroupsSection() {
setDetail(group);
setGroupData({
permissions: group.permissions || [],
token_budget_daily: group.token_budget_daily ?? '',
token_budget_monthly: group.token_budget_monthly ?? '',
allowed_models: group.allowed_models || [],
});
try {
const [m, p, u, models] = await Promise.all([
const [m, p, u] = await Promise.all([
sw.api.admin.groups.members(group.id),
sw.api.admin.permissions.list(),
sw.api.admin.users.list(),
sw.api.admin.models.list().catch(() => []),
]);
setMembers(m || []);
setAllPerms(p.permissions || []);
setAllUsers(u || []);
setAllModels(models || []);
} catch (e) { sw.toast(e.message, 'error'); }
}
@@ -63,13 +57,10 @@ export default function GroupsSection() {
} catch (e) { sw.toast(e.message, 'error'); }
}
async function savePermsAndBudgets() {
async function savePermissions() {
try {
await sw.api.admin.groups.update(detail.id, {
permissions: groupData.permissions,
token_budget_daily: groupData.token_budget_daily || null,
token_budget_monthly: groupData.token_budget_monthly || null,
allowed_models: groupData.allowed_models.length ? groupData.allowed_models : null,
});
sw.toast('Saved', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
@@ -84,15 +75,6 @@ export default function GroupsSection() {
});
}
function toggleModel(modelId) {
setGroupData(prev => {
const list = prev.allowed_models.includes(modelId)
? prev.allowed_models.filter(m => m !== modelId)
: [...prev.allowed_models, modelId];
return { ...prev, allowed_models: list };
});
}
async function addMember(e) {
e.preventDefault();
const uid = e.target.userId.value;
@@ -151,37 +133,8 @@ export default function GroupsSection() {
</div>
</div>
<div class="settings-section" style="margin-bottom:16px;">
<h5 style="margin:0 0 8px;">Token Budgets</h5>
<div class="form-row" style="gap:12px;">
<div class="form-group" style="flex:1;"><label>Daily limit</label>
<input type="number" placeholder="Unlimited" min="0" value=${groupData.token_budget_daily}
onInput=${e => setGroupData(p => ({ ...p, token_budget_daily: e.target.value ? Number(e.target.value) : '' }))} />
</div>
<div class="form-group" style="flex:1;"><label>Monthly limit</label>
<input type="number" placeholder="Unlimited" min="0" value=${groupData.token_budget_monthly}
onInput=${e => setGroupData(p => ({ ...p, token_budget_monthly: e.target.value ? Number(e.target.value) : '' }))} />
</div>
</div>
</div>
<div class="settings-section" style="margin-bottom:16px;">
<h5 style="margin:0 0 8px;">Allowed Models</h5>
<p class="text-muted" style="font-size:12px;margin:0 0 8px;">Leave empty for unrestricted.</p>
<div class="admin-models-grid">
${allModels.map(m => html`
<label class="toggle-label" key=${m.id || m.model_id}>
<input type="checkbox" checked=${groupData.allowed_models.includes(m.model_id || m.id)}
onChange=${() => toggleModel(m.model_id || m.id)} />
<span class="toggle-track"></span>
<span>${m.display_name || m.model_id || m.id}</span>
</label>
`)}
</div>
</div>
<div style="margin-bottom:16px;">
<button class="btn-small btn-primary" onClick=${savePermsAndBudgets}>Save Permissions & Budgets</button>
<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;" />

View File

@@ -33,7 +33,7 @@ export default function UsersSection() {
username: form.username.value.trim(),
email: form.email.value.trim(),
password: form.password.value,
role: form.role.value,
is_admin: form.is_admin.checked,
};
if (!data.username || !data.password) return sw.toast('Username and password required', 'error');
try {
@@ -52,10 +52,10 @@ export default function UsersSection() {
} catch (e) { sw.toast(e.message, 'error'); }
}
async function changeRole(id, role) {
async function toggleAdmin(id, isAdmin) {
try {
await sw.api.admin.users.updateRole(id, role);
sw.toast(`Role updated to ${role}`, 'success');
await sw.api.admin.users.updateRole(id, isAdmin);
sw.toast(isAdmin ? 'Admin granted' : 'Admin revoked', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
@@ -112,8 +112,8 @@ export default function UsersSection() {
<div class="form-group"><label>Username</label><input name="username" placeholder="username" autocomplete="off" /></div>
<div class="form-group"><label>Email</label><input name="email" placeholder="email@example.com" autocomplete="off" /></div>
<div class="form-group"><label>Password</label><input name="password" type="password" placeholder="min 8 chars" autocomplete="new-password" /></div>
<div class="form-group"><label>Role</label>
<select name="role"><option value="user">User</option><option value="admin">Admin</option></select>
<div class="form-group" style="align-self:flex-end;">
<label class="toggle-label"><input type="checkbox" name="is_admin" /><span class="toggle-track"></span><span>Admin</span></label>
</div>
</div>
<div class="form-row" style="margin-top:8px;gap:8px;">
@@ -131,17 +131,11 @@ export default function UsersSection() {
<strong>${u.username}</strong>
<span class="text-muted" style="margin-left:8px;font-size:12px;">${u.email || ''}</span>
${' '}${statusBadge(u)}
${' '}<span class="badge ${u.role === 'admin' ? 'badge-admin' : ''}">${u.role}</span>
</div>
<div class="admin-actions-cell">
${u.status === 'pending' && html`
<button class="btn-small btn-primary" onClick=${() => approveUser(u.id)}>Approve</button>
`}
<select value=${u.role} onChange=${e => changeRole(u.id, e.target.value)}
style="font-size:11px;padding:2px 4px;">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
${u.is_active
? html`<button class="btn-small" onClick=${() => toggleActive(u.id, false)}>Disable</button>`
: html`<button class="btn-small" onClick=${() => toggleActive(u.id, true)}>Enable</button>`