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:
1 parent
302a5cb023
commit
596365b805
5 files changed
+227
-11
No files matched your search
@@ -475,6 +475,83 @@ def set_permissions():
|
||||
return jsonify({'status': 'ok', 'stat': _path_stat(safe_path)})
|
||||
|
||||
|
||||
# ===== GROUPS =====
|
||||
|
||||
_GROUP_WHITELIST = {'root', 'staff', 'sambashare', 'www-data'}
|
||||
|
||||
|
||||
def _list_groups():
|
||||
groups = []
|
||||
with open('/etc/group', 'r') as f:
|
||||
for line in f:
|
||||
parts = line.strip().split(':')
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
name, gid_str, members_str = parts[0], parts[2], parts[3]
|
||||
try:
|
||||
gid = int(gid_str)
|
||||
except ValueError:
|
||||
continue
|
||||
members = [m for m in members_str.split(',') if m]
|
||||
if gid >= 1000 or name in _GROUP_WHITELIST:
|
||||
groups.append({'name': name, 'gid': gid, 'members': members})
|
||||
return sorted(groups, key=lambda g: g['name'])
|
||||
|
||||
|
||||
@app.route('/api/groups', methods=['GET'])
|
||||
@require_auth
|
||||
def list_groups():
|
||||
return jsonify(_list_groups())
|
||||
|
||||
|
||||
@app.route('/api/groups', methods=['POST'])
|
||||
@require_auth
|
||||
def create_group():
|
||||
name = (request.json or {}).get('name', '')
|
||||
if not VALID_NAME.match(name):
|
||||
return jsonify({'error': 'invalid group name'}), 400
|
||||
r = subprocess.run(['groupadd', name], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
return jsonify({'error': r.stderr.strip() or 'groupadd failed'}), 400
|
||||
return jsonify({'status': 'created'})
|
||||
|
||||
|
||||
@app.route('/api/groups/<name>', methods=['DELETE'])
|
||||
@require_auth
|
||||
def delete_group(name):
|
||||
if not VALID_NAME.match(name):
|
||||
return jsonify({'error': 'invalid group name'}), 400
|
||||
r = subprocess.run(['groupdel', name], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
return jsonify({'error': r.stderr.strip() or 'groupdel failed'}), 500
|
||||
return jsonify({'status': 'deleted'})
|
||||
|
||||
|
||||
@app.route('/api/groups/<name>/members', methods=['POST'])
|
||||
@require_auth
|
||||
def add_group_member(name):
|
||||
if not VALID_NAME.match(name):
|
||||
return jsonify({'error': 'invalid group name'}), 400
|
||||
user = (request.json or {}).get('user', '')
|
||||
if not VALID_NAME.match(user):
|
||||
return jsonify({'error': 'invalid username'}), 400
|
||||
r = subprocess.run(['gpasswd', '-a', user, name], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
return jsonify({'error': r.stderr.strip() or 'gpasswd failed'}), 500
|
||||
return jsonify({'status': 'added'})
|
||||
|
||||
|
||||
@app.route('/api/groups/<name>/members/<user>', methods=['DELETE'])
|
||||
@require_auth
|
||||
def remove_group_member(name, user):
|
||||
if not VALID_NAME.match(name) or not VALID_NAME.match(user):
|
||||
return jsonify({'error': 'invalid name'}), 400
|
||||
r = subprocess.run(['gpasswd', '-d', user, name], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
return jsonify({'error': r.stderr.strip() or 'gpasswd failed'}), 500
|
||||
return jsonify({'status': 'removed'})
|
||||
|
||||
|
||||
# ===== USERS =====
|
||||
|
||||
def _smb_users():
|
||||
|
||||
+2
-1
@@ -17,4 +17,5 @@ function _startRefreshCountdown() {
|
||||
loadDashboard();
|
||||
loadShares();
|
||||
loadUsers();
|
||||
setInterval(() => { loadDashboard(); loadShares(); loadUsers(); }, REFRESH_INTERVAL * 1000);
|
||||
loadGroups();
|
||||
setInterval(() => { loadDashboard(); loadShares(); loadUsers(); loadGroups(); }, REFRESH_INTERVAL * 1000);
|
||||
+16
-4
@@ -100,22 +100,35 @@ async function addNFSShare() {
|
||||
|
||||
let _editCtx = null;
|
||||
|
||||
function openEditModal(type, share) {
|
||||
async function openEditModal(type, share) {
|
||||
_editCtx = { type, original: share };
|
||||
document.getElementById('editModalTitle').textContent =
|
||||
type === 'smb' ? `Edit SMB Share — ${esc(share.name)}` : `Edit NFS Export — ${esc(share.path)}`;
|
||||
document.getElementById('editModalBody').innerHTML =
|
||||
`<div style="color:var(--muted); padding:.5rem 0">Loading…</div>`;
|
||||
new bootstrap.Modal(document.getElementById('editModal')).show();
|
||||
|
||||
const [ur, gr] = await Promise.all([apiFetch('/api/users'), apiFetch('/api/groups')]);
|
||||
const users = ur ? await ur.json() : [];
|
||||
const groups = gr ? await gr.json() : [];
|
||||
|
||||
const ownerOptions = ['root', ...users.map(u => u.name)]
|
||||
.map(n => `<option value="${esc(n)}">`).join('');
|
||||
const groupOptions = groups.map(g => `<option value="${esc(g.name)}">`).join('');
|
||||
|
||||
const permSection = `
|
||||
<datalist id="ownerList">${ownerOptions}</datalist>
|
||||
<datalist id="groupList">${groupOptions}</datalist>
|
||||
<hr style="border-color:var(--border); margin:1.25rem 0 1rem">
|
||||
<div class="fw-semibold mb-3" style="font-size:.9rem; color:var(--text)">Folder Permissions</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-5">
|
||||
<label class="form-label" style="color:var(--muted); font-size:.85rem">Owner</label>
|
||||
<input type="text" id="editOwner" class="form-control" value="${esc(share.owner || '')}" placeholder="root">
|
||||
<input type="text" id="editOwner" class="form-control" value="${esc(share.owner || '')}" placeholder="root" list="ownerList">
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<label class="form-label" style="color:var(--muted); font-size:.85rem">Group</label>
|
||||
<input type="text" id="editGroup" class="form-control" value="${esc(share.group || '')}" placeholder="root">
|
||||
<input type="text" id="editGroup" class="form-control" value="${esc(share.group || '')}" placeholder="root" list="groupList">
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<label class="form-label" style="color:var(--muted); font-size:.85rem">Mode</label>
|
||||
@@ -167,7 +180,6 @@ function openEditModal(type, share) {
|
||||
}
|
||||
|
||||
document.getElementById('editModalBody').innerHTML = body;
|
||||
new bootstrap.Modal(document.getElementById('editModal')).show();
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
|
||||
+116
-5
@@ -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">×</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();
|
||||
}
|
||||
+16
-1
@@ -202,7 +202,7 @@
|
||||
<div class="col-md-2"><button type="button" class="btn btn-success w-100" onclick="createUser()">Create</button></div>
|
||||
</div>
|
||||
<div class="fw-semibold mb-2">Users</div>
|
||||
<div class="table-responsive">
|
||||
<div class="table-responsive mb-5">
|
||||
<table class="table table-striped mb-0">
|
||||
<thead><tr><th>Username</th><th>UID</th><th>SMB Account</th><th>Actions</th></tr></thead>
|
||||
<tbody id="usersTableBody">
|
||||
@@ -210,6 +210,21 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="fw-semibold mb-3">Create Group</div>
|
||||
<div class="row g-2 mb-4">
|
||||
<div class="col-md-3"><input type="text" id="newGroupName" class="form-control" placeholder="Group name"></div>
|
||||
<div class="col-md-2"><button type="button" class="btn btn-success w-100" onclick="createGroup()">Create</button></div>
|
||||
</div>
|
||||
<div class="fw-semibold mb-2">Groups</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped mb-0">
|
||||
<thead><tr><th>Group</th><th>GID</th><th>Members</th><th>Actions</th></tr></thead>
|
||||
<tbody id="groupsTableBody">
|
||||
<tr><td colspan="4" style="color:var(--muted)">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user