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 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:
url = (
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
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:
suffix = "".join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(10))
return f"{username}~{suffix}"
@ -59,23 +69,36 @@ def _verify_url(request_code: str, token: str) -> str:
ONBOARDING_STEPS: tuple[str, ...] = (
"vaultwarden_master_password",
"keycloak_password_rotated",
"element_recovery_key",
"element_recovery_key_stored",
"firefly_login",
"health_data_notice",
"wger_login",
"vaultwarden_browser_extension",
"vaultwarden_desktop_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",
"jellyfin_login",
"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(
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:
@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"])
def request_access() -> Any:
if not settings.ACCESS_REQUEST_ENABLED:
@ -310,13 +375,9 @@ def register(app) -> None:
):
return jsonify({"error": "rate limited"}), 429
if not username:
return jsonify({"error": "username is required"}), 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
username_error = _validate_username(username)
if username_error:
return jsonify({"error": username_error}), 400
if not email:
return jsonify({"error": "email is required"}), 400
if "@" not in email:

View File

@ -5,9 +5,9 @@ from urllib.parse import quote
from flask import jsonify, g, request
from .. import ariadne_client
from .. import ariadne_client, settings
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
@ -51,6 +51,24 @@ def register(app) -> None:
)
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"])
@require_auth
def admin_approve_request(username: str) -> Any:

View File

