portal: surface provisioning task status

This commit is contained in:
Brad Stein 2026-01-03 04:55:03 -03:00
parent 5e2888abf7
commit a4c621a9bb
4 changed files with 216 additions and 13 deletions

View File

@ -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(

View File

@ -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")

View File

@ -52,6 +52,24 @@
<p class="muted">
Your request is approved. Atlas is now preparing accounts and credentials. Check back in a minute.
</p>
<div v-if="tasks.length" class="task-box">
<div class="module-head" style="margin-bottom: 10px;">
<h3>Automation</h3>
<span class="pill mono" :class="blocked ? 'pill-bad' : 'pill-ok'">
{{ blocked ? "blocked" : "running" }}
</span>
</div>
<ul class="task-list">
<li v-for="item in tasks" :key="item.task" class="task-row">
<span class="mono task-name">{{ item.task }}</span>
<span class="pill mono" :class="taskPillClass(item.status)">{{ item.status }}</span>
<span v-if="item.detail" class="mono task-detail">{{ item.detail }}</span>
</li>
</ul>
<p v-if="blocked" class="muted" style="margin-top: 10px;">
One or more automation steps failed. Fix the error above, then check again.
</p>
</div>
</div>
<div v-if="status === 'awaiting_onboarding' || status === 'ready'" class="steps">
@ -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;

View File

@ -115,6 +115,22 @@
<div v-if="onboardingUrl" class="actions" style="margin-top: 12px;">
<a class="primary" :href="onboardingUrl">Continue onboarding</a>
</div>
<div v-if="tasks.length" class="task-box">
<div class="module-head" style="margin-bottom: 10px;">
<h2>Automation</h2>
<span class="pill mono" :class="blocked ? 'pill-bad' : 'pill-ok'">
{{ blocked ? "blocked" : "running" }}
</span>
</div>
<ul class="task-list">
<li v-for="item in tasks" :key="item.task" class="task-row">
<span class="mono task-name">{{ item.task }}</span>
<span class="pill mono" :class="taskPillClass(item.status)">{{ item.status }}</span>
<span v-if="item.detail" class="mono task-detail">{{ item.detail }}</span>
</li>
</ul>
</div>
</div>
<div v-if="error" class="error-box">
@ -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);
}
</style>
<style scoped>
.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;
}
</style>