portal: gate requests on verified email

This commit is contained in:
Brad Stein 2026-01-03 02:36:29 -03:00
parent 2c2f0b04d9
commit 13b0099c29
8 changed files with 345 additions and 37 deletions

View File

@ -33,6 +33,9 @@ def ensure_schema() -> None:
contact_email TEXT, contact_email TEXT,
note TEXT, note TEXT,
status TEXT NOT NULL, status TEXT NOT NULL,
email_verification_token_hash TEXT,
email_verification_sent_at TIMESTAMPTZ,
email_verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
decided_at TIMESTAMPTZ, decided_at TIMESTAMPTZ,
decided_by TEXT, decided_by TEXT,
@ -43,6 +46,9 @@ def ensure_schema() -> None:
) )
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS initial_password TEXT") conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS initial_password TEXT")
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS initial_password_revealed_at TIMESTAMPTZ") conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS initial_password_revealed_at TIMESTAMPTZ")
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS email_verification_token_hash TEXT")
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS email_verification_sent_at TIMESTAMPTZ")
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ")
conn.execute( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS access_request_tasks ( CREATE TABLE IF NOT EXISTS access_request_tasks (

View File

@ -0,0 +1,53 @@
from __future__ import annotations
import smtplib
from email.message import EmailMessage
from . import settings
class MailerError(RuntimeError):
pass
def send_text_email(*, to_addr: str, subject: str, body: str) -> None:
if not to_addr:
raise MailerError("missing recipient")
if not settings.SMTP_HOST:
raise MailerError("smtp not configured")
message = EmailMessage()
message["From"] = settings.SMTP_FROM
message["To"] = to_addr
message["Subject"] = subject
message.set_content(body)
smtp_cls = smtplib.SMTP_SSL if settings.SMTP_USE_TLS else smtplib.SMTP
try:
with smtp_cls(settings.SMTP_HOST, settings.SMTP_PORT, timeout=settings.SMTP_TIMEOUT_SEC) as client:
if settings.SMTP_STARTTLS and not settings.SMTP_USE_TLS:
client.starttls()
if settings.SMTP_USERNAME:
client.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD)
client.send_message(message)
except Exception as exc:
raise MailerError("failed to send email") from exc
def access_request_verification_body(*, request_code: str, verify_url: str) -> str:
return "\n".join(
[
"Atlas — confirm your email",
"",
"Someone requested an Atlas account using this email address.",
"",
f"Request code: {request_code}",
"",
"To confirm this request, open:",
verify_url,
"",
"If you did not request access, you can ignore this email.",
"",
]
)

View File

@ -12,6 +12,7 @@ from .utils import random_password
from .vaultwarden import invite_user from .vaultwarden import invite_user
MAILU_EMAIL_ATTR = "mailu_email"
MAILU_APP_PASSWORD_ATTR = "mailu_app_password" MAILU_APP_PASSWORD_ATTR = "mailu_app_password"
REQUIRED_PROVISION_TASKS: tuple[str, ...] = ( REQUIRED_PROVISION_TASKS: tuple[str, ...] = (
"keycloak_user", "keycloak_user",
@ -78,7 +79,12 @@ def provision_access_request(request_code: str) -> ProvisionResult:
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
""" """
SELECT username, contact_email, status, initial_password, initial_password_revealed_at SELECT username,
contact_email,
email_verified_at,
status,
initial_password,
initial_password_revealed_at
FROM access_requests FROM access_requests
WHERE request_code = %s WHERE request_code = %s
""", """,
@ -89,6 +95,7 @@ def provision_access_request(request_code: str) -> ProvisionResult:
username = str(row.get("username") or "") username = str(row.get("username") or "")
contact_email = str(row.get("contact_email") or "") contact_email = str(row.get("contact_email") or "")
email_verified_at = row.get("email_verified_at")
status = str(row.get("status") or "") status = str(row.get("status") or "")
initial_password = row.get("initial_password") initial_password = row.get("initial_password")
revealed_at = row.get("initial_password_revealed_at") revealed_at = row.get("initial_password_revealed_at")
@ -97,24 +104,43 @@ def provision_access_request(request_code: str) -> ProvisionResult:
return ProvisionResult(ok=False, status=status or "unknown") return ProvisionResult(ok=False, status=status or "unknown")
user_id = "" user_id = ""
mailu_email = f"{username}@{settings.MAILU_DOMAIN}"
# Task: ensure Keycloak user exists # Task: ensure Keycloak user exists
try: try:
user = admin_client().find_user(username) user = admin_client().find_user(username)
if not user: if not user:
email = contact_email.strip() or f"{username}@{settings.MAILU_DOMAIN}" email = contact_email.strip()
if not email:
raise RuntimeError("contact email missing")
payload = { payload = {
"username": username, "username": username,
"enabled": True, "enabled": True,
"email": email, "email": email,
"emailVerified": False, "emailVerified": bool(email_verified_at),
"requiredActions": ["CONFIGURE_TOTP"], "requiredActions": ["CONFIGURE_TOTP"],
"attributes": {MAILU_EMAIL_ATTR: [mailu_email]},
} }
created_id = admin_client().create_user(payload) created_id = admin_client().create_user(payload)
user = admin_client().get_user(created_id) user = admin_client().get_user(created_id)
user_id = str((user or {}).get("id") or "") user_id = str((user or {}).get("id") or "")
if not user_id: if not user_id:
raise RuntimeError("user id missing") raise RuntimeError("user id missing")
try:
full = admin_client().get_user(user_id)
attrs = full.get("attributes") or {}
if isinstance(attrs, dict):
raw_mailu = attrs.get(MAILU_EMAIL_ATTR)
if isinstance(raw_mailu, list) and raw_mailu and isinstance(raw_mailu[0], str):
mailu_email = raw_mailu[0]
elif isinstance(raw_mailu, str) and raw_mailu:
mailu_email = raw_mailu
if not mailu_email:
mailu_email = f"{username}@{settings.MAILU_DOMAIN}"
admin_client().set_user_attribute(username, MAILU_EMAIL_ATTR, mailu_email)
except Exception:
# Non-fatal: Mailu sync will fall back to username@domain.
mailu_email = f"{username}@{settings.MAILU_DOMAIN}"
_upsert_task(conn, request_code, "keycloak_user", "ok", None) _upsert_task(conn, request_code, "keycloak_user", "ok", None)
except Exception: except Exception:
_upsert_task(conn, request_code, "keycloak_user", "error", "failed to ensure user") _upsert_task(conn, request_code, "keycloak_user", "error", "failed to ensure user")
@ -201,15 +227,7 @@ def provision_access_request(request_code: str) -> ProvisionResult:
# Task: ensure Vaultwarden account exists (invite flow) # Task: ensure Vaultwarden account exists (invite flow)
try: try:
if user_id: if user_id:
full = admin_client().get_user(user_id) result = invite_user(mailu_email or f"{username}@{settings.MAILU_DOMAIN}")
keycloak_email = str(full.get("email") or "")
email = ""
if keycloak_email.lower().endswith(f"@{settings.MAILU_DOMAIN.lower()}"):
email = keycloak_email
else:
email = f"{username}@{settings.MAILU_DOMAIN}"
result = invite_user(email)
if result.ok: if result.ok:
_upsert_task(conn, request_code, "vaultwarden_invite", "ok", result.status) _upsert_task(conn, request_code, "vaultwarden_invite", "ok", result.status)
else: else:

View File

@ -1,9 +1,13 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
import hashlib
import hmac
import re import re
import secrets import secrets
import string import string
from typing import Any from typing import Any
from urllib.parse import quote
from flask import jsonify, request, g from flask import jsonify, request, g
@ -11,6 +15,7 @@ import psycopg
from ..db import connect, configured from ..db import connect, configured
from ..keycloak import admin_client, require_auth from ..keycloak import admin_client, require_auth
from ..mailer import MailerError, access_request_verification_body, send_text_email
from ..rate_limit import rate_limit_allow from ..rate_limit import rate_limit_allow
from ..provisioning import provision_tasks_complete from ..provisioning import provision_tasks_complete
from .. import settings from .. import settings
@ -39,6 +44,18 @@ def _client_ip() -> str:
return request.remote_addr or "unknown" return request.remote_addr or "unknown"
EMAIL_VERIFY_PENDING_STATUS = "pending_email_verification"
def _hash_verification_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _verify_url(request_code: str, token: str) -> str:
base = settings.PORTAL_PUBLIC_BASE_URL.rstrip("/")
return f"{base}/request-access?code={quote(request_code)}&verify={quote(token)}"
ONBOARDING_STEPS: tuple[str, ...] = ( ONBOARDING_STEPS: tuple[str, ...] = (
"keycloak_password_changed", "keycloak_password_changed",
"keycloak_mfa_configured", "keycloak_mfa_configured",
@ -207,8 +224,12 @@ def register(app) -> None:
return jsonify({"error": "username must be 3-32 characters"}), 400 return jsonify({"error": "username must be 3-32 characters"}), 400
if not re.fullmatch(r"[a-zA-Z0-9._-]+", username): if not re.fullmatch(r"[a-zA-Z0-9._-]+", username):
return jsonify({"error": "username contains invalid characters"}), 400 return jsonify({"error": "username contains invalid characters"}), 400
if email and "@" not in email: if not email:
return jsonify({"error": "email is required"}), 400
if "@" not in email:
return jsonify({"error": "invalid email"}), 400 return jsonify({"error": "invalid email"}), 400
if email.lower().endswith(f"@{settings.MAILU_DOMAIN.lower()}"):
return jsonify({"error": "email must be an external address"}), 400
if admin_client().ready() and admin_client().find_user(username): if admin_client().ready() and admin_client().find_user(username):
return jsonify({"error": "username already exists"}), 409 return jsonify({"error": "username already exists"}), 409
@ -219,25 +240,60 @@ def register(app) -> None:
""" """
SELECT request_code, status SELECT request_code, status
FROM access_requests FROM access_requests
WHERE username = %s AND status = 'pending' WHERE username = %s AND status IN (%s, 'pending')
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1 LIMIT 1
""", """,
(username,), (username, EMAIL_VERIFY_PENDING_STATUS),
).fetchone() ).fetchone()
if existing: if existing:
return jsonify({"ok": True, "request_code": existing["request_code"], "status": existing["status"]}) existing_status = str(existing.get("status") or "")
request_code = str(existing.get("request_code") or "")
if existing_status != EMAIL_VERIFY_PENDING_STATUS:
return jsonify({"ok": True, "request_code": request_code, "status": existing_status})
token = secrets.token_urlsafe(24)
token_hash = _hash_verification_token(token)
conn.execute(
"""
UPDATE access_requests
SET contact_email = %s,
note = %s,
email_verification_token_hash = %s,
email_verification_sent_at = NOW(),
email_verified_at = NULL
WHERE request_code = %s AND status = %s
""",
(email, note or None, token_hash, request_code, EMAIL_VERIFY_PENDING_STATUS),
)
verify_url = _verify_url(request_code, token)
try:
send_text_email(
to_addr=email,
subject="Atlas: confirm your email",
body=access_request_verification_body(request_code=request_code, verify_url=verify_url),
)
except MailerError:
return (
jsonify({"error": "failed to send verification email", "request_code": request_code}),
502,
)
return jsonify({"ok": True, "request_code": request_code, "status": EMAIL_VERIFY_PENDING_STATUS})
request_code = _random_request_code(username) request_code = _random_request_code(username)
token = secrets.token_urlsafe(24)
token_hash = _hash_verification_token(token)
try: try:
conn.execute( conn.execute(
""" """
INSERT INTO access_requests INSERT INTO access_requests
(request_code, username, contact_email, note, status) (request_code, username, contact_email, note, status,
email_verification_token_hash, email_verification_sent_at)
VALUES VALUES
(%s, %s, %s, %s, 'pending') (%s, %s, %s, %s, %s, %s, NOW())
""", """,
(request_code, username, email or None, note or None), (request_code, username, email, note or None, EMAIL_VERIFY_PENDING_STATUS, token_hash),
) )
except psycopg.errors.UniqueViolation: except psycopg.errors.UniqueViolation:
conn.rollback() conn.rollback()
@ -245,19 +301,109 @@ def register(app) -> None:
""" """
SELECT request_code, status SELECT request_code, status
FROM access_requests FROM access_requests
WHERE username = %s AND status = 'pending' WHERE username = %s AND status IN (%s, 'pending')
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1 LIMIT 1
""", """,
(username,), (username, EMAIL_VERIFY_PENDING_STATUS),
).fetchone() ).fetchone()
if not existing: if not existing:
raise raise
return jsonify({"ok": True, "request_code": existing["request_code"], "status": existing["status"]}) return jsonify({"ok": True, "request_code": existing["request_code"], "status": existing["status"]})
verify_url = _verify_url(request_code, token)
try:
send_text_email(
to_addr=email,
subject="Atlas: confirm your email",
body=access_request_verification_body(request_code=request_code, verify_url=verify_url),
)
except MailerError:
return jsonify({"error": "failed to send verification email", "request_code": request_code}), 502
except Exception: except Exception:
return jsonify({"error": "failed to submit request"}), 502 return jsonify({"error": "failed to submit request"}), 502
return jsonify({"ok": True, "request_code": request_code}) return jsonify({"ok": True, "request_code": request_code, "status": EMAIL_VERIFY_PENDING_STATUS})
@app.route("/api/access/request/verify", methods=["POST"])
def request_access_verify() -> Any:
if not settings.ACCESS_REQUEST_ENABLED:
return jsonify({"error": "request access disabled"}), 503
if not configured():
return jsonify({"error": "server not configured"}), 503
ip = _client_ip()
if not rate_limit_allow(
ip,
key="access_request_verify",
limit=60,
window_sec=60,
):
return jsonify({"error": "rate limited"}), 429
payload = request.get_json(silent=True) or {}
code = (payload.get("request_code") or payload.get("code") or "").strip()
token = (payload.get("token") or payload.get("verify") or "").strip()
if not code:
return jsonify({"error": "request_code is required"}), 400
if not token:
return jsonify({"error": "token is required"}), 400
if not rate_limit_allow(
f"{ip}:{code}",
key="access_request_verify_code",
limit=30,
window_sec=60,
):
return jsonify({"error": "rate limited"}), 429
try:
with connect() as conn:
row = conn.execute(
"""
SELECT status, email_verification_token_hash, email_verification_sent_at, email_verified_at
FROM access_requests
WHERE request_code = %s
""",
(code,),
).fetchone()
if not row:
return jsonify({"error": "not found"}), 404
status = _normalize_status(row.get("status") or "")
if status != EMAIL_VERIFY_PENDING_STATUS:
return jsonify({"ok": True, "status": status})
stored_hash = str(row.get("email_verification_token_hash") or "")
if not stored_hash:
return jsonify({"error": "verification token missing"}), 409
provided_hash = _hash_verification_token(token)
if not hmac.compare_digest(stored_hash, provided_hash):
return jsonify({"error": "invalid token"}), 401
sent_at = row.get("email_verification_sent_at")
if isinstance(sent_at, datetime):
now = datetime.now(timezone.utc)
if sent_at.tzinfo is None:
sent_at = sent_at.replace(tzinfo=timezone.utc)
age_sec = (now - sent_at).total_seconds()
if age_sec > settings.ACCESS_REQUEST_EMAIL_VERIFY_TTL_SEC:
return jsonify({"error": "verification token expired"}), 410
conn.execute(
"""
UPDATE access_requests
SET status = 'pending',
email_verified_at = NOW(),
email_verification_token_hash = NULL
WHERE request_code = %s AND status = %s
""",
(code, EMAIL_VERIFY_PENDING_STATUS),
)
return jsonify({"ok": True, "status": "pending"})
except Exception:
return jsonify({"error": "failed to verify"}), 502
@app.route("/api/access/request/status", methods=["POST"]) @app.route("/api/access/request/status", methods=["POST"])
def request_access_status() -> Any: def request_access_status() -> Any:

View File

@ -32,6 +32,7 @@ def register(app) -> None:
username = g.keycloak_username username = g.keycloak_username
keycloak_email = g.keycloak_email or "" keycloak_email = g.keycloak_email or ""
mailu_email = ""
mailu_app_password = "" mailu_app_password = ""
mailu_status = "ready" mailu_status = "ready"
jellyfin_status = "ready" jellyfin_status = "ready"
@ -54,6 +55,11 @@ def register(app) -> None:
attrs = user.get("attributes") if isinstance(user, dict) else None attrs = user.get("attributes") if isinstance(user, dict) else None
if isinstance(attrs, dict): if isinstance(attrs, dict):
raw_mailu = attrs.get("mailu_email")
if isinstance(raw_mailu, list) and raw_mailu:
mailu_email = str(raw_mailu[0])
elif isinstance(raw_mailu, str) and raw_mailu:
mailu_email = raw_mailu
raw_pw = attrs.get("mailu_app_password") raw_pw = attrs.get("mailu_app_password")
if isinstance(raw_pw, list) and raw_pw: if isinstance(raw_pw, list) and raw_pw:
mailu_app_password = str(raw_pw[0]) mailu_app_password = str(raw_pw[0])
@ -61,13 +67,20 @@ def register(app) -> None:
mailu_app_password = raw_pw mailu_app_password = raw_pw
user_id = user.get("id") if isinstance(user, dict) else None user_id = user.get("id") if isinstance(user, dict) else None
if user_id and (not keycloak_email or not mailu_app_password): if user_id and (not keycloak_email or not mailu_email or not mailu_app_password):
full = admin_client().get_user(str(user_id)) full = admin_client().get_user(str(user_id))
if not keycloak_email: if not keycloak_email:
keycloak_email = str(full.get("email") or "") keycloak_email = str(full.get("email") or "")
if not mailu_app_password: attrs = full.get("attributes") or {}
attrs = full.get("attributes") or {} if isinstance(attrs, dict):
if isinstance(attrs, dict): if not mailu_email:
raw_mailu = attrs.get("mailu_email")
if isinstance(raw_mailu, list) and raw_mailu and isinstance(raw_mailu[0], str):
mailu_email = raw_mailu[0]
elif isinstance(raw_mailu, str) and raw_mailu:
mailu_email = raw_mailu
if not mailu_app_password:
raw_pw = attrs.get("mailu_app_password") raw_pw = attrs.get("mailu_app_password")
if isinstance(raw_pw, list) and raw_pw: if isinstance(raw_pw, list) and raw_pw:
mailu_app_password = str(raw_pw[0]) mailu_app_password = str(raw_pw[0])
@ -79,11 +92,7 @@ def register(app) -> None:
jellyfin_sync_status = "unknown" jellyfin_sync_status = "unknown"
jellyfin_sync_detail = "unavailable" jellyfin_sync_detail = "unavailable"
mailu_username = "" mailu_username = mailu_email or (f"{username}@{settings.MAILU_DOMAIN}" if username else "")
if keycloak_email and keycloak_email.lower().endswith(f"@{settings.MAILU_DOMAIN.lower()}"):
mailu_username = keycloak_email
elif username:
mailu_username = f"{username}@{settings.MAILU_DOMAIN}"
if not mailu_app_password and mailu_status == "ready": if not mailu_app_password and mailu_status == "ready":
mailu_status = "needs app password" mailu_status = "needs app password"

View File

@ -71,6 +71,9 @@ ACCESS_REQUEST_SUBMIT_RATE_WINDOW_SEC = int(
) )
ACCESS_REQUEST_STATUS_RATE_LIMIT = int(os.getenv("ACCESS_REQUEST_STATUS_RATE_LIMIT", "60")) ACCESS_REQUEST_STATUS_RATE_LIMIT = int(os.getenv("ACCESS_REQUEST_STATUS_RATE_LIMIT", "60"))
ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC = int(os.getenv("ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC", "60")) ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC = int(os.getenv("ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC", "60"))
ACCESS_REQUEST_EMAIL_VERIFY_TTL_SEC = int(os.getenv("ACCESS_REQUEST_EMAIL_VERIFY_TTL_SEC", str(24 * 60 * 60)))
PORTAL_PUBLIC_BASE_URL = os.getenv("PORTAL_PUBLIC_BASE_URL", "https://bstein.dev").rstrip("/")
MAILU_DOMAIN = os.getenv("MAILU_DOMAIN", "bstein.dev") MAILU_DOMAIN = os.getenv("MAILU_DOMAIN", "bstein.dev")
MAILU_SYNC_URL = os.getenv( MAILU_SYNC_URL = os.getenv(
@ -78,6 +81,15 @@ MAILU_SYNC_URL = os.getenv(
"http://mailu-sync-listener.mailu-mailserver.svc.cluster.local:8080/events", "http://mailu-sync-listener.mailu-mailserver.svc.cluster.local:8080/events",
).rstrip("/") ).rstrip("/")
SMTP_HOST = os.getenv("SMTP_HOST", "mailu-front.mailu-mailserver.svc.cluster.local").strip()
SMTP_PORT = int(os.getenv("SMTP_PORT", "25"))
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "").strip()
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "").strip()
SMTP_STARTTLS = _env_bool("SMTP_STARTTLS", "false")
SMTP_USE_TLS = _env_bool("SMTP_USE_TLS", "false")
SMTP_FROM = os.getenv("SMTP_FROM", "").strip() or f"postmaster@{MAILU_DOMAIN}"
SMTP_TIMEOUT_SEC = float(os.getenv("SMTP_TIMEOUT_SEC", "10"))
JELLYFIN_SYNC_URL = os.getenv("JELLYFIN_SYNC_URL", "").rstrip("/") JELLYFIN_SYNC_URL = os.getenv("JELLYFIN_SYNC_URL", "").rstrip("/")
JELLYFIN_LDAP_HOST = os.getenv("JELLYFIN_LDAP_HOST", "openldap.sso.svc.cluster.local").strip() JELLYFIN_LDAP_HOST = os.getenv("JELLYFIN_LDAP_HOST", "openldap.sso.svc.cluster.local").strip()
JELLYFIN_LDAP_PORT = int(os.getenv("JELLYFIN_LDAP_PORT", "389")) JELLYFIN_LDAP_PORT = int(os.getenv("JELLYFIN_LDAP_PORT", "389"))

