From 2f703005fcd5f4c564ce875ceab58235105deab2 Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 21 Apr 2026 07:25:40 -0300 Subject: [PATCH] docs(bstein-home): document full source gate surface --- backend/atlas_portal/ariadne_client.py | 16 +++++ backend/atlas_portal/db.py | 16 +++++ backend/atlas_portal/firefly_user_sync.py | 12 ++++ backend/atlas_portal/k8s.py | 5 +- backend/atlas_portal/keycloak.py | 66 +++++++++++++++++++ backend/atlas_portal/mailer.py | 7 +- backend/atlas_portal/migrate.py | 2 + backend/atlas_portal/nextcloud_mail_sync.py | 13 +++- .../routes/access_request_onboarding.py | 9 +++ .../routes/access_request_state.py | 16 +++++ .../routes/access_request_status.py | 8 +++ .../routes/access_request_submission.py | 14 ++++ .../atlas_portal/routes/account_actions.py | 12 ++++ .../atlas_portal/routes/account_overview.py | 6 ++ backend/atlas_portal/routes/admin_access.py | 14 ++++ backend/atlas_portal/routes/ai.py | 18 +++++ backend/atlas_portal/routes/lab.py | 8 +++ backend/atlas_portal/vaultwarden.py | 18 +++++ backend/atlas_portal/wger_user_sync.py | 12 ++++ frontend/src/account/useAccountDashboard.js | 2 + frontend/src/components/MermaidCard.vue | 13 ++++ frontend/src/onboarding/onboardingLabels.js | 25 +++++-- frontend/src/onboarding/useOnboardingFlow.js | 4 +- .../src/onboarding/useOnboardingGuides.js | 6 ++ .../request-access/useRequestAccessFlow.js | 19 ++++++ frontend/src/views/AiView.vue | 6 ++ 26 files changed, 338 insertions(+), 9 deletions(-) diff --git a/backend/atlas_portal/ariadne_client.py b/backend/atlas_portal/ariadne_client.py index 42aff29..eb5f865 100644 --- a/backend/atlas_portal/ariadne_client.py +++ b/backend/atlas_portal/ariadne_client.py @@ -13,12 +13,16 @@ logger = logging.getLogger(__name__) class AriadneError(Exception): + """Carry an upstream-facing error message and HTTP status code.""" + def __init__(self, message: str, status_code: int = 502) -> None: super().__init__(message) self.status_code = status_code def enabled() -> bool: + """Return whether Ariadne proxying is configured for this portal.""" + return bool(settings.ARIADNE_URL) @@ -40,6 +44,12 @@ def request_raw( payload: Any | None = None, params: dict[str, Any] | None = None, ) -> httpx.Response: + """Send one authenticated request to Ariadne and return the raw response. + + WHY: callers need the exact upstream status/body so local routes can act as + a transparent compatibility proxy while still applying retry telemetry. + """ + if not enabled(): raise AriadneError("ariadne not configured", 503) @@ -84,6 +94,12 @@ def proxy( payload: Any | None = None, params: dict[str, Any] | None = None, ) -> tuple[Any, int]: + """Proxy an Ariadne response through Flask as JSON plus status code. + + WHY: route handlers should share upstream error normalization instead of + duplicating JSON parsing and outage handling at each call site. + """ + try: resp = request_raw(method, path, payload=payload, params=params) except AriadneError as exc: diff --git a/backend/atlas_portal/db.py b/backend/atlas_portal/db.py index 0be83a1..c580635 100644 --- a/backend/atlas_portal/db.py +++ b/backend/atlas_portal/db.py @@ -15,10 +15,14 @@ _pool: ConnectionPool | None = None def configured() -> bool: + """Return whether the portal has enough database configuration to connect.""" + return bool(settings.PORTAL_DATABASE_URL) def _pool_kwargs() -> dict[str, Any]: + """Build shared psycopg pool options for Atlas portal connections.""" + options = ( f"-c lock_timeout={settings.PORTAL_DB_LOCK_TIMEOUT_SEC}s " f"-c statement_timeout={settings.PORTAL_DB_STATEMENT_TIMEOUT_SEC}s " @@ -33,6 +37,8 @@ def _pool_kwargs() -> dict[str, Any]: def _get_pool() -> ConnectionPool: + """Return the singleton Postgres connection pool for request handlers.""" + global _pool if _pool is None: if not settings.PORTAL_DATABASE_URL: @@ -48,6 +54,8 @@ def _get_pool() -> ConnectionPool: @contextmanager def connect() -> Iterator[psycopg.Connection[Any]]: + """Yield a dict-row Postgres connection from the shared pool.""" + if not settings.PORTAL_DATABASE_URL: raise RuntimeError("portal database not configured") with _get_pool().connection() as conn: @@ -70,6 +78,12 @@ def _release_advisory_lock(conn: psycopg.Connection[Any], lock_id: int) -> None: def run_migrations() -> None: + """Create and upgrade the portal schema using an advisory lock. + + WHY: every replica may start concurrently, so schema changes must be safe + to run repeatedly without allowing multiple pods to migrate at once. + """ + if not settings.PORTAL_DATABASE_URL or not settings.PORTAL_RUN_MIGRATIONS: return with connect() as conn: @@ -193,4 +207,6 @@ def run_migrations() -> None: def ensure_schema() -> None: + """Run startup migrations through a small semantic wrapper.""" + run_migrations() diff --git a/backend/atlas_portal/firefly_user_sync.py b/backend/atlas_portal/firefly_user_sync.py index 00c9e39..ee34e55 100644 --- a/backend/atlas_portal/firefly_user_sync.py +++ b/backend/atlas_portal/firefly_user_sync.py @@ -21,6 +21,8 @@ def _job_from_cronjob( email: str, password: str, ) -> dict[str, Any]: + """Render a one-off Firefly user sync Job from the managed CronJob template.""" + spec = cronjob.get("spec") if isinstance(cronjob.get("spec"), dict) else {} jt = spec.get("jobTemplate") if isinstance(spec.get("jobTemplate"), dict) else {} job_spec = jt.get("spec") if isinstance(jt.get("spec"), dict) else {} @@ -70,6 +72,8 @@ def _job_from_cronjob( def _job_succeeded(job: dict[str, Any]) -> bool: + """Return whether Kubernetes reports the sync Job as successfully complete.""" + status = job.get("status") if isinstance(job.get("status"), dict) else {} if int(status.get("succeeded") or 0) > 0: return True @@ -83,6 +87,8 @@ def _job_succeeded(job: dict[str, Any]) -> bool: def _job_failed(job: dict[str, Any]) -> bool: + """Return whether Kubernetes reports the sync Job as failed.""" + status = job.get("status") if isinstance(job.get("status"), dict) else {} if int(status.get("failed") or 0) > 0: return True @@ -96,6 +102,12 @@ def _job_failed(job: dict[str, Any]) -> bool: def trigger(username: str, email: str, password: str, wait: bool = True) -> dict[str, Any]: + """Start the Firefly sync Job for one user and optionally wait for completion. + + WHY: account self-service should be able to repair one user without waiting + for the broader scheduled reconciliation loop. + """ + username = (username or "").strip() if not username: raise RuntimeError("missing username") diff --git a/backend/atlas_portal/k8s.py b/backend/atlas_portal/k8s.py index 26d689b..3d963d0 100644 --- a/backend/atlas_portal/k8s.py +++ b/backend/atlas_portal/k8s.py @@ -24,6 +24,8 @@ def _read_service_account() -> tuple[str, str]: def get_json(path: str) -> dict[str, Any]: + """Fetch a Kubernetes API object as JSON using the pod service account.""" + token, ca_path = _read_service_account() url = f"{_K8S_BASE_URL}{path}" with httpx.Client( @@ -40,6 +42,8 @@ def get_json(path: str) -> dict[str, Any]: def post_json(path: str, payload: dict[str, Any]) -> dict[str, Any]: + """Post a JSON payload to Kubernetes using the pod service account.""" + token, ca_path = _read_service_account() url = f"{_K8S_BASE_URL}{path}" with httpx.Client( @@ -53,4 +57,3 @@ def post_json(path: str, payload: dict[str, Any]) -> dict[str, Any]: if not isinstance(data, dict): raise RuntimeError("unexpected kubernetes response") return data - diff --git a/backend/atlas_portal/keycloak.py b/backend/atlas_portal/keycloak.py index a5d72b5..e135128 100644 --- a/backend/atlas_portal/keycloak.py +++ b/backend/atlas_portal/keycloak.py @@ -14,6 +14,8 @@ from . import settings class KeycloakOIDC: + """Verify user-facing Keycloak tokens for portal requests.""" + def __init__(self) -> None: self._jwk_client: PyJWKClient | None = None @@ -23,6 +25,12 @@ class KeycloakOIDC: return self._jwk_client def verify(self, token: str) -> dict[str, Any]: + """Validate a bearer token and return decoded claims. + + WHY: the portal trusts Keycloak groups and usernames only after issuer + and client ownership are checked locally. + """ + if not settings.KEYCLOAK_ENABLED: raise ValueError("keycloak not enabled") @@ -50,6 +58,8 @@ class KeycloakOIDC: class KeycloakAdminClient: + """Perform service-account Keycloak admin operations for provisioning.""" + def __init__(self) -> None: self._token: str = "" self._expires_at: float = 0.0 @@ -57,6 +67,12 @@ class KeycloakAdminClient: @staticmethod def _safe_update_payload(full: dict[str, Any]) -> dict[str, Any]: + """Extract mutable fields from a full Keycloak user document. + + WHY: partial updates can accidentally clear profile or attribute data, + so callers merge desired changes into a safe copy first. + """ + payload: dict[str, Any] = {} username = full.get("username") if isinstance(username, str): @@ -86,9 +102,13 @@ class KeycloakAdminClient: return payload def ready(self) -> bool: + """Return whether admin-client credentials are available.""" + return bool(settings.KEYCLOAK_ADMIN_CLIENT_ID and settings.KEYCLOAK_ADMIN_CLIENT_SECRET) def _get_token(self) -> str: + """Return a cached service-account token, refreshing before expiry.""" + if not self.ready(): raise RuntimeError("keycloak admin client not configured") @@ -120,9 +140,13 @@ class KeycloakAdminClient: return {"Authorization": f"Bearer {self._get_token()}"} def headers(self) -> dict[str, str]: + """Return authorization headers for callers that need raw admin access.""" + return self._headers() def find_user(self, username: str) -> dict[str, Any] | None: + """Look up one Keycloak user by exact username.""" + url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users" # Keycloak 26.x in our environment intermittently 400s on filtered user queries unless `max` is set. # Use `max=1` and exact username match to keep admin calls reliable for portal provisioning. @@ -137,6 +161,8 @@ class KeycloakAdminClient: return user if isinstance(user, dict) else None def find_user_by_email(self, email: str) -> dict[str, Any] | None: + """Look up one Keycloak user by exact email address.""" + email = (email or "").strip() if not email: return None @@ -161,6 +187,8 @@ class KeycloakAdminClient: return None def get_user(self, user_id: str) -> dict[str, Any]: + """Fetch a full Keycloak user representation by ID.""" + url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users/{quote(user_id, safe='')}" with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client: resp = client.get(url, headers=self._headers()) @@ -171,12 +199,16 @@ class KeycloakAdminClient: return data def update_user(self, user_id: str, payload: dict[str, Any]) -> None: + """Replace a Keycloak user document with the supplied payload.""" + url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users/{quote(user_id, safe='')}" with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client: resp = client.put(url, headers={**self._headers(), "Content-Type": "application/json"}, json=payload) resp.raise_for_status() def update_user_safe(self, user_id: str, payload: dict[str, Any]) -> None: + """Merge selected user changes into the current Keycloak document.""" + full = self.get_user(user_id) merged = self._safe_update_payload(full) for key, value in payload.items(): @@ -192,6 +224,8 @@ class KeycloakAdminClient: self.update_user(user_id, merged) def create_user(self, payload: dict[str, Any]) -> str: + """Create a Keycloak user and return the generated user ID.""" + url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users" with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client: resp = client.post(url, headers={**self._headers(), "Content-Type": "application/json"}, json=payload) @@ -202,6 +236,8 @@ class KeycloakAdminClient: raise RuntimeError("failed to determine created user id") def reset_password(self, user_id: str, password: str, temporary: bool = True) -> None: + """Set a Keycloak password credential for a user.""" + url = ( f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}" f"/users/{quote(user_id, safe='')}/reset-password" @@ -212,6 +248,8 @@ class KeycloakAdminClient: resp.raise_for_status() def set_user_attribute(self, username: str, key: str, value: str) -> None: + """Set one single-value Keycloak user attribute by username.""" + user = self.find_user(username) if not user: raise RuntimeError("user not found") @@ -230,6 +268,8 @@ class KeycloakAdminClient: self.update_user(user_id, payload) def get_group_id(self, group_name: str) -> str | None: + """Resolve and cache the Keycloak ID for a group name.""" + cached = self._group_id_cache.get(group_name) if cached: return cached @@ -252,6 +292,8 @@ class KeycloakAdminClient: return None def list_group_names(self) -> list[str]: + """Return all Keycloak group names visible to the admin client.""" + url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/groups" with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client: resp = client.get(url, headers=self._headers()) @@ -263,6 +305,8 @@ class KeycloakAdminClient: names: set[str] = set() def walk(groups: list[Any]) -> None: + """Visit nested Keycloak group records and collect names.""" + for group in groups: if not isinstance(group, dict): continue @@ -277,6 +321,8 @@ class KeycloakAdminClient: return sorted(names) def list_user_groups(self, user_id: str) -> list[str]: + """Return group names assigned to one Keycloak user.""" + url = ( f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}" f"/users/{quote(user_id, safe='')}/groups" @@ -297,6 +343,8 @@ class KeycloakAdminClient: return names def add_user_to_group(self, user_id: str, group_id: str) -> None: + """Attach one Keycloak user to one group by ID.""" + url = ( f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}" f"/users/{quote(user_id, safe='')}/groups/{quote(group_id, safe='')}" @@ -306,6 +354,8 @@ class KeycloakAdminClient: resp.raise_for_status() def execute_actions_email(self, user_id: str, actions: list[str], redirect_uri: str) -> None: + """Ask Keycloak to email required-account-action links to a user.""" + url = ( f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}" f"/users/{quote(user_id, safe='')}/execute-actions-email" @@ -321,6 +371,8 @@ class KeycloakAdminClient: resp.raise_for_status() def get_user_credentials(self, user_id: str) -> list[dict[str, Any]]: + """Return credential metadata for one Keycloak user.""" + url = ( f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}" f"/users/{quote(user_id, safe='')}/credentials" @@ -339,6 +391,8 @@ _ADMIN: KeycloakAdminClient | None = None def oidc_client() -> KeycloakOIDC: + """Return the singleton OIDC verifier.""" + global _OIDC if _OIDC is None: _OIDC = KeycloakOIDC() @@ -346,6 +400,8 @@ def oidc_client() -> KeycloakOIDC: def admin_client() -> KeycloakAdminClient: + """Return the singleton Keycloak admin client.""" + global _ADMIN if _ADMIN is None: _ADMIN = KeycloakAdminClient() @@ -364,6 +420,8 @@ def _normalize_groups(groups: Any) -> list[str]: def _extract_bearer_token() -> str | None: + """Extract a bearer token from the current Flask request.""" + header = request.headers.get("Authorization", "") if not header: return None @@ -377,8 +435,12 @@ def _extract_bearer_token() -> str | None: def require_auth(fn): + """Decorate a Flask route so it requires a valid Keycloak bearer token.""" + @wraps(fn) def wrapper(*args, **kwargs): + """Validate the request token and place normalized claims on Flask globals.""" + token = _extract_bearer_token() if not token: return jsonify({"error": "missing bearer token"}), 401 @@ -397,6 +459,8 @@ def require_auth(fn): def require_portal_admin() -> tuple[bool, Any]: + """Return whether the authenticated user can use portal admin actions.""" + if not settings.KEYCLOAK_ENABLED: return False, (jsonify({"error": "keycloak not enabled"}), 503) @@ -411,6 +475,8 @@ def require_portal_admin() -> tuple[bool, Any]: def require_account_access() -> tuple[bool, Any]: + """Return whether the authenticated user can use self-service account pages.""" + if not settings.KEYCLOAK_ENABLED: return False, (jsonify({"error": "keycloak not enabled"}), 503) if not settings.ACCOUNT_ALLOWED_GROUPS: diff --git a/backend/atlas_portal/mailer.py b/backend/atlas_portal/mailer.py index ac0a322..90252b6 100644 --- a/backend/atlas_portal/mailer.py +++ b/backend/atlas_portal/mailer.py @@ -7,10 +7,14 @@ from . import settings class MailerError(RuntimeError): + """Signal a mail delivery problem without leaking provider internals.""" + pass def send_text_email(*, to_addr: str, subject: str, body: str) -> None: + """Send a plaintext email through the configured SMTP relay.""" + if not to_addr: raise MailerError("missing recipient") if not settings.SMTP_HOST: @@ -35,6 +39,8 @@ def send_text_email(*, to_addr: str, subject: str, body: str) -> None: def access_request_verification_body(*, request_code: str, verify_url: str) -> str: + """Render the access-request email verification message body.""" + return "\n".join( [ "Atlas — confirm your email", @@ -50,4 +56,3 @@ def access_request_verification_body(*, request_code: str, verify_url: str) -> s "", ] ) - diff --git a/backend/atlas_portal/migrate.py b/backend/atlas_portal/migrate.py index 30948dd..4945cb8 100644 --- a/backend/atlas_portal/migrate.py +++ b/backend/atlas_portal/migrate.py @@ -4,6 +4,8 @@ from .db import run_migrations def main() -> None: + """Run database migrations when invoked as a module or script.""" + run_migrations() diff --git a/backend/atlas_portal/nextcloud_mail_sync.py b/backend/atlas_portal/nextcloud_mail_sync.py index 7879728..42edd0d 100644 --- a/backend/atlas_portal/nextcloud_mail_sync.py +++ b/backend/atlas_portal/nextcloud_mail_sync.py @@ -16,6 +16,8 @@ def _safe_name_fragment(value: str, max_len: int = 24) -> str: def _job_from_cronjob(cronjob: dict[str, Any], username: str) -> dict[str, Any]: + """Render a one-off Nextcloud mail sync Job from the CronJob template.""" + spec = cronjob.get("spec") if isinstance(cronjob.get("spec"), dict) else {} jt = spec.get("jobTemplate") if isinstance(spec.get("jobTemplate"), dict) else {} job_spec = jt.get("spec") if isinstance(jt.get("spec"), dict) else {} @@ -61,6 +63,8 @@ def _job_from_cronjob(cronjob: dict[str, Any], username: str) -> dict[str, Any]: def _job_succeeded(job: dict[str, Any]) -> bool: + """Return whether Kubernetes reports the sync Job as successfully complete.""" + status = job.get("status") if isinstance(job.get("status"), dict) else {} if int(status.get("succeeded") or 0) > 0: return True @@ -74,6 +78,8 @@ def _job_succeeded(job: dict[str, Any]) -> bool: def _job_failed(job: dict[str, Any]) -> bool: + """Return whether Kubernetes reports the sync Job as failed.""" + status = job.get("status") if isinstance(job.get("status"), dict) else {} if int(status.get("failed") or 0) > 0: return True @@ -87,6 +93,12 @@ def _job_failed(job: dict[str, Any]) -> bool: def trigger(username: str, wait: bool = True) -> dict[str, Any]: + """Start the Nextcloud mail sync Job for one user and optionally wait. + + WHY: onboarding and account actions need a targeted sync repair path that + reuses the same template as scheduled cluster automation. + """ + username = (username or "").strip() if not username: raise RuntimeError("missing username") @@ -120,4 +132,3 @@ def trigger(username: str, wait: bool = True) -> dict[str, Any]: last_state = "running" return {"job": job_name, "status": last_state} - diff --git a/backend/atlas_portal/routes/access_request_onboarding.py b/backend/atlas_portal/routes/access_request_onboarding.py index ed09d9f..eb0700a 100644 --- a/backend/atlas_portal/routes/access_request_onboarding.py +++ b/backend/atlas_portal/routes/access_request_onboarding.py @@ -11,6 +11,13 @@ def register_access_request_onboarding(app, deps) -> None: @app.route("/api/access/request/onboarding/attest", methods=["POST"]) def request_access_onboarding_attest() -> Any: + """Record or clear a user-attested onboarding step. + + WHY: onboarding mixes manual tasks with Keycloak-managed tasks, so this + route enforces prerequisites and only accepts attestations for UI-owned + steps. + """ + if not deps.configured(): return jsonify({"error": "server not deps.configured"}), 503 @@ -161,6 +168,8 @@ def register_access_request_onboarding(app, deps) -> None: @app.route("/api/access/request/onboarding/keycloak-password-rotate", methods=["POST"]) def request_access_onboarding_keycloak_password_rotate() -> Any: + """Request Keycloak password rotation for an onboarding user.""" + if not deps.configured(): return jsonify({"error": "server not deps.configured"}), 503 diff --git a/backend/atlas_portal/routes/access_request_state.py b/backend/atlas_portal/routes/access_request_state.py index d9e280c..546a818 100644 --- a/backend/atlas_portal/routes/access_request_state.py +++ b/backend/atlas_portal/routes/access_request_state.py @@ -101,6 +101,8 @@ def _send_verification_email(*, request_code: str, email: str, token: str) -> No class VerificationError(Exception): + """Describe an email verification failure with an HTTP status.""" + def __init__(self, status_code: int, message: str) -> None: super().__init__(message) self.status_code = status_code @@ -108,6 +110,7 @@ class VerificationError(Exception): def _verify_request(conn, code: str, token: str) -> str: + """Validate email proof and atomically advance a pending request.""" row = conn.execute( """ SELECT status, email_verification_token_hash, email_verification_sent_at, email_verified_at @@ -161,6 +164,7 @@ def _normalize_status(status: str) -> str: def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]: + """Return manually attested onboarding steps for one request.""" rows = conn.execute( "SELECT step FROM access_request_onboarding_steps WHERE request_code = %s", (request_code,), @@ -182,6 +186,7 @@ def _normalize_flag_list(raw: Any) -> set[str]: def _fetch_request_flags_and_email(conn, request_code: str) -> tuple[set[str], str]: + """Return approval flags and contact email used by onboarding decisions.""" row = conn.execute( "SELECT approval_flags, contact_email FROM access_requests WHERE request_code = %s", (request_code,), @@ -194,6 +199,7 @@ def _fetch_request_flags_and_email(conn, request_code: str) -> tuple[set[str], s def _user_in_group(username: str, group_name: str) -> bool: + """Return whether a Keycloak user belongs to a named group.""" if not username or not group_name: return False if not admin_client().ready(): @@ -219,6 +225,7 @@ def _vaultwarden_grandfathered(conn, request_code: str, username: str) -> tuple[ def _resolve_recovery_email(username: str, fallback: str) -> str: + """Find the best recovery email for Vaultwarden onboarding.""" if username and admin_client().ready(): try: user = admin_client().find_user(username) or {} @@ -234,6 +241,7 @@ def _resolve_recovery_email(username: str, fallback: str) -> str: def _password_rotation_requested(conn, request_code: str) -> bool: + """Return whether Keycloak password rotation was requested for this request.""" row = conn.execute( """ SELECT 1 @@ -247,6 +255,7 @@ def _password_rotation_requested(conn, request_code: str) -> bool: def _request_keycloak_password_rotation(conn, request_code: str, username: str) -> None: + """Require Keycloak password rotation and persist the request marker.""" if not username: raise ValueError("username missing") if not admin_client().ready(): @@ -277,6 +286,7 @@ def _request_keycloak_password_rotation(conn, request_code: str, username: str) def _extract_attr(attrs: Any, key: str) -> str: + """Return the first string value for a Keycloak attribute.""" if not isinstance(attrs, dict): return "" raw = attrs.get(key) @@ -291,6 +301,7 @@ def _extract_attr(attrs: Any, key: str) -> str: def _vaultwarden_status_for_user(username: str) -> str: + """Read the Vaultwarden lifecycle status mirrored on a Keycloak user.""" if not username: return "" if not admin_client().ready(): @@ -308,6 +319,7 @@ def _vaultwarden_status_for_user(username: str) -> str: def _auto_completed_service_steps(attrs: Any) -> set[str]: + """Infer onboarding steps completed by backend service automation.""" completed: set[str] = set() if not isinstance(attrs, dict): return completed @@ -333,6 +345,7 @@ def _auto_completed_service_steps(attrs: Any) -> set[str]: def _auto_completed_keycloak_steps(conn, request_code: str, username: str) -> set[str]: + """Infer onboarding steps from Keycloak profile state.""" if not username: return set() if not admin_client().ready(): @@ -389,6 +402,7 @@ def _completed_onboarding_steps(conn, request_code: str, username: str) -> set[s def _automation_ready(conn, request_code: str, username: str) -> bool: + """Return whether account provisioning has finished enough for onboarding.""" if not username: return False if not admin_client().ready(): @@ -423,6 +437,7 @@ def _automation_ready(conn, request_code: str, username: str) -> bool: def _advance_status(conn, request_code: str, username: str, status: str) -> str: + """Advance an access request through automatic status transitions.""" status = _normalize_status(status) if status == "accounts_building" and _automation_ready(conn, request_code, username): @@ -450,6 +465,7 @@ def _advance_status(conn, request_code: str, username: str, status: str) -> str: def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any]: + """Build the onboarding progress payload returned to the frontend.""" completed_steps = sorted(_completed_onboarding_steps(conn, request_code, username)) password_rotation_requested = _password_rotation_requested(conn, request_code) grandfathered, contact_email = _vaultwarden_grandfathered(conn, request_code, username) diff --git a/backend/atlas_portal/routes/access_request_status.py b/backend/atlas_portal/routes/access_request_status.py index 2dbb61f..ec1cfdf 100644 --- a/backend/atlas_portal/routes/access_request_status.py +++ b/backend/atlas_portal/routes/access_request_status.py @@ -11,6 +11,12 @@ def register_access_request_status(app, deps) -> None: @app.route("/api/access/request/status", methods=["POST"]) def request_access_status() -> Any: + """Return current provisioning and onboarding status for a request. + + WHY: this endpoint is polled by the public flow, so it also advances + safe automatic transitions before rendering the latest state. + """ + if not deps.settings.ACCESS_REQUEST_ENABLED: return jsonify({"error": "request access disabled"}), 503 if not deps.configured(): @@ -142,6 +148,8 @@ def register_access_request_status(app, deps) -> None: @app.route("/api/access/request/retry", methods=["POST"]) def request_access_retry() -> Any: + """Retry failed provisioning tasks for an access request.""" + if not deps.settings.ACCESS_REQUEST_ENABLED: return jsonify({"error": "request access disabled"}), 503 if not deps.configured(): diff --git a/backend/atlas_portal/routes/access_request_submission.py b/backend/atlas_portal/routes/access_request_submission.py index 956b670..217bc16 100644 --- a/backend/atlas_portal/routes/access_request_submission.py +++ b/backend/atlas_portal/routes/access_request_submission.py @@ -13,6 +13,8 @@ def register_access_request_submission(app, deps) -> None: @app.route("/api/access/request/availability", methods=["GET"]) def request_access_availability() -> Any: + """Report whether a requested username can start access signup.""" + if not deps.settings.ACCESS_REQUEST_ENABLED: return jsonify({"error": "request access disabled"}), 503 if not deps.configured(): @@ -55,6 +57,12 @@ def register_access_request_submission(app, deps) -> None: @app.route("/api/access/request", methods=["POST"]) def request_access() -> Any: + """Create or refresh an email-verified access request. + + WHY: submissions are anonymous, so this route validates names, rate + limits by client/request, and emails a proof token before queuing work. + """ + if not deps.settings.ACCESS_REQUEST_ENABLED: return jsonify({"error": "request access disabled"}), 503 if not deps.configured(): @@ -202,6 +210,8 @@ def register_access_request_submission(app, deps) -> None: @app.route("/api/access/request/verify", methods=["POST"]) def request_access_verify() -> Any: + """Verify a submitted access request using a request code and token.""" + if not deps.settings.ACCESS_REQUEST_ENABLED: return jsonify({"error": "request access disabled"}), 503 if not deps.configured(): @@ -246,6 +256,8 @@ def register_access_request_submission(app, deps) -> None: @app.route("/api/access/request/verify-link", methods=["GET"]) def request_access_verify_link() -> Any: + """Verify an emailed access-request link and redirect to the UI.""" + if not deps.settings.ACCESS_REQUEST_ENABLED: return jsonify({"error": "request access disabled"}), 503 if not deps.configured(): @@ -267,6 +279,8 @@ def register_access_request_submission(app, deps) -> None: @app.route("/api/access/request/resend", methods=["POST"]) def request_access_resend() -> Any: + """Send a fresh verification token for a pending access request.""" + if not deps.settings.ACCESS_REQUEST_ENABLED: return jsonify({"error": "request access disabled"}), 503 if not deps.configured(): diff --git a/backend/atlas_portal/routes/account_actions.py b/backend/atlas_portal/routes/account_actions.py index 3284aa8..7f7833b 100644 --- a/backend/atlas_portal/routes/account_actions.py +++ b/backend/atlas_portal/routes/account_actions.py @@ -34,6 +34,8 @@ def register_account_actions(app) -> None: @app.route("/api/account/mailu/rotate", methods=["POST"]) @require_auth def account_mailu_rotate() -> Any: + """Rotate the user's Mailu app password and trigger dependent syncs.""" + ok, resp = require_account_access() if not ok: return resp @@ -87,6 +89,8 @@ def register_account_actions(app) -> None: @app.route("/api/account/wger/reset", methods=["POST"]) @require_auth def account_wger_reset() -> Any: + """Reset the user's Wger password through the sync Job path.""" + ok, resp = require_account_access() if not ok: return resp @@ -140,6 +144,8 @@ def register_account_actions(app) -> None: @app.route("/api/account/wger/rotation/check", methods=["POST"]) @require_auth def account_wger_rotation_check() -> Any: + """Proxy or reject Wger rotation status checks for this account.""" + ok, resp = require_account_access() if not ok: return resp @@ -150,6 +156,8 @@ def register_account_actions(app) -> None: @app.route("/api/account/firefly/reset", methods=["POST"]) @require_auth def account_firefly_reset() -> Any: + """Reset the user's Firefly password through the sync Job path.""" + ok, resp = require_account_access() if not ok: return resp @@ -203,6 +211,8 @@ def register_account_actions(app) -> None: @app.route("/api/account/firefly/rotation/check", methods=["POST"]) @require_auth def account_firefly_rotation_check() -> Any: + """Proxy or reject Firefly rotation status checks for this account.""" + ok, resp = require_account_access() if not ok: return resp @@ -213,6 +223,8 @@ def register_account_actions(app) -> None: @app.route("/api/account/nextcloud/mail/sync", methods=["POST"]) @require_auth def account_nextcloud_mail_sync() -> Any: + """Trigger a targeted Nextcloud mail sync for the signed-in user.""" + ok, resp = require_account_access() if not ok: return resp diff --git a/backend/atlas_portal/routes/account_overview.py b/backend/atlas_portal/routes/account_overview.py index f0385e8..b89389d 100644 --- a/backend/atlas_portal/routes/account_overview.py +++ b/backend/atlas_portal/routes/account_overview.py @@ -34,6 +34,12 @@ def register_account_overview(app) -> None: @app.route("/api/account/overview", methods=["GET"]) @require_auth def account_overview() -> Any: + """Build the signed-in user's self-service account dashboard state. + + WHY: the UI needs one coherent status payload assembled from Keycloak, + service sync markers, and legacy fallback checks. + """ + ok, resp = require_account_access() if not ok: return resp diff --git a/backend/atlas_portal/routes/admin_access.py b/backend/atlas_portal/routes/admin_access.py index 510c004..3b1814e 100644 --- a/backend/atlas_portal/routes/admin_access.py +++ b/backend/atlas_portal/routes/admin_access.py @@ -12,9 +12,13 @@ from ..provisioning import provision_access_request def register(app) -> None: + """Register administrator routes for access-request decisions.""" + @app.route("/api/admin/access/requests", methods=["GET"]) @require_auth def admin_list_requests() -> Any: + """List pending access requests for portal administrators.""" + ok, resp = require_portal_admin() if not ok: return resp @@ -56,6 +60,8 @@ def register(app) -> None: @app.route("/api/admin/access/flags", methods=["GET"]) @require_auth def admin_list_flags() -> Any: + """List Keycloak groups that can be applied as approval flags.""" + ok, resp = require_portal_admin() if not ok: return resp @@ -74,6 +80,12 @@ def register(app) -> None: @app.route("/api/admin/access/requests//approve", methods=["POST"]) @require_auth def admin_approve_request(username: str) -> Any: + """Approve one verified access request and start provisioning. + + WHY: approval should atomically record the admin decision before + best-effort provisioning so status polling can surface any later issue. + """ + ok, resp = require_portal_admin() if not ok: return resp @@ -125,6 +137,8 @@ def register(app) -> None: @app.route("/api/admin/access/requests//deny", methods=["POST"]) @require_auth def admin_deny_request(username: str) -> Any: + """Deny one pending access request with optional admin context.""" + ok, resp = require_portal_admin() if not ok: return resp diff --git a/backend/atlas_portal/routes/ai.py b/backend/atlas_portal/routes/ai.py index b534a61..4810e9e 100644 --- a/backend/atlas_portal/routes/ai.py +++ b/backend/atlas_portal/routes/ai.py @@ -13,9 +13,13 @@ from .. import settings def register(app) -> None: + """Register the Atlas AI chat and model-info endpoints.""" + @app.route("/api/chat", methods=["POST"]) @app.route("/api/ai/chat", methods=["POST"]) def ai_chat() -> Any: + """Return an Atlasbot answer or a budget-aware fallback message.""" + payload = request.get_json(silent=True) or {} user_message = (payload.get("message") or "").strip() profile = (payload.get("profile") or payload.get("mode") or "atlas-quick").strip().lower() @@ -61,6 +65,8 @@ def register(app) -> None: @app.route("/api/chat/info", methods=["GET"]) @app.route("/api/ai/info", methods=["GET"]) def ai_info() -> Any: + """Return model and placement metadata for the requested AI profile.""" + profile = (request.args.get("profile") or "atlas-quick").strip().lower() meta = _discover_ai_meta(profile) return jsonify(meta) @@ -69,6 +75,8 @@ def register(app) -> None: def _atlasbot_answer(message: str, mode: str, conversation_id: str) -> str: + """Ask Atlasbot for one answer and return an empty string on soft failure.""" + endpoint = settings.AI_ATLASBOT_ENDPOINT if not endpoint: return "" @@ -99,6 +107,12 @@ def _atlasbot_timeout_sec(mode: str) -> float: def _discover_ai_meta(profile: str) -> dict[str, str]: + """Discover AI model metadata from settings and the running Kubernetes pod. + + WHY: the frontend needs a human-readable model/GPU hint even when the model + image or GPU placement changes outside the portal code. + """ + meta = { "node": settings.AI_NODE_NAME, "gpu": settings.AI_GPU_DESC, @@ -168,10 +182,14 @@ def _discover_ai_meta(profile: str) -> dict[str, str]: def _start_keep_warm() -> None: + """Start the optional background keep-warm loop for the chat backend.""" + if not settings.AI_WARM_ENABLED or settings.AI_WARM_INTERVAL_SEC <= 0: return def loop() -> None: + """Periodically send a tiny chat request so the backend stays warm.""" + while True: time.sleep(settings.AI_WARM_INTERVAL_SEC) try: diff --git a/backend/atlas_portal/routes/lab.py b/backend/atlas_portal/routes/lab.py index 5909176..b03dd5c 100644 --- a/backend/atlas_portal/routes/lab.py +++ b/backend/atlas_portal/routes/lab.py @@ -15,6 +15,8 @@ _LAB_STATUS_CACHE: dict[str, Any] = {"ts": 0.0, "value": None} def _vm_query(expr: str) -> float | None: + """Run one instant VictoriaMetrics query and return the largest value.""" + url = f"{settings.VM_BASE_URL}/api/v1/query?{urlencode({'query': expr})}" with urlopen(url, timeout=settings.VM_QUERY_TIMEOUT_SEC) as resp: payload = json.loads(resp.read().decode("utf-8")) @@ -40,6 +42,8 @@ def _vm_query(expr: str) -> float | None: def _http_ok(url: str, expect_substring: str | None = None) -> bool: + """Return whether a URL responds successfully and optionally contains text.""" + try: with urlopen(url, timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as resp: if getattr(resp, "status", 200) != 200: @@ -53,8 +57,12 @@ def _http_ok(url: str, expect_substring: str | None = None) -> bool: def register(app) -> None: + """Register the lightweight lab connectivity status endpoint.""" + @app.route("/api/lab/status") def lab_status() -> Any: + """Return cached Atlas/Oceanus health hints for the home page.""" + now = time.time() cached = _LAB_STATUS_CACHE.get("value") if cached and (now - float(_LAB_STATUS_CACHE.get("ts", 0.0)) < settings.LAB_STATUS_CACHE_SEC): diff --git a/backend/atlas_portal/vaultwarden.py b/backend/atlas_portal/vaultwarden.py index 0a774a3..e511862 100644 --- a/backend/atlas_portal/vaultwarden.py +++ b/backend/atlas_portal/vaultwarden.py @@ -28,6 +28,8 @@ def _read_service_account() -> tuple[str, str]: def _k8s_get_json(path: str) -> dict[str, Any]: + """Fetch a Kubernetes object as JSON through the pod service account.""" + token, ca_path = _read_service_account() url = f"{_K8S_BASE_URL}{path}" with httpx.Client( @@ -44,12 +46,16 @@ def _k8s_get_json(path: str) -> dict[str, Any]: def _k8s_find_pod_ip(namespace: str, label_selector: str) -> str: + """Find a usable Vaultwarden pod IP for direct admin fallback.""" + data = _k8s_get_json(f"/api/v1/namespaces/{namespace}/pods?labelSelector={label_selector}") items = data.get("items") or [] if not isinstance(items, list) or not items: raise RuntimeError("no vaultwarden pods found") def _pod_ready(pod: dict[str, Any]) -> bool: + """Return whether a listed pod is running and ready enough to contact.""" + status = pod.get("status") if isinstance(pod.get("status"), dict) else {} if status.get("phase") != "Running": return False @@ -74,6 +80,8 @@ def _k8s_find_pod_ip(namespace: str, label_selector: str) -> str: def _k8s_get_secret_value(namespace: str, name: str, key: str) -> str: + """Read and decode one Kubernetes Secret value.""" + data = _k8s_get_json(f"/api/v1/namespaces/{namespace}/secrets/{name}") blob = data.get("data") if isinstance(data.get("data"), dict) else {} raw = blob.get(key) @@ -90,6 +98,8 @@ def _k8s_get_secret_value(namespace: str, name: str, key: str) -> str: @dataclass(frozen=True) class VaultwardenInvite: + """Describe the result of attempting to create a Vaultwarden invite.""" + ok: bool status: str detail: str = "" @@ -103,6 +113,12 @@ _ADMIN_RATE_LIMITED_UNTIL: float = 0.0 def _admin_session(base_url: str) -> httpx.Client: + """Return a cached authenticated Vaultwarden admin session. + + WHY: Vaultwarden rate-limits admin login attempts, so invite creation must + reuse a short-lived session instead of logging in for every user action. + """ + global _ADMIN_SESSION, _ADMIN_SESSION_EXPIRES_AT, _ADMIN_SESSION_BASE_URL, _ADMIN_RATE_LIMITED_UNTIL now = time.time() with _ADMIN_LOCK: @@ -146,6 +162,8 @@ def _admin_session(base_url: str) -> httpx.Client: def invite_user(email: str) -> VaultwardenInvite: + """Invite one email address to Vaultwarden through the admin UI.""" + global _ADMIN_RATE_LIMITED_UNTIL email = (email or "").strip() if not email or "@" not in email: diff --git a/backend/atlas_portal/wger_user_sync.py b/backend/atlas_portal/wger_user_sync.py index ed8f63c..88bb279 100644 --- a/backend/atlas_portal/wger_user_sync.py +++ b/backend/atlas_portal/wger_user_sync.py @@ -21,6 +21,8 @@ def _job_from_cronjob( email: str, password: str, ) -> dict[str, Any]: + """Render a one-off Wger user sync Job from the managed CronJob template.""" + spec = cronjob.get("spec") if isinstance(cronjob.get("spec"), dict) else {} jt = spec.get("jobTemplate") if isinstance(spec.get("jobTemplate"), dict) else {} job_spec = jt.get("spec") if isinstance(jt.get("spec"), dict) else {} @@ -71,6 +73,8 @@ def _job_from_cronjob( def _job_succeeded(job: dict[str, Any]) -> bool: + """Return whether Kubernetes reports the sync Job as successfully complete.""" + status = job.get("status") if isinstance(job.get("status"), dict) else {} if int(status.get("succeeded") or 0) > 0: return True @@ -84,6 +88,8 @@ def _job_succeeded(job: dict[str, Any]) -> bool: def _job_failed(job: dict[str, Any]) -> bool: + """Return whether Kubernetes reports the sync Job as failed.""" + status = job.get("status") if isinstance(job.get("status"), dict) else {} if int(status.get("failed") or 0) > 0: return True @@ -97,6 +103,12 @@ def _job_failed(job: dict[str, Any]) -> bool: def trigger(username: str, email: str, password: str, wait: bool = True) -> dict[str, Any]: + """Start the Wger sync Job for one user and optionally wait for completion. + + WHY: account actions need an immediate per-user repair path without + mutating the reusable CronJob template that Flux owns. + """ + username = (username or "").strip() if not username: raise RuntimeError("missing username") diff --git a/frontend/src/account/useAccountDashboard.js b/frontend/src/account/useAccountDashboard.js index 013b223..ed19c32 100644 --- a/frontend/src/account/useAccountDashboard.js +++ b/frontend/src/account/useAccountDashboard.js @@ -256,6 +256,7 @@ export function useAccountDashboard() { return Array.isArray(selected) && selected.includes(flag); } + // WHY: admins need readable applicant names even when optional fields are missing; @returns display name. function formatName(req) { if (!req) return "unknown"; const parts = []; @@ -380,6 +381,7 @@ export function useAccountDashboard() { } } + // WHY: Safari/private-mode clipboard support varies; @returns whether fallback copy completed. function fallbackCopy(text) { const textarea = document.createElement("textarea"); textarea.value = text; diff --git a/frontend/src/components/MermaidCard.vue b/frontend/src/components/MermaidCard.vue index a16375f..88921c5 100644 --- a/frontend/src/components/MermaidCard.vue +++ b/frontend/src/components/MermaidCard.vue @@ -67,6 +67,11 @@ const renderDiagram = async () => { } }; +/** + * Cancel pending Mermaid rendering work before scheduling a replacement. + * + * @returns {void} + */ function cancelScheduledRender() { if (!scheduledHandle) return; if (scheduledKind === "idle" && window.cancelIdleCallback) { @@ -78,6 +83,14 @@ function cancelScheduledRender() { scheduledKind = ""; } +/** + * Schedule Mermaid rendering during idle time when the browser supports it. + * + * WHY: diagrams are decorative and can be expensive, so rendering should not + * compete with first paint or input responsiveness. + * + * @returns {void} + */ function scheduleRenderDiagram() { cancelScheduledRender(); if (!props.diagram) return; diff --git a/frontend/src/onboarding/onboardingLabels.js b/frontend/src/onboarding/onboardingLabels.js index 9476f32..3253754 100644 --- a/frontend/src/onboarding/onboardingLabels.js +++ b/frontend/src/onboarding/onboardingLabels.js @@ -1,4 +1,9 @@ -/** Display labels and pill classes for onboarding status values. */ +/** + * Convert an onboarding status code into display copy. + * + * @param {string} value - Raw status returned by the backend. + * @returns {string} Human-readable status label. + */ export function statusLabel(value) { const key = (value || "").trim(); if (key === "pending_email_verification") return "confirm email"; @@ -8,8 +13,14 @@ export function statusLabel(value) { if (key === "ready") return "ready"; if (key === "denied") return "rejected"; return key || "unknown"; - } +} +/** + * Convert an onboarding status code into the matching pill class. + * + * @param {string} value - Raw status returned by the backend. + * @returns {string} CSS class for status emphasis. + */ export function statusPillClass(value) { const key = (value || "").trim(); if (key === "pending_email_verification") return "pill-warn"; @@ -19,11 +30,17 @@ export function statusPillClass(value) { if (key === "ready") return "pill-info"; if (key === "denied") return "pill-bad"; return "pill-warn"; - } +} +/** + * Convert a provisioning task status into a pill class. + * + * @param {string} value - Raw task status returned by the backend. + * @returns {string} CSS class for task emphasis. + */ export function taskPillClass(value) { const key = (value || "").trim(); if (key === "ok") return "pill-ok"; if (key === "error") return "pill-bad"; return "pill-warn"; - } +} diff --git a/frontend/src/onboarding/useOnboardingFlow.js b/frontend/src/onboarding/useOnboardingFlow.js index 49b40d4..c1453f7 100644 --- a/frontend/src/onboarding/useOnboardingFlow.js +++ b/frontend/src/onboarding/useOnboardingFlow.js @@ -7,11 +7,9 @@ import { SECTION_DEFS, STEP_PREREQS, VAULTWARDEN_TEMP_STEP } from "./onboardingS /** * Build the Onboarding page state machine. - * * WHY: onboarding coordinates request status, guide media, password reveal, * and service attestation flow; isolating that state keeps the view focused * on layout and makes the workflow independently testable. - * * @param {import("vue-router").RouteLocationNormalizedLoaded} route - active route with optional request code query params. * @returns {object} reactive onboarding state and event handlers. */ @@ -118,6 +116,7 @@ export function useOnboardingFlow(route) { return prereqs.some((req) => !isStepDone(req)); } + // WHY: service login rules vary by step; @returns optional helper copy. function stepNote(step) { if (step.id === "vaultwarden_master_password") { return `Vaultwarden uses an email login. Use ${vaultwardenLoginEmailLower.value} to sign in.`; @@ -134,6 +133,7 @@ export function useOnboardingFlow(route) { return ""; } + // WHY: step state combines completion, prerequisites, and backend automation; @returns pill text. function stepPillLabel(step) { if (isStepDone(step.id)) return "done"; if (isStepBlocked(step.id)) return "blocked"; diff --git a/frontend/src/onboarding/useOnboardingGuides.js b/frontend/src/onboarding/useOnboardingGuides.js index 967e4d0..86a0d3c 100644 --- a/frontend/src/onboarding/useOnboardingGuides.js +++ b/frontend/src/onboarding/useOnboardingGuides.js @@ -12,6 +12,12 @@ export function useOnboardingGuides({ isStepDone, isStepBlocked }) { const guidePage = ref({}); const lightboxShot = ref(null); + /** + * Return screenshot groups for a step, honoring configured head/tail limits. + * + * @param {object} step - Onboarding step definition with optional guide metadata. + * @returns {Array} Screenshot groups to render for the guide carousel. + */ function guideGroups(step) { if (!step.guide) return []; const service = step.guide.service; diff --git a/frontend/src/request-access/useRequestAccessFlow.js b/frontend/src/request-access/useRequestAccessFlow.js index 8f8fb1e..73cbf24 100644 --- a/frontend/src/request-access/useRequestAccessFlow.js +++ b/frontend/src/request-access/useRequestAccessFlow.js @@ -11,6 +11,12 @@ import { onMounted, reactive, ref, watch } from "vue"; * @returns {object} reactive state and event handlers used by the view template. */ export function useRequestAccessFlow(route) { + /** + * Convert backend request status into copy for the public flow. + * + * @param {string} value - Raw access request status. + * @returns {string} Human-readable status label. + */ function statusLabel(value) { const key = (value || "").trim(); if (key === "pending_email_verification") return "confirm email"; @@ -22,6 +28,12 @@ export function useRequestAccessFlow(route) { return key || "unknown"; } + /** + * Convert backend request status into the matching pill class. + * + * @param {string} value - Raw access request status. + * @returns {string} CSS class for status emphasis. + */ function statusPillClass(value) { const key = (value || "").trim(); if (key === "pending_email_verification") return "pill-warn"; @@ -87,6 +99,13 @@ export function useRequestAccessFlow(route) { availability.blockSubmit = false; } + /** + * Update username availability UI state from a normalized backend result. + * + * @param {string} state - Availability state key. + * @param {string} detail - Optional human-readable explanation. + * @returns {void} + */ function setAvailability(state, detail = "") { availability.detail = detail; availability.blockSubmit = false; diff --git a/frontend/src/views/AiView.vue b/frontend/src/views/AiView.vue index 9160be5..739d04a 100644 --- a/frontend/src/views/AiView.vue +++ b/frontend/src/views/AiView.vue @@ -119,6 +119,12 @@ const chatWindow = ref(null); const copied = ref(false); const conversationIds = reactive({}); +/** + * Return a stable local conversation ID for one AI profile. + * + * @param {string} profile - Active AI profile key. + * @returns {string} Stable conversation identifier persisted in local storage. + */ function ensureConversationId(profile) { if (conversationIds[profile]) return conversationIds[profile]; const key = `atlas-ai-conversation:${profile}`;