portal: add Keycloak-backed account portal
This commit is contained in:
parent
b980eda249
commit
d53a63021d
328
backend/app.py
328
backend/app.py
@ -3,15 +3,21 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.error import URLError
|
from urllib.error import URLError
|
||||||
|
from urllib.parse import quote
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from urllib.request import urlopen
|
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
|
from flask_cors import CORS
|
||||||
import httpx
|
import httpx
|
||||||
|
import jwt
|
||||||
|
from jwt import PyJWKClient
|
||||||
|
|
||||||
|
|
||||||
app = Flask(__name__, static_folder="../frontend/dist", static_url_path="")
|
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_INTERVAL_SEC = float(os.getenv("AI_WARM_INTERVAL_SEC", "300"))
|
||||||
AI_WARM_ENABLED = os.getenv("AI_WARM_ENABLED", "true").lower() in ("1", "true", "yes")
|
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}
|
_LAB_STATUS_CACHE: dict[str, Any] = {"ts": 0.0, "value": None}
|
||||||
|
|
||||||
@app.route("/api/healthz")
|
@app.route("/api/healthz")
|
||||||
@ -51,6 +86,297 @@ def healthz() -> Any:
|
|||||||
return jsonify({"ok": True})
|
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")
|
@app.route("/api/monero/get_info")
|
||||||
def monero_get_info() -> Any:
|
def monero_get_info() -> Any:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -2,3 +2,4 @@ flask==3.0.3
|
|||||||
flask-cors==4.0.0
|
flask-cors==4.0.0
|
||||||
gunicorn==21.2.0
|
gunicorn==21.2.0
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
|
PyJWT[crypto]==2.10.1
|
||||||
|
|||||||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "bstein-portfolio",
|
"name": "bstein-portfolio",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bstein-portfolio",
|
"name": "bstein-portfolio",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
"keycloak-js": "^26.2.2",
|
||||||
"mermaid": "^10.9.1",
|
"mermaid": "^10.9.1",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-router": "^4.3.2"
|
"vue-router": "^4.3.2"
|
||||||
@ -1905,6 +1906,15 @@
|
|||||||
"node": ">= 12"
|
"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": {
|
"node_modules/khroma": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
"keycloak-js": "^26.2.2",
|
||||||
"mermaid": "^10.9.1",
|
"mermaid": "^10.9.1",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-router": "^4.3.2"
|
"vue-router": "^4.3.2"
|
||||||
|
|||||||
13
frontend/public/silent-check-sso.html
Normal file
13
frontend/public/silent-check-sso.html
Normal 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
106
frontend/src/auth.js
Normal 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 });
|
||||||
|
}
|
||||||
|
|
||||||
@ -13,18 +13,32 @@
|
|||||||
<RouterLink to="/" class="nav-link">Home</RouterLink>
|
<RouterLink to="/" class="nav-link">Home</RouterLink>
|
||||||
<RouterLink to="/about" class="nav-link">About</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://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>
|
<template v-if="auth.enabled">
|
||||||
<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.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>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useRouter, RouterLink } from "vue-router";
|
import { useRouter, RouterLink } from "vue-router";
|
||||||
|
import { auth, login, logout } from "@/auth";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const goAbout = () => router.push("/about");
|
const goAbout = () => router.push("/about");
|
||||||
|
|
||||||
|
const doLogin = () => login();
|
||||||
|
const doLogout = () => logout();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -95,6 +109,11 @@ const goAbout = () => router.push("/about");
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-link.strong {
|
.nav-link.strong {
|
||||||
border-color: rgba(255, 255, 255, 0.14);
|
border-color: rgba(255, 255, 255, 0.14);
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
|
|||||||
@ -3,7 +3,10 @@ import App from "./App.vue";
|
|||||||
import router from "./router";
|
import router from "./router";
|
||||||
import "./assets/base.css";
|
import "./assets/base.css";
|
||||||
import "./assets/theme.css";
|
import "./assets/theme.css";
|
||||||
|
import { initAuth } from "./auth";
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
|
|
||||||
|
initAuth();
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import AboutView from "./views/AboutView.vue";
|
|||||||
import AiView from "./views/AiView.vue";
|
import AiView from "./views/AiView.vue";
|
||||||
import AiPlanView from "./views/AiPlanView.vue";
|
import AiPlanView from "./views/AiPlanView.vue";
|
||||||
import MoneroView from "./views/MoneroView.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({
|
export default createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@ -14,5 +17,8 @@ export default createRouter({
|
|||||||
{ path: "/ai/chat", name: "ai-chat", component: AiView },
|
{ path: "/ai/chat", name: "ai-chat", component: AiView },
|
||||||
{ path: "/ai/roadmap", name: "ai-roadmap", component: AiPlanView },
|
{ path: "/ai/roadmap", name: "ai-roadmap", component: AiPlanView },
|
||||||
{ path: "/monero", name: "monero", component: MoneroView },
|
{ 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 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
360
frontend/src/views/AccountView.vue
Normal file
360
frontend/src/views/AccountView.vue
Normal 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>
|
||||||
76
frontend/src/views/AppsView.vue
Normal file
76
frontend/src/views/AppsView.vue
Normal 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>
|
||||||
68
frontend/src/views/RequestAccessView.vue
Normal file
68
frontend/src/views/RequestAccessView.vue
Normal 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>
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user