nxtgauge-frontend-solid/src/routes/login.tsx
Ashwin Kumar e5406a0061 feat: add auth context, route guards, password reset, and API client
- Add AuthProvider context and RequireAuth route guard
- Create API client with all endpoint helpers
- Add forgot-password route wired to backend reset endpoints
- Remove dummy login button from login page
- Wire dashboard to auth context for user data
- Enhance profile save to send all fields
- Wire profile submit-for-verification to backend API
2026-04-06 06:19:23 +02:00

297 lines
11 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 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 otpCode = createMemo(() => otp().join(''));
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 = String(user?.active_role || user?.role || roleGuess() || '')
.trim()
.toUpperCase()
.replace(/\s+/g, '_');
const storedRole = normalizedRole
? normalizedRole.toLowerCase()
: roleGuess();
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',
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 () => {
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);
window.localStorage.setItem('nxtgauge_access_token', accessToken);
}
saveUser(data?.user || {});
if (auth.setUser) {
auth.setUser({
id: data?.user?.id || '',
email: data?.user?.email || email().trim().toLowerCase(),
full_name: data?.user?.full_name || '',
active_role: data?.user?.active_role || 'JOB_SEEKER',
email_verified: data?.user?.email_verified || false,
});
}
navigate('/dashboard', { replace: true });
} finally {
setSubmitting(false);
}
};
const resendOtp = async () => {
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 () => {
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) => setEmail(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>
</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>
);
}