View File

@ -30,6 +30,18 @@
</div> </div>
</div> </div>
<div v-if="status === 'pending_email_verification'" class="steps">
<h3>Confirm your email</h3>
<p class="muted">
Open the verification email from Atlas and click the link to confirm your address. After verification, an admin can approve your request.
</p>
<p class="muted">
If you did not receive an email, return to
<a href="/request-access">Request Access</a>
and submit again using a reachable external address.
</p>
</div>
<div v-if="status === 'pending'" class="steps"> <div v-if="status === 'pending'" class="steps">
<h3>Awaiting approval</h3> <h3>Awaiting approval</h3>
<p class="muted">An Atlas admin has to approve this request before an account can be provisioned.</p> <p class="muted">An Atlas admin has to approve this request before an account can be provisioned.</p>
@ -206,6 +218,7 @@ const copied = ref(false);
function statusLabel(value) { function statusLabel(value) {
const key = (value || "").trim(); const key = (value || "").trim();
if (key === "pending_email_verification") return "confirm email";
if (key === "pending") return "awaiting approval"; if (key === "pending") return "awaiting approval";
if (key === "accounts_building") return "accounts building"; if (key === "accounts_building") return "accounts building";
if (key === "awaiting_onboarding") return "awaiting onboarding"; if (key === "awaiting_onboarding") return "awaiting onboarding";
@ -216,6 +229,7 @@ function statusLabel(value) {
function statusPillClass(value) { function statusPillClass(value) {
const key = (value || "").trim(); const key = (value || "").trim();
if (key === "pending_email_verification") return "pill-warn";
if (key === "pending") return "pill-wait"; if (key === "pending") return "pill-wait";
if (key === "accounts_building") return "pill-warn"; if (key === "accounts_building") return "pill-warn";
if (key === "awaiting_onboarding") return "pill-ok"; if (key === "awaiting_onboarding") return "pill-ok";

View File

@ -19,7 +19,7 @@
</div> </div>
<p class="muted"> <p class="muted">
This creates a pending request in Atlas. If approved, check your request code for an onboarding link to complete setup. Requests require a verified external email so Keycloak can support account recovery. After verification, an admin can approve your account.
</p> </p>
<form class="form" @submit.prevent="submit" v-if="!submitted"> <form class="form" @submit.prevent="submit" v-if="!submitted">
@ -37,15 +37,17 @@
</label> </label>
<label class="field"> <label class="field">
<span class="label mono">Email (optional)</span> <span class="label mono">Email</span>
<input <input
v-model="form.email" v-model="form.email"
class="input mono" class="input mono"
type="email" type="email"
autocomplete="email" autocomplete="email"
placeholder="you@example.com (optional)" placeholder="you@example.com"
:disabled="submitting" :disabled="submitting"
required
/> />
<span class="hint mono">Must be an external address (not @{{ mailDomain }})</span>
</label> </label>
<label class="field"> <label class="field">
@ -70,7 +72,7 @@
<div v-else class="success-box"> <div v-else class="success-box">
<div class="mono">Request submitted.</div> <div class="mono">Request submitted.</div>
<div class="muted"> <div class="muted">
Save this request code. You can use it to check the status of your request. Save this request code. Check your email for a verification link, then use the code to track status.
</div> </div>
<div class="request-code-row"> <div class="request-code-row">
<span class="label mono">Request Code</span> <span class="label mono">Request Code</span>
@ -106,6 +108,10 @@
</button> </button>
</div> </div>
<div v-if="verifying" class="muted" style="margin-top: 10px;">
Verifying email
</div>
<div v-if="onboardingUrl" class="actions" style="margin-top: 12px;"> <div v-if="onboardingUrl" class="actions" style="margin-top: 12px;">
<a class="primary" :href="onboardingUrl">Continue onboarding</a> <a class="primary" :href="onboardingUrl">Continue onboarding</a>
</div> </div>
@ -119,10 +125,14 @@
</template> </template>
<script setup> <script setup>
import { reactive, ref } from "vue"; import { onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
function statusLabel(value) { function statusLabel(value) {
const key = (value || "").trim(); const key = (value || "").trim();
if (key === "pending_email_verification") return "confirm email";
if (key === "pending") return "awaiting approval"; if (key === "pending") return "awaiting approval";
if (key === "accounts_building") return "accounts building"; if (key === "accounts_building") return "accounts building";
if (key === "awaiting_onboarding") return "awaiting onboarding"; if (key === "awaiting_onboarding") return "awaiting onboarding";
@ -133,6 +143,7 @@ function statusLabel(value) {
function statusPillClass(value) { function statusPillClass(value) {
const key = (value || "").trim(); const key = (value || "").trim();
if (key === "pending_email_verification") return "pill-warn";
if (key === "pending") return "pill-wait"; if (key === "pending") return "pill-wait";
if (key === "accounts_building") return "pill-warn"; if (key === "accounts_building") return "pill-warn";
if (key === "awaiting_onboarding") return "pill-ok"; if (key === "awaiting_onboarding") return "pill-ok";
@ -152,6 +163,8 @@ const submitted = ref(false);
const error = ref(""); const error = ref("");
const requestCode = ref(""); const requestCode = ref("");
const copied = ref(false); const copied = ref(false);
const verifying = ref(false);
const mailDomain = import.meta.env?.VITE_MAILU_DOMAIN || "bstein.dev";
const statusForm = reactive({ const statusForm = reactive({
request_code: "", request_code: "",
@ -180,7 +193,7 @@ async function submit() {
submitted.value = true; submitted.value = true;
requestCode.value = data.request_code || ""; requestCode.value = data.request_code || "";
statusForm.request_code = requestCode.value; statusForm.request_code = requestCode.value;
status.value = "pending"; status.value = data.status || "pending_email_verification";
} catch (err) { } catch (err) {
error.value = err.message || "Failed to submit request"; error.value = err.message || "Failed to submit request";
} finally { } finally {
@ -244,6 +257,43 @@ async function checkStatus() {
checking.value = false; checking.value = false;
} }
} }
async function verifyFromLink(code, token) {
verifying.value = true;
try {
const resp = await fetch("/api/access/request/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
cache: "no-store",
body: JSON.stringify({ request_code: code, token }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
status.value = data.status || status.value;
} finally {
verifying.value = false;
}
}
onMounted(async () => {
const code = typeof route.query.code === "string" ? route.query.code.trim() : "";
const token = typeof route.query.verify === "string" ? route.query.verify.trim() : "";
if (code) {
requestCode.value = code;
statusForm.request_code = code;
submitted.value = true;
}
if (code && token) {
try {
await verifyFromLink(code, token);
} catch (err) {
error.value = err?.message || "Failed to verify email";
}
}
if (code) {
await checkStatus();
}
});
</script> </script>
<style scoped> <style scoped>