diff --git a/frontend/src/request-access/useRequestAccessFlow.js b/frontend/src/request-access/useRequestAccessFlow.js
new file mode 100644
index 0000000..8f8fb1e
--- /dev/null
+++ b/frontend/src/request-access/useRequestAccessFlow.js
@@ -0,0 +1,435 @@
+import { onMounted, reactive, ref, watch } from "vue";
+
+/**
+ * Build the Request Access page state machine.
+ *
+ * WHY: the view combines form submission, verification-link handling,
+ * provisioning retry, and status polling; keeping that orchestration in a
+ * composable makes the SFC small and gives the behavior a testable seam.
+ *
+ * @param {import("vue-router").RouteLocationNormalizedLoaded} route - active route with optional verification query params.
+ * @returns {object} reactive state and event handlers used by the view template.
+ */
+export function useRequestAccessFlow(route) {
+ 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";
+ }
+
+ const form = reactive({
+ username: "",
+ first_name: "",
+ last_name: "",
+ 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("");
+ const onboardingUrl = ref("");
+ const tasks = ref([]);
+ const blocked = ref(false);
+ const retrying = ref(false);
+ const retryMessage = ref("");
+ const resending = ref(false);
+ const resendMessage = ref("");
+ const verifyBanner = ref(null);
+
+ 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";
+ }
+
+ function resetAvailability() {
+ availability.label = "";
+ availability.detail = "";
+ availability.pillClass = "";
+ availability.blockSubmit = false;
+ }
+
+ function setAvailability(state, detail = "") {
+ availability.detail = detail;
+ availability.blockSubmit = false;
+ if (state === "checking") {
+ availability.label = "checking";
+ availability.pillClass = "pill-warn";
+ return;
+ }
+ if (state === "available") {
+ availability.label = "available";
+ availability.pillClass = "pill-ok";
+ return;
+ }
+ if (state === "invalid") {
+ availability.label = "invalid";
+ availability.pillClass = "pill-bad";
+ availability.blockSubmit = true;
+ return;
+ }
+ if (state === "requested") {
+ availability.label = "requested";
+ availability.pillClass = "pill-warn";
+ availability.blockSubmit = true;
+ return;
+ }
+ if (state === "exists") {
+ availability.label = "taken";
+ availability.pillClass = "pill-bad";
+ availability.blockSubmit = true;
+ return;
+ }
+ if (state === "error") {
+ availability.label = "error";
+ availability.pillClass = "pill-warn";
+ return;
+ }
+ resetAvailability();
+ }
+
+ async function checkAvailability(name) {
+ const token = (availabilityToken += 1);
+ setAvailability("checking");
+ availability.checking = true;
+ try {
+ const resp = await fetch(`/api/access/request/availability?username=${encodeURIComponent(name)}`, {
+ headers: { Accept: "application/json" },
+ cache: "no-store",
+ });
+ const data = await resp.json().catch(() => ({}));
+ if (token !== availabilityToken) return;
+ if (!resp.ok) throw new Error(data.error || `status ${resp.status}`);
+ if (data.available) {
+ setAvailability("available", "Username is available.");
+ return;
+ }
+ const reason = data.reason || "";
+ const status = data.status || "";
+ if (reason === "invalid") {
+ setAvailability("invalid", data.detail || "Use 3-32 characters (letters, numbers, . _ -).");
+ return;
+ }
+ if (reason === "exists") {
+ setAvailability("exists", "Already in use. Choose another name.");
+ return;
+ }
+ if (reason === "requested") {
+ const label = status ? `Existing request: ${statusLabel(status)}` : "Request already exists.";
+ setAvailability("requested", label);
+ return;
+ }
+ setAvailability("error", "Unable to confirm availability.");
+ } catch (err) {
+ if (token !== availabilityToken) return;
+ setAvailability("error", err.message || "Availability check failed.");
+ } finally {
+ if (token === availabilityToken) availability.checking = false;
+ }
+ }
+
+ async function submit() {
+ if (submitting.value) return;
+ error.value = "";
+ submitting.value = true;
+ try {
+ const resp = await fetch("/api/access/request", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ cache: "no-store",
+ body: JSON.stringify({
+ username: form.username.trim(),
+ first_name: form.first_name.trim(),
+ last_name: form.last_name.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";
+ } 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 = "";
+ verifyBanner.value = null;
+ 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";
+ onboardingUrl.value = data.onboarding_url || "";
+ tasks.value = Array.isArray(data.tasks) ? data.tasks : [];
+ blocked.value = Boolean(data.blocked);
+ if (data.email_verified && status.value === "pending") {
+ verifyBanner.value = {
+ title: "Email confirmed",
+ body: "Your request is now waiting for manual approval. Check back here after an admin reviews it.",
+ };
+ } else {
+ verifyBanner.value = null;
+ }
+ } 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 retryProvisioning() {
+ if (retrying.value) return;
+ retryMessage.value = "";
+ const code = statusForm.request_code.trim();
+ if (!code) return;
+ retrying.value = true;
+ try {
+ const retryTasks = tasks.value
+ .filter((item) => item.status === "error")
+ .map((item) => item.task)
+ .filter(Boolean);
+ const resp = await fetch("/api/access/request/retry", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ cache: "no-store",
+ body: JSON.stringify({ request_code: code, tasks: retryTasks }),
+ });
+ const data = await resp.json().catch(() => ({}));
+ if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
+ retryMessage.value = "Retry requested. Check again in a moment.";
+ await checkStatus();
+ } catch (err) {
+ retryMessage.value = err?.message || "Retry request failed.";
+ } finally {
+ retrying.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;
+ if (status.value === "pending") {
+ verifyBanner.value = {
+ title: "Email confirmed",
+ body: "Your request is now waiting for manual approval. Check back here after an admin reviews it.",
+ };
+ } else {
+ verifyBanner.value = null;
+ }
+ } finally {
+ verifying.value = false;
+ }
+ }
+
+ async function resendVerification() {
+ if (resending.value) return;
+ const code = statusForm.request_code.trim();
+ if (!code) return;
+ resending.value = true;
+ resendMessage.value = "";
+ try {
+ const resp = await fetch("/api/access/request/resend", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ cache: "no-store",
+ body: JSON.stringify({ request_code: code }),
+ });
+ const data = await resp.json().catch(() => ({}));
+ if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
+ resendMessage.value = "Verification email sent.";
+ } catch (err) {
+ resendMessage.value = err?.message || "Failed to resend verification email.";
+ } finally {
+ resending.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() : "";
+ const verified = typeof route.query.verified === "string" ? route.query.verified.trim() : "";
+ const verifyError = typeof route.query.verify_error === "string" ? route.query.verify_error.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();
+ }
+ if (verified && status.value === "pending") {
+ verifyBanner.value = {
+ title: "Email confirmed",
+ body: "Your request is now waiting for manual approval. Check back here after an admin reviews it.",
+ };
+ }
+ if (verifyError) {
+ error.value = `Email verification failed: ${decodeURIComponent(verifyError)}`;
+ }
+ });
+
+ return {
+ statusLabel,
+ statusPillClass,
+ form,
+ submitting,
+ submitted,
+ error,
+ requestCode,
+ copied,
+ verifying,
+ mailDomain,
+ availability,
+ statusForm,
+ checking,
+ status,
+ onboardingUrl,
+ tasks,
+ blocked,
+ retrying,
+ retryMessage,
+ resending,
+ resendMessage,
+ verifyBanner,
+ taskPillClass,
+ submit,
+ copyRequestCode,
+ checkStatus,
+ retryProvisioning,
+ resendVerification,
+ };
+}
diff --git a/frontend/src/views/RequestAccessView.vue b/frontend/src/views/RequestAccessView.vue
index a37fdd2..c4a00a3 100644
--- a/frontend/src/views/RequestAccessView.vue
+++ b/frontend/src/views/RequestAccessView.vue
@@ -210,401 +210,39 @@