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

837 lines
33 KiB
TypeScript
Raw Normal View History

2026-04-26 23:58:42 +02:00
import { A, useLocation, useNavigate, useSearchParams } from "@solidjs/router";
import {
2026-04-26 23:58:42 +02:00
For,
Show,
createEffect,
createMemo,
createSignal,
onCleanup,
onMount,
type JSX,
} from "solid-js";
import { Bell, Moon, Search, Settings, Sun, User } from "lucide-solid";
import AdminSidebar from "./AdminSidebar";
import { isExternalIdentity } from "~/lib/admin-auth";
import { normalizeAllowedModules } from "~/lib/admin/module-access";
import { sessionExpired, clearSessionExpired, installSessionExpiryWatcher } from "~/lib/session-expired";
import { installSessionRefreshLoop } from "~/lib/session-refresh";
type Tab = { href: string; label: string; exact?: boolean };
type SearchResult = { id: string; title: string; subtitle: string; href: string };
type SearchGroup = { label: string; viewAllHref: string; results: SearchResult[] };
const PAGE_TITLES: Array<{ prefix: string; label: string; exact?: boolean }> = [
2026-04-26 23:58:42 +02:00
{ prefix: "/admin", label: "Dashboard", exact: true },
{ prefix: "/admin/department", label: "Department Management" },
{ prefix: "/admin/designation", label: "Designation Management" },
{ prefix: "/admin/roles", label: "Internal Role Management" },
{ prefix: "/admin/employees", label: "Employee Management" },
{ prefix: "/admin/external-roles", label: "External Role Management" },
{ prefix: "/admin/internal-dashboard-management", label: "Internal Dashboard Management" },
{ prefix: "/admin/external-dashboard-management", label: "External Dashboard Management" },
{ prefix: "/admin/role-ui-configs", label: "External Dashboard Management" },
{ prefix: "/admin/verification", label: "Verification Management" },
{ prefix: "/admin/verification-status", label: "Verification Management" },
{ prefix: "/admin/approval", label: "Approval Management" },
{ prefix: "/admin/approvals", label: "Approval Management" },
{ prefix: "/admin/approval-management", label: "Approval Management" },
{ prefix: "/admin/users", label: "Users Management" },
{ prefix: "/admin/company", label: "Company Management" },
{ prefix: "/admin/candidate", label: "Candidate Management" },
{ prefix: "/admin/customer", label: "Customer Management" },
{ prefix: "/admin/photographer", label: "Photographer Management" },
{ prefix: "/admin/makeup-artist", label: "Makeup Artist Management" },
{ prefix: "/admin/tutors", label: "Tutors Management" },
{ prefix: "/admin/developers", label: "Developers Management" },
{ prefix: "/admin/video-editors", label: "Video Editor Management" },
{ prefix: "/admin/fitness-trainers", label: "Fitness Trainer Management" },
{ prefix: "/admin/catering-services", label: "Catering Services Management" },
{ prefix: "/admin/ugc-content-creators", label: "UGC Content Creator Management" },
{ prefix: "/admin/graphic-designers", label: "Graphic Designer Management" },
{ prefix: "/admin/social-media-managers", label: "Social Media Manager Management" },
{ prefix: "/admin/jobs", label: "Jobs Management" },
{ prefix: "/admin/leads", label: "Leads Management" },
{ prefix: "/admin/applications", label: "Applications Management" },
{ prefix: "/admin/responses", label: "Responses Management" },
{ prefix: "/admin/pricing", label: "Pricing Management" },
{ prefix: "/admin/credit", label: "Credit Management" },
{ prefix: "/admin/coupon", label: "Coupon Management" },
{ prefix: "/admin/discount", label: "Discount Management" },
{ prefix: "/admin/tax", label: "Tax Management" },
{ prefix: "/admin/order", label: "Order Management" },
{ prefix: "/admin/invoice", label: "Invoice Management" },
{ prefix: "/admin/payment-gateway", label: "Payment Gateway Management" },
{ prefix: "/admin/smtp", label: "SMTP Management" },
{ prefix: "/admin/kb", label: "Knowledge Base Management" },
{ prefix: "/admin/notifications", label: "Notifications" },
{ prefix: "/admin/review", label: "Review Management" },
{ prefix: "/admin/support", label: "Support Management" },
{ prefix: "/admin/report", label: "Report Management" },
{ prefix: "/admin/ledger", label: "Ledger Management" },
];
const TAB_SETS: Array<{ prefixes: string[]; tabs: Tab[] }> = [];
const ROUTE_MODULE_KEYS: Array<{ prefix: string; keys: string[] }> = [
2026-04-26 23:58:42 +02:00
{ prefix: "/admin", keys: ["ADMIN_DASHBOARD", "DASHBOARD"] },
{ prefix: "/admin/department", keys: ["DEPARTMENT_MANAGEMENT", "DEPARTMENTS"] },
{ prefix: "/admin/department-management", keys: ["DEPARTMENT_MANAGEMENT", "DEPARTMENTS"] },
{ prefix: "/admin/designation", keys: ["DESIGNATION_MANAGEMENT", "DESIGNATIONS"] },
{ prefix: "/admin/designation-management", keys: ["DESIGNATION_MANAGEMENT", "DESIGNATIONS"] },
{ prefix: "/admin/roles", keys: ["INTERNAL_ROLE_MANAGEMENT", "ROLES"] },
{ prefix: "/admin/employees", keys: ["EMPLOYEE_MANAGEMENT", "EMPLOYEES"] },
{ prefix: "/admin/external-roles", keys: ["EXTERNAL_ROLE_MANAGEMENT", "EXTERNAL_ROLES"] },
{
prefix: "/admin/internal-dashboard-management",
keys: ["INTERNAL_DASHBOARD_MANAGEMENT", "INTERNAL_DASHBOARDS", "INTERNAL_DASHBOARD_CONFIG"],
},
{
prefix: "/admin/external-dashboard-management",
keys: [
"DASHBOARD_CONFIG_MANAGEMENT",
"EXTERNAL_DASHBOARD_MANAGEMENT",
"EXTERNAL_DASHBOARDS",
"EXTERNAL_DASHBOARD_CONFIG",
"RUNTIME_ROLES",
],
},
{
prefix: "/admin/role-ui-configs",
keys: [
"DASHBOARD_CONFIG_MANAGEMENT",
"EXTERNAL_DASHBOARD_MANAGEMENT",
"EXTERNAL_DASHBOARDS",
"EXTERNAL_DASHBOARD_CONFIG",
"RUNTIME_ROLES",
],
},
{ prefix: "/admin/verification", keys: ["VERIFICATION_MANAGEMENT", "VERIFICATIONS"] },
{ prefix: "/admin/verification-status", keys: ["VERIFICATION_MANAGEMENT", "VERIFICATIONS"] },
{ prefix: "/admin/approval", keys: ["APPROVAL_MANAGEMENT", "APPROVALS"] },
{ prefix: "/admin/approvals", keys: ["APPROVAL_MANAGEMENT", "APPROVALS"] },
{ prefix: "/admin/approval-management", keys: ["APPROVAL_MANAGEMENT", "APPROVALS"] },
{ prefix: "/admin/users", keys: ["USER_MANAGEMENT", "USERS"] },
{ prefix: "/admin/company", keys: ["COMPANY_MANAGEMENT", "COMPANIES"] },
{ prefix: "/admin/candidate", keys: ["CANDIDATE_MANAGEMENT", "CANDIDATES"] },
{ prefix: "/admin/customer", keys: ["CUSTOMER_MANAGEMENT", "CUSTOMERS"] },
{ prefix: "/admin/photographer", keys: ["PHOTOGRAPHER_MANAGEMENT", "PHOTOGRAPHERS"] },
{ prefix: "/admin/makeup-artist", keys: ["MAKEUP_ARTIST_MANAGEMENT", "MAKEUP_ARTISTS"] },
{ prefix: "/admin/tutors", keys: ["TUTOR_MANAGEMENT", "TUTORS"] },
{ prefix: "/admin/developers", keys: ["DEVELOPER_MANAGEMENT", "DEVELOPERS"] },
{ prefix: "/admin/video-editors", keys: ["VIDEO_EDITOR_MANAGEMENT", "VIDEO_EDITORS"] },
{ prefix: "/admin/fitness-trainers", keys: ["FITNESS_TRAINER_MANAGEMENT", "FITNESS_TRAINERS"] },
{
prefix: "/admin/catering-services",
keys: ["CATERING_SERVICES_MANAGEMENT", "CATERING_SERVICES"],
},
{
prefix: "/admin/ugc-content-creator",
keys: ["UGC_CONTENT_CREATOR_MANAGEMENT", "UGC_CONTENT_CREATOR"],
},
{
prefix: "/admin/graphic-designers",
keys: ["GRAPHIC_DESIGNER_MANAGEMENT", "GRAPHIC_DESIGNERS"],
},
{
prefix: "/admin/social-media-managers",
keys: ["SOCIAL_MEDIA_MANAGEMENT", "SOCIAL_MEDIA_MANAGER_MANAGEMENT", "SOCIAL_MEDIA_MANAGERS"],
},
{ prefix: "/admin/jobs", keys: ["JOBS_MANAGEMENT", "JOBS"] },
{
prefix: "/admin/leads",
keys: ["LEADS_MANAGEMENT", "LEADS", "REQUIREMENTS_MANAGEMENT", "REQUIREMENTS"],
},
{ prefix: "/admin/applications", keys: ["APPLICATIONS_MANAGEMENT", "APPLICATIONS"] },
{ prefix: "/admin/responses", keys: ["RESPONSES_MANAGEMENT", "RESPONSES"] },
{ prefix: "/admin/pricing", keys: ["PRICING_MANAGEMENT", "PRICING"] },
{ prefix: "/admin/credit", keys: ["CREDIT_MANAGEMENT", "CREDITS"] },
{ prefix: "/admin/coupon", keys: ["COUPON_MANAGEMENT", "COUPONS"] },
{ prefix: "/admin/discount", keys: ["DISCOUNT_MANAGEMENT", "DISCOUNTS"] },
{ prefix: "/admin/tax", keys: ["TAX_MANAGEMENT", "TAXES"] },
{ prefix: "/admin/order", keys: ["ORDER_MANAGEMENT", "ORDERS"] },
{ prefix: "/admin/invoice", keys: ["INVOICE_MANAGEMENT", "INVOICES"] },
{ prefix: "/admin/payment-gateway", keys: ["PAYMENT_GATEWAY_MANAGEMENT", "PAYMENT_GATEWAY"] },
{ prefix: "/admin/smtp", keys: ["SMTP_MANAGEMENT", "SMTP"] },
{ prefix: "/admin/kb", keys: ["KNOWLEDGE_BASE_MANAGEMENT", "KNOWLEDGE_BASE", "KB"] },
{ prefix: "/admin/notifications", keys: ["NOTIFICATIONS_MANAGEMENT", "NOTIFICATIONS"] },
{ prefix: "/admin/review", keys: ["REVIEW_MANAGEMENT", "REVIEWS"] },
{ prefix: "/admin/support", keys: ["SUPPORT_MANAGEMENT", "SUPPORT"] },
{ prefix: "/admin/report", keys: ["REPORT_MANAGEMENT", "REPORTS"] },
{ prefix: "/admin/ledger", keys: ["LEDGER", "LEDGER_MANAGEMENT"] },
];
const SEARCH_MODULES = [
{
2026-04-26 23:58:42 +02:00
label: "Users",
viewAllHref: "/admin/users",
api: "/api/admin/users",
listKeys: ["users", "items"],
titleKeys: ["full_name", "name"],
subtitleKeys: ["email", "phone"],
detailBase: "/admin/users",
},
{
2026-04-26 23:58:42 +02:00
label: "Companies",
viewAllHref: "/admin/company",
api: "/api/admin/companies",
listKeys: ["companies", "items"],
titleKeys: ["name", "companyName"],
subtitleKeys: ["email", "phone"],
detailBase: "/admin/company",
},
{
2026-04-26 23:58:42 +02:00
label: "Employees",
viewAllHref: "/admin/employees",
api: "/api/admin/employees",
listKeys: ["employees", "items"],
titleKeys: ["full_name", "name"],
subtitleKeys: ["email", "department_name"],
detailBase: "/admin/employees",
},
{
2026-04-26 23:58:42 +02:00
label: "Jobs",
viewAllHref: "/admin/jobs",
api: "/api/admin/jobs",
listKeys: ["jobs", "items"],
titleKeys: ["title", "name"],
subtitleKeys: ["status", "company_name"],
detailBase: "/admin/jobs",
},
{
2026-04-26 23:58:42 +02:00
label: "Leads",
viewAllHref: "/admin/leads",
api: "/api/admin/leads",
listKeys: ["leads", "items"],
titleKeys: ["name", "full_name"],
subtitleKeys: ["email", "status"],
detailBase: "/admin/leads",
},
];
function pickStr(obj: Record<string, any>, keys: string[]): string {
for (const k of keys) if (obj[k]) return String(obj[k]);
2026-04-26 23:58:42 +02:00
return "—";
}
function extractList(data: any, keys: string[]): any[] {
if (Array.isArray(data)) return data;
for (const k of keys) if (Array.isArray(data[k])) return data[k];
return [];
}
function GlobalSearch() {
2026-04-26 23:58:42 +02:00
const [query, setQuery] = createSignal("");
const [open, setOpen] = createSignal(false);
const [groups, setGroups] = createSignal<SearchGroup[]>([]);
const [searching, setSearching] = createSignal(false);
let wrapRef!: HTMLDivElement;
let timer: ReturnType<typeof setTimeout>;
const doSearch = async (q: string) => {
const trimmed = q.trim();
2026-04-26 23:58:42 +02:00
if (trimmed.length < 2) {
setGroups([]);
setOpen(false);
return;
}
setSearching(true);
const settled = await Promise.allSettled(
SEARCH_MODULES.map(async (mod) => {
2026-04-26 23:58:42 +02:00
const res = await fetch(`${mod.api}?search=${encodeURIComponent(trimmed)}&limit=4`).catch(
() => null
);
if (!res?.ok) return null;
const data = await res.json().catch(() => null);
if (!data) return null;
const items = extractList(data, mod.listKeys).slice(0, 4);
if (!items.length) return null;
return {
label: mod.label,
viewAllHref: mod.viewAllHref,
results: items.map((item: any) => ({
id: item.id,
title: pickStr(item, mod.titleKeys),
subtitle: pickStr(item, mod.subtitleKeys),
href: `${mod.detailBase}/${item.id}`,
})),
} satisfies SearchGroup;
2026-04-26 23:58:42 +02:00
})
);
2026-04-26 23:58:42 +02:00
setGroups(settled.flatMap((r) => (r.status === "fulfilled" && r.value ? [r.value] : [])));
setOpen(true);
setSearching(false);
};
const handleInput = (val: string) => {
setQuery(val);
clearTimeout(timer);
2026-04-26 23:58:42 +02:00
if (val.trim().length < 2) {
setGroups([]);
setOpen(false);
return;
}
timer = setTimeout(() => doSearch(val), 350);
};
2026-04-26 23:58:42 +02:00
const close = () => {
setOpen(false);
setQuery("");
setGroups([]);
};
const onOutside = (e: MouseEvent) => {
if (!wrapRef.contains(e.target as Node)) setOpen(false);
};
2026-04-26 23:58:42 +02:00
onMount(() => document.addEventListener("mousedown", onOutside));
onCleanup(() => document.removeEventListener("mousedown", onOutside));
return (
2026-03-26 20:58:39 +01:00
<div ref={wrapRef!} class="relative ml-10 w-[560px] shrink-0">
2026-04-26 23:58:42 +02:00
<Search
size={20}
class="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 text-[#9498ad]"
/>
<input
type="text"
value={query()}
placeholder="Search system resources..."
onInput={(e) => handleInput(e.currentTarget.value)}
onFocus={() => groups().length > 0 && setOpen(true)}
2026-04-26 23:58:42 +02:00
onKeyDown={(e) => e.key === "Escape" && close()}
2026-03-26 20:58:39 +01:00
class="h-[68px] w-full rounded-[24px] border-2 border-transparent bg-[#f4f5f8] pl-[60px] pr-6 text-[16px] text-[#0D0D2A] placeholder:text-[rgba(13,13,42,0.4)] outline-none transition-all focus:border-[#e5e7eb] focus:bg-white"
/>
<Show when={open() && groups().length > 0}>
<div class="absolute left-0 right-0 top-[calc(100%+8px)] z-[300] max-h-[460px] overflow-y-auto rounded-2xl border border-[#e5e7eb] bg-white shadow-xl">
<For each={groups()}>
{(group) => (
<div class="border-b border-[#f1f2f5] px-4 py-3 last:border-b-0">
<div class="mb-2 flex items-center justify-between">
2026-04-26 23:58:42 +02:00
<span class="text-[10px] font-bold uppercase tracking-[0.12em] text-[#9aa0b9]">
{group.label}
</span>
<A
href={group.viewAllHref}
onClick={close}
class="text-[12px] font-semibold text-[#FF5E13]"
>
View all
</A>
</div>
<div class="space-y-1">
<For each={group.results}>
{(item) => (
2026-04-26 23:58:42 +02:00
<A
href={item.href}
onClick={close}
class="flex items-center gap-3 rounded-xl px-2 py-2 hover:bg-[#f9fafb]"
>
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-[rgba(255,94,19,0.12)] text-[12px] font-bold text-[#FF5E13]">
{item.title.trim().slice(0, 1).toUpperCase()}
</div>
<div class="min-w-0">
2026-04-26 23:58:42 +02:00
<p class="truncate text-[13px] font-semibold text-[#0D0D2A]">
{item.title}
</p>
<p class="truncate text-[12px] text-[rgba(13,13,42,0.55)]">
{item.subtitle}
</p>
</div>
</A>
)}
</For>
</div>
</div>
)}
</For>
</div>
</Show>
<Show when={open() && !searching() && query().trim().length >= 2 && groups().length === 0}>
<div class="absolute left-0 right-0 top-[calc(100%+8px)] z-[300] rounded-2xl border border-[#e5e7eb] bg-white px-4 py-6 text-center text-[13px] text-[rgba(13,13,42,0.55)]">
No results for "{query()}"
</div>
</Show>
</div>
);
}
function ShowTabs(props: {
tabs: Tab[];
isTabActive: (tab: Tab) => boolean;
setTabsTrackEl: (el: HTMLDivElement) => void;
2026-04-26 23:58:42 +02:00
setTabRefs: (
fn: (prev: Record<string, HTMLAnchorElement>) => Record<string, HTMLAnchorElement>
) => void;
tabIndicator: () => { left: number; width: number; ready: boolean };
}) {
if (props.tabs.length === 0) return null;
return (
2026-04-26 23:58:42 +02:00
<div
ref={props.setTabsTrackEl}
class="relative mb-6 mt-1 flex items-center gap-1 border-b border-[#e5e7eb]"
>
<For each={props.tabs}>
{(tab) => (
<A
href={tab.href}
ref={(el) => props.setTabRefs((prev) => ({ ...prev, [tab.href]: el }))}
2026-04-26 23:58:42 +02:00
aria-current={props.isTabActive(tab) ? "page" : undefined}
class={`px-4 pb-3 pt-3 text-[14px] font-semibold transition-colors ${
2026-04-26 23:58:42 +02:00
props.isTabActive(tab)
? "text-[#FF5E13]"
: "text-[rgba(13,13,42,0.6)] hover:text-[#0D0D2A]"
}`}
>
{tab.label}
</A>
)}
</For>
<div
2026-04-26 23:58:42 +02:00
class={`absolute bottom-0 h-[2px] bg-[#FF5E13] transition-all duration-300 ease-out ${props.tabIndicator().ready ? "opacity-100" : "opacity-0"}`}
style={{ left: `${props.tabIndicator().left}px`, width: `${props.tabIndicator().width}px` }}
/>
</div>
);
}
export default function AdminShell(props: { children: JSX.Element }) {
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
2026-04-26 23:58:42 +02:00
const [checkedSession, setCheckedSession] = createSignal(true);
const [adminName, setAdminName] = createSignal("Admin User");
const [allowedModules, setAllowedModules] = createSignal<string[] | null>(null);
const [isSuperAdmin, setIsSuperAdmin] = createSignal(false);
const [sidebarOpen, setSidebarOpen] = createSignal(false);
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false);
const [unreadCount, setUnreadCount] = createSignal(0);
2026-04-26 23:58:42 +02:00
const [theme, setTheme] = createSignal<"light" | "dark">("light");
const [routeTransitioning, setRouteTransitioning] = createSignal(false);
const [tabsTrackEl, setTabsTrackEl] = createSignal<HTMLDivElement>();
const [tabRefs, setTabRefs] = createSignal<Record<string, HTMLAnchorElement>>({});
const [tabIndicator, setTabIndicator] = createSignal({ left: 0, width: 0, ready: false });
let contentScrollRef: HTMLDivElement | undefined;
const logout = async () => {
try {
2026-04-26 23:58:42 +02:00
const accessToken =
typeof sessionStorage !== "undefined"
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: "";
await fetch("/api/auth/logout", {
method: "POST",
headers: {
2026-04-26 23:58:42 +02:00
Accept: "application/json",
"x-portal-target": "admin",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
2026-04-26 23:58:42 +02:00
credentials: "include",
}).catch(() => null);
} finally {
2026-04-26 23:58:42 +02:00
if (typeof sessionStorage !== "undefined") {
sessionStorage.removeItem("nxtgauge_admin_access_token");
sessionStorage.removeItem("nxtgauge_admin_preview");
}
2026-04-26 23:58:42 +02:00
navigate("/login", { replace: true });
}
};
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) =>
tab.exact
? location.pathname === tab.href
: location.pathname === tab.href || location.pathname.startsWith(`${tab.href}/`);
const refreshTabIndicator = () => {
const activeTab = tabs().find((tab) => isTabActive(tab));
const track = tabsTrackEl();
2026-04-26 23:58:42 +02:00
if (!activeTab || !track) {
setTabIndicator((p) => ({ ...p, ready: false }));
return;
}
const el = tabRefs()[activeTab.href];
if (!el) return;
setTabIndicator({ left: el.offsetLeft, width: el.offsetWidth, ready: true });
};
createEffect(() => {
tabs();
location.pathname;
requestAnimationFrame(refreshTabIndicator);
});
createEffect(() => {
location.pathname;
2026-04-26 23:58:42 +02:00
if (contentScrollRef) {
contentScrollRef.scrollTop = 0;
}
});
const goToLoginAfterExpiry = () => {
clearSessionExpired();
if (typeof sessionStorage !== "undefined") {
sessionStorage.removeItem("nxtgauge_admin_access_token");
sessionStorage.removeItem("nxtgauge_admin_preview");
}
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, {
replace: true,
});
};
onMount(() => {
installSessionExpiryWatcher();
installSessionRefreshLoop();
2026-04-26 23:58:42 +02:00
const savedTheme = (
typeof localStorage !== "undefined" ? localStorage.getItem("nxtgauge_admin_theme") : null
) as "light" | "dark" | null;
const nextTheme = savedTheme === "dark" ? "dark" : "light";
setTheme(nextTheme);
2026-04-26 23:58:42 +02:00
if (typeof document !== "undefined") {
document.documentElement.setAttribute("data-theme", nextTheme);
}
2026-04-26 23:58:42 +02:00
window.addEventListener("resize", refreshTabIndicator);
onCleanup(() => window.removeEventListener("resize", refreshTabIndicator));
// Fetch unread notification count and poll every 30 seconds
const fetchUnreadCount = async () => {
try {
2026-04-26 23:58:42 +02:00
const accessToken =
typeof sessionStorage !== "undefined"
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: "";
if (!accessToken) return;
2026-04-26 23:58:42 +02:00
const res = await fetch("/api/me/notifications/unread-count", {
method: "GET",
headers: {
2026-04-26 23:58:42 +02:00
Accept: "application/json",
"x-portal-target": "admin",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
2026-04-26 23:58:42 +02:00
credentials: "include",
});
if (res.ok) {
const data = await res.json();
setUnreadCount(data.unread_count || 0);
}
} catch (e) {
2026-04-26 23:58:42 +02:00
console.error("Failed to fetch unread count:", e);
}
};
fetchUnreadCount();
const interval = setInterval(fetchUnreadCount, 30000);
onCleanup(() => clearInterval(interval));
const verify = async () => {
// The server-verified session check below (/api/auth/session) is the
// real security boundary. It redirects to /login on any failure.
try {
2026-04-26 23:58:42 +02:00
const accessToken =
typeof sessionStorage !== "undefined"
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: "";
const response = await fetch("/api/auth/session", {
method: "GET",
headers: {
2026-04-26 23:58:42 +02:00
Accept: "application/json",
"x-portal-target": "admin",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
2026-04-26 23:58:42 +02:00
credentials: "include",
});
const payload = await response.json().catch(() => ({}));
2026-04-26 23:58:42 +02:00
if (!response.ok || isExternalIdentity(payload)) throw new Error("Unauthorized");
if (payload?.full_name) setAdminName(payload.full_name);
const roleKey = String(
2026-04-26 23:58:42 +02:00
payload?.active_role ||
payload?.role ||
payload?.user?.active_role ||
payload?.user?.active_role_key ||
payload?.user?.role ||
payload?.user?.role_key ||
""
).toUpperCase();
2026-04-26 23:58:42 +02:00
setIsSuperAdmin(roleKey === "SUPER_ADMIN");
try {
2026-04-26 23:58:42 +02:00
const res = await fetch("/api/runtime-config", {
method: "GET",
headers: {
2026-04-26 23:58:42 +02:00
Accept: "application/json",
"x-portal-target": "admin",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
2026-04-26 23:58:42 +02:00
credentials: "include",
});
const runtime = await res.json().catch(() => ({}));
if (res.ok) {
setAllowedModules(normalizeAllowedModules(runtime));
2026-04-26 23:58:42 +02:00
const activeRole = String(
runtime?.active_role || runtime?.user?.active_role || roleKey || ""
).toUpperCase();
if (activeRole) setIsSuperAdmin(activeRole === "SUPER_ADMIN");
} else {
setAllowedModules(null);
}
} catch {
setAllowedModules(null);
}
setCheckedSession(true);
} catch {
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, {
2026-04-26 23:58:42 +02:00
replace: true,
});
}
};
void verify();
});
const pageTitle = createMemo(() => {
const path = location.pathname;
for (const entry of PAGE_TITLES) {
2026-04-26 23:58:42 +02:00
if (
entry.exact
? path === entry.prefix
: path === entry.prefix || path.startsWith(`${entry.prefix}/`)
) {
return entry.label;
}
}
2026-04-26 23:58:42 +02:00
return "Admin";
});
const adminInitials = createMemo(() => {
2026-04-26 23:58:42 +02:00
if (adminName().trim().toLowerCase() === "admin user") return "AD";
const parts = adminName()
.split(" ")
.map((s) => s.trim())
.filter(Boolean);
if (parts.length === 0) return "U";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
});
createEffect(() => {
const t = theme();
2026-04-26 23:58:42 +02:00
if (typeof localStorage !== "undefined") localStorage.setItem("nxtgauge_admin_theme", t);
if (typeof document !== "undefined") document.documentElement.setAttribute("data-theme", t);
});
2026-04-26 23:58:42 +02:00
const toggleTheme = () => setTheme((v) => (v === "dark" ? "light" : "dark"));
const isDark = () => theme() === "dark";
createEffect(() => {
if (!checkedSession()) return;
if (isSuperAdmin()) return;
const modules = allowedModules();
if (!modules || modules.length === 0) return;
const path = location.pathname;
2026-04-26 23:58:42 +02:00
if (path === "/admin") return;
const matches = ROUTE_MODULE_KEYS.filter(
2026-04-26 23:58:42 +02:00
(entry) => path === entry.prefix || path.startsWith(`${entry.prefix}/`)
);
const guard = matches.sort((a, b) => b.prefix.length - a.prefix.length)[0];
if (!guard) return;
2026-04-26 23:58:42 +02:00
const allowed = new Set(
modules
.map((m) =>
String(m || "")
.trim()
.toUpperCase()
)
.filter(Boolean)
);
const ok = guard.keys.some((k) => allowed.has(String(k).toUpperCase()));
if (ok) return;
2026-04-26 23:58:42 +02:00
navigate(
`/admin?denied=${encodeURIComponent(guard.keys[0])}&from=${encodeURIComponent(path)}`,
{ replace: true }
);
});
return (
2026-04-26 23:58:42 +02:00
<div
class="min-h-screen"
style={{
background: isDark() ? "#0B1220" : "#F9FAFB",
color: isDark() ? "#E5E7EB" : "#0D0D2A",
}}
>
<Show when={sessionExpired()}>
<div
style="position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;background:rgba(13,13,42,0.55);padding:16px"
role="alertdialog"
aria-modal="true"
aria-labelledby="session-expired-title"
>
<div
style={{
background: isDark() ? "#111827" : "white",
color: isDark() ? "#E5E7EB" : "#0D0D2A",
"border-radius": "12px",
padding: "24px",
"max-width": "380px",
width: "100%",
"box-shadow": "0 20px 40px rgba(0,0,0,0.25)",
}}
>
<h2 id="session-expired-title" style="font-size:16px;font-weight:700;margin:0 0 8px">
You have been logged out
</h2>
<p style="font-size:14px;color:#6B7280;margin:0 0 20px">
Your session has expired for security reasons. Please log in again to continue.
</p>
<button
type="button"
onClick={goToLoginAfterExpiry}
style="width:100%;padding:10px 16px;border-radius:8px;border:none;background:#FF5E13;color:white;font-size:14px;font-weight:600;cursor:pointer"
>
Log In Again
</button>
</div>
</div>
</Show>
<Show
when={checkedSession()}
2026-04-26 23:58:42 +02:00
fallback={
<div class="flex min-h-screen items-center justify-center text-[14px] text-[rgba(13,13,42,0.55)]">
Checking session
</div>
}
>
<div style="display:flex;height:100vh;overflow:hidden">
2026-04-26 23:58:42 +02:00
<div
class={`fixed inset-0 z-20 bg-black/30 transition-opacity lg:hidden ${sidebarOpen() ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`}
onClick={() => setSidebarOpen(false)}
/>
<div style="height:100%;display:flex;flex-shrink:0">
<AdminSidebar
collapsed={sidebarCollapsed()}
onToggle={() => setSidebarCollapsed((v) => !v)}
onNavigate={() => setSidebarOpen(false)}
adminName={adminName()}
adminInitials={adminInitials()}
theme={theme()}
allowedModules={allowedModules()}
isSuperAdmin={isSuperAdmin()}
/>
</div>
<div class="flex min-w-0 flex-1 flex-col">
2026-04-26 23:58:42 +02:00
<header
style={`height:64px;border-bottom:1px solid ${isDark() ? "#1F2937" : "#E5E7EB"};background:${isDark() ? "#111827" : "white"};flex-shrink:0`}
>
<div style="display:flex;height:100%;width:100%;align-items:center;justify-content:flex-end;padding:0 32px">
<div style="display:flex;align-items:center;gap:4px">
2026-04-26 23:58:42 +02:00
<button
type="button"
onClick={toggleTheme}
style={`display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:${isDark() ? "#CBD5E1" : "#6B7280"};background:none;border:none;cursor:pointer`}
aria-label="Toggle theme"
>
<Show when={isDark()} fallback={<Moon size={18} />}>
<Sun size={18} />
</Show>
</button>
2026-04-26 23:58:42 +02:00
<button
type="button"
style={`position:relative;display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:${isDark() ? "#CBD5E1" : "#6B7280"};background:none;border:none;cursor:pointer`}
aria-label="Notifications"
>
<Bell size={18} />
<Show when={unreadCount() > 0}>
<span
style={`position:absolute;right:8px;top:8px;width:7px;height:7px;border-radius:50%;border:2px solid ${isDark() ? "#111827" : "white"};background:#FF5E13`}
/>
</Show>
</button>
<button
type="button"
style={`display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:${isDark() ? "#CBD5E1" : "#6B7280"};background:none;border:none;cursor:pointer`}
aria-label="Settings"
>
<Settings size={18} />
</button>
2026-04-26 23:58:42 +02:00
<div
style={`width:1px;height:24px;background:${isDark() ? "#1F2937" : "#E5E7EB"};margin:0 8px`}
/>
<button
type="button"
style="display:inline-flex;align-items:center;gap:8px;border-radius:8px;padding:4px 8px 4px 4px;background:none;border:none;cursor:pointer"
aria-label="User menu"
>
<div style="width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#FF5E13,#ff7a3d);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:white;flex-shrink:0">
{adminInitials()}
</div>
<div style="text-align:left">
2026-04-26 23:58:42 +02:00
<p
style={`font-size:13px;font-weight:600;color:${isDark() ? "#E5E7EB" : "#111827"};line-height:1.3`}
>
{adminName()}
</p>
<p
style={`font-size:11px;color:${isDark() ? "#94A3B8" : "#6B7280"};line-height:1.3`}
>
Super Admin
</p>
</div>
</button>
<button
type="button"
onClick={() => void logout()}
2026-04-26 23:58:42 +02:00
style={`height:32px;border-radius:8px;border:1px solid ${isDark() ? "#374151" : "#E5E7EB"};background:${isDark() ? "#1F2937" : "white"};padding:0 12px;font-size:12px;font-weight:600;color:${isDark() ? "#E5E7EB" : "#374151"};cursor:pointer`}
>
Logout
</button>
</div>
</div>
</header>
<div
2026-04-26 23:58:42 +02:00
ref={(el) => {
contentScrollRef = el;
}}
class="min-h-0 flex-1 overflow-y-scroll"
2026-04-26 23:58:42 +02:00
style={{ background: isDark() ? "#0B1220" : "#F9FAFB", "scrollbar-gutter": "stable" }}
>
<main
class="admin-main"
style={{
2026-04-26 23:58:42 +02:00
width: "100%",
padding: "28px 24px 36px 24px",
filter: isDark() ? "brightness(0.96)" : "none",
transition: "opacity 150ms ease",
opacity: routeTransitioning() ? "0.92" : "1",
}}
>
{props.children}
</main>
</div>
</div>
</div>
</Show>
</div>
);
}