fix(auth): correct resend-otp API endpoint path

- Change from /api/gateway/api/auth/resend-otp to /api/auth/resend-otp
- Fix in signup.tsx and login.tsx
- Gateway already proxies /api/auth/* to users service
This commit is contained in:
Tracewebstudio Dev 2026-04-16 17:29:46 +02:00
parent a365e1fa94
commit 152f918a7b
2 changed files with 166 additions and 109 deletions

View file

@ -1,27 +1,32 @@
import { A, useNavigate } from '@solidjs/router'; import { A, useNavigate } from "@solidjs/router";
import { createMemo, createSignal, For, Show } from 'solid-js'; import { createMemo, createSignal, For, Show } from "solid-js";
import { useAuth } from '~/lib/auth'; import { useAuth } from "~/lib/auth";
import PublicBackground from '~/components/PublicBackground'; import PublicBackground from "~/components/PublicBackground";
import PublicHeader from '~/components/PublicHeader'; import PublicHeader from "~/components/PublicHeader";
import CaptchaCanvas from '~/components/CaptchaCanvas'; import CaptchaCanvas from "~/components/CaptchaCanvas";
import { isValidEmail } from '~/lib/form-validation'; import { isValidEmail } from "~/lib/form-validation";
type RoleKey = 'company' | 'job_seeker' | 'professional' | 'customer'; type RoleKey = "company" | "job_seeker" | "professional" | "customer";
function normalizeRoleValue(value: unknown): string { function normalizeRoleValue(value: unknown): string {
return String(value || '').trim().toUpperCase().replace(/\s+/g, '_'); return String(value || "")
.trim()
.toUpperCase()
.replace(/\s+/g, "_");
} }
function getStoredPreferredRole(emailHint?: string): string | null { function getStoredPreferredRole(emailHint?: string): string | null {
if (typeof window === 'undefined') return null; if (typeof window === "undefined") return null;
const keys = ['nxtgauge_signup_profile_v1', 'nxtgauge_auth_user', 'nxtgauge_user']; const keys = ["nxtgauge_signup_profile_v1", "nxtgauge_auth_user", "nxtgauge_user"];
for (const key of keys) { for (const key of keys) {
const raw = window.localStorage.getItem(key); const raw = window.localStorage.getItem(key);
if (!raw) continue; if (!raw) continue;
try { try {
const parsed = JSON.parse(raw) as Record<string, any>; const parsed = JSON.parse(raw) as Record<string, any>;
const storedEmail = String(parsed?.email || '').trim().toLowerCase(); const storedEmail = String(parsed?.email || "")
.trim()
.toLowerCase();
if (emailHint && storedEmail && storedEmail !== emailHint.trim().toLowerCase()) continue; if (emailHint && storedEmail && storedEmail !== emailHint.trim().toLowerCase()) continue;
const selectedProfessionalRole = normalizeRoleValue(parsed?.selectedProfessionalRole); const selectedProfessionalRole = normalizeRoleValue(parsed?.selectedProfessionalRole);
@ -42,12 +47,12 @@ function resolveActiveRole(rawBackendRole: unknown, emailHint?: string): string
if (backendRole) return backendRole; if (backendRole) return backendRole;
const preferredRole = getStoredPreferredRole(emailHint); const preferredRole = getStoredPreferredRole(emailHint);
if (preferredRole) return preferredRole; if (preferredRole) return preferredRole;
return preferredRole || 'JOB_SEEKER'; return preferredRole || "JOB_SEEKER";
} }
function makeCaptcha() { function makeCaptcha() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
} }
function PasswordVisibilityIcon(props: { visible: boolean }) { function PasswordVisibilityIcon(props: { visible: boolean }) {
@ -72,45 +77,45 @@ function PasswordVisibilityIcon(props: { visible: boolean }) {
export default function LoginRoute() { export default function LoginRoute() {
const navigate = useNavigate(); const navigate = useNavigate();
const auth = useAuth(); const auth = useAuth();
const [email, setEmail] = createSignal(''); const [email, setEmail] = createSignal("");
const [password, setPassword] = createSignal(''); const [password, setPassword] = createSignal("");
const [otp, setOtp] = createSignal(['', '', '', '', '', '']); const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
const [showVerify, setShowVerify] = createSignal(false); const [showVerify, setShowVerify] = createSignal(false);
const [showPassword, setShowPassword] = createSignal(false); const [showPassword, setShowPassword] = createSignal(false);
const [captcha, setCaptcha] = createSignal(makeCaptcha()); const [captcha, setCaptcha] = createSignal(makeCaptcha());
const [captchaInput, setCaptchaInput] = createSignal(''); const [captchaInput, setCaptchaInput] = createSignal("");
const [error, setError] = createSignal(''); const [error, setError] = createSignal("");
const [submitting, setSubmitting] = createSignal(false); const [submitting, setSubmitting] = createSignal(false);
const [roleGuess, setRoleGuess] = createSignal<RoleKey>('job_seeker'); const [roleGuess, setRoleGuess] = createSignal<RoleKey>("job_seeker");
const [roleHint, setRoleHint] = createSignal(''); const [roleHint, setRoleHint] = createSignal("");
const [checkingRole, setCheckingRole] = createSignal(false); const [checkingRole, setCheckingRole] = createSignal(false);
const otpCode = createMemo(() => otp().join('')); const otpCode = createMemo(() => otp().join(""));
const formatRoleLabel = (value: string): string => const formatRoleLabel = (value: string): string =>
String(value || '') String(value || "")
.trim() .trim()
.replace(/[_\s]+/g, ' ') .replace(/[_\s]+/g, " ")
.toLowerCase() .toLowerCase()
.replace(/\b\w/g, (ch) => ch.toUpperCase()); .replace(/\b\w/g, (ch) => ch.toUpperCase());
const lookupRoleByEmail = async (emailValue: string) => { const lookupRoleByEmail = async (emailValue: string) => {
const normalized = emailValue.trim().toLowerCase(); const normalized = emailValue.trim().toLowerCase();
if (!normalized || !isValidEmail(normalized)) { if (!normalized || !isValidEmail(normalized)) {
setRoleHint(''); setRoleHint("");
return; return;
} }
setCheckingRole(true); setCheckingRole(true);
try { try {
const response = await fetch('/api/gateway/api/auth/check-email', { const response = await fetch("/api/gateway/api/auth/check-email", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: 'include', credentials: "include",
body: JSON.stringify({ email: normalized }), body: JSON.stringify({ email: normalized }),
}); });
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.exists) { if (!response.ok || !payload?.exists) {
setRoleHint(''); setRoleHint("");
return; return;
} }
const detectedRole = normalizeRoleValue( const detectedRole = normalizeRoleValue(
@ -118,27 +123,28 @@ export default function LoginRoute() {
); );
if (!detectedRole) { if (!detectedRole) {
const fallbackRole = normalizeRoleValue(getStoredPreferredRole(normalized)); const fallbackRole = normalizeRoleValue(getStoredPreferredRole(normalized));
setRoleHint( setRoleHint(fallbackRole ? `Role: ${formatRoleLabel(fallbackRole)}` : "Role: Not assigned");
fallbackRole
? `Role: ${formatRoleLabel(fallbackRole)}`
: 'Role: Not assigned'
);
return; return;
} }
setRoleHint(`Role: ${formatRoleLabel(detectedRole)}`); setRoleHint(`Role: ${formatRoleLabel(detectedRole)}`);
const roleLower = detectedRole.toLowerCase(); const roleLower = detectedRole.toLowerCase();
if (roleLower === 'company' || roleLower === 'customer' || roleLower === 'job_seeker' || roleLower === 'professional') { if (
roleLower === "company" ||
roleLower === "customer" ||
roleLower === "job_seeker" ||
roleLower === "professional"
) {
setRoleGuess(roleLower as RoleKey); setRoleGuess(roleLower as RoleKey);
} }
} catch { } catch {
setRoleHint(''); setRoleHint("");
} finally { } finally {
setCheckingRole(false); setCheckingRole(false);
} }
}; };
const setOtpDigit = (index: number, value: string) => { const setOtpDigit = (index: number, value: string) => {
const clean = value.replace(/\D/g, '').slice(0, 1); const clean = value.replace(/\D/g, "").slice(0, 1);
setOtp((prev) => { setOtp((prev) => {
const next = prev.slice(); const next = prev.slice();
next[index] = clean; next[index] = clean;
@ -151,60 +157,60 @@ export default function LoginRoute() {
}; };
const saveUser = (user: any) => { const saveUser = (user: any) => {
const fullName = String(user?.full_name || user?.fullName || '').trim(); const fullName = String(user?.full_name || user?.fullName || "").trim();
const [firstName, ...rest] = fullName.split(' '); const [firstName, ...rest] = fullName.split(" ");
const lastName = rest.join(' '); const lastName = rest.join(" ");
const normalizedRole = resolveActiveRole( const normalizedRole = resolveActiveRole(
user?.active_role || user?.role || roleGuess(), user?.active_role || user?.role || roleGuess(),
String(user?.email || email()) String(user?.email || email())
); );
const storedRole = normalizedRole const storedRole = normalizedRole ? normalizedRole.toLowerCase() : roleGuess();
? normalizedRole.toLowerCase()
: roleGuess();
const selectedProfessionalRole = getStoredPreferredRole(String(user?.email || email())); const selectedProfessionalRole = getStoredPreferredRole(String(user?.email || email()));
const payload = { const payload = {
firstName: firstName || '', firstName: firstName || "",
lastName: lastName || '', lastName: lastName || "",
fullName: fullName || '', fullName: fullName || "",
name: fullName || '', name: fullName || "",
displayName: fullName || '', displayName: fullName || "",
email: String(user?.email || email()).trim().toLowerCase(), email: String(user?.email || email())
.trim()
.toLowerCase(),
roleKey: storedRole, roleKey: storedRole,
role: storedRole, role: storedRole,
active_role: normalizedRole || 'JOB_SEEKER', active_role: normalizedRole || "JOB_SEEKER",
selectedProfessionalRole: selectedProfessionalRole || null, selectedProfessionalRole: selectedProfessionalRole || null,
user, user,
}; };
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
window.localStorage.setItem('nxtgauge_auth_user', JSON.stringify(payload)); window.localStorage.setItem("nxtgauge_auth_user", JSON.stringify(payload));
window.localStorage.setItem('nxtgauge_user', JSON.stringify(payload)); window.localStorage.setItem("nxtgauge_user", JSON.stringify(payload));
window.localStorage.setItem('nxtgauge_signup_profile_v1', JSON.stringify(payload)); window.localStorage.setItem("nxtgauge_signup_profile_v1", JSON.stringify(payload));
} }
}; };
const login = async () => { const login = async () => {
if (submitting()) return; if (submitting()) return;
setError(''); setError("");
if (!isValidEmail(email())) { if (!isValidEmail(email())) {
setError('Enter a valid email address.'); setError("Enter a valid email address.");
return; return;
} }
if (!password().trim()) { if (!password().trim()) {
setError('Password is required.'); setError("Password is required.");
return; return;
} }
if (!captchaInput().trim() || captchaInput().trim().toUpperCase() !== captcha().toUpperCase()) { if (!captchaInput().trim() || captchaInput().trim().toUpperCase() !== captcha().toUpperCase()) {
setError('Captcha does not match. Please try again.'); setError("Captcha does not match. Please try again.");
setCaptcha(makeCaptcha()); setCaptcha(makeCaptcha());
setCaptchaInput(''); setCaptchaInput("");
return; return;
} }
setSubmitting(true); setSubmitting(true);
try { try {
const res = await fetch('/api/gateway/api/auth/login', { const res = await fetch("/api/gateway/api/auth/login", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: 'include', credentials: "include",
body: JSON.stringify({ body: JSON.stringify({
email: email().trim().toLowerCase(), email: email().trim().toLowerCase(),
password: password(), password: password(),
@ -212,20 +218,20 @@ export default function LoginRoute() {
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
const code = String(data?.code || '').toUpperCase(); const code = String(data?.code || "").toUpperCase();
if (code === 'EMAIL_NOT_VERIFIED') { if (code === "EMAIL_NOT_VERIFIED") {
setShowVerify(true); setShowVerify(true);
setError('Email not verified. Enter OTP sent to your inbox.'); setError("Email not verified. Enter OTP sent to your inbox.");
return; return;
} }
setError(String(data?.error || data?.message || 'Invalid login credentials.')); setError(String(data?.error || data?.message || "Invalid login credentials."));
return; return;
} }
const accessToken = String(data?.access_token || '').trim(); const accessToken = String(data?.access_token || "").trim();
if (typeof window !== 'undefined' && accessToken) { if (typeof window !== "undefined" && accessToken) {
window.sessionStorage.setItem('nxtgauge_access_token', accessToken); window.sessionStorage.setItem("nxtgauge_access_token", accessToken);
window.sessionStorage.setItem('nxtgauge_frontend_access_token', accessToken); window.sessionStorage.setItem("nxtgauge_frontend_access_token", accessToken);
} }
const resolvedActiveRole = resolveActiveRole( const resolvedActiveRole = resolveActiveRole(
data?.user?.active_role || data?.user?.role, data?.user?.active_role || data?.user?.role,
@ -233,9 +239,9 @@ export default function LoginRoute() {
); );
const normalizedEmail = email().trim().toLowerCase(); const normalizedEmail = email().trim().toLowerCase();
const userPayload = { const userPayload = {
id: String(data?.user?.id || ''), id: String(data?.user?.id || ""),
email: String(data?.user?.email || normalizedEmail), email: String(data?.user?.email || normalizedEmail),
full_name: String(data?.user?.full_name || ''), full_name: String(data?.user?.full_name || ""),
active_role: resolvedActiveRole, active_role: resolvedActiveRole,
email_verified: Boolean(data?.user?.email_verified ?? true), email_verified: Boolean(data?.user?.email_verified ?? true),
}; };
@ -243,9 +249,9 @@ export default function LoginRoute() {
if (auth.setUser) { if (auth.setUser) {
auth.setUser(userPayload); auth.setUser(userPayload);
} }
navigate('/dashboard', { replace: true }); navigate("/dashboard", { replace: true });
} catch { } catch {
setError('Network error during login. Please try again.'); setError("Network error during login. Please try again.");
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -253,18 +259,18 @@ export default function LoginRoute() {
const resendOtp = async () => { const resendOtp = async () => {
if (submitting()) return; if (submitting()) return;
setError(''); setError("");
setSubmitting(true); setSubmitting(true);
try { try {
const res = await fetch('/api/gateway/api/auth/resend-otp', { const res = await fetch("/api/auth/resend-otp", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: 'include', credentials: "include",
body: JSON.stringify({ email: email().trim().toLowerCase() }), body: JSON.stringify({ email: email().trim().toLowerCase() }),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
setError(String(data?.error || data?.message || 'Unable to resend OTP.')); setError(String(data?.error || data?.message || "Unable to resend OTP."));
} }
} finally { } finally {
setSubmitting(false); setSubmitting(false);
@ -273,22 +279,22 @@ export default function LoginRoute() {
const verifyThenLogin = async () => { const verifyThenLogin = async () => {
if (submitting()) return; if (submitting()) return;
setError(''); setError("");
if (otpCode().length !== 6) { if (otpCode().length !== 6) {
setError('Enter a valid 6-digit OTP.'); setError("Enter a valid 6-digit OTP.");
return; return;
} }
setSubmitting(true); setSubmitting(true);
try { try {
const verifyRes = await fetch('/api/gateway/api/auth/verify-email', { const verifyRes = await fetch("/api/gateway/api/auth/verify-email", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: 'include', credentials: "include",
body: JSON.stringify({ otp: otpCode() }), body: JSON.stringify({ otp: otpCode() }),
}); });
const verifyData = await verifyRes.json().catch(() => ({})); const verifyData = await verifyRes.json().catch(() => ({}));
if (!verifyRes.ok) { if (!verifyRes.ok) {
setError(String(verifyData?.error || verifyData?.message || 'OTP verification failed.')); setError(String(verifyData?.error || verifyData?.message || "OTP verification failed."));
return; return;
} }
await login(); await login();
@ -309,7 +315,9 @@ export default function LoginRoute() {
<div class="auth-visual-content"> <div class="auth-visual-content">
<p class="eyebrow">Public Workspace</p> <p class="eyebrow">Public Workspace</p>
<h1 class="title light">Welcome Back To Nxtgauge</h1> <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> <p class="subtitle light">
Sign in to manage your profile, portfolio, and verification in one place.
</p>
</div> </div>
</section> </section>
@ -317,7 +325,9 @@ export default function LoginRoute() {
<h2 class="title">Sign In</h2> <h2 class="title">Sign In</h2>
<div class="field"> <div class="field">
<label class="label" for="login-email">EMAIL</label> <label class="label" for="login-email">
EMAIL
</label>
<input <input
id="login-email" id="login-email"
type="email" type="email"
@ -333,25 +343,39 @@ export default function LoginRoute() {
}} }}
placeholder="Enter your email" placeholder="Enter your email"
/> />
<p class="validation-note" style={{ color: email().trim() && isValidEmail(email()) ? '#fd6116' : '#6e7591' }}> <p
{email().trim() && isValidEmail(email()) ? '✓ Valid email format' : '• Enter a valid email format'} class="validation-note"
style={{ color: email().trim() && isValidEmail(email()) ? "#fd6116" : "#6e7591" }}
>
{email().trim() && isValidEmail(email())
? "✓ Valid email format"
: "• Enter a valid email format"}
</p> </p>
<Show when={roleHint() || checkingRole()}> <Show when={roleHint() || checkingRole()}>
<p class="validation-note" style={{ color: '#0f766e' }}> <p class="validation-note" style={{ color: "#0f766e" }}>
{checkingRole() ? 'Checking account role...' : `${roleHint()}`} {checkingRole() ? "Checking account role..." : `${roleHint()}`}
</p> </p>
</Show> </Show>
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="login-password">PASSWORD</label> <label class="label" for="login-password">
PASSWORD
</label>
<div class="auth-password-wrap"> <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" /> <input
id="login-password"
type={showPassword() ? "text" : "password"}
class="input"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
placeholder="Enter your password"
/>
<button <button
class="auth-toggle-visibility" class="auth-toggle-visibility"
type="button" type="button"
onClick={() => setShowPassword((prev) => !prev)} onClick={() => setShowPassword((prev) => !prev)}
aria-label={showPassword() ? 'Hide password' : 'Show password'} aria-label={showPassword() ? "Hide password" : "Show password"}
> >
<PasswordVisibilityIcon visible={showPassword()} /> <PasswordVisibilityIcon visible={showPassword()} />
</button> </button>
@ -361,9 +385,23 @@ export default function LoginRoute() {
<div class="field"> <div class="field">
<label class="label">CAPTCHA</label> <label class="label">CAPTCHA</label>
<div class="auth-captcha-row"> <div class="auth-captcha-row">
<button class="auth-captcha-refresh" type="button" onClick={() => { setCaptcha(makeCaptcha()); setCaptchaInput(''); }}></button> <button
class="auth-captcha-refresh"
type="button"
onClick={() => {
setCaptcha(makeCaptcha());
setCaptchaInput("");
}}
>
</button>
<CaptchaCanvas code={captcha()} class="auth-captcha-canvas" /> <CaptchaCanvas code={captcha()} class="auth-captcha-canvas" />
<input class="input" value={captchaInput()} onInput={(e) => setCaptchaInput(e.currentTarget.value)} placeholder="Enter captcha" /> <input
class="input"
value={captchaInput()}
onInput={(e) => setCaptchaInput(e.currentTarget.value)}
placeholder="Enter captcha"
/>
</div> </div>
</div> </div>
@ -385,26 +423,45 @@ export default function LoginRoute() {
</div> </div>
<div class="auth-footer-row"> <div class="auth-footer-row">
<p class="note">Didnt receive code?</p> <p class="note">Didnt receive code?</p>
<button class="auth-forgot-link" type="button" onClick={() => void resendOtp()} disabled={submitting()}> <button
class="auth-forgot-link"
type="button"
onClick={() => void resendOtp()}
disabled={submitting()}
>
Resend OTP Resend OTP
</button> </button>
</div> </div>
</Show> </Show>
<button class="auth-submit-btn" type="button" onClick={() => void login()} disabled={submitting()}> <button
{submitting() ? 'Signing In...' : 'Sign In'} class="auth-submit-btn"
type="button"
onClick={() => void login()}
disabled={submitting()}
>
{submitting() ? "Signing In..." : "Sign In"}
</button> </button>
<Show when={showVerify()}> <Show when={showVerify()}>
<button class="auth-submit-btn" type="button" onClick={() => void verifyThenLogin()} disabled={submitting()}> <button
{submitting() ? 'Verifying...' : 'Verify Email and Login'} class="auth-submit-btn"
type="button"
onClick={() => void verifyThenLogin()}
disabled={submitting()}
>
{submitting() ? "Verifying..." : "Verify Email and Login"}
</button> </button>
</Show> </Show>
<div class="auth-footer-row"> <div class="auth-footer-row">
<p class="footer-text">Secure login with email verification.</p> <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">
<p class="note"><A href="/forgot-password">Forgot Password?</A></p> New user? <A href="/signup">Sign Up</A>
</p>
<p class="note">
<A href="/forgot-password">Forgot Password?</A>
</p>
</div> </div>
<Show when={error()}> <Show when={error()}>

View file

@ -272,7 +272,7 @@ export default function SignupRoute() {
setServerError(""); setServerError("");
setSubmitting(true); setSubmitting(true);
try { try {
const res = await fetch("/api/gateway/api/auth/resend-otp", { const res = await fetch("/api/auth/resend-otp", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" }, headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: "include", credentials: "include",