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

417 lines
15 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 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/gateway/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(' ');
const normalizedRole = resolveActiveRole(
user?.active_role || user?.role || roleGuess(),
String(user?.email || email())
);
const storedRole = normalizedRole
? normalizedRole.toLowerCase()
: roleGuess();
const selectedProfessionalRole = getStoredPreferredRole(String(user?.email || email()));
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 || 'JOB_SEEKER',
selectedProfessionalRole: selectedProfessionalRole || null,
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/gateway/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 resolvedActiveRole = resolveActiveRole(
data?.user?.active_role || data?.user?.role,
data?.user?.email || email().trim().toLowerCase()
);
const normalizedEmail = email().trim().toLowerCase();
const userPayload = {
id: String(data?.user?.id || ''),
email: String(data?.user?.email || normalizedEmail),
full_name: String(data?.user?.full_name || ''),
active_role: resolvedActiveRole,
email_verified: Boolean(data?.user?.email_verified ?? true),
};
saveUser({ ...(data?.user || {}), ...userPayload });
if (auth.setUser) {
auth.setUser(userPayload);
}
navigate('/dashboard', { 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/gateway/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/gateway/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>
);
}