2026-04-16 17:29:46 +02:00
|
|
|
|
import { A, useNavigate } from "@solidjs/router";
|
|
|
|
|
|
import { createMemo, createSignal, For, Show } from "solid-js";
|
|
|
|
|
|
import { useAuth } from "~/lib/auth";
|
|
|
|
|
|
import PublicBackground from "~/components/PublicBackground";
|
|
|
|
|
|
import PublicHeader from "~/components/PublicHeader";
|
|
|
|
|
|
import CaptchaCanvas from "~/components/CaptchaCanvas";
|
|
|
|
|
|
import { isValidEmail } from "~/lib/form-validation";
|
2026-04-05 16:52:02 +02:00
|
|
|
|
|
2026-04-16 17:29:46 +02:00
|
|
|
|
type RoleKey = "company" | "job_seeker" | "professional" | "customer";
|
2026-04-05 16:52:02 +02:00
|
|
|
|
|
2026-04-15 06:23:28 +02:00
|
|
|
|
function normalizeRoleValue(value: unknown): string {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
return String(value || "")
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.toUpperCase()
|
|
|
|
|
|
.replace(/\s+/g, "_");
|
2026-04-15 06:23:28 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 23:56:19 +02:00
|
|
|
|
function extractRoleKey(value: unknown): string {
|
|
|
|
|
|
const normalized = normalizeRoleValue(value);
|
|
|
|
|
|
if (normalized && normalized !== "[OBJECT_OBJECT]") return normalized;
|
|
|
|
|
|
if (!value || typeof value !== "object") return normalized;
|
|
|
|
|
|
const maybe = value as Record<string, unknown>;
|
|
|
|
|
|
return normalizeRoleValue(
|
|
|
|
|
|
maybe.key ?? maybe.role_key ?? maybe.roleKey ?? maybe.name ?? maybe.role ?? maybe.id
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeRoleKeysList(value: unknown): string[] {
|
|
|
|
|
|
if (!Array.isArray(value)) return [];
|
|
|
|
|
|
return value.map((item) => extractRoleKey(item)).filter(Boolean);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isJobSeekerRole(roleKey: string): boolean {
|
|
|
|
|
|
return normalizeRoleValue(roleKey) === "JOB_SEEKER";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function firstNonJobSeekerRole(roleKeys: string[]): string | null {
|
|
|
|
|
|
for (const roleKey of roleKeys) {
|
|
|
|
|
|
if (!isJobSeekerRole(roleKey)) return roleKey;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 06:23:28 +02:00
|
|
|
|
function getStoredPreferredRole(emailHint?: string): string | null {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
if (typeof window === "undefined") return null;
|
|
|
|
|
|
const keys = ["nxtgauge_signup_profile_v1", "nxtgauge_auth_user", "nxtgauge_user"];
|
2026-04-15 06:23:28 +02:00
|
|
|
|
|
|
|
|
|
|
for (const key of keys) {
|
|
|
|
|
|
const raw = window.localStorage.getItem(key);
|
|
|
|
|
|
if (!raw) continue;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsed = JSON.parse(raw) as Record<string, any>;
|
2026-04-16 17:29:46 +02:00
|
|
|
|
const storedEmail = String(parsed?.email || "")
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.toLowerCase();
|
2026-04-15 06:23:28 +02:00
|
|
|
|
if (emailHint && storedEmail && storedEmail !== emailHint.trim().toLowerCase()) continue;
|
|
|
|
|
|
|
|
|
|
|
|
const selectedProfessionalRole = normalizeRoleValue(parsed?.selectedProfessionalRole);
|
|
|
|
|
|
if (selectedProfessionalRole) return selectedProfessionalRole;
|
|
|
|
|
|
|
|
|
|
|
|
const activeRole = normalizeRoleValue(parsed?.active_role || parsed?.role);
|
|
|
|
|
|
if (activeRole) return activeRole;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Ignore malformed local storage payloads.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveActiveRole(rawBackendRole: unknown, emailHint?: string): string {
|
|
|
|
|
|
const backendRole = normalizeRoleValue(rawBackendRole);
|
|
|
|
|
|
if (backendRole) return backendRole;
|
|
|
|
|
|
const preferredRole = getStoredPreferredRole(emailHint);
|
|
|
|
|
|
if (preferredRole) return preferredRole;
|
2026-04-21 23:56:19 +02:00
|
|
|
|
return "";
|
2026-04-15 06:23:28 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 16:52:02 +02:00
|
|
|
|
function makeCaptcha() {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
|
|
|
|
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
|
2026-04-05 16:52:02 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function PasswordVisibilityIcon(props: { visible: boolean }) {
|
|
|
|
|
|
if (props.visible) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
|
|
|
|
<path d="M3 3l18 18" />
|
|
|
|
|
|
<path d="M10.58 10.58a2 2 0 0 0 2.83 2.83" />
|
|
|
|
|
|
<path d="M9.88 5.09A11 11 0 0 1 12 4.9c5.5 0 10 4.1 10 7.1 0 1.2-.72 2.53-1.95 3.72" />
|
|
|
|
|
|
<path d="M6.1 6.1C3.54 7.58 2 9.79 2 12c0 3 4.48 7.1 10 7.1 1.72 0 3.36-.4 4.84-1.12" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
|
|
|
|
<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z" />
|
|
|
|
|
|
<circle cx="12" cy="12" r="3" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function LoginRoute() {
|
|
|
|
|
|
const navigate = useNavigate();
|
2026-04-06 06:19:23 +02:00
|
|
|
|
const auth = useAuth();
|
2026-04-16 17:29:46 +02:00
|
|
|
|
const [email, setEmail] = createSignal("");
|
|
|
|
|
|
const [password, setPassword] = createSignal("");
|
|
|
|
|
|
const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
|
2026-04-05 16:52:02 +02:00
|
|
|
|
const [showVerify, setShowVerify] = createSignal(false);
|
|
|
|
|
|
const [showPassword, setShowPassword] = createSignal(false);
|
|
|
|
|
|
const [captcha, setCaptcha] = createSignal(makeCaptcha());
|
2026-04-16 17:29:46 +02:00
|
|
|
|
const [captchaInput, setCaptchaInput] = createSignal("");
|
|
|
|
|
|
const [error, setError] = createSignal("");
|
2026-04-05 16:52:02 +02:00
|
|
|
|
const [submitting, setSubmitting] = createSignal(false);
|
2026-04-16 17:29:46 +02:00
|
|
|
|
const [roleGuess, setRoleGuess] = createSignal<RoleKey>("job_seeker");
|
|
|
|
|
|
const [roleHint, setRoleHint] = createSignal("");
|
2026-04-15 06:23:28 +02:00
|
|
|
|
const [checkingRole, setCheckingRole] = createSignal(false);
|
2026-04-05 16:52:02 +02:00
|
|
|
|
|
2026-04-16 17:29:46 +02:00
|
|
|
|
const otpCode = createMemo(() => otp().join(""));
|
2026-04-05 16:52:02 +02:00
|
|
|
|
|
2026-04-15 06:23:28 +02:00
|
|
|
|
const formatRoleLabel = (value: string): string =>
|
2026-04-16 17:29:46 +02:00
|
|
|
|
String(value || "")
|
2026-04-15 06:23:28 +02:00
|
|
|
|
.trim()
|
2026-04-16 17:29:46 +02:00
|
|
|
|
.replace(/[_\s]+/g, " ")
|
2026-04-15 06:23:28 +02:00
|
|
|
|
.toLowerCase()
|
|
|
|
|
|
.replace(/\b\w/g, (ch) => ch.toUpperCase());
|
|
|
|
|
|
|
|
|
|
|
|
const lookupRoleByEmail = async (emailValue: string) => {
|
|
|
|
|
|
const normalized = emailValue.trim().toLowerCase();
|
|
|
|
|
|
if (!normalized || !isValidEmail(normalized)) {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
setRoleHint("");
|
2026-04-15 06:23:28 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setCheckingRole(true);
|
|
|
|
|
|
try {
|
2026-04-17 17:00:10 +02:00
|
|
|
|
const response = await fetch("/api/auth/check-email", {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
|
|
|
|
credentials: "include",
|
2026-04-15 06:23:28 +02:00
|
|
|
|
body: JSON.stringify({ email: normalized }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const payload = await response.json().catch(() => ({}));
|
|
|
|
|
|
if (!response.ok || !payload?.exists) {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
setRoleHint("");
|
2026-04-15 06:23:28 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const detectedRole = normalizeRoleValue(
|
|
|
|
|
|
payload?.active_role || payload?.role || payload?.roles?.[0]
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!detectedRole) {
|
|
|
|
|
|
const fallbackRole = normalizeRoleValue(getStoredPreferredRole(normalized));
|
2026-04-16 17:29:46 +02:00
|
|
|
|
setRoleHint(fallbackRole ? `Role: ${formatRoleLabel(fallbackRole)}` : "Role: Not assigned");
|
2026-04-15 06:23:28 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setRoleHint(`Role: ${formatRoleLabel(detectedRole)}`);
|
|
|
|
|
|
const roleLower = detectedRole.toLowerCase();
|
2026-04-16 17:29:46 +02:00
|
|
|
|
if (
|
|
|
|
|
|
roleLower === "company" ||
|
|
|
|
|
|
roleLower === "customer" ||
|
|
|
|
|
|
roleLower === "job_seeker" ||
|
|
|
|
|
|
roleLower === "professional"
|
|
|
|
|
|
) {
|
2026-04-15 06:23:28 +02:00
|
|
|
|
setRoleGuess(roleLower as RoleKey);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
setRoleHint("");
|
2026-04-15 06:23:28 +02:00
|
|
|
|
} finally {
|
|
|
|
|
|
setCheckingRole(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-05 16:52:02 +02:00
|
|
|
|
const setOtpDigit = (index: number, value: string) => {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
const clean = value.replace(/\D/g, "").slice(0, 1);
|
2026-04-05 16:52:02 +02:00
|
|
|
|
setOtp((prev) => {
|
|
|
|
|
|
const next = prev.slice();
|
|
|
|
|
|
next[index] = clean;
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
|
|
|
|
// Defer focus until after SolidJS reactive flush so the next input exists in DOM
|
|
|
|
|
|
queueMicrotask(() => {
|
|
|
|
|
|
if (clean && index < 5) {
|
|
|
|
|
|
const nextEl = document.querySelector<HTMLInputElement>(`#login-otp-${index + 1}`);
|
|
|
|
|
|
if (nextEl) nextEl.focus();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-04-05 16:52:02 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const saveUser = (user: any) => {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
const fullName = String(user?.full_name || user?.fullName || "").trim();
|
|
|
|
|
|
const [firstName, ...rest] = fullName.split(" ");
|
|
|
|
|
|
const lastName = rest.join(" ");
|
2026-04-21 23:56:19 +02:00
|
|
|
|
// Trust the passed-in role and never force a JOB_SEEKER fallback.
|
|
|
|
|
|
const preferredRole = getStoredPreferredRole(String(user?.email || email()));
|
|
|
|
|
|
const normalizedRole = normalizeRoleValue(
|
|
|
|
|
|
user?.active_role || user?.role || preferredRole || ""
|
|
|
|
|
|
);
|
|
|
|
|
|
const storedRole = normalizedRole ? normalizedRole.toLowerCase() : "";
|
|
|
|
|
|
const selectedRoleForStorage =
|
|
|
|
|
|
isJobSeekerRole(normalizedRole) && preferredRole && !isJobSeekerRole(preferredRole)
|
|
|
|
|
|
? preferredRole
|
|
|
|
|
|
: normalizedRole;
|
|
|
|
|
|
|
2026-04-05 16:52:02 +02:00
|
|
|
|
const payload = {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
firstName: firstName || "",
|
|
|
|
|
|
lastName: lastName || "",
|
|
|
|
|
|
fullName: fullName || "",
|
|
|
|
|
|
name: fullName || "",
|
|
|
|
|
|
displayName: fullName || "",
|
|
|
|
|
|
email: String(user?.email || email())
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.toLowerCase(),
|
2026-04-05 16:52:02 +02:00
|
|
|
|
roleKey: storedRole,
|
|
|
|
|
|
role: storedRole,
|
2026-04-21 21:50:57 +02:00
|
|
|
|
active_role: normalizedRole,
|
2026-04-21 23:56:19 +02:00
|
|
|
|
selectedProfessionalRole: selectedRoleForStorage,
|
2026-04-05 16:52:02 +02:00
|
|
|
|
user,
|
|
|
|
|
|
};
|
2026-04-16 17:29:46 +02:00
|
|
|
|
if (typeof window !== "undefined") {
|
|
|
|
|
|
window.localStorage.setItem("nxtgauge_auth_user", JSON.stringify(payload));
|
|
|
|
|
|
window.localStorage.setItem("nxtgauge_user", JSON.stringify(payload));
|
|
|
|
|
|
window.localStorage.setItem("nxtgauge_signup_profile_v1", JSON.stringify(payload));
|
2026-04-05 16:52:02 +02:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const login = async () => {
|
2026-04-15 06:23:28 +02:00
|
|
|
|
if (submitting()) return;
|
2026-04-16 17:29:46 +02:00
|
|
|
|
setError("");
|
2026-04-05 16:52:02 +02:00
|
|
|
|
if (!isValidEmail(email())) {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
setError("Enter a valid email address.");
|
2026-04-05 16:52:02 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!password().trim()) {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
setError("Password is required.");
|
2026-04-05 16:52:02 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
|
|
|
|
// DEV bypass: skip CAPTCHA validation in development
|
|
|
|
|
|
const isDev = typeof import.meta !== 'undefined' && import.meta.env?.DEV;
|
|
|
|
|
|
if (!isDev && (!captchaInput().trim() || captchaInput().trim().toUpperCase() !== captcha().toUpperCase())) {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
setError("Captcha does not match. Please try again.");
|
2026-04-05 16:52:02 +02:00
|
|
|
|
setCaptcha(makeCaptcha());
|
2026-04-16 17:29:46 +02:00
|
|
|
|
setCaptchaInput("");
|
2026-04-05 16:52:02 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
try {
|
2026-04-17 17:00:10 +02:00
|
|
|
|
const res = await fetch("/api/auth/login", {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
|
|
|
|
credentials: "include",
|
2026-04-05 16:52:02 +02:00
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
email: email().trim().toLowerCase(),
|
|
|
|
|
|
password: password(),
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
const data = await res.json().catch(() => ({}));
|
|
|
|
|
|
if (!res.ok) {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
const code = String(data?.code || "").toUpperCase();
|
|
|
|
|
|
if (code === "EMAIL_NOT_VERIFIED") {
|
2026-04-05 16:52:02 +02:00
|
|
|
|
setShowVerify(true);
|
2026-04-16 17:29:46 +02:00
|
|
|
|
setError("Email not verified. Enter OTP sent to your inbox.");
|
2026-04-05 16:52:02 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-04-16 17:29:46 +02:00
|
|
|
|
setError(String(data?.error || data?.message || "Invalid login credentials."));
|
2026-04-05 16:52:02 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 17:29:46 +02:00
|
|
|
|
const accessToken = String(data?.access_token || "").trim();
|
2026-04-21 23:56:19 +02:00
|
|
|
|
const normalizedEmail = email().trim().toLowerCase();
|
|
|
|
|
|
const userEmail = String(data?.user?.email || normalizedEmail).trim().toLowerCase();
|
|
|
|
|
|
const availableRoleKeys = normalizeRoleKeysList(data?.user?.roles || data?.roles);
|
|
|
|
|
|
const backendActiveRoleKey =
|
|
|
|
|
|
extractRoleKey(
|
|
|
|
|
|
data?.user?.active_role ||
|
|
|
|
|
|
data?.active_role ||
|
|
|
|
|
|
data?.role ||
|
|
|
|
|
|
data?.role_code
|
|
|
|
|
|
) ||
|
|
|
|
|
|
firstNonJobSeekerRole(availableRoleKeys) ||
|
|
|
|
|
|
"";
|
|
|
|
|
|
const preferredRoleKey = getStoredPreferredRole(userEmail);
|
|
|
|
|
|
let discoveredRoleKeys = [...availableRoleKeys];
|
|
|
|
|
|
let discoveredActiveRole = backendActiveRoleKey;
|
|
|
|
|
|
|
|
|
|
|
|
if (discoveredRoleKeys.length === 0 || isJobSeekerRole(discoveredActiveRole)) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const checkRes = await fetch("/api/auth/check-email", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
|
|
|
|
credentials: "include",
|
|
|
|
|
|
body: JSON.stringify({ email: userEmail }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const checkPayload = await checkRes.json().catch(() => ({}));
|
|
|
|
|
|
if (checkRes.ok && checkPayload?.exists) {
|
|
|
|
|
|
const checkRoles = normalizeRoleKeysList(checkPayload?.roles);
|
|
|
|
|
|
if (checkRoles.length > 0) {
|
|
|
|
|
|
discoveredRoleKeys = Array.from(new Set([...discoveredRoleKeys, ...checkRoles]));
|
|
|
|
|
|
}
|
|
|
|
|
|
const checkActive = extractRoleKey(checkPayload?.active_role || checkPayload?.role);
|
|
|
|
|
|
if (checkActive) {
|
|
|
|
|
|
discoveredActiveRole = checkActive;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Ignore check-email failures; continue with login payload roles.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Choose the role we *want* to activate:
|
|
|
|
|
|
// 1) Prefer the stored role selection (from signup/switch services) when it isn't JOB_SEEKER.
|
|
|
|
|
|
// If backend doesn't return roles, still attempt to activate it via switch-role.
|
|
|
|
|
|
// 2) Otherwise, if backend returns JOB_SEEKER but other roles exist, pick the first non-JOB_SEEKER role.
|
|
|
|
|
|
let requestedRoleKey: string | null = null;
|
|
|
|
|
|
if (
|
|
|
|
|
|
preferredRoleKey &&
|
|
|
|
|
|
!isJobSeekerRole(preferredRoleKey) &&
|
|
|
|
|
|
(discoveredRoleKeys.length === 0 || discoveredRoleKeys.includes(preferredRoleKey))
|
|
|
|
|
|
) {
|
|
|
|
|
|
requestedRoleKey = preferredRoleKey;
|
|
|
|
|
|
} else if (isJobSeekerRole(discoveredActiveRole)) {
|
|
|
|
|
|
requestedRoleKey = firstNonJobSeekerRole(discoveredRoleKeys);
|
2026-04-05 16:52:02 +02:00
|
|
|
|
}
|
2026-04-21 23:56:19 +02:00
|
|
|
|
|
|
|
|
|
|
let finalAccessToken = accessToken;
|
|
|
|
|
|
let desiredRoleKey = discoveredActiveRole;
|
|
|
|
|
|
if (finalAccessToken && requestedRoleKey && requestedRoleKey !== discoveredActiveRole) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const switchRes = await fetch("/api/auth/switch-role", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
Accept: "application/json",
|
|
|
|
|
|
Authorization: `Bearer ${finalAccessToken}`,
|
|
|
|
|
|
},
|
|
|
|
|
|
credentials: "include",
|
|
|
|
|
|
body: JSON.stringify({ role_key: requestedRoleKey }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const switchPayload = await switchRes.json().catch(() => ({}));
|
|
|
|
|
|
const switchedToken = String(switchPayload?.access_token || "").trim();
|
|
|
|
|
|
if (switchRes.ok && switchedToken) {
|
|
|
|
|
|
finalAccessToken = switchedToken;
|
|
|
|
|
|
desiredRoleKey = requestedRoleKey;
|
2026-04-21 21:50:57 +02:00
|
|
|
|
}
|
2026-04-21 23:56:19 +02:00
|
|
|
|
} catch {
|
|
|
|
|
|
// Ignore switch failures; fall back to backend active role token.
|
2026-04-21 21:50:57 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-21 23:56:19 +02:00
|
|
|
|
|
|
|
|
|
|
if (typeof window !== "undefined" && finalAccessToken) {
|
|
|
|
|
|
window.sessionStorage.setItem("nxtgauge_access_token", finalAccessToken);
|
|
|
|
|
|
window.sessionStorage.setItem("nxtgauge_frontend_access_token", finalAccessToken);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const finalRole = normalizeRoleValue(
|
|
|
|
|
|
desiredRoleKey ||
|
|
|
|
|
|
backendActiveRoleKey ||
|
|
|
|
|
|
preferredRoleKey ||
|
|
|
|
|
|
firstNonJobSeekerRole(discoveredRoleKeys) ||
|
|
|
|
|
|
""
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!finalRole) {
|
|
|
|
|
|
setError("No active role is assigned to this account. Please contact support.");
|
|
|
|
|
|
return;
|
2026-04-21 21:50:57 +02:00
|
|
|
|
}
|
2026-04-15 06:23:28 +02:00
|
|
|
|
const userPayload = {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
id: String(data?.user?.id || ""),
|
2026-04-21 23:56:19 +02:00
|
|
|
|
email: userEmail,
|
|
|
|
|
|
full_name: String(data?.user?.full_name || data?.user?.name || data?.name || ""),
|
2026-04-21 21:50:57 +02:00
|
|
|
|
active_role: finalRole,
|
2026-04-15 06:23:28 +02:00
|
|
|
|
email_verified: Boolean(data?.user?.email_verified ?? true),
|
|
|
|
|
|
};
|
2026-04-21 21:50:57 +02:00
|
|
|
|
saveUser({ ...userPayload });
|
|
|
|
|
|
if (auth.refreshUser) {
|
|
|
|
|
|
auth.refreshUser(userPayload);
|
2026-04-06 06:19:23 +02:00
|
|
|
|
}
|
2026-04-21 23:56:19 +02:00
|
|
|
|
navigate(`/dashboard?role=${encodeURIComponent(finalRole)}`, { replace: true });
|
2026-04-15 06:23:28 +02:00
|
|
|
|
} catch {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
setError("Network error during login. Please try again.");
|
2026-04-05 16:52:02 +02:00
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const resendOtp = async () => {
|
2026-04-15 06:23:28 +02:00
|
|
|
|
if (submitting()) return;
|
2026-04-16 17:29:46 +02:00
|
|
|
|
setError("");
|
2026-04-05 16:52:02 +02:00
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
try {
|
2026-04-17 17:00:10 +02:00
|
|
|
|
const res = await fetch("/api/auth/resend-otp", {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
|
|
|
|
credentials: "include",
|
2026-04-05 16:52:02 +02:00
|
|
|
|
body: JSON.stringify({ email: email().trim().toLowerCase() }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const data = await res.json().catch(() => ({}));
|
|
|
|
|
|
if (!res.ok) {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
setError(String(data?.error || data?.message || "Unable to resend OTP."));
|
2026-04-05 16:52:02 +02:00
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const verifyThenLogin = async () => {
|
2026-04-15 06:23:28 +02:00
|
|
|
|
if (submitting()) return;
|
2026-04-16 17:29:46 +02:00
|
|
|
|
setError("");
|
2026-04-05 16:52:02 +02:00
|
|
|
|
if (otpCode().length !== 6) {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
setError("Enter a valid 6-digit OTP.");
|
2026-04-05 16:52:02 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
try {
|
2026-04-17 17:00:10 +02:00
|
|
|
|
const verifyRes = await fetch("/api/auth/verify-email", {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
|
|
|
|
credentials: "include",
|
2026-04-05 16:52:02 +02:00
|
|
|
|
body: JSON.stringify({ otp: otpCode() }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const verifyData = await verifyRes.json().catch(() => ({}));
|
|
|
|
|
|
if (!verifyRes.ok) {
|
2026-04-16 17:29:46 +02:00
|
|
|
|
setError(String(verifyData?.error || verifyData?.message || "OTP verification failed."));
|
2026-04-05 16:52:02 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await login();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
|
window.__captchaCode = captcha();
|
|
|
|
|
|
(window as any).__loginVerifyOtp = verifyThenLogin;
|
|
|
|
|
|
(window as any).__setLoginOtp = setOtp;
|
|
|
|
|
|
(window as any).__loginOtp = otp;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 16:52:02 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<main class="auth-page">
|
|
|
|
|
|
<PublicBackground />
|
|
|
|
|
|
<PublicHeader />
|
|
|
|
|
|
|
|
|
|
|
|
<div class="auth-layout">
|
|
|
|
|
|
<section class="auth-visual card glass-dark">
|
|
|
|
|
|
<img class="auth-visual-img" src="/images/auth-company-1.jpg" alt="Public Workspace" />
|
|
|
|
|
|
<div class="auth-visual-overlay" />
|
|
|
|
|
|
<div class="auth-visual-content">
|
|
|
|
|
|
<p class="eyebrow">Public Workspace</p>
|
|
|
|
|
|
<h1 class="title light">Welcome Back To Nxtgauge</h1>
|
2026-04-16 17:29:46 +02:00
|
|
|
|
<p class="subtitle light">
|
|
|
|
|
|
Sign in to manage your profile, portfolio, and verification in one place.
|
|
|
|
|
|
</p>
|
2026-04-05 16:52:02 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section class="auth-form card glass-light">
|
|
|
|
|
|
<h2 class="title">Sign In</h2>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="field">
|
2026-04-16 17:29:46 +02:00
|
|
|
|
<label class="label" for="login-email">
|
|
|
|
|
|
EMAIL
|
|
|
|
|
|
</label>
|
2026-04-15 06:23:28 +02:00
|
|
|
|
<input
|
|
|
|
|
|
id="login-email"
|
|
|
|
|
|
type="email"
|
|
|
|
|
|
class="input"
|
|
|
|
|
|
value={email()}
|
|
|
|
|
|
onInput={(e) => {
|
|
|
|
|
|
const value = e.currentTarget.value;
|
|
|
|
|
|
setEmail(value);
|
|
|
|
|
|
void lookupRoleByEmail(value);
|
|
|
|
|
|
}}
|
|
|
|
|
|
onBlur={(e) => {
|
|
|
|
|
|
void lookupRoleByEmail(e.currentTarget.value);
|
|
|
|
|
|
}}
|
|
|
|
|
|
placeholder="Enter your email"
|
|
|
|
|
|
/>
|
2026-04-16 17:29:46 +02:00
|
|
|
|
<p
|
|
|
|
|
|
class="validation-note"
|
|
|
|
|
|
style={{ color: email().trim() && isValidEmail(email()) ? "#fd6116" : "#6e7591" }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{email().trim() && isValidEmail(email())
|
|
|
|
|
|
? "✓ Valid email format"
|
|
|
|
|
|
: "• Enter a valid email format"}
|
2026-04-05 16:52:02 +02:00
|
|
|
|
</p>
|
2026-04-15 06:23:28 +02:00
|
|
|
|
<Show when={roleHint() || checkingRole()}>
|
2026-04-16 17:29:46 +02:00
|
|
|
|
<p class="validation-note" style={{ color: "#0f766e" }}>
|
|
|
|
|
|
{checkingRole() ? "Checking account role..." : `• ${roleHint()}`}
|
2026-04-15 06:23:28 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
</Show>
|
2026-04-05 16:52:02 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="field">
|
2026-04-16 17:29:46 +02:00
|
|
|
|
<label class="label" for="login-password">
|
|
|
|
|
|
PASSWORD
|
|
|
|
|
|
</label>
|
2026-04-05 16:52:02 +02:00
|
|
|
|
<div class="auth-password-wrap">
|
2026-04-16 17:29:46 +02:00
|
|
|
|
<input
|
|
|
|
|
|
id="login-password"
|
|
|
|
|
|
type={showPassword() ? "text" : "password"}
|
|
|
|
|
|
class="input"
|
|
|
|
|
|
value={password()}
|
|
|
|
|
|
onInput={(e) => setPassword(e.currentTarget.value)}
|
|
|
|
|
|
placeholder="Enter your password"
|
|
|
|
|
|
/>
|
2026-04-05 16:52:02 +02:00
|
|
|
|
<button
|
|
|
|
|
|
class="auth-toggle-visibility"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setShowPassword((prev) => !prev)}
|
2026-04-16 17:29:46 +02:00
|
|
|
|
aria-label={showPassword() ? "Hide password" : "Show password"}
|
2026-04-05 16:52:02 +02:00
|
|
|
|
>
|
|
|
|
|
|
<PasswordVisibilityIcon visible={showPassword()} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="field">
|
|
|
|
|
|
<label class="label">CAPTCHA</label>
|
|
|
|
|
|
<div class="auth-captcha-row">
|
2026-04-16 17:29:46 +02:00
|
|
|
|
<button
|
|
|
|
|
|
class="auth-captcha-refresh"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => {
|
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
|
|
|
|
const newCaptcha = makeCaptcha();
|
|
|
|
|
|
setCaptcha(newCaptcha);
|
|
|
|
|
|
window.__captchaCode = newCaptcha;
|
2026-04-16 17:29:46 +02:00
|
|
|
|
setCaptchaInput("");
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
↻
|
|
|
|
|
|
</button>
|
2026-04-05 16:52:02 +02:00
|
|
|
|
<CaptchaCanvas code={captcha()} class="auth-captcha-canvas" />
|
2026-04-16 17:29:46 +02:00
|
|
|
|
<input
|
|
|
|
|
|
class="input"
|
|
|
|
|
|
value={captchaInput()}
|
|
|
|
|
|
onInput={(e) => setCaptchaInput(e.currentTarget.value)}
|
|
|
|
|
|
placeholder="Enter captcha"
|
|
|
|
|
|
/>
|
2026-04-05 16:52:02 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Show when={showVerify()}>
|
|
|
|
|
|
<label class="label">EMAIL OTP</label>
|
|
|
|
|
|
<div class="otp-row">
|
|
|
|
|
|
<For each={Array.from({ length: 6 }, (_, index) => index)}>
|
|
|
|
|
|
{(index) => (
|
|
|
|
|
|
<input
|
|
|
|
|
|
id={`login-otp-${index}`}
|
|
|
|
|
|
class="otp-input"
|
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
|
maxlength={1}
|
|
|
|
|
|
value={otp()[index]}
|
|
|
|
|
|
onInput={(e) => setOtpDigit(index, e.currentTarget.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</For>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="auth-footer-row">
|
|
|
|
|
|
<p class="note">Didn’t receive code?</p>
|
2026-04-16 17:29:46 +02:00
|
|
|
|
<button
|
|
|
|
|
|
class="auth-forgot-link"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => void resendOtp()}
|
|
|
|
|
|
disabled={submitting()}
|
|
|
|
|
|
>
|
2026-04-05 16:52:02 +02:00
|
|
|
|
Resend OTP
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
|
2026-04-16 17:29:46 +02:00
|
|
|
|
<button
|
|
|
|
|
|
class="auth-submit-btn"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => void login()}
|
|
|
|
|
|
disabled={submitting()}
|
|
|
|
|
|
>
|
|
|
|
|
|
{submitting() ? "Signing In..." : "Sign In"}
|
2026-04-05 16:52:02 +02:00
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<Show when={showVerify()}>
|
2026-04-16 17:29:46 +02:00
|
|
|
|
<button
|
|
|
|
|
|
class="auth-submit-btn"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => void verifyThenLogin()}
|
|
|
|
|
|
disabled={submitting()}
|
|
|
|
|
|
>
|
|
|
|
|
|
{submitting() ? "Verifying..." : "Verify Email and Login"}
|
2026-04-05 16:52:02 +02:00
|
|
|
|
</button>
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="auth-footer-row">
|
|
|
|
|
|
<p class="footer-text">Secure login with email verification.</p>
|
2026-04-16 17:29:46 +02:00
|
|
|
|
<p class="note">
|
|
|
|
|
|
New user? <A href="/signup">Sign Up</A>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p class="note">
|
|
|
|
|
|
<A href="/forgot-password">Forgot Password?</A>
|
|
|
|
|
|
</p>
|
2026-04-05 16:52:02 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Show when={error()}>
|
|
|
|
|
|
<p class="error">{error()}</p>
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</main>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|