portal: integrate ariadne onboarding flow

This commit is contained in:
Brad Stein 2026-01-21 16:57:40 -03:00
parent fbed11aeed
commit 9735cfed6a
6 changed files with 562 additions and 219 deletions

View File

@ -251,6 +251,31 @@ class KeycloakAdminClient:
return gid return gid
return None return None
def list_group_names(self) -> list[str]:
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/groups"
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.get(url, headers=self._headers())
resp.raise_for_status()
items = resp.json()
if not isinstance(items, list):
return []
names: set[str] = set()
def walk(groups: list[Any]) -> None:
for group in groups:
if not isinstance(group, dict):
continue
name = group.get("name")
if isinstance(name, str) and name:
names.add(name)
sub = group.get("subGroups")
if isinstance(sub, list) and sub:
walk(sub)
walk(items)
return sorted(names)
def add_user_to_group(self, user_id: str, group_id: str) -> None: def add_user_to_group(self, user_id: str, group_id: str) -> None:
url = ( url = (
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}" f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"

View File

@ -30,6 +30,16 @@ def _extract_request_payload() -> tuple[str, str, str]:
return username, email, note return username, email, note
def _validate_username(username: str) -> str | None:
if not username:
return "username is required"
if len(username) < 3 or len(username) > 32:
return "username must be 3-32 characters"
if not re.fullmatch(r"[a-zA-Z0-9._-]+", username):
return "username contains invalid characters"
return None
def _random_request_code(username: str) -> str: def _random_request_code(username: str) -> str:
suffix = "".join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(10)) suffix = "".join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(10))
return f"{username}~{suffix}" return f"{username}~{suffix}"
@ -59,23 +69,36 @@ def _verify_url(request_code: str, token: str) -> str:
ONBOARDING_STEPS: tuple[str, ...] = ( ONBOARDING_STEPS: tuple[str, ...] = (
"vaultwarden_master_password", "vaultwarden_master_password",
"keycloak_password_rotated",
"element_recovery_key",
"element_recovery_key_stored",
"firefly_login",
"health_data_notice",
"wger_login",
"vaultwarden_browser_extension", "vaultwarden_browser_extension",
"vaultwarden_desktop_app", "vaultwarden_desktop_app",
"vaultwarden_mobile_app", "vaultwarden_mobile_app",
"health_data_notice",
"wger_login",
"actual_login",
"firefly_login",
"keycloak_password_rotated",
"keycloak_mfa_optional",
"element_recovery_key",
"element_recovery_key_stored",
"elementx_setup", "elementx_setup",
"jellyfin_login", "jellyfin_login",
"mail_client_setup", "mail_client_setup",
"actual_login",
"outline_login",
"planka_login",
"keycloak_mfa_optional",
) )
ONBOARDING_OPTIONAL_STEPS: set[str] = {"keycloak_mfa_optional"} ONBOARDING_OPTIONAL_STEPS: set[str] = {
"vaultwarden_browser_extension",
"vaultwarden_desktop_app",
"vaultwarden_mobile_app",
"elementx_setup",
"jellyfin_login",
"mail_client_setup",
"actual_login",
"outline_login",
"planka_login",
"keycloak_mfa_optional",
}
ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = tuple( ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = tuple(
step for step in ONBOARDING_STEPS if step not in ONBOARDING_OPTIONAL_STEPS step for step in ONBOARDING_STEPS if step not in ONBOARDING_OPTIONAL_STEPS
) )
@ -289,6 +312,48 @@ def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any
def register(app) -> None: def register(app) -> None:
@app.route("/api/access/request/availability", methods=["GET"])
def request_access_availability() -> Any:
if not settings.ACCESS_REQUEST_ENABLED:
return jsonify({"error": "request access disabled"}), 503
if not configured():
return jsonify({"error": "server not configured"}), 503
username = (request.args.get("username") or "").strip()
error = _validate_username(username)
if error:
return jsonify({"available": False, "reason": "invalid", "detail": error})
if admin_client().ready() and admin_client().find_user(username):
return jsonify({"available": False, "reason": "exists", "detail": "username already exists"})
try:
with connect() as conn:
existing = conn.execute(
"""
SELECT status
FROM access_requests
WHERE username = %s
ORDER BY created_at DESC
LIMIT 1
""",
(username,),
).fetchone()
except Exception:
return jsonify({"error": "failed to check availability"}), 502
if existing:
status = str(existing.get("status") or "")
return jsonify(
{
"available": False,
"reason": "requested",
"status": _normalize_status(status),
}
)
return jsonify({"available": True})
@app.route("/api/access/request", methods=["POST"]) @app.route("/api/access/request", methods=["POST"])
def request_access() -> Any: def request_access() -> Any:
if not settings.ACCESS_REQUEST_ENABLED: if not settings.ACCESS_REQUEST_ENABLED:
@ -310,13 +375,9 @@ def register(app) -> None:
): ):
return jsonify({"error": "rate limited"}), 429 return jsonify({"error": "rate limited"}), 429
if not username: username_error = _validate_username(username)
return jsonify({"error": "username is required"}), 400 if username_error:
return jsonify({"error": username_error}), 400
if len(username) < 3 or len(username) > 32:
return jsonify({"error": "username must be 3-32 characters"}), 400
if not re.fullmatch(r"[a-zA-Z0-9._-]+", username):
return jsonify({"error": "username contains invalid characters"}), 400
if not email: if not email:
return jsonify({"error": "email is required"}), 400 return jsonify({"error": "email is required"}), 400
if "@" not in email: if "@" not in email:

