portal: provision Keycloak + Mailu on approve
This commit is contained in:
parent
712676a054
commit
d986cbd922
@ -35,7 +35,23 @@ def ensure_schema() -> None:
|
||||
status TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
decided_at TIMESTAMPTZ,
|
||||
decided_by TEXT
|
||||
decided_by TEXT,
|
||||
initial_password TEXT,
|
||||
initial_password_revealed_at TIMESTAMPTZ
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS initial_password TEXT")
|
||||
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS initial_password_revealed_at TIMESTAMPTZ")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS access_request_tasks (
|
||||
request_code TEXT NOT NULL REFERENCES access_requests(request_code) ON DELETE CASCADE,
|
||||
task TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (request_code, task)
|
||||
)
|
||||
"""
|
||||
)
|
||||
@ -55,6 +71,12 @@ def ensure_schema() -> None:
|
||||
ON access_requests (status, created_at)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS access_request_tasks_request_code
|
||||
ON access_request_tasks (request_code)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS access_request_onboarding_steps_request_code
|
||||
|
||||
@ -130,6 +130,16 @@ class KeycloakAdminClient:
|
||||
return location.rstrip("/").split("/")[-1]
|
||||
raise RuntimeError("failed to determine created user id")
|
||||
|
||||
def reset_password(self, user_id: str, password: str, temporary: bool = True) -> None:
|
||||
url = (
|
||||
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
|
||||
f"/users/{quote(user_id, safe='')}/reset-password"
|
||||
)
|
||||
payload = {"type": "password", "value": password, "temporary": bool(temporary)}
|
||||
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
|
||||
resp = client.put(url, headers={**self._headers(), "Content-Type": "application/json"}, json=payload)
|
||||
resp.raise_for_status()
|
||||
|
||||
def set_user_attribute(self, username: str, key: str, value: str) -> None:
|
||||
user = self.find_user(username)
|
||||
if not user:
|
||||
|
||||
210
backend/atlas_portal/provisioning.py
Normal file
210
backend/atlas_portal/provisioning.py
Normal file
@ -0,0 +1,210 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from . import settings
|
||||
from .db import connect
|
||||
from .keycloak import admin_client
|
||||
from .utils import random_password
|
||||
|
||||
|
||||
MAILU_APP_PASSWORD_ATTR = "mailu_app_password"
|
||||
REQUIRED_PROVISION_TASKS: tuple[str, ...] = (
|
||||
"keycloak_user",
|
||||
"keycloak_password",
|
||||
"keycloak_groups",
|
||||
"mailu_app_password",
|
||||
"mailu_sync",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProvisionResult:
|
||||
ok: bool
|
||||
status: str
|
||||
|
||||
|
||||
def _upsert_task(conn, request_code: str, task: str, status: str, detail: str | None = None) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO access_request_tasks (request_code, task, status, detail, updated_at)
|
||||
VALUES (%s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (request_code, task)
|
||||
DO UPDATE SET status = EXCLUDED.status, detail = EXCLUDED.detail, updated_at = NOW()
|
||||
""",
|
||||
(request_code, task, status, detail),
|
||||
)
|
||||
|
||||
|
||||
def _task_statuses(conn, request_code: str) -> dict[str, str]:
|
||||
rows = conn.execute(
|
||||
"SELECT task, status FROM access_request_tasks WHERE request_code = %s",
|
||||
(request_code,),
|
||||
).fetchall()
|
||||
output: dict[str, str] = {}
|
||||
for row in rows:
|
||||
task = row.get("task") if isinstance(row, dict) else None
|
||||
status = row.get("status") if isinstance(row, dict) else None
|
||||
if isinstance(task, str) and isinstance(status, str):
|
||||
output[task] = status
|
||||
return output
|
||||
|
||||
|
||||
def _all_tasks_ok(conn, request_code: str, tasks: list[str]) -> bool:
|
||||
statuses = _task_statuses(conn, request_code)
|
||||
for task in tasks:
|
||||
if statuses.get(task) != "ok":
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def provision_tasks_complete(conn, request_code: str) -> bool:
|
||||
return _all_tasks_ok(conn, request_code, list(REQUIRED_PROVISION_TASKS))
|
||||
|
||||
|
||||
def provision_access_request(request_code: str) -> ProvisionResult:
|
||||
if not request_code:
|
||||
return ProvisionResult(ok=False, status="unknown")
|
||||
if not admin_client().ready():
|
||||
return ProvisionResult(ok=False, status="accounts_building")
|
||||
|
||||
required_tasks = list(REQUIRED_PROVISION_TASKS)
|
||||
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT username, contact_email, status, initial_password, initial_password_revealed_at
|
||||
FROM access_requests
|
||||
WHERE request_code = %s
|
||||
""",
|
||||
(request_code,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return ProvisionResult(ok=False, status="unknown")
|
||||
|
||||
username = str(row.get("username") or "")
|
||||
contact_email = str(row.get("contact_email") or "")
|
||||
status = str(row.get("status") or "")
|
||||
initial_password = row.get("initial_password")
|
||||
revealed_at = row.get("initial_password_revealed_at")
|
||||
|
||||
if status not in {"accounts_building", "awaiting_onboarding", "ready"}:
|
||||
return ProvisionResult(ok=False, status=status or "unknown")
|
||||
|
||||
user_id = ""
|
||||
|
||||
# Task: ensure Keycloak user exists
|
||||
try:
|
||||
user = admin_client().find_user(username)
|
||||
if not user:
|
||||
email = contact_email.strip() or f"{username}@{settings.MAILU_DOMAIN}"
|
||||
payload = {
|
||||
"username": username,
|
||||
"enabled": True,
|
||||
"email": email,
|
||||
"emailVerified": False,
|
||||
}
|
||||
created_id = admin_client().create_user(payload)
|
||||
user = admin_client().get_user(created_id)
|
||||
user_id = str((user or {}).get("id") or "")
|
||||
if not user_id:
|
||||
raise RuntimeError("user id missing")
|
||||
_upsert_task(conn, request_code, "keycloak_user", "ok", None)
|
||||
except Exception:
|
||||
_upsert_task(conn, request_code, "keycloak_user", "error", "failed to ensure user")
|
||||
|
||||
# Task: set initial temporary password and store it for "show once" onboarding
|
||||
try:
|
||||
if user_id:
|
||||
password_value = ""
|
||||
if isinstance(initial_password, str) and initial_password:
|
||||
password_value = initial_password
|
||||
elif initial_password is None and revealed_at is None:
|
||||
password_value = random_password(20)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE access_requests
|
||||
SET initial_password = %s, initial_password_revealed_at = NULL
|
||||
WHERE request_code = %s AND initial_password IS NULL
|
||||
""",
|
||||
(password_value, request_code),
|
||||
)
|
||||
initial_password = password_value
|
||||
elif isinstance(initial_password, str) and initial_password and revealed_at is None:
|
||||
password_value = initial_password
|
||||
|
||||
if password_value:
|
||||
admin_client().reset_password(user_id, password_value, temporary=True)
|
||||
_upsert_task(conn, request_code, "keycloak_password", "ok", None)
|
||||
else:
|
||||
raise RuntimeError("missing user id")
|
||||
except Exception:
|
||||
_upsert_task(conn, request_code, "keycloak_password", "error", "failed to set password")
|
||||
|
||||
# Task: group membership (default dev)
|
||||
try:
|
||||
if user_id:
|
||||
groups = settings.DEFAULT_USER_GROUPS or ["dev"]
|
||||
for group_name in groups:
|
||||
gid = admin_client().get_group_id(group_name)
|
||||
if not gid:
|
||||
raise RuntimeError("group missing")
|
||||
admin_client().add_user_to_group(user_id, gid)
|
||||
_upsert_task(conn, request_code, "keycloak_groups", "ok", None)
|
||||
else:
|
||||
raise RuntimeError("missing user id")
|
||||
except Exception:
|
||||
_upsert_task(conn, request_code, "keycloak_groups", "error", "failed to add groups")
|
||||
|
||||
# Task: ensure mailu_app_password attribute exists
|
||||
try:
|
||||
if user_id:
|
||||
full = admin_client().get_user(user_id)
|
||||
attrs = full.get("attributes") or {}
|
||||
existing = None
|
||||
if isinstance(attrs, dict):
|
||||
raw = attrs.get(MAILU_APP_PASSWORD_ATTR)
|
||||
if isinstance(raw, list) and raw and isinstance(raw[0], str):
|
||||
existing = raw[0]
|
||||
elif isinstance(raw, str) and raw:
|
||||
existing = raw
|
||||
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)
|
||||
else:
|
||||
raise RuntimeError("missing user id")
|
||||
except Exception:
|
||||
_upsert_task(conn, request_code, "mailu_app_password", "error", "failed to set mail password")
|
||||
|
||||
# Task: trigger Mailu sync if configured
|
||||
try:
|
||||
if not settings.MAILU_SYNC_URL:
|
||||
_upsert_task(conn, request_code, "mailu_sync", "ok", "sync disabled")
|
||||
else:
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.post(
|
||||
settings.MAILU_SYNC_URL,
|
||||
json={"ts": int(time.time()), "wait": True, "reason": "portal_access_approve"},
|
||||
)
|
||||
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")
|
||||
|
||||
# If everything is OK, advance to awaiting_onboarding.
|
||||
if _all_tasks_ok(conn, request_code, required_tasks):
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE access_requests
|
||||
SET status = 'awaiting_onboarding'
|
||||
WHERE request_code = %s AND status = 'accounts_building'
|
||||
""",
|
||||
(request_code,),
|
||||
)
|
||||
return ProvisionResult(ok=True, status="awaiting_onboarding")
|
||||
|
||||
return ProvisionResult(ok=False, status="accounts_building")
|
||||
@ -12,6 +12,7 @@ import psycopg
|
||||
from ..db import connect, configured
|
||||
from ..keycloak import admin_client, require_auth
|
||||
from ..rate_limit import rate_limit_allow
|
||||
from ..provisioning import provision_tasks_complete
|
||||
from .. import settings
|
||||
|
||||
|
||||
@ -66,13 +67,36 @@ def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]:
|
||||
return completed
|
||||
|
||||
|
||||
def _automation_ready(username: str) -> bool:
|
||||
def _automation_ready(conn, request_code: str, username: str) -> bool:
|
||||
if not username:
|
||||
return False
|
||||
if not admin_client().ready():
|
||||
return False
|
||||
|
||||
# Prefer task-based readiness when we have task rows for the request.
|
||||
task_row = conn.execute(
|
||||
"SELECT 1 FROM access_request_tasks WHERE request_code = %s LIMIT 1",
|
||||
(request_code,),
|
||||
).fetchone()
|
||||
if task_row:
|
||||
return provision_tasks_complete(conn, request_code)
|
||||
|
||||
# Fallback for legacy requests: confirm user exists and has a mail app password.
|
||||
try:
|
||||
return bool(admin_client().find_user(username))
|
||||
user = admin_client().find_user(username)
|
||||
if not user:
|
||||
return False
|
||||
user_id = user.get("id") if isinstance(user, dict) else None
|
||||
if not user_id:
|
||||
return False
|
||||
full = admin_client().get_user(str(user_id))
|
||||
attrs = full.get("attributes") or {}
|
||||
if not isinstance(attrs, dict):
|
||||
return False
|
||||
raw_pw = attrs.get("mailu_app_password")
|
||||
if isinstance(raw_pw, list):
|
||||
return bool(raw_pw and isinstance(raw_pw[0], str) and raw_pw[0])
|
||||
return bool(isinstance(raw_pw, str) and raw_pw)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@ -80,7 +104,7 @@ def _automation_ready(username: str) -> bool:
|
||||
def _advance_status(conn, request_code: str, username: str, status: str) -> str:
|
||||
status = _normalize_status(status)
|
||||
|
||||
if status == "accounts_building" and _automation_ready(username):
|
||||
if status == "accounts_building" and _automation_ready(conn, request_code, username):
|
||||
conn.execute(
|
||||
"UPDATE access_requests SET status = 'awaiting_onboarding' WHERE request_code = %s AND status = 'accounts_building'",
|
||||
(request_code,),
|
||||
@ -213,7 +237,7 @@ def register(app) -> None:
|
||||
try:
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT status, username FROM access_requests WHERE request_code = %s",
|
||||
"SELECT status, username, initial_password, initial_password_revealed_at FROM access_requests WHERE request_code = %s",
|
||||
(code,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
@ -224,6 +248,15 @@ def register(app) -> None:
|
||||
"status": status,
|
||||
"username": row.get("username") or "",
|
||||
}
|
||||
if status in {"awaiting_onboarding", "ready"}:
|
||||
password = row.get("initial_password")
|
||||
revealed_at = row.get("initial_password_revealed_at")
|
||||
if isinstance(password, str) and password and revealed_at is None:
|
||||
response["initial_password"] = password
|
||||
conn.execute(
|
||||
"UPDATE access_requests SET initial_password_revealed_at = NOW() WHERE request_code = %s AND initial_password_revealed_at IS NULL",
|
||||
(code,),
|
||||
)
|
||||
if status in {"awaiting_onboarding", "ready"}:
|
||||
response["onboarding_url"] = f"/onboarding?code={code}"
|
||||
if status in {"awaiting_onboarding", "ready"}:
|
||||
|
||||
@ -6,6 +6,7 @@ from flask import jsonify, g
|
||||
|
||||
from ..db import connect, configured
|
||||
from ..keycloak import require_auth, require_portal_admin
|
||||
from ..provisioning import provision_access_request
|
||||
|
||||
|
||||
def register(app) -> None:
|
||||
@ -72,6 +73,13 @@ def register(app) -> None:
|
||||
|
||||
if not row:
|
||||
return jsonify({"ok": True, "request_code": ""})
|
||||
|
||||
# Provision the account best-effort (Keycloak user + Mailu password + sync).
|
||||
try:
|
||||
provision_access_request(row["request_code"])
|
||||
except Exception:
|
||||
# Keep the request in accounts_building; status checks will surface it.
|
||||
pass
|
||||
return jsonify({"ok": True, "request_code": row["request_code"]})
|
||||
|
||||
@app.route("/api/admin/access/requests/<username>/deny", methods=["POST"])
|
||||
|
||||
@ -58,6 +58,8 @@ PORTAL_DATABASE_URL = os.getenv("PORTAL_DATABASE_URL", "").strip()
|
||||
PORTAL_ADMIN_USERS = [u.strip() for u in os.getenv("PORTAL_ADMIN_USERS", "bstein").split(",") if u.strip()]
|
||||
PORTAL_ADMIN_GROUPS = [g.strip() for g in os.getenv("PORTAL_ADMIN_GROUPS", "admin").split(",") if g.strip()]
|
||||
|
||||
DEFAULT_USER_GROUPS = [g.strip() for g in os.getenv("DEFAULT_USER_GROUPS", "dev").split(",") if g.strip()]
|
||||
|
||||
ACCESS_REQUEST_ENABLED = _env_bool("ACCESS_REQUEST_ENABLED", "true")
|
||||
ACCESS_REQUEST_RATE_LIMIT = int(os.getenv("ACCESS_REQUEST_RATE_LIMIT", "5"))
|
||||
ACCESS_REQUEST_RATE_WINDOW_SEC = int(os.getenv("ACCESS_REQUEST_RATE_WINDOW_SEC", str(60 * 60)))
|
||||
|
||||
@ -10,119 +10,169 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-for="category in categories" :key="category.title" class="card category">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>{{ category.title }}</h2>
|
||||
<p class="muted">{{ category.description }}</p>
|
||||
<section class="section-grid">
|
||||
<section v-for="section in sections" :key="section.title" class="card category">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>{{ section.title }}</h2>
|
||||
<p class="muted">{{ section.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tiles">
|
||||
<a
|
||||
v-for="app in category.apps"
|
||||
:key="app.name"
|
||||
class="tile"
|
||||
:href="app.url"
|
||||
:target="app.target"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div class="tile-title">{{ app.name }}</div>
|
||||
<div class="tile-desc">{{ app.description }}</div>
|
||||
</a>
|
||||
</div>
|
||||
<div v-for="group in section.groups" :key="group.title" class="group">
|
||||
<div class="group-title">{{ group.title }}</div>
|
||||
<div class="tiles">
|
||||
<a
|
||||
v-for="app in group.apps"
|
||||
:key="app.name"
|
||||
class="tile"
|
||||
:href="app.url"
|
||||
:target="app.target"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div class="tile-title">{{ app.name }}</div>
|
||||
<div class="tile-desc">{{ app.description }}</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const categories = [
|
||||
const sections = [
|
||||
{
|
||||
title: "Cloud",
|
||||
description: "Files, photos, mail, calendars, and documents — the primary hub for Atlas users.",
|
||||
apps: [
|
||||
groups: [
|
||||
{
|
||||
name: "Nextcloud",
|
||||
url: "https://cloud.bstein.dev",
|
||||
target: "_blank",
|
||||
description: "Your personal cloud storage and productivity suite.",
|
||||
title: "Nextcloud",
|
||||
apps: [
|
||||
{
|
||||
name: "Cloud",
|
||||
url: "https://cloud.bstein.dev",
|
||||
target: "_blank",
|
||||
description: "Your personal cloud storage and productivity suite.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Security",
|
||||
description: "Modern password security and secrets tooling.",
|
||||
apps: [
|
||||
description: "Password security, secrets, and account hygiene.",
|
||||
groups: [
|
||||
{
|
||||
name: "Vaultwarden",
|
||||
url: "https://vault.bstein.dev",
|
||||
target: "_blank",
|
||||
description: "Password manager (Bitwarden-compatible).",
|
||||
title: "Personal",
|
||||
apps: [
|
||||
{
|
||||
name: "Vaultwarden",
|
||||
url: "https://vault.bstein.dev",
|
||||
target: "_blank",
|
||||
description: "Password manager (Bitwarden-compatible).",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Vault",
|
||||
url: "https://secret.bstein.dev",
|
||||
target: "_blank",
|
||||
description: "Secrets management for infrastructure and apps.",
|
||||
title: "Infrastructure",
|
||||
apps: [
|
||||
{
|
||||
name: "Vault",
|
||||
url: "https://secret.bstein.dev",
|
||||
target: "_blank",
|
||||
description: "Secrets management for infrastructure and apps.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Communications",
|
||||
description: "Chat and meetings.",
|
||||
apps: [
|
||||
description: "Discord-like chat, calls, and rooms — plus Atlas AI chat bots.",
|
||||
groups: [
|
||||
{
|
||||
name: "AI Chat",
|
||||
url: "/ai/chat",
|
||||
target: "_self",
|
||||
description: "Chat with Atlas AI (GPU-accelerated).",
|
||||
},
|
||||
{
|
||||
name: "Meet",
|
||||
url: "https://meet.bstein.dev",
|
||||
target: "_blank",
|
||||
description: "Video meetings (Jitsi).",
|
||||
title: "Chat",
|
||||
apps: [
|
||||
{
|
||||
name: "Element",
|
||||
url: "https://live.bstein.dev",
|
||||
target: "_blank",
|
||||
description: "Matrix chat rooms with voice/video powered by local infra.",
|
||||
},
|
||||
{
|
||||
name: "AI Chat",
|
||||
url: "/ai/chat",
|
||||
target: "_self",
|
||||
description: "Chat with Atlas AI (GPU-accelerated).",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Streaming",
|
||||
description: "Family media streaming and upload workflows.",
|
||||
apps: [
|
||||
groups: [
|
||||
{
|
||||
name: "Jellyfin",
|
||||
url: "https://stream.bstein.dev",
|
||||
target: "_blank",
|
||||
description: "Stream videos to desktop, mobile, and TV.",
|
||||
},
|
||||
{
|
||||
name: "Pegasus",
|
||||
url: "https://pegasus.bstein.dev",
|
||||
target: "_blank",
|
||||
description: "Mobile-friendly upload/publish into Jellyfin.",
|
||||
title: "Media",
|
||||
apps: [
|
||||
{
|
||||
name: "Jellyfin",
|
||||
url: "https://stream.bstein.dev",
|
||||
target: "_blank",
|
||||
description: "Stream videos to desktop, mobile, and TV.",
|
||||
},
|
||||
{
|
||||
name: "Pegasus",
|
||||
url: "https://pegasus.bstein.dev",
|
||||
target: "_blank",
|
||||
description: "Mobile-friendly upload/publish into Jellyfin.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Dev",
|
||||
description: "Source control, CI, registry, GitOps, and observability.",
|
||||
apps: [
|
||||
{ name: "Gitea", url: "https://scm.bstein.dev", target: "_blank", description: "Git hosting and collaboration." },
|
||||
{ name: "Jenkins", url: "https://ci.bstein.dev", target: "_blank", description: "CI pipelines and automation." },
|
||||
{ name: "Harbor", url: "https://registry.bstein.dev", target: "_blank", description: "Artifact registry." },
|
||||
{ name: "GitOps", url: "https://cd.bstein.dev", target: "_blank", description: "GitOps UI for Flux." },
|
||||
{ name: "Grafana", url: "https://metrics.bstein.dev", target: "_blank", description: "Dashboards and monitoring." },
|
||||
groups: [
|
||||
{
|
||||
title: "Source & CI",
|
||||
apps: [
|
||||
{ name: "Gitea", url: "https://scm.bstein.dev", target: "_blank", description: "Git hosting and collaboration." },
|
||||
{ name: "Jenkins", url: "https://ci.bstein.dev", target: "_blank", description: "CI pipelines and automation." },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Registry & Deploy",
|
||||
apps: [
|
||||
{ name: "Harbor", url: "https://registry.bstein.dev", target: "_blank", description: "Artifact registry." },
|
||||
{ name: "GitOps", url: "https://cd.bstein.dev", target: "_blank", description: "GitOps UI for Flux." },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Observability",
|
||||
apps: [
|
||||
{ name: "Grafana", url: "https://metrics.bstein.dev", target: "_blank", description: "Dashboards and monitoring." },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Crypto",
|
||||
description: "Local infrastructure for crypto workloads.",
|
||||
apps: [
|
||||
groups: [
|
||||
{
|
||||
name: "Monero Node",
|
||||
url: "https://monero.bstein.dev",
|
||||
target: "_blank",
|
||||
description: "Faster sync using the Atlas Monero node.",
|
||||
title: "Monero",
|
||||
apps: [
|
||||
{
|
||||
name: "Monero Node",
|
||||
url: "https://monero.bstein.dev",
|
||||
target: "_blank",
|
||||
description: "Faster sync using the Atlas Monero node.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -136,6 +186,17 @@ const categories = [
|
||||
padding: 32px 22px 72px;
|
||||
}
|
||||
|
||||
.section-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@media (min-width: 980px) {
|
||||
.section-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@ -146,7 +207,6 @@ const categories = [
|
||||
|
||||
.category {
|
||||
padding: 18px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
@ -157,6 +217,16 @@ const categories = [
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.group + .group {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-weight: 700;
|
||||
color: var(--text-strong);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-muted);
|
||||
|
||||
@ -54,6 +54,26 @@
|
||||
Atlas can't reliably verify these steps automatically yet. Mark them complete once you're done.
|
||||
</p>
|
||||
|
||||
<div v-if="initialPassword" class="initial-password">
|
||||
<h3>Temporary password</h3>
|
||||
<p class="muted">
|
||||
Use this password to log in for the first time. Keycloak will prompt you to change it.
|
||||
</p>
|
||||
<div class="request-code-row">
|
||||
<span class="label mono">Password</span>
|
||||
<button class="copy mono" type="button" @click="copyInitialPassword">
|
||||
{{ initialPassword }}
|
||||
<span v-if="copied" class="copied">copied</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="muted">
|
||||
Log in at
|
||||
<a href="https://sso.bstein.dev" target="_blank" rel="noreferrer">sso.bstein.dev</a>
|
||||
or go directly to
|
||||
<a href="https://cloud.bstein.dev" target="_blank" rel="noreferrer">cloud.bstein.dev</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!auth.authenticated" class="login-callout">
|
||||
<p class="muted">Log in to check off onboarding steps.</p>
|
||||
<button class="primary" type="button" @click="loginToContinue" :disabled="loading">Log in</button>
|
||||
@ -155,6 +175,8 @@ const status = ref("");
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const onboarding = ref({ required_steps: [], completed_steps: [] });
|
||||
const initialPassword = ref("");
|
||||
const copied = ref(false);
|
||||
|
||||
function statusLabel(value) {
|
||||
const key = (value || "").trim();
|
||||
@ -196,6 +218,9 @@ async function check() {
|
||||
status.value = data.status || "unknown";
|
||||
requestUsername.value = data.username || "";
|
||||
onboarding.value = data.onboarding || { required_steps: [], completed_steps: [] };
|
||||
if (data.initial_password) {
|
||||
initialPassword.value = data.initial_password;
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message || "Failed to check status";
|
||||
} finally {
|
||||
@ -203,6 +228,31 @@ async function check() {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyInitialPassword() {
|
||||
if (!initialPassword.value) return;
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(initialPassword.value);
|
||||
} else {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = initialPassword.value;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "-9999px";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
copied.value = true;
|
||||
setTimeout(() => (copied.value = false), 1500);
|
||||
} catch (err) {
|
||||
error.value = err?.message || "Failed to copy password";
|
||||
}
|
||||
}
|
||||
|
||||
async function loginToContinue() {
|
||||
await login(`/onboarding?code=${encodeURIComponent(requestCode.value.trim())}`);
|
||||
}
|
||||
@ -395,6 +445,38 @@ button.primary {
|
||||
background: rgba(120, 180, 255, 0.06);
|
||||
}
|
||||
|
||||
.initial-password {
|
||||
margin-top: 14px;
|
||||
padding: 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 220, 120, 0.25);
|
||||
background: rgba(255, 220, 120, 0.06);
|
||||
}
|
||||
|
||||
.request-code-row {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.copy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copied {
|
||||
font-size: 12px;
|
||||
color: rgba(120, 255, 160, 0.9);
|
||||
}
|
||||
|
||||
.steps ol {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user