nxtgauge-admin-solid/src/components/AdminShell.tsx

257 lines
9.9 KiB
TypeScript
Raw Normal View History

import { A, useLocation, useNavigate, useSearchParams } from '@solidjs/router';
import { createMemo, createSignal, onMount, type JSX } from 'solid-js';
import AdminSidebar from './AdminSidebar';
import { isExternalIdentity } from '~/lib/admin-auth';
import { clearAdminSession, hasAdminSession, setAdminSession } from '~/lib/admin-session';
import { sidebarCollapsed } from '~/lib/sidebar-state';
type Tab = { href: string; label: string; exact?: boolean };
const TAB_SETS: Array<{ prefixes: string[]; tabs: Tab[] }> = [
{
prefixes: ['/admin/roles'],
tabs: [
{ href: '/admin/roles', label: 'Internal Roles', exact: true },
{ href: '/admin/roles/create', label: 'Create Role' },
{ href: '/admin/roles/templates', label: 'Role Templates' },
],
},
{
prefixes: ['/admin/runtime-roles'],
tabs: [
{ href: '/admin/runtime-roles', label: 'External Roles', exact: true },
{ href: '/admin/runtime-roles/new', label: 'Create External Role' },
],
},
{
prefixes: ['/admin/onboarding-schemas'],
tabs: [
{ href: '/admin/onboarding-schemas', label: 'Onboarding Flows', exact: true },
{ href: '/admin/onboarding-schemas/new', label: 'Create Flow' },
],
},
{
prefixes: ['/admin/internal-dashboard-management'],
tabs: [
{ href: '/admin/internal-dashboard-management', label: 'Internal Dashboards' },
],
},
{
prefixes: ['/admin/external-dashboard-management'],
tabs: [
{ href: '/admin/external-dashboard-management', label: 'External Dashboards' },
],
},
{
prefixes: ['/admin/role-ui-configs'],
tabs: [
{ href: '/admin/role-ui-configs', label: 'Config Inspector', exact: true },
{ href: '/admin/role-ui-configs/new', label: 'Create Config' },
],
},
];
const PAGE_TITLES: Array<{ prefix: string; title: string }> = [
{ prefix: '/admin/workspace', title: 'Dashboard Workspace' },
{ prefix: '/admin/settings', title: 'Settings' },
{ prefix: '/admin/role-modules', title: 'Role Modules' },
{ prefix: '/admin/modules', title: 'Module Management' },
{ prefix: '/admin/responses', title: 'Lead Responses' },
{ prefix: '/admin/applications', title: 'Applications' },
{ prefix: '/admin/financial', title: 'Financial Management' },
{ prefix: '/admin/help', title: 'Support Management' },
{ prefix: '/admin/verification-status', title: 'Verification Status' },
{ prefix: '/admin/verification', title: 'Verification Review' },
{ prefix: '/admin/approval', title: 'Approval Management' },
{ prefix: '/admin/users', title: 'Users Management' },
{ prefix: '/admin/company', title: 'Company Management' },
{ prefix: '/admin/customer', title: 'Customer Management' },
{ prefix: '/admin/candidate', title: 'Candidate Management' },
{ prefix: '/admin/photographer', title: 'Photographer Management' },
{ prefix: '/admin/makeup-artist', title: 'Makeup Artist Management' },
{ prefix: '/admin/tutors', title: 'Tutors Management' },
{ prefix: '/admin/developers', title: 'Developers Management' },
{ prefix: '/admin/graphic-designers', title: 'Graphics Designer Management' },
{ prefix: '/admin/jobs', title: 'Jobs Management' },
{ prefix: '/admin/leads', title: 'Leads Management' },
{ prefix: '/admin/requirements', title: 'Requirement Request' },
{ prefix: '/admin/pricing', title: 'Pricing Management' },
{ prefix: '/admin/invoice', title: 'Invoice Management' },
{ prefix: '/admin/credit', title: 'Credit Management' },
{ prefix: '/admin/ledger', title: 'Ledger Management' },
{ prefix: '/admin/report', title: 'Report Management' },
{ prefix: '/admin/employees', title: 'Employee Management' },
{ prefix: '/admin/roles', title: 'Internal Role Management' },
{ prefix: '/admin/external-role-management', title: 'External Role Management' },
{ prefix: '/admin/internal-role-management', title: 'Internal Role Management' },
{ prefix: '/admin/runtime-roles', title: 'External Role Management' },
{ prefix: '/admin/onboarding-management', title: 'External Onboarding Management' },
{ prefix: '/admin/onboarding-schemas', title: 'External Onboarding Management' },
{ prefix: '/admin/kb', title: 'KB Management' },
{ prefix: '/admin', title: 'Dashboard' },
];
export default function AdminShell(props: { children: JSX.Element }) {
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [checkedSession, setCheckedSession] = createSignal(false);
const tabs = createMemo<Tab[]>(() => {
const path = location.pathname;
for (const set of TAB_SETS) {
if (set.prefixes.some((p) => path === p || path.startsWith(`${p}/`))) {
return set.tabs;
}
}
return [];
});
const isTabActive = (tab: Tab) => {
if (tab.exact) return location.pathname === tab.href;
return location.pathname === tab.href || location.pathname.startsWith(`${tab.href}/`);
};
const pageTitle = createMemo(() => {
const path = location.pathname;
for (const item of PAGE_TITLES) {
if (path === item.prefix || path.startsWith(`${item.prefix}/`)) return item.title;
}
return 'Dashboard';
});
onMount(() => {
// ?_preview=1 or sessionStorage flag — bypass auth for UI testing without a live backend.
// Sets the session cookie AND a sessionStorage flag so all subsequent pages in this tab
// also skip the API check without needing ?_preview=1 in every URL.
const isLocalDev =
typeof window !== 'undefined' &&
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
const isPreview =
searchParams._preview === '1' ||
(typeof sessionStorage !== 'undefined' && sessionStorage.getItem('nxtgauge_admin_preview') === '1');
if (isPreview || isLocalDev) {
if (typeof sessionStorage !== 'undefined') sessionStorage.setItem('nxtgauge_admin_preview', '1');
setAdminSession();
setCheckedSession(true);
return;
}
const verify = async () => {
if (!hasAdminSession()) {
const from = encodeURIComponent(location.pathname + location.search);
navigate(`/login?from=${from}`, { replace: true });
return;
}
try {
const accessToken =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const response = await fetch('/api/gateway/users/auth/me', {
method: 'GET',
headers: {
Accept: 'application/json',
'x-portal-target': 'admin',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || isExternalIdentity(payload)) {
throw new Error('Unauthorized');
}
setCheckedSession(true);
} catch {
clearAdminSession();
const from = encodeURIComponent(location.pathname + location.search);
navigate(`/login?from=${from}`, { replace: true });
}
};
void verify();
});
const onLogout = async () => {
await fetch('/api/gateway/users/auth/logout', {
method: 'POST',
headers: {
Accept: 'application/json',
'x-portal-target': 'admin',
},
credentials: 'include',
}).catch(() => {});
clearAdminSession();
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem('nxtgauge_admin_access_token');
}
navigate('/login', { replace: true });
};
return (
<div class="admin-root">
<header class="admin-header">
<div class="admin-header-left">
<div class="admin-brand">
<img src="/nxtgauge-logo.png" alt="NXTGAUGE" />
</div>
<h1 class="admin-page-heading">{pageTitle()}</h1>
</div>
<div class="admin-header-actions">
<button class="admin-notification-btn" type="button" aria-label="Notifications">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
</button>
<button class="admin-avatar-btn" type="button" aria-label="Admin profile">
<span class="admin-avatar">A</span>
<span class="admin-avatar-meta">
<span class="admin-avatar-name">Admin</span>
<span class="admin-avatar-role">Super Admin</span>
</span>
</button>
<button class="admin-logout-btn" type="button" onClick={onLogout} aria-label="Logout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</header>
{checkedSession() ? (
<div class={`shell${sidebarCollapsed() ? ' sidebar-collapsed' : ''}`}>
<aside class="sidebar-wrap">
<AdminSidebar />
</aside>
<main class="main">
{tabs().length > 0 ? (
<div class="admin-tab-wrap">
<nav class="admin-tabs">
{tabs().map((tab) => (
<A href={tab.href} class={`admin-tab ${isTabActive(tab) ? 'active' : ''}`}>
{tab.label}
</A>
))}
</nav>
</div>
) : null}
<div class="main-inner">{props.children}</div>
</main>
</div>
) : (
<div class="shell">
<main class="main">
<div class="card">
<p class="notice">Checking session...</p>
</div>
</main>
</div>
)}
</div>
);
}