portal: add Keycloak-backed account portal

This commit is contained in:
Brad Stein 2026-01-01 21:37:53 -03:00
parent b980eda249
commit d53a63021d
12 changed files with 995 additions and 6 deletions

View File

@ -3,15 +3,21 @@ from __future__ import annotations
import json
import os
import time
from functools import wraps
from pathlib import Path
import secrets
import string
from typing import Any
from urllib.error import URLError
from urllib.parse import quote
from urllib.parse import urlencode
from urllib.request import urlopen
from flask import Flask, jsonify, request, send_from_directory
from flask import Flask, g, jsonify, request, send_from_directory
from flask_cors import CORS
import httpx
import jwt
from jwt import PyJWKClient
app = Flask(__name__, static_folder="../frontend/dist", static_url_path="")
@ -44,6 +50,35 @@ AI_GPU_ANNOTATION = os.getenv("AI_GPU_ANNOTATION", "ai.bstein.dev/gpu")
AI_WARM_INTERVAL_SEC = float(os.getenv("AI_WARM_INTERVAL_SEC", "300"))
AI_WARM_ENABLED = os.getenv("AI_WARM_ENABLED", "true").lower() in ("1", "true", "yes")
KEYCLOAK_ENABLED = os.getenv("KEYCLOAK_ENABLED", "false").lower() in ("1", "true", "yes")
KEYCLOAK_URL = os.getenv("KEYCLOAK_URL", "https://sso.bstein.dev").rstrip("/")
KEYCLOAK_REALM = os.getenv("KEYCLOAK_REALM", "atlas")
KEYCLOAK_CLIENT_ID = os.getenv("KEYCLOAK_CLIENT_ID", "bstein-dev-home")
KEYCLOAK_ISSUER = os.getenv("KEYCLOAK_ISSUER", f"{KEYCLOAK_URL}/realms/{KEYCLOAK_REALM}").rstrip("/")
KEYCLOAK_JWKS_URL = os.getenv("KEYCLOAK_JWKS_URL", f"{KEYCLOAK_ISSUER}/protocol/openid-connect/certs").rstrip("/")
KEYCLOAK_ADMIN_URL = os.getenv("KEYCLOAK_ADMIN_URL", KEYCLOAK_URL).rstrip("/")
KEYCLOAK_ADMIN_CLIENT_ID = os.getenv("KEYCLOAK_ADMIN_CLIENT_ID", "")
KEYCLOAK_ADMIN_CLIENT_SECRET = os.getenv("KEYCLOAK_ADMIN_CLIENT_SECRET", "")
KEYCLOAK_ADMIN_REALM = os.getenv("KEYCLOAK_ADMIN_REALM", KEYCLOAK_REALM)
ACCOUNT_ALLOWED_GROUPS = [
g.strip()
for g in os.getenv("ACCOUNT_ALLOWED_GROUPS", "dev,admin").split(",")
if g.strip()
]
MAILU_DOMAIN = os.getenv("MAILU_DOMAIN", "bstein.dev")
MAILU_SYNC_URL = os.getenv(
"MAILU_SYNC_URL",
"http://mailu-sync-listener.mailu-mailserver.svc.cluster.local:8080/events",
).rstrip("/")
JELLYFIN_SYNC_URL = os.getenv("JELLYFIN_SYNC_URL", "").rstrip("/")
_KEYCLOAK_JWK_CLIENT: PyJWKClient | None = None
_KEYCLOAK_ADMIN_TOKEN: dict[str, Any] = {"token": "", "expires_at": 0.0}
_LAB_STATUS_CACHE: dict[str, Any] = {"ts": 0.0, "value": None}
@app.route("/api/healthz")
@ -51,6 +86,297 @@ def healthz() -> Any:
return jsonify({"ok": True})
def _normalize_groups(groups: Any) -> list[str]:
if not isinstance(groups, list):
return []
cleaned: list[str] = []
for gname in groups:
if not isinstance(gname, str):
continue
cleaned.append(gname.lstrip("/"))
return [gname for gname in cleaned if gname]
def _extract_bearer_token() -> str | None:
header = request.headers.get("Authorization", "")
if not header:
return None
parts = header.split(None, 1)
if len(parts) != 2:
return None
scheme, token = parts[0].lower(), parts[1].strip()
if scheme != "bearer" or not token:
return None
return token
def _get_jwk_client() -> PyJWKClient:
global _KEYCLOAK_JWK_CLIENT
if _KEYCLOAK_JWK_CLIENT is None:
_KEYCLOAK_JWK_CLIENT = PyJWKClient(KEYCLOAK_JWKS_URL)
return _KEYCLOAK_JWK_CLIENT
def _verify_keycloak_token(token: str) -> dict[str, Any]:
if not KEYCLOAK_ENABLED:
raise ValueError("keycloak not enabled")
signing_key = _get_jwk_client().get_signing_key_from_jwt(token).key
claims = jwt.decode(
token,
signing_key,
algorithms=["RS256"],
options={"verify_aud": False},
issuer=KEYCLOAK_ISSUER,
)
# Ensure this token was minted for our frontend client (defense-in-depth).
azp = claims.get("azp")
aud = claims.get("aud")
aud_list: list[str] = []
if isinstance(aud, str):
aud_list = [aud]
elif isinstance(aud, list):
aud_list = [a for a in aud if isinstance(a, str)]
if azp != KEYCLOAK_CLIENT_ID and KEYCLOAK_CLIENT_ID not in aud_list:
raise ValueError("token not issued for this client")
return claims
def require_auth(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
token = _extract_bearer_token()
if not token:
return jsonify({"error": "missing bearer token"}), 401
try:
claims = _verify_keycloak_token(token)
except Exception:
return jsonify({"error": "invalid token"}), 401
g.keycloak_claims = claims
g.keycloak_username = claims.get("preferred_username") or ""
g.keycloak_email = claims.get("email") or ""
g.keycloak_groups = _normalize_groups(claims.get("groups"))
return fn(*args, **kwargs)
return wrapper
@app.route("/api/auth/config", methods=["GET"])
def auth_config() -> Any:
if not KEYCLOAK_ENABLED:
return jsonify({"enabled": False})
issuer = KEYCLOAK_ISSUER
public_origin = request.host_url.rstrip("/")
redirect_uri = quote(f"{public_origin}/", safe="")
login_url = (
f"{issuer}/protocol/openid-connect/auth"
f"?client_id={quote(KEYCLOAK_CLIENT_ID, safe='')}"
f"&redirect_uri={redirect_uri}"
f"&response_type=code"
f"&scope=openid"
)
return jsonify(
{
"enabled": True,
"url": KEYCLOAK_URL,
"realm": KEYCLOAK_REALM,
"client_id": KEYCLOAK_CLIENT_ID,
"login_url": login_url,
"reset_url": login_url,
}
)
def _require_account_access() -> tuple[bool, Any]:
if not KEYCLOAK_ENABLED:
return False, (jsonify({"error": "keycloak not enabled"}), 503)
if not ACCOUNT_ALLOWED_GROUPS:
return True, None
groups = set(getattr(g, "keycloak_groups", []) or [])
if groups.intersection(ACCOUNT_ALLOWED_GROUPS):
return True, None
return False, (jsonify({"error": "forbidden"}), 403)
def _kc_admin_ready() -> bool:
return bool(KEYCLOAK_ADMIN_CLIENT_ID and KEYCLOAK_ADMIN_CLIENT_SECRET)
def _kc_admin_get_token() -> str:
if not _kc_admin_ready():
raise RuntimeError("keycloak admin client not configured")
now = time.time()
cached = _KEYCLOAK_ADMIN_TOKEN.get("token")
expires_at = float(_KEYCLOAK_ADMIN_TOKEN.get("expires_at") or 0.0)
if cached and now < expires_at - 30:
return str(cached)
token_url = f"{KEYCLOAK_ADMIN_URL}/realms/{KEYCLOAK_ADMIN_REALM}/protocol/openid-connect/token"
data = {
"grant_type": "client_credentials",
"client_id": KEYCLOAK_ADMIN_CLIENT_ID,
"client_secret": KEYCLOAK_ADMIN_CLIENT_SECRET,
}
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.post(token_url, data=data)
resp.raise_for_status()
payload = resp.json()
token = payload.get("access_token") or ""
if not token:
raise RuntimeError("no access_token in response")
expires_in = int(payload.get("expires_in") or 60)
_KEYCLOAK_ADMIN_TOKEN["token"] = token
_KEYCLOAK_ADMIN_TOKEN["expires_at"] = now + expires_in
return token
def _kc_admin_headers() -> dict[str, str]:
return {"Authorization": f"Bearer {_kc_admin_get_token()}"}
def _kc_admin_find_user(username: str) -> dict[str, Any] | None:
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users"
params = {"username": username, "exact": "true"}
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.get(url, params=params, headers=_kc_admin_headers())
resp.raise_for_status()
users = resp.json()
if not isinstance(users, list) or not users:
return None
# Keycloak returns a list even with exact=true; use first match.
user = users[0]
return user if isinstance(user, dict) else None
def _kc_admin_get_user(user_id: str) -> dict[str, Any]:
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}"
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.get(url, headers=_kc_admin_headers())
resp.raise_for_status()
data = resp.json()
if not isinstance(data, dict):
raise RuntimeError("unexpected user payload")
return data
def _kc_admin_update_user(user_id: str, payload: dict[str, Any]) -> None:
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}"
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.put(url, headers={**_kc_admin_headers(), "Content-Type": "application/json"}, json=payload)
resp.raise_for_status()
def _kc_set_user_attribute(username: str, key: str, value: str) -> None:
user = _kc_admin_find_user(username)
if not user:
raise RuntimeError("user not found")
user_id = user.get("id") or ""
if not user_id:
raise RuntimeError("user id missing")
full = _kc_admin_get_user(user_id)
attrs = full.get("attributes") or {}
if not isinstance(attrs, dict):
attrs = {}
attrs[key] = [value]
full["attributes"] = attrs
_kc_admin_update_user(user_id, full)
def _random_password(length: int = 32) -> str:
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def _best_effort_post(url: str) -> None:
if not url:
return
try:
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
client.post(url, json={"ts": int(time.time())})
except Exception:
return
@app.route("/api/account/overview", methods=["GET"])
@require_auth
def account_overview() -> Any:
ok, resp = _require_account_access()
if not ok:
return resp
username = g.keycloak_username
mailu_username = f"{username}@{MAILU_DOMAIN}" if username else ""
mailu_status = "ready"
jellyfin_status = "ready"
if not _kc_admin_ready():
mailu_status = "server not configured"
jellyfin_status = "server not configured"
return jsonify(
{
"user": {"username": username, "email": g.keycloak_email, "groups": g.keycloak_groups},
"mailu": {"status": mailu_status, "username": mailu_username},
"jellyfin": {"status": jellyfin_status, "username": username},
}
)
@app.route("/api/account/mailu/rotate", methods=["POST"])
@require_auth
def account_mailu_rotate() -> Any:
ok, resp = _require_account_access()
if not ok:
return resp
if not _kc_admin_ready():
return jsonify({"error": "server not configured"}), 503
username = g.keycloak_username
if not username:
return jsonify({"error": "missing username"}), 400
password = _random_password()
try:
_kc_set_user_attribute(username, "mailu_app_password", password)
except Exception:
return jsonify({"error": "failed to update mail password"}), 502
_best_effort_post(MAILU_SYNC_URL)
return jsonify({"password": password})
@app.route("/api/account/jellyfin/rotate", methods=["POST"])
@require_auth
def account_jellyfin_rotate() -> Any:
ok, resp = _require_account_access()
if not ok:
return resp
if not _kc_admin_ready():
return jsonify({"error": "server not configured"}), 503
username = g.keycloak_username
if not username:
return jsonify({"error": "missing username"}), 400
password = _random_password()
try:
_kc_set_user_attribute(username, "jellyfin_app_password", password)
except Exception:
return jsonify({"error": "failed to update jellyfin password"}), 502
_best_effort_post(JELLYFIN_SYNC_URL)
return jsonify({"password": password})
@app.route("/api/monero/get_info")
def monero_get_info() -> Any:
try:

