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,
|
status TEXT NOT NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
decided_at TIMESTAMPTZ,
|
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)
|
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(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS access_request_onboarding_steps_request_code
|
CREATE INDEX IF NOT EXISTS access_request_onboarding_steps_request_code
|
||||||
|
|||||||
@ -130,6 +130,16 @@ class KeycloakAdminClient:
|
|||||||
return location.rstrip("/").split("/")[-1]
|
return location.rstrip("/").split("/")[-1]
|
||||||
raise RuntimeError("failed to determine created user id")
|
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:
|
def set_user_attribute(self, username: str, key: str, value: str) -> None:
|
||||||
user = self.find_user(username)
|
user = self.find_user(username)
|
||||||
if not user:
|
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 ..db import connect, configured
|
||||||
from ..keycloak import admin_client, require_auth
|
from ..keycloak import admin_client, require_auth
|
||||||
from ..rate_limit import rate_limit_allow
|
from ..rate_limit import rate_limit_allow
|
||||||
|
from ..provisioning import provision_tasks_complete
|
||||||
from .. import settings
|
from .. import settings
|
||||||
|
|
||||||
|
|
||||||
@ -66,13 +67,36 @@ def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]:
|
|||||||
return completed
|
return completed
|
||||||
|
|
||||||
|
|
||||||
def _automation_ready(username: str) -> bool:
|
def _automation_ready(conn, request_code: str, username: str) -> bool:
|
||||||
if not username:
|
if not username:
|
||||||
return False
|
return False
|
||||||
if not admin_client().ready():
|
if not admin_client().ready():
|
||||||
return False
|
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:
|
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:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -80,7 +104,7 @@ def _automation_ready(username: str) -> bool:
|
|||||||
def _advance_status(conn, request_code: str, username: str, status: str) -> str:
|
def _advance_status(conn, request_code: str, username: str, status: str) -> str:
|
||||||
status = _normalize_status(status)
|
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(
|
conn.execute(
|
||||||
"UPDATE access_requests SET status = 'awaiting_onboarding' WHERE request_code = %s AND status = 'accounts_building'",
|
"UPDATE access_requests SET status = 'awaiting_onboarding' WHERE request_code = %s AND status = 'accounts_building'",
|
||||||
(request_code,),
|
(request_code,),
|
||||||
@ -213,7 +237,7 @@ def register(app) -> None:
|
|||||||
try:
|
try:
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
row = conn.execute(
|
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,),
|
(code,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
@ -224,6 +248,15 @@ def register(app) -> None:
|
|||||||
"status": status,
|
"status": status,
|
||||||
"username": row.get("username") or "",
|
"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"}:
|
if status in {"awaiting_onboarding", "ready"}:
|
||||||
response["onboarding_url"] = f"/onboarding?code={code}"
|
response["onboarding_url"] = f"/onboarding?code={code}"
|
||||||
if status in {"awaiting_onboarding", "ready"}:
|
if status in {"awaiting_onboarding", "ready"}:
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from flask import jsonify, g
|
|||||||
|
|
||||||
from ..db import connect, configured
|
from ..db import connect, configured
|
||||||
from ..keycloak import require_auth, require_portal_admin
|
from ..keycloak import require_auth, require_portal_admin
|
||||||
|
from ..provisioning import provision_access_request
|
||||||
|
|
||||||
|
|
||||||
def register(app) -> None:
|
def register(app) -> None:
|
||||||
@ -72,6 +73,13 @@ def register(app) -> None:
|
|||||||
|
|
||||||
if not row:
|
if not row:
|
||||||
return jsonify({"ok": True, "request_code": ""})
|
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"]})
|
return jsonify({"ok": True, "request_code": row["request_code"]})
|
||||||
|
|
||||||
@app.route("/api/admin/access/requests/<username>/deny", methods=["POST"])
|
@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_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()]
|
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_ENABLED = _env_bool("ACCESS_REQUEST_ENABLED", "true")
|
||||||
ACCESS_REQUEST_RATE_LIMIT = int(os.getenv("ACCESS_REQUEST_RATE_LIMIT", "5"))
|
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)))
|
ACCESS_REQUEST_RATE_WINDOW_SEC = int(os.getenv("ACCESS_REQUEST_RATE_WINDOW_SEC", str(60 * 60)))
|
||||||
|
|||||||
@ -10,119 +10,169 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-for="category in categories" :key="category.title" class="card category">
|
<section class="section-grid">
|
||||||
<div class="section-head">
|
<section v-for="section in sections" :key="section.title" class="card category">
|
||||||
<div>
|
<div class="section-head">
|
||||||
<h2>{{ category.title }}</h2>
|
<div>
|
||||||
<p class="muted">{{ category.description }}</p>
|
<h2>{{ section.title }}</h2>
|
||||||
|
<p class="muted">{{ section.description }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tiles">
|
<div v-for="group in section.groups" :key="group.title" class="group">
|
||||||
<a
|
<div class="group-title">{{ group.title }}</div>
|
||||||
v-for="app in category.apps"
|
<div class="tiles">
|
||||||
:key="app.name"
|
<a
|
||||||
class="tile"
|
v-for="app in group.apps"
|
||||||
:href="app.url"
|
:key="app.name"
|
||||||
:target="app.target"
|
class="tile"
|
||||||
rel="noreferrer"
|
:href="app.url"
|
||||||
>
|
:target="app.target"
|
||||||
<div class="tile-title">{{ app.name }}</div>
|
rel="noreferrer"
|
||||||
<div class="tile-desc">{{ app.description }}</div>
|
>
|
||||||
</a>
|
<div class="tile-title">{{ app.name }}</div>
|
||||||
</div>
|
<div class="tile-desc">{{ app.description }}</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const categories = [
|
const sections = [
|
||||||
{
|
{
|
||||||
title: "Cloud",
|
title: "Cloud",
|
||||||
description: "Files, photos, mail, calendars, and documents — the primary hub for Atlas users.",
|
description: "Files, photos, mail, calendars, and documents — the primary hub for Atlas users.",
|
||||||
apps: [
|
groups: [
|
||||||
{
|
{
|
||||||
name: "Nextcloud",
|
title: "Nextcloud",
|
||||||
url: "https://cloud.bstein.dev",
|
apps: [
|
||||||
target: "_blank",
|
{
|
||||||
description: "Your personal cloud storage and productivity suite.",
|
name: "Cloud",
|
||||||
|
url: "https://cloud.bstein.dev",
|
||||||
|
target: "_blank",
|
||||||
|
description: "Your personal cloud storage and productivity suite.",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Security",
|
title: "Security",
|
||||||
description: "Modern password security and secrets tooling.",
|
description: "Password security, secrets, and account hygiene.",
|
||||||
apps: [
|
groups: [
|
||||||
{
|
{
|
||||||
name: "Vaultwarden",
|
title: "Personal",
|
||||||
url: "https://vault.bstein.dev",
|
apps: [
|
||||||
target: "_blank",
|
{
|
||||||
description: "Password manager (Bitwarden-compatible).",
|
name: "Vaultwarden",
|
||||||
|
url: "https://vault.bstein.dev",
|
||||||
|
target: "_blank",
|
||||||
|
description: "Password manager (Bitwarden-compatible).",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Vault",
|
title: "Infrastructure",
|
||||||
url: "https://secret.bstein.dev",
|
apps: [
|
||||||
target: "_blank",
|
{
|
||||||
description: "Secrets management for infrastructure and apps.",
|
name: "Vault",
|
||||||
|
url: "https://secret.bstein.dev",
|
||||||
|
target: "_blank",
|
||||||
|
description: "Secrets management for infrastructure and apps.",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Communications",
|
title: "Communications",
|
||||||
description: "Chat and meetings.",
|
description: "Discord-like chat, calls, and rooms — plus Atlas AI chat bots.",
|
||||||
apps: [
|
groups: [
|
||||||
{
|
{
|
||||||
name: "AI Chat",
|
title: "Chat",
|
||||||
url: "/ai/chat",
|
apps: [
|
||||||
target: "_self",
|
{
|
||||||
description: "Chat with Atlas AI (GPU-accelerated).",
|
name: "Element",
|
||||||
},
|
url: "https://live.bstein.dev",
|
||||||
{
|
target: "_blank",
|
||||||
name: "Meet",
|
description: "Matrix chat rooms with voice/video powered by local infra.",
|
||||||
url: "https://meet.bstein.dev",
|
},
|
||||||
target: "_blank",
|
{
|
||||||
description: "Video meetings (Jitsi).",
|
name: "AI Chat",
|
||||||
|
url: "/ai/chat",
|
||||||
|
target: "_self",
|
||||||
|
description: "Chat with Atlas AI (GPU-accelerated).",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Streaming",
|
title: "Streaming",
|
||||||
description: "Family media streaming and upload workflows.",
|
description: "Family media streaming and upload workflows.",
|
||||||
apps: [
|
groups: [
|
||||||
{
|
{
|
||||||
name: "Jellyfin",
|
title: "Media",
|
||||||
url: "https://stream.bstein.dev",
|
apps: [
|
||||||
target: "_blank",
|
{
|
||||||
description: "Stream videos to desktop, mobile, and TV.",
|
name: "Jellyfin",
|
||||||
},
|
url: "https://stream.bstein.dev",
|
||||||
{
|
target: "_blank",
|
||||||
name: "Pegasus",
|
description: "Stream videos to desktop, mobile, and TV.",
|
||||||
url: "https://pegasus.bstein.dev",
|
},
|
||||||
target: "_blank",
|
{
|
||||||
description: "Mobile-friendly upload/publish into Jellyfin.",
|
name: "Pegasus",
|
||||||
|
url: "https://pegasus.bstein.dev",
|
||||||
|
target: "_blank",
|
||||||
|
description: "Mobile-friendly upload/publish into Jellyfin.",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Dev",
|
title: "Dev",
|
||||||
description: "Source control, CI, registry, GitOps, and observability.",
|
description: "Source control, CI, registry, GitOps, and observability.",
|
||||||
apps: [
|
groups: [
|
||||||
{ 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: "Source & CI",
|
||||||
{ name: "Harbor", url: "https://registry.bstein.dev", target: "_blank", description: "Artifact registry." },
|
apps: [
|
||||||
{ name: "GitOps", url: "https://cd.bstein.dev", target: "_blank", description: "GitOps UI for Flux." },
|
{ name: "Gitea", url: "https://scm.bstein.dev", target: "_blank", description: "Git hosting and collaboration." },
|
||||||
{ name: "Grafana", url: "https://metrics.bstein.dev", target: "_blank", description: "Dashboards and monitoring." },
|
{ 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",
|
title: "Crypto",
|
||||||
description: "Local infrastructure for crypto workloads.",
|
description: "Local infrastructure for crypto workloads.",
|
||||||
apps: [
|
groups: [
|
||||||
{
|
{
|
||||||
name: "Monero Node",
|
title: "Monero",
|
||||||
url: "https://monero.bstein.dev",
|
apps: [
|
||||||
target: "_blank",
|
{
|
||||||
description: "Faster sync using the Atlas Monero node.",
|
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;
|
padding: 32px 22px 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 980px) {
|
||||||
|
.section-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@ -146,7 +207,6 @@ const categories = [
|
|||||||
|
|
||||||
.category {
|
.category {
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
margin-top: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-head {
|
.section-head {
|
||||||
@ -157,6 +217,16 @@ const categories = [
|
|||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.group + .group {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-strong);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@ -54,6 +54,26 @@
|
|||||||
Atlas can't reliably verify these steps automatically yet. Mark them complete once you're done.
|
Atlas can't reliably verify these steps automatically yet. Mark them complete once you're done.
|
||||||
</p>
|
</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">
|
<div v-if="!auth.authenticated" class="login-callout">
|
||||||
<p class="muted">Log in to check off onboarding steps.</p>
|
<p class="muted">Log in to check off onboarding steps.</p>
|
||||||
<button class="primary" type="button" @click="loginToContinue" :disabled="loading">Log in</button>
|
<button class="primary" type="button" @click="loginToContinue" :disabled="loading">Log in</button>
|
||||||
@ -155,6 +175,8 @@ const status = ref("");
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
const onboarding = ref({ required_steps: [], completed_steps: [] });
|
const onboarding = ref({ required_steps: [], completed_steps: [] });
|
||||||
|
const initialPassword = ref("");
|
||||||
|
const copied = ref(false);
|
||||||
|
|
||||||
function statusLabel(value) {
|
function statusLabel(value) {
|
||||||
const key = (value || "").trim();
|
const key = (value || "").trim();
|
||||||
@ -196,6 +218,9 @@ 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: [] };
|
||||||
|
if (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";
|
||||||
} finally {
|
} 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() {
|
async function loginToContinue() {
|
||||||
await login(`/onboarding?code=${encodeURIComponent(requestCode.value.trim())}`);
|
await login(`/onboarding?code=${encodeURIComponent(requestCode.value.trim())}`);
|
||||||
}
|
}
|
||||||
@ -395,6 +445,38 @@ button.primary {
|
|||||||
background: rgba(120, 180, 255, 0.06);
|
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 {
|
.steps ol {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 18px;
|
padding-left: 18px;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user