From 822e6b3b87c979576419766d70cdbd8e54a7f9d5 Mon Sep 17 00:00:00 2001 From: marc Date: Fri, 19 Jun 2026 22:14:10 +0200 Subject: [PATCH] Phase 2.5: UI redesign, inline quotas, toasts, Users tab - Full UI redesign: GitHub-dark palette, custom CSS vars, better contrast, card layout, cleaner tables and typography - Dashboard auto-refresh every 30s (was 60s) - Remove Quotas tab; each share row now has a Quota button that opens a modal pre-filled with the share's valid users (SMB) or path (NFS) - Replace all alert() with Bootstrap toast notifications - Add Users tab: list system users (uid >= 1000) with SMB status, create user (useradd + smbpasswd), delete user, change SMB password - Backend: /api/users GET/POST, /api/users/ DELETE, /api/users//password POST Co-Authored-By: Claude Sonnet 4.6 --- PLANNING.md | 38 +++- app.py | 95 ++++++++ index.html | 608 +++++++++++++++++++++++++++++++++++++--------------- 3 files changed, 561 insertions(+), 180 deletions(-) diff --git a/PLANNING.md b/PLANNING.md index 47b81b1..0828cb0 100644 --- a/PLANNING.md +++ b/PLANNING.md @@ -64,19 +64,40 @@ on a second client). This app does not implement backup. ### Phase 2 — Dashboard & health (the main goal) A landing page that shows the state of the server at a glance: -- [ ] **Daemon status** — is `smbd` running? is `nfsd` running? (green/red). -- [ ] **Disk usage** — free / used / total for `/srv` (and per-disk if simple). -- [ ] **All shares in one view** — every SMB share and NFS export, their paths, +- [x] **Daemon status** — is `smbd` running? is `nfsd` running? (green/red). +- [x] **Disk usage** — free / used / total for `/srv`. +- [x] **All shares in one view** — every SMB share and NFS export, their paths, and who can access them. -- [ ] **Per-share data used** — size on disk of each share's path (`du`). -- [ ] **Quota usage** — for each user with a quota: used vs limit, with a - progress bar (e.g. "12 GB / 50 GB"). -- [ ] Auto-refresh or a manual refresh button. +- [x] **Per-share data used** — size on disk of each share's path (`du`). +- [x] **Quota usage** — for each user with a quota: used vs limit, with a + progress bar. +- [x] Auto-refresh every 30 seconds + manual refresh button. + +### Phase 2.5 — UX improvements (next up) +Improvements identified after first real test: + +- [x] **UI redesign** — GitHub-dark palette, better contrast, card layout, + breathing room. Custom CSS variables, no extra dependencies. +- [x] **Inline quota management** — removed dedicated Quotas tab. Each SMB + share and NFS export row has a "Quota" button that opens a modal. + For SMB: lists valid_users with individual GB inputs. For NFS: manual + user + GB. Submits one API call per user in parallel. +- [x] **Toast notifications** — replaced all `alert()` with Bootstrap toasts. +- [x] **Auto-refresh every 30 s** (was 60 s). +- [ ] **User management tab** — create, delete, and change the SMB password + of NAS users without touching the command line: + - List all system users (uid ≥ 1000), show whether they have a Samba + account. + - Create user: `useradd -M -s /usr/sbin/nologin` + `smbpasswd -a`. + - Delete user: `smbpasswd -x` + `userdel`. + - Change SMB password: `smbpasswd -s` via modal. + - Backend: `/api/users` GET/POST, `/api/users/` DELETE, + `/api/users//password` POST. ### Phase 3 — Full management (complete the CRUD) - [ ] Working **delete** for SMB & NFS (parse config into blocks, rewrite the whole file — not append-only). -- [ ] **Edit** existing shares. +- [ ] **Edit** existing shares (path, valid users). - [ ] Enable/disable a share without deleting it. ### Phase 4 — Nice-to-have @@ -84,7 +105,6 @@ A landing page that shows the state of the server at a glance: app drop an Apache/nginx WebDAV config snippet, same pattern as SMB/NFS, so it reuses the existing architecture. - [ ] Simple in-browser file browser for `/srv`. -- [ ] Toast notifications instead of `alert()`. --- diff --git a/app.py b/app.py index 31a8be5..6f74a58 100644 --- a/app.py +++ b/app.py @@ -220,6 +220,101 @@ def set_quota(): return jsonify({'status': 'ok'}) +# ===== USERS ===== + +def _smb_users(): + try: + r = subprocess.run(['pdbedit', '-L'], capture_output=True, text=True) + return {line.split(':')[0].strip() for line in r.stdout.splitlines() if ':' in line} + except FileNotFoundError: + return set() + + +def _list_users(): + smb = _smb_users() + users = [] + with open('/etc/passwd', 'r') as f: + for line in f: + parts = line.strip().split(':') + if len(parts) < 4: + continue + name, _, uid = parts[0], parts[1], parts[2] + uid = int(uid) + if uid < 1000 or uid == 65534: + continue + users.append({'name': name, 'uid': uid, 'smb': name in smb}) + return users + + +def _set_smb_password(name, password): + r = subprocess.run( + ['smbpasswd', '-a', '-s', name], + input=f'{password}\n{password}\n', + capture_output=True, text=True, + ) + if r.returncode != 0: + raise RuntimeError(r.stderr.strip() or 'smbpasswd failed') + + +@app.route('/api/users', methods=['GET']) +@require_auth +def list_users(): + return jsonify(_list_users()) + + +@app.route('/api/users', methods=['POST']) +@require_auth +def create_user(): + data = request.json or {} + name = data.get('name', '') + password = data.get('password', '') + + if not VALID_NAME.match(name): + return jsonify({'error': 'invalid username (letters, digits, _ -)'}), 400 + if len(password) < 8: + return jsonify({'error': 'password must be at least 8 characters'}), 400 + + try: + subprocess.run(['useradd', '-M', '-s', '/usr/sbin/nologin', name], check=True) + except subprocess.CalledProcessError: + return jsonify({'error': f'user {name!r} already exists or useradd failed'}), 400 + try: + _set_smb_password(name, password) + except RuntimeError as e: + return jsonify({'error': str(e)}), 500 + + return jsonify({'status': 'created'}) + + +@app.route('/api/users/', methods=['DELETE']) +@require_auth +def delete_user(name): + if not VALID_NAME.match(name): + return jsonify({'error': 'invalid username'}), 400 + subprocess.run(['smbpasswd', '-x', name], capture_output=True) + try: + subprocess.run(['userdel', name], check=True) + except subprocess.CalledProcessError as e: + return jsonify({'error': f'userdel failed: {e}'}), 500 + return jsonify({'status': 'deleted'}) + + +@app.route('/api/users//password', methods=['POST']) +@require_auth +def change_password(name): + if not VALID_NAME.match(name): + return jsonify({'error': 'invalid username'}), 400 + data = request.json or {} + password = data.get('password', '') + if len(password) < 8: + return jsonify({'error': 'password must be at least 8 characters'}), 400 + try: + _set_smb_password(name, password) + except RuntimeError as e: + return jsonify({'error': str(e)}), 500 + return jsonify({'status': 'updated'}) + + # ===== DASHBOARD ===== def _daemon_status(): diff --git a/index.html b/index.html index 57721c5..751084f 100644 --- a/index.html +++ b/index.html @@ -4,16 +4,120 @@ MicroNAS + - + -
-
-

MicroNAS

- + +
+ + + -