nxtgauge-frontend-solid/src/routes/login.tsx

606 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
});
// 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();
}
});
};
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;
}
// 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())) {
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);
}
};
if (typeof window !== 'undefined') {
window.__captchaCode = captcha();
(window as any).__loginVerifyOtp = verifyThenLogin;
(window as any).__setLoginOtp = setOtp;
(window as any).__loginOtp = otp;
}
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={() => {
const newCaptcha = makeCaptcha();
setCaptcha(newCaptcha);
window.__captchaCode = newCaptcha;
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">Didnt 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>
);
}