portal: add access request approvals
This commit is contained in:
parent
5a9a3b4f8b
commit
c22c27b8aa
281
backend/app.py
281
backend/app.py
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
@ -70,6 +71,13 @@ ACCOUNT_ALLOWED_GROUPS = [
|
||||
if g.strip()
|
||||
]
|
||||
|
||||
PORTAL_ADMIN_USERS = [u.strip() for u in os.getenv("PORTAL_ADMIN_USERS", "bstein").split(",") if u.strip()]
|
||||
PORTAL_ADMIN_GROUPS = [g.strip() for g in os.getenv("PORTAL_ADMIN_GROUPS", "admin").split(",") if g.strip()]
|
||||
|
||||
ACCESS_REQUEST_ENABLED = os.getenv("ACCESS_REQUEST_ENABLED", "true").lower() in ("1", "true", "yes")
|
||||
ACCESS_REQUEST_RATE_LIMIT = int(os.getenv("ACCESS_REQUEST_RATE_LIMIT", "5"))
|
||||
ACCESS_REQUEST_RATE_WINDOW_SEC = int(os.getenv("ACCESS_REQUEST_RATE_WINDOW_SEC", str(60 * 60)))
|
||||
|
||||
MAILU_DOMAIN = os.getenv("MAILU_DOMAIN", "bstein.dev")
|
||||
MAILU_SYNC_URL = os.getenv(
|
||||
"MAILU_SYNC_URL",
|
||||
@ -80,6 +88,8 @@ JELLYFIN_SYNC_URL = os.getenv("JELLYFIN_SYNC_URL", "").rstrip("/")
|
||||
|
||||
_KEYCLOAK_JWK_CLIENT: PyJWKClient | None = None
|
||||
_KEYCLOAK_ADMIN_TOKEN: dict[str, Any] = {"token": "", "expires_at": 0.0}
|
||||
_KEYCLOAK_GROUP_ID_CACHE: dict[str, str] = {}
|
||||
_ACCESS_REQUEST_RATE: dict[str, list[float]] = {}
|
||||
|
||||
_LAB_STATUS_CACHE: dict[str, Any] = {"ts": 0.0, "value": None}
|
||||
|
||||
@ -167,6 +177,20 @@ def require_auth(fn):
|
||||
return wrapper
|
||||
|
||||
|
||||
def _require_portal_admin() -> tuple[bool, Any]:
|
||||
if not KEYCLOAK_ENABLED:
|
||||
return False, (jsonify({"error": "keycloak not enabled"}), 503)
|
||||
|
||||
username = getattr(g, "keycloak_username", "") or ""
|
||||
groups = set(getattr(g, "keycloak_groups", []) or [])
|
||||
|
||||
if username and username in PORTAL_ADMIN_USERS:
|
||||
return True, None
|
||||
if PORTAL_ADMIN_GROUPS and groups.intersection(PORTAL_ADMIN_GROUPS):
|
||||
return True, None
|
||||
return False, (jsonify({"error": "forbidden"}), 403)
|
||||
|
||||
|
||||
@app.route("/api/auth/config", methods=["GET"])
|
||||
def auth_config() -> Any:
|
||||
if not KEYCLOAK_ENABLED:
|
||||
@ -334,6 +358,263 @@ def account_overview() -> Any:
|
||||
)
|
||||
|
||||
|
||||
def _rate_limit_allow(ip: str) -> bool:
|
||||
if ACCESS_REQUEST_RATE_LIMIT <= 0:
|
||||
return True
|
||||
now = time.time()
|
||||
window_start = now - ACCESS_REQUEST_RATE_WINDOW_SEC
|
||||
bucket = _ACCESS_REQUEST_RATE.setdefault(ip, [])
|
||||
bucket[:] = [t for t in bucket if t >= window_start]
|
||||
if len(bucket) >= ACCESS_REQUEST_RATE_LIMIT:
|
||||
return False
|
||||
bucket.append(now)
|
||||
return True
|
||||
|
||||
|
||||
def _kc_admin_create_user(payload: dict[str, Any]) -> str:
|
||||
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users"
|
||||
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
|
||||
resp = client.post(url, headers={**_kc_admin_headers(), "Content-Type": "application/json"}, json=payload)
|
||||
resp.raise_for_status()
|
||||
location = resp.headers.get("Location") or ""
|
||||
if location:
|
||||
return location.rstrip("/").split("/")[-1]
|
||||
raise RuntimeError("failed to determine created user id")
|
||||
|
||||
|
||||
def _kc_admin_delete_user(user_id: str) -> None:
|
||||
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}"
|
||||
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
|
||||
resp = client.delete(url, headers=_kc_admin_headers())
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def _kc_admin_get_group_id(group_name: str) -> str | None:
|
||||
cached = _KEYCLOAK_GROUP_ID_CACHE.get(group_name)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/groups"
|
||||
params = {"search": group_name}
|
||||
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
|
||||
resp = client.get(url, params=params, headers=_kc_admin_headers())
|
||||
resp.raise_for_status()
|
||||
items = resp.json()
|
||||
if not isinstance(items, list):
|
||||
return None
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("name") == group_name and item.get("id"):
|
||||
gid = str(item["id"])
|
||||
_KEYCLOAK_GROUP_ID_CACHE[group_name] = gid
|
||||
return gid
|
||||
return None
|
||||
|
||||
|
||||
def _kc_admin_add_user_to_group(user_id: str, group_id: str) -> None:
|
||||
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}/groups/{quote(group_id, safe='')}"
|
||||
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
|
||||
resp = client.put(url, headers=_kc_admin_headers())
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def _kc_admin_execute_actions_email(user_id: str, actions: list[str]) -> None:
|
||||
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}/execute-actions-email"
|
||||
params = {"client_id": KEYCLOAK_CLIENT_ID, "redirect_uri": request.host_url.rstrip("/") + "/"}
|
||||
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
|
||||
resp = client.put(
|
||||
url,
|
||||
params=params,
|
||||
headers={**_kc_admin_headers(), "Content-Type": "application/json"},
|
||||
json=actions,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def _extract_request_payload() -> tuple[str, str, str]:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
username = (payload.get("username") or "").strip()
|
||||
email = (payload.get("email") or "").strip()
|
||||
note = (payload.get("note") or "").strip()
|
||||
return username, email, note
|
||||
|
||||
|
||||
@app.route("/api/access/request", methods=["POST"])
|
||||
def request_access() -> Any:
|
||||
if not ACCESS_REQUEST_ENABLED:
|
||||
return jsonify({"error": "request access disabled"}), 503
|
||||
if not _kc_admin_ready():
|
||||
return jsonify({"error": "server not configured"}), 503
|
||||
|
||||
ip = request.remote_addr or "unknown"
|
||||
if not _rate_limit_allow(ip):
|
||||
return jsonify({"error": "rate limited"}), 429
|
||||
|
||||
username, email, note = _extract_request_payload()
|
||||
if not username or not email:
|
||||
return jsonify({"error": "username and email are 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
|
||||
|
||||
if _kc_admin_find_user(username):
|
||||
return jsonify({"error": "username already exists"}), 409
|
||||
|
||||
attrs: dict[str, Any] = {
|
||||
"access_request_status": ["pending"],
|
||||
"access_request_note": [note] if note else [""],
|
||||
"access_request_created_at": [str(int(time.time()))],
|
||||
}
|
||||
|
||||
user_payload = {
|
||||
"username": username,
|
||||
"enabled": False,
|
||||
"email": email,
|
||||
"emailVerified": False,
|
||||
"attributes": attrs,
|
||||
}
|
||||
try:
|
||||
user_id = _kc_admin_create_user(user_payload)
|
||||
except Exception:
|
||||
return jsonify({"error": "failed to submit request"}), 502
|
||||
|
||||
return jsonify({"ok": True, "id": user_id})
|
||||
|
||||
|
||||
def _kc_admin_list_pending_requests(limit: int = 100) -> list[dict[str, Any]]:
|
||||
def is_pending(user: dict[str, Any]) -> bool:
|
||||
attrs = user.get("attributes") or {}
|
||||
if not isinstance(attrs, dict):
|
||||
return False
|
||||
status = attrs.get("access_request_status")
|
||||
if isinstance(status, list) and status:
|
||||
return str(status[0]) == "pending"
|
||||
if isinstance(status, str):
|
||||
return status == "pending"
|
||||
return False
|
||||
|
||||
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users"
|
||||
|
||||
# Prefer server-side search (q=) if supported by this Keycloak version.
|
||||
# Always filter client-side for correctness.
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for params in (
|
||||
{"max": str(limit), "enabled": "false", "q": "access_request_status:pending"},
|
||||
{"max": str(limit), "enabled": "false"},
|
||||
{"max": str(limit)},
|
||||
):
|
||||
try:
|
||||
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
|
||||
resp = client.get(url, params=params, headers=_kc_admin_headers())
|
||||
resp.raise_for_status()
|
||||
users = resp.json()
|
||||
if not isinstance(users, list):
|
||||
continue
|
||||
candidates = [u for u in users if isinstance(u, dict)]
|
||||
break
|
||||
except httpx.HTTPStatusError:
|
||||
continue
|
||||
|
||||
pending = [u for u in candidates if is_pending(u)]
|
||||
return pending[:limit]
|
||||
|
||||
|
||||
@app.route("/api/admin/access/requests", methods=["GET"])
|
||||
@require_auth
|
||||
def admin_list_requests() -> Any:
|
||||
ok, resp = _require_portal_admin()
|
||||
if not ok:
|
||||
return resp
|
||||
if not _kc_admin_ready():
|
||||
return jsonify({"error": "server not configured"}), 503
|
||||
|
||||
try:
|
||||
items = _kc_admin_list_pending_requests()
|
||||
except Exception:
|
||||
return jsonify({"error": "failed to load requests"}), 502
|
||||
|
||||
output: list[dict[str, Any]] = []
|
||||
for user in items:
|
||||
attrs = user.get("attributes") or {}
|
||||
if not isinstance(attrs, dict):
|
||||
attrs = {}
|
||||
output.append(
|
||||
{
|
||||
"id": user.get("id") or "",
|
||||
"username": user.get("username") or "",
|
||||
"email": user.get("email") or "",
|
||||
"created_at": (attrs.get("access_request_created_at") or [""])[0],
|
||||
"note": (attrs.get("access_request_note") or [""])[0],
|
||||
}
|
||||
)
|
||||
return jsonify({"requests": output})
|
||||
|
||||
|
||||
@app.route("/api/admin/access/requests/<username>/approve", methods=["POST"])
|
||||
@require_auth
|
||||
def admin_approve_request(username: str) -> Any:
|
||||
ok, resp = _require_portal_admin()
|
||||
if not ok:
|
||||
return resp
|
||||
if not _kc_admin_ready():
|
||||
return jsonify({"error": "server not configured"}), 503
|
||||
|
||||
user = _kc_admin_find_user(username)
|
||||
if not user:
|
||||
return jsonify({"error": "user not found"}), 404
|
||||
user_id = user.get("id") or ""
|
||||
if not user_id:
|
||||
return jsonify({"error": "user id missing"}), 502
|
||||
|
||||
full = _kc_admin_get_user(user_id)
|
||||
full["enabled"] = True
|
||||
try:
|
||||
_kc_admin_update_user(user_id, full)
|
||||
except Exception:
|
||||
return jsonify({"error": "failed to enable user"}), 502
|
||||
|
||||
group_id = _kc_admin_get_group_id("dev")
|
||||
if group_id:
|
||||
try:
|
||||
_kc_admin_add_user_to_group(user_id, group_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
_kc_admin_execute_actions_email(user_id, ["UPDATE_PASSWORD"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/api/admin/access/requests/<username>/deny", methods=["POST"])
|
||||
@require_auth
|
||||
def admin_deny_request(username: str) -> Any:
|
||||
ok, resp = _require_portal_admin()
|
||||
if not ok:
|
||||
return resp
|
||||
if not _kc_admin_ready():
|
||||
return jsonify({"error": "server not configured"}), 503
|
||||
|
||||
user = _kc_admin_find_user(username)
|
||||
if not user:
|
||||
return jsonify({"ok": True})
|
||||
user_id = user.get("id") or ""
|
||||
if not user_id:
|
||||
return jsonify({"error": "user id missing"}), 502
|
||||
|
||||
try:
|
||||
_kc_admin_delete_user(user_id)
|
||||
except Exception:
|
||||
return jsonify({"error": "failed to delete user"}), 502
|
||||
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/api/account/mailu/rotate", methods=["POST"])
|
||||
@require_auth
|
||||
def account_mailu_rotate() -> Any:
|
||||
|
||||
@ -15,8 +15,9 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="auth.ready && auth.authenticated" class="grid two">
|
||||
<div class="card module">
|
||||
<section v-if="auth.ready && auth.authenticated">
|
||||
<div class="grid two">
|
||||
<div class="card module">
|
||||
<div class="module-head">
|
||||
<h2>Mail</h2>
|
||||
<span class="pill mono">{{ mailu.status }}</span>
|
||||
@ -59,7 +60,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card module">
|
||||
<div class="card module">
|
||||
<div class="module-head">
|
||||
<h2>Jellyfin</h2>
|
||||
<span class="pill mono">{{ jellyfin.status }}</span>
|
||||
@ -94,6 +95,43 @@
|
||||
<div class="mono">{{ jellyfin.error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="admin.enabled" class="card module admin">
|
||||
<div class="module-head">
|
||||
<h2>Admin Approvals</h2>
|
||||
<span class="pill mono">{{ admin.loading ? "loading..." : `${admin.requests.length} pending` }}</span>
|
||||
</div>
|
||||
<p class="muted">Approve or deny pending access requests.</p>
|
||||
|
||||
<div v-if="admin.error" class="error-box">
|
||||
<div class="mono">{{ admin.error }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!admin.loading && admin.requests.length === 0" class="muted">No pending requests.</div>
|
||||
|
||||
<div v-else class="requests">
|
||||
<div class="req-head mono">
|
||||
<span>User</span>
|
||||
<span>Email</span>
|
||||
<span>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-actions">
|
||||
<button class="primary" type="button" :disabled="admin.acting[req.username]" @click="approve(req.username)">
|
||||
approve
|
||||
</button>
|
||||
<button class="pill mono" type="button" :disabled="admin.acting[req.username]" @click="deny(req.username)">
|
||||
deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else class="card module">
|
||||
@ -128,6 +166,14 @@ const jellyfin = reactive({
|
||||
error: "",
|
||||
});
|
||||
|
||||
const admin = reactive({
|
||||
enabled: false,
|
||||
loading: false,
|
||||
requests: [],
|
||||
error: "",
|
||||
acting: {},
|
||||
});
|
||||
|
||||
const doLogin = () => login("/account");
|
||||
|
||||
onMounted(async () => {
|
||||
@ -138,6 +184,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
await refreshOverview();
|
||||
await refreshAdminRequests();
|
||||
});
|
||||
|
||||
async function refreshOverview() {
|
||||
@ -157,6 +204,29 @@ async function refreshOverview() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAdminRequests() {
|
||||
admin.error = "";
|
||||
admin.loading = true;
|
||||
try {
|
||||
const resp = await authFetch("/api/admin/access/requests", { headers: { Accept: "application/json" } });
|
||||
if (resp.status === 403) {
|
||||
admin.enabled = false;
|
||||
admin.requests = [];
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) throw new Error(`status ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
admin.enabled = true;
|
||||
admin.requests = Array.isArray(data.requests) ? data.requests : [];
|
||||
} catch (err) {
|
||||
admin.enabled = false;
|
||||
admin.requests = [];
|
||||
admin.error = err.message || "Failed to load access requests.";
|
||||
} finally {
|
||||
admin.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateMailu() {
|
||||
mailu.error = "";
|
||||
mailu.newPassword = "";
|
||||
@ -191,6 +261,40 @@ async function rotateJellyfin() {
|
||||
}
|
||||
}
|
||||
|
||||
async function approve(username) {
|
||||
admin.error = "";
|
||||
admin.acting[username] = true;
|
||||
try {
|
||||
const resp = await authFetch(`/api/admin/access/requests/${encodeURIComponent(username)}/approve`, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
throw new Error(data.error || `status ${resp.status}`);
|
||||
}
|
||||
await refreshAdminRequests();
|
||||
} catch (err) {
|
||||
admin.error = err.message || "Approve failed";
|
||||
} finally {
|
||||
admin.acting[username] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deny(username) {
|
||||
admin.error = "";
|
||||
admin.acting[username] = true;
|
||||
try {
|
||||
const resp = await authFetch(`/api/admin/access/requests/${encodeURIComponent(username)}/deny`, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
throw new Error(data.error || `status ${resp.status}`);
|
||||
}
|
||||
await refreshAdminRequests();
|
||||
} catch (err) {
|
||||
admin.error = err.message || "Deny failed";
|
||||
} finally {
|
||||
admin.acting[username] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(text) {
|
||||
if (!text) return;
|
||||
try {
|
||||
@ -357,4 +461,68 @@ button.primary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.admin {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.requests {
|
||||
margin-top: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.req-head {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 220px 1fr 140px;
|
||||
gap: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.req-row {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 220px 1fr 140px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.note {
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.req-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.req-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.req-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.note {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.req-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -4,20 +4,120 @@
|
||||
<div>
|
||||
<p class="eyebrow">Atlas</p>
|
||||
<h1>Request Access</h1>
|
||||
<p class="lede">Self-serve signups are not enabled yet. Request access and an admin can approve your account.</p>
|
||||
<p class="lede">
|
||||
Self-serve signups are not enabled yet. Request access and an admin can approve your account.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card module">
|
||||
<h2>Not live yet</h2>
|
||||
<div class="module-head">
|
||||
<h2>Request form</h2>
|
||||
<span class="pill mono" :class="submitted ? 'pill-ok' : 'pill-warn'">
|
||||
{{ submitted ? "submitted" : "pending" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="muted">
|
||||
This flow is being wired into Keycloak approvals. For now, email <span class="mono">brad@bstein.dev</span> with the
|
||||
username you want and a short note about what you need access to.
|
||||
This creates a pending request in Atlas. If approved, you'll receive an email with next steps.
|
||||
</p>
|
||||
|
||||
<form class="form" @submit.prevent="submit" v-if="!submitted">
|
||||
<label class="field">
|
||||
<span class="label mono">Desired Username</span>
|
||||
<input
|
||||
v-model="form.username"
|
||||
class="input mono"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
placeholder="e.g. alice"
|
||||
:disabled="submitting"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label mono">Email</span>
|
||||
<input
|
||||
v-model="form.email"
|
||||
class="input mono"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
placeholder="you@example.com"
|
||||
:disabled="submitting"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label mono">Note (optional)</span>
|
||||
<textarea
|
||||
v-model="form.note"
|
||||
class="textarea"
|
||||
rows="4"
|
||||
placeholder="What do you want access to?"
|
||||
:disabled="submitting"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary" type="submit" :disabled="submitting || !form.username.trim() || !form.email.trim()">
|
||||
{{ submitting ? "Submitting..." : "Submit request" }}
|
||||
</button>
|
||||
<span class="hint mono">Requests are rate-limited.</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-else class="success-box">
|
||||
<div class="mono">Request submitted.</div>
|
||||
<div class="muted">If approved, you'll get an email to finish setup.</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-box">
|
||||
<div class="mono">{{ error }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
const form = reactive({
|
||||
username: "",
|
||||
email: "",
|
||||
note: "",
|
||||
});
|
||||
|
||||
const submitting = ref(false);
|
||||
const submitted = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value) return;
|
||||
error.value = "";
|
||||
submitting.value = true;
|
||||
try {
|
||||
const resp = await fetch("/api/access/request", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: form.username.trim(),
|
||||
email: form.email.trim(),
|
||||
note: form.note.trim(),
|
||||
}),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
|
||||
submitted.value = true;
|
||||
} catch (err) {
|
||||
error.value = err.message || "Failed to submit request";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 960px;
|
||||
@ -56,6 +156,13 @@ h1 {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.module-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
margin: 10px 0 0;
|
||||
@ -64,5 +171,81 @@ h1 {
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
</style>
|
||||
|
||||
.form {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
margin-top: 14px;
|
||||
border: 1px solid rgba(255, 120, 120, 0.35);
|
||||
background: rgba(255, 64, 64, 0.12);
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.success-box {
|
||||
margin-top: 14px;
|
||||
border: 1px solid rgba(120, 255, 160, 0.25);
|
||||
background: rgba(48, 255, 160, 0.1);
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.pill-ok {
|
||||
border-color: rgba(120, 255, 160, 0.3);
|
||||
}
|
||||
|
||||
.pill-warn {
|
||||
border-color: rgba(255, 220, 120, 0.25);
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user