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.

+
+
+

Automation

+ + {{ blocked ? "blocked" : "running" }} + +
+ +

+ One or more automation steps failed. Fix the error above, then check again. +

+
@@ -215,6 +233,8 @@ const error = ref(""); const onboarding = ref({ required_steps: [], completed_steps: [] }); const initialPassword = ref(""); const copied = ref(false); +const tasks = ref([]); +const blocked = ref(false); function statusLabel(value) { const key = (value || "").trim(); @@ -243,6 +263,14 @@ function isStepDone(step) { return Array.isArray(steps) ? steps.includes(step) : false; } +function taskPillClass(status) { + const key = (status || "").trim(); + if (key === "ok") return "pill-ok"; + if (key === "error") return "pill-bad"; + if (key === "pending") return "pill-warn"; + return "pill-warn"; +} + async function check() { if (loading.value) return; error.value = ""; @@ -258,11 +286,15 @@ async function check() { status.value = data.status || "unknown"; requestUsername.value = data.username || ""; onboarding.value = data.onboarding || { required_steps: [], completed_steps: [] }; + tasks.value = Array.isArray(data.tasks) ? data.tasks : []; + blocked.value = Boolean(data.blocked); if (data.initial_password) { initialPassword.value = data.initial_password; } } catch (err) { error.value = err.message || "Failed to check status"; + tasks.value = []; + blocked.value = false; } finally { loading.value = false; } @@ -504,6 +536,39 @@ button.primary { background: rgba(255, 220, 120, 0.06); } +.task-box { + margin-top: 14px; + padding: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + background: rgba(0, 0, 0, 0.25); +} + +.task-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 10px; +} + +.task-row { + display: grid; + gap: 6px; + grid-template-columns: 1fr auto; + align-items: center; +} + +.task-name { + color: var(--text); +} + +.task-detail { + grid-column: 1 / -1; + color: var(--text-muted); + font-size: 12px; +} + .request-code-row { margin-top: 12px; display: flex; diff --git a/frontend/src/views/RequestAccessView.vue b/frontend/src/views/RequestAccessView.vue index a95e33b..177d5af 100644 --- a/frontend/src/views/RequestAccessView.vue +++ b/frontend/src/views/RequestAccessView.vue @@ -115,6 +115,22 @@
Continue onboarding
+ +
+
+

Automation

+ + {{ blocked ? "blocked" : "running" }} + +
+ +
@@ -172,6 +188,16 @@ const statusForm = reactive({ const checking = ref(false); const status = ref(""); const onboardingUrl = ref(""); +const tasks = ref([]); +const blocked = ref(false); + +function taskPillClass(status) { + const key = (status || "").trim(); + if (key === "ok") return "pill-ok"; + if (key === "error") return "pill-bad"; + if (key === "pending") return "pill-warn"; + return "pill-warn"; +} async function submit() { if (submitting.value) return; @@ -235,6 +261,8 @@ async function checkStatus() { error.value = "Request code should look like username~XXXXXXXXXX. Copy it from the submit step."; status.value = "unknown"; onboardingUrl.value = ""; + tasks.value = []; + blocked.value = false; return; } checking.value = true; @@ -249,10 +277,14 @@ async function checkStatus() { if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`); status.value = data.status || "unknown"; onboardingUrl.value = data.onboarding_url || ""; + tasks.value = Array.isArray(data.tasks) ? data.tasks : []; + blocked.value = Boolean(data.blocked); } catch (err) { error.value = err.message || "Failed to check status"; status.value = "unknown"; onboardingUrl.value = ""; + tasks.value = []; + blocked.value = false; } finally { checking.value = false; } @@ -461,3 +493,38 @@ h1 { border-color: rgba(255, 220, 120, 0.25); } + +