refactor(bstein-home): extract request access flow
This commit is contained in:
parent
e11ee72a9e
commit
4ad9803c0c
435
frontend/src/request-access/useRequestAccessFlow.js
Normal file
435
frontend/src/request-access/useRequestAccessFlow.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -210,401 +210,39 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, reactive, ref, watch } from "vue";
|
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
|
import { useRequestAccessFlow } from "../request-access/useRequestAccessFlow";
|
||||||
|
|
||||||
const route = useRoute();
|
const {
|
||||||
|
statusLabel,
|
||||||
function statusLabel(value) {
|
statusPillClass,
|
||||||
const key = (value || "").trim();
|
form,
|
||||||
if (key === "pending_email_verification") return "confirm email";
|
submitting,
|
||||||
if (key === "pending") return "awaiting approval";
|
submitted,
|
||||||
if (key === "accounts_building") return "accounts building";
|
error,
|
||||||
if (key === "awaiting_onboarding") return "awaiting onboarding";
|
requestCode,
|
||||||
if (key === "ready") return "ready";
|
copied,
|
||||||
if (key === "denied") return "rejected";
|
verifying,
|
||||||
return key || "unknown";
|
mailDomain,
|
||||||
}
|
availability,
|
||||||
|
statusForm,
|
||||||
function statusPillClass(value) {
|
checking,
|
||||||
const key = (value || "").trim();
|
status,
|
||||||
if (key === "pending_email_verification") return "pill-warn";
|
onboardingUrl,
|
||||||
if (key === "pending") return "pill-wait";
|
tasks,
|
||||||
if (key === "accounts_building") return "pill-warn";
|
blocked,
|
||||||
if (key === "awaiting_onboarding") return "pill-ok";
|
retrying,
|
||||||
if (key === "ready") return "pill-info";
|
retryMessage,
|
||||||
if (key === "denied") return "pill-bad";
|
resending,
|
||||||
return "pill-warn";
|
resendMessage,
|
||||||
}
|
verifyBanner,
|
||||||
|
taskPillClass,
|
||||||
const form = reactive({
|
submit,
|
||||||
username: "",
|
copyRequestCode,
|
||||||
first_name: "",
|
checkStatus,
|
||||||
last_name: "",
|
retryProvisioning,
|
||||||
email: "",
|
resendVerification,
|
||||||
note: "",
|
} = useRequestAccessFlow(useRoute());
|
||||||
});
|
|
||||||
|
|
||||||
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)}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped src="../styles/request-access.css"></style>
|
<style scoped src="../styles/request-access.css"></style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user