diff --git a/backend/atlas_portal/routes/account.py b/backend/atlas_portal/routes/account.py index 58dd1d8..18a880f 100644 --- a/backend/atlas_portal/routes/account.py +++ b/backend/atlas_portal/routes/account.py @@ -1,5 +1,6 @@ from __future__ import annotations +import socket import time from typing import Any @@ -11,6 +12,16 @@ from ..keycloak import admin_client, require_auth, require_account_access from ..utils import random_password +def _tcp_check(host: str, port: int, timeout_sec: float) -> bool: + if not host or port <= 0: + return False + try: + with socket.create_connection((host, port), timeout=timeout_sec): + return True + except OSError: + return False + + def register(app) -> None: @app.route("/api/account/overview", methods=["GET"]) @require_auth @@ -24,10 +35,14 @@ def register(app) -> None: mailu_app_password = "" mailu_status = "ready" jellyfin_status = "ready" + jellyfin_sync_status = "unknown" + jellyfin_sync_detail = "" if not admin_client().ready(): mailu_status = "server not configured" jellyfin_status = "server not configured" + jellyfin_sync_status = "unknown" + jellyfin_sync_detail = "keycloak admin not configured" elif username: try: user = admin_client().find_user(username) or {} @@ -58,6 +73,8 @@ def register(app) -> None: except Exception: mailu_status = "keycloak admin error" jellyfin_status = "keycloak admin error" + jellyfin_sync_status = "unknown" + jellyfin_sync_detail = "keycloak admin error" mailu_username = "" if keycloak_email and keycloak_email.lower().endswith(f"@{settings.MAILU_DOMAIN.lower()}"): @@ -65,11 +82,28 @@ def register(app) -> None: elif username: mailu_username = f"{username}@{settings.MAILU_DOMAIN}" + if jellyfin_status == "ready": + if _tcp_check( + settings.JELLYFIN_LDAP_HOST, + settings.JELLYFIN_LDAP_PORT, + settings.JELLYFIN_LDAP_CHECK_TIMEOUT_SEC, + ): + jellyfin_sync_status = "ok" + jellyfin_sync_detail = "LDAP reachable" + else: + jellyfin_sync_status = "degraded" + jellyfin_sync_detail = "LDAP unreachable" + return jsonify( { "user": {"username": username, "email": keycloak_email, "groups": g.keycloak_groups}, "mailu": {"status": mailu_status, "username": mailu_username, "app_password": mailu_app_password}, - "jellyfin": {"status": jellyfin_status, "username": username}, + "jellyfin": { + "status": jellyfin_status, + "username": username, + "sync_status": jellyfin_sync_status, + "sync_detail": jellyfin_sync_detail, + }, } ) diff --git a/backend/atlas_portal/settings.py b/backend/atlas_portal/settings.py index 8105bde..394b56a 100644 --- a/backend/atlas_portal/settings.py +++ b/backend/atlas_portal/settings.py @@ -77,3 +77,6 @@ MAILU_SYNC_URL = os.getenv( ).rstrip("/") JELLYFIN_SYNC_URL = os.getenv("JELLYFIN_SYNC_URL", "").rstrip("/") +JELLYFIN_LDAP_HOST = os.getenv("JELLYFIN_LDAP_HOST", "openldap.sso.svc.cluster.local").strip() +JELLYFIN_LDAP_PORT = int(os.getenv("JELLYFIN_LDAP_PORT", "389")) +JELLYFIN_LDAP_CHECK_TIMEOUT_SEC = float(os.getenv("JELLYFIN_LDAP_CHECK_TIMEOUT_SEC", "1")) diff --git a/frontend/src/assets/theme.css b/frontend/src/assets/theme.css index 11e1ee8..160b37e 100644 --- a/frontend/src/assets/theme.css +++ b/frontend/src/assets/theme.css @@ -29,6 +29,16 @@ font-size: 13px; } +.pill-ok { + border-color: rgba(120, 255, 160, 0.35); + color: rgba(170, 255, 215, 0.92); +} + +.pill-warn { + border-color: rgba(255, 220, 120, 0.35); + color: rgba(255, 230, 170, 0.92); +} + .card { background: var(--bg-panel); border: 1px solid var(--border); diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue index d05d6ed..1e771ba 100644 --- a/frontend/src/views/AccountView.vue +++ b/frontend/src/views/AccountView.vue @@ -85,7 +85,18 @@

Jellyfin

- {{ jellyfin.status }} + + {{ + jellyfin.syncStatus === "ok" + ? "in sync" + : jellyfin.syncStatus === "degraded" + ? "check sync" + : jellyfin.status + }} +

Jellyfin authentication is backed by LDAP (Keycloak is the source of truth). Use your Keycloak username and @@ -101,6 +112,7 @@ {{ jellyfin.username }}

+
{{ jellyfin.syncDetail }}
{{ jellyfin.error }}
@@ -173,6 +185,8 @@ const mailu = reactive({ const jellyfin = reactive({ status: "loading", username: "", + syncStatus: "", + syncDetail: "", error: "", }); @@ -230,9 +244,13 @@ async function refreshOverview() { mailu.currentPassword = data.mailu?.app_password || ""; jellyfin.status = data.jellyfin?.status || "ready"; jellyfin.username = data.jellyfin?.username || auth.username; + jellyfin.syncStatus = data.jellyfin?.sync_status || ""; + jellyfin.syncDetail = data.jellyfin?.sync_detail || ""; } catch (err) { mailu.status = "unavailable"; jellyfin.status = "unavailable"; + jellyfin.syncStatus = ""; + jellyfin.syncDetail = ""; mailu.error = "Failed to load account status."; jellyfin.error = "Failed to load account status."; } diff --git a/frontend/src/views/RequestAccessView.vue b/frontend/src/views/RequestAccessView.vue index 7249f71..463d70b 100644 --- a/frontend/src/views/RequestAccessView.vue +++ b/frontend/src/views/RequestAccessView.vue @@ -196,13 +196,21 @@ async function copyRequestCode() { async function checkStatus() { if (checking.value) return; error.value = ""; + const trimmed = statusForm.request_code.trim(); + if (!trimmed) return; + if (!trimmed.includes("~")) { + error.value = "Request code should look like username~XXXXXXXXXX. Copy it from the submit step."; + status.value = "unknown"; + onboardingUrl.value = ""; + return; + } checking.value = true; try { const resp = await fetch("/api/access/request/status", { method: "POST", headers: { "Content-Type": "application/json" }, cache: "no-store", - body: JSON.stringify({ request_code: statusForm.request_code.trim() }), + body: JSON.stringify({ request_code: trimmed }), }); const data = await resp.json().catch(() => ({})); if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);