Use full-page navigation after login/logout so the dashboard reloads with the fresh auth cookie, and mark login/logout responses no-store. Fixes #2100 Co-authored-by: Cursor <cursoragent@cursor.com>
370 lines
12 KiB
JavaScript
370 lines
12 KiB
JavaScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { usePathname } from "next/navigation";
|
|
import Link from "next/link";
|
|
import PropTypes from "prop-types";
|
|
import ProviderIcon from "@/shared/components/ProviderIcon";
|
|
import HeaderMenu from "@/shared/components/HeaderMenu";
|
|
import HeaderLanguage from "@/shared/components/HeaderLanguage";
|
|
import ThemeToggle from "@/shared/components/ThemeToggle";
|
|
import DonateModal from "@/shared/components/DonateModal";
|
|
import { useHeaderSearchStore } from "@/store/headerSearchStore";
|
|
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
|
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS } from "@/shared/constants/providers";
|
|
import { translate } from "@/i18n/runtime";
|
|
|
|
const getPageInfo = (pathname) => {
|
|
if (!pathname) return { title: "", description: "", breadcrumbs: [] };
|
|
|
|
// Media provider detail: /dashboard/media-providers/[kind]/[id]
|
|
const mediaDetailMatch = pathname.match(/\/media-providers\/([^/]+)\/([^/]+)$/);
|
|
if (mediaDetailMatch) {
|
|
const kindId = mediaDetailMatch[1];
|
|
const providerId = mediaDetailMatch[2];
|
|
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kindId);
|
|
const provider = AI_PROVIDERS[providerId];
|
|
return {
|
|
title: provider?.name || providerId,
|
|
description: "",
|
|
breadcrumbs: [
|
|
{ label: "Media Providers", href: `/dashboard/media-providers/${kindId}` },
|
|
{ label: kindConfig?.label || kindId, href: `/dashboard/media-providers/${kindId}` },
|
|
{ label: provider?.name || providerId, image: `/providers/${providerId}.png` },
|
|
],
|
|
};
|
|
}
|
|
|
|
// Media provider kind: /dashboard/media-providers/[kind]
|
|
const mediaKindMatch = pathname.match(/\/media-providers\/([^/]+)$/);
|
|
if (mediaKindMatch) {
|
|
const kindId = mediaKindMatch[1];
|
|
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kindId);
|
|
return {
|
|
title: kindConfig?.label || kindId,
|
|
description: `Manage your ${kindConfig?.label || kindId} providers`,
|
|
icon: kindConfig?.icon || "perm_media",
|
|
breadcrumbs: [],
|
|
};
|
|
}
|
|
|
|
// Provider detail page: /dashboard/providers/[id]
|
|
const providerMatch = pathname.match(/\/providers\/([^/]+)$/);
|
|
if (providerMatch) {
|
|
const providerId = providerMatch[1];
|
|
const providerInfo =
|
|
OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId];
|
|
if (providerInfo) {
|
|
return {
|
|
title: providerInfo.name,
|
|
description: "",
|
|
breadcrumbs: [
|
|
{ label: "Providers", href: "/dashboard/providers" },
|
|
{
|
|
label: providerInfo.name,
|
|
image: `/providers/${providerInfo.id}.png`,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
}
|
|
|
|
if (pathname.includes("/providers") && !pathname.includes("/media-providers"))
|
|
return {
|
|
title: "Providers",
|
|
description: "Manage your AI provider connections",
|
|
icon: "dns",
|
|
breadcrumbs: [],
|
|
};
|
|
if (pathname.includes("/combos"))
|
|
return {
|
|
title: "Combos",
|
|
description: "Model combos with fallback",
|
|
icon: "layers",
|
|
breadcrumbs: [],
|
|
};
|
|
if (pathname.includes("/usage"))
|
|
return {
|
|
title: "Usage & Analytics",
|
|
description:
|
|
"Monitor your API usage, token consumption, and request logs",
|
|
icon: "bar_chart",
|
|
breadcrumbs: [],
|
|
};
|
|
if (pathname.includes("/auth-files"))
|
|
return {
|
|
title: "Auth Files",
|
|
description: "Map provider credentials stored in the local database",
|
|
icon: "vpn_key",
|
|
breadcrumbs: [],
|
|
};
|
|
if (pathname.includes("/quota"))
|
|
return {
|
|
title: "Quota Tracker",
|
|
description: "Track and manage your API quota limits",
|
|
icon: "data_usage",
|
|
breadcrumbs: [],
|
|
};
|
|
if (pathname.includes("/mitm"))
|
|
return {
|
|
title: "MITM Proxy",
|
|
description: "Intercept CLI tool traffic and route through 9Router",
|
|
icon: "security",
|
|
breadcrumbs: [],
|
|
};
|
|
if (pathname.includes("/token-saver"))
|
|
return {
|
|
title: "Token Saver",
|
|
description: "Compress prompts and outputs to save tokens",
|
|
icon: "savings",
|
|
breadcrumbs: [],
|
|
};
|
|
if (pathname.includes("/cli-tools"))
|
|
return {
|
|
title: "CLI Tools",
|
|
description: "Configure CLI tools",
|
|
icon: "terminal",
|
|
breadcrumbs: [],
|
|
};
|
|
if (pathname.includes("/proxy-pools"))
|
|
return {
|
|
title: "Proxy Pools",
|
|
description: "Manage your proxy pool configurations",
|
|
icon: "lan",
|
|
breadcrumbs: [],
|
|
};
|
|
if (pathname.includes("/skills"))
|
|
return {
|
|
title: "Agent Skills",
|
|
description: "Copy a link and paste to your AI to use 9Router — no install needed",
|
|
icon: "extension",
|
|
breadcrumbs: [],
|
|
};
|
|
if (pathname.includes("/endpoint"))
|
|
return {
|
|
title: "Endpoint",
|
|
description: "API endpoint configuration",
|
|
icon: "api",
|
|
breadcrumbs: [],
|
|
};
|
|
if (pathname.includes("/profile"))
|
|
return {
|
|
title: "Settings",
|
|
description: "Manage your preferences",
|
|
icon: "settings",
|
|
breadcrumbs: [],
|
|
};
|
|
if (pathname.includes("/translator"))
|
|
return {
|
|
title: "Translator",
|
|
description: "Debug translation flow between formats",
|
|
icon: "translate",
|
|
breadcrumbs: [],
|
|
};
|
|
if (pathname.includes("/console-log"))
|
|
return {
|
|
title: "Console Log",
|
|
description: "Live server console output",
|
|
icon: "monitor",
|
|
breadcrumbs: [],
|
|
};
|
|
if (pathname === "/dashboard")
|
|
return {
|
|
title: "Endpoint",
|
|
description: "API endpoint configuration",
|
|
icon: "api",
|
|
breadcrumbs: [],
|
|
};
|
|
return { title: "", description: "", breadcrumbs: [] };
|
|
};
|
|
|
|
export default function Header({ onMenuClick, showMenuButton = true }) {
|
|
const pathname = usePathname();
|
|
const [displayName, setDisplayName] = useState("");
|
|
const [loginMethod, setLoginMethod] = useState("");
|
|
const [donateOpen, setDonateOpen] = useState(false);
|
|
|
|
// Memoize page info to prevent unnecessary recalculations
|
|
const pageInfo = useMemo(() => getPageInfo(pathname), [pathname]);
|
|
const { title, description, icon, breadcrumbs } = pageInfo;
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
async function loadAuthStatus() {
|
|
try {
|
|
const res = await fetch("/api/auth/status", { cache: "no-store" });
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
if (!cancelled) {
|
|
setDisplayName(data?.displayName || data?.oidcName || data?.oidcEmail || "");
|
|
setLoginMethod(data?.loginMethod || "");
|
|
}
|
|
} catch {
|
|
if (!cancelled) {
|
|
setDisplayName("");
|
|
setLoginMethod("");
|
|
}
|
|
}
|
|
}
|
|
|
|
loadAuthStatus();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
const handleLogout = async () => {
|
|
try {
|
|
const res = await fetch("/api/auth/logout", { method: "POST" });
|
|
if (res.ok) {
|
|
window.location.assign("/login");
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to logout:", err);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<header className="shrink-0 flex items-center justify-between gap-3 px-4 lg:px-8 pt-3 pb-2 border-b border-border-subtle bg-surface/60 backdrop-blur-xl lg:bg-transparent lg:backdrop-blur-none z-20">
|
|
{/* Mobile menu button */}
|
|
<div className="flex items-center gap-3 lg:hidden shrink-0">
|
|
{showMenuButton && (
|
|
<button
|
|
onClick={onMenuClick}
|
|
className="text-text-main hover:text-primary transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined">menu</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Page title with breadcrumbs */}
|
|
<div className="flex flex-col min-w-0 flex-1">
|
|
{breadcrumbs.length > 0 ? (
|
|
<div className="flex items-center gap-2">
|
|
{breadcrumbs.map((crumb, index) => (
|
|
<div
|
|
key={`${crumb.label}-${crumb.href || "current"}`}
|
|
className="flex items-center gap-2"
|
|
>
|
|
{index > 0 && (
|
|
<span className="material-symbols-outlined text-text-muted text-base">
|
|
chevron_right
|
|
</span>
|
|
)}
|
|
{crumb.href ? (
|
|
<Link
|
|
href={crumb.href}
|
|
className="text-text-muted hover:text-primary transition-colors"
|
|
>
|
|
{crumb.label}
|
|
</Link>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
{crumb.image && (
|
|
<ProviderIcon
|
|
src={crumb.image}
|
|
alt={crumb.label}
|
|
size={28}
|
|
className="object-contain rounded max-w-[28px] max-h-[28px]"
|
|
fallbackText={crumb.label.slice(0, 2).toUpperCase()}
|
|
/>
|
|
)}
|
|
<h1 className="text-base lg:text-2xl font-semibold text-text-main tracking-tight truncate">
|
|
{translate(crumb.label)}
|
|
</h1>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : title ? (
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
{icon && (
|
|
<span className="material-symbols-outlined text-primary text-xl lg:text-2xl">
|
|
{icon}
|
|
</span>
|
|
)}
|
|
<h1 className="text-base lg:text-2xl font-semibold tracking-tight truncate">
|
|
{translate(title)}
|
|
</h1>
|
|
</div>
|
|
{description && (
|
|
<p className="hidden lg:block text-sm text-text-muted truncate">
|
|
{translate(description)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Right actions */}
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{displayName && loginMethod === "OIDC" && (
|
|
<div className="hidden sm:flex items-center max-w-[220px] px-3 py-1.5 rounded-full border border-border bg-surface/70 text-xs text-text-muted truncate">
|
|
<span className="material-symbols-outlined text-[14px] mr-1.5 text-primary">person</span>
|
|
<span className="truncate">{displayName}</span>
|
|
<span className="ml-2 shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
|
|
OIDC
|
|
</span>
|
|
</div>
|
|
)}
|
|
<HeaderSearch />
|
|
<button
|
|
onClick={() => setDonateOpen(true)}
|
|
className="flex items-center gap-1.5 px-3 h-8 rounded-lg border border-pink-500/30 bg-pink-500/10 text-pink-600 dark:text-pink-400 hover:bg-pink-500/20 transition-colors text-sm font-medium"
|
|
aria-label="Donate"
|
|
>
|
|
<span className="material-symbols-outlined text-[18px]">volunteer_activism</span>
|
|
<span className="hidden sm:inline">Donate</span>
|
|
</button>
|
|
<ThemeToggle />
|
|
<HeaderLanguage />
|
|
<HeaderMenu onLogout={handleLogout} />
|
|
</div>
|
|
<DonateModal isOpen={donateOpen} onClose={() => setDonateOpen(false)} />
|
|
</header>
|
|
);
|
|
}
|
|
|
|
function HeaderSearch() {
|
|
const visible = useHeaderSearchStore((s) => s.visible);
|
|
const query = useHeaderSearchStore((s) => s.query);
|
|
const placeholder = useHeaderSearchStore((s) => s.placeholder);
|
|
const setQuery = useHeaderSearchStore((s) => s.setQuery);
|
|
|
|
if (!visible) return null;
|
|
|
|
return (
|
|
<div className="relative w-[160px] sm:w-[220px]">
|
|
<span className="material-symbols-outlined absolute left-2 top-1/2 -translate-y-1/2 text-text-muted text-[16px] pointer-events-none">
|
|
search
|
|
</span>
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={placeholder}
|
|
className="w-full h-8 pl-7 pr-7 rounded-lg border border-border bg-surface/60 text-sm focus:outline-none focus:border-primary/50 transition-colors"
|
|
/>
|
|
{query && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setQuery("")}
|
|
className="absolute right-1 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-main p-0.5 rounded"
|
|
aria-label="Clear search"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">close</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
Header.propTypes = {
|
|
onMenuClick: PropTypes.func,
|
|
showMenuButton: PropTypes.bool,
|
|
};
|