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:
Claude
2026-05-10 14:18:07 +02:00
parent cf068ce4ec
commit 8b9e577694
5 changed files with 98 additions and 214 deletions

25
package-lock.json generated
View File

@@ -19,8 +19,8 @@
"devDependencies": { "devDependencies": {
"@types/google.maps": "^3.55.0", "@types/google.maps": "^3.55.0",
"@types/nodemailer": "^6.4.0", "@types/nodemailer": "^6.4.0",
"@types/react": "^18.3.0", "@types/react": "^19.0.0",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^19.0.0",
"typescript": "^5.4.0" "typescript": "^5.4.0"
}, },
"peerDependencies": { "peerDependencies": {
@@ -453,30 +453,23 @@
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT" "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": { "node_modules/@types/react": {
"version": "18.3.27", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
}, },
"node_modules/@types/react-dom": { "node_modules/@types/react-dom": {
"version": "18.3.7", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/react-transition-group": { "node_modules/@types/react-transition-group": {

View File

@@ -55,8 +55,8 @@
"devDependencies": { "devDependencies": {
"@types/google.maps": "^3.55.0", "@types/google.maps": "^3.55.0",
"@types/nodemailer": "^6.4.0", "@types/nodemailer": "^6.4.0",
"@types/react": "^18.3.0", "@types/react": "^19.0.0",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^19.0.0",
"typescript": "^5.4.0" "typescript": "^5.4.0"
} }
} }

View File

@@ -5,7 +5,7 @@ export type PopoverProps = {
title?: React.ReactNode; title?: React.ReactNode;
content: React.ReactNode; content: React.ReactNode;
placement?: Placement; placement?: Placement;
children: React.ReactElement; children: React.ReactElement<any>;
className?: string; className?: string;
}; };

View File

@@ -4,7 +4,7 @@ import { useFloating, offset, shift, flip, arrow, Placement, Middleware } from '
export type TooltipProps = { export type TooltipProps = {
content: React.ReactNode; content: React.ReactNode;
placement?: Placement; placement?: Placement;
children: React.ReactElement; children: React.ReactElement<any>;
className?: string; className?: string;
}; };

View File

