2026-03-19 03:36:46 +01:00
|
|
|
import { useNavigate } from '@solidjs/router';
|
|
|
|
|
import { createMemo, createSignal, onMount } from 'solid-js';
|
2026-03-20 22:37:17 +01:00
|
|
|
import { isExternalIdentity, pickManagementLoginError } from '~/lib/admin-auth';
|
2026-03-19 03:36:46 +01:00
|
|
|
import { hasAdminSession, setAdminSession } from '~/lib/admin-session';
|
|
|
|
|
|
|
|
|
|
type AuthMode = 'login' | 'reset';
|
|
|
|
|
type ResetStep = 'request' | 'verify';
|
|
|
|
|
|
|
|
|
|
function pickChallengeId(payload: any): string {
|
|
|
|
|
const direct = String(payload?.challengeId || '').trim();
|
|
|
|
|
if (direct) return direct;
|
|
|
|
|
const nested = String(payload?.data?.challengeId || '').trim();
|
|
|
|
|
if (nested) return nested;
|
|
|
|
|
const snake = String(payload?.challenge_id || '').trim();
|
|
|
|
|
if (snake) return snake;
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pickMaskedEmail(payload: any, fallback: string): string {
|
|
|
|
|
const direct = String(payload?.maskedEmail || '').trim();
|
|
|
|
|
if (direct) return direct;
|
|
|
|
|
const nested = String(payload?.data?.maskedEmail || '').trim();
|
|
|
|
|
if (nested) return nested;
|
|
|
|
|
return fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function LoginPage() {
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const [mode, setMode] = createSignal<AuthMode>('login');
|
|
|
|
|
const [resetStep, setResetStep] = createSignal<ResetStep>('request');
|
|
|
|
|
const [email, setEmail] = createSignal('');
|
|
|
|
|
const [password, setPassword] = createSignal('');
|
|
|
|
|
const [resetCode, setResetCode] = createSignal('');
|
|
|
|
|
const [newPassword, setNewPassword] = createSignal('');
|
|
|
|
|
const [confirmPassword, setConfirmPassword] = createSignal('');
|
|
|
|
|
const [challengeId, setChallengeId] = createSignal('');
|
|
|
|
|
const [maskedEmail, setMaskedEmail] = createSignal('');
|
|
|
|
|
const [isSubmitting, setIsSubmitting] = createSignal(false);
|
|
|
|
|
const [error, setError] = createSignal('');
|
|
|
|
|
const [info, setInfo] = createSignal('');
|
|
|
|
|
|
|
|
|
|
const canSubmitResetRequest = createMemo(
|
|
|
|
|
() =>
|
|
|
|
|
email().trim().length > 0 &&
|
|
|
|
|
newPassword().trim().length > 0 &&
|
|
|
|
|
confirmPassword().trim().length > 0,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const canSubmitResetVerify = createMemo(
|
|
|
|
|
() => challengeId().trim().length > 0 && resetCode().trim().length === 6,
|
|
|
|
|
);
|
|
|
|
|
const canSubmitLoginCredentials = createMemo(
|
|
|
|
|
() => email().trim().length > 0 && password().trim().length > 0,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const clearMessages = () => {
|
|
|
|
|
setError('');
|
|
|
|
|
setInfo('');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const resetPasswordFlow = () => {
|
|
|
|
|
setResetStep('request');
|
|
|
|
|
setResetCode('');
|
|
|
|
|
setChallengeId('');
|
|
|
|
|
setMaskedEmail('');
|
|
|
|
|
setNewPassword('');
|
|
|
|
|
setConfirmPassword('');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const switchMode = (nextMode: AuthMode) => {
|
|
|
|
|
clearMessages();
|
|
|
|
|
setMode(nextMode);
|
|
|
|
|
if (nextMode === 'login') {
|
|
|
|
|
resetPasswordFlow();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
onMount(() => {
|
|
|
|
|
if (hasAdminSession()) {
|
|
|
|
|
navigate('/admin', { replace: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const completeAdminLogin = () => {
|
|
|
|
|
setAdminSession();
|
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
|
|
|
const from = params.get('from');
|
|
|
|
|
const nextPath = from && from.startsWith('/admin') ? from : '/admin';
|
|
|
|
|
navigate(nextPath, { replace: true });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const directSignIn = async () => {
|
|
|
|
|
clearMessages();
|
|
|
|
|
if (!canSubmitLoginCredentials()) {
|
|
|
|
|
setError('Email and password are required.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsSubmitting(true);
|
|
|
|
|
try {
|
2026-03-20 22:37:17 +01:00
|
|
|
const loginPayload = {
|
|
|
|
|
email: email().trim().toLowerCase(),
|
|
|
|
|
password: password(),
|
|
|
|
|
loginTarget: 'admin',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const attempts: string[] = [
|
|
|
|
|
'/api/gateway/users/auth/internal/login',
|
|
|
|
|
'/api/gateway/auth/internal/login',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let payload: any = {};
|
|
|
|
|
let status = 500;
|
|
|
|
|
let success = false;
|
|
|
|
|
for (const url of attempts) {
|
|
|
|
|
const response = await fetch(url, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
Accept: 'application/json',
|
|
|
|
|
'x-portal-target': 'admin',
|
|
|
|
|
},
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
body: JSON.stringify(loginPayload),
|
|
|
|
|
});
|
|
|
|
|
status = response.status;
|
|
|
|
|
payload = await response.json().catch(() => ({}));
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
success = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!success) {
|
|
|
|
|
const fallback = status === 502
|
|
|
|
|
? 'Auth service is temporarily unavailable (502). Please retry in 1-2 minutes.'
|
|
|
|
|
: 'Sign in failed.';
|
|
|
|
|
throw new Error(pickManagementLoginError(payload) || fallback);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isExternalIdentity(payload)) {
|
|
|
|
|
throw new Error('External users are not allowed on management login. Please use the external user login.');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 03:36:46 +01:00
|
|
|
completeAdminLogin();
|
|
|
|
|
} catch (nextError: any) {
|
|
|
|
|
setError(String(nextError?.message || 'Sign in failed.'));
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSubmitting(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const requestResetCode = async () => {
|
|
|
|
|
clearMessages();
|
|
|
|
|
if (!canSubmitResetRequest()) {
|
|
|
|
|
setError('Email, new password, and confirm password are required.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (newPassword() !== confirmPassword()) {
|
|
|
|
|
setError('Passwords do not match.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const trimmedEmail = email().trim().toLowerCase();
|
|
|
|
|
const resolvedPassword = newPassword().trim();
|
|
|
|
|
const requestPayload = {
|
|
|
|
|
email: trimmedEmail,
|
|
|
|
|
newPassword: resolvedPassword,
|
|
|
|
|
new_password: resolvedPassword,
|
|
|
|
|
password: resolvedPassword,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const attempts: Array<{ url: string; body: string }> = [
|
|
|
|
|
{
|
|
|
|
|
url: '/api/gateway/users/auth/internal/forgot-password/request-code',
|
|
|
|
|
body: JSON.stringify(requestPayload),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
url: '/api/gateway/auth/internal/forgot-password/request-code',
|
|
|
|
|
body: JSON.stringify(requestPayload),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
url: '/api/gateway/users/auth/internal/forgot-password/request-code',
|
|
|
|
|
body: JSON.stringify({ data: requestPayload }),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
url: '/api/gateway/auth/internal/forgot-password/request-code',
|
|
|
|
|
body: JSON.stringify({ data: requestPayload }),
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
setIsSubmitting(true);
|
|
|
|
|
try {
|
|
|
|
|
let payload: any = {};
|
|
|
|
|
let status = 500;
|
|
|
|
|
for (const attempt of attempts) {
|
|
|
|
|
const response = await fetch(attempt.url, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
Accept: 'application/json',
|
|
|
|
|
},
|
|
|
|
|
body: attempt.body,
|
|
|
|
|
});
|
|
|
|
|
status = response.status;
|
|
|
|
|
payload = await response.json().catch(() => ({}));
|
|
|
|
|
if (response.ok) break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextChallengeId = pickChallengeId(payload);
|
|
|
|
|
if (!nextChallengeId) {
|
|
|
|
|
const fallbackMessage =
|
|
|
|
|
status === 502
|
|
|
|
|
? 'Verification service is temporarily unavailable (502). Please retry in 1-2 minutes.'
|
|
|
|
|
: 'Failed to send reset code.';
|
|
|
|
|
throw new Error(String(payload?.message || payload?.error || fallbackMessage).trim());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setChallengeId(nextChallengeId);
|
|
|
|
|
setMaskedEmail(pickMaskedEmail(payload, trimmedEmail));
|
|
|
|
|
setResetStep('verify');
|
|
|
|
|
const debugCode = String(payload?.debugCode || payload?.data?.debugCode || '').trim();
|
|
|
|
|
setInfo(debugCode ? `Reset code sent. [DEV CODE: ${debugCode}]` : 'Reset code sent to your email.');
|
|
|
|
|
} catch (nextError: any) {
|
|
|
|
|
setError(String(nextError?.message || 'Failed to send reset code.'));
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSubmitting(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const verifyResetCode = async () => {
|
|
|
|
|
clearMessages();
|
|
|
|
|
if (!canSubmitResetVerify()) {
|
|
|
|
|
setError('A valid 6-digit verification code is required.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (newPassword().trim() !== confirmPassword().trim()) {
|
|
|
|
|
setError('Passwords do not match.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const resolvedPassword = newPassword().trim();
|
|
|
|
|
const resolvedChallengeId = challengeId().trim();
|
|
|
|
|
const resolvedCode = resetCode().trim();
|
|
|
|
|
|
|
|
|
|
const verifyPayload = {
|
|
|
|
|
challengeId: resolvedChallengeId,
|
|
|
|
|
challenge_id: resolvedChallengeId,
|
|
|
|
|
code: resolvedCode,
|
|
|
|
|
otp: resolvedCode,
|
|
|
|
|
verificationCode: resolvedCode,
|
|
|
|
|
verification_code: resolvedCode,
|
|
|
|
|
newPassword: resolvedPassword,
|
|
|
|
|
new_password: resolvedPassword,
|
|
|
|
|
password: resolvedPassword,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const attempts: string[] = [
|
|
|
|
|
'/api/gateway/users/auth/internal/forgot-password/verify-code',
|
|
|
|
|
'/api/gateway/auth/internal/forgot-password/verify-code',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
setIsSubmitting(true);
|
|
|
|
|
try {
|
|
|
|
|
let payload: any = {};
|
|
|
|
|
let status = 500;
|
|
|
|
|
let success = false;
|
|
|
|
|
for (const url of attempts) {
|
|
|
|
|
const response = await fetch(url, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
Accept: 'application/json',
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify(verifyPayload),
|
|
|
|
|
});
|
|
|
|
|
status = response.status;
|
|
|
|
|
payload = await response.json().catch(() => ({}));
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
success = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!success) {
|
|
|
|
|
const fallbackMessage =
|
|
|
|
|
status === 502
|
|
|
|
|
? 'Verification service is temporarily unavailable (502). Please retry in 1-2 minutes.'
|
|
|
|
|
: 'Password reset failed.';
|
|
|
|
|
throw new Error(String(payload?.message || payload?.error || fallbackMessage).trim());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setPassword('');
|
|
|
|
|
switchMode('login');
|
|
|
|
|
setInfo('Password reset successful. Please sign in with your new password.');
|
|
|
|
|
} catch (nextError: any) {
|
|
|
|
|
setError(String(nextError?.message || 'Password reset failed.'));
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSubmitting(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<main class="auth-page auth-page-login">
|
|
|
|
|
<div class="auth-bg" />
|
|
|
|
|
<div class="auth-layout">
|
|
|
|
|
<section class="auth-visual">
|
|
|
|
|
<p class="auth-visual-kicker">Internal Access</p>
|
|
|
|
|
<h1>Welcome back to Nxtgauge.</h1>
|
|
|
|
|
<p>Sign in securely to access the admin control panel.</p>
|
|
|
|
|
<img
|
|
|
|
|
src="https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&w=1200&q=80"
|
|
|
|
|
alt="Office workspace"
|
|
|
|
|
/>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="auth-card auth-form-card">
|
|
|
|
|
<img src="/nxtgauge-logo.png" alt="NXTGAUGE" class="auth-logo" />
|
|
|
|
|
<h2 class="auth-title">{mode() === 'login' ? 'Employee Login' : 'Reset Password'}</h2>
|
|
|
|
|
|
|
|
|
|
<form class="auth-form-grid">
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label>Email address</label>
|
|
|
|
|
<input
|
|
|
|
|
type="email"
|
|
|
|
|
value={email()}
|
|
|
|
|
onInput={(event) => {
|
|
|
|
|
setEmail(event.currentTarget.value);
|
|
|
|
|
clearMessages();
|
|
|
|
|
}}
|
|
|
|
|
placeholder="Enter your email"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
{mode() === 'login' ? (
|
|
|
|
|
<>
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label>Password</label>
|
|
|
|
|
<input
|
|
|
|
|
type="password"
|
|
|
|
|
value={password()}
|
|
|
|
|
onInput={(event) => {
|
|
|
|
|
setPassword(event.currentTarget.value);
|
|
|
|
|
clearMessages();
|
|
|
|
|
}}
|
|
|
|
|
placeholder="Enter your password"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="auth-switch">
|
|
|
|
|
<button type="button" class="auth-link-btn" onClick={() => switchMode('reset')}>
|
|
|
|
|
Forgot password?
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="actions">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="btn primary block-btn"
|
|
|
|
|
disabled={isSubmitting() || !canSubmitLoginCredentials()}
|
|
|
|
|
onClick={directSignIn}
|
|
|
|
|
>
|
|
|
|
|
{isSubmitting() ? 'Signing in...' : 'Sign in'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label>New password</label>
|
|
|
|
|
<input
|
|
|
|
|
type="password"
|
|
|
|
|
value={newPassword()}
|
|
|
|
|
onInput={(event) => {
|
|
|
|
|
setNewPassword(event.currentTarget.value);
|
|
|
|
|
clearMessages();
|
|
|
|
|
}}
|
|
|
|
|
placeholder="Enter your new password"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label>Confirm password</label>
|
|
|
|
|
<input
|
|
|
|
|
type="password"
|
|
|
|
|
value={confirmPassword()}
|
|
|
|
|
onInput={(event) => {
|
|
|
|
|
setConfirmPassword(event.currentTarget.value);
|
|
|
|
|
clearMessages();
|
|
|
|
|
}}
|
|
|
|
|
placeholder="Confirm your new password"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{resetStep() === 'verify' ? (
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label>Verification code</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
maxLength={6}
|
|
|
|
|
value={resetCode()}
|
|
|
|
|
onInput={(event) => {
|
|
|
|
|
setResetCode(event.currentTarget.value.replace(/\D/g, '').slice(0, 6));
|
|
|
|
|
clearMessages();
|
|
|
|
|
}}
|
|
|
|
|
placeholder="Enter 6-digit code"
|
|
|
|
|
/>
|
|
|
|
|
<p class="hint">Code sent to {maskedEmail() || email()}.</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<div class="auth-switch">
|
|
|
|
|
<button type="button" class="auth-link-btn" onClick={() => switchMode('login')}>
|
|
|
|
|
Back to sign in
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="actions">
|
|
|
|
|
{resetStep() === 'request' ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="btn primary block-btn"
|
|
|
|
|
disabled={isSubmitting() || !canSubmitResetRequest()}
|
|
|
|
|
onClick={requestResetCode}
|
|
|
|
|
>
|
|
|
|
|
{isSubmitting() ? 'Sending code...' : 'Send reset code'}
|
|
|
|
|
</button>
|
|
|
|
|
) : (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="btn primary block-btn"
|
|
|
|
|
disabled={isSubmitting() || !canSubmitResetVerify()}
|
|
|
|
|
onClick={verifyResetCode}
|
|
|
|
|
>
|
|
|
|
|
{isSubmitting() ? 'Resetting password...' : 'Verify & reset password'}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</form>
|
|
|
|
|
{info() ? <p class="inline-note auth-inline-msg">{info()}</p> : null}
|
|
|
|
|
{error() ? <p class="error-note auth-inline-msg">{error()}</p> : null}
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
</main>
|
|
|
|
|
);
|
|
|
|
|
}
|