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]:
|
def _task_statuses(conn, request_code: str) -> dict[str, str]:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT task, status FROM access_request_tasks WHERE request_code = %s",
|
"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"}:
|
if status not in {"accounts_building", "awaiting_onboarding", "ready"}:
|
||||||
return ProvisionResult(ok=False, status=status or "unknown")
|
return ProvisionResult(ok=False, status=status or "unknown")
|
||||||
|
|
||||||
|
_ensure_task_rows(conn, request_code, required_tasks)
|
||||||
|
|
||||||
if status == "accounts_building":
|
if status == "accounts_building":
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
if isinstance(attempted_at, datetime):
|
if isinstance(attempted_at, datetime):
|
||||||
@ -150,7 +178,7 @@ def provision_access_request(request_code: str) -> ProvisionResult:
|
|||||||
if not user:
|
if not user:
|
||||||
email = contact_email.strip()
|
email = contact_email.strip()
|
||||||
if not email:
|
if not email:
|
||||||
raise RuntimeError("contact email missing")
|
raise RuntimeError("missing verified email address")
|
||||||
payload = {
|
payload = {
|
||||||
"username": username,
|
"username": username,
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@ -188,8 +216,11 @@ def provision_access_request(request_code: str) -> ProvisionResult:
|
|||||||
mailu_email = f"{username}@{settings.MAILU_DOMAIN}"
|
mailu_email = f"{username}@{settings.MAILU_DOMAIN}"
|
||||||
|
|
||||||
_upsert_task(conn, request_code, "keycloak_user", "ok", None)
|
_upsert_task(conn, request_code, "keycloak_user", "ok", None)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
_upsert_task(conn, request_code, "keycloak_user", "error", "failed to ensure user")
|
_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.
|
# Task: set initial temporary password and store it for "show once" onboarding.
|
||||||
try:
|
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")
|
_upsert_task(conn, request_code, "keycloak_password", "ok", "initial password already revealed")
|
||||||
else:
|
else:
|
||||||
raise RuntimeError("initial password missing")
|
raise RuntimeError("initial password missing")
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
_upsert_task(conn, request_code, "keycloak_password", "error", "failed to set password")
|
_upsert_task(conn, request_code, "keycloak_password", "error", _safe_error_detail(exc, "failed to set password"))
|
||||||
|
|
||||||
# Task: group membership (default dev)
|
# Task: group membership (default dev)
|
||||||
try:
|
try:
|
||||||
@ -237,8 +268,8 @@ def provision_access_request(request_code: str) -> ProvisionResult:
|
|||||||
raise RuntimeError("group missing")
|
raise RuntimeError("group missing")
|
||||||
admin_client().add_user_to_group(user_id, gid)
|
admin_client().add_user_to_group(user_id, gid)
|
||||||
_upsert_task(conn, request_code, "keycloak_groups", "ok", None)
|
_upsert_task(conn, request_code, "keycloak_groups", "ok", None)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
_upsert_task(conn, request_code, "keycloak_groups", "error", "failed to add groups")
|
_upsert_task(conn, request_code, "keycloak_groups", "error", _safe_error_detail(exc, "failed to add groups"))
|
||||||
|
|
||||||
# Task: ensure mailu_app_password attribute exists
|
# Task: ensure mailu_app_password attribute exists
|
||||||
try:
|
try:
|
||||||
@ -256,8 +287,8 @@ def provision_access_request(request_code: str) -> ProvisionResult:
|
|||||||
if not existing:
|
if not existing:
|
||||||
admin_client().set_user_attribute(username, MAILU_APP_PASSWORD_ATTR, random_password())
|
admin_client().set_user_attribute(username, MAILU_APP_PASSWORD_ATTR, random_password())
|
||||||
_upsert_task(conn, request_code, "mailu_app_password", "ok", None)
|
_upsert_task(conn, request_code, "mailu_app_password", "ok", None)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
_upsert_task(conn, request_code, "mailu_app_password", "error", "failed to set mail password")
|
_upsert_task(conn, request_code, "mailu_app_password", "error", _safe_error_detail(exc, "failed to set mail password"))
|
||||||
|
|
||||||
# Task: trigger Mailu sync if configured
|
# Task: trigger Mailu sync if configured
|
||||||
try:
|
try:
|
||||||
@ -272,8 +303,8 @@ def provision_access_request(request_code: str) -> ProvisionResult:
|
|||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise RuntimeError("mailu sync failed")
|
raise RuntimeError("mailu sync failed")
|
||||||
_upsert_task(conn, request_code, "mailu_sync", "ok", None)
|
_upsert_task(conn, request_code, "mailu_sync", "ok", None)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
_upsert_task(conn, request_code, "mailu_sync", "error", "failed to sync mailu")
|
_upsert_task(conn, request_code, "mailu_sync", "error", _safe_error_detail(exc, "failed to sync mailu"))
|
||||||
|
|
||||||
# Task: ensure Vaultwarden account exists (invite flow)
|
# Task: ensure Vaultwarden account exists (invite flow)
|
||||||
try:
|
try:
|
||||||
@ -284,8 +315,14 @@ def provision_access_request(request_code: str) -> ProvisionResult:
|
|||||||
_upsert_task(conn, request_code, "vaultwarden_invite", "ok", result.status)
|
_upsert_task(conn, request_code, "vaultwarden_invite", "ok", result.status)
|
||||||
else:
|
else:
|
||||||
_upsert_task(conn, request_code, "vaultwarden_invite", "error", result.detail or result.status)
|
_upsert_task(conn, request_code, "vaultwarden_invite", "error", result.detail or result.status)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
_upsert_task(conn, request_code, "vaultwarden_invite", "error", "failed to provision vaultwarden")
|
_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):
|
if _all_tasks_ok(conn, request_code, required_tasks):
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@ -462,6 +462,40 @@ def register(app) -> None:
|
|||||||
"status": status,
|
"status": status,
|
||||||
"username": row.get("username") or "",
|
"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"}:
|
if status in {"awaiting_onboarding", "ready"}:
|
||||||
password = row.get("initial_password")
|
password = row.get("initial_password")
|
||||||
revealed_at = row.get("initial_password_revealed_at")
|
revealed_at = row.get("initial_password_revealed_at")
|
||||||
|
|||||||
@ -52,6 +52,24 @@
|
|||||||
<p class="muted">
|
<p class="muted">
|
||||||
Your request is approved. Atlas is now preparing accounts and credentials. Check back in a minute.
|
Your request is approved. Atlas is now preparing accounts and credentials. Check back in a minute.
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div v-if="status === 'awaiting_onboarding' || status === 'ready'" class="steps">
|
<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 onboarding = ref({ required_steps: [], completed_steps: [] });
|
||||||
const initialPassword = ref("");
|
const initialPassword = ref("");
|
||||||
const copied = ref(false);
|
const copied = ref(false);
|
||||||
|
const tasks = ref([]);
|
||||||
|
const blocked = ref(false);
|
||||||
|
|
||||||
function statusLabel(value) {
|
function statusLabel(value) {
|
||||||
const key = (value || "").trim();
|
const key = (value || "").trim();
|
||||||
@ -243,6 +263,14 @@ function isStepDone(step) {
|
|||||||
return Array.isArray(steps) ? steps.includes(step) : false;
|
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() {
|
async function check() {
|
||||||
if (loading.value) return;
|
if (loading.value) return;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
@ -258,11 +286,15 @@ async function check() {
|
|||||||
status.value = data.status || "unknown";
|
status.value = data.status || "unknown";
|
||||||
requestUsername.value = data.username || "";
|
requestUsername.value = data.username || "";
|
||||||
onboarding.value = data.onboarding || { required_steps: [], completed_steps: [] };
|
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) {
|
if (data.initial_password) {
|
||||||
initialPassword.value = data.initial_password;
|
initialPassword.value = data.initial_password;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err.message || "Failed to check status";
|
error.value = err.message || "Failed to check status";
|
||||||
|
tasks.value = [];
|
||||||
|
blocked.value = false;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@ -504,6 +536,39 @@ button.primary {
|
|||||||
background: rgba(255, 220, 120, 0.06);
|
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 {
|
.request-code-row {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -115,6 +115,22 @@
|
|||||||
<div v-if="onboardingUrl" class="actions" style="margin-top: 12px;">
|
<div v-if="onboardingUrl" class="actions" style="margin-top: 12px;">
|
||||||
<a class="primary" :href="onboardingUrl">Continue onboarding</a>
|
<a class="primary" :href="onboardingUrl">Continue onboarding</a>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div v-if="error" class="error-box">
|
<div v-if="error" class="error-box">
|
||||||
@ -172,6 +188,16 @@ const statusForm = reactive({
|
|||||||
const checking = ref(false);
|
const checking = ref(false);
|
||||||
const status = ref("");
|
const status = ref("");
|
||||||
const onboardingUrl = 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() {
|
async function submit() {
|
||||||
if (submitting.value) return;
|
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.";
|
error.value = "Request code should look like username~XXXXXXXXXX. Copy it from the submit step.";
|
||||||
status.value = "unknown";
|
status.value = "unknown";
|
||||||
onboardingUrl.value = "";
|
onboardingUrl.value = "";
|
||||||
|
tasks.value = [];
|
||||||
|
blocked.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
checking.value = true;
|
checking.value = true;
|
||||||
@ -249,10 +277,14 @@ async function checkStatus() {
|
|||||||
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
|
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
|
||||||
status.value = data.status || "unknown";
|
status.value = data.status || "unknown";
|
||||||
onboardingUrl.value = data.onboarding_url || "";
|
onboardingUrl.value = data.onboarding_url || "";
|
||||||
|
tasks.value = Array.isArray(data.tasks) ? data.tasks : [];
|
||||||
|
blocked.value = Boolean(data.blocked);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err.message || "Failed to check status";
|
error.value = err.message || "Failed to check status";
|
||||||
status.value = "unknown";
|
status.value = "unknown";
|
||||||
onboardingUrl.value = "";
|
onboardingUrl.value = "";
|
||||||
|
tasks.value = [];
|
||||||
|
blocked.value = false;
|
||||||
} finally {
|
} finally {
|
||||||
checking.value = false;
|
checking.value = false;
|
||||||
}
|
}
|
||||||
@ -461,3 +493,38 @@ h1 {
|
|||||||
border-color: rgba(255, 220, 120, 0.25);
|
border-color: rgba(255, 220, 120, 0.25);
|
||||||
}
|
}
|
||||||
</style>
|
</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