Změny:
users migrace má nové sloupce gitea_user_id, gitea_sync_status, gitea_sync_error, gitea_synced_at.
Přidal jsem gitea_provisioning.py, který přes Gitea API vytváří, aktivuje, deaktivuje a synchronizuje účty.
Role viewer Gitea účet nemá; developer a admin účet mají.
Změny role a enable/disable v administraci spouští sync do Gitea.
Admin UI v routes/users.py zobrazuje stav Gitea synchronizace a má ruční Sync Gitea.
Google login po přihlášení dorovnává Gitea stav podle portálového uživatele.
Gitea OAuth login je v portálu vypnutý; endpointy zůstaly, ale vrací hlášku, že se má použít Google.
Lokální login se zobrazuje/povolí jen při explicitním AUTH_MODE=local.
Gitea API token se čte z GITEA_ADMIN_TOKEN, případně fallback GITEA_API_TOKEN nebo GITEA_TOKEN. Podřízené Gitea username je deterministicky portal-{user_id}, portálová tabulka zůstává zdroj pravdy.
Ověření: python -m py_compile prošel pro všechny upravené Python soubory.
This commit is contained in:
+10
-1
@@ -18,7 +18,16 @@ def _user_select_columns(con) -> str:
|
|||||||
columns = _table_columns(con, "users")
|
columns = _table_columns(con, "users")
|
||||||
enabled_select = "COALESCE(is_enabled, 1) AS is_enabled" if "is_enabled" in columns else "1 AS is_enabled"
|
enabled_select = "COALESCE(is_enabled, 1) AS is_enabled" if "is_enabled" in columns else "1 AS is_enabled"
|
||||||
optional_columns = []
|
optional_columns = []
|
||||||
for column in ("last_login_at", "auth_provider", "provider_subject", "avatar_url"):
|
for column in (
|
||||||
|
"last_login_at",
|
||||||
|
"auth_provider",
|
||||||
|
"provider_subject",
|
||||||
|
"avatar_url",
|
||||||
|
"gitea_user_id",
|
||||||
|
"gitea_sync_status",
|
||||||
|
"gitea_sync_error",
|
||||||
|
"gitea_synced_at",
|
||||||
|
):
|
||||||
if column in columns:
|
if column in columns:
|
||||||
optional_columns.append(column)
|
optional_columns.append(column)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -67,6 +67,14 @@ def get_gitea_server_url() -> str:
|
|||||||
return get_gitea_public_url() or INTERNAL_GITEA_URL
|
return get_gitea_public_url() or INTERNAL_GITEA_URL
|
||||||
|
|
||||||
|
|
||||||
|
def get_gitea_admin_token() -> str:
|
||||||
|
return (
|
||||||
|
read_env_value("GITEA_ADMIN_TOKEN", "")
|
||||||
|
or read_env_value("GITEA_API_TOKEN", "")
|
||||||
|
or read_env_value("GITEA_TOKEN", "")
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
|
||||||
def get_gitea_redirect_uri() -> str:
|
def get_gitea_redirect_uri() -> str:
|
||||||
redirect_uri = read_env_value("GITEA_OAUTH_REDIRECT_URI", "").strip()
|
redirect_uri = read_env_value("GITEA_OAUTH_REDIRECT_URI", "").strip()
|
||||||
if redirect_uri:
|
if redirect_uri:
|
||||||
|
|||||||
+10
-1
@@ -23,7 +23,11 @@ def ensure_user_columns(con) -> None:
|
|||||||
last_login_at TEXT,
|
last_login_at TEXT,
|
||||||
auth_provider TEXT,
|
auth_provider TEXT,
|
||||||
provider_subject TEXT,
|
provider_subject TEXT,
|
||||||
avatar_url TEXT
|
avatar_url TEXT,
|
||||||
|
gitea_user_id INTEGER,
|
||||||
|
gitea_sync_status TEXT NOT NULL DEFAULT 'not_required',
|
||||||
|
gitea_sync_error TEXT,
|
||||||
|
gitea_synced_at TEXT
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -45,6 +49,10 @@ def ensure_user_columns(con) -> None:
|
|||||||
"auth_provider": "TEXT",
|
"auth_provider": "TEXT",
|
||||||
"provider_subject": "TEXT",
|
"provider_subject": "TEXT",
|
||||||
"avatar_url": "TEXT",
|
"avatar_url": "TEXT",
|
||||||
|
"gitea_user_id": "INTEGER",
|
||||||
|
"gitea_sync_status": "TEXT NOT NULL DEFAULT 'not_required'",
|
||||||
|
"gitea_sync_error": "TEXT",
|
||||||
|
"gitea_synced_at": "TEXT",
|
||||||
}
|
}
|
||||||
for column, definition in defaults.items():
|
for column, definition in defaults.items():
|
||||||
if column not in columns:
|
if column not in columns:
|
||||||
@@ -54,6 +62,7 @@ def ensure_user_columns(con) -> None:
|
|||||||
if "is_active" in columns and "is_enabled" in columns:
|
if "is_active" in columns and "is_enabled" in columns:
|
||||||
con.execute("UPDATE users SET is_enabled = COALESCE(is_active, 1) WHERE is_enabled IS NULL")
|
con.execute("UPDATE users SET is_enabled = COALESCE(is_active, 1) WHERE is_enabled IS NULL")
|
||||||
con.execute("UPDATE users SET role = 'viewer' WHERE role IS NULL OR role = ''")
|
con.execute("UPDATE users SET role = 'viewer' WHERE role IS NULL OR role = ''")
|
||||||
|
con.execute("UPDATE users SET gitea_sync_status = 'not_required' WHERE gitea_sync_status IS NULL OR gitea_sync_status = ''")
|
||||||
con.execute("UPDATE users SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL OR created_at = ''")
|
con.execute("UPDATE users SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL OR created_at = ''")
|
||||||
con.execute("UPDATE users SET updated_at = COALESCE(created_at, CURRENT_TIMESTAMP) WHERE updated_at IS NULL OR updated_at = ''")
|
con.execute("UPDATE users SET updated_at = COALESCE(created_at, CURRENT_TIMESTAMP) WHERE updated_at IS NULL OR updated_at = ''")
|
||||||
con.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)")
|
con.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)")
|
||||||
|
|||||||
+57
-2
@@ -19,6 +19,10 @@ def list_users() -> list[dict[str, Any]]:
|
|||||||
auth_provider_select = "auth_provider" if "auth_provider" in columns else "NULL AS auth_provider"
|
auth_provider_select = "auth_provider" if "auth_provider" in columns else "NULL AS auth_provider"
|
||||||
provider_subject_select = "provider_subject" if "provider_subject" in columns else "NULL AS provider_subject"
|
provider_subject_select = "provider_subject" if "provider_subject" in columns else "NULL AS provider_subject"
|
||||||
avatar_url_select = "avatar_url" if "avatar_url" in columns else "NULL AS avatar_url"
|
avatar_url_select = "avatar_url" if "avatar_url" in columns else "NULL AS avatar_url"
|
||||||
|
gitea_user_id_select = "gitea_user_id" if "gitea_user_id" in columns else "NULL AS gitea_user_id"
|
||||||
|
gitea_sync_status_select = "gitea_sync_status" if "gitea_sync_status" in columns else "'not_required' AS gitea_sync_status"
|
||||||
|
gitea_sync_error_select = "gitea_sync_error" if "gitea_sync_error" in columns else "NULL AS gitea_sync_error"
|
||||||
|
gitea_synced_at_select = "gitea_synced_at" if "gitea_synced_at" in columns else "NULL AS gitea_synced_at"
|
||||||
|
|
||||||
rows = con.execute(
|
rows = con.execute(
|
||||||
f"""
|
f"""
|
||||||
@@ -34,7 +38,11 @@ def list_users() -> list[dict[str, Any]]:
|
|||||||
{last_login_select},
|
{last_login_select},
|
||||||
{auth_provider_select},
|
{auth_provider_select},
|
||||||
{provider_subject_select},
|
{provider_subject_select},
|
||||||
{avatar_url_select}
|
{avatar_url_select},
|
||||||
|
{gitea_user_id_select},
|
||||||
|
{gitea_sync_status_select},
|
||||||
|
{gitea_sync_error_select},
|
||||||
|
{gitea_synced_at_select}
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
"""
|
"""
|
||||||
@@ -52,6 +60,10 @@ def get_user(user_id: int) -> dict[str, Any] | None:
|
|||||||
auth_provider_select = "auth_provider" if "auth_provider" in columns else "NULL AS auth_provider"
|
auth_provider_select = "auth_provider" if "auth_provider" in columns else "NULL AS auth_provider"
|
||||||
provider_subject_select = "provider_subject" if "provider_subject" in columns else "NULL AS provider_subject"
|
provider_subject_select = "provider_subject" if "provider_subject" in columns else "NULL AS provider_subject"
|
||||||
avatar_url_select = "avatar_url" if "avatar_url" in columns else "NULL AS avatar_url"
|
avatar_url_select = "avatar_url" if "avatar_url" in columns else "NULL AS avatar_url"
|
||||||
|
gitea_user_id_select = "gitea_user_id" if "gitea_user_id" in columns else "NULL AS gitea_user_id"
|
||||||
|
gitea_sync_status_select = "gitea_sync_status" if "gitea_sync_status" in columns else "'not_required' AS gitea_sync_status"
|
||||||
|
gitea_sync_error_select = "gitea_sync_error" if "gitea_sync_error" in columns else "NULL AS gitea_sync_error"
|
||||||
|
gitea_synced_at_select = "gitea_synced_at" if "gitea_synced_at" in columns else "NULL AS gitea_synced_at"
|
||||||
|
|
||||||
row = con.execute(
|
row = con.execute(
|
||||||
f"""
|
f"""
|
||||||
@@ -67,7 +79,11 @@ def get_user(user_id: int) -> dict[str, Any] | None:
|
|||||||
{last_login_select},
|
{last_login_select},
|
||||||
{auth_provider_select},
|
{auth_provider_select},
|
||||||
{provider_subject_select},
|
{provider_subject_select},
|
||||||
{avatar_url_select}
|
{avatar_url_select},
|
||||||
|
{gitea_user_id_select},
|
||||||
|
{gitea_sync_status_select},
|
||||||
|
{gitea_sync_error_select},
|
||||||
|
{gitea_synced_at_select}
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""",
|
""",
|
||||||
@@ -136,3 +152,42 @@ def set_user_enabled(user_id: int, enabled: bool) -> None:
|
|||||||
)
|
)
|
||||||
con.commit()
|
con.commit()
|
||||||
con.close()
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_gitea_sync_state(
|
||||||
|
user_id: int,
|
||||||
|
status: str,
|
||||||
|
*,
|
||||||
|
gitea_user_id: int | None = None,
|
||||||
|
error: str = "",
|
||||||
|
synced: bool = False,
|
||||||
|
) -> None:
|
||||||
|
run_migrations()
|
||||||
|
con = get_connection()
|
||||||
|
if gitea_user_id is None:
|
||||||
|
con.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET gitea_sync_status = ?,
|
||||||
|
gitea_sync_error = ?,
|
||||||
|
gitea_synced_at = CASE WHEN ? THEN CURRENT_TIMESTAMP ELSE gitea_synced_at END,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(status, error, 1 if synced else 0, user_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
con.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET gitea_user_id = ?,
|
||||||
|
gitea_sync_status = ?,
|
||||||
|
gitea_sync_error = ?,
|
||||||
|
gitea_synced_at = CASE WHEN ? THEN CURRENT_TIMESTAMP ELSE gitea_synced_at END,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(gitea_user_id, status, error, 1 if synced else 0, user_id),
|
||||||
|
)
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import Request as UrlRequest
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
|
from app.config import get_gitea_admin_token, get_gitea_server_url
|
||||||
|
from app.db.users import update_gitea_sync_state
|
||||||
|
|
||||||
|
GITEA_ACCOUNT_ROLES = {"admin", "developer"}
|
||||||
|
|
||||||
|
|
||||||
|
def sync_gitea_user(user: dict) -> dict:
|
||||||
|
user_id = int(user["id"])
|
||||||
|
desired_active = _requires_gitea_account(user)
|
||||||
|
username = _gitea_username(user_id)
|
||||||
|
|
||||||
|
update_gitea_sync_state(user_id, "syncing")
|
||||||
|
try:
|
||||||
|
if desired_active:
|
||||||
|
gitea_user = _ensure_gitea_user(user, username)
|
||||||
|
_update_gitea_user(username, user, active=True)
|
||||||
|
update_gitea_sync_state(
|
||||||
|
user_id,
|
||||||
|
"synced",
|
||||||
|
gitea_user_id=int(gitea_user["id"]),
|
||||||
|
synced=True,
|
||||||
|
)
|
||||||
|
return {"status": "synced", "action": "activated", "gitea_user_id": gitea_user["id"]}
|
||||||
|
|
||||||
|
if not user.get("gitea_user_id"):
|
||||||
|
update_gitea_sync_state(user_id, "not_required", synced=True)
|
||||||
|
return {"status": "not_required", "action": "none"}
|
||||||
|
|
||||||
|
gitea_user = _get_gitea_user(username, missing_ok=True)
|
||||||
|
if gitea_user:
|
||||||
|
_update_gitea_user(username, user, active=False)
|
||||||
|
update_gitea_sync_state(
|
||||||
|
user_id,
|
||||||
|
"not_required",
|
||||||
|
gitea_user_id=int(gitea_user["id"]),
|
||||||
|
synced=True,
|
||||||
|
)
|
||||||
|
return {"status": "not_required", "action": "deactivated", "gitea_user_id": gitea_user["id"]}
|
||||||
|
|
||||||
|
update_gitea_sync_state(user_id, "not_required", synced=True)
|
||||||
|
return {"status": "not_required", "action": "none"}
|
||||||
|
except Exception as exc:
|
||||||
|
error = str(exc)[:500]
|
||||||
|
update_gitea_sync_state(user_id, "error", error=error)
|
||||||
|
return {"status": "error", "error": error}
|
||||||
|
|
||||||
|
|
||||||
|
def _requires_gitea_account(user: dict) -> bool:
|
||||||
|
role = (user.get("role") or "").lower()
|
||||||
|
return role in GITEA_ACCOUNT_ROLES and bool(user.get("is_enabled", 1)) and bool(user.get("is_active", 1))
|
||||||
|
|
||||||
|
|
||||||
|
def _gitea_username(user_id: int) -> str:
|
||||||
|
return f"portal-{user_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_gitea_user(user: dict, username: str) -> dict:
|
||||||
|
existing = _get_gitea_user(username, missing_ok=True)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
return _create_gitea_user(user, username)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_gitea_user(user: dict, username: str) -> dict:
|
||||||
|
payload = {
|
||||||
|
"username": username,
|
||||||
|
"login_name": username,
|
||||||
|
"source_id": 0,
|
||||||
|
"email": _email(user),
|
||||||
|
"full_name": _display_name(user),
|
||||||
|
"password": _random_password(),
|
||||||
|
"must_change_password": False,
|
||||||
|
"send_notify": False,
|
||||||
|
"visibility": "private",
|
||||||
|
}
|
||||||
|
return _request_json("POST", "/api/v1/admin/users", payload, expected=(201,))
|
||||||
|
|
||||||
|
|
||||||
|
def _update_gitea_user(username: str, user: dict, active: bool) -> dict:
|
||||||
|
payload = {
|
||||||
|
"email": _email(user),
|
||||||
|
"full_name": _display_name(user),
|
||||||
|
"login_name": username,
|
||||||
|
"source_id": 0,
|
||||||
|
"active": active,
|
||||||
|
"admin": False,
|
||||||
|
"visibility": "private",
|
||||||
|
}
|
||||||
|
return _request_json("PATCH", f"/api/v1/admin/users/{quote(username)}", payload, expected=(200,))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_gitea_user(username: str, missing_ok: bool = False) -> dict | None:
|
||||||
|
try:
|
||||||
|
return _request_json("GET", f"/api/v1/admin/users/{quote(username)}", expected=(200,))
|
||||||
|
except RuntimeError as exc:
|
||||||
|
if missing_ok and "HTTP 404" in str(exc):
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _request_json(method: str, path: str, payload: dict | None = None, expected: tuple[int, ...] = (200,)) -> dict:
|
||||||
|
base_url = get_gitea_server_url().rstrip("/")
|
||||||
|
token = get_gitea_admin_token()
|
||||||
|
if not base_url:
|
||||||
|
raise RuntimeError("Gitea server URL is not configured")
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("Gitea admin token is not configured")
|
||||||
|
|
||||||
|
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||||
|
request = UrlRequest(
|
||||||
|
f"{base_url}{path}",
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": f"token {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=10) as response:
|
||||||
|
body = response.read().decode("utf-8")
|
||||||
|
if response.status not in expected:
|
||||||
|
raise RuntimeError(f"Gitea API returned HTTP {response.status}")
|
||||||
|
return json.loads(body) if body else {}
|
||||||
|
except HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", errors="replace")
|
||||||
|
raise RuntimeError(f"Gitea API returned HTTP {exc.code}: {detail}") from exc
|
||||||
|
except (URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||||||
|
raise RuntimeError(f"Gitea API request failed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _email(user: dict) -> str:
|
||||||
|
email = (user.get("email") or "").strip().lower()
|
||||||
|
if email:
|
||||||
|
return email
|
||||||
|
return f"portal-{int(user['id'])}@localhost"
|
||||||
|
|
||||||
|
|
||||||
|
def _display_name(user: dict) -> str:
|
||||||
|
return (user.get("display_name") or user.get("username") or _email(user)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _random_password() -> str:
|
||||||
|
alphabet = string.ascii_letters + string.digits
|
||||||
|
required = [
|
||||||
|
secrets.choice(string.ascii_lowercase),
|
||||||
|
secrets.choice(string.ascii_uppercase),
|
||||||
|
secrets.choice(string.digits),
|
||||||
|
]
|
||||||
|
required.extend(secrets.choice(alphabet) for _ in range(29))
|
||||||
|
secrets.SystemRandom().shuffle(required)
|
||||||
|
return "".join(required)
|
||||||
+10
-14
@@ -9,7 +9,7 @@ from urllib.request import urlopen
|
|||||||
from fastapi import APIRouter, Form, Request
|
from fastapi import APIRouter, Form, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
from app.auth import authenticate_user, current_user, find_or_create_google_user, find_or_create_oauth_user, mark_last_login
|
from app.auth import authenticate_user, current_user, find_or_create_google_user, mark_last_login
|
||||||
from app.config import (
|
from app.config import (
|
||||||
get_auth_mode,
|
get_auth_mode,
|
||||||
get_gitea_public_url,
|
get_gitea_public_url,
|
||||||
@@ -21,6 +21,7 @@ from app.config import (
|
|||||||
read_env_value,
|
read_env_value,
|
||||||
)
|
)
|
||||||
from app.db.audit import log_audit_event
|
from app.db.audit import log_audit_event
|
||||||
|
from app.gitea_provisioning import sync_gitea_user
|
||||||
from app.templates.layout import page
|
from app.templates.layout import page
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -39,7 +40,7 @@ def login_form(request: Request):
|
|||||||
|
|
||||||
@router.post("/login", response_class=HTMLResponse)
|
@router.post("/login", response_class=HTMLResponse)
|
||||||
def login(request: Request, username: str = Form(...), password: str = Form(...)):
|
def login(request: Request, username: str = Form(...), password: str = Form(...)):
|
||||||
if get_auth_mode() == "google":
|
if get_auth_mode() != "local":
|
||||||
return _render_login("Local login is disabled. Use Google sign in.")
|
return _render_login("Local login is disabled. Use Google sign in.")
|
||||||
|
|
||||||
user = authenticate_user(username, password)
|
user = authenticate_user(username, password)
|
||||||
@@ -64,8 +65,7 @@ def login(request: Request, username: str = Form(...), password: str = Form(...)
|
|||||||
def gitea_login(request: Request):
|
def gitea_login(request: Request):
|
||||||
if current_user(request):
|
if current_user(request):
|
||||||
return RedirectResponse(url="/portal/operations", status_code=303)
|
return RedirectResponse(url="/portal/operations", status_code=303)
|
||||||
if get_auth_mode() != "mixed":
|
return _render_login("Gitea login is disabled. Use Google sign in.")
|
||||||
return _render_login("Gitea login is not enabled in this auth mode.")
|
|
||||||
|
|
||||||
gitea_url = get_gitea_public_url()
|
gitea_url = get_gitea_public_url()
|
||||||
client_id = read_env_value("GITEA_OAUTH_CLIENT_ID", "")
|
client_id = read_env_value("GITEA_OAUTH_CLIENT_ID", "")
|
||||||
@@ -98,8 +98,7 @@ def gitea_login(request: Request):
|
|||||||
|
|
||||||
@router.get("/auth/gitea/callback", response_class=HTMLResponse)
|
@router.get("/auth/gitea/callback", response_class=HTMLResponse)
|
||||||
def gitea_callback(request: Request, code: str = "", state: str = "", error: str = ""):
|
def gitea_callback(request: Request, code: str = "", state: str = "", error: str = ""):
|
||||||
if get_auth_mode() != "mixed":
|
return _render_login("Gitea login is disabled. Use Google sign in.")
|
||||||
return _render_login("Gitea login is not enabled in this auth mode.")
|
|
||||||
|
|
||||||
expected_state = request.session.pop("gitea_oauth_state", None)
|
expected_state = request.session.pop("gitea_oauth_state", None)
|
||||||
if error:
|
if error:
|
||||||
@@ -228,6 +227,8 @@ def google_callback(request: Request, code: str = "", state: str = "", error: st
|
|||||||
_log_google_failure("disabled_user", username=username)
|
_log_google_failure("disabled_user", username=username)
|
||||||
return _render_login("Uživatel je v portálu vypnutý.")
|
return _render_login("Uživatel je v portálu vypnutý.")
|
||||||
|
|
||||||
|
sync_result = sync_gitea_user(user)
|
||||||
|
|
||||||
request.session.clear()
|
request.session.clear()
|
||||||
request.session["user_id"] = user["id"]
|
request.session["user_id"] = user["id"]
|
||||||
mark_last_login(int(user["id"]))
|
mark_last_login(int(user["id"]))
|
||||||
@@ -242,6 +243,7 @@ def google_callback(request: Request, code: str = "", state: str = "", error: st
|
|||||||
"email": user.get("email"),
|
"email": user.get("email"),
|
||||||
"role": user.get("role"),
|
"role": user.get("role"),
|
||||||
"provider": "google",
|
"provider": "google",
|
||||||
|
"gitea_sync_status": sync_result.get("status"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
log_audit_event(
|
log_audit_event(
|
||||||
@@ -249,7 +251,7 @@ def google_callback(request: Request, code: str = "", state: str = "", error: st
|
|||||||
action="auth.google.login.success",
|
action="auth.google.login.success",
|
||||||
target_type="user",
|
target_type="user",
|
||||||
target_id=user.get("id"),
|
target_id=user.get("id"),
|
||||||
metadata={"username": user.get("username"), "provider": "google"},
|
metadata={"username": user.get("username"), "provider": "google", "gitea_sync_status": sync_result.get("status")},
|
||||||
)
|
)
|
||||||
return RedirectResponse(url="/portal", status_code=303)
|
return RedirectResponse(url="/portal", status_code=303)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -397,12 +399,6 @@ def _render_login(error: str | None = None) -> str:
|
|||||||
error_html = f'<p class="alert alert-danger">{html.escape(error)}</p>'
|
error_html = f'<p class="alert alert-danger">{html.escape(error)}</p>'
|
||||||
auth_mode = get_auth_mode()
|
auth_mode = get_auth_mode()
|
||||||
gitea_login_html = ""
|
gitea_login_html = ""
|
||||||
if auth_mode == "mixed" and is_gitea_oauth_button_enabled():
|
|
||||||
gitea_login_html = """
|
|
||||||
<p>
|
|
||||||
<a class="btn btn-secondary" href="/portal/auth/gitea/login">Sign in with Gitea</a>
|
|
||||||
</p>
|
|
||||||
"""
|
|
||||||
google_login_html = ""
|
google_login_html = ""
|
||||||
if auth_mode in {"google", "mixed"} and is_google_oauth_button_enabled():
|
if auth_mode in {"google", "mixed"} and is_google_oauth_button_enabled():
|
||||||
google_login_html = """
|
google_login_html = """
|
||||||
@@ -414,7 +410,7 @@ def _render_login(error: str | None = None) -> str:
|
|||||||
google_login_html = '<p class="alert alert-danger">Google login is not fully configured.</p>'
|
google_login_html = '<p class="alert alert-danger">Google login is not fully configured.</p>'
|
||||||
|
|
||||||
local_login_html = ""
|
local_login_html = ""
|
||||||
if auth_mode in {"mixed", "local"}:
|
if auth_mode == "local":
|
||||||
local_login_html = """
|
local_login_html = """
|
||||||
<form method="post" action="/portal/login">
|
<form method="post" action="/portal/login">
|
||||||
<h3>Local login</h3>
|
<h3>Local login</h3>
|
||||||
|
|||||||
+77
-6
@@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
from app.auth import require_user
|
from app.auth import require_user
|
||||||
from app.db.audit import log_audit_event
|
from app.db.audit import log_audit_event
|
||||||
from app.db.users import ROLES, count_enabled_admins, get_user, list_users, set_user_enabled, update_user_role
|
from app.db.users import ROLES, count_enabled_admins, get_user, list_users, set_user_enabled, update_user_role
|
||||||
|
from app.gitea_provisioning import sync_gitea_user
|
||||||
from app.templates.layout import page
|
from app.templates.layout import page
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -31,6 +32,17 @@ def render_enabled_pill(enabled) -> str:
|
|||||||
return '<span class="pill pill-muted">disabled</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:
|
def render_role_overview() -> str:
|
||||||
role_rows = (
|
role_rows = (
|
||||||
("admin", "Full administration, user management, role changes, disabling/enabling users, and admin-only operations."),
|
("admin", "Full administration, user management, role changes, disabling/enabling users, and admin-only operations."),
|
||||||
@@ -83,6 +95,15 @@ def users_page(request: Request, message: str = "", error: str = "", user=Depend
|
|||||||
created_at = html.escape(managed_user.get("created_at", "") or "")
|
created_at = html.escape(managed_user.get("created_at", "") or "")
|
||||||
last_login_at = html.escape(managed_user.get("last_login_at", "") or "")
|
last_login_at = html.escape(managed_user.get("last_login_at", "") or "")
|
||||||
enabled = bool(managed_user.get("is_enabled", True))
|
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_label = "Disable" if enabled else "Enable"
|
||||||
toggle_action = "disable" if enabled else "enable"
|
toggle_action = "disable" if enabled else "enable"
|
||||||
|
|
||||||
@@ -101,18 +122,22 @@ def users_page(request: Request, message: str = "", error: str = "", user=Depend
|
|||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
<td>{render_enabled_pill(enabled)}</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>{created_at}</td>
|
||||||
<td>{last_login_at}</td>
|
<td>{last_login_at}</td>
|
||||||
<td class="actions-cell">
|
<td class="actions-cell">
|
||||||
<form method="post" action="/portal/admin/users/{user_id_html}/{toggle_action}" class="inline-form">
|
<form method="post" action="/portal/admin/users/{user_id_html}/{toggle_action}" class="inline-form">
|
||||||
<button type="submit" class="btn-secondary">{toggle_label}</button>
|
<button type="submit" class="btn-secondary">{toggle_label}</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form method="post" action="/portal/admin/users/{user_id_html}/sync-gitea" class="inline-form">
|
||||||
|
<button type="submit" class="btn-secondary">Sync Gitea</button>
|
||||||
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
rows = '<tr><td colspan="11">No users found.</td></tr>'
|
rows = '<tr><td colspan="12">No users found.</td></tr>'
|
||||||
|
|
||||||
return page(
|
return page(
|
||||||
"Users",
|
"Users",
|
||||||
@@ -135,6 +160,7 @@ def users_page(request: Request, message: str = "", error: str = "", user=Depend
|
|||||||
<th>Provider</th>
|
<th>Provider</th>
|
||||||
<th>Role</th>
|
<th>Role</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Gitea sync</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
<th>Last login</th>
|
<th>Last login</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
@@ -165,6 +191,8 @@ def update_role_action(user_id: int, role: str = Form(...), user=Depends(require
|
|||||||
)
|
)
|
||||||
|
|
||||||
update_user_role(user_id, role)
|
update_user_role(user_id, role)
|
||||||
|
target = get_user(user_id) or target
|
||||||
|
sync_result = sync_gitea_user(target)
|
||||||
if old_role != role:
|
if old_role != role:
|
||||||
log_audit_event(
|
log_audit_event(
|
||||||
user,
|
user,
|
||||||
@@ -175,10 +203,16 @@ def update_role_action(user_id: int, role: str = Form(...), user=Depends(require
|
|||||||
"username": target.get("username"),
|
"username": target.get("username"),
|
||||||
"old_role": old_role,
|
"old_role": old_role,
|
||||||
"new_role": role,
|
"new_role": role,
|
||||||
|
"gitea_sync_status": sync_result.get("status"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return RedirectResponse(url="/portal/admin/users?message=" + quote("User role updated."), status_code=303)
|
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")
|
@router.post("/admin/users/{user_id}/disable")
|
||||||
@@ -194,14 +228,21 @@ def disable_user_action(user_id: int, user=Depends(require_user)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
set_user_enabled(user_id, False)
|
set_user_enabled(user_id, False)
|
||||||
|
target = get_user(user_id) or target
|
||||||
|
sync_result = sync_gitea_user(target)
|
||||||
log_audit_event(
|
log_audit_event(
|
||||||
user,
|
user,
|
||||||
action="user.disabled",
|
action="user.disabled",
|
||||||
target_type="user",
|
target_type="user",
|
||||||
target_id=user_id,
|
target_id=user_id,
|
||||||
metadata={"username": target.get("username")},
|
metadata={"username": target.get("username"), "gitea_sync_status": sync_result.get("status")},
|
||||||
)
|
)
|
||||||
return RedirectResponse(url="/portal/admin/users?message=" + quote("User disabled."), status_code=303)
|
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")
|
@router.post("/admin/users/{user_id}/enable")
|
||||||
@@ -212,11 +253,41 @@ def enable_user_action(user_id: int, user=Depends(require_user)):
|
|||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
set_user_enabled(user_id, True)
|
set_user_enabled(user_id, True)
|
||||||
|
target = get_user(user_id) or target
|
||||||
|
sync_result = sync_gitea_user(target)
|
||||||
log_audit_event(
|
log_audit_event(
|
||||||
user,
|
user,
|
||||||
action="user.enabled",
|
action="user.enabled",
|
||||||
target_type="user",
|
target_type="user",
|
||||||
target_id=user_id,
|
target_id=user_id,
|
||||||
metadata={"username": target.get("username")},
|
metadata={"username": target.get("username"), "gitea_sync_status": sync_result.get("status")},
|
||||||
)
|
)
|
||||||
return RedirectResponse(url="/portal/admin/users?message=" + quote("User enabled."), status_code=303)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user