diff --git a/backend/atlas_portal/routes/account.py b/backend/atlas_portal/routes/account.py
index 98a80a8..b178afc 100644
--- a/backend/atlas_portal/routes/account.py
+++ b/backend/atlas_portal/routes/account.py
@@ -2,12 +2,14 @@ from __future__ import annotations
import socket
import time
+from urllib.parse import quote
from typing import Any
import httpx
from flask import jsonify, g, request
from .. import settings
+from ..db import connect
from ..keycloak import admin_client, require_auth, require_account_access
from ..nextcloud_mail_sync import trigger as trigger_nextcloud_mail_sync
from ..utils import random_password
@@ -55,6 +57,7 @@ def register(app) -> None:
jellyfin_sync_status = "unknown"
jellyfin_sync_detail = ""
jellyfin_user_is_ldap = False
+ onboarding_url = ""
if not admin_client().ready():
mailu_status = "server not configured"
@@ -277,9 +280,30 @@ def register(app) -> None:
if not vaultwarden_status:
vaultwarden_status = "needs provisioning"
+ if settings.PORTAL_DATABASE_URL and username:
+ request_code = ""
+ try:
+ with connect() as conn:
+ row = conn.execute(
+ "SELECT request_code FROM access_requests WHERE username = %s ORDER BY created_at DESC LIMIT 1",
+ (username,),
+ ).fetchone()
+ if not row and keycloak_email:
+ row = conn.execute(
+ "SELECT request_code FROM access_requests WHERE contact_email = %s ORDER BY created_at DESC LIMIT 1",
+ (keycloak_email,),
+ ).fetchone()
+ if row and isinstance(row, dict):
+ request_code = str(row.get("request_code") or "").strip()
+ except Exception:
+ request_code = ""
+ if request_code:
+ onboarding_url = f"{settings.PORTAL_PUBLIC_BASE_URL}/onboarding?code={quote(request_code)}"
+
return jsonify(
{
"user": {"username": username, "email": keycloak_email, "groups": g.keycloak_groups},
+ "onboarding_url": onboarding_url,
"mailu": {"status": mailu_status, "username": mailu_username, "app_password": mailu_app_password},
"nextcloud_mail": {
"status": nextcloud_mail_status,
diff --git a/backend/atlas_portal/settings.py b/backend/atlas_portal/settings.py
index ec426de..0aaffe8 100644
--- a/backend/atlas_portal/settings.py
+++ b/backend/atlas_portal/settings.py
@@ -123,3 +123,6 @@ VAULTWARDEN_SERVICE_HOST = os.getenv("VAULTWARDEN_SERVICE_HOST", "vaultwarden-se
VAULTWARDEN_ADMIN_SECRET_NAME = os.getenv("VAULTWARDEN_ADMIN_SECRET_NAME", "vaultwarden-admin").strip()
VAULTWARDEN_ADMIN_SECRET_KEY = os.getenv("VAULTWARDEN_ADMIN_SECRET_KEY", "ADMIN_TOKEN").strip()
VAULTWARDEN_ADMIN_SESSION_TTL_SEC = float(os.getenv("VAULTWARDEN_ADMIN_SESSION_TTL_SEC", "300"))
+VAULTWARDEN_ADMIN_RATE_LIMIT_BACKOFF_SEC = float(
+ os.getenv("VAULTWARDEN_ADMIN_RATE_LIMIT_BACKOFF_SEC", "600")
+)
diff --git a/backend/atlas_portal/vaultwarden.py b/backend/atlas_portal/vaultwarden.py
index 55c7773..0a774a3 100644
--- a/backend/atlas_portal/vaultwarden.py
+++ b/backend/atlas_portal/vaultwarden.py
@@ -99,12 +99,15 @@ _ADMIN_LOCK = threading.Lock()
_ADMIN_SESSION: httpx.Client | None = None
_ADMIN_SESSION_EXPIRES_AT: float = 0.0
_ADMIN_SESSION_BASE_URL: str = ""
+_ADMIN_RATE_LIMITED_UNTIL: float = 0.0
def _admin_session(base_url: str) -> httpx.Client:
- global _ADMIN_SESSION, _ADMIN_SESSION_EXPIRES_AT, _ADMIN_SESSION_BASE_URL
+ global _ADMIN_SESSION, _ADMIN_SESSION_EXPIRES_AT, _ADMIN_SESSION_BASE_URL, _ADMIN_RATE_LIMITED_UNTIL
now = time.time()
with _ADMIN_LOCK:
+ if _ADMIN_RATE_LIMITED_UNTIL and now < _ADMIN_RATE_LIMITED_UNTIL:
+ raise RuntimeError("vaultwarden rate limited")
if _ADMIN_SESSION and now < _ADMIN_SESSION_EXPIRES_AT and _ADMIN_SESSION_BASE_URL == base_url:
return _ADMIN_SESSION
@@ -132,6 +135,7 @@ def _admin_session(base_url: str) -> httpx.Client:
# Vaultwarden can rate-limit admin login attempts, so keep this session cached briefly.
resp = client.post("/admin", data={"token": token})
if resp.status_code == 429:
+ _ADMIN_RATE_LIMITED_UNTIL = now + float(settings.VAULTWARDEN_ADMIN_RATE_LIMIT_BACKOFF_SEC)
raise RuntimeError("vaultwarden rate limited")
resp.raise_for_status()
@@ -142,9 +146,12 @@ def _admin_session(base_url: str) -> httpx.Client:
def invite_user(email: str) -> VaultwardenInvite:
+ global _ADMIN_RATE_LIMITED_UNTIL
email = (email or "").strip()
if not email or "@" not in email:
return VaultwardenInvite(ok=False, status="invalid_email", detail="email invalid")
+ if _ADMIN_RATE_LIMITED_UNTIL and time.time() < _ADMIN_RATE_LIMITED_UNTIL:
+ return VaultwardenInvite(ok=False, status="rate_limited", detail="vaultwarden rate limited")
# Prefer the service name when it works; fall back to pod IP because the Service can be misconfigured.
base_url = f"http://{settings.VAULTWARDEN_SERVICE_HOST}"
@@ -163,6 +170,7 @@ def invite_user(email: str) -> VaultwardenInvite:
session = _admin_session(candidate)
resp = session.post("/admin/invite", json={"email": email})
if resp.status_code == 429:
+ _ADMIN_RATE_LIMITED_UNTIL = time.time() + float(settings.VAULTWARDEN_ADMIN_RATE_LIMIT_BACKOFF_SEC)
return VaultwardenInvite(ok=False, status="rate_limited", detail="vaultwarden rate limited")
if resp.status_code in {200, 201, 204}:
@@ -188,7 +196,8 @@ def invite_user(email: str) -> VaultwardenInvite:
last_error = f"status {resp.status_code}"
except Exception as exc:
last_error = str(exc)
+ if "rate limited" in last_error.lower():
+ return VaultwardenInvite(ok=False, status="rate_limited", detail="vaultwarden rate limited")
continue
return VaultwardenInvite(ok=False, status="error", detail=last_error or "failed to invite")
-
diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue
index fa44cfc..f9fe89f 100644
--- a/frontend/src/views/AccountView.vue
+++ b/frontend/src/views/AccountView.vue
@@ -21,7 +21,7 @@
Change password
- Onboarding
+ Onboarding
@@ -386,7 +386,7 @@