Add group management and owner/group dropdowns in permission editor

Backend:
- _list_groups(): reads /etc/group, returns groups with gid >= 1000
  plus a small whitelist (root, staff, sambashare, www-data)
- GET/POST /api/groups: list and create groups (groupadd)
- DELETE /api/groups/<name>: delete group (groupdel)
- POST /api/groups/<name>/members: add user to group (gpasswd -a)
- DELETE /api/groups/<name>/members/<user>: remove user (gpasswd -d)

Frontend:
- Users tab now has a Groups section: table with GID, member badges
  with × remove buttons, per-row "add member" select populated with
  users not already in the group, delete group button
- loadUsers() caches result in _cachedUsers for groups table to use
- openEditModal is now async; fetches /api/users + /api/groups before
  rendering so Owner and Group inputs have datalist autocomplete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Sonnet 4.6 committed 2026-06-20 00:47:43 +02:00
1 parent 302a5cb023
commit 596365b805
5 files changed
+227 -11

No files matched your search

+116 -5
View File
@@ -1,14 +1,16 @@
// Cached user list — used by groups table to populate "add member" selects
let _cachedUsers = [];
async function loadUsers() {
const res = await apiFetch('/api/users');
if (!res) return;
const users = await res.json();
_cachedUsers = await res.json();
const tb = document.getElementById('usersTableBody');
if (!users.length) {
if (!_cachedUsers.length) {
tb.innerHTML = `<tr><td colspan="4" style="color:var(--muted)">No users found.</td></tr>`;
return;
}
tb.innerHTML = users.map(u => {
// esc() HTML-encodes JSON so " inside onclick="..." doesn't break the attribute
tb.innerHTML = _cachedUsers.map(u => {
const nameJson = esc(JSON.stringify(u.name));
return `<tr>
<td>${esc(u.name)}</td>
@@ -45,12 +47,12 @@ async function createUser() {
document.getElementById('newUsername').value = '';
document.getElementById('newPassword').value = '';
loadUsers();
loadGroups();
}
function confirmDeleteUser(name) {
document.getElementById('deleteModalMsg').textContent =
`Are you sure you want to delete user "${name}"? This removes both the system account and SMB access.`;
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
const btn = document.getElementById('deleteModalConfirm');
const handler = async () => {
@@ -62,6 +64,7 @@ function confirmDeleteUser(name) {
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
showToast(`User "${name}" deleted.`);
loadUsers();
loadGroups();
};
btn.addEventListener('click', handler);
modal.show();
@@ -91,3 +94,111 @@ async function submitPassword() {
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
showToast(`Password updated for "${name}".`);
}
// ===== GROUPS =====
async function loadGroups() {
const res = await apiFetch('/api/groups');
if (!res) return;
const groups = await res.json();
const tb = document.getElementById('groupsTableBody');
if (!groups.length) {
tb.innerHTML = `<tr><td colspan="4" style="color:var(--muted)">No groups found.</td></tr>`;
return;
}
tb.innerHTML = groups.map(g => {
const gJson = esc(JSON.stringify(g.name));
const memberBadges = g.members.length
? g.members.map(m => `
<span class="badge me-1 mb-1 d-inline-flex align-items-center gap-1"
style="background:var(--surf2); color:var(--text); font-weight:400">
${esc(m)}
<button onclick="removeMember(${gJson},${esc(JSON.stringify(m))})"
style="background:none; border:none; color:var(--muted); cursor:pointer; padding:0; line-height:1; font-size:.8rem">&times;</button>
</span>`).join('')
: `<span style="color:var(--muted)">—</span>`;
const nonMembers = _cachedUsers.filter(u => !g.members.includes(u.name));
const addSelect = nonMembers.length
? `<select id="addMember-${esc(g.name)}" class="form-select form-select-sm d-inline-block me-1" style="width:auto; max-width:130px">
${nonMembers.map(u => `<option value="${esc(u.name)}">${esc(u.name)}</option>`).join('')}
</select>
<button class="btn btn-sm me-2" style="border:1px solid var(--border); color:var(--muted)"
onclick="addMember(${gJson})">Add</button>`
: '';
return `<tr>
<td>${esc(g.name)}</td>
<td style="color:var(--muted)">${g.gid}</td>
<td>${memberBadges}</td>
<td>
${addSelect}
<button class="btn btn-sm btn-danger"
onclick="confirmDeleteGroup(${gJson})">Delete</button>
</td>
</tr>`;
}).join('');
}
async function createGroup() {
const name = document.getElementById('newGroupName').value.trim();
if (!name) return showToast('Group name is required.', 'danger');
const res = await apiFetch('/api/groups', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name}),
});
if (!res) return;
const data = await res.json();
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
showToast(`Group "${name}" created.`);
document.getElementById('newGroupName').value = '';
loadGroups();
}
function confirmDeleteGroup(name) {
document.getElementById('deleteModalMsg').textContent =
`Delete group "${name}"? Users in this group won't be deleted, just removed from it.`;
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
const btn = document.getElementById('deleteModalConfirm');
const handler = async () => {
btn.removeEventListener('click', handler);
modal.hide();
const res = await apiFetch(`/api/groups/${encodeURIComponent(name)}`, { method: 'DELETE' });
if (!res) return;
const data = await res.json();
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
showToast(`Group "${name}" deleted.`);
loadGroups();
};
btn.addEventListener('click', handler);
modal.show();
}
async function addMember(groupName) {
const sel = document.getElementById(`addMember-${groupName}`);
if (!sel) return;
const user = sel.value;
const res = await apiFetch(`/api/groups/${encodeURIComponent(groupName)}/members`, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({user}),
});
if (!res) return;
const data = await res.json();
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
showToast(`${user} added to ${groupName}.`);
loadGroups();
}
async function removeMember(groupName, userName) {
const res = await apiFetch(
`/api/groups/${encodeURIComponent(groupName)}/members/${encodeURIComponent(userName)}`,
{ method: 'DELETE' }
);
if (!res) return;
const data = await res.json();
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
showToast(`${userName} removed from ${groupName}.`);
loadGroups();
}