portal: fix access requests and account status

This commit is contained in:
Brad Stein 2026-01-02 03:48:22 -03:00
parent 8b5a8bda3d
commit 8edc680503
4 changed files with 90 additions and 35 deletions

View File

@ -47,15 +47,19 @@ def register(app) -> None:
return jsonify({"error": "server not configured"}), 503 return jsonify({"error": "server not configured"}), 503
ip = _client_ip() ip = _client_ip()
username, email, note = _extract_request_payload()
rate_key = ip
if username:
rate_key = f"{ip}:{username}"
if not rate_limit_allow( if not rate_limit_allow(
ip, rate_key,
key="access_request_submit", key="access_request_submit",
limit=settings.ACCESS_REQUEST_SUBMIT_RATE_LIMIT, limit=settings.ACCESS_REQUEST_SUBMIT_RATE_LIMIT,
window_sec=settings.ACCESS_REQUEST_SUBMIT_RATE_WINDOW_SEC, window_sec=settings.ACCESS_REQUEST_SUBMIT_RATE_WINDOW_SEC,
): ):
return jsonify({"error": "rate limited"}), 429 return jsonify({"error": "rate limited"}), 429
username, email, note = _extract_request_payload()
if not username: if not username:
return jsonify({"error": "username is required"}), 400 return jsonify({"error": "username is required"}), 400
@ -136,6 +140,15 @@ def register(app) -> None:
if not code: if not code:
return jsonify({"error": "request_code is required"}), 400 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: try:
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(

View File

@ -22,37 +22,48 @@ def register(app) -> None:
username = g.keycloak_username username = g.keycloak_username
keycloak_email = g.keycloak_email or "" keycloak_email = g.keycloak_email or ""
mailu_app_password = "" 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" mailu_status = "ready"
jellyfin_status = "ready" jellyfin_status = "ready"
if not admin_client().ready(): if not admin_client().ready():
mailu_status = "server not configured" mailu_status = "server not configured"
jellyfin_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( return jsonify(
{ {
@ -81,9 +92,10 @@ def register(app) -> None:
except Exception: except Exception:
return jsonify({"error": "failed to update mail password"}), 502 return jsonify({"error": "failed to update mail password"}), 502
sync_enabled = bool(settings.MAILU_SYNC_URL)
sync_ok = False sync_ok = False
sync_error = "" sync_error = ""
if settings.MAILU_SYNC_URL: if sync_enabled:
try: try:
with httpx.Client(timeout=30) as client: with httpx.Client(timeout=30) as client:
resp = client.post( resp = client.post(
@ -96,4 +108,11 @@ def register(app) -> None:
except Exception: except Exception:
sync_error = "sync request failed" 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,
}
)

View File

@ -219,11 +219,14 @@ async function refreshOverview() {
mailu.error = ""; mailu.error = "";
jellyfin.error = ""; jellyfin.error = "";
try { 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}`); if (!resp.ok) throw new Error(`status ${resp.status}`);
const data = await resp.json(); const data = await resp.json();
mailu.status = data.mailu?.status || "ready"; 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 || ""; mailu.currentPassword = data.mailu?.app_password || "";
jellyfin.status = data.jellyfin?.status || "ready"; jellyfin.status = data.jellyfin?.status || "ready";
jellyfin.username = data.jellyfin?.username || auth.username; jellyfin.username = data.jellyfin?.username || auth.username;
@ -239,7 +242,10 @@ async function refreshAdminRequests() {
admin.error = ""; admin.error = "";
admin.loading = true; admin.loading = true;
try { 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) { if (resp.status === 403) {
admin.enabled = false; admin.enabled = false;
admin.requests = []; admin.requests = [];
@ -267,9 +273,22 @@ async function rotateMailu() {
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || `status ${resp.status}`); if (!resp.ok) throw new Error(data.error || `status ${resp.status}`);
mailu.newPassword = data.password || ""; mailu.newPassword = data.password || "";
mailu.currentPassword = mailu.newPassword; if (mailu.newPassword) {
mailu.revealPassword = true; mailu.currentPassword = mailu.newPassword;
mailu.status = "updated"; 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) { } catch (err) {
mailu.error = err.message || "Rotation failed"; mailu.error = err.message || "Rotation failed";
} finally { } finally {

View File

@ -148,6 +148,7 @@ async function submit() {
const resp = await fetch("/api/access/request", { const resp = await fetch("/api/access/request", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
cache: "no-store",
body: JSON.stringify({ body: JSON.stringify({
username: form.username.trim(), username: form.username.trim(),
email: form.email.trim(), email: form.email.trim(),
@ -200,6 +201,7 @@ async function checkStatus() {
const resp = await fetch("/api/access/request/status", { const resp = await fetch("/api/access/request/status", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
cache: "no-store",
body: JSON.stringify({ request_code: statusForm.request_code.trim() }), body: JSON.stringify({ request_code: statusForm.request_code.trim() }),
}); });
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
@ -208,6 +210,8 @@ async function checkStatus() {
onboardingUrl.value = data.onboarding_url || ""; onboardingUrl.value = data.onboarding_url || "";
} catch (err) { } catch (err) {
error.value = err.message || "Failed to check status"; error.value = err.message || "Failed to check status";
status.value = "unknown";
onboardingUrl.value = "";
} finally { } finally {
checking.value = false; checking.value = false;
} }