The loading spinner in RequireAuth caused a hydration error: on SSR the session was available so children rendered, but during client hydration session.loading was true so the spinner rendered instead, causing DOM mismatch (null nextSibling). Also includes role resolution priority fixes from previous session: - prefer preferredRole when backendRole is JOB_SEEKER but preferredRole is not - pass role via URL param to dashboard - urlRoleLocked signal prevents auth effects from overriding URL role
592 lines
21 KiB
TypeScript
592 lines
21 KiB
TypeScript
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";
|
||
|
||
type RoleKey = "company" | "job_seeker" | "professional" | "customer";
|
||
|
||
function normalizeRoleValue(value: unknown): string {
|
||
return String(value || "")
|
||
.trim()
|
||
.toUpperCase()
|
||
.replace(/\s+/g, "_");
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
function getStoredPreferredRole(emailHint?: string): string | null {
|
||
if (typeof window === "undefined") return null;
|
||
const keys = ["nxtgauge_signup_profile_v1", "nxtgauge_auth_user", "nxtgauge_user"];
|
||
|
||
for (const key of keys) {
|
||
const raw = window.localStorage.getItem(key);
|
||
if (!raw) continue;
|
||
try {
|
||
const parsed = JSON.parse(raw) as Record<string, any>;
|
||
const storedEmail = String(parsed?.email || "")
|
||
.trim()
|
||
.toLowerCase();
|
||
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;
|
||
return "";
|
||
}
|
||
|
||
function makeCaptcha() {
|
||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
|
||
}
|
||
|
||
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();
|
||
const auth = useAuth();
|
||
const [email, setEmail] = createSignal("");
|
||
const [password, setPassword] = createSignal("");
|
||
const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
|
||
const [showVerify, setShowVerify] = createSignal(false);
|
||
const [showPassword, setShowPassword] = createSignal(false);
|
||
const [captcha, setCaptcha] = createSignal(makeCaptcha());
|
||
const [captchaInput, setCaptchaInput] = createSignal("");
|
||
const [error, setError] = createSignal("");
|
||
const [submitting, setSubmitting] = createSignal(false);
|
||
const [roleGuess, setRoleGuess] = createSignal<RoleKey>("job_seeker");
|
||
const [roleHint, setRoleHint] = createSignal("");
|
||
const [checkingRole, setCheckingRole] = createSignal(false);
|
||
|
||
const otpCode = createMemo(() => otp().join(""));
|
||
|
||
const formatRoleLabel = (value: string): string =>
|
||
String(value || "")
|
||
.trim()
|
||
.replace(/[_\s]+/g, " ")
|
||
.toLowerCase()
|
||
.replace(/\b\w/g, (ch) => ch.toUpperCase());
|
||
|
||
const lookupRoleByEmail = async (emailValue: string) => {
|
||
const normalized = emailValue.trim().toLowerCase();
|
||
if (!normalized || !isValidEmail(normalized)) {
|
||
setRoleHint("");
|
||
return;
|
||
}
|
||
setCheckingRole(true);
|
||
try {
|
||
const response = await fetch("/api/auth/check-email", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({ email: normalized }),
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok || !payload?.exists) {
|
||
setRoleHint("");
|
||
return;
|
||
}
|
||
const detectedRole = normalizeRoleValue(
|
||
payload?.active_role || payload?.role || payload?.roles?.[0]
|
||
);
|
||
if (!detectedRole) {
|
||
const fallbackRole = normalizeRoleValue(getStoredPreferredRole(normalized));
|
||
setRoleHint(fallbackRole ? `Role: ${formatRoleLabel(fallbackRole)}` : "Role: Not assigned");
|
||
return;
|
||
}
|
||
setRoleHint(`Role: ${formatRoleLabel(detectedRole)}`);
|
||
const roleLower = detectedRole.toLowerCase();
|
||
if (
|
||
roleLower === "company" ||
|
||
roleLower === "customer" ||
|
||
roleLower === "job_seeker" ||
|
||
roleLower === "professional"
|
||
) {
|
||
setRoleGuess(roleLower as RoleKey);
|
||
}
|
||
} catch {
|
||
setRoleHint("");
|
||
} finally {
|
||
setCheckingRole(false);
|
||
}
|
||
};
|
||
|
||
const setOtpDigit = (index: number, value: string) => {
|
||
const clean = value.replace(/\D/g, "").slice(0, 1);
|
||
setOtp((prev) => {
|
||
const next = prev.slice();
|
||
next[index] = clean;
|
||
return next;
|
||
});
|
||
if (clean) {
|
||
const nextEl = document.querySelector<HTMLInputElement>(`#login-otp-${index + 1}`);
|
||
if (nextEl) nextEl.focus();
|
||
}
|
||
};
|
||
|
||
const saveUser = (user: any) => {
|
||
const fullName = String(user?.full_name || user?.fullName || "").trim();
|
||
const [firstName, ...rest] = fullName.split(" ");
|
||
const lastName = rest.join(" ");
|
||
// 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;
|
||
|
||
const payload = {
|
||
firstName: firstName || "",
|
||
lastName: lastName || "",
|
||
fullName: fullName || "",
|
||
name: fullName || "",
|
||
displayName: fullName || "",
|
||
email: String(user?.email || email())
|
||
.trim()
|
||
.toLowerCase(),
|
||
roleKey: storedRole,
|
||
role: storedRole,
|
||
active_role: normalizedRole,
|
||
selectedProfessionalRole: selectedRoleForStorage,
|
||
user,
|
||
};
|
||
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));
|
||
}
|
||
};
|
||
|
||
const login = async () => {
|
||
if (submitting()) return;
|
||
setError("");
|
||
if (!isValidEmail(email())) {
|
||
setError("Enter a valid email address.");
|
||
return;
|
||
}
|
||
if (!password().trim()) {
|
||
setError("Password is required.");
|
||
return;
|
||
}
|
||
if (!captchaInput().trim() || captchaInput().trim().toUpperCase() !== captcha().toUpperCase()) {
|
||
setError("Captcha does not match. Please try again.");
|
||
setCaptcha(makeCaptcha());
|
||
setCaptchaInput("");
|
||
return;
|
||
}
|
||
setSubmitting(true);
|
||
try {
|
||
const res = await fetch("/api/auth/login", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({
|
||
email: email().trim().toLowerCase(),
|
||
password: password(),
|
||
}),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
const code = String(data?.code || "").toUpperCase();
|
||
if (code === "EMAIL_NOT_VERIFIED") {
|
||
setShowVerify(true);
|
||
setError("Email not verified. Enter OTP sent to your inbox.");
|
||
return;
|
||
}
|
||
setError(String(data?.error || data?.message || "Invalid login credentials."));
|
||
return;
|
||
}
|
||
|
||
const accessToken = String(data?.access_token || "").trim();
|
||
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);
|
||
}
|
||
|
||
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;
|
||
}
|
||
} catch {
|
||
// Ignore switch failures; fall back to backend active role token.
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
const userPayload = {
|
||
id: String(data?.user?.id || ""),
|
||
email: userEmail,
|
||
full_name: String(data?.user?.full_name || data?.user?.name || data?.name || ""),
|
||
active_role: finalRole,
|
||
email_verified: Boolean(data?.user?.email_verified ?? true),
|
||
};
|
||
saveUser({ ...userPayload });
|
||
if (auth.refreshUser) {
|
||
auth.refreshUser(userPayload);
|
||
}
|
||
navigate(`/dashboard?role=${encodeURIComponent(finalRole)}`, { replace: true });
|
||
} catch {
|
||
setError("Network error during login. Please try again.");
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const resendOtp = async () => {
|
||
if (submitting()) return;
|
||
setError("");
|
||
setSubmitting(true);
|
||
try {
|
||
const res = await fetch("/api/auth/resend-otp", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({ email: email().trim().toLowerCase() }),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
setError(String(data?.error || data?.message || "Unable to resend OTP."));
|
||
}
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const verifyThenLogin = async () => {
|
||
if (submitting()) return;
|
||
setError("");
|
||
if (otpCode().length !== 6) {
|
||
setError("Enter a valid 6-digit OTP.");
|
||
return;
|
||
}
|
||
setSubmitting(true);
|
||
try {
|
||
const verifyRes = await fetch("/api/auth/verify-email", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({ otp: otpCode() }),
|
||
});
|
||
const verifyData = await verifyRes.json().catch(() => ({}));
|
||
if (!verifyRes.ok) {
|
||
setError(String(verifyData?.error || verifyData?.message || "OTP verification failed."));
|
||
return;
|
||
}
|
||
await login();
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
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>
|
||
<p class="subtitle light">
|
||
Sign in to manage your profile, portfolio, and verification in one place.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="auth-form card glass-light">
|
||
<h2 class="title">Sign In</h2>
|
||
|
||
<div class="field">
|
||
<label class="label" for="login-email">
|
||
EMAIL
|
||
</label>
|
||
<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"
|
||
/>
|
||
<p
|
||
class="validation-note"
|
||
style={{ color: email().trim() && isValidEmail(email()) ? "#fd6116" : "#6e7591" }}
|
||
>
|
||
{email().trim() && isValidEmail(email())
|
||
? "✓ Valid email format"
|
||
: "• Enter a valid email format"}
|
||
</p>
|
||
<Show when={roleHint() || checkingRole()}>
|
||
<p class="validation-note" style={{ color: "#0f766e" }}>
|
||
{checkingRole() ? "Checking account role..." : `• ${roleHint()}`}
|
||
</p>
|
||
</Show>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="label" for="login-password">
|
||
PASSWORD
|
||
</label>
|
||
<div class="auth-password-wrap">
|
||
<input
|
||
id="login-password"
|
||
type={showPassword() ? "text" : "password"}
|
||
class="input"
|
||
value={password()}
|
||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||
placeholder="Enter your password"
|
||
/>
|
||
<button
|
||
class="auth-toggle-visibility"
|
||
type="button"
|
||
onClick={() => setShowPassword((prev) => !prev)}
|
||
aria-label={showPassword() ? "Hide password" : "Show password"}
|
||
>
|
||
<PasswordVisibilityIcon visible={showPassword()} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="label">CAPTCHA</label>
|
||
<div class="auth-captcha-row">
|
||
<button
|
||
class="auth-captcha-refresh"
|
||
type="button"
|
||
onClick={() => {
|
||
setCaptcha(makeCaptcha());
|
||
setCaptchaInput("");
|
||
}}
|
||
>
|
||
↻
|
||
</button>
|
||
<CaptchaCanvas code={captcha()} class="auth-captcha-canvas" />
|
||
<input
|
||
class="input"
|
||
value={captchaInput()}
|
||
onInput={(e) => setCaptchaInput(e.currentTarget.value)}
|
||
placeholder="Enter captcha"
|
||
/>
|
||
</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>
|
||
<button
|
||
class="auth-forgot-link"
|
||
type="button"
|
||
onClick={() => void resendOtp()}
|
||
disabled={submitting()}
|
||
>
|
||
Resend OTP
|
||
</button>
|
||
</div>
|
||
</Show>
|
||
|
||
<button
|
||
class="auth-submit-btn"
|
||
type="button"
|
||
onClick={() => void login()}
|
||
disabled={submitting()}
|
||
>
|
||
{submitting() ? "Signing In..." : "Sign In"}
|
||
</button>
|
||
|
||
<Show when={showVerify()}>
|
||
<button
|
||
class="auth-submit-btn"
|
||
type="button"
|
||
onClick={() => void verifyThenLogin()}
|
||
disabled={submitting()}
|
||
>
|
||
{submitting() ? "Verifying..." : "Verify Email and Login"}
|
||
</button>
|
||
</Show>
|
||
|
||
<div class="auth-footer-row">
|
||
<p class="footer-text">Secure login with email verification.</p>
|
||
<p class="note">
|
||
New user? <A href="/signup">Sign Up</A>
|
||
</p>
|
||
<p class="note">
|
||
<A href="/forgot-password">Forgot Password?</A>
|
||
</p>
|
||
</div>
|
||
|
||
<Show when={error()}>
|
||
<p class="error">{error()}</p>
|
||
</Show>
|
||
</section>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|