nxtgauge-admin-solid/src/routes/login.tsx
Ashwin Kumar 801bf293a0 style: match login page to public website design language
- Dark navy gradient background (matches public site's MarketingBackground feel)
- Glassmorphism card: rounded-[28px] border-white/30 bg-white/92 backdrop-blur-xl
  shadow-[0_28px_60px_-34px_rgba(2,6,23,0.88)] — identical to public site card
- Input style: rounded-xl border-[#cfd4e3] focus:border-[#fd6216] focus:ring-[#ffd8c3]
- Label style: text-xs font-semibold uppercase tracking-[0.11em] text-[#4b546f]
- Field spacing: space-y-3.5 with mb-2 labels — matches public site exactly
- Password show/hide toggle (eye icon) matching public site
- Radial glow accent blobs behind the background
- Left brand panel kept as frosted glass over the dark bg (lg+ only)
- Navy submit button (intentional admin deviation from public orange)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 16:01:07 +01:00

356 lines
18 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 { useNavigate } from '@solidjs/router';
import { Show, createMemo, createSignal, onMount } from 'solid-js';
import { isExternalIdentity, pickManagementLoginError } from '~/lib/admin-auth';
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;
return String(payload?.challenge_id || '').trim();
}
function pickMaskedEmail(payload: any, fallback: string): string {
const direct = String(payload?.maskedEmail || '').trim();
if (direct) return direct;
const nested = String(payload?.data?.maskedEmail || '').trim();
return nested || fallback;
}
/* Matches public website input exactly */
const inputCls =
'h-11 w-full rounded-xl border border-[#cfd4e3] bg-white px-4 text-sm text-[#101228] outline-none transition placeholder:text-[#9ba3bc] focus:border-[#fd6216] focus:ring-2 focus:ring-[#ffd8c3]';
const labelCls =
'mb-2 block text-xs font-semibold uppercase tracking-[0.11em] text-[#4b546f]';
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 [showPassword, setShowPassword] = createSignal(false);
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 canSubmitLogin = createMemo(() => email().trim().length > 0 && password().trim().length > 0);
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 clearMessages = () => { setError(''); setInfo(''); };
const switchMode = (next: AuthMode) => {
clearMessages();
setMode(next);
if (next === 'login') {
setResetStep('request');
setResetCode(''); setChallengeId(''); setMaskedEmail('');
setNewPassword(''); setConfirmPassword('');
}
};
onMount(() => { if (hasAdminSession()) navigate('/admin', { replace: true }); });
const completeAdminLogin = () => {
setAdminSession();
const from = new URLSearchParams(window.location.search).get('from');
navigate(from && from.startsWith('/admin') ? from : '/admin', { replace: true });
};
const directSignIn = async () => {
clearMessages();
if (!canSubmitLogin()) { setError('Email and password are required.'); return; }
setIsSubmitting(true);
try {
const body = JSON.stringify({ email: email().trim().toLowerCase(), password: password(), loginTarget: 'admin' });
const headers = { 'Content-Type': 'application/json', Accept: 'application/json', 'x-portal-target': 'admin' };
let payload: any = {}; let status = 500; let success = false;
for (const url of ['/api/gateway/users/auth/internal/login', '/api/gateway/auth/internal/login']) {
const r = await fetch(url, { method: 'POST', headers, credentials: 'include', body });
status = r.status; payload = await r.json().catch(() => ({}));
if (r.ok) { success = true; break; }
}
if (!success) {
const fallback = status === 502 ? 'Auth service unavailable (502). Please retry in 12 minutes.' : 'Sign in failed.';
throw new Error(pickManagementLoginError(payload) || fallback);
}
if (isExternalIdentity(payload)) throw new Error('External users cannot use this portal.');
const token = String(payload?.access_token || payload?.accessToken || '').trim();
if (token) sessionStorage.setItem('nxtgauge_admin_access_token', token);
completeAdminLogin();
} catch (e: any) { setError(String(e?.message || 'Sign in failed.')); }
finally { setIsSubmitting(false); }
};
const requestResetCode = async () => {
clearMessages();
if (!canSubmitResetRequest()) { setError('All fields are required.'); return; }
if (newPassword() !== confirmPassword()) { setError('Passwords do not match.'); return; }
const trimmedEmail = email().trim().toLowerCase();
const pw = newPassword().trim();
const reqBody = { email: trimmedEmail, newPassword: pw, new_password: pw, password: pw };
setIsSubmitting(true);
try {
let payload: any = {}; let status = 500;
for (const { url, body } of [
{ url: '/api/gateway/users/auth/internal/forgot-password/request-code', body: JSON.stringify(reqBody) },
{ url: '/api/gateway/auth/internal/forgot-password/request-code', body: JSON.stringify(reqBody) },
{ url: '/api/gateway/users/auth/internal/forgot-password/request-code', body: JSON.stringify({ data: reqBody }) },
{ url: '/api/gateway/auth/internal/forgot-password/request-code', body: JSON.stringify({ data: reqBody }) },
]) {
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body });
status = r.status; payload = await r.json().catch(() => ({}));
if (r.ok) break;
}
const cId = pickChallengeId(payload);
if (!cId) {
const fallback = status === 502 ? 'Service unavailable (502). Please retry.' : 'Failed to send reset code.';
throw new Error(String(payload?.message || payload?.error || fallback).trim());
}
setChallengeId(cId);
setMaskedEmail(pickMaskedEmail(payload, trimmedEmail));
setResetStep('verify');
const dev = String(payload?.debugCode || payload?.data?.debugCode || '').trim();
setInfo(dev ? `Code sent. [DEV: ${dev}]` : 'Reset code sent to your email.');
} catch (e: any) { setError(String(e?.message || 'Failed to send reset code.')); }
finally { setIsSubmitting(false); }
};
const verifyResetCode = async () => {
clearMessages();
if (!canSubmitResetVerify()) { setError('A valid 6-digit code is required.'); return; }
if (newPassword().trim() !== confirmPassword().trim()) { setError('Passwords do not match.'); return; }
const pw = newPassword().trim();
const cId = challengeId().trim();
const code = resetCode().trim();
const body = JSON.stringify({ challengeId: cId, challenge_id: cId, code, otp: code, verificationCode: code, verification_code: code, newPassword: pw, new_password: pw, password: pw });
setIsSubmitting(true);
try {
let payload: any = {}; let status = 500; let success = false;
for (const url of ['/api/gateway/users/auth/internal/forgot-password/verify-code', '/api/gateway/auth/internal/forgot-password/verify-code']) {
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body });
status = r.status; payload = await r.json().catch(() => ({}));
if (r.ok) { success = true; break; }
}
if (!success) {
const fallback = status === 502 ? 'Service unavailable (502). Please retry.' : 'Password reset failed.';
throw new Error(String(payload?.message || payload?.error || fallback).trim());
}
setPassword(''); switchMode('login');
setInfo('Password reset successful. Please sign in with your new password.');
} catch (e: any) { setError(String(e?.message || 'Password reset failed.')); }
finally { setIsSubmitting(false); }
};
return (
<main
class="relative min-h-screen overflow-x-clip"
style="background: linear-gradient(135deg, #0a1d37 0%, #0d2347 40%, #0a1d37 70%, #081628 100%)"
>
{/* Radial glow accents matching public site MarketingBackground feel */}
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<div class="absolute -top-40 -left-40 h-[500px] w-[500px] rounded-full bg-[#fd6216]/8 blur-[100px]" />
<div class="absolute top-1/2 right-0 h-[400px] w-[400px] -translate-y-1/2 rounded-full bg-[#fd6216]/5 blur-[80px]" />
<div class="absolute bottom-0 left-1/3 h-[300px] w-[300px] rounded-full bg-white/3 blur-[60px]" />
</div>
<div class="relative z-10 mx-auto grid min-h-screen w-full max-w-[1260px] items-center gap-6 px-4 py-8 sm:px-6 lg:grid-cols-[1.02fr_0.98fr] lg:py-10">
{/* ── Left brand panel (hidden on mobile) ── */}
<section class="relative hidden min-h-[620px] overflow-hidden rounded-[28px] border border-white/15 bg-white/5 p-10 text-white backdrop-blur-sm lg:flex lg:flex-col lg:justify-between">
{/* Inner glow */}
<div class="pointer-events-none absolute -top-24 -right-24 h-64 w-64 rounded-full bg-[#fd6216]/12 blur-3xl" />
{/* Logo */}
<img src="/nxtgauge-logo.png" alt="NXTGAUGE" class="h-10 w-auto brightness-0 invert" />
{/* Main copy */}
<div class="space-y-5">
<p class="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-3 py-1 text-[11px] font-bold uppercase tracking-widest text-orange-300">
<span class="h-1.5 w-1.5 rounded-full bg-orange-400" />
Internal Admin Portal
</p>
<h1 class="text-[40px] font-extrabold leading-tight text-white xl:text-[46px]">
Welcome back<br />to Nxtgauge.
</h1>
<p class="text-[15px] leading-relaxed text-white/60">
Sign in to manage operations, roles, and approval workflows from one secure control panel.
</p>
<ul class="space-y-3 text-sm text-white/70">
{(['Role & permission management', 'Approval workflow control', 'User & company oversight', 'Dashboard configuration'] as const).map(item => (
<li class="flex items-center gap-3">
<span class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-[#fd6216]/20 text-[#fd6216]">
<svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l2.5 2.5L9 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
{item}
</li>
))}
</ul>
</div>
{/* Bottom tag */}
<p class="rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-[13px] text-white/50">
🔒 Secured with internal access policies. Authorised personnel only.
</p>
</section>
{/* ── Right form card — matches public website card exactly ── */}
<section class="rounded-[28px] border border-white/30 bg-white/92 p-5 text-[#101228] shadow-[0_28px_60px_-34px_rgba(2,6,23,0.88)] backdrop-blur-xl sm:p-6">
{/* Logo (always visible) */}
<img src="/nxtgauge-logo.png" alt="NXTGAUGE" class="h-8 w-auto" />
<div class="mt-4">
<h2 class="text-3xl font-extrabold text-[#101228]">
{mode() === 'login' ? 'Sign In' : 'Reset Password'}
</h2>
<p class="mt-1.5 text-sm text-[#535e7a]">
{mode() === 'login' ? 'Internal team access only.' : 'Use your internal email to reset access.'}
</p>
</div>
<div class="mt-5 space-y-3.5">
{/* Email */}
<div>
<label class={labelCls}>Email</label>
<input
type="email"
value={email()}
onInput={(e) => { setEmail(e.currentTarget.value); clearMessages(); }}
placeholder="Enter your email"
class={inputCls}
autocomplete="email"
/>
</div>
{/* Login mode */}
<Show when={mode() === 'login'}>
<div>
<div class="mb-2 flex items-center justify-between">
<label class={labelCls} style="margin-bottom:0">Password</label>
<button type="button" class="text-xs font-semibold text-[#fd6216] underline" onClick={() => switchMode('reset')}>
Forgot?
</button>
</div>
<div class="relative">
<input
type={showPassword() ? 'text' : 'password'}
value={password()}
onInput={(e) => { setPassword(e.currentTarget.value); clearMessages(); }}
placeholder="Enter your password"
class={`${inputCls} pr-11`}
autocomplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(v => !v)}
class="absolute inset-y-0 right-0 flex items-center px-3 text-[#5b6480] transition hover:text-[#1b2440]"
>
<Show when={showPassword()} fallback={
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" 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>
}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 3l18 18M10.58 10.58a2 2 0 002.83 2.83" />
<path stroke-linecap="round" stroke-linejoin="round" d="M9.88 5.09A10.96 10.96 0 0112 4.91c5.52 0 10 4.09 10 7.09 0 1.2-.72 2.53-1.95 3.72M6.1 6.1C3.54 7.58 2 9.79 2 12c0 3 4.48 7.09 10 7.09 1.72 0 3.36-.4 4.84-1.12" />
</svg>
</Show>
</button>
</div>
</div>
</Show>
{/* Reset mode */}
<Show when={mode() === 'reset'}>
<div>
<label class={labelCls}>New Password</label>
<input type="password" value={newPassword()} onInput={(e) => { setNewPassword(e.currentTarget.value); clearMessages(); }} placeholder="Minimum 8 characters" class={inputCls} />
</div>
<div>
<label class={labelCls}>Confirm Password</label>
<input type="password" value={confirmPassword()} onInput={(e) => { setConfirmPassword(e.currentTarget.value); clearMessages(); }} placeholder="Repeat new password" class={inputCls} />
</div>
<Show when={resetStep() === 'verify'}>
<div>
<label class={labelCls}>Verification Code</label>
<input
type="text" inputMode="numeric" maxLength={6}
value={resetCode()}
onInput={(e) => { setResetCode(e.currentTarget.value.replace(/\D/g, '').slice(0, 6)); clearMessages(); }}
placeholder="000000"
class={`${inputCls} text-center text-lg tracking-[0.3em]`}
/>
<p class="mt-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs text-emerald-800">
Code sent to <span class="font-semibold">{maskedEmail() || email()}</span>
</p>
</div>
</Show>
</Show>
{/* Error / Info */}
<Show when={error()}>
<p class="text-sm font-medium text-red-600">{error()}</p>
</Show>
<Show when={info()}>
<p class="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{info()}</p>
</Show>
{/* Sign In button */}
<Show when={mode() === 'login'}>
<button
type="button"
class="h-11 w-full rounded-xl bg-[#0a1d37] text-sm font-semibold text-white transition hover:bg-[#0f2a4e] disabled:cursor-not-allowed disabled:opacity-60"
disabled={isSubmitting()}
onClick={directSignIn}
>
{isSubmitting() ? 'Signing in…' : 'Sign In'}
</button>
<p class="text-xs text-[#6a7390]">Secure login with internal access policies.</p>
</Show>
{/* Reset buttons */}
<Show when={mode() === 'reset'}>
<Show when={resetStep() === 'request'} fallback={
<button
type="button"
class="h-11 w-full rounded-xl bg-[#0a1d37] text-sm font-semibold text-white transition hover:bg-[#0f2a4e] disabled:cursor-not-allowed disabled:opacity-60"
disabled={isSubmitting() || !canSubmitResetVerify()}
onClick={verifyResetCode}
>
{isSubmitting() ? 'Resetting…' : 'Verify & Reset Password'}
</button>
}>
<button
type="button"
class="h-11 w-full rounded-xl bg-[#0a1d37] text-sm font-semibold text-white transition hover:bg-[#0f2a4e] disabled:cursor-not-allowed disabled:opacity-60"
disabled={isSubmitting() || !canSubmitResetRequest()}
onClick={requestResetCode}
>
{isSubmitting() ? 'Sending Code…' : 'Send Reset Code'}
</button>
</Show>
<button type="button" class="w-full text-center text-sm font-semibold text-[#fd6216] underline" onClick={() => switchMode('login')}>
Back to sign in
</button>
</Show>
</div>
</section>
</div>
</main>
);
}