From 596365b80566e28cbfebb92ebf66bb00769dd1b6 Mon Sep 17 00:00:00 2001 From: marc Date: Sat, 20 Jun 2026 00:47:43 +0200 Subject: [PATCH] Add group management and owner/group dropdowns in permission editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/: delete group (groupdel) - POST /api/groups//members: add user to group (gpasswd -a) - DELETE /api/groups//members/: 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 --- app.py | 77 +++++++++++++++++++++++++++ static/js/main.js | 3 +- static/js/shares.js | 20 +++++-- static/js/users.js | 121 +++++++++++++++++++++++++++++++++++++++++-- templates/index.html | 17 +++++- 5 files changed, 227 insertions(+), 11 deletions(-) diff --git a/app.py b/app.py index 35fd368..cb8c7a6 100644 --- a/app.py +++ b/app.py @@ -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/', 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//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//members/', 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(): diff --git a/static/js/main.js b/static/js/main.js index b82f79c..0170afe 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -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); diff --git a/static/js/shares.js b/static/js/shares.js index 28d896d..5a44113 100644 --- a/static/js/shares.js +++ b/static/js/shares.js @@ -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 = + `
Loading…
`; + 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 => `