refactor(AppShell): pure renderer, drop browser fetch
The browser-fetching variant ran into the cross-origin / cert-trust wall that came up the moment a real user loaded the page. Move the network call out of the library: <AppShell> now takes a pre-resolved ShellConfig as a required prop. Apps fetch from gsc-shell-api in their RSC layout (server-to-server, no CORS, no cert dance) and pass the result down. Public surface: AppShell, useShell, ShellConfig + sub-types Removed: ShellProvider (network logic gone), all fetcher props (apiUrl, getToken, appKey, initialConfig, revalidateMs). useShell() now returns ShellConfig directly (was ShellContextValue with loading/error/refresh — no longer relevant). Apps drive revalidation by re-rendering; chrome stays request-fresh without any client-side polling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
25
package-lock.json
generated
25
package-lock.json
generated
@@ -19,8 +19,8 @@
|
||||
"devDependencies": {
|
||||
"@types/google.maps": "^3.55.0",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"typescript": "^5.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -453,30 +453,23 @@
|
||||
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-transition-group": {
|
||||
|
||||
@@ -55,8 +55,8 @@
|
||||
"devDependencies": {
|
||||
"@types/google.maps": "^3.55.0",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export type PopoverProps = {
|
||||
title?: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
placement?: Placement;
|
||||
children: React.ReactElement;
|
||||
children: React.ReactElement<any>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useFloating, offset, shift, flip, arrow, Placement, Middleware } from '
|
||||
export type TooltipProps = {
|
||||
content: React.ReactNode;
|
||||
placement?: Placement;
|
||||
children: React.ReactElement;
|
||||
children: React.ReactElement<any>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import React, { createContext, useContext } from "react";
|
||||
|
||||
import { Navbar } from "./Navbar";
|
||||
import { Sidebar, type SidebarNavItem } from "./Sidebar";
|
||||
import { Footer, type FooterNavItem } from "./Footer";
|
||||
import { PageShell } from "./PageShell";
|
||||
|
||||
// ─── ShellConfig types — mirror gsc-shell-api's response DTO ──────────────────
|
||||
// ─── ShellConfig types ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Mirrors gsc-shell-api's response DTO. Apps fetch this server-side (RSC)
|
||||
// and pass it to <AppShell>; the library doesn't talk to the network.
|
||||
|
||||
export type ShellMenuZone = "topbar" | "sidebar" | "footer" | "user-menu";
|
||||
|
||||
@@ -52,112 +55,28 @@ export type ShellConfig = {
|
||||
menus: Partial<Record<ShellMenuZone, ShellMenuItem[]>>;
|
||||
};
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
// ─── Context — exposes config to descendants via useShell() ───────────────────
|
||||
|
||||
type ShellContextValue = {
|
||||
config: ShellConfig | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
const ShellContext = createContext<ShellConfig | null>(null);
|
||||
|
||||
const ShellContext = createContext<ShellContextValue | null>(null);
|
||||
|
||||
export function useShell(): ShellContextValue {
|
||||
const ctx = useContext(ShellContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useShell must be used inside <AppShell> / <ShellProvider>");
|
||||
/** Read the current ShellConfig from anywhere inside <AppShell>. */
|
||||
export function useShell(): ShellConfig {
|
||||
const cfg = useContext(ShellContext);
|
||||
if (!cfg) {
|
||||
throw new Error("useShell must be used inside <AppShell>");
|
||||
}
|
||||
return ctx;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
// ─── Provider — handles fetch + revalidation + cache ──────────────────────────
|
||||
|
||||
export type ShellProviderProps = {
|
||||
/** App identifier as registered in shell-api (e.g. "gsc-crm") */
|
||||
appKey: string;
|
||||
/** Base URL of gsc-shell-api (e.g. "https://shell-api.gosec.internal") */
|
||||
apiUrl: string;
|
||||
/** Returns a fresh Keycloak access token. Called on each fetch. */
|
||||
getToken: () => Promise<string>;
|
||||
/** Optional: a snapshot of ShellConfig to render before the first fetch finishes. */
|
||||
initialConfig?: ShellConfig;
|
||||
/** Optional: revalidate this often (ms). Default 5 minutes. */
|
||||
revalidateMs?: number;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ShellProvider({
|
||||
appKey,
|
||||
apiUrl,
|
||||
getToken,
|
||||
initialConfig,
|
||||
revalidateMs = 5 * 60 * 1000,
|
||||
children,
|
||||
}: ShellProviderProps) {
|
||||
const [config, setConfig] = useState<ShellConfig | null>(initialConfig ?? null);
|
||||
const [loading, setLoading] = useState<boolean>(!initialConfig);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [etag, setEtag] = useState<string | null>(null);
|
||||
|
||||
const fetcher = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = await getToken();
|
||||
const res = await fetch(`${apiUrl}/api/v1/shell/${encodeURIComponent(appKey)}`, {
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
...(etag ? { "If-None-Match": etag } : {}),
|
||||
},
|
||||
credentials: "omit",
|
||||
});
|
||||
if (res.status === 304) {
|
||||
// Cached config still valid
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`shell-api ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
const next = (await res.json()) as ShellConfig;
|
||||
setConfig(next);
|
||||
setError(null);
|
||||
const newEtag = res.headers.get("ETag");
|
||||
if (newEtag) setEtag(newEtag);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e : new Error(String(e)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiUrl, appKey, getToken, etag]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetcher();
|
||||
if (!revalidateMs) return;
|
||||
const id = window.setInterval(() => {
|
||||
void fetcher();
|
||||
}, revalidateMs);
|
||||
return () => window.clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appKey, apiUrl]);
|
||||
|
||||
const value = useMemo<ShellContextValue>(
|
||||
() => ({ config, loading, error, refresh: fetcher }),
|
||||
[config, loading, error, fetcher],
|
||||
);
|
||||
|
||||
return <ShellContext.Provider value={value}>{children}</ShellContext.Provider>;
|
||||
}
|
||||
|
||||
// ─── AppShell — composite that renders chrome from ShellConfig ─────────────────
|
||||
// ─── AppShell — pure renderer ─────────────────────────────────────────────────
|
||||
|
||||
export type AppShellProps = {
|
||||
/** Same args as ShellProvider — AppShell wraps it. */
|
||||
appKey: string;
|
||||
apiUrl: string;
|
||||
getToken: () => Promise<string>;
|
||||
initialConfig?: ShellConfig;
|
||||
revalidateMs?: number;
|
||||
/**
|
||||
* Pre-resolved shell config. Apps fetch this server-side from
|
||||
* gsc-shell-api and pass it down. The library never makes a
|
||||
* network call — no CORS, no cert trust, no browser auth dance.
|
||||
*/
|
||||
config: ShellConfig;
|
||||
|
||||
/** Current pathname for active-route highlight. Default: window.location.pathname. */
|
||||
currentPath?: string;
|
||||
@@ -168,68 +87,28 @@ export type AppShellProps = {
|
||||
/** Optional: signs the user out. Hook this up to your NextAuth/Keycloak signout. */
|
||||
onSignOut?: () => void;
|
||||
|
||||
/** Optional: rendered inside <PageHeader> slot. */
|
||||
/** Optional content for the <PageHeader> slot (breadcrumbs, page title, etc.). */
|
||||
pageHeader?: React.ReactNode;
|
||||
|
||||
/** Page content. */
|
||||
children: React.ReactNode;
|
||||
|
||||
/** Override the wrapper className. */
|
||||
/** Optional className on the outer wrapper. */
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AppShell(props: AppShellProps) {
|
||||
return (
|
||||
<ShellProvider
|
||||
appKey={props.appKey}
|
||||
apiUrl={props.apiUrl}
|
||||
getToken={props.getToken}
|
||||
initialConfig={props.initialConfig}
|
||||
revalidateMs={props.revalidateMs}
|
||||
>
|
||||
<ShellChrome
|
||||
currentPath={props.currentPath}
|
||||
translate={props.translate}
|
||||
onSignOut={props.onSignOut}
|
||||
pageHeader={props.pageHeader}
|
||||
className={props.className}
|
||||
>
|
||||
{props.children}
|
||||
</ShellChrome>
|
||||
</ShellProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type ShellChromeProps = Pick<AppShellProps, "currentPath" | "translate" | "onSignOut" | "pageHeader" | "className" | "children">;
|
||||
|
||||
function ShellChrome({ currentPath, translate, onSignOut, pageHeader, className, children }: ShellChromeProps) {
|
||||
const { config, error } = useShell();
|
||||
export function AppShell({
|
||||
config,
|
||||
currentPath,
|
||||
translate,
|
||||
onSignOut,
|
||||
pageHeader,
|
||||
className,
|
||||
children,
|
||||
}: AppShellProps) {
|
||||
const t = translate ?? ((k: string) => k);
|
||||
const path = currentPath ?? (typeof window !== "undefined" ? window.location.pathname : "/");
|
||||
|
||||
// Fallback chrome if config never loaded — minimal so the app still renders.
|
||||
if (!config) {
|
||||
return (
|
||||
<PageShell
|
||||
navbar={
|
||||
<Navbar
|
||||
brand={<span className="navbar-brand-text">…</span>}
|
||||
brandHref="/"
|
||||
showSidebarToggle={false}
|
||||
/>
|
||||
}
|
||||
className={className}
|
||||
>
|
||||
{error ? (
|
||||
<div className="alert alert-warning mt-3">
|
||||
Chrome unavailable: {error.message}. Showing app content only.
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarItems = (config.menus.sidebar ?? []).map((m) => toSidebarNavItem(m, path, t));
|
||||
const footerNavItems: FooterNavItem[] = (config.menus.footer ?? []).map((m) => ({
|
||||
label: t(m.translationKey),
|
||||
@@ -238,7 +117,11 @@ function ShellChrome({ currentPath, translate, onSignOut, pageHeader, className,
|
||||
}));
|
||||
|
||||
const navbarBrand = (
|
||||
<a className="d-flex align-items-center gap-2" href={config.app.baseUrl} style={{ color: "inherit", textDecoration: "none" }}>
|
||||
<a
|
||||
className="d-flex align-items-center gap-2"
|
||||
href={config.app.baseUrl}
|
||||
style={{ color: "inherit", textDecoration: "none" }}
|
||||
>
|
||||
<img src={config.branding.logoUrl} alt="" height={24} />
|
||||
<span className="fw-semibold">{config.branding.productName}</span>
|
||||
</a>
|
||||
@@ -270,7 +153,9 @@ function ShellChrome({ currentPath, translate, onSignOut, pageHeader, className,
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span className="d-none d-md-inline me-2">{config.user.displayName || t("menu.account")}</span>
|
||||
<span className="d-none d-md-inline me-2">
|
||||
{config.user.displayName || t("menu.account")}
|
||||
</span>
|
||||
<i className="ph-user-circle" />
|
||||
</button>
|
||||
<div className="dropdown-menu dropdown-menu-end">{userMenuItems}</div>
|
||||
@@ -279,46 +164,50 @@ function ShellChrome({ currentPath, translate, onSignOut, pageHeader, className,
|
||||
);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
className={className}
|
||||
navbar={
|
||||
<Navbar
|
||||
brand={navbarBrand}
|
||||
brandHref={config.app.baseUrl}
|
||||
endItems={navbarEnd}
|
||||
showSidebarToggle
|
||||
/>
|
||||
}
|
||||
pageHeader={pageHeader}
|
||||
mainSidebar={
|
||||
<Sidebar
|
||||
variant="main"
|
||||
color="light"
|
||||
user={{
|
||||
name: config.user.displayName || "—",
|
||||
subtitle: config.user.email,
|
||||
}}
|
||||
items={sidebarItems}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<Footer
|
||||
copyright={
|
||||
config.branding.footerHtml ? (
|
||||
<span dangerouslySetInnerHTML={{ __html: config.branding.footerHtml }} />
|
||||
) : (
|
||||
<>© {new Date().getFullYear()} GoSec Cloud</>
|
||||
)
|
||||
}
|
||||
navItems={footerNavItems}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</PageShell>
|
||||
<ShellContext.Provider value={config}>
|
||||
<PageShell
|
||||
className={className}
|
||||
navbar={
|
||||
<Navbar
|
||||
brand={navbarBrand}
|
||||
brandHref={config.app.baseUrl}
|
||||
endItems={navbarEnd}
|
||||
showSidebarToggle
|
||||
/>
|
||||
}
|
||||
pageHeader={pageHeader}
|
||||
mainSidebar={
|
||||
<Sidebar
|
||||
variant="main"
|
||||
color="light"
|
||||
user={{
|
||||
name: config.user.displayName || "—",
|
||||
subtitle: config.user.email,
|
||||
}}
|
||||
items={sidebarItems}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<Footer
|
||||
copyright={
|
||||
config.branding.footerHtml ? (
|
||||
<span dangerouslySetInnerHTML={{ __html: config.branding.footerHtml }} />
|
||||
) : (
|
||||
<>© {new Date().getFullYear()} GoSec Cloud</>
|
||||
)
|
||||
}
|
||||
navItems={footerNavItems}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</PageShell>
|
||||
</ShellContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function toSidebarNavItem(
|
||||
m: ShellMenuItem,
|
||||
currentPath: string,
|
||||
@@ -328,21 +217,23 @@ function toSidebarNavItem(
|
||||
const item: SidebarNavItem = {
|
||||
type: m.children && m.children.length > 0 ? "submenu" : "link",
|
||||
label: t(m.translationKey),
|
||||
href: m.isExternal ? m.href : m.href,
|
||||
href: m.href,
|
||||
iconClass: m.icon,
|
||||
active,
|
||||
};
|
||||
if (m.children && m.children.length > 0) {
|
||||
item.children = m.children.map((c) => toSidebarNavItem(c, currentPath, t));
|
||||
// submenu open if any descendant is active
|
||||
item.isOpen = item.children.some((c) => c.active || (c.children?.some((g) => g.active) ?? false));
|
||||
item.isOpen = item.children.some(
|
||||
(c) => c.active || (c.children?.some((g) => g.active) ?? false),
|
||||
);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function isActiveHref(href: string, currentPath: string): boolean {
|
||||
if (!href) return false;
|
||||
// Strip locale prefix from current path so /en/dashboard matches /dashboard
|
||||
// Strip a leading 2-letter locale prefix so /en/dashboard matches /dashboard.
|
||||
const stripped = currentPath.replace(/^\/[a-z]{2}(?=\/|$)/, "") || "/";
|
||||
if (href === "/") return stripped === "/";
|
||||
return stripped === href || stripped.startsWith(`${href}/`);
|
||||
|
||||
Reference in New Issue
Block a user