- 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
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
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">Didn’t 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>
|
||
);
|
||
}
|