- 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.
355 lines
18 KiB
TypeScript
355 lines
18 KiB
TypeScript
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 1–2 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>
|
||
);
|
||
}
|