bstein-dev-home/frontend/src/views/RequestAccessView.vue

706 lines
18 KiB
Vue
Raw Normal View History

<template>
<div class="page">
<section class="card hero glass">
<div>
<p class="eyebrow">Atlas</p>
<h1>Request Access</h1>
2026-01-01 22:14:15 -03:00
<p class="lede">
Request access to Atlas. Approved accounts are provisioned from this form only.
2026-01-01 22:14:15 -03:00
</p>
</div>
</section>
<section class="card module">
2026-01-01 22:14:15 -03:00
<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">
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>
2026-01-01 22:14:15 -03:00
<form class="form" @submit.prevent="submit" v-if="!submitted">
<label class="field">
<span class="label mono">Lab Name (username)</span>
2026-01-01 22:14:15 -03:00
<input
v-model="form.username"
class="input mono"
type="text"
autocomplete="username"
placeholder="e.g. alice"
: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>
2026-01-01 22:14:15 -03:00
</label>
<label class="field">
<span class="label mono">Email</span>
2026-01-01 22:14:15 -03:00
<input
v-model="form.email"
class="input mono"
type="email"
autocomplete="email"
placeholder="you@example.com"
2026-01-01 22:14:15 -03:00
:disabled="submitting"
required
2026-01-01 22:14:15 -03:00
/>
<span class="hint mono">Must be an external address (not @{{ mailDomain }})</span>
2026-01-01 22:14:15 -03:00
</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() || availability.blockSubmit">
2026-01-01 22:14:15 -03:00
{{ 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">
2026-01-06 13:55:24 -03:00
Save this request code. Check your email for a verification link, then use the code to track status. Once approved,
your status will provide an onboarding link to finish account setup.
</div>
<div class="request-code-row">
<span class="label mono">Request Code</span>
<button class="copy mono" type="button" @click="copyRequestCode">
{{ requestCode }}
<span v-if="copied" class="copied">copied</span>
</button>
</div>
</div>
<div class="card module status-module">
<div class="module-head">
<h2>Check status</h2>
<span class="pill mono" :class="statusPillClass(status)">
{{ statusLabel(status) }}
</span>
</div>
<p class="muted">
Enter your request code to see whether it is awaiting approval, building accounts, awaiting onboarding, ready, or rejected.
</p>
<div class="status-form">
<input
v-model="statusForm.request_code"
class="input mono"
type="text"
placeholder="username~XXXXXXXXXX"
:disabled="checking"
/>
<button class="primary" type="button" @click="checkStatus" :disabled="checking || !statusForm.request_code.trim()">
{{ checking ? "Checking..." : "Check" }}
</button>
</div>
2026-01-02 01:34:18 -03:00
<div v-if="verifying" class="muted" style="margin-top: 10px;">
Verifying email
</div>
<div v-if="tasks.length" class="task-box">
<div class="module-head" style="margin-bottom: 10px;">
<h2>Automation</h2>
<span class="pill mono" :class="blocked ? 'pill-bad' : 'pill-ok'">
{{ blocked ? "blocked" : "running" }}
</span>
</div>
<ul class="task-list">
<li v-for="item in tasks" :key="item.task" class="task-row">
<span class="mono task-name">{{ item.task }}</span>
<span class="pill mono" :class="taskPillClass(item.status)">{{ item.status }}</span>
<span v-if="item.detail" class="mono task-detail">{{ item.detail }}</span>
</li>
</ul>
2026-01-03 05:10:04 -03:00
<p v-if="blocked" class="muted" style="margin-top: 10px;">
One or more automation steps failed. Fix the error above, then check again.
</p>
</div>
2026-01-04 08:44:25 -03:00
<div
v-if="onboardingUrl && (status === 'awaiting_onboarding' || status === 'ready')"
class="actions onboarding-actions"
>
2026-01-05 02:28:15 -03:00
<div class="onboarding-copy">
<p class="muted" style="margin: 0;">
Your accounts are ready. Continue onboarding to finish setup.
</p>
</div>
2026-01-04 08:44:25 -03:00
<a class="primary onboarding-cta" :href="onboardingUrl">Continue onboarding</a>
</div>
2026-01-01 22:14:15 -03:00
</div>
<div v-if="error" class="error-box">
<div class="mono">{{ error }}</div>
</div>
</section>
</div>
</template>
2026-01-01 22:14:15 -03:00
<script setup>
import { onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
2026-01-01 22:14:15 -03:00
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";
if (key === "ready") return "ready";
if (key === "denied") return "rejected";
return key || "unknown";
}
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";
if (key === "ready") return "pill-info";
if (key === "denied") return "pill-bad";
return "pill-warn";
}
2026-01-01 22:14:15 -03:00
const form = reactive({
username: "",
email: "",
note: "",
});
const submitting = ref(false);
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 availability = reactive({
label: "",
detail: "",
pillClass: "",
checking: false,
blockSubmit: false,
});
let availabilityTimer = 0;
let availabilityToken = 0;
const statusForm = reactive({
request_code: "",
});
const checking = ref(false);
const status = ref("");
2026-01-02 01:34:18 -03:00
const onboardingUrl = ref("");
const tasks = ref([]);
const blocked = ref(false);
function taskPillClass(status) {
const key = (status || "").trim();
if (key === "ok") return "pill-ok";
if (key === "error") return "pill-bad";
if (key === "pending") return "pill-warn";
return "pill-warn";
}
2026-01-01 22:14:15 -03:00
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;
}
}
2026-01-01 22:14:15 -03:00
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" },
cache: "no-store",
2026-01-01 22:14:15 -03:00
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;
requestCode.value = data.request_code || "";
statusForm.request_code = requestCode.value;
status.value = data.status || "pending_email_verification";
2026-01-01 22:14:15 -03:00
} catch (err) {
error.value = err.message || "Failed to submit request";
} finally {
submitting.value = false;
}
}
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 {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(requestCode.value);
} else {
const textarea = document.createElement("textarea");
textarea.value = requestCode.value;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "-9999px";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
document.execCommand("copy");
document.body.removeChild(textarea);
}
copied.value = true;
setTimeout(() => (copied.value = false), 1500);
} catch (err) {
error.value = err?.message || "Failed to copy request code";
}
}
async function checkStatus() {
if (checking.value) return;
error.value = "";
const trimmed = statusForm.request_code.trim();
if (!trimmed) return;
if (!trimmed.includes("~")) {
error.value = "Request code should look like username~XXXXXXXXXX. Copy it from the submit step.";
status.value = "unknown";
onboardingUrl.value = "";
tasks.value = [];
blocked.value = false;
return;
}
checking.value = true;
try {
const resp = await fetch("/api/access/request/status", {
method: "POST",
headers: { "Content-Type": "application/json" },
cache: "no-store",
body: JSON.stringify({ request_code: trimmed }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
status.value = data.status || "unknown";
2026-01-02 01:34:18 -03:00
onboardingUrl.value = data.onboarding_url || "";
tasks.value = Array.isArray(data.tasks) ? data.tasks : [];
blocked.value = Boolean(data.blocked);
} catch (err) {
error.value = err.message || "Failed to check status";
status.value = "unknown";
onboardingUrl.value = "";
tasks.value = [];
blocked.value = false;
} finally {
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();
}
});
2026-01-01 22:14:15 -03:00
</script>
<style scoped>
.page {
max-width: 960px;
margin: 0 auto;
padding: 32px 22px 72px;
}
.hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 12px;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin: 0 0 6px;
font-size: 13px;
}
h1 {
margin: 0 0 6px;
font-size: 32px;
}
.lede {
margin: 0;
color: var(--text-muted);
max-width: 640px;
}
.module {
padding: 18px;
}
.status-module {
margin-top: 14px;
}
2026-01-01 22:14:15 -03:00
.module-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.muted {
color: var(--text-muted);
margin: 10px 0 0;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
2026-01-01 22:14:15 -03:00
.form {
margin-top: 14px;
display: grid;
gap: 12px;
}
.field {
display: grid;
gap: 6px;
}
.availability {
display: flex;
align-items: center;
gap: 8px;
}
2026-01-01 22:14:15 -03:00
.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;
}
2026-01-05 02:28:15 -03:00
button.primary,
a.primary {
background: linear-gradient(90deg, #4f8bff, #7dd0ff);
color: #0b1222;
padding: 10px 14px;
border: none;
border-radius: 10px;
cursor: pointer;
font-weight: 700;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
button.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
2026-01-04 08:44:25 -03:00
.onboarding-actions {
2026-01-05 02:28:15 -03:00
margin-top: 18px;
flex-direction: column;
align-items: stretch;
padding: 14px;
border-radius: 14px;
border: 1px solid rgba(120, 180, 255, 0.2);
background: rgba(0, 0, 0, 0.24);
}
.onboarding-copy {
display: grid;
gap: 6px;
2026-01-04 08:44:25 -03:00
}
.onboarding-cta {
text-align: center;
2026-01-05 02:28:15 -03:00
width: 100%;
2026-01-04 08:44:25 -03:00
}
.status-form {
display: flex;
gap: 10px;
margin-top: 12px;
}
2026-01-01 22:14:15 -03:00
.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;
}
.request-code-row {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.copy {
display: inline-flex;
align-items: center;
gap: 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.22);
color: var(--text);
padding: 10px 12px;
cursor: pointer;
}
.copied {
font-size: 12px;
color: rgba(120, 255, 160, 0.9);
}
2026-01-01 22:14:15 -03:00
.pill {
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
}
</style>
<style scoped>
.task-box {
margin-top: 14px;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
background: rgba(0, 0, 0, 0.25);
}
.task-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 10px;
}
.task-row {
display: grid;
gap: 6px;
grid-template-columns: 1fr auto;
align-items: center;
}
.task-name {
color: var(--text);
}
.task-detail {
grid-column: 1 / -1;
color: var(--text-muted);
font-size: 12px;
}
</style>