From 5d56b0357983496654d2a4a4154512a7da3d58c4 Mon Sep 17 00:00:00 2001 From: neptunehub Date: Fri, 10 Jul 2026 11:00:08 +0200 Subject: [PATCH 1/2] User Management security improvement --- app_auth.py | 262 ++++++++--- app_backup.py | 14 + database.py | 9 + docs/AUTH.md | 14 + static/users.js | 138 +++++- templates/users.html | 41 ++ .../test_auth_barrier_integration.py | 205 +++++++- .../test_auth_users_integration.py | 60 ++- test/unit/test_app_auth.py | 436 +++++++++++++++++- 9 files changed, 1097 insertions(+), 82 deletions(-) diff --git a/app_auth.py b/app_auth.py index 5d28c0b3..effee0f2 100644 --- a/app_auth.py +++ b/app_auth.py @@ -17,6 +17,12 @@ * Role constants, password hashing, and CRUD helpers for user accounts. * The ``check_setup_needed`` / ``check_auth_needed`` / ``check_admin_needed`` barrier guards and the ``/login``, ``/auth``, ``/logout``, ``/api/users`` routes. +* Sessions validated against the users table on every request: deleting a + user or changing a password revokes that user's live JWT sessions, and the + row's role is authoritative over token claims. +* User creation, password changes, and user deletion require the acting + session user to confirm with their own password (bearer-token callers + exempt). * One-shot legacy env -> users-table seed and the startup JWT-secret resolution. """ @@ -168,6 +174,19 @@ def get_additional_user_by_id(user_id): } +def _password_stamp(): + """App-clock UTC timestamp for ``password_changed_at``, floored to the + whole second so it compares exactly against integer JWT ``iat`` values. + Stamped on every row that gets a (new) password hash - insert, upsert, + and update - so tokens minted before the hash existed never validate, + including tokens for a previously deleted user whose username was + re-created. + """ + return datetime.datetime.now(datetime.timezone.utc).replace( + microsecond=0, tzinfo=None + ) + + def create_additional_user(username, password, role=USER_ROLE_USER): """Create a new user. Returns ``(ok, error_message)``.""" if not isinstance(username, str) or not username.strip(): @@ -191,10 +210,10 @@ def create_additional_user(username, password, role=USER_ROLE_USER): db = _get_db() with db.cursor() as cur: cur.execute( - f"INSERT INTO audiomuse_users (username, password_hash, role, created_at) " - f"VALUES (%s, %s, %s, {UTC_NOW_SQL}) " + f"INSERT INTO audiomuse_users (username, password_hash, role, created_at, password_changed_at) " + f"VALUES (%s, %s, %s, {UTC_NOW_SQL}, %s) " f"ON CONFLICT (username) DO NOTHING RETURNING id", - (username, password_hash, normalized_role), + (username, password_hash, normalized_role, _password_stamp()), ) row = cur.fetchone() db.commit() @@ -261,7 +280,10 @@ def delete_additional_user_safe(user_id): def update_additional_user_password(user_id, new_password): - """Update a user's password. Returns ``(ok, error_message)``.""" + """Update a user's password and stamp ``password_changed_at`` so that + session tokens issued before the change stop validating. + Returns ``(ok, error_message)``. + """ try: user_id = int(user_id) except (TypeError, ValueError): @@ -276,8 +298,8 @@ def update_additional_user_password(user_id, new_password): db = _get_db() with db.cursor() as cur: cur.execute( - "UPDATE audiomuse_users SET password_hash = %s WHERE id = %s", - (password_hash, user_id), + "UPDATE audiomuse_users SET password_hash = %s, password_changed_at = %s WHERE id = %s", + (password_hash, _password_stamp(), user_id), ) updated = cur.rowcount db.commit() @@ -318,6 +340,50 @@ def verify_additional_user(username, password): return _normalize_role(role) or USER_ROLE_USER +def get_session_user(username): + """Return ``{username, role, password_changed_at}`` for a username, or None.""" + if not isinstance(username, str) or not username: + return None + db = _get_db() + with db.cursor(cursor_factory=DictCursor) as cur: + cur.execute( + "SELECT username, role, password_changed_at FROM audiomuse_users WHERE username = %s", + (username,), + ) + row = cur.fetchone() + if not row: + return None + return { + 'username': row['username'], + 'role': _normalize_role(row['role']) or USER_ROLE_USER, + 'password_changed_at': row['password_changed_at'], + } + + +def _confirm_password_error(data): + """Gate for sensitive user-management actions: the acting user must + re-enter their own password as ``current_password``. + + Bearer-token (M2M) callers are exempt: the API token itself is the + credential and there is no account password to confirm with. + Returns an error message, or None when the action is confirmed. + """ + if getattr(g, 'auth_method', None) == 'bearer': + return None + username = getattr(g, 'auth_user', None) + password = data.get('current_password') if isinstance(data, dict) else None + if ( + not isinstance(username, str) + or not username + or not isinstance(password, str) + or not password + ): + return "Current password is required." + if verify_additional_user(username, password) is None: + return "Current password is incorrect." + return None + + def upsert_admin_user(username, password): """Create an admin row, or update the password and force admin role when the username already exists. Returns ``(ok, error_message)``. @@ -338,10 +404,11 @@ def upsert_admin_user(username, password): db = _get_db() with db.cursor() as cur: cur.execute( - f"INSERT INTO audiomuse_users (username, password_hash, role, created_at) " - f"VALUES (%s, %s, %s, {UTC_NOW_SQL}) " - f"ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash, role = 'admin'", - (username, password_hash, USER_ROLE_ADMIN), + f"INSERT INTO audiomuse_users (username, password_hash, role, created_at, password_changed_at) " + f"VALUES (%s, %s, %s, {UTC_NOW_SQL}, %s) " + f"ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash, " + f"role = 'admin', password_changed_at = EXCLUDED.password_changed_at", + (username, password_hash, USER_ROLE_ADMIN, _password_stamp()), ) db.commit() return True, None @@ -397,10 +464,10 @@ def seed_admin_from_env(): try: with db.cursor() as cur: cur.execute( - f"INSERT INTO audiomuse_users (username, password_hash, role, created_at) " - f"VALUES (%s, %s, %s, {UTC_NOW_SQL}) " + f"INSERT INTO audiomuse_users (username, password_hash, role, created_at, password_changed_at) " + f"VALUES (%s, %s, %s, {UTC_NOW_SQL}, %s) " f"ON CONFLICT (username) DO NOTHING", - (user.strip(), password_hash, USER_ROLE_ADMIN), + (user.strip(), password_hash, USER_ROLE_ADMIN, _password_stamp()), ) db.commit() if source == 'app_config': @@ -502,18 +569,81 @@ def check_setup_needed(): return True +def _session_from_token(token, jwt_secret): + """Fully validate a session cookie: JWT signature/expiry, then the user + row in the database. A session is only valid while its user still exists + and the token was issued at or after the user's last password change, so + deleting a user or changing a password revokes their live sessions. + + Returns ``(username, role)`` from the database (authoritative over the + token claims), or None. Fails closed on malformed claims and DB errors. + """ + if not token or not jwt_secret: + return None + try: + payload = pyjwt.decode(token, jwt_secret, algorithms=['HS256']) + except pyjwt.InvalidTokenError: + return None + username = payload.get('sub') + iat = payload.get('iat') + if not isinstance(username, str) or not username or not isinstance(iat, (int, float)): + return None + try: + row = get_session_user(username) + except Exception: + logger.exception("Failed to load session user for token validation") + return None + if row is None: + return None + changed_at = row['password_changed_at'] + if changed_at is not None: + issued_at = datetime.datetime.fromtimestamp( + int(iat), datetime.timezone.utc + ).replace(tzinfo=None) + if issued_at < changed_at: + return None + return row['username'], row['role'] + + +def _issue_session_token(username, role, secret): + now = datetime.datetime.now(datetime.timezone.utc) + payload = { + 'sub': username, + 'role': role, + 'iat': now, + 'exp': now + datetime.timedelta(hours=8), + } + return pyjwt.encode(payload, secret, algorithm='HS256') + + +def _set_session_cookie(resp, token): + resp.set_cookie( + 'audiomuse_jwt', + token, + path='/', + httponly=True, + samesite='Strict', + # Secure when the original request was HTTPS, including via a reverse + # proxy (X-Forwarded-Proto) even when ProxyFix is disabled. + secure=_original_request_is_https(), + max_age=8 * 3600, + ) + + def check_auth_needed(jwt_secret): """Check if the current request requires authentication. Returns None when the request is authenticated or auth is disabled. Returns a Response (redirect or JSON 401) otherwise. - Populates ``flask.g.auth_role`` and ``flask.g.auth_user``. + Populates ``flask.g.auth_role``, ``flask.g.auth_user`` and + ``flask.g.auth_method`` ('session' or 'bearer'). """ import config as _cfg # Default: when auth is disabled every request behaves as an admin. g.auth_role = 'admin' g.auth_user = None + g.auth_method = None if not _cfg.AUTH_ENABLED: return None @@ -522,17 +652,11 @@ def check_auth_needed(jwt_secret): # secret: PyJWT validates HS256 tokens signed with an empty key (it only # warns), so a blank secret would let anyone forge an admin token. Fail # closed by treating a missing secret as "no valid session". - token = request.cookies.get('audiomuse_jwt') - if token and jwt_secret: - try: - payload = pyjwt.decode(token, jwt_secret, algorithms=['HS256']) - # Backward-compat: tokens issued before the multi-user feature - # have no 'role' claim; treat them as admin. - g.auth_role = payload.get('role', 'admin') - g.auth_user = payload.get('sub') - return None - except pyjwt.InvalidTokenError: - pass + session = _session_from_token(request.cookies.get('audiomuse_jwt'), jwt_secret) + if session is not None: + g.auth_user, g.auth_role = session[0], session[1] + g.auth_method = 'session' + return None # Check valid Bearer token (M2M callers) - always admin-equivalent. # Use secrets.compare_digest to avoid leaking token contents via timing. @@ -544,6 +668,7 @@ def check_auth_needed(jwt_secret): ): g.auth_role = 'admin' g.auth_user = None + g.auth_method = 'bearer' return None # Not authenticated @@ -733,14 +858,8 @@ def login_page(): if not _cfg.AUTH_ENABLED: return redirect(url_for('dashboard_bp.dashboard_page')) - token = request.cookies.get('audiomuse_jwt') - secret = _jwt_secret() - if token and secret: - try: - pyjwt.decode(token, secret, algorithms=['HS256']) - return redirect(url_for('dashboard_bp.dashboard_page')) - except pyjwt.InvalidTokenError: - pass + if _session_from_token(request.cookies.get('audiomuse_jwt'), _jwt_secret()) is not None: + return redirect(url_for('dashboard_bp.dashboard_page')) return render_template('login.html', title='Login - AudioMuse-AI') @@ -840,30 +959,13 @@ def auth_endpoint(): current_app.logger.error("Cannot issue session token: JWT secret is not configured.") return jsonify({"error": "Server authentication is misconfigured."}), 500 - now = datetime.datetime.now(datetime.timezone.utc) - payload = { - 'sub': user, - 'role': role, - 'iat': now, - 'exp': now + datetime.timedelta(hours=8), - } - token = pyjwt.encode(payload, secret, algorithm='HS256') + token = _issue_session_token(user, role, secret) if is_ajax: resp = make_response(jsonify({"status": "ok"}), 200) else: resp = make_response(redirect(url_for('dashboard_bp.dashboard_page'))) - resp.set_cookie( - 'audiomuse_jwt', - token, - path='/', - httponly=True, - samesite='Strict', - # Secure when the original request was HTTPS, including via a reverse - # proxy (X-Forwarded-Proto) even when ProxyFix is disabled. - secure=_original_request_is_https(), - max_age=8 * 3600, - ) + _set_session_cookie(resp, token) return resp @@ -958,18 +1060,24 @@ def create_user_endpoint(): tags: - Users summary: Admin-only. Create a new user with role `user` or `admin`. + description: | + Session (cookie) callers must confirm the operation with their own + password in `current_password`; bearer-token callers are exempt. requestBody: required: true content: application/json: schema: type: object - required: [username, password] + required: [username, password, current_password] properties: username: type: string password: type: string + current_password: + type: string + description: The acting admin's own password. Required for session callers. role: type: string enum: [user, admin] @@ -978,7 +1086,7 @@ def create_user_endpoint(): 201: description: User created. 400: - description: Invalid role / missing fields / username conflict. + description: Invalid role / missing fields / missing or wrong current_password / username conflict. 403: description: Caller is not an admin. 404: @@ -998,6 +1106,9 @@ def create_user_endpoint(): return jsonify({"error": "Role must be 'user' or 'admin'."}), 400 if not username or not password: return jsonify({"error": "Username and password are required."}), 400 + confirm_error = _confirm_password_error(data) + if confirm_error: + return jsonify({"error": confirm_error}), 400 ok, err = create_additional_user(username, password, role=role) if not ok: return jsonify({"error": err or "Failed to create user."}), 400 @@ -1011,16 +1122,30 @@ def delete_user_endpoint(user_id): tags: - Users summary: Admin-only. Refuses self-deletion and refuses to remove the last admin. + description: | + Deleting a user immediately invalidates that user's active sessions. + Session (cookie) callers must confirm the operation with their own + password in `current_password`; bearer-token callers are exempt. parameters: - name: user_id in: path required: true schema: { type: integer } + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + current_password: + type: string + description: The acting admin's own password. Required for session callers. responses: 200: description: User deleted. 400: - description: Invalid id, or attempt to delete self / the last admin. + description: Invalid id, missing/wrong current_password, or attempt to delete self / the last admin. 403: description: Caller is not an admin. 404: @@ -1040,6 +1165,9 @@ def delete_user_endpoint(user_id): current_username = getattr(g, 'auth_user', None) if current_username and target['username'] == current_username: return jsonify({"error": "You cannot delete your own account."}), 400 + confirm_error = _confirm_password_error(request.get_json(silent=True)) + if confirm_error: + return jsonify({"error": confirm_error}), 400 status, err = delete_additional_user_safe(user_id) if status == "deleted": return jsonify({"status": "ok"}) @@ -1060,6 +1188,13 @@ def update_user_password_endpoint(user_id): tags: - Users summary: Admin can change anyone's password; non-admin can only change their own. + description: | + Changing a password immediately invalidates the target user's active + sessions. Session (cookie) callers must confirm the operation with + their own password in `current_password` - both for self-service + changes and for admins changing someone else's password; bearer-token + callers are exempt. When users change their own password, the response + sets a fresh session cookie so their current session stays valid. parameters: - name: user_id in: path @@ -1071,18 +1206,18 @@ def update_user_password_endpoint(user_id): application/json: schema: type: object - required: [password] + required: [password, current_password] properties: password: type: string current_password: type: string - description: Required when a non-admin updates their own password. + description: The acting user's own password. Required for session callers. responses: 200: description: Password updated. 400: - description: Validation error (weak password, wrong current_password, etc.). + description: Validation error (missing password, missing/wrong current_password, etc.). 403: description: Forbidden (non-admin updating someone else). 404: @@ -1103,10 +1238,19 @@ def update_user_password_endpoint(user_id): new_password = data.get('password') or '' if not isinstance(new_password, str) or not new_password: return jsonify({"error": "Password is required."}), 400 + confirm_error = _confirm_password_error(data) + if confirm_error: + return jsonify({"error": confirm_error}), 400 ok, err = update_additional_user_password(user_id, new_password) if not ok: return jsonify({"error": err or "Failed to update password."}), 400 - return jsonify({"status": "ok"}) + resp = jsonify({"status": "ok"}) + if current_username and target['username'] == current_username: + secret = _jwt_secret() + if secret: + role_now = _normalize_role(target.get('role')) or USER_ROLE_USER + _set_session_cookie(resp, _issue_session_token(current_username, role_now, secret)) + return resp # --- Flask registration ----------------------------------------------------- diff --git a/app_backup.py b/app_backup.py index 174a6083..e09776f9 100644 --- a/app_backup.py +++ b/app_backup.py @@ -231,6 +231,20 @@ def _run_restore_runner(dump_file, log_file): log.write(f"Restore command finished with return code {ret}\n") log.flush() + if ret == 0: + try: + from database import USERS_PASSWORD_CHANGED_AT_DDL + + ensure_cmd = _pg_cmd('psql', '-d', POSTGRES_DB, '-c', USERS_PASSWORD_CHANGED_AT_DDL) + ensure_ret = subprocess.run( + ensure_cmd, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=120 + ).returncode + log.write(f"Ensured users session schema after restore (rc={ensure_ret}).\n") + log.flush() + except Exception as exc: + log.write(f"Could not ensure users session schema after restore: {exc}\n") + log.flush() + try: try: restart_manager.publish_start_request() diff --git a/database.py b/database.py index 2718eede..6fada39f 100644 --- a/database.py +++ b/database.py @@ -49,6 +49,14 @@ TASK_HISTORY_MAX_ROWS = 10 MAX_LOG_ENTRIES_STORED = 10 +# Session validation reads this column on every request; the backup restore +# runner re-applies it after loading a dump taken on an older schema, since +# on that path Flask may keep running without re-entering init_db. +USERS_PASSWORD_CHANGED_AT_DDL = ( + "ALTER TABLE IF EXISTS audiomuse_users " + "ADD COLUMN IF NOT EXISTS password_changed_at TIMESTAMP" +) + MAP_PROJECTION_CACHE = None _embedded_server = None @@ -982,6 +990,7 @@ def init_db(): cur.execute( "ALTER TABLE audiomuse_users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'user'" ) + cur.execute(USERS_PASSWORD_CHANGED_AT_DDL) cur.execute( "CREATE TABLE IF NOT EXISTS dashboard_stats (" "id INTEGER PRIMARY KEY, " diff --git a/docs/AUTH.md b/docs/AUTH.md index f1a459ff..1493cf78 100644 --- a/docs/AUTH.md +++ b/docs/AUTH.md @@ -18,6 +18,20 @@ curl -v \ -d '{}' ``` +## Session validation and revocation + +Every request authenticated with the JWT cookie is also validated against the `audiomuse_users` table: + +- The user in the token must still exist; deleting a user immediately terminates their active sessions. +- Changing a user's password immediately invalidates every session token issued before the change (the token's issue time is compared with the `password_changed_at` column). When you change your own password, the response sets a fresh cookie so your current session keeps working; other devices are logged out. +- The role stored in the database wins over the role claim inside the token, so a stale token can never keep more privileges than the account currently has. + +## Confirming sensitive operations + +Creating a user, changing any password (your own, or - as an admin - another user's), and deleting a user require the acting user to re-enter their own password. The Users page asks for it in a dedicated confirmation field; API callers send it as `current_password` in the JSON body of `POST /api/users`, `PUT /api/users//password` and `DELETE /api/users/`. + +Bearer-token (`API_TOKEN`) callers are exempt from `current_password`: the token itself is the credential and it is not tied to an account password. + # Password reset If you have lost access to all admin accounts, reset admin access by deleting both the legacy admin config entries and the admin rows in `audiomuse_users`. diff --git a/static/users.js b/static/users.js index e1348a79..d62f5819 100644 --- a/static/users.js +++ b/static/users.js @@ -3,18 +3,22 @@ // - only admins can create/delete accounts or change another user's password // - non-admins only see themselves in /api/users and can only change their // own password +// - user creation, password changes, and deletions require the acting user +// to confirm with their own password (sent as current_password) // Mirrored in the UI so admins see everyone plus create/delete controls, // and non-admins only see their own row with a "Change password" button. (function () { const nameInput = document.getElementById('additional-user-name'); const passInput = document.getElementById('additional-user-password'); const passConfirmInput = document.getElementById('additional-user-password-confirm'); + const currentInput = document.getElementById('additional-user-current-password'); const roleInput = document.getElementById('additional-user-role'); const addBtn = document.getElementById('additional-user-add'); const addToggleBtn = document.getElementById('add-user-toggle'); const addCancelBtn = document.getElementById('add-user-cancel'); const addPanel = document.getElementById('add-user-panel'); const feedback = document.getElementById('additional-user-feedback'); + const pageFeedback = document.getElementById('users-page-feedback'); const tbody = document.getElementById('additional-users-tbody'); const table = document.getElementById('additional-users-table'); if (!tbody || !table) return; @@ -26,12 +30,24 @@ const pwTarget = document.getElementById('change-password-target'); const pwNew = document.getElementById('change-password-new'); const pwConfirm = document.getElementById('change-password-confirm'); + const pwCurrent = document.getElementById('change-password-current'); + const pwCurrentLabel = document.getElementById('change-password-current-label'); + const pwConfirmHint = document.getElementById('change-password-confirm-hint'); const pwSave = document.getElementById('change-password-save'); const pwCancel = document.getElementById('change-password-cancel'); const pwFeedback = document.getElementById('change-password-feedback'); let pwTargetId = null; let pwTargetName = ''; + const delPanel = document.getElementById('delete-user-panel'); + const delTarget = document.getElementById('delete-user-target'); + const delCurrent = document.getElementById('delete-user-current'); + const delConfirmBtn = document.getElementById('delete-user-confirm'); + const delCancelBtn = document.getElementById('delete-user-cancel'); + const delFeedback = document.getElementById('delete-user-feedback'); + let delTargetId = null; + let delTargetName = ''; + function showFeedback(msg, kind) { if (!feedback) return; feedback.textContent = msg || ''; @@ -40,6 +56,14 @@ feedback.style.display = msg ? '' : 'none'; } + function showPageFeedback(msg, kind) { + if (!pageFeedback) return; + pageFeedback.textContent = msg || ''; + pageFeedback.className = 'inline-feedback'; + if (kind) pageFeedback.classList.add('status-' + kind); + pageFeedback.style.display = msg ? '' : 'none'; + } + function showPwFeedback(msg, kind) { if (!pwFeedback) return; pwFeedback.textContent = msg || ''; @@ -48,6 +72,14 @@ pwFeedback.style.display = msg ? '' : 'none'; } + function showDelFeedback(msg, kind) { + if (!delFeedback) return; + delFeedback.textContent = msg || ''; + delFeedback.className = 'inline-feedback'; + if (kind) delFeedback.classList.add('status-' + kind); + delFeedback.style.display = msg ? '' : 'none'; + } + function formatDate(iso) { // Backend already converts to the container's local TZ (issue #499); // render raw, mirroring fmtTime() in templates/dashboard.html. @@ -60,11 +92,23 @@ function openPasswordPanel(id, username) { closeAddPanel(); + closeDeletePanel(); pwTargetId = id; pwTargetName = username; if (pwTarget) pwTarget.textContent = 'Target user: ' + username; + const isSelf = currentUser && username === currentUser; + if (isSelf) { + if (pwConfirmHint) pwConfirmHint.textContent = 'Confirm this change with your current password.'; + if (pwCurrentLabel) pwCurrentLabel.textContent = 'Current password'; + if (pwCurrent) pwCurrent.placeholder = 'your current password'; + } else { + if (pwConfirmHint) pwConfirmHint.textContent = 'You are changing the password of "' + username + '". Enter your admin password to confirm this operation.'; + if (pwCurrentLabel) pwCurrentLabel.textContent = 'Your admin password'; + if (pwCurrent) pwCurrent.placeholder = 'your admin password'; + } if (pwNew) pwNew.value = ''; if (pwConfirm) pwConfirm.value = ''; + if (pwCurrent) pwCurrent.value = ''; showPwFeedback('', null); if (pwPanel) { pwPanel.style.display = ''; @@ -79,14 +123,40 @@ if (pwPanel) pwPanel.style.display = 'none'; if (pwNew) pwNew.value = ''; if (pwConfirm) pwConfirm.value = ''; + if (pwCurrent) pwCurrent.value = ''; showPwFeedback('', null); } + function openDeletePanel(id, username) { + closeAddPanel(); + closePasswordPanel(); + delTargetId = id; + delTargetName = username; + if (delTarget) delTarget.textContent = 'Target user: ' + username; + if (delCurrent) delCurrent.value = ''; + showDelFeedback('', null); + if (delPanel) { + delPanel.style.display = ''; + delPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + if (delCurrent) delCurrent.focus(); + } + + function closeDeletePanel() { + delTargetId = null; + delTargetName = ''; + if (delPanel) delPanel.style.display = 'none'; + if (delCurrent) delCurrent.value = ''; + showDelFeedback('', null); + } + function openAddPanel() { closePasswordPanel(); + closeDeletePanel(); if (nameInput) nameInput.value = ''; if (passInput) passInput.value = ''; if (passConfirmInput) passConfirmInput.value = ''; + if (currentInput) currentInput.value = ''; if (roleInput) roleInput.value = 'user'; showFeedback('', null); if (addPanel) { @@ -101,6 +171,7 @@ if (nameInput) nameInput.value = ''; if (passInput) passInput.value = ''; if (passConfirmInput) passConfirmInput.value = ''; + if (currentInput) currentInput.value = ''; if (roleInput) roleInput.value = 'user'; showFeedback('', null); } @@ -157,7 +228,7 @@ del.type = 'button'; del.className = 'btn btn-danger'; del.textContent = 'Delete'; - del.addEventListener('click', function () { deleteUser(u.id, u.username); }); + del.addEventListener('click', function () { openDeletePanel(u.id, u.username); }); tdAct.appendChild(del); } } else if (isSelf) { @@ -182,13 +253,14 @@ return r.json(); }) .then(function (data) { renderUsers((data && data.users) || []); }) - .catch(function (err) { showFeedback(err.message || 'Failed to load users.', 'error'); }); + .catch(function (err) { showPageFeedback(err.message || 'Failed to load users.', 'error'); }); } function addUser() { const username = (nameInput.value || '').trim(); const password = passInput.value || ''; const passwordConfirm = (passConfirmInput && passConfirmInput.value) || ''; + const currentPw = (currentInput && currentInput.value) || ''; const role = (roleInput && roleInput.value) || 'user'; if (!username || !password) { showFeedback('Username and password are required.', 'error'); @@ -198,48 +270,76 @@ showFeedback('Passwords do not match.', 'error'); return; } + if (!currentPw) { + showFeedback('Your admin password is required to confirm.', 'error'); + return; + } addBtn.disabled = true; showFeedback('Creating user...', 'info'); fetch('/api/users', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username: username, password: password, role: role }) + body: JSON.stringify({ + username: username, + password: password, + current_password: currentPw, + role: role + }) }) .then(function (r) { return r.json().then(function (data) { return { ok: r.ok, status: r.status, data: data }; }); }) .then(function (res) { if (!res.ok) throw new Error((res.data && res.data.error) || ('Failed to create user (' + res.status + ').')); - showFeedback('User "' + username + '" created.', 'success'); - loadUsers(); closeAddPanel(); + showPageFeedback('User "' + username + '" created.', 'success'); + loadUsers(); + }) + .catch(function (err) { + if (currentInput) currentInput.value = ''; + showFeedback(err.message || 'Failed to create user.', 'error'); }) - .catch(function (err) { showFeedback(err.message || 'Failed to create user.', 'error'); }) .finally(function () { addBtn.disabled = false; }); } - function deleteUser(id, username) { - if (!window.confirm('Delete user "' + username + '"? This cannot be undone.')) return; - fetch('/api/users/' + encodeURIComponent(id), { + function deleteUser() { + if (delTargetId === null) return; + const currentPw = (delCurrent && delCurrent.value) || ''; + if (!currentPw) { + showDelFeedback('Your admin password is required to confirm.', 'error'); + return; + } + delConfirmBtn.disabled = true; + showDelFeedback('Deleting user...', 'info'); + const targetName = delTargetName; + fetch('/api/users/' + encodeURIComponent(delTargetId), { method: 'DELETE', - credentials: 'same-origin' + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ current_password: currentPw }) }) .then(function (r) { return r.json().then(function (data) { return { ok: r.ok, status: r.status, data: data }; }); }) .then(function (res) { if (!res.ok) throw new Error((res.data && res.data.error) || ('Failed to delete user (' + res.status + ').')); - showFeedback('User "' + username + '" deleted.', 'success'); + closeDeletePanel(); + showPageFeedback('User "' + targetName + '" deleted.', 'success'); loadUsers(); }) - .catch(function (err) { showFeedback(err.message || 'Failed to delete user.', 'error'); }); + .catch(function (err) { + if (delCurrent) delCurrent.value = ''; + showDelFeedback(err.message || 'Failed to delete user.', 'error'); + }) + .finally(function () { delConfirmBtn.disabled = false; }); } function savePassword() { if (pwTargetId === null) return; const newPw = (pwNew && pwNew.value) || ''; const confirmPw = (pwConfirm && pwConfirm.value) || ''; + const currentPw = (pwCurrent && pwCurrent.value) || ''; if (!newPw) { showPwFeedback('New password cannot be empty.', 'error'); return; @@ -248,6 +348,10 @@ showPwFeedback('Passwords do not match.', 'error'); return; } + if (!currentPw) { + showPwFeedback('Your password is required to confirm this change.', 'error'); + return; + } pwSave.disabled = true; showPwFeedback('Updating password...', 'info'); const targetName = pwTargetName; @@ -255,7 +359,7 @@ method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password: newPw }) + body: JSON.stringify({ password: newPw, current_password: currentPw }) }) .then(function (r) { return r.json().then(function (data) { return { ok: r.ok, status: r.status, data: data }; }); @@ -265,9 +369,13 @@ showPwFeedback('Password for "' + targetName + '" updated.', 'success'); if (pwNew) pwNew.value = ''; if (pwConfirm) pwConfirm.value = ''; + if (pwCurrent) pwCurrent.value = ''; closePasswordPanel(); }) - .catch(function (err) { showPwFeedback(err.message || 'Failed to update password.', 'error'); }) + .catch(function (err) { + if (pwCurrent) pwCurrent.value = ''; + showPwFeedback(err.message || 'Failed to update password.', 'error'); + }) .finally(function () { pwSave.disabled = false; }); } @@ -276,5 +384,7 @@ if (addCancelBtn) addCancelBtn.addEventListener('click', closeAddPanel); if (pwSave) pwSave.addEventListener('click', savePassword); if (pwCancel) pwCancel.addEventListener('click', closePasswordPanel); + if (delConfirmBtn) delConfirmBtn.addEventListener('click', deleteUser); + if (delCancelBtn) delCancelBtn.addEventListener('click', closeDeletePanel); loadUsers(); })(); diff --git a/templates/users.html b/templates/users.html index c5df4385..fb76fdc8 100644 --- a/templates/users.html +++ b/templates/users.html @@ -31,6 +31,7 @@

AudioMuse-AI - Users configuration

{% endif %}
+
Add user +
+

Enter your admin password to confirm this operation.

+
+
+ + +
+
+
@@ -85,6 +95,28 @@

Add user

+ + {% endif %}