Merge pull request 'Auto-commit local changes before build (2026-01-09 12:43:58)' (#80) from v20260109-06-user-management-edit-roles into main

Reviewed-on: #80
This commit is contained in:
Ivo Oskamp 2026-01-13 11:33:26 +01:00
commit 80447813c0
4 changed files with 98 additions and 6 deletions

View File

@ -1 +1 @@
v20260109-05-fix-parsers-route-import v20260109-06-user-management-edit-roles

View File

@ -586,6 +586,15 @@ def settings():
news_admin_stats = {} news_admin_stats = {}
users = User.query.order_by(User.username.asc()).all()
# Count users that have 'admin' among their assigned roles (comma-separated storage)
admin_users_count = 0
try:
admin_users_count = sum(1 for u in users if "admin" in (getattr(u, "roles", None) or []))
except Exception:
admin_users_count = 0
return render_template( return render_template(
"main/settings.html", "main/settings.html",
settings=settings, settings=settings,
@ -594,7 +603,8 @@ def settings():
free_disk_warning=free_disk_warning, free_disk_warning=free_disk_warning,
has_client_secret=has_client_secret, has_client_secret=has_client_secret,
tz_options=tz_options, tz_options=tz_options,
users=User.query.order_by(User.username.asc()).all(), users=users,
admin_users_count=admin_users_count,
section=section, section=section,
news_admin_items=news_admin_items, news_admin_items=news_admin_items,
news_admin_stats=news_admin_stats, news_admin_stats=news_admin_stats,
@ -915,6 +925,53 @@ def settings_users_reset_password(user_id: int):
return redirect(url_for("main.settings", section="users")) return redirect(url_for("main.settings", section="users"))
@main_bp.route("/settings/users/<int:user_id>/roles", methods=["POST"])
@login_required
@roles_required("admin")
def settings_users_update_roles(user_id: int):
user = User.query.get_or_404(user_id)
roles = [r.strip() for r in request.form.getlist("roles") if (r or "").strip()]
roles = list(dict.fromkeys(roles))
if not roles:
roles = ["viewer"]
# Prevent removing the last remaining admin role
removing_admin = ("admin" in user.roles) and ("admin" not in roles)
if removing_admin:
try:
all_users = User.query.all()
admin_count = sum(1 for u in all_users if "admin" in (getattr(u, "roles", None) or []))
except Exception:
admin_count = 0
if admin_count <= 1:
flash("Cannot remove admin role from the last admin account.", "danger")
return redirect(url_for("main.settings", section="users"))
old_roles = ",".join(user.roles)
new_roles = ",".join(roles)
user.role = new_roles
try:
db.session.commit()
flash(f"Roles for '{user.username}' have been updated.", "success")
_log_admin_event("user_update_roles", f"User '{user.username}' roles changed from '{old_roles}' to '{new_roles}'.")
# If the updated user is currently logged in, make sure the active role stays valid.
try:
if getattr(current_user, "id", None) == user.id:
current_user.set_active_role(user.roles[0])
except Exception:
pass
except Exception as exc:
db.session.rollback()
print(f"[settings-users] Failed to update roles: {exc}")
flash("Failed to update roles.", "danger")
return redirect(url_for("main.settings", section="users"))
@main_bp.route("/settings/users/<int:user_id>/delete", methods=["POST"]) @main_bp.route("/settings/users/<int:user_id>/delete", methods=["POST"])
@login_required @login_required
@roles_required("admin") @roles_required("admin")
@ -922,8 +979,13 @@ def settings_users_delete(user_id: int):
user = User.query.get_or_404(user_id) user = User.query.get_or_404(user_id)
# Prevent deleting the last admin user # Prevent deleting the last admin user
if user.role == "admin": if "admin" in user.roles:
admin_count = User.query.filter_by(role="admin").count() try:
all_users = User.query.all()
admin_count = sum(1 for u in all_users if "admin" in (getattr(u, "roles", None) or []))
except Exception:
admin_count = 0
if admin_count <= 1: if admin_count <= 1:
flash("Cannot delete the last admin account.", "danger") flash("Cannot delete the last admin account.", "danger")
return redirect(url_for("main.settings", section="general")) return redirect(url_for("main.settings", section="general"))

View File

@ -160,8 +160,30 @@
{% if users %} {% if users %}
{% for user in users %} {% for user in users %}
<tr> <tr>
{% set is_last_admin = ('admin' in user.roles and (admin_users_count or 0) <= 1) %}
<td>{{ user.username }}</td> <td>{{ user.username }}</td>
<td>{{ (user.role or '')|replace(',', ', ') }}</td> <td>
<form method="post" action="{{ url_for('main.settings_users_update_roles', user_id=user.id) }}" class="d-flex flex-wrap gap-2 align-items-center">
<div class="form-check form-check-inline m-0">
<input class="form-check-input" type="checkbox" id="role_admin_{{ user.id }}" name="roles" value="admin" {% if 'admin' in user.roles %}checked{% endif %} {% if is_last_admin %}disabled title="Cannot remove admin from the last admin account"{% endif %} />
<label class="form-check-label" for="role_admin_{{ user.id }}">Admin</label>
</div>
<div class="form-check form-check-inline m-0">
<input class="form-check-input" type="checkbox" id="role_operator_{{ user.id }}" name="roles" value="operator" {% if 'operator' in user.roles %}checked{% endif %} />
<label class="form-check-label" for="role_operator_{{ user.id }}">Operator</label>
</div>
<div class="form-check form-check-inline m-0">
<input class="form-check-input" type="checkbox" id="role_reporter_{{ user.id }}" name="roles" value="reporter" {% if 'reporter' in user.roles %}checked{% endif %} />
<label class="form-check-label" for="role_reporter_{{ user.id }}">Reporter</label>
</div>
<div class="form-check form-check-inline m-0">
<input class="form-check-input" type="checkbox" id="role_viewer_{{ user.id }}" name="roles" value="viewer" {% if 'viewer' in user.roles %}checked{% endif %} />
<label class="form-check-label" for="role_viewer_{{ user.id }}">Viewer</label>
</div>
<button type="submit" class="btn btn-sm btn-outline-primary">Save</button>
</form>
<div class="text-muted small mt-1">Current: {{ (user.role or '')|replace(',', ', ') }}</div>
</td>
<td> <td>
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
<form method="post" action="{{ url_for('main.settings_users_reset_password', user_id=user.id) }}" class="d-inline"> <form method="post" action="{{ url_for('main.settings_users_reset_password', user_id=user.id) }}" class="d-inline">
@ -170,7 +192,6 @@
<button type="submit" class="btn btn-outline-secondary">Reset</button> <button type="submit" class="btn btn-outline-secondary">Reset</button>
</div> </div>
</form> </form>
{% set is_last_admin = (user.role == 'admin' and (users | selectattr('role', 'equalto', 'admin') | list | length) <= 1) %}
<form method="post" action="{{ url_for('main.settings_users_delete', user_id=user.id) }}" class="d-inline"> <form method="post" action="{{ url_for('main.settings_users_delete', user_id=user.id) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger" {% if is_last_admin %}disabled title="Cannot delete the last admin account"{% endif %}>Delete</button> <button type="submit" class="btn btn-sm btn-outline-danger" {% if is_last_admin %}disabled title="Cannot delete the last admin account"{% endif %}>Delete</button>
</form> </form>

View File

@ -37,6 +37,15 @@
- Fixed parsers page route startup crash by replacing an invalid absolute import with a package-relative import for the parser registry. - Fixed parsers page route startup crash by replacing an invalid absolute import with a package-relative import for the parser registry.
- Prevented Gunicorn worker boot failure (Bad Gateway) caused by "No module named 'app'" during application initialization. - Prevented Gunicorn worker boot failure (Bad Gateway) caused by "No module named 'app'" during application initialization.
---
## v20260109-06-user-management-edit-roles
- Added support to edit user roles directly in the User Management interface.
- Implemented backend logic to update existing user role assignments without requiring user deletion.
- Updated validation to ensure role changes are applied immediately and consistently.
- Ensured role updates are reflected correctly in permissions and access control.
================================================================================================================================================ ================================================================================================================================================
## v0.1.19 ## v0.1.19
This release delivers a broad set of improvements focused on reliability, transparency, and operational control across mail processing, administrative auditing, and Run Checks workflows. The changes aim to make message handling more robust, provide better insight for administrators, and give operators clearer and more flexible control when reviewing backup runs. This release delivers a broad set of improvements focused on reliability, transparency, and operational control across mail processing, administrative auditing, and Run Checks workflows. The changes aim to make message handling more robust, provide better insight for administrators, and give operators clearer and more flexible control when reviewing backup runs.