portal: gate requests on verified email
This commit is contained in:
parent
2c2f0b04d9
commit
13b0099c29
@ -33,6 +33,9 @@ def ensure_schema() -> None:
|
||||
contact_email TEXT,
|
||||
note TEXT,
|
||||
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(),
|
||||
decided_at TIMESTAMPTZ,
|
||||
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_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(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS access_request_tasks (
|
||||
|
||||
53
backend/atlas_portal/mailer.py
Normal file
53
backend/atlas_portal/mailer.py
Normal 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.",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
@ -12,6 +12,7 @@ from .utils import random_password
|
||||
from .vaultwarden import invite_user
|
||||
|
||||
|
||||
MAILU_EMAIL_ATTR = "mailu_email"
|
||||
MAILU_APP_PASSWORD_ATTR = "mailu_app_password"
|
||||
REQUIRED_PROVISION_TASKS: tuple[str, ...] = (
|
||||
"keycloak_user",
|
||||
@ -78,7 +79,12 @@ def provision_access_request(request_code: str) -> ProvisionResult:
|
||||
with connect() as conn:
|
||||
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
|
||||
WHERE request_code = %s
|
||||
""",
|
||||
@ -89,6 +95,7 @@ def provision_access_request(request_code: str) -> ProvisionResult:
|
||||
|
||||
username = str(row.get("username") or "")
|
||||
contact_email = str(row.get("contact_email") or "")
|
||||
email_verified_at = row.get("email_verified_at")
|
||||
status = str(row.get("status") or "")
|
||||
initial_password = row.get("initial_password")
|
||||
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")
|
||||
|
||||
user_id = ""
|
||||
mailu_email = f"{username}@{settings.MAILU_DOMAIN}"
|
||||
|
||||
# Task: ensure Keycloak user exists
|
||||
try:
|
||||
user = admin_client().find_user(username)
|
||||
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 = {
|
||||
"username": username,
|
||||
"enabled": True,
|
||||
"email": email,
|
||||
"emailVerified": False,
|
||||
"emailVerified": bool(email_verified_at),
|
||||
"requiredActions": ["CONFIGURE_TOTP"],
|
||||
"attributes": {MAILU_EMAIL_ATTR: [mailu_email]},
|
||||
}
|
||||
created_id = admin_client().create_user(payload)
|
||||
user = admin_client().get_user(created_id)
|
||||
user_id = str((user or {}).get("id") or "")
|
||||
if not user_id:
|
||||
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)
|
||||
except Exception:
|
||||
_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)
|
||||
try:
|
||||
if user_id:
|
||||
full = admin_client().get_user(user_id)
|
||||
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)
|
||||
result = invite_user(mailu_email or f"{username}@{settings.MAILU_DOMAIN}")
|
||||
if result.ok:
|
||||
_upsert_task(conn, request_code, "vaultwarden_invite", "ok", result.status)
|
||||
else:
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import jsonify, request, g
|
||||
|
||||
@ -11,6 +15,7 @@ import psycopg
|
||||
|
||||
from ..db import connect, configured
|
||||
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 ..provisioning import provision_tasks_complete
|
||||
from .. import settings
|
||||
@ -39,6 +44,18 @@ def _client_ip() -> str:
|
||||
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, ...] = (
|
||||
"keycloak_password_changed",
|
||||
"keycloak_mfa_configured",
|
||||
@ -207,8 +224,12 @@ def register(app) -> None:
|
||||
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 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
|
||||
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):
|
||||
return jsonify({"error": "username already exists"}), 409
|
||||
@ -219,25 +240,60 @@ def register(app) -> None:
|
||||
"""
|
||||
SELECT request_code, status
|
||||
FROM access_requests
|
||||
WHERE username = %s AND status = 'pending'
|
||||
WHERE username = %s AND status IN (%s, 'pending')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(username,),
|
||||
(username, EMAIL_VERIFY_PENDING_STATUS),
|
||||
).fetchone()
|
||||
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)
|
||||
token = secrets.token_urlsafe(24)
|
||||
token_hash = _hash_verification_token(token)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
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
|
||||
(%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:
|
||||
conn.rollback()
|
||||
@ -245,19 +301,109 @@ def register(app) -> None:
|
||||
"""
|
||||
SELECT request_code, status
|
||||
FROM access_requests
|
||||
WHERE username = %s AND status = 'pending'
|
||||
WHERE username = %s AND status IN (%s, 'pending')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(username,),
|
||||
(username, EMAIL_VERIFY_PENDING_STATUS),
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise
|
||||
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:
|
||||
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"])
|
||||
def request_access_status() -> Any:
|
||||
|
||||
@ -32,6 +32,7 @@ def register(app) -> None:
|
||||
|
||||
username = g.keycloak_username
|
||||
keycloak_email = g.keycloak_email or ""
|
||||
mailu_email = ""
|
||||
mailu_app_password = ""
|
||||
mailu_status = "ready"
|
||||
jellyfin_status = "ready"
|
||||
@ -54,6 +55,11 @@ def register(app) -> None:
|
||||
|
||||
attrs = user.get("attributes") if isinstance(user, dict) else None
|
||||
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")
|
||||
if isinstance(raw_pw, list) and raw_pw:
|
||||
mailu_app_password = str(raw_pw[0])
|
||||
@ -61,13 +67,20 @@ def register(app) -> None:
|
||||
mailu_app_password = raw_pw
|
||||
|
||||
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))
|
||||
if not keycloak_email:
|
||||
keycloak_email = str(full.get("email") or "")
|
||||
if not mailu_app_password:
|
||||
attrs = full.get("attributes") or {}
|
||||
if isinstance(attrs, dict):
|
||||
attrs = full.get("attributes") or {}
|
||||
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")
|
||||
if isinstance(raw_pw, list) and raw_pw:
|
||||
mailu_app_password = str(raw_pw[0])
|
||||
@ -79,11 +92,7 @@ def register(app) -> None:
|
||||
jellyfin_sync_status = "unknown"
|
||||
jellyfin_sync_detail = "unavailable"
|
||||
|
||||
mailu_username = ""
|
||||
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}"
|
||||
mailu_username = mailu_email or (f"{username}@{settings.MAILU_DOMAIN}" if username else "")
|
||||
|
||||
if not mailu_app_password and mailu_status == "ready":
|
||||
mailu_status = "needs app password"
|
||||
|
||||
@ -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_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_SYNC_URL = os.getenv(
|
||||
@ -78,6 +81,15 @@ MAILU_SYNC_URL = os.getenv(
|
||||
"http://mailu-sync-listener.mailu-mailserver.svc.cluster.local:8080/events",
|
||||
).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_LDAP_HOST = os.getenv("JELLYFIN_LDAP_HOST", "openldap.sso.svc.cluster.local").strip()
|
||||
JELLYFIN_LDAP_PORT = int(os.getenv("JELLYFIN_LDAP_PORT", "389"))
|
||||
|
||||
@ -30,6 +30,18 @@
|
||||
</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">
|
||||
<h3>Awaiting approval</h3>
|
||||
<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) {
|
||||
const key = (value || "").trim();
|
||||
if (key === "pending_email_verification") return "confirm email";
|
||||
if (key === "pending") return "awaiting approval";
|
||||
if (key === "accounts_building") return "accounts building";
|
||||
if (key === "awaiting_onboarding") return "awaiting onboarding";
|
||||
@ -216,6 +229,7 @@ function statusLabel(value) {
|
||||
|
||||
function statusPillClass(value) {
|
||||
const key = (value || "").trim();
|
||||
if (key === "pending_email_verification") return "pill-warn";
|
||||
if (key === "pending") return "pill-wait";
|
||||
if (key === "accounts_building") return "pill-warn";
|
||||
if (key === "awaiting_onboarding") return "pill-ok";
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<form class="form" @submit.prevent="submit" v-if="!submitted">
|
||||
@ -37,15 +37,17 @@
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label mono">Email (optional)</span>
|
||||
<span class="label mono">Email</span>
|
||||
<input
|
||||
v-model="form.email"
|
||||
class="input mono"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
placeholder="you@example.com (optional)"
|
||||
placeholder="you@example.com"
|
||||
:disabled="submitting"
|
||||
required
|
||||
/>
|
||||
<span class="hint mono">Must be an external address (not @{{ mailDomain }})</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
@ -70,7 +72,7 @@
|
||||
<div v-else class="success-box">
|
||||
<div class="mono">Request submitted.</div>
|
||||
<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 class="request-code-row">
|
||||
<span class="label mono">Request Code</span>
|
||||
@ -106,6 +108,10 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="verifying" class="muted" style="margin-top: 10px;">
|
||||
Verifying email…
|
||||
</div>
|
||||
|
||||
<div v-if="onboardingUrl" class="actions" style="margin-top: 12px;">
|
||||
<a class="primary" :href="onboardingUrl">Continue onboarding</a>
|
||||
</div>
|
||||
@ -119,10 +125,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from "vue";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
function statusLabel(value) {
|
||||
const key = (value || "").trim();
|
||||
if (key === "pending_email_verification") return "confirm email";
|
||||
if (key === "pending") return "awaiting approval";
|
||||
if (key === "accounts_building") return "accounts building";
|
||||
if (key === "awaiting_onboarding") return "awaiting onboarding";
|
||||
@ -133,6 +143,7 @@ function statusLabel(value) {
|
||||
|
||||
function statusPillClass(value) {
|
||||
const key = (value || "").trim();
|
||||
if (key === "pending_email_verification") return "pill-warn";
|
||||
if (key === "pending") return "pill-wait";
|
||||
if (key === "accounts_building") return "pill-warn";
|
||||
if (key === "awaiting_onboarding") return "pill-ok";
|
||||
@ -152,6 +163,8 @@ const submitted = ref(false);
|
||||
const error = ref("");
|
||||
const requestCode = ref("");
|
||||
const copied = ref(false);
|
||||
const verifying = ref(false);
|
||||
const mailDomain = import.meta.env?.VITE_MAILU_DOMAIN || "bstein.dev";
|
||||
|
||||
const statusForm = reactive({
|
||||
request_code: "",
|
||||
@ -180,7 +193,7 @@ async function submit() {
|
||||
submitted.value = true;
|
||||
requestCode.value = data.request_code || "";
|
||||
statusForm.request_code = requestCode.value;
|
||||
status.value = "pending";
|
||||
status.value = data.status || "pending_email_verification";
|
||||
} catch (err) {
|
||||
error.value = err.message || "Failed to submit request";
|
||||
} finally {
|
||||
@ -244,6 +257,43 @@ async function checkStatus() {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user