From 8edc6805035ea062107aeda67f4cb47730d7323a Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Fri, 2 Jan 2026 03:48:22 -0300 Subject: [PATCH] portal: fix access requests and account status --- .../atlas_portal/routes/access_requests.py | 17 ++++- backend/atlas_portal/routes/account.py | 73 ++++++++++++------- frontend/src/views/AccountView.vue | 31 ++++++-- frontend/src/views/RequestAccessView.vue | 4 + 4 files changed, 90 insertions(+), 35 deletions(-) diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index 8a7f852..b4a8f32 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -47,15 +47,19 @@ def register(app) -> None: return jsonify({"error": "server not configured"}), 503 ip = _client_ip() + username, email, note = _extract_request_payload() + + rate_key = ip + if username: + rate_key = f"{ip}:{username}" if not rate_limit_allow( - ip, + rate_key, key="access_request_submit", limit=settings.ACCESS_REQUEST_SUBMIT_RATE_LIMIT, window_sec=settings.ACCESS_REQUEST_SUBMIT_RATE_WINDOW_SEC, ): return jsonify({"error": "rate limited"}), 429 - username, email, note = _extract_request_payload() if not username: return jsonify({"error": "username is required"}), 400 @@ -136,6 +140,15 @@ def register(app) -> None: if not code: return jsonify({"error": "request_code is required"}), 400 + # Additional per-code limiter to avoid global NAT rate-limit blowups. + if not rate_limit_allow( + f"{ip}:{code}", + key="access_request_status_code", + limit=max(20, settings.ACCESS_REQUEST_STATUS_RATE_LIMIT), + window_sec=settings.ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC, + ): + return jsonify({"error": "rate limited"}), 429 + try: with connect() as conn: row = conn.execute( diff --git a/backend/atlas_portal/routes/account.py b/backend/atlas_portal/routes/account.py index f9c2d3d..58dd1d8 100644 --- a/backend/atlas_portal/routes/account.py +++ b/backend/atlas_portal/routes/account.py @@ -22,37 +22,48 @@ def register(app) -> None: username = g.keycloak_username keycloak_email = g.keycloak_email or "" mailu_app_password = "" - - if admin_client().ready() and username: - try: - user = admin_client().find_user(username) or {} - user_id = user.get("id") or "" - if user_id: - full = admin_client().get_user(str(user_id)) - if not keycloak_email: - keycloak_email = str(full.get("email") or "") - attrs = full.get("attributes") or {} - if isinstance(attrs, dict): - raw_pw = attrs.get("mailu_app_password") - if isinstance(raw_pw, list) and raw_pw: - mailu_app_password = str(raw_pw[0]) - elif isinstance(raw_pw, str): - mailu_app_password = raw_pw - except Exception: - pass - - mailu_username = "" - if keycloak_email and keycloak_email.lower().endswith(f"@{settings.MAILU_DOMAIN.lower()}"): - mailu_username = keycloak_email - elif username: - mailu_username = f"{username}@{settings.MAILU_DOMAIN}" - mailu_status = "ready" jellyfin_status = "ready" if not admin_client().ready(): mailu_status = "server not configured" jellyfin_status = "server not configured" + elif username: + try: + user = admin_client().find_user(username) or {} + if not keycloak_email: + keycloak_email = str(user.get("email") or "") + + attrs = user.get("attributes") if isinstance(user, dict) else None + if isinstance(attrs, dict): + raw_pw = attrs.get("mailu_app_password") + if isinstance(raw_pw, list) and raw_pw: + mailu_app_password = str(raw_pw[0]) + elif isinstance(raw_pw, str) and raw_pw: + mailu_app_password = raw_pw + + user_id = user.get("id") if isinstance(user, dict) else None + if user_id and (not keycloak_email or not mailu_app_password): + full = admin_client().get_user(str(user_id)) + if not keycloak_email: + keycloak_email = str(full.get("email") or "") + if not mailu_app_password: + attrs = full.get("attributes") or {} + if isinstance(attrs, dict): + raw_pw = attrs.get("mailu_app_password") + if isinstance(raw_pw, list) and raw_pw: + mailu_app_password = str(raw_pw[0]) + elif isinstance(raw_pw, str) and raw_pw: + mailu_app_password = raw_pw + except Exception: + mailu_status = "keycloak admin error" + jellyfin_status = "keycloak admin error" + + mailu_username = "" + if keycloak_email and keycloak_email.lower().endswith(f"@{settings.MAILU_DOMAIN.lower()}"): + mailu_username = keycloak_email + elif username: + mailu_username = f"{username}@{settings.MAILU_DOMAIN}" return jsonify( { @@ -81,9 +92,10 @@ def register(app) -> None: except Exception: return jsonify({"error": "failed to update mail password"}), 502 + sync_enabled = bool(settings.MAILU_SYNC_URL) sync_ok = False sync_error = "" - if settings.MAILU_SYNC_URL: + if sync_enabled: try: with httpx.Client(timeout=30) as client: resp = client.post( @@ -96,4 +108,11 @@ def register(app) -> None: except Exception: sync_error = "sync request failed" - return jsonify({"password": password, "sync_ok": sync_ok, "sync_error": sync_error}) + return jsonify( + { + "password": password, + "sync_enabled": sync_enabled, + "sync_ok": sync_ok, + "sync_error": sync_error, + } + ) diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue index dcdf9c7..d05d6ed 100644 --- a/frontend/src/views/AccountView.vue +++ b/frontend/src/views/AccountView.vue @@ -219,11 +219,14 @@ async function refreshOverview() { mailu.error = ""; jellyfin.error = ""; try { - const resp = await authFetch("/api/account/overview", { headers: { Accept: "application/json" } }); + const resp = await authFetch("/api/account/overview", { + headers: { Accept: "application/json" }, + cache: "no-store", + }); if (!resp.ok) throw new Error(`status ${resp.status}`); const data = await resp.json(); mailu.status = data.mailu?.status || "ready"; - mailu.username = data.mailu?.username || auth.username; + mailu.username = data.mailu?.username || auth.email || auth.username; mailu.currentPassword = data.mailu?.app_password || ""; jellyfin.status = data.jellyfin?.status || "ready"; jellyfin.username = data.jellyfin?.username || auth.username; @@ -239,7 +242,10 @@ async function refreshAdminRequests() { admin.error = ""; admin.loading = true; try { - const resp = await authFetch("/api/admin/access/requests", { headers: { Accept: "application/json" } }); + const resp = await authFetch("/api/admin/access/requests", { + headers: { Accept: "application/json" }, + cache: "no-store", + }); if (resp.status === 403) { admin.enabled = false; admin.requests = []; @@ -267,9 +273,22 @@ async function rotateMailu() { const data = await resp.json().catch(() => ({})); if (!resp.ok) throw new Error(data.error || `status ${resp.status}`); mailu.newPassword = data.password || ""; - mailu.currentPassword = mailu.newPassword; - mailu.revealPassword = true; - mailu.status = "updated"; + if (mailu.newPassword) { + mailu.currentPassword = mailu.newPassword; + mailu.revealPassword = true; + } + const syncEnabled = Boolean(data.sync_enabled); + const syncOk = Boolean(data.sync_ok); + const syncError = data.sync_error || ""; + if (!syncEnabled) { + mailu.status = "updated"; + mailu.error = "Mail sync is not configured; password may not take effect until an admin sync runs."; + } else if (!syncOk) { + mailu.status = "sync pending"; + mailu.error = syncError || "Mail sync did not confirm success yet. Try again in a moment."; + } else { + mailu.status = "updated"; + } } catch (err) { mailu.error = err.message || "Rotation failed"; } finally { diff --git a/frontend/src/views/RequestAccessView.vue b/frontend/src/views/RequestAccessView.vue index 0de4664..7249f71 100644 --- a/frontend/src/views/RequestAccessView.vue +++ b/frontend/src/views/RequestAccessView.vue @@ -148,6 +148,7 @@ async function submit() { const resp = await fetch("/api/access/request", { method: "POST", headers: { "Content-Type": "application/json" }, + cache: "no-store", body: JSON.stringify({ username: form.username.trim(), email: form.email.trim(), @@ -200,6 +201,7 @@ async function checkStatus() { 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() }), }); const data = await resp.json().catch(() => ({})); @@ -208,6 +210,8 @@ async function checkStatus() { onboardingUrl.value = data.onboarding_url || ""; } catch (err) { error.value = err.message || "Failed to check status"; + status.value = "unknown"; + onboardingUrl.value = ""; } finally { checking.value = false; }