View File

@ -5,9 +5,9 @@ from urllib.parse import quote
from flask import jsonify, g, request from flask import jsonify, g, request
from .. import ariadne_client from .. import ariadne_client, settings
from ..db import connect, configured from ..db import connect, configured
from ..keycloak import require_auth, require_portal_admin from ..keycloak import admin_client, require_auth, require_portal_admin
from ..provisioning import provision_access_request from ..provisioning import provision_access_request
@ -51,6 +51,24 @@ def register(app) -> None:
) )
return jsonify({"requests": output}) return jsonify({"requests": output})
@app.route("/api/admin/access/flags", methods=["GET"])
@require_auth
def admin_list_flags() -> Any:
ok, resp = require_portal_admin()
if not ok:
return resp
if ariadne_client.enabled():
return ariadne_client.proxy("GET", "/api/admin/access/flags")
if not admin_client().ready():
return jsonify({"error": "keycloak admin unavailable"}), 503
try:
groups = admin_client().list_group_names()
except Exception:
return jsonify({"error": "failed to list flags"}), 502
excluded = set(settings.PORTAL_ADMIN_GROUPS)
flags = sorted([name for name in groups if name not in excluded])
return jsonify({"flags": flags})
@app.route("/api/admin/access/requests/<username>/approve", methods=["POST"]) @app.route("/api/admin/access/requests/<username>/approve", methods=["POST"])
@require_auth @require_auth
def admin_approve_request(username: str) -> Any: def admin_approve_request(username: str) -> Any:

View File