@@ -1,13 +1,16 @@
"use client"; "use client";
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; import React, { createContext, useContext } from "react";
import { Navbar } from "./Navbar"; import { Navbar } from "./Navbar";
import { Sidebar, type SidebarNavItem } from "./Sidebar"; import { Sidebar, type SidebarNavItem } from "./Sidebar";
import { Footer, type FooterNavItem } from "./Footer"; import { Footer, type FooterNavItem } from "./Footer";
import { PageShell } from "./PageShell"; 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"; export type ShellMenuZone = "topbar" | "sidebar" | "footer" | "user-menu";
@@ -52,112 +55,28 @@ export type ShellConfig = {
menus: Partial<Record<ShellMenuZone, ShellMenuItem[]>>; menus: Partial<Record<ShellMenuZone, ShellMenuItem[]>>;
}; };
// ─── Context ────────────────────────────────────────────────────────────────── // ─── Context — exposes config to descendants via useShell() ───────────────────
type ShellContextValue = { const ShellContext = createContext<ShellConfig | null>(null);
config: ShellConfig | null;
loading: boolean;
error: Error | null;
refresh: () => Promise<void>;
};
const ShellContext = createContext<ShellContextValue | null>(null); /** Read the current ShellConfig from anywhere inside <AppShell>. */
export function useShell(): ShellConfig {
export function useShell(): ShellContextValue { const cfg = useContext(ShellContext);
const ctx = useContext(ShellContext); if (!cfg) {
if (!ctx) { throw new Error("useShell must be used inside <AppShell>");
throw new Error("useShell must be used inside <AppShell> / <ShellProvider>");
} }
return ctx; return cfg;
} }
// ─── Provider — handles fetch + revalidation + cache ────────────────────────── // ─── AppShell — pure renderer ─────────────────────────────────────────────────
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 ─────────────────
export type AppShellProps = { export type AppShellProps = {
/** Same args as ShellProvider — AppShell wraps it. */ /**
appKey: string; * Pre-resolved shell config. Apps fetch this server-side from
apiUrl: string; * gsc-shell-api and pass it down. The library never makes a
getToken: () => Promise<string>; * network call — no CORS, no cert trust, no browser auth dance.
initialConfig?: ShellConfig; */
revalidateMs?: number; config: ShellConfig;
/** Current pathname for active-route highlight. Default: window.location.pathname. */ /** Current pathname for active-route highlight. Default: window.location.pathname. */
currentPath?: string; currentPath?: string;
@@ -168,68 +87,28 @@ export type AppShellProps = {
/** Optional: signs the user out. Hook this up to your NextAuth/Keycloak signout. */ /** Optional: signs the user out. Hook this up to your NextAuth/Keycloak signout. */
onSignOut?: () => void; onSignOut?: () => void;
/** Optional: rendered inside <PageHeader> slot. */ /** Optional content for the <PageHeader> slot (breadcrumbs, page title, etc.). */
pageHeader?: React.ReactNode; pageHeader?: React.ReactNode;
/** Page content. */ /** Page content. */
children: React.ReactNode; children: React.ReactNode;
/** Override the wrapper className. */ /** Optional className on the outer wrapper. */
className?: string; className?: string;
}; };
export function AppShell(props: AppShellProps) { export function AppShell({
return ( config,
<ShellProvider currentPath,
appKey={props.appKey} translate,
apiUrl={props.apiUrl} onSignOut,
getToken={props.getToken} pageHeader,
initialConfig={props.initialConfig} className,
revalidateMs={props.revalidateMs} children,
> }: AppShellProps) {
<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();
const t = translate ?? ((k: string) => k); const t = translate ?? ((k: string) => k);
const path = currentPath ?? (typeof window !== "undefined" ? window.location.pathname : "/"); 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 sidebarItems = (config.menus.sidebar ?? []).map((m) => toSidebarNavItem(m, path, t));
const footerNavItems: FooterNavItem[] = (config.menus.footer ?? []).map((m) => ({ const footerNavItems: FooterNavItem[] = (config.menus.footer ?? []).map((m) => ({
label: t(m.translationKey), label: t(m.translationKey),
@@ -238,7 +117,11 @@ function ShellChrome({ currentPath, translate, onSignOut, pageHeader, className,
})); }));
const navbarBrand = ( 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} /> <img src={config.branding.logoUrl} alt="" height={24} />
<span className="fw-semibold">{config.branding.productName}</span> <span className="fw-semibold">{config.branding.productName}</span>
</a> </a>
@@ -270,7 +153,9 @@ function ShellChrome({ currentPath, translate, onSignOut, pageHeader, className,
data-bs-toggle="dropdown" data-bs-toggle="dropdown"
aria-expanded="false" 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" /> <i className="ph-user-circle" />
</button> </button>
<div className="dropdown-menu dropdown-menu-end">{userMenuItems}</div> <div className="dropdown-menu dropdown-menu-end">{userMenuItems}</div>
@@ -279,46 +164,50 @@ function ShellChrome({ currentPath, translate, onSignOut, pageHeader, className,
); );
return ( return (
<PageShell <ShellContext.Provider value={config}>
className={className} <PageShell
navbar={ className={className}
<Navbar navbar={
brand={navbarBrand} <Navbar
brandHref={config.app.baseUrl} brand={navbarBrand}
endItems={navbarEnd} brandHref={config.app.baseUrl}
showSidebarToggle endItems={navbarEnd}
/> showSidebarToggle
} />
pageHeader={pageHeader} }
mainSidebar={ pageHeader={pageHeader}
<Sidebar mainSidebar={
variant="main" <Sidebar
color="light" variant="main"
user={{ color="light"
name: config.user.displayName || "—", user={{
subtitle: config.user.email, name: config.user.displayName || "—",
}} subtitle: config.user.email,
items={sidebarItems} }}
/> items={sidebarItems}
} />
footer={ }
<Footer footer={
copyright={ <Footer
config.branding.footerHtml ? ( copyright={
<span dangerouslySetInnerHTML={{ __html: config.branding.footerHtml }} /> config.branding.footerHtml ? (
) : ( <span dangerouslySetInnerHTML={{ __html: config.branding.footerHtml }} />
<>© {new Date().getFullYear()} GoSec Cloud</> ) : (
) <>© {new Date().getFullYear()} GoSec Cloud</>
} )
navItems={footerNavItems} }
/> navItems={footerNavItems}
} />
> }
{children} >
</PageShell> {children}
</PageShell>
</ShellContext.Provider>
); );
} }
// ─── helpers ──────────────────────────────────────────────────────────────────
function toSidebarNavItem( function toSidebarNavItem(
m: ShellMenuItem, m: ShellMenuItem,
currentPath: string, currentPath: string,
@@ -328,21 +217,23 @@ function toSidebarNavItem(
const item: SidebarNavItem = { const item: SidebarNavItem = {
type: m.children && m.children.length > 0 ? "submenu" : "link", type: m.children && m.children.length > 0 ? "submenu" : "link",
label: t(m.translationKey), label: t(m.translationKey),
href: m.isExternal ? m.href : m.href, href: m.href,
iconClass: m.icon, iconClass: m.icon,
active, active,
}; };
if (m.children && m.children.length > 0) { if (m.children && m.children.length > 0) {
item.children = m.children.map((c) => toSidebarNavItem(c, currentPath, t)); item.children = m.children.map((c) => toSidebarNavItem(c, currentPath, t));
// submenu open if any descendant is active // 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; return item;
} }
function isActiveHref(href: string, currentPath: string): boolean { function isActiveHref(href: string, currentPath: string): boolean {
if (!href) return false; 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}(?=\/|$)/, "") || "/"; const stripped = currentPath.replace(/^\/[a-z]{2}(?=\/|$)/, "") || "/";
if (href === "/") return stripped === "/"; if (href === "/") return stripped === "/";
return stripped === href || stripped.startsWith(`${href}/`); return stripped === href || stripped.startsWith(`${href}/`);