refactor(bstein-home): extract request access flow

This commit is contained in:
codex 2026-04-21 06:53:09 -03:00
parent e11ee72a9e
commit 4ad9803c0c
2 changed files with 466 additions and 393 deletions

View File

@ -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,
};
}

View File

@ -210,401 +210,39 @@
</template>
<script setup>
import { onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { useRequestAccessFlow } from "../request-access/useRequestAccessFlow";
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";
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)}`;
}
});
const {
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,
} = useRequestAccessFlow(useRoute());
</script>
<style scoped src="../styles/request-access.css"></style>