portal: surface provisioning task status
This commit is contained in:
parent
5e2888abf7
commit
a4c621a9bb
@ -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(
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user