Files
JiriUhlir bd256d565c c
2026-06-17 12:47:53 +02:00

460 lines
20 KiB
Python
Raw Permalink 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
import re
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_gitea_username,
set_user_enabled,
update_user_role,
)
from app.gitea_provisioning import apply_gitea_credentials, sync_gitea_user
from app.logging_config import get_logger
from app.templates.layout import page
router = APIRouter()
logger = get_logger(__name__)
# Gitea handle: začíná alfanumerickým znakem, dál povolen . _ -; max 40 znaků.
GITEA_USERNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$")
MIN_GITEA_PASSWORD_LEN = 8
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 = (managed_user.get("gitea_sync_error", "") or "").strip()
gitea_synced_at = (managed_user.get("gitea_synced_at", "") or "").strip()
gitea_username_value = (managed_user.get("gitea_username") or "").strip()
current_gitea_username = gitea_username_value or (f"portal-{user_id}" if gitea_user_id else "")
toggle_label = "Vypnout" if enabled else "Zapnout"
toggle_action = "disable" if enabled else "enable"
# Gitea: pill (synced/…) + (i) s detaily v tooltipu (ID, jméno, čas synchronizace, chyba).
detail_parts = []
if gitea_user_id:
detail_parts.append(f"ID {gitea_user_id}")
if current_gitea_username:
detail_parts.append(f"@{current_gitea_username}")
if gitea_synced_at:
detail_parts.append(f"synced {gitea_synced_at}")
if gitea_sync_error:
detail_parts.append(f"chyba: {gitea_sync_error}")
tooltip = html.escape(" · ".join(detail_parts) or "Bez Gitea účtu", quote=True)
gitea_info = (
f'<span class="info-dot" tabindex="0" role="note" aria-label="{tooltip}" data-tooltip="{tooltip}">'
'<i class="fa-solid fa-circle-info" aria-hidden="true"></i></span>'
)
avatar_block = avatar or '<span class="user-avatar user-avatar-empty"><i class="fa-solid fa-user" aria-hidden="true"></i></span>'
provider_label = auth_provider or ""
email_line = f'<br><span class="muted">{email}</span>' if email else ""
# Nastavení Gitea jména a hesla v modálu (nezarovnává řádek a neořízne se scrollem tabulky).
gitea_modal_id = f"gitea-modal-{user_id_html}"
gitea_modal = f"""
<dialog class="resource-modal" id="{gitea_modal_id}">
<form method="post" action="/portal/admin/users/{user_id_html}/gitea-credentials">
<div class="modal-header">
<h3>Gitea účet {display_name or username}</h3>
<button type="button" class="icon-button" onclick="closeResourceModal('{gitea_modal_id}')" aria-label="Zavřít">×</button>
</div>
<p class="muted">Nastaví uživatelské jméno a/nebo heslo přímo v Gitea. Heslo se nikam neukládá.</p>
<label>Gitea uživatelské jméno</label>
<input name="gitea_username" value="{html.escape(current_gitea_username, quote=True)}" placeholder="portal-{user_id_html}" autocomplete="off">
<label>Nové heslo</label>
<input type="password" name="gitea_password" placeholder="prázdné = beze změny" autocomplete="new-password">
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeResourceModal('{gitea_modal_id}')"><i class="fa-solid fa-xmark" aria-hidden="true"></i> Zrušit</button>
<button type="submit"><i class="fa-solid fa-key" aria-hidden="true"></i> Uložit</button>
</div>
</form>
</dialog>
"""
rows += f"""
<tr class="user-row">
<td class="service-name-cell">
<div class="service-title">
{avatar_block}
<div>
<strong>{display_name or username}</strong><br>
<span class="muted">@{username}</span>{email_line}
</div>
</div>
</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="icon-action" title="Uložit roli" aria-label="Uložit roli"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i></button>
</form>
</td>
<td>{render_enabled_pill(enabled)}<br><span class="muted">{provider_label}</span></td>
<td>{render_sync_pill(gitea_sync_status)} {gitea_info}</td>
<td class="muted-cell">
<span class="muted">vytvořeno</span><br>{created_at or ""}<br>
<span class="muted">poslední přihlášení</span><br>{last_login_at or ""}
</td>
<td class="actions-cell service-actions">
<form method="post" action="/portal/admin/users/{user_id_html}/{toggle_action}" class="inline-form">
<button type="submit" class="icon-action" title="{toggle_label}" aria-label="{toggle_label}"><i class="fa-solid fa-power-off" aria-hidden="true"></i></button>
</form>
<form method="post" action="/portal/admin/users/{user_id_html}/sync-gitea" class="inline-form">
<button type="submit" class="icon-action" title="Synchronizovat Gitea" aria-label="Synchronizovat Gitea"><i class="fa-solid fa-rotate" aria-hidden="true"></i></button>
</form>
<button type="button" class="icon-action" onclick="return openResourceModal('{gitea_modal_id}')" title="Gitea jméno a heslo" aria-label="Gitea jméno a heslo"><i class="fa-solid fa-code-branch" aria-hidden="true"></i></button>
<form method="post" action="/portal/admin/users/{user_id_html}/delete" class="inline-form" onsubmit="return confirm('Smazat uživatele {username}? Odstraní se portálový účet.');">
<button type="submit" class="icon-action icon-action-danger" title="Smazat" aria-label="Smazat"><i class="fa-solid fa-trash" aria-hidden="true"></i></button>
</form>
{gitea_modal}
</td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="6">Žádní uživatelé.</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>Seznam uživatelů</h2>
<table>
<tr>
<th>Uživatel</th>
<th>Role</th>
<th>Stav</th>
<th>Gitea</th>
<th>Aktivita</th>
<th>Akce</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}/gitea-credentials")
def update_gitea_credentials_action(
user_id: int,
gitea_username: str = Form(""),
gitea_password: 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")
new_username = (gitea_username or "").strip()
new_password = gitea_password or ""
if not new_username and not new_password:
return RedirectResponse(
url="/portal/admin/users?error=" + quote("Zadejte Gitea jméno nebo nové heslo."),
status_code=303,
)
if new_username and not GITEA_USERNAME_RE.match(new_username):
return RedirectResponse(
url="/portal/admin/users?error=" + quote("Neplatné Gitea jméno (povoleno A-Z, 0-9, . _ -, max 40 znaků)."),
status_code=303,
)
if new_password and len(new_password) < MIN_GITEA_PASSWORD_LEN:
return RedirectResponse(
url="/portal/admin/users?error=" + quote(f"Heslo musí mít alespoň {MIN_GITEA_PASSWORD_LEN} znaků."),
status_code=303,
)
result = apply_gitea_credentials(target, new_username, new_password)
applied = result.get("applied") or []
errors = result.get("errors") or []
if result.get("renamed"):
set_gitea_username(user_id, result.get("gitea_username") or new_username)
log_audit_event(
user,
action="user.gitea.credentials_updated",
target_type="user",
target_id=user_id,
metadata={
"username": target.get("username"),
"gitea_username": result.get("gitea_username"),
"applied": applied,
"errors": errors, # heslo se nikdy nezaznamenává
},
)
status = result.get("status")
if status == "error":
detail = result.get("error") or "; ".join(errors) or "neznámá chyba"
logger.error("Změna Gitea údajů selhala pro uživatele id=%s: %s", user_id, detail)
return RedirectResponse(url="/portal/admin/users?error=" + quote(f"Gitea: {detail}"), status_code=303)
if errors:
msg = f"Gitea částečně aktualizováno ({', '.join(applied)}). Nepovedlo se: {'; '.join(errors)}"
logger.warning("Gitea údaje částečně pro uživatele id=%s: applied=%s errors=%s", user_id, applied, errors)
return RedirectResponse(url="/portal/admin/users?error=" + quote(msg), status_code=303)
logger.info("Gitea údaje aktualizovány pro uživatele id=%s: %s", user_id, applied)
return RedirectResponse(
url="/portal/admin/users?message=" + quote(f"Gitea aktualizováno: {', '.join(applied) or 'beze změny'}."),
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)