@ -356,12 +356,36 @@
<span>User</span> <span>User</span>
<span>Email</span> <span>Email</span>
<span>Note</span> <span>Note</span>
<span>Flags</span>
<span>Decision note</span>
<span></span> <span></span>
</div> </div>
<div v-for="req in admin.requests" :key="req.username" class="req-row"> <div v-for="req in admin.requests" :key="req.username" class="req-row">
<div class="mono">{{ req.username }}</div> <div class="mono">{{ req.username }}</div>
<div class="mono">{{ req.email }}</div> <div class="mono">{{ req.email }}</div>
<div class="note">{{ req.note }}</div> <div class="note">{{ req.note }}</div>
<div class="req-flags">
<div v-if="admin.flagsLoading" class="muted">loading flags...</div>
<label v-for="flag in admin.flags" :key="flag" class="flag-pill">
<input
type="checkbox"
:checked="hasFlag(req.username, flag)"
:disabled="admin.acting[req.username]"
@change="toggleFlag(req.username, flag, $event)"
/>
<span class="mono">{{ flag }}</span>
</label>
<div v-if="!admin.flagsLoading && !admin.flags.length" class="muted">no flags</div>
</div>
<div class="req-note">
<input
v-model="admin.notes[req.username]"
class="input mono"
type="text"
placeholder="Optional note"
:disabled="admin.acting[req.username]"
/>
</div>
<div class="req-actions"> <div class="req-actions">
<button class="primary" type="button" :disabled="admin.acting[req.username]" @click="approve(req.username)"> <button class="primary" type="button" :disabled="admin.acting[req.username]" @click="approve(req.username)">
approve approve
@ -451,17 +475,23 @@ const admin = reactive({
requests: [], requests: [],
error: "", error: "",
acting: {}, acting: {},
flags: [],
flagsLoading: false,
notes: {},
selectedFlags: {},
}); });
const onboardingUrl = ref("/onboarding"); const onboardingUrl = ref("/onboarding");
const doLogin = () => login("/account"); const doLogin = () => login("/account");
const copied = reactive({}); const copied = reactive({});
const isPortalAdmin = () => Array.isArray(auth.groups) && auth.groups.includes("admin");
onMounted(() => { onMounted(() => {
if (auth.ready && auth.authenticated) { if (auth.ready && auth.authenticated) {
refreshOverview(); refreshOverview();
refreshAdminRequests(); refreshAdminRequests();
refreshAdminFlags();
} else { } else {
mailu.status = "login required"; mailu.status = "login required";
nextcloudMail.status = "login required"; nextcloudMail.status = "login required";
@ -486,10 +516,12 @@ watch(
onboardingUrl.value = "/onboarding"; onboardingUrl.value = "/onboarding";
admin.enabled = false; admin.enabled = false;
admin.requests = []; admin.requests = [];
admin.flags = [];
return; return;
} }
refreshOverview(); refreshOverview();
refreshAdminRequests(); refreshAdminRequests();
refreshAdminFlags();
}, },
{ immediate: false }, { immediate: false },
); );
@ -519,15 +551,15 @@ async function refreshOverview() {
nextcloudMail.accountCount = data.nextcloud_mail?.account_count || ""; nextcloudMail.accountCount = data.nextcloud_mail?.account_count || "";
nextcloudMail.syncedAt = data.nextcloud_mail?.synced_at || ""; nextcloudMail.syncedAt = data.nextcloud_mail?.synced_at || "";
wger.status = data.wger?.status || "unknown"; wger.status = data.wger?.status || "unknown";
wger.username = data.wger?.username || auth.username; wger.username = data.wger?.username || mailu.username || auth.username;
wger.password = data.wger?.password || ""; wger.password = data.wger?.password || "";
wger.passwordUpdatedAt = data.wger?.password_updated_at || ""; wger.passwordUpdatedAt = data.wger?.password_updated_at || "";
firefly.status = data.firefly?.status || "unknown"; firefly.status = data.firefly?.status || "unknown";
firefly.username = data.firefly?.username || auth.email || auth.username; firefly.username = data.firefly?.username || mailu.username || auth.username;
firefly.password = data.firefly?.password || ""; firefly.password = data.firefly?.password || "";
firefly.passwordUpdatedAt = data.firefly?.password_updated_at || ""; firefly.passwordUpdatedAt = data.firefly?.password_updated_at || "";
vaultwarden.status = data.vaultwarden?.status || "unknown"; vaultwarden.status = data.vaultwarden?.status || "unknown";
vaultwarden.username = data.vaultwarden?.username || auth.email || auth.username; vaultwarden.username = data.vaultwarden?.username || mailu.username || auth.username;
vaultwarden.syncedAt = data.vaultwarden?.synced_at || ""; vaultwarden.syncedAt = data.vaultwarden?.synced_at || "";
jellyfin.status = data.jellyfin?.status || "ready"; jellyfin.status = data.jellyfin?.status || "ready";
jellyfin.username = data.jellyfin?.username || auth.username; jellyfin.username = data.jellyfin?.username || auth.username;
@ -555,6 +587,11 @@ async function refreshOverview() {
} }
async function refreshAdminRequests() { async function refreshAdminRequests() {
if (!isPortalAdmin()) {
admin.enabled = false;
admin.requests = [];
return;
}
admin.error = ""; admin.error = "";
admin.loading = true; admin.loading = true;
try { try {
@ -571,6 +608,11 @@ async function refreshAdminRequests() {
const data = await resp.json(); const data = await resp.json();
admin.enabled = true; admin.enabled = true;
admin.requests = Array.isArray(data.requests) ? data.requests : []; admin.requests = Array.isArray(data.requests) ? data.requests : [];
for (const req of admin.requests) {
if (!req?.username) continue;
if (!(req.username in admin.notes)) admin.notes[req.username] = "";
if (!(req.username in admin.selectedFlags)) admin.selectedFlags[req.username] = [];
}
} catch (err) { } catch (err) {
admin.enabled = false; admin.enabled = false;
admin.requests = []; admin.requests = [];
@ -580,6 +622,45 @@ async function refreshAdminRequests() {
} }
} }
async function refreshAdminFlags() {
if (!isPortalAdmin()) {
admin.flags = [];
admin.flagsLoading = false;
return;
}
admin.flagsLoading = true;
try {
const resp = await authFetch("/api/admin/access/flags", {
headers: { Accept: "application/json" },
cache: "no-store",
});
if (resp.status === 403) {
admin.flags = [];
return;
}
if (!resp.ok) throw new Error(`status ${resp.status}`);
const data = await resp.json();
admin.flags = Array.isArray(data.flags) ? data.flags : [];
} catch (err) {
admin.flags = [];
admin.error = admin.error || err.message || "Failed to load access flags.";
} finally {
admin.flagsLoading = false;
}
}
function hasFlag(username, flag) {
const selected = admin.selectedFlags[username];
return Array.isArray(selected) && selected.includes(flag);
}
function toggleFlag(username, flag, event) {
const checked = Boolean(event?.target?.checked);
const selected = Array.isArray(admin.selectedFlags[username]) ? [...admin.selectedFlags[username]] : [];
const next = checked ? Array.from(new Set([...selected, flag])) : selected.filter((item) => item !== flag);
admin.selectedFlags[username] = next;
}
async function rotateMailu() { async function rotateMailu() {
mailu.error = ""; mailu.error = "";
mailu.newPassword = ""; mailu.newPassword = "";
@ -688,7 +769,16 @@ async function approve(username) {
admin.error = ""; admin.error = "";
admin.acting[username] = true; admin.acting[username] = true;
try { try {
const resp = await authFetch(`/api/admin/access/requests/${encodeURIComponent(username)}/approve`, { method: "POST" }); const flags = Array.isArray(admin.selectedFlags[username]) ? admin.selectedFlags[username] : [];
const note = (admin.notes[username] || "").trim();
const payload = {};
if (flags.length) payload.flags = flags;
if (note) payload.note = note;
const resp = await authFetch(`/api/admin/access/requests/${encodeURIComponent(username)}/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) { if (!resp.ok) {
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `status ${resp.status}`); throw new Error(data.error || `status ${resp.status}`);
@ -705,7 +795,13 @@ async function deny(username) {
admin.error = ""; admin.error = "";
admin.acting[username] = true; admin.acting[username] = true;
try { try {
const resp = await authFetch(`/api/admin/access/requests/${encodeURIComponent(username)}/deny`, { method: "POST" }); const note = (admin.notes[username] || "").trim();
const payload = note ? { note } : {};
const resp = await authFetch(`/api/admin/access/requests/${encodeURIComponent(username)}/deny`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) { if (!resp.ok) {
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `status ${resp.status}`); throw new Error(data.error || `status ${resp.status}`);
@ -973,7 +1069,7 @@ button.primary {
.req-head { .req-head {
display: grid; display: grid;
grid-template-columns: 160px 220px 1fr 140px; grid-template-columns: 140px 220px 1fr 220px 200px 140px;
gap: 12px; gap: 12px;
color: var(--text-muted); color: var(--text-muted);
font-size: 12px; font-size: 12px;
@ -983,7 +1079,7 @@ button.primary {
.req-row { .req-row {
display: grid; display: grid;
grid-template-columns: 160px 220px 1fr 140px; grid-template-columns: 140px 220px 1fr 220px 200px 140px;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
@ -1005,6 +1101,37 @@ button.primary {
gap: 8px; gap: 8px;
} }
.req-flags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.flag-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.2);
font-size: 12px;
}
.flag-pill input {
width: 14px;
height: 14px;
}
.req-note .input {
width: 100%;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.22);
color: var(--text-primary);
padding: 8px 10px;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.req-head { .req-head {
display: none; display: none;

View File

@ -90,9 +90,9 @@
<div v-if="initialPassword" class="initial-password"> <div v-if="initialPassword" class="initial-password">
<h3>Temporary password</h3> <h3>Temporary password</h3>
<p class="muted"> <p class="muted">
Use this password to log in for the first time. You won't be forced to change it immediately — you'll rotate Use this password to log in for the first time (Nextcloud, Element). You won't be forced to change it
it later after Vaultwarden is set up. This password is shown once copy it now. If you refresh this page, immediately you'll rotate it after Vaultwarden is set up. This password is shown once copy it now. If you
it may disappear. refresh this page, it may disappear.
</p> </p>
<div class="request-code-row"> <div class="request-code-row">
<span class="label mono">Password</span> <span class="label mono">Password</span>
@ -130,11 +130,9 @@
</label> </label>
<p class="muted"> <p class="muted">
Open <a href="https://vault.bstein.dev" target="_blank" rel="noreferrer">Passwords</a> and set a strong master Open <a href="https://vault.bstein.dev" target="_blank" rel="noreferrer">Passwords</a> and set a strong master
password you won't forget. password you won't forget. Your master password is the one password to rule all passwords: use a long passphrase
Your master password is the one password to rule all passwords: use a long passphrase (64+ characters is a good target), and never (64+ characters is a good target), and never write it down or share it with anyone. If you lose it, Atlas can't
write it down or share it with anyone. recover your vault. If you can't sign in yet, check your Atlas mailbox in
If you lose it, Atlas can't recover your vault.
If you can't sign in yet, check your Atlas mailbox in
<a href="https://cloud.bstein.dev" target="_blank" rel="noreferrer">Nextcloud Mail</a> for the invite link. <a href="https://cloud.bstein.dev" target="_blank" rel="noreferrer">Nextcloud Mail</a> for the invite link.
</p> </p>
<details class="howto"> <details class="howto">
@ -147,146 +145,10 @@
</details> </details>
</li> </li>
<li class="check-item" :class="checkItemClass('vaultwarden_browser_extension')">
<label>
<input
type="checkbox"
:checked="isStepDone('vaultwarden_browser_extension')"
:disabled="!auth.authenticated || loading || isStepBlocked('vaultwarden_browser_extension')"
@change="toggleStep('vaultwarden_browser_extension', $event)"
/>
<span>Install the Vaultwarden browser extension</span>
<span class="pill mono auto-pill" :class="stepPillClass('vaultwarden_browser_extension')">
{{ stepPillLabel("vaultwarden_browser_extension") }}
</span>
</label>
<p class="muted">
Install Bitwarden in your browser and point it at vault.bstein.dev (Settings Account Environment Self-hosted).
<a href="https://bitwarden.com/download" target="_blank" rel="noreferrer">Bitwarden downloads</a>.
</p>
</li>
<li class="check-item" :class="checkItemClass('vaultwarden_desktop_app')">
<label>
<input
type="checkbox"
:checked="isStepDone('vaultwarden_desktop_app')"
:disabled="!auth.authenticated || loading || isStepBlocked('vaultwarden_desktop_app')"
@change="toggleStep('vaultwarden_desktop_app', $event)"
/>
<span>Install the Vaultwarden desktop app</span>
<span class="pill mono auto-pill" :class="stepPillClass('vaultwarden_desktop_app')">
{{ stepPillLabel("vaultwarden_desktop_app") }}
</span>
</label>
<p class="muted">
Install the Bitwarden desktop app and set the server to vault.bstein.dev (Settings Account Environment Self-hosted).
<a href="https://bitwarden.com/download" target="_blank" rel="noreferrer">Bitwarden downloads</a>.
</p>
</li>
<li class="check-item" :class="checkItemClass('vaultwarden_mobile_app')">
<label>
<input
type="checkbox"
:checked="isStepDone('vaultwarden_mobile_app')"
:disabled="!auth.authenticated || loading || isStepBlocked('vaultwarden_mobile_app')"
@change="toggleStep('vaultwarden_mobile_app', $event)"
/>
<span>Install Bitwarden on your phone</span>
<span class="pill mono auto-pill" :class="stepPillClass('vaultwarden_mobile_app')">
{{ stepPillLabel("vaultwarden_mobile_app") }}
</span>
</label>
<p class="muted">
Install the mobile app, set the server to vault.bstein.dev, and enable biometrics for fast unlock.
<a href="https://bitwarden.com/download" target="_blank" rel="noreferrer">Bitwarden downloads</a>.
</p>
</li>
<li class="check-item" :class="checkItemClass('health_data_notice')">
<label>
<input
type="checkbox"
:checked="isStepDone('health_data_notice')"
:disabled="!auth.authenticated || loading || isStepBlocked('health_data_notice')"
@change="toggleStep('health_data_notice', $event)"
/>
<span>Review the Wger health data notice</span>
<span class="pill mono auto-pill" :class="stepPillClass('health_data_notice')">
{{ stepPillLabel("health_data_notice") }}
</span>
</label>
<p class="muted">
Wger is a personal wellness tool, not medical advice. Use it at your own risk. Your health data belongs to
you and will never be sold or used beyond providing the service. We apply best practices to protect it,
but no system is risk-free.
</p>
</li>
<li class="check-item" :class="checkItemClass('wger_login')">
<label>
<input
type="checkbox"
:checked="isStepDone('wger_login')"
:disabled="!auth.authenticated || loading || isStepBlocked('wger_login')"
@change="toggleStep('wger_login', $event)"
/>
<span>Sign in to Wger</span>
<span class="pill mono auto-pill" :class="stepPillClass('wger_login')">
{{ stepPillLabel("wger_login") }}
</span>
</label>
<p class="muted">
Open <a href="https://health.bstein.dev" target="_blank" rel="noreferrer">health.bstein.dev</a> and sign in
with the credentials shown on your <a href="/account">Account</a> page. In the mobile app, set the server
URL to health.bstein.dev and log in once.
</p>
</li>
<li class="check-item" :class="checkItemClass('actual_login')">
<label>
<input
type="checkbox"
:checked="isStepDone('actual_login')"
:disabled="!auth.authenticated || loading || isStepBlocked('actual_login')"
@change="toggleStep('actual_login', $event)"
/>
<span>Sign in to Actual Budget</span>
<span class="pill mono auto-pill" :class="stepPillClass('actual_login')">
{{ stepPillLabel("actual_login") }}
</span>
</label>
<p class="muted">
Open <a href="https://budget.bstein.dev" target="_blank" rel="noreferrer">budget.bstein.dev</a> and sign in
with your Keycloak account.
</p>
</li>
<li class="check-item" :class="checkItemClass('firefly_login')">
<label>
<input
type="checkbox"
:checked="isStepDone('firefly_login')"
:disabled="!auth.authenticated || loading || isStepBlocked('firefly_login')"
@change="toggleStep('firefly_login', $event)"
/>
<span>Sign in to Firefly III</span>
<span class="pill mono auto-pill" :class="stepPillClass('firefly_login')">
{{ stepPillLabel("firefly_login") }}
</span>
</label>
<p class="muted">
Open <a href="https://money.bstein.dev" target="_blank" rel="noreferrer">money.bstein.dev</a> and sign in
with the credentials from your <a href="/account">Account</a> page. In the Abacus app, set the server URL
to money.bstein.dev and log in once.
</p>
</li>
<li class="check-item" :class="checkItemClass('keycloak_password_rotated')"> <li class="check-item" :class="checkItemClass('keycloak_password_rotated')">
<label> <label>
<input type="checkbox" :checked="isStepDone('keycloak_password_rotated')" disabled /> <input type="checkbox" :checked="isStepDone('keycloak_password_rotated')" disabled />
<span>Rotate your Keycloak password</span> <span>Sign in to Element and update your Keycloak profile</span>
<span class="pill mono auto-pill" :class="keycloakRotationPillClass()"> <span class="pill mono auto-pill" :class="keycloakRotationPillClass()">
{{ keycloakRotationPillLabel() }} {{ keycloakRotationPillLabel() }}
</span> </span>
@ -304,20 +166,14 @@
keycloakPasswordRotationRequested keycloakPasswordRotationRequested
" "
> >
Enable rotation Start Keycloak update
</button> </button>
<a <a class="mono" href="https://live.bstein.dev" target="_blank" rel="noreferrer">Open Element</a>
class="mono"
href="https://sso.bstein.dev/realms/atlas/account/#/security/signing-in"
target="_blank"
rel="noreferrer"
>
Open Keycloak
</a>
</div> </div>
<p class="muted"> <p class="muted">
After Vaultwarden is set up, rotate your Keycloak password to a strong one and store it in Vaultwarden. After Vaultwarden is ready, sign in to Element with the temporary password. Keycloak will prompt you to set
Atlas verifies this once Keycloak no longer requires you to update your password. a new password and your name. Store the new password in Vaultwarden. Atlas will mark this step complete once
Keycloak no longer requires a password update.
</p> </p>
</li> </li>
@ -403,9 +259,9 @@
</button> </button>
</div> </div>
<p class="muted"> <p class="muted">
In Element, create a recovery key so you can restore encrypted history if you lose a device. Atlas stores only a SHA-256 hash so the In Element, create a recovery key so you can restore encrypted history if you lose a device. Atlas stores only a
recovery key itself is never saved server-side. SHA-256 hash so the recovery key itself is never saved server-side. Open
Open <a href="https://live.bstein.dev/#/settings" target="_blank" rel="noreferrer">Element settings</a> Encryption. <a href="https://live.bstein.dev/#/settings" target="_blank" rel="noreferrer">Element settings</a> Encryption.
</p> </p>
</li> </li>
@ -422,7 +278,143 @@
{{ stepPillLabel("element_recovery_key_stored") }} {{ stepPillLabel("element_recovery_key_stored") }}
</span> </span>
</label> </label>
<p class="muted">Save the recovery key in Vaultwarden so it doesn't get lost.</p> <p class="muted">Add the Element recovery key to Vaultwarden so it's stored safely.</p>
</li>
<li class="check-item" :class="checkItemClass('firefly_login')">
<label>
<input
type="checkbox"
:checked="isStepDone('firefly_login')"
:disabled="!auth.authenticated || loading || isStepBlocked('firefly_login')"
@change="toggleStep('firefly_login', $event)"
/>
<span>Sign in to Firefly III</span>
<span class="pill mono auto-pill" :class="stepPillClass('firefly_login')">
{{ stepPillLabel("firefly_login") }}
</span>
</label>
<p class="muted">
Open <a href="https://money.bstein.dev" target="_blank" rel="noreferrer">money.bstein.dev</a> and sign in
with the credentials from your <a href="/account">Account</a> page. Change the password immediately and
save it in Vaultwarden. In the Abacus app, set the server URL to money.bstein.dev and log in once.
</p>
</li>
<li class="check-item" :class="checkItemClass('health_data_notice')">
<label>
<input
type="checkbox"
:checked="isStepDone('health_data_notice')"
:disabled="!auth.authenticated || loading || isStepBlocked('health_data_notice')"
@change="toggleStep('health_data_notice', $event)"
/>
<span>Review the Wger health data notice</span>
<span class="pill mono auto-pill" :class="stepPillClass('health_data_notice')">
{{ stepPillLabel("health_data_notice") }}
</span>
</label>
<p class="muted">
Wger is a personal wellness tool, not medical advice. Use it at your own risk. Your health data belongs to
you and will never be sold or used beyond providing the service. We apply best practices to protect it,
but no system is risk-free.
</p>
</li>
<li class="check-item" :class="checkItemClass('wger_login')">
<label>
<input
type="checkbox"
:checked="isStepDone('wger_login')"
:disabled="!auth.authenticated || loading || isStepBlocked('wger_login')"
@change="toggleStep('wger_login', $event)"
/>
<span>Sign in to Wger</span>
<span class="pill mono auto-pill" :class="stepPillClass('wger_login')">
{{ stepPillLabel("wger_login") }}
</span>
</label>
<p class="muted">
Open <a href="https://health.bstein.dev" target="_blank" rel="noreferrer">health.bstein.dev</a> and sign in
with the credentials shown on your <a href="/account">Account</a> page. Change the password immediately and
store it in Vaultwarden. In the mobile app, set the server to health.bstein.dev and sign in once.
</p>
</li>
<li class="check-item" :class="checkItemClass('vaultwarden_browser_extension')">
<label>
<input
type="checkbox"
:checked="isStepDone('vaultwarden_browser_extension')"
:disabled="!auth.authenticated || loading || isStepBlocked('vaultwarden_browser_extension')"
@change="toggleStep('vaultwarden_browser_extension', $event)"
/>
<span>Install the Vaultwarden browser extension</span>
<span class="pill mono auto-pill" :class="stepPillClass('vaultwarden_browser_extension')">
{{ stepPillLabel("vaultwarden_browser_extension") }}
</span>
</label>
<p class="muted">
Install Bitwarden in your browser and point it at vault.bstein.dev (Settings Account Environment Self-hosted).
<a href="https://bitwarden.com/download" target="_blank" rel="noreferrer">Bitwarden downloads</a>.
</p>
</li>
<li class="check-item" :class="checkItemClass('vaultwarden_desktop_app')">
<label>
<input
type="checkbox"
:checked="isStepDone('vaultwarden_desktop_app')"
:disabled="!auth.authenticated || loading || isStepBlocked('vaultwarden_desktop_app')"
@change="toggleStep('vaultwarden_desktop_app', $event)"
/>
<span>Install the Vaultwarden desktop app</span>
<span class="pill mono auto-pill" :class="stepPillClass('vaultwarden_desktop_app')">
{{ stepPillLabel("vaultwarden_desktop_app") }}
</span>
</label>
<p class="muted">
Install the Bitwarden desktop app and set the server to vault.bstein.dev (Settings Account Environment Self-hosted).
<a href="https://bitwarden.com/download" target="_blank" rel="noreferrer">Bitwarden downloads</a>.
</p>
</li>
<li class="check-item" :class="checkItemClass('vaultwarden_mobile_app')">
<label>
<input
type="checkbox"
:checked="isStepDone('vaultwarden_mobile_app')"
:disabled="!auth.authenticated || loading || isStepBlocked('vaultwarden_mobile_app')"
@change="toggleStep('vaultwarden_mobile_app', $event)"
/>
<span>Install Bitwarden on your phone</span>
<span class="pill mono auto-pill" :class="stepPillClass('vaultwarden_mobile_app')">
{{ stepPillLabel("vaultwarden_mobile_app") }}
</span>
</label>
<p class="muted">
Install the mobile app, set the server to vault.bstein.dev, and enable biometrics for fast unlock.
<a href="https://bitwarden.com/download" target="_blank" rel="noreferrer">Bitwarden downloads</a>.
</p>
</li>
<li class="check-item" :class="checkItemClass('actual_login')">
<label>
<input
type="checkbox"
:checked="isStepDone('actual_login')"
:disabled="!auth.authenticated || loading || isStepBlocked('actual_login')"
@change="toggleStep('actual_login', $event)"
/>
<span>Optional: sign in to Actual Budget</span>
<span class="pill mono auto-pill" :class="stepPillClass('actual_login')">
{{ stepPillLabel("actual_login") }}
</span>
</label>
<p class="muted">
Open <a href="https://budget.bstein.dev" target="_blank" rel="noreferrer">budget.bstein.dev</a> and sign in
with your Keycloak account.
</p>
</li> </li>
<li v-for="step in extraSteps" :key="step.id" class="check-item" :class="checkItemClass(step.id)"> <li v-for="step in extraSteps" :key="step.id" class="check-item" :class="checkItemClass(step.id)">
@ -434,23 +426,19 @@
@change="toggleStep(step.id, $event)" @change="toggleStep(step.id, $event)"
/> />
<span>{{ step.title }}</span> <span>{{ step.title }}</span>
<span class="pill mono auto-pill" :class="stepPillClass(step.id)">{{ stepPillLabel(step.id) }}</span> <span class="pill mono auto-pill" :class="stepPillClass(step.id)">
{{ stepPillLabel(step.id) }}
</span>
</label> </label>
<p class="muted"> <p class="muted">
{{ step.description }} {{ step.description }}
<template v-if="step.primaryLink"> <a v-if="step.primaryLink" :href="step.primaryLink.href" target="_blank" rel="noreferrer">{{
<a :href="step.primaryLink.href" target="_blank" rel="noreferrer">{{ step.primaryLink.text }}</a step.primaryLink.text
>. }}</a>
</template> .
<template v-if="step.secondaryLink">
<span> </span>
<a :href="step.secondaryLink.href" target="_blank" rel="noreferrer">{{ step.secondaryLink.text }}</a
>.
</template>
</p> </p>
</li> </li>
</ul> </ul>
<div class="mobile-guides"> <div class="mobile-guides">
<div class="module-head"> <div class="module-head">
<h3>Mobile app guides</h3> <h3>Mobile app guides</h3>
@ -560,6 +548,12 @@ const extraSteps = [
"Use the IMAP/SMTP details on your Account page to add mail to your phone or desktop client (FairEmail on Android, Apple Mail on iOS, Thunderbird on desktop).", "Use the IMAP/SMTP details on your Account page to add mail to your phone or desktop client (FairEmail on Android, Apple Mail on iOS, Thunderbird on desktop).",
primaryLink: { href: "/account", text: "Account" }, primaryLink: { href: "/account", text: "Account" },
}, },
{
id: "jellyfin_login",
title: "Sign in to Jellyfin",
description: "Sign in with your Atlas username/password (LDAP-backed).",
primaryLink: { href: "https://stream.bstein.dev", text: "Jellyfin" },
},
{ {
id: "outline_login", id: "outline_login",
title: "Open Outline and create your first doc", title: "Open Outline and create your first doc",
@ -572,12 +566,6 @@ const extraSteps = [
description: "Spin up a project board and invite teammates to collaborate.", description: "Spin up a project board and invite teammates to collaborate.",
primaryLink: { href: "https://tasks.bstein.dev", text: "Planka" }, primaryLink: { href: "https://tasks.bstein.dev", text: "Planka" },
}, },
{
id: "jellyfin_login",
title: "Sign in to Jellyfin",
description: "Sign in with your Atlas username/password (LDAP-backed).",
primaryLink: { href: "https://stream.bstein.dev", text: "Jellyfin" },
},
]; ];
// Guide images: drop files into src/assets/onboarding/<guideId>/01-step.png to auto-load and order. // Guide images: drop files into src/assets/onboarding/<guideId>/01-step.png to auto-load and order.
@ -719,21 +707,12 @@ function requiredStepOrder() {
} }
return [ return [
"vaultwarden_master_password", "vaultwarden_master_password",
"vaultwarden_browser_extension",
"vaultwarden_desktop_app",
"vaultwarden_mobile_app",
"health_data_notice",
"wger_login",
"actual_login",
"firefly_login",
"keycloak_password_rotated", "keycloak_password_rotated",
"element_recovery_key", "element_recovery_key",
"element_recovery_key_stored", "element_recovery_key_stored",
"elementx_setup", "firefly_login",
"mail_client_setup", "health_data_notice",
"outline_login", "wger_login",
"planka_login",
"jellyfin_login",
]; ];
} }
@ -789,7 +768,7 @@ function mfaPillClass() {
function keycloakRotationPillLabel() { function keycloakRotationPillLabel() {
if (isStepDone("keycloak_password_rotated")) return "done"; if (isStepDone("keycloak_password_rotated")) return "done";
if (isStepBlocked("keycloak_password_rotated")) return "blocked"; if (isStepBlocked("keycloak_password_rotated")) return "blocked";
if (keycloakPasswordRotationRequested.value) return "rotate now"; if (keycloakPasswordRotationRequested.value) return "update now";
return "ready"; return "ready";
} }

View File

@ -5,7 +5,7 @@
<p class="eyebrow">Atlas</p> <p class="eyebrow">Atlas</p>
<h1>Request Access</h1> <h1>Request Access</h1>
<p class="lede"> <p class="lede">
Request access and an admin can approve your account. Request access to Atlas. Approved accounts are provisioned from this form only.
</p> </p>
</div> </div>
</section> </section>
@ -20,11 +20,12 @@
<p class="muted"> <p class="muted">
Requests require a verified external email so Keycloak can support account recovery. After verification, an admin can approve your account. Requests require a verified external email so Keycloak can support account recovery. After verification, an admin can approve your account.
Your lab username becomes your Atlas identity (including your @{{ mailDomain }} mailbox).
</p> </p>
<form class="form" @submit.prevent="submit" v-if="!submitted"> <form class="form" @submit.prevent="submit" v-if="!submitted">
<label class="field"> <label class="field">
<span class="label mono">Desired Username</span> <span class="label mono">Lab Name (username)</span>
<input <input
v-model="form.username" v-model="form.username"
class="input mono" class="input mono"
@ -34,6 +35,10 @@
:disabled="submitting" :disabled="submitting"
required required
/> />
<div v-if="availability.label" class="availability">
<span class="pill mono" :class="availability.pillClass">{{ availability.label }}</span>
<span v-if="availability.detail" class="hint mono">{{ availability.detail }}</span>
</div>
</label> </label>
<label class="field"> <label class="field">
@ -62,7 +67,7 @@
</label> </label>
<div class="actions"> <div class="actions">
<button class="primary" type="submit" :disabled="submitting || !form.username.trim()"> <button class="primary" type="submit" :disabled="submitting || !form.username.trim() || availability.blockSubmit">
{{ submitting ? "Submitting..." : "Submit request" }} {{ submitting ? "Submitting..." : "Submit request" }}
</button> </button>
<span class="hint mono">Requests are rate-limited.</span> <span class="hint mono">Requests are rate-limited.</span>
@ -153,7 +158,7 @@
</template> </template>
<script setup> <script setup>
import { onMounted, reactive, ref } from "vue"; import { onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
const route = useRoute(); const route = useRoute();
@ -193,6 +198,15 @@ const requestCode = ref("");
const copied = ref(false); const copied = ref(false);
const verifying = ref(false); const verifying = ref(false);
const mailDomain = import.meta.env?.VITE_MAILU_DOMAIN || "bstein.dev"; const mailDomain = import.meta.env?.VITE_MAILU_DOMAIN || "bstein.dev";
const availability = reactive({
label: "",
detail: "",
pillClass: "",
checking: false,
blockSubmit: false,
});
let availabilityTimer = 0;
let availabilityToken = 0;
const statusForm = reactive({ const statusForm = reactive({
request_code: "", request_code: "",
@ -211,6 +225,92 @@ function taskPillClass(status) {
return "pill-warn"; return "pill-warn";
} }
function resetAvailability() {
availability.label = "";
availability.detail = "";
availability.pillClass = "";
availability.blockSubmit = false;
}
function setAvailability(state, detail = "") {
availability.detail = detail;
availability.blockSubmit = false;
if (state === "checking") {
availability.label = "checking";
availability.pillClass = "pill-warn";
return;
}
if (state === "available") {
availability.label = "available";
availability.pillClass = "pill-ok";
return;
}
if (state === "invalid") {
availability.label = "invalid";
availability.pillClass = "pill-bad";
availability.blockSubmit = true;
return;
}
if (state === "requested") {
availability.label = "requested";
availability.pillClass = "pill-warn";
availability.blockSubmit = true;
return;
}
if (state === "exists") {
availability.label = "taken";
availability.pillClass = "pill-bad";
availability.blockSubmit = true;
return;
}
if (state === "error") {
availability.label = "error";
availability.pillClass = "pill-warn";
return;
}
resetAvailability();
}
async function checkAvailability(name) {
const token = (availabilityToken += 1);
setAvailability("checking");
availability.checking = true;
try {
const resp = await fetch(`/api/access/request/availability?username=${encodeURIComponent(name)}`, {
headers: { Accept: "application/json" },
cache: "no-store",
});
const data = await resp.json().catch(() => ({}));
if (token !== availabilityToken) return;
if (!resp.ok) throw new Error(data.error || `status ${resp.status}`);
if (data.available) {
setAvailability("available", "Username is available.");
return;
}
const reason = data.reason || "";
const status = data.status || "";
if (reason === "invalid") {
setAvailability("invalid", data.detail || "Use 3-32 characters (letters, numbers, . _ -).");
return;
}
if (reason === "exists") {
setAvailability("exists", "Already in use. Choose another name.");
return;
}
if (reason === "requested") {
const label = status ? `Existing request: ${statusLabel(status)}` : "Request already exists.";
setAvailability("requested", label);
return;
}
setAvailability("error", "Unable to confirm availability.");
} catch (err) {
if (token !== availabilityToken) return;
setAvailability("error", err.message || "Availability check failed.");
} finally {
if (token === availabilityToken) availability.checking = false;
}
}
async function submit() { async function submit() {
if (submitting.value) return; if (submitting.value) return;
error.value = ""; error.value = "";
@ -239,6 +339,33 @@ async function submit() {
} }
} }
watch(
() => form.username,
(value) => {
const trimmed = value.trim();
if (availabilityTimer) {
window.clearTimeout(availabilityTimer);
availabilityTimer = 0;
}
availabilityToken += 1;
if (!trimmed) {
resetAvailability();
return;
}
if (trimmed.length < 3 || trimmed.length > 32) {
setAvailability("invalid", "Use 3-32 characters (letters, numbers, . _ -).");
return;
}
if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) {
setAvailability("invalid", "Use letters, numbers, and . _ - only.");
return;
}
availabilityTimer = window.setTimeout(() => {
checkAvailability(trimmed);
}, 350);
},
);
async function copyRequestCode() { async function copyRequestCode() {
if (!requestCode.value) return; if (!requestCode.value) return;
try { try {
@ -409,6 +536,12 @@ h1 {
gap: 6px; gap: 6px;
} }
.availability {
display: flex;
align-items: center;
gap: 8px;
}
.label { .label {
color: var(--text-muted); color: var(--text-muted);
font-size: 12px; font-size: 12px;