Files
appfactory-portal/app/routes/users.py
T
2026-06-16 10:23:43 +02:00

335 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import html
from urllib.parse import quote
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth import require_user
from app.db.audit import log_audit_event
from app.db.users import ROLES, count_enabled_admins, delete_user, get_user, list_users, set_user_enabled, update_user_role
from app.gitea_provisioning import sync_gitea_user
from app.templates.layout import page
router = APIRouter()
def require_admin(user: dict) -> None:
if (user.get("role") or "").lower() != "admin":
raise HTTPException(status_code=403, detail="Only admins can manage users")
def render_role_options(selected: str) -> str:
options = []
for role in ROLES:
selected_attr = " selected" if selected == role else ""
options.append(f'<option value="{role}"{selected_attr}>{role}</option>')
return "".join(options)
def render_enabled_pill(enabled) -> str:
if enabled:
return '<span class="pill pill-success">enabled</span>'
return '<span class="pill pill-muted">disabled</span>'
def render_sync_pill(status: str) -> str:
status = (status or "not_required").lower()
if status == "synced":
return '<span class="pill pill-success">synced</span>'
if status == "error":
return '<span class="pill pill-danger">error</span>'
if status == "syncing":
return '<span class="pill pill-muted">syncing</span>'
return '<span class="pill pill-muted">not required</span>'
def render_role_overview() -> str:
role_rows = (
("admin", "Plný přístup: vše co developer + mazání služeb, správa záloh a jejich obnova, správa uživatelů a rolí, admin-only operace."),
("developer", "Vidí běžící služby a dokumentaci jako viewer + provozní workflow služeb: Git/klonování, nasazení, proměnné, prostředky, metadata. Nemůže mazat služby ani do Administrace / Uživatelů."),
("viewer", "Pouze pro čtení: vidí jaké služby běží a jejich dokumentaci (Swagger). Bez Gitu, úprav, nasazení a mazání. Bez přístupu do Administrace / Uživatelů."),
("guest", "Výchozí role nového účtu po prvním přihlášení. Nemá přístup nikam vidí jen výzvu, aby požádal administrátora o přidělení práv."),
)
rows = ""
for role, description in role_rows:
rows += f"""
<tr>
<td><span class="pill pill-muted">{html.escape(role)}</span></td>
<td>{html.escape(description)}</td>
</tr>
"""
return f"""
<div class="card">
<h2>Role Permissions</h2>
<table>
<tr>
<th>Role</th>
<th>Access</th>
</tr>
{rows}
</table>
</div>
"""
@router.get("/admin/users", response_class=HTMLResponse)
def users_page(request: Request, message: str = "", error: str = "", user=Depends(require_user)):
require_admin(user)
notice = ""
if message:
notice = f'<p class="alert">{html.escape(message)}</p>'
if error:
notice = f'<p class="alert alert-danger">{html.escape(error)}</p>'
rows = ""
for managed_user in list_users():
user_id = int(managed_user.get("id"))
user_id_html = html.escape(str(user_id))
username = html.escape(managed_user.get("username", "") or "")
display_name = html.escape(managed_user.get("display_name", "") or "")
email = html.escape(managed_user.get("email", "") or "")
role = html.escape(managed_user.get("role", "") or "")
auth_provider = html.escape(managed_user.get("auth_provider", "") or "")
avatar_url = html.escape(managed_user.get("avatar_url", "") or "", quote=True)
avatar = f'<img class="user-avatar" src="{avatar_url}" alt="">' if avatar_url else ""
created_at = html.escape(managed_user.get("created_at", "") or "")
last_login_at = html.escape(managed_user.get("last_login_at", "") or "")
enabled = bool(managed_user.get("is_enabled", True))
gitea_user_id = managed_user.get("gitea_user_id")
gitea_sync_status = managed_user.get("gitea_sync_status", "not_required") or "not_required"
gitea_sync_error = html.escape(managed_user.get("gitea_sync_error", "") or "")
gitea_synced_at = html.escape(managed_user.get("gitea_synced_at", "") or "")
gitea_details = f"ID {html.escape(str(gitea_user_id))}" if gitea_user_id else "No account"
if gitea_synced_at:
gitea_details += f"<br><small>{gitea_synced_at}</small>"
if gitea_sync_error:
gitea_details += f'<br><small class="text-danger">{gitea_sync_error}</small>'
toggle_label = "Disable" if enabled else "Enable"
toggle_action = "disable" if enabled else "enable"
rows += f"""
<tr>
<td>{user_id_html}</td>
<td><strong>{username}</strong></td>
<td>{display_name}</td>
<td>{avatar}</td>
<td>{email}</td>
<td>{auth_provider}</td>
<td>
<form method="post" action="/portal/admin/users/{user_id_html}/role" class="inline-form">
<select name="role">{render_role_options(role)}</select>
<button type="submit" class="btn-secondary"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Save</button>
</form>
</td>
<td>{render_enabled_pill(enabled)}</td>
<td>{render_sync_pill(gitea_sync_status)}<br><small>{gitea_details}</small></td>
<td>{created_at}</td>
<td>{last_login_at}</td>
<td class="actions-cell">
<form method="post" action="/portal/admin/users/{user_id_html}/{toggle_action}" class="inline-form">
<button type="submit" class="btn-secondary"><i class="fa-solid fa-power-off" aria-hidden="true"></i> {toggle_label}</button>
</form>
<form method="post" action="/portal/admin/users/{user_id_html}/sync-gitea" class="inline-form">
<button type="submit" class="btn-secondary"><i class="fa-solid fa-rotate" aria-hidden="true"></i> Sync Gitea</button>
</form>
<form method="post" action="/portal/admin/users/{user_id_html}/delete" class="inline-form" onsubmit="return confirm('Delete this user? This removes the portal account.');">
<button type="submit" class="btn-secondary"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>
</form>
</td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="12">No users found.</td></tr>'
return page(
"Users",
f"""
<div class="card">
<h2><i class="fa-solid fa-users" aria-hidden="true"></i> Users</h2>
<p class="muted">Administration of portal users and roles.</p>
{notice}
</div>
<div class="card">
<h2>User List</h2>
<table>
<tr>
<th>ID</th>
<th>Username</th>
<th>Display name</th>
<th>Avatar</th>
<th>Email</th>
<th>Provider</th>
<th>Role</th>
<th>Status</th>
<th>Gitea sync</th>
<th>Created</th>
<th>Last login</th>
<th>Actions</th>
</tr>
{rows}
</table>
</div>
{render_role_overview()}
""",
user=user,
)
@router.post("/admin/users/{user_id}/role")
def update_role_action(user_id: int, role: str = Form(...), user=Depends(require_user)):
require_admin(user)
target = get_user(user_id)
if not target:
raise HTTPException(status_code=404, detail="User not found")
if role not in ROLES:
raise HTTPException(status_code=400, detail="Invalid role")
old_role = (target.get("role") or "").lower()
if old_role == "admin" and role != "admin" and count_enabled_admins(excluding_user_id=user_id) == 0:
return RedirectResponse(
url="/portal/admin/users?error=" + quote("Cannot remove admin role from the last enabled admin."),
status_code=303,
)
update_user_role(user_id, role)
target = get_user(user_id) or target
sync_result = sync_gitea_user(target)
if old_role != role:
log_audit_event(
user,
action="user.role.updated",
target_type="user",
target_id=user_id,
metadata={
"username": target.get("username"),
"old_role": old_role,
"new_role": role,
"gitea_sync_status": sync_result.get("status"),
},
)
if sync_result.get("status") == "error":
return RedirectResponse(
url="/portal/admin/users?error=" + quote(f"User role updated, but Gitea sync failed: {sync_result.get('error')}"),
status_code=303,
)
return RedirectResponse(url="/portal/admin/users?message=" + quote("User role updated and Gitea synchronized."), status_code=303)
@router.post("/admin/users/{user_id}/disable")
def disable_user_action(user_id: int, user=Depends(require_user)):
require_admin(user)
target = get_user(user_id)
if not target:
raise HTTPException(status_code=404, detail="User not found")
if (target.get("role") or "").lower() == "admin" and count_enabled_admins(excluding_user_id=user_id) == 0:
return RedirectResponse(
url="/portal/admin/users?error=" + quote("Cannot disable the last enabled admin."),
status_code=303,
)
set_user_enabled(user_id, False)
target = get_user(user_id) or target
sync_result = sync_gitea_user(target)
log_audit_event(
user,
action="user.disabled",
target_type="user",
target_id=user_id,
metadata={"username": target.get("username"), "gitea_sync_status": sync_result.get("status")},
)
if sync_result.get("status") == "error":
return RedirectResponse(
url="/portal/admin/users?error=" + quote(f"User disabled, but Gitea sync failed: {sync_result.get('error')}"),
status_code=303,
)
return RedirectResponse(url="/portal/admin/users?message=" + quote("User disabled and Gitea synchronized."), status_code=303)
@router.post("/admin/users/{user_id}/enable")
def enable_user_action(user_id: int, user=Depends(require_user)):
require_admin(user)
target = get_user(user_id)
if not target:
raise HTTPException(status_code=404, detail="User not found")
set_user_enabled(user_id, True)
target = get_user(user_id) or target
sync_result = sync_gitea_user(target)
log_audit_event(
user,
action="user.enabled",
target_type="user",
target_id=user_id,
metadata={"username": target.get("username"), "gitea_sync_status": sync_result.get("status")},
)
if sync_result.get("status") == "error":
return RedirectResponse(
url="/portal/admin/users?error=" + quote(f"User enabled, but Gitea sync failed: {sync_result.get('error')}"),
status_code=303,
)
return RedirectResponse(url="/portal/admin/users?message=" + quote("User enabled and Gitea synchronized."), status_code=303)
@router.post("/admin/users/{user_id}/sync-gitea")
def sync_gitea_user_action(user_id: int, user=Depends(require_user)):
require_admin(user)
target = get_user(user_id)
if not target:
raise HTTPException(status_code=404, detail="User not found")
sync_result = sync_gitea_user(target)
log_audit_event(
user,
action="user.gitea.synced",
target_type="user",
target_id=user_id,
metadata={"username": target.get("username"), **sync_result},
)
if sync_result.get("status") == "error":
return RedirectResponse(
url="/portal/admin/users?error=" + quote(f"Gitea sync failed: {sync_result.get('error')}"),
status_code=303,
)
return RedirectResponse(url="/portal/admin/users?message=" + quote("Gitea synchronized."), status_code=303)
@router.post("/admin/users/{user_id}/delete")
def delete_user_action(user_id: int, user=Depends(require_user)):
require_admin(user)
target = get_user(user_id)
if not target:
raise HTTPException(status_code=404, detail="User not found")
if int(user.get("id")) == user_id:
return RedirectResponse(
url="/portal/admin/users?error=" + quote("Cannot delete your own user account."),
status_code=303,
)
if (target.get("role") or "").lower() == "admin" and count_enabled_admins(excluding_user_id=user_id) == 0:
return RedirectResponse(
url="/portal/admin/users?error=" + quote("Cannot delete the last enabled admin."),
status_code=303,
)
set_user_enabled(user_id, False)
target = get_user(user_id) or target
sync_result = sync_gitea_user(target)
if sync_result.get("status") == "error":
return RedirectResponse(
url="/portal/admin/users?error=" + quote(f"User disabled, but Gitea sync failed: {sync_result.get('error')}"),
status_code=303,
)
delete_user(user_id)
log_audit_event(
user,
action="user.deleted",
target_type="user",
target_id=user_id,
metadata={"username": target.get("username"), "role": target.get("role"), "gitea_sync_status": sync_result.get("status")},
)
return RedirectResponse(url="/portal/admin/users?message=" + quote("User deleted."), status_code=303)