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

489 lines
17 KiB
TypeScript
Raw Normal View History

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 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 preferredRole || "JOB_SEEKER";
}
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 — don't re-resolve from storage (which can be stale)
const normalizedRole = normalizeRoleValue(user?.active_role || user?.role || "JOB_SEEKER");
const storedRole = normalizedRole.toLowerCase();
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: normalizedRole,
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();
if (typeof window !== "undefined" && accessToken) {
window.sessionStorage.setItem("nxtgauge_access_token", accessToken);
window.sessionStorage.setItem("nxtgauge_frontend_access_token", accessToken);
}
const user_roles = data?.user?.roles || [];
const selectedRole = getStoredPreferredRole(data?.user?.email || email().trim().toLowerCase());
const backendActiveRole = data?.user?.active_role;
// Use selected role from storage if available, otherwise use backend role
// If backend returns JOB_SEEKER but user has professional roles, pick the first professional role
let resolvedActiveRole = backendActiveRole;
if (normalizeRoleValue(backendActiveRole) === "JOB_SEEKER" && user_roles.length > 0) {
// Find first non-JOB_SEEKER role from backend's roles array
for (const role of user_roles) {
if (normalizeRoleValue(role) !== "JOB_SEEKER") {
resolvedActiveRole = role;
break;
}
}
}
if (!resolvedActiveRole) {
resolvedActiveRole = selectedRole || backendActiveRole || "JOB_SEEKER";
}
const normalizedEmail = email().trim().toLowerCase();
const finalRole = normalizeRoleValue(resolvedActiveRole);
const userPayload = {
id: String(data?.user?.id || ""),
email: String(data?.user?.email || normalizedEmail),
full_name: String(data?.user?.full_name || ""),
active_role: finalRole,
email_verified: Boolean(data?.user?.email_verified ?? true),
};
saveUser({ ...userPayload });
if (auth.refreshUser) {
auth.refreshUser(userPayload);
}
navigate(`/dashboard?role=${encodeURIComponent(normalizeRoleValue(resolvedActiveRole))}`, { 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">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>
);
}