diff --git a/backend/atlas_portal/provisioning.py b/backend/atlas_portal/provisioning.py index def1666..cb8e8b3 100644 --- a/backend/atlas_portal/provisioning.py +++ b/backend/atlas_portal/provisioning.py @@ -49,6 +49,32 @@ def _upsert_task(conn, request_code: str, task: str, status: str, detail: str | ) +def _ensure_task_rows(conn, request_code: str, tasks: list[str]) -> None: + if not tasks: + return + conn.execute( + """ + INSERT INTO access_request_tasks (request_code, task, status, detail, updated_at) + SELECT %s, task, 'pending', NULL, NOW() + FROM UNNEST(%s::text[]) AS task + ON CONFLICT (request_code, task) DO NOTHING + """, + (request_code, tasks), + ) + + +def _safe_error_detail(exc: Exception, fallback: str) -> str: + if isinstance(exc, RuntimeError): + msg = str(exc).strip() + if msg: + return msg + if isinstance(exc, httpx.HTTPStatusError): + return f"http {exc.response.status_code}" + if isinstance(exc, httpx.TimeoutException): + return "timeout" + return fallback + + def _task_statuses(conn, request_code: str) -> dict[str, str]: rows = conn.execute( "SELECT task, status FROM access_request_tasks WHERE request_code = %s", @@ -128,6 +154,8 @@ def provision_access_request(request_code: str) -> ProvisionResult: if status not in {"accounts_building", "awaiting_onboarding", "ready"}: return ProvisionResult(ok=False, status=status or "unknown") + _ensure_task_rows(conn, request_code, required_tasks) + if status == "accounts_building": now = datetime.now(timezone.utc) if isinstance(attempted_at, datetime): @@ -150,7 +178,7 @@ def provision_access_request(request_code: str) -> ProvisionResult: if not user: email = contact_email.strip() if not email: - raise RuntimeError("contact email missing") + raise RuntimeError("missing verified email address") payload = { "username": username, "enabled": True, @@ -188,8 +216,11 @@ def provision_access_request(request_code: str) -> ProvisionResult: mailu_email = f"{username}@{settings.MAILU_DOMAIN}" _upsert_task(conn, request_code, "keycloak_user", "ok", None) - except Exception: - _upsert_task(conn, request_code, "keycloak_user", "error", "failed to ensure user") + except Exception as exc: + _upsert_task(conn, request_code, "keycloak_user", "error", _safe_error_detail(exc, "failed to ensure user")) + + if not user_id: + return ProvisionResult(ok=False, status="accounts_building") # Task: set initial temporary password and store it for "show once" onboarding. try: @@ -223,8 +254,8 @@ def provision_access_request(request_code: str) -> ProvisionResult: _upsert_task(conn, request_code, "keycloak_password", "ok", "initial password already revealed") else: raise RuntimeError("initial password missing") - except Exception: - _upsert_task(conn, request_code, "keycloak_password", "error", "failed to set password") + except Exception as exc: + _upsert_task(conn, request_code, "keycloak_password", "error", _safe_error_detail(exc, "failed to set password")) # Task: group membership (default dev) try: @@ -237,8 +268,8 @@ def provision_access_request(request_code: str) -> ProvisionResult: raise RuntimeError("group missing") admin_client().add_user_to_group(user_id, gid) _upsert_task(conn, request_code, "keycloak_groups", "ok", None) - except Exception: - _upsert_task(conn, request_code, "keycloak_groups", "error", "failed to add groups") + except Exception as exc: + _upsert_task(conn, request_code, "keycloak_groups", "error", _safe_error_detail(exc, "failed to add groups")) # Task: ensure mailu_app_password attribute exists try: @@ -256,8 +287,8 @@ def provision_access_request(request_code: str) -> ProvisionResult: if not existing: admin_client().set_user_attribute(username, MAILU_APP_PASSWORD_ATTR, random_password()) _upsert_task(conn, request_code, "mailu_app_password", "ok", None) - except Exception: - _upsert_task(conn, request_code, "mailu_app_password", "error", "failed to set mail password") + except Exception as exc: + _upsert_task(conn, request_code, "mailu_app_password", "error", _safe_error_detail(exc, "failed to set mail password")) # Task: trigger Mailu sync if configured try: @@ -272,8 +303,8 @@ def provision_access_request(request_code: str) -> ProvisionResult: if resp.status_code != 200: raise RuntimeError("mailu sync failed") _upsert_task(conn, request_code, "mailu_sync", "ok", None) - except Exception: - _upsert_task(conn, request_code, "mailu_sync", "error", "failed to sync mailu") + except Exception as exc: + _upsert_task(conn, request_code, "mailu_sync", "error", _safe_error_detail(exc, "failed to sync mailu")) # Task: ensure Vaultwarden account exists (invite flow) try: @@ -284,8 +315,14 @@ def provision_access_request(request_code: str) -> ProvisionResult: _upsert_task(conn, request_code, "vaultwarden_invite", "ok", result.status) else: _upsert_task(conn, request_code, "vaultwarden_invite", "error", result.detail or result.status) - except Exception: - _upsert_task(conn, request_code, "vaultwarden_invite", "error", "failed to provision vaultwarden") + except Exception as exc: + _upsert_task( + conn, + request_code, + "vaultwarden_invite", + "error", + _safe_error_detail(exc, "failed to provision vaultwarden"), + ) if _all_tasks_ok(conn, request_code, required_tasks): conn.execute( diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index 3249e1a..93aefd0 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -462,6 +462,40 @@ def register(app) -> None: "status": status, "username": row.get("username") or "", } + task_rows = conn.execute( + """ + SELECT task, status, detail, updated_at + FROM access_request_tasks + WHERE request_code = %s + ORDER BY task + """, + (code,), + ).fetchall() + if task_rows: + tasks: list[dict[str, Any]] = [] + blocked = False + for task_row in task_rows: + task_name = task_row.get("task") if isinstance(task_row, dict) else None + task_status = task_row.get("status") if isinstance(task_row, dict) else None + detail = task_row.get("detail") if isinstance(task_row, dict) else None + updated_at = task_row.get("updated_at") if isinstance(task_row, dict) else None + + if isinstance(task_status, str) and task_status == "error": + blocked = True + + task_payload: dict[str, Any] = { + "task": task_name or "", + "status": task_status or "", + } + if isinstance(detail, str) and detail: + task_payload["detail"] = detail + if isinstance(updated_at, datetime): + task_payload["updated_at"] = updated_at.astimezone(timezone.utc).isoformat() + tasks.append(task_payload) + + response["tasks"] = tasks + response["automation_complete"] = provision_tasks_complete(conn, code) + response["blocked"] = blocked if status in {"awaiting_onboarding", "ready"}: password = row.get("initial_password") revealed_at = row.get("initial_password_revealed_at") diff --git a/frontend/src/views/OnboardingView.vue b/frontend/src/views/OnboardingView.vue index ec21ca7..7c9339a 100644 --- a/frontend/src/views/OnboardingView.vue +++ b/frontend/src/views/OnboardingView.vue @@ -52,6 +52,24 @@
Your request is approved. Atlas is now preparing accounts and credentials. Check back in a minute.
++ One or more automation steps failed. Fix the error above, then check again. +
+