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 @@