nxtgauge-admin-solid/src/routes/login.tsx
Ashwin Kumar 0ec64be905 feat: unify API paths and upgrade table UIs
- Replace all /api/gateway/* with /api/* to match gateway routing
- Fix AdminShell.tsx: update UGC route to singular and fix logout URL
- Remove Applications and Responses from sidebar (unused)
- Move conflicting route files into folders (company, approval, verification, users, jobs, kb, leads, photographer) as index.tsx to avoid catch-all interference
- Upgrade ProfessionAdminListPage to match Department Management UI:
  • Dark headers with white text
  • Icons on Sort/Filters/Export buttons
  • Pagination UI
  • Improved empty state with Create button
  • Hover effects and consistent spacing
- Update all pages using ProfessionAdminListPage to benefit from new UI
- Fix jobs admin endpoint to use /api/admin/companies/jobs with auth
- Add authentication headers to jobs and leads fetch calls

These changes unify the API architecture and bring a consistent, professional look to all management tables.
2026-04-07 22:12:52 +02:00

355 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-[#E5E7EB] bg-white px-4 text-sm text-[#111827] outline-none transition placeholder:text-[#9CA3AF] focus:border-[#FF5E13] focus:ring-2 focus:ring-[#FFE6D9]';
const labelCls =
'mb-2 block text-[12px] font-semibold uppercase tracking-[0.11em] text-[#4B5563]';
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;
const r = await fetch('/api/auth/login', { method: 'POST', headers, credentials: 'include', body });
status = r.status; payload = await r.json().catch(() => ({}));
if (r.ok) { success = true; }
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/auth/internal/forgot-password/request-code', body: JSON.stringify(reqBody) },
{ url: '/api/auth/internal/forgot-password/request-code', body: JSON.stringify(reqBody) },
{ url: '/api/auth/internal/forgot-password/request-code', body: JSON.stringify({ data: reqBody }) },
{ url: '/api/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/auth/internal/forgot-password/verify-code', '/api/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="min-h-screen bg-[#F9FAFB] text-[#111827]">
<div class="mx-auto flex min-h-screen w-full max-w-[1260px] items-center px-4 py-8 sm:px-6 lg:py-10">
<div class="grid w-full gap-6 lg:grid-cols-[1.05fr_0.95fr]">
<section class="relative hidden overflow-hidden rounded-3xl border border-[#E5E7EB] bg-white p-10 shadow-sm lg:flex lg:min-h-[640px] lg:flex-col lg:justify-between">
<div class="pointer-events-none absolute -right-20 -top-20 h-72 w-72 rounded-full bg-[#FF5E13]/10 blur-3xl" />
<div class="pointer-events-none absolute bottom-[-80px] left-[-80px] h-72 w-72 rounded-full bg-[#0D0D2A]/10 blur-3xl" />
<div>
<img src="/nxtgauge-logo.png" alt="NXTGAUGE" class="h-auto w-[190px] object-contain" />
</div>
<div class="space-y-6">
<p class="inline-flex items-center gap-2 rounded-full border border-[#FFE4D5] bg-[#FFF3EC] px-3 py-1 text-[11px] font-bold uppercase tracking-widest text-[#FF5E13]">
<span class="h-1.5 w-1.5 rounded-full bg-[#FF5E13]" />
Internal Admin Portal
</p>
<h1 class="text-[40px] font-extrabold leading-tight text-[#0D0D2A]">
Dashboard Access
<br />
For Operations Team
</h1>
<p class="max-w-[520px] text-[15px] leading-relaxed text-[#6B7280]">
Sign in to manage roles, approvals, user operations, and runtime module configuration from one control center.
</p>
<div class="grid grid-cols-2 gap-3">
<div class="rounded-xl border border-[#E5E7EB] bg-[#FAFAFA] px-4 py-3">
<p class="text-[12px] font-semibold text-[#6B7280]">Access Control</p>
<p class="mt-1 text-[14px] font-semibold text-[#0D0D2A]">Role Management</p>
</div>
<div class="rounded-xl border border-[#E5E7EB] bg-[#FAFAFA] px-4 py-3">
<p class="text-[12px] font-semibold text-[#6B7280]">Workflows</p>
<p class="mt-1 text-[14px] font-semibold text-[#0D0D2A]">Approvals & Verification</p>
</div>
<div class="rounded-xl border border-[#E5E7EB] bg-[#FAFAFA] px-4 py-3">
<p class="text-[12px] font-semibold text-[#6B7280]">Operations</p>
<p class="mt-1 text-[14px] font-semibold text-[#0D0D2A]">Users & Companies</p>
</div>
<div class="rounded-xl border border-[#E5E7EB] bg-[#FAFAFA] px-4 py-3">
<p class="text-[12px] font-semibold text-[#6B7280]">Runtime</p>
<p class="mt-1 text-[14px] font-semibold text-[#0D0D2A]">Module Visibility</p>
</div>
</div>
</div>
<p class="rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] px-4 py-3 text-[13px] text-[#6B7280]">
Secured with internal access policies. Authorized personnel only.
</p>
</section>
<section class="rounded-3xl border border-[#E5E7EB] bg-white p-6 shadow-sm sm:p-7">
<div class="mb-5 flex items-center justify-between">
<img src="/nxtgauge-logo.png" alt="NXTGAUGE" class="h-auto w-[170px] object-contain" />
<span class="rounded-full bg-[#FFF1EB] px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-[#FF5E13]">
Admin
</span>
</div>
<div class="mt-4">
<h2 class="text-[30px] font-extrabold text-[#0D0D2A]">
{mode() === 'login' ? 'Sign In' : 'Reset Password'}
</h2>
<p class="mt-1.5 text-sm text-[#6B7280]">
{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="rounded-xl border border-red-200 bg-red-50 px-3 py-2 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-[#0D0D2A] text-sm font-semibold text-white transition hover:bg-[#1A1A3A] disabled:cursor-not-allowed disabled:opacity-60"
disabled={isSubmitting()}
onClick={directSignIn}
>
{isSubmitting() ? 'Signing in…' : 'Sign In'}
</button>
<p class="text-xs text-[#6B7280]">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-[#0D0D2A] text-sm font-semibold text-white transition hover:bg-[#1A1A3A] 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-[#0D0D2A] text-sm font-semibold text-white transition hover:bg-[#1A1A3A] 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>
</div>
</main>
);
}