@ -356,12 +356,36 @@
<span>User</span>
<span>Email</span>
<span>Note</span>
<span>Flags</span>
<span>Decision note</span>
<span></span>
</div>
<div v-for="req in admin.requests" :key="req.username" class="req-row">
<div class="mono">{{ req.username }}</div>
<div class="mono">{{ req.email }}</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">
<button class="primary" type="button" :disabled="admin.acting[req.username]" @click="approve(req.username)">
approve
@ -451,17 +475,23 @@ const admin = reactive({
requests: [],
error: "",
acting: {},
flags: [],
flagsLoading: false,
notes: {},
selectedFlags: {},
});
const onboardingUrl = ref("/onboarding");
const doLogin = () => login("/account");
const copied = reactive({});
const isPortalAdmin = () => Array.isArray(auth.groups) && auth.groups.includes("admin");
onMounted(() => {
if (auth.ready && auth.authenticated) {
refreshOverview();
refreshAdminRequests();
refreshAdminFlags();
} else {
mailu.status = "login required";
nextcloudMail.status = "login required";
@ -486,10 +516,12 @@ watch(
onboardingUrl.value = "/onboarding";
admin.enabled = false;
admin.requests = [];
admin.flags = [];
return;
}
refreshOverview();
refreshAdminRequests();
refreshAdminFlags();
},
{ immediate: false },
);
@ -519,15 +551,15 @@ async function refreshOverview() {
nextcloudMail.accountCount = data.nextcloud_mail?.account_count || "";
nextcloudMail.syncedAt = data.nextcloud_mail?.synced_at || "";
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.passwordUpdatedAt = data.wger?.password_updated_at || "";
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.passwordUpdatedAt = data.firefly?.password_updated_at || "";
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 || "";
jellyfin.status = data.jellyfin?.status || "ready";
jellyfin.username = data.jellyfin?.username || auth.username;
@ -555,6 +587,11 @@ async function refreshOverview() {
}
async function refreshAdminRequests() {
if (!isPortalAdmin()) {
admin.enabled = false;
admin.requests = [];
return;
}
admin.error = "";
admin.loading = true;
try {
@ -571,6 +608,11 @@ async function refreshAdminRequests() {
const data = await resp.json();
admin.enabled = true;
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) {
admin.enabled = false;
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() {
mailu.error = "";
mailu.newPassword = "";
@ -688,7 +769,16 @@ async function approve(username) {
admin.error = "";
admin.acting[username] = true;
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) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `status ${resp.status}`);
@ -705,7 +795,13 @@ async function deny(username) {
admin.error = "";
admin.acting[username] = true;
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) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `status ${resp.status}`);
@ -973,7 +1069,7 @@ button.primary {
.req-head {
display: grid;
grid-template-columns: 160px 220px 1fr 140px;
grid-template-columns: 140px 220px 1fr 220px 200px 140px;
gap: 12px;
color: var(--text-muted);
font-size: 12px;
@ -983,7 +1079,7 @@ button.primary {
.req-row {
display: grid;
grid-template-columns: 160px 220px 1fr 140px;
grid-template-columns: 140px 220px 1fr 220px 200px 140px;
gap: 12px;
align-items: center;
border: 1px solid rgba(255, 255, 255, 0.08);
@ -1005,6 +1101,37 @@ button.primary {
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) {
.req-head {
display: none;

View File

@ -90,9 +90,9 @@
<div v-if="initialPassword" class="initial-password">
<h3>Temporary password</h3>
<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
it later after Vaultwarden is set up. This password is shown once copy it now. If you refresh this page,
it may disappear.
Use this password to log in for the first time (Nextcloud, Element). You won't be forced to change it
immediately you'll rotate it after Vaultwarden is set up. This password is shown once copy it now. If you
refresh this page, it may disappear.
</p>
<div class="request-code-row">
<span class="label mono">Password</span>
@ -130,11 +130,9 @@
</label>
<p class="muted">
Open <a href="https://vault.bstein.dev" target="_blank" rel="noreferrer">Passwords</a> and set a strong master
password you won't forget.
Your master password is the one password to rule all passwords: use a long passphrase (64+ characters is a good target), and never
write it down or share it with anyone.
If you lose it, Atlas can't recover your vault.
If you can't sign in yet, check your Atlas mailbox in
password you won't forget. Your master password is the one password to rule all passwords: use a long passphrase
(64+ characters is a good target), and never write it down or share it with anyone. 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.
</p>
<details class="howto">
@ -147,146 +145,10 @@
</details>
</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')">
<label>
<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()">
{{ keycloakRotationPillLabel() }}
</span>
@ -304,20 +166,14 @@
keycloakPasswordRotationRequested
"
>
Enable rotation
Start Keycloak update
</button>
<a
class="mono"
href="https://sso.bstein.dev/realms/atlas/account/#/security/signing-in"
target="_blank"
rel="noreferrer"
>
Open Keycloak
</a>
<a class="mono" href="https://live.bstein.dev" target="_blank" rel="noreferrer">Open Element</a>
</div>
<p class="muted">
After Vaultwarden is set up, rotate your Keycloak password to a strong one and store it in Vaultwarden.
Atlas verifies this once Keycloak no longer requires you to update your password.
After Vaultwarden is ready, sign in to Element with the temporary password. Keycloak will prompt you to set
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>
</li>
@ -403,9 +259,9 @@
</button>
</div>
<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
recovery key itself is never saved server-side.
Open <a href="https://live.bstein.dev/#/settings" target="_blank" rel="noreferrer">Element settings</a> Encryption.
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 recovery key itself is never saved server-side. Open
<a href="https://live.bstein.dev/#/settings" target="_blank" rel="noreferrer">Element settings</a> Encryption.
</p>
</li>
@ -422,7 +278,143 @@
{{ stepPillLabel("element_recovery_key_stored") }}
</span>
</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 v-for="step in extraSteps" :key="step.id" class="check-item" :class="checkItemClass(step.id)">
@ -434,23 +426,19 @@
@change="toggleStep(step.id, $event)"
/>
<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>
<p class="muted">
{{ step.description }}
<template v-if="step.primaryLink">
<a :href="step.primaryLink.href" target="_blank" rel="noreferrer">{{ 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>
<a v-if="step.primaryLink" :href="step.primaryLink.href" target="_blank" rel="noreferrer">{{
step.primaryLink.text
}}</a>
.
</p>
</li>
</ul>
<div class="mobile-guides">
<div class="module-head">
<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).",
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",
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.",
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.
@ -719,21 +707,12 @@ function requiredStepOrder() {
}
return [
"vaultwarden_master_password",
"vaultwarden_browser_extension",
"vaultwarden_desktop_app",
"vaultwarden_mobile_app",
"health_data_notice",
"wger_login",
"actual_login",
"firefly_login",
"keycloak_password_rotated",
"element_recovery_key",
"element_recovery_key_stored",
"elementx_setup",
"mail_client_setup",
"outline_login",
"planka_login",
"jellyfin_login",
"firefly_login",
"health_data_notice",
"wger_login",
];
}
@ -789,7 +768,7 @@ function mfaPillClass() {
function keycloakRotationPillLabel() {
if (isStepDone("keycloak_password_rotated")) return "done";
if (isStepBlocked("keycloak_password_rotated")) return "blocked";
if (keycloakPasswordRotationRequested.value) return "rotate now";
if (keycloakPasswordRotationRequested.value) return "update now";
return "ready";
}

View File

@ -5,7 +5,7 @@
<p class="eyebrow">Atlas</p>
<h1>Request Access</h1>
<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>
</div>
</section>
@ -20,11 +20,12 @@
<p class="muted">
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>
<form class="form" @submit.prevent="submit" v-if="!submitted">
<label class="field">
<span class="label mono">Desired Username</span>
<span class="label mono">Lab Name (username)</span>
<input
v-model="form.username"
class="input mono"
@ -34,6 +35,10 @@
:disabled="submitting"
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 class="field">
@ -62,7 +67,7 @@
</label>
<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" }}
</button>
<span class="hint mono">Requests are rate-limited.</span>
@ -153,7 +158,7 @@
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
@ -193,6 +198,15 @@ const requestCode = ref("");
const copied = ref(false);
const verifying = ref(false);
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({
request_code: "",
@ -211,6 +225,92 @@ function taskPillClass(status) {
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() {
if (submitting.value) return;
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() {
if (!requestCode.value) return;
try {
@ -409,6 +536,12 @@ h1 {
gap: 6px;
}
.availability {
display: flex;
align-items: center;
gap: 8px;
}
.label {
color: var(--text-muted);
font-size: 12px;