View File

@ -2,3 +2,4 @@ flask==3.0.3
flask-cors==4.0.0
gunicorn==21.2.0
httpx==0.27.2
PyJWT[crypto]==2.10.1

View File

@ -1,14 +1,15 @@
{
"name": "bstein-portfolio",
"version": "0.1.0",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bstein-portfolio",
"version": "0.1.0",
"version": "0.1.1",
"dependencies": {
"axios": "^1.6.7",
"keycloak-js": "^26.2.2",
"mermaid": "^10.9.1",
"vue": "^3.4.21",
"vue-router": "^4.3.2"
@ -1905,6 +1906,15 @@
"node": ">= 12"
}
},
"node_modules/keycloak-js": {
"version": "26.2.2",
"resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.2.tgz",
"integrity": "sha512-ug7pNZ1xNkd7PPkerOJCEU2VnUhS7CYStDOCFJgqCNQ64h53ppxaKrh4iXH0xM8hFu5b1W6e6lsyYWqBMvaQFg==",
"license": "Apache-2.0",
"workspaces": [
"test"
]
},
"node_modules/khroma": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",

View File

@ -10,6 +10,7 @@
},
"dependencies": {
"axios": "^1.6.7",
"keycloak-js": "^26.2.2",
"mermaid": "^10.9.1",
"vue": "^3.4.21",
"vue-router": "^4.3.2"

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSO</title>
</head>
<body>
<script>
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>

106
frontend/src/auth.js Normal file
View File

@ -0,0 +1,106 @@
import Keycloak from "keycloak-js";
import { reactive } from "vue";
export const auth = reactive({
ready: false,
enabled: false,
authenticated: false,
username: "",
email: "",
groups: [],
loginUrl: "",
resetUrl: "",
token: "",
});
let keycloak = null;
let initPromise = null;
function normalizeGroups(groups) {
if (!Array.isArray(groups)) return [];
return groups
.filter((g) => typeof g === "string")
.map((g) => g.replace(/^\//, ""))
.filter(Boolean);
}
function updateFromToken() {
const parsed = keycloak?.tokenParsed || {};
auth.authenticated = Boolean(keycloak?.authenticated);
auth.token = keycloak?.token || "";
auth.username = parsed.preferred_username || "";
auth.email = parsed.email || "";
auth.groups = normalizeGroups(parsed.groups);
}
export async function initAuth() {
if (initPromise) return initPromise;
initPromise = (async () => {
try {
const resp = await fetch("/api/auth/config", { headers: { Accept: "application/json" } });
if (!resp.ok) throw new Error(`auth config ${resp.status}`);
const cfg = await resp.json();
auth.enabled = Boolean(cfg.enabled);
auth.loginUrl = cfg.login_url || "";
auth.resetUrl = cfg.reset_url || "";
if (!auth.enabled) return;
keycloak = new Keycloak({
url: cfg.url,
realm: cfg.realm,
clientId: cfg.client_id,
});
const authenticated = await keycloak.init({
onLoad: "check-sso",
pkceMethod: "S256",
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
checkLoginIframe: true,
});
auth.authenticated = authenticated;
updateFromToken();
keycloak.onAuthSuccess = () => updateFromToken();
keycloak.onAuthLogout = () => updateFromToken();
keycloak.onAuthRefreshSuccess = () => updateFromToken();
keycloak.onTokenExpired = () => {
keycloak
.updateToken(30)
.then(() => updateFromToken())
.catch(() => updateFromToken());
};
window.setInterval(() => {
if (!keycloak?.authenticated) return;
keycloak.updateToken(60).then(updateFromToken).catch(() => {});
}, 30_000);
} catch {
auth.enabled = false;
} finally {
auth.ready = true;
}
})();
return initPromise;
}
export async function login(redirectPath = window.location.pathname + window.location.search + window.location.hash) {
if (!keycloak) return;
const redirectUri = new URL(redirectPath, window.location.origin).toString();
await keycloak.login({ redirectUri });
}
export async function logout() {
if (!keycloak) return;
await keycloak.logout({ redirectUri: window.location.origin });
}
export async function authFetch(url, options = {}) {
const headers = new Headers(options.headers || {});
if (auth.token) headers.set("Authorization", `Bearer ${auth.token}`);
return fetch(url, { ...options, headers });
}

View File

@ -13,18 +13,32 @@
<RouterLink to="/" class="nav-link">Home</RouterLink>
<RouterLink to="/about" class="nav-link">About</RouterLink>
<a href="https://cloud.bstein.dev" class="nav-link strong" target="_blank" rel="noreferrer">Cloud</a>
<a href="https://sso.bstein.dev" class="nav-link" target="_blank" rel="noreferrer">Login</a>
<a href="https://sso.bstein.dev/realms/master/protocol/openid-connect/registrations" class="nav-link" target="_blank" rel="noreferrer">Sign Up</a>
<a href="https://sso.bstein.dev/realms/master/login-actions/reset-credentials" class="nav-link" target="_blank" rel="noreferrer">Reset</a>
<template v-if="auth.enabled">
<template v-if="auth.authenticated">
<RouterLink to="/apps" class="nav-link">Apps</RouterLink>
<RouterLink to="/account" class="nav-link">Account</RouterLink>
<button class="nav-link button" type="button" @click="doLogout">Logout</button>
</template>
<template v-else>
<button class="nav-link button" type="button" @click="doLogin">Login</button>
<RouterLink to="/request-access" class="nav-link">Request Access</RouterLink>
<a v-if="auth.resetUrl" :href="auth.resetUrl" class="nav-link" target="_blank" rel="noreferrer">Reset Password</a>
</template>
</template>
</nav>
</header>
</template>
<script setup>
import { useRouter, RouterLink } from "vue-router";
import { auth, login, logout } from "@/auth";
const router = useRouter();
const goAbout = () => router.push("/about");
const doLogin = () => login();
const doLogout = () => logout();
</script>
<style scoped>
@ -95,6 +109,11 @@ const goAbout = () => router.push("/about");
border: 1px solid transparent;
}
.button {
background: transparent;
cursor: pointer;
}
.nav-link.strong {
border-color: rgba(255, 255, 255, 0.14);
color: var(--accent-cyan);

View File

@ -3,7 +3,10 @@ import App from "./App.vue";
import router from "./router";
import "./assets/base.css";
import "./assets/theme.css";
import { initAuth } from "./auth";
const app = createApp(App);
app.use(router);
app.mount("#app");
initAuth();

View File

@ -4,6 +4,9 @@ import AboutView from "./views/AboutView.vue";
import AiView from "./views/AiView.vue";
import AiPlanView from "./views/AiPlanView.vue";
import MoneroView from "./views/MoneroView.vue";
import AppsView from "./views/AppsView.vue";
import AccountView from "./views/AccountView.vue";
import RequestAccessView from "./views/RequestAccessView.vue";
export default createRouter({
history: createWebHistory(),
@ -14,5 +17,8 @@ export default createRouter({
{ path: "/ai/chat", name: "ai-chat", component: AiView },
{ path: "/ai/roadmap", name: "ai-roadmap", component: AiPlanView },
{ path: "/monero", name: "monero", component: MoneroView },
{ path: "/apps", name: "apps", component: AppsView },
{ path: "/account", name: "account", component: AccountView },
{ path: "/request-access", name: "request-access", component: RequestAccessView },
],
});

View File

@ -0,0 +1,360 @@
<template>
<div class="page">
<section class="card hero glass">
<div>
<p class="eyebrow">Atlas</p>
<h1>Account</h1>
<p class="lede">
Self-service tools for services that don't support seamless Single Sign-On. This portal helps keep everything in
sync with your Keycloak identity.
</p>
</div>
<div class="hero-actions">
<div v-if="auth.authenticated" class="pill mono pill-ok">{{ auth.username }}</div>
<button v-else class="pill mono" type="button" @click="doLogin">Login</button>
</div>
</section>
<section v-if="auth.ready && auth.authenticated" class="grid two">
<div class="card module">
<div class="module-head">
<h2>Mail</h2>
<span class="pill mono">{{ mailu.status }}</span>
</div>
<p class="muted">
Use a dedicated app password for IMAP/SMTP clients (mobile mail, Thunderbird, Outlook). Rotate it any time.
</p>
<div class="kv">
<div class="row">
<span class="k mono">IMAP</span>
<span class="v mono">{{ mailu.imap }}</span>
</div>
<div class="row">
<span class="k mono">SMTP</span>
<span class="v mono">{{ mailu.smtp }}</span>
</div>
<div class="row">
<span class="k mono">Username</span>
<span class="v mono">{{ mailu.username }}</span>
</div>
</div>
<div class="actions">
<button class="primary" type="button" :disabled="mailu.rotating" @click="rotateMailu">
{{ mailu.rotating ? "Rotating..." : "Rotate mail app password" }}
</button>
</div>
<div v-if="mailu.newPassword" class="secret-box">
<div class="secret-head">
<div class="pill mono pill-warn">Show once</div>
<button class="copy mono" type="button" @click="copy(mailu.newPassword)">copy</button>
</div>
<div class="mono secret">{{ mailu.newPassword }}</div>
<div class="hint mono">Update your mail client password to match.</div>
</div>
<div v-if="mailu.error" class="error-box">
<div class="mono">{{ mailu.error }}</div>
</div>
</div>
<div class="card module">
<div class="module-head">
<h2>Jellyfin</h2>
<span class="pill mono">{{ jellyfin.status }}</span>
</div>
<p class="muted">
Jellyfin authentication is backed by LDAP. If your login ever fails, rotate an app password and re-sync.
</p>
<div class="kv">
<div class="row">
<span class="k mono">URL</span>
<a class="v mono link" href="https://stream.bstein.dev" target="_blank" rel="noreferrer">stream.bstein.dev</a>
</div>
<div class="row">
<span class="k mono">Username</span>
<span class="v mono">{{ jellyfin.username }}</span>
</div>
</div>
<div class="actions">
<button class="primary" type="button" :disabled="jellyfin.rotating" @click="rotateJellyfin">
{{ jellyfin.rotating ? "Rotating..." : "Rotate Jellyfin app password" }}
</button>
</div>
<div v-if="jellyfin.newPassword" class="secret-box">
<div class="secret-head">
<div class="pill mono pill-warn">Show once</div>
<button class="copy mono" type="button" @click="copy(jellyfin.newPassword)">copy</button>
</div>
<div class="mono secret">{{ jellyfin.newPassword }}</div>
<div class="hint mono">Use your Keycloak username and this password on Jellyfin.</div>
</div>
<div v-if="jellyfin.error" class="error-box">
<div class="mono">{{ jellyfin.error }}</div>
</div>
</div>
</section>
<section v-else class="card module">
<h2>Login required</h2>
<p class="muted">Log in to manage app passwords and view account status.</p>
<div class="actions">
<button class="primary" type="button" @click="doLogin">Login</button>
</div>
</section>
</div>
</template>
<script setup>
import { onMounted, reactive } from "vue";
import { auth, authFetch, login } from "@/auth";
const mailu = reactive({
status: "loading",
imap: "mail.bstein.dev:993 (TLS)",
smtp: "mail.bstein.dev:587 (STARTTLS)",
username: "",
rotating: false,
newPassword: "",
error: "",
});
const jellyfin = reactive({
status: "loading",
username: "",
rotating: false,
newPassword: "",
error: "",
});
const doLogin = () => login("/account");
onMounted(async () => {
if (!auth.authenticated) {
mailu.status = "login required";
jellyfin.status = "login required";
return;
}
await refreshOverview();
});
async function refreshOverview() {
try {
const resp = await authFetch("/api/account/overview", { headers: { Accept: "application/json" } });
if (!resp.ok) throw new Error(`status ${resp.status}`);
const data = await resp.json();
mailu.status = data.mailu?.status || "ready";
mailu.username = data.mailu?.username || auth.username;
jellyfin.status = data.jellyfin?.status || "ready";
jellyfin.username = data.jellyfin?.username || auth.username;
} catch (err) {
mailu.status = "unavailable";
jellyfin.status = "unavailable";
mailu.error = "Failed to load account status.";
jellyfin.error = "Failed to load account status.";
}
}
async function rotateMailu() {
mailu.error = "";
mailu.newPassword = "";
mailu.rotating = true;
try {
const resp = await authFetch("/api/account/mailu/rotate", { method: "POST" });
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || `status ${resp.status}`);
mailu.newPassword = data.password || "";
mailu.status = "updated";
} catch (err) {
mailu.error = err.message || "Rotation failed";
} finally {
mailu.rotating = false;
}
}
async function rotateJellyfin() {
jellyfin.error = "";
jellyfin.newPassword = "";
jellyfin.rotating = true;
try {
const resp = await authFetch("/api/account/jellyfin/rotate", { method: "POST" });
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || `status ${resp.status}`);
jellyfin.newPassword = data.password || "";
jellyfin.status = "updated";
} catch (err) {
jellyfin.error = err.message || "Rotation failed";
} finally {
jellyfin.rotating = false;
}
}
async function copy(text) {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
} catch {
// ignore
}
}
</script>
<style scoped>
.page {
max-width: 1200px;
margin: 0 auto;
padding: 32px 22px 72px;
}
.hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 12px;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin: 0 0 6px;
font-size: 13px;
}
h1 {
margin: 0 0 6px;
font-size: 32px;
}
.lede {
margin: 0;
color: var(--text-muted);
max-width: 640px;
}
.grid.two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 12px;
}
.module {
padding: 18px;
}
.module-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.muted {
color: var(--text-muted);
margin: 10px 0 0;
}
.kv {
margin-top: 12px;
border: 1px solid var(--card-border);
border-radius: 12px;
overflow: hidden;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.02);
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.row:first-child {
border-top: none;
}
.k {
color: var(--text-muted);
}
.v {
color: var(--text-strong);
}
.link {
color: var(--accent-cyan);
text-decoration: none;
}
.actions {
margin-top: 12px;
display: flex;
gap: 10px;
}
button.primary {
background: linear-gradient(90deg, #4f8bff, #7dd0ff);
color: #0b1222;
padding: 10px 14px;
border: none;
border-radius: 10px;
cursor: pointer;
font-weight: 700;
}
.secret-box {
margin-top: 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.03);
padding: 12px;
}
.secret-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.secret {
word-break: break-word;
color: var(--text-strong);
}
.hint {
margin-top: 6px;
color: var(--text-muted);
font-size: 12px;
}
.copy {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.14);
color: var(--text-primary);
border-radius: 10px;
padding: 6px 10px;
cursor: pointer;
}
.error-box {
margin-top: 12px;
border-radius: 12px;
border: 1px solid rgba(255, 87, 87, 0.5);
background: rgba(255, 87, 87, 0.06);
padding: 10px 12px;
}
@media (max-width: 820px) {
.grid.two {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,76 @@
<template>
<div class="page">
<section class="card hero glass">
<div>
<p class="eyebrow">Atlas</p>
<h1>Apps</h1>
<p class="lede">Quick links to the lab services. Some apps open in a new tab for security reasons.</p>
</div>
<div class="hero-actions">
<a class="pill mono" href="https://cloud.bstein.dev" target="_blank" rel="noreferrer">Open Nextcloud</a>
<a class="pill mono" href="https://sso.bstein.dev" target="_blank" rel="noreferrer">Open Keycloak</a>
</div>
</section>
<section class="card">
<div class="section-head">
<h2>Service Grid</h2>
<span class="pill mono">apps + pipelines + observability</span>
</div>
<ServiceGrid :services="displayServices" />
</section>
</div>
</template>
<script setup>
import { computed } from "vue";
import ServiceGrid from "../components/ServiceGrid.vue";
import { fallbackServices } from "../data/sample.js";
const props = defineProps({
serviceData: Object,
});
const displayServices = computed(() => props.serviceData?.services || fallbackServices().services);
</script>
<style scoped>
.page {
max-width: 1200px;
margin: 0 auto;
padding: 32px 22px 72px;
}
.hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 12px;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin: 0 0 6px;
font-size: 13px;
}
h1 {
margin: 0 0 6px;
font-size: 32px;
}
.lede {
margin: 0;
color: var(--text-muted);
max-width: 640px;
}
.hero-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
</style>

View File

@ -0,0 +1,68 @@
<template>
<div class="page">
<section class="card hero glass">
<div>
<p class="eyebrow">Atlas</p>
<h1>Request Access</h1>
<p class="lede">Self-serve signups are not enabled yet. Request access and an admin can approve your account.</p>
</div>
</section>
<section class="card module">
<h2>Not live yet</h2>
<p class="muted">
This flow is being wired into Keycloak approvals. For now, email <span class="mono">brad@bstein.dev</span> with the
username you want and a short note about what you need access to.
</p>
</section>
</div>
</template>
<style scoped>
.page {
max-width: 960px;
margin: 0 auto;
padding: 32px 22px 72px;
}
.hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 12px;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin: 0 0 6px;
font-size: 13px;
}
h1 {
margin: 0 0 6px;
font-size: 32px;
}
.lede {
margin: 0;
color: var(--text-muted);
max-width: 640px;
}
.module {
padding: 18px;
}
.muted {
color: var(--text-muted);
margin: 10px 0 0;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
</style>