Upravil jsem Google OAuth tak, že Google uživatel se hledá/vytváří přes email, ukládá auth_provider='google', provider_subject, display_name, avatar_url a po úspěšném loginu last_login_at. První Google uživatel v prázdné users tabulce dostane admin, další noví Google uživatelé viewer.
Přidal jsem AUTH_MODE=google|mixed|local: google: jen Google login, lokální/Gitea nejdou ani přes přímý endpoint mixed: Google + lokální/Gitea fallback local: jen lokální login Správa uživatelů zůstává admin-only, guard posledního admina platí pro odebrání role i deaktivaci. Přidal jsem do ní i přehled oprávnění rolí a zobrazení provider/avatar metadat. DB migrace teď přes PRAGMA table_info(users) doplňuje chybějící sloupce bez mazání/recreate existující tabulky; pokud users vůbec neexistuje, bezpečně ji vytvoří. Podporované sloupce zahrnují email, display_name, role, is_enabled, created_at, updated_at, last_login_at, auth_provider, provider_subject, plus kompatibilní sloupce pro lokální login.
This commit is contained in:
+107
-1
@@ -17,10 +17,21 @@ def _table_columns(con, table_name: str) -> set[str]:
|
|||||||
def _user_select_columns(con) -> str:
|
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"
|
||||||
return f"id, username, display_name, email, password_hash, role, is_active, {enabled_select}, created_at, updated_at"
|
optional_columns = []
|
||||||
|
for column in ("last_login_at", "auth_provider", "provider_subject", "avatar_url"):
|
||||||
|
if column in columns:
|
||||||
|
optional_columns.append(column)
|
||||||
|
else:
|
||||||
|
optional_columns.append(f"NULL AS {column}")
|
||||||
|
return (
|
||||||
|
"id, username, display_name, email, password_hash, role, "
|
||||||
|
f"is_active, {enabled_select}, created_at, updated_at, "
|
||||||
|
+ ", ".join(optional_columns)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def mark_last_login(user_id: int) -> None:
|
def mark_last_login(user_id: int) -> None:
|
||||||
|
run_migrations()
|
||||||
con = get_connection()
|
con = get_connection()
|
||||||
columns = _table_columns(con, "users")
|
columns = _table_columns(con, "users")
|
||||||
if "last_login_at" in columns:
|
if "last_login_at" in columns:
|
||||||
@@ -125,6 +136,101 @@ def find_or_create_oauth_user(username: str, display_name: str, email: str) -> d
|
|||||||
return dict(user)
|
return dict(user)
|
||||||
|
|
||||||
|
|
||||||
|
def find_or_create_google_user(email: str, display_name: str, provider_subject: str, avatar_url: str = "") -> tuple[dict[str, Any], bool]:
|
||||||
|
email = email.strip().lower()
|
||||||
|
display_name = (display_name or email).strip() or email
|
||||||
|
provider_subject = (provider_subject or "").strip()
|
||||||
|
avatar_url = (avatar_url or "").strip()
|
||||||
|
|
||||||
|
run_migrations()
|
||||||
|
con = get_connection()
|
||||||
|
columns = _table_columns(con, "users")
|
||||||
|
params: list[Any] = [email, email]
|
||||||
|
provider_clause = ""
|
||||||
|
if provider_subject:
|
||||||
|
provider_clause = " OR (auth_provider = 'google' AND provider_subject = ?)"
|
||||||
|
params.append(provider_subject)
|
||||||
|
|
||||||
|
row = con.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {_user_select_columns(con)}
|
||||||
|
FROM users
|
||||||
|
WHERE LOWER(COALESCE(email, '')) = ?
|
||||||
|
OR LOWER(COALESCE(username, '')) = ?
|
||||||
|
{provider_clause}
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN LOWER(COALESCE(email, '')) = ? THEN 0
|
||||||
|
WHEN LOWER(COALESCE(username, '')) = ? THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END,
|
||||||
|
id
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(*params, email, email),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
updates = [
|
||||||
|
"display_name = ?",
|
||||||
|
"email = ?",
|
||||||
|
"auth_provider = 'google'",
|
||||||
|
"provider_subject = ?",
|
||||||
|
"updated_at = CURRENT_TIMESTAMP",
|
||||||
|
]
|
||||||
|
values: list[Any] = [display_name, email, provider_subject]
|
||||||
|
if "avatar_url" in columns:
|
||||||
|
updates.append("avatar_url = ?")
|
||||||
|
values.append(avatar_url)
|
||||||
|
values.append(row["id"])
|
||||||
|
con.execute(f"UPDATE users SET {', '.join(updates)} WHERE id = ?", values)
|
||||||
|
created = False
|
||||||
|
user_id = row["id"]
|
||||||
|
else:
|
||||||
|
role = "admin" if con.execute("SELECT COUNT(*) AS count FROM users").fetchone()["count"] == 0 else "viewer"
|
||||||
|
insert_columns = [
|
||||||
|
"username",
|
||||||
|
"display_name",
|
||||||
|
"email",
|
||||||
|
"password_hash",
|
||||||
|
"role",
|
||||||
|
"is_active",
|
||||||
|
"is_enabled",
|
||||||
|
"auth_provider",
|
||||||
|
"provider_subject",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
values = [email, display_name, email, "", role, 1, 1, "google", provider_subject]
|
||||||
|
placeholders = ["?", "?", "?", "?", "?", "?", "?", "?", "?", "CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP"]
|
||||||
|
if "avatar_url" in columns:
|
||||||
|
insert_columns.append("avatar_url")
|
||||||
|
values.append(avatar_url)
|
||||||
|
placeholders.append("?")
|
||||||
|
con.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO users ({', '.join(insert_columns)})
|
||||||
|
VALUES ({', '.join(placeholders)})
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
created = True
|
||||||
|
user_id = con.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||||
|
|
||||||
|
con.commit()
|
||||||
|
user = con.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {_user_select_columns(con)}
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
con.close()
|
||||||
|
return dict(user), created
|
||||||
|
|
||||||
|
|
||||||
def get_user_by_id(user_id: int) -> dict[str, Any] | None:
|
def get_user_by_id(user_id: int) -> dict[str, Any] | None:
|
||||||
run_migrations()
|
run_migrations()
|
||||||
con = get_connection()
|
con = get_connection()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ DEFAULT_GITEA_ORG = "appfactory"
|
|||||||
INTERNAL_GITEA_URL = "http://appfactory-gitea:3000"
|
INTERNAL_GITEA_URL = "http://appfactory-gitea:3000"
|
||||||
|
|
||||||
PORTAL_PREFIX = "/portal"
|
PORTAL_PREFIX = "/portal"
|
||||||
|
AUTH_MODES = {"google", "mixed", "local"}
|
||||||
|
|
||||||
|
|
||||||
def read_env_value(key: str, default: str = "") -> str:
|
def read_env_value(key: str, default: str = "") -> str:
|
||||||
@@ -107,6 +108,9 @@ def get_auth_domain_readiness() -> dict[str, bool]:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"portal_public_url_configured": bool(get_portal_public_url()),
|
"portal_public_url_configured": bool(get_portal_public_url()),
|
||||||
|
"auth_mode_google": get_auth_mode() == "google",
|
||||||
|
"auth_mode_mixed": get_auth_mode() == "mixed",
|
||||||
|
"auth_mode_local": get_auth_mode() == "local",
|
||||||
"google_oauth_enabled": google_enabled,
|
"google_oauth_enabled": google_enabled,
|
||||||
"google_oauth_configured": bool(google_client_id and google_client_secret and google_redirect_uri),
|
"google_oauth_configured": bool(google_client_id and google_client_secret and google_redirect_uri),
|
||||||
"google_redirect_uri_configured": bool(google_redirect_uri),
|
"google_redirect_uri_configured": bool(google_redirect_uri),
|
||||||
@@ -114,9 +118,17 @@ def get_auth_domain_readiness() -> dict[str, bool]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_mode() -> str:
|
||||||
|
mode = read_env_value("AUTH_MODE", "mixed").strip().lower()
|
||||||
|
if mode in AUTH_MODES:
|
||||||
|
return mode
|
||||||
|
return "mixed"
|
||||||
|
|
||||||
|
|
||||||
def is_google_oauth_button_enabled() -> bool:
|
def is_google_oauth_button_enabled() -> bool:
|
||||||
return bool(
|
return bool(
|
||||||
read_env_bool("GOOGLE_OAUTH_ENABLED")
|
read_env_bool("GOOGLE_OAUTH_ENABLED")
|
||||||
and read_env_value("GOOGLE_CLIENT_ID", "")
|
and read_env_value("GOOGLE_CLIENT_ID", "")
|
||||||
and read_env_value("GOOGLE_CLIENT_SECRET", "")
|
and read_env_value("GOOGLE_CLIENT_SECRET", "")
|
||||||
|
and get_google_redirect_uri()
|
||||||
)
|
)
|
||||||
|
|||||||
+46
-7
@@ -7,18 +7,57 @@ def _table_columns(con, table_name: str) -> set[str]:
|
|||||||
|
|
||||||
|
|
||||||
def ensure_user_columns(con) -> None:
|
def ensure_user_columns(con) -> None:
|
||||||
|
con.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
display_name TEXT,
|
||||||
|
email TEXT,
|
||||||
|
password_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer',
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_login_at TEXT,
|
||||||
|
auth_provider TEXT,
|
||||||
|
provider_subject TEXT,
|
||||||
|
avatar_url TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
columns = _table_columns(con, "users")
|
columns = _table_columns(con, "users")
|
||||||
if not columns:
|
if not columns:
|
||||||
return
|
return
|
||||||
|
|
||||||
if "is_enabled" not in columns:
|
defaults = {
|
||||||
default_expression = "COALESCE(is_active, 1)" if "is_active" in columns else "1"
|
"username": "TEXT",
|
||||||
con.execute("ALTER TABLE users ADD COLUMN is_enabled INTEGER NOT NULL DEFAULT 1")
|
"display_name": "TEXT",
|
||||||
con.execute(f"UPDATE users SET is_enabled = {default_expression} WHERE is_enabled IS NULL OR is_enabled = 1")
|
"email": "TEXT",
|
||||||
|
"password_hash": "TEXT NOT NULL DEFAULT ''",
|
||||||
|
"role": "TEXT NOT NULL DEFAULT 'viewer'",
|
||||||
|
"is_active": "INTEGER NOT NULL DEFAULT 1",
|
||||||
|
"is_enabled": "INTEGER NOT NULL DEFAULT 1",
|
||||||
|
"created_at": "TEXT",
|
||||||
|
"updated_at": "TEXT",
|
||||||
|
"last_login_at": "TEXT",
|
||||||
|
"auth_provider": "TEXT",
|
||||||
|
"provider_subject": "TEXT",
|
||||||
|
"avatar_url": "TEXT",
|
||||||
|
}
|
||||||
|
for column, definition in defaults.items():
|
||||||
|
if column not in columns:
|
||||||
|
con.execute(f"ALTER TABLE users ADD COLUMN {column} {definition}")
|
||||||
|
|
||||||
if "updated_at" not in columns:
|
columns = _table_columns(con, "users")
|
||||||
con.execute("ALTER TABLE users ADD COLUMN updated_at TEXT")
|
if "is_active" in columns and "is_enabled" in columns:
|
||||||
con.execute("UPDATE users SET updated_at = COALESCE(created_at, CURRENT_TIMESTAMP) WHERE updated_at 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 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("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)")
|
||||||
|
con.execute("CREATE INDEX IF NOT EXISTS idx_users_provider_subject ON users(auth_provider, provider_subject)")
|
||||||
|
|
||||||
|
|
||||||
def run_migrations():
|
def run_migrations():
|
||||||
|
|||||||
+15
-2
@@ -16,6 +16,9 @@ def list_users() -> list[dict[str, Any]]:
|
|||||||
con = get_connection()
|
con = get_connection()
|
||||||
columns = _table_columns(con, "users")
|
columns = _table_columns(con, "users")
|
||||||
last_login_select = "last_login_at" if "last_login_at" in columns else "NULL AS last_login_at"
|
last_login_select = "last_login_at" if "last_login_at" in columns else "NULL AS last_login_at"
|
||||||
|
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"
|
||||||
|
avatar_url_select = "avatar_url" if "avatar_url" in columns else "NULL AS avatar_url"
|
||||||
|
|
||||||
rows = con.execute(
|
rows = con.execute(
|
||||||
f"""
|
f"""
|
||||||
@@ -28,7 +31,10 @@ def list_users() -> list[dict[str, Any]]:
|
|||||||
COALESCE(is_enabled, 1) AS is_enabled,
|
COALESCE(is_enabled, 1) AS is_enabled,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at,
|
updated_at,
|
||||||
{last_login_select}
|
{last_login_select},
|
||||||
|
{auth_provider_select},
|
||||||
|
{provider_subject_select},
|
||||||
|
{avatar_url_select}
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
"""
|
"""
|
||||||
@@ -43,6 +49,9 @@ def get_user(user_id: int) -> dict[str, Any] | None:
|
|||||||
con = get_connection()
|
con = get_connection()
|
||||||
columns = _table_columns(con, "users")
|
columns = _table_columns(con, "users")
|
||||||
last_login_select = "last_login_at" if "last_login_at" in columns else "NULL AS last_login_at"
|
last_login_select = "last_login_at" if "last_login_at" in columns else "NULL AS last_login_at"
|
||||||
|
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"
|
||||||
|
avatar_url_select = "avatar_url" if "avatar_url" in columns else "NULL AS avatar_url"
|
||||||
|
|
||||||
row = con.execute(
|
row = con.execute(
|
||||||
f"""
|
f"""
|
||||||
@@ -55,7 +64,10 @@ def get_user(user_id: int) -> dict[str, Any] | None:
|
|||||||
COALESCE(is_enabled, 1) AS is_enabled,
|
COALESCE(is_enabled, 1) AS is_enabled,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at,
|
updated_at,
|
||||||
{last_login_select}
|
{last_login_select},
|
||||||
|
{auth_provider_select},
|
||||||
|
{provider_subject_select},
|
||||||
|
{avatar_url_select}
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""",
|
""",
|
||||||
@@ -81,6 +93,7 @@ def count_enabled_admins(excluding_user_id: int | None = None) -> int:
|
|||||||
FROM users
|
FROM users
|
||||||
WHERE LOWER(COALESCE(role, '')) = 'admin'
|
WHERE LOWER(COALESCE(role, '')) = 'admin'
|
||||||
AND COALESCE(is_enabled, 1) = 1
|
AND COALESCE(is_enabled, 1) = 1
|
||||||
|
AND COALESCE(is_active, 1) = 1
|
||||||
{exclusion}
|
{exclusion}
|
||||||
""",
|
""",
|
||||||
params,
|
params,
|
||||||
|
|||||||
+69
-5
@@ -9,8 +9,9 @@ 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_oauth_user, mark_last_login
|
from app.auth import authenticate_user, current_user, find_or_create_google_user, find_or_create_oauth_user, mark_last_login
|
||||||
from app.config import (
|
from app.config import (
|
||||||
|
get_auth_mode,
|
||||||
get_gitea_public_url,
|
get_gitea_public_url,
|
||||||
get_gitea_redirect_uri,
|
get_gitea_redirect_uri,
|
||||||
get_gitea_server_url,
|
get_gitea_server_url,
|
||||||
@@ -38,6 +39,9 @@ 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":
|
||||||
|
return _render_login("Local login is disabled. Use Google sign in.")
|
||||||
|
|
||||||
user = authenticate_user(username, password)
|
user = authenticate_user(username, password)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
@@ -60,6 +64,8 @@ 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 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", "")
|
||||||
@@ -92,6 +98,9 @@ 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 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:
|
||||||
_log_gitea_failure("provider_error", error=error)
|
_log_gitea_failure("provider_error", error=error)
|
||||||
@@ -141,6 +150,8 @@ def gitea_callback(request: Request, code: str = "", state: str = "", error: str
|
|||||||
def google_login(request: Request):
|
def google_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() == "local":
|
||||||
|
return _render_login("Google login is not enabled in local auth mode.")
|
||||||
|
|
||||||
if not is_google_oauth_button_enabled():
|
if not is_google_oauth_button_enabled():
|
||||||
_log_google_failure("missing_oauth_config")
|
_log_google_failure("missing_oauth_config")
|
||||||
@@ -176,6 +187,9 @@ def google_login(request: Request):
|
|||||||
|
|
||||||
@router.get("/auth/google/callback", response_class=HTMLResponse)
|
@router.get("/auth/google/callback", response_class=HTMLResponse)
|
||||||
def google_callback(request: Request, code: str = "", state: str = "", error: str = ""):
|
def google_callback(request: Request, code: str = "", state: str = "", error: str = ""):
|
||||||
|
if get_auth_mode() == "local":
|
||||||
|
return _render_login("Google login is not enabled in local auth mode.")
|
||||||
|
|
||||||
expected_state = request.session.pop("google_oauth_state", None)
|
expected_state = request.session.pop("google_oauth_state", None)
|
||||||
if error:
|
if error:
|
||||||
_log_google_failure("provider_error", error=error)
|
_log_google_failure("provider_error", error=error)
|
||||||
@@ -204,7 +218,9 @@ def google_callback(request: Request, code: str = "", state: str = "", error: st
|
|||||||
|
|
||||||
username = email
|
username = email
|
||||||
display_name = (google_user.get("name") or email).strip()
|
display_name = (google_user.get("name") or email).strip()
|
||||||
user = find_or_create_oauth_user(username, display_name, email)
|
provider_subject = (google_user.get("sub") or "").strip()
|
||||||
|
avatar_url = (google_user.get("picture") or "").strip()
|
||||||
|
user, created = find_or_create_google_user(email, display_name, provider_subject, avatar_url)
|
||||||
if not user.get("is_enabled", 1):
|
if not user.get("is_enabled", 1):
|
||||||
_log_google_failure("disabled_user", username=username)
|
_log_google_failure("disabled_user", username=username)
|
||||||
return _render_login("User is disabled in the portal.")
|
return _render_login("User is disabled in the portal.")
|
||||||
@@ -215,6 +231,19 @@ def google_callback(request: Request, code: str = "", state: str = "", error: st
|
|||||||
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"]))
|
||||||
|
if created:
|
||||||
|
log_audit_event(
|
||||||
|
user,
|
||||||
|
action="auth.google.user.created",
|
||||||
|
target_type="user",
|
||||||
|
target_id=user.get("id"),
|
||||||
|
metadata={
|
||||||
|
"username": user.get("username"),
|
||||||
|
"email": user.get("email"),
|
||||||
|
"role": user.get("role"),
|
||||||
|
"provider": "google",
|
||||||
|
},
|
||||||
|
)
|
||||||
log_audit_event(
|
log_audit_event(
|
||||||
user,
|
user,
|
||||||
action="auth.google.login.success",
|
action="auth.google.login.success",
|
||||||
@@ -366,20 +395,55 @@ def _render_login(error: str | None = None) -> str:
|
|||||||
error_html = ""
|
error_html = ""
|
||||||
if error:
|
if error:
|
||||||
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()
|
||||||
gitea_login_html = ""
|
gitea_login_html = ""
|
||||||
if is_gitea_oauth_button_enabled():
|
if auth_mode == "mixed" and is_gitea_oauth_button_enabled():
|
||||||
gitea_login_html = """
|
gitea_login_html = """
|
||||||
<p>
|
<p>
|
||||||
<a class="btn" href="/portal/auth/gitea/login">Sign in with Gitea</a>
|
<a class="btn btn-secondary" href="/portal/auth/gitea/login">Sign in with Gitea</a>
|
||||||
</p>
|
</p>
|
||||||
"""
|
"""
|
||||||
google_login_html = ""
|
google_login_html = ""
|
||||||
if is_google_oauth_button_enabled():
|
if auth_mode in {"google", "mixed"} and is_google_oauth_button_enabled():
|
||||||
google_login_html = """
|
google_login_html = """
|
||||||
<p>
|
<p>
|
||||||
<a class="btn" href="/portal/auth/google/login">Sign in with Google</a>
|
<a class="btn" href="/portal/auth/google/login">Sign in with Google</a>
|
||||||
</p>
|
</p>
|
||||||
"""
|
"""
|
||||||
|
elif auth_mode in {"google", "mixed"}:
|
||||||
|
google_login_html = '<p class="alert alert-danger">Google login is not fully configured.</p>'
|
||||||
|
|
||||||
|
local_login_html = ""
|
||||||
|
if auth_mode in {"mixed", "local"}:
|
||||||
|
local_login_html = """
|
||||||
|
<form method="post" action="/portal/login">
|
||||||
|
<h3>Local login</h3>
|
||||||
|
<p>
|
||||||
|
<label>Username</label><br>
|
||||||
|
<input name="username" autocomplete="username" required autofocus>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<label>Password</label><br>
|
||||||
|
<input type="password" name="password" autocomplete="current-password" required>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-secondary">Sign in locally</button>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return page(
|
||||||
|
"Sign in",
|
||||||
|
f"""
|
||||||
|
<div class="auth-card card">
|
||||||
|
<h2>CSBot Services Portal</h2>
|
||||||
|
{error_html}
|
||||||
|
{google_login_html}
|
||||||
|
{gitea_login_html}
|
||||||
|
{local_login_html}
|
||||||
|
</div>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
return page(
|
return page(
|
||||||
"Přihlášení",
|
"Přihlášení",
|
||||||
|
|||||||
+37
-1
@@ -31,6 +31,34 @@ def render_enabled_pill(enabled) -> str:
|
|||||||
return '<span class="pill pill-muted">disabled</span>'
|
return '<span class="pill pill-muted">disabled</span>'
|
||||||
|
|
||||||
|
|
||||||
|
def render_role_overview() -> str:
|
||||||
|
role_rows = (
|
||||||
|
("admin", "Full administration, user management, role changes, disabling/enabling users, and admin-only operations."),
|
||||||
|
("developer", "Operational portal access for application and service workflows. No access to Administration / Users."),
|
||||||
|
("viewer", "Default Google role. Read-oriented portal access where routes allow it. No access to Administration / Users."),
|
||||||
|
)
|
||||||
|
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)
|
@router.get("/admin/users", response_class=HTMLResponse)
|
||||||
def users_page(request: Request, message: str = "", error: str = "", user=Depends(require_user)):
|
def users_page(request: Request, message: str = "", error: str = "", user=Depends(require_user)):
|
||||||
require_admin(user)
|
require_admin(user)
|
||||||
@@ -49,6 +77,9 @@ def users_page(request: Request, message: str = "", error: str = "", user=Depend
|
|||||||
display_name = html.escape(managed_user.get("display_name", "") or "")
|
display_name = html.escape(managed_user.get("display_name", "") or "")
|
||||||
email = html.escape(managed_user.get("email", "") or "")
|
email = html.escape(managed_user.get("email", "") or "")
|
||||||
role = html.escape(managed_user.get("role", "") 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 "")
|
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))
|
||||||
@@ -60,7 +91,9 @@ def users_page(request: Request, message: str = "", error: str = "", user=Depend
|
|||||||
<td>{user_id_html}</td>
|
<td>{user_id_html}</td>
|
||||||
<td><strong>{username}</strong></td>
|
<td><strong>{username}</strong></td>
|
||||||
<td>{display_name}</td>
|
<td>{display_name}</td>
|
||||||
|
<td>{avatar}</td>
|
||||||
<td>{email}</td>
|
<td>{email}</td>
|
||||||
|
<td>{auth_provider}</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="/portal/admin/users/{user_id_html}/role" class="inline-form">
|
<form method="post" action="/portal/admin/users/{user_id_html}/role" class="inline-form">
|
||||||
<select name="role">{render_role_options(role)}</select>
|
<select name="role">{render_role_options(role)}</select>
|
||||||
@@ -79,7 +112,7 @@ def users_page(request: Request, message: str = "", error: str = "", user=Depend
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
rows = '<tr><td colspan="9">No users found.</td></tr>'
|
rows = '<tr><td colspan="11">No users found.</td></tr>'
|
||||||
|
|
||||||
return page(
|
return page(
|
||||||
"Users",
|
"Users",
|
||||||
@@ -97,7 +130,9 @@ def users_page(request: Request, message: str = "", error: str = "", user=Depend
|
|||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Username</th>
|
<th>Username</th>
|
||||||
<th>Display name</th>
|
<th>Display name</th>
|
||||||
|
<th>Avatar</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
|
<th>Provider</th>
|
||||||
<th>Role</th>
|
<th>Role</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
@@ -107,6 +142,7 @@ def users_page(request: Request, message: str = "", error: str = "", user=Depend
|
|||||||
{rows}
|
{rows}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{render_role_overview()}
|
||||||
""",
|
""",
|
||||||
user=user,
|
user=user,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -168,6 +168,15 @@ nav a:hover,
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #eef6f9;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
margin: 48px auto 24px;
|
margin: 48px auto 24px;
|
||||||
|
|||||||
Reference in New Issue
Block a user