From 1f2141118d58f76912aa0a29f56112aca2e08db1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 00:20:08 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20=E2=80=94=20layout=20=C2=B7?= =?UTF-8?q?=20auth=20=C2=B7=20shell=20are=20real?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @gsc/web-kit v0.2.0. Three modules turn from stubs into the working surface apps need to render a chrome-wrapped Next.js page with one import per concern. auth/server: - createAuth({ keycloak: { clientId, clientSecret, issuer } }) factory returns { handlers, signIn, signOut, auth, requireAuth, signInPath }. Canonical SessionUser shape (id, keycloakId, tenantId, email, displayName, givenName, familyName, roles, accessToken, idToken) baked into the session callback. Apps drop their hand-rolled src/auth.ts (~80 lines) for a 6-line factory call. - requireAuth() — server-only. await it at the top of an RSC layout or page; redirects to signInPath if no session. auth/middleware: - createAuthMiddleware({ publicRoutes? }) returns a Next.js middleware that redirects unauth'd requests to /api/auth/signin/keycloak with ?callbackUrl=. Bypasses /api/auth/*, /_next/*, /images/*, favicon, robots.txt always. auth (client): - signInRedirect(callbackUrl?) — hard-nav from any client component. shell/server: - fetchShellConfig({ appKey, accessToken, apiUrl?, timeoutMs? }). Server-only fetcher. 3s default timeout. Graceful fallback config on any error — shell-api outages can't blank-screen a host app. shell (client): - + useShell() — read the resolved config from any descendant of . layout: - . Renders the chronos-style Bootstrap-Layout-3 chrome (navbar-static, sidebar-light sidebar-main with collapse + persistence in localStorage, navbar-footer). Wraps children with the kit's ShellProvider so useShell() works. devDep: @types/node for the server-side process.env read. All 14 sub-exports still resolve under dist/. Phase 3 (data + forms) and the gscCRM pilot cutover come next. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 22 ++- package.json | 3 +- src/auth/index.ts | 26 ++- src/auth/middleware.ts | 74 ++++++- src/auth/server.ts | 126 +++++++++++- src/auth/types.ts | 41 ++++ src/layout/AppLayout.tsx | 403 +++++++++++++++++++++++++++++++++++++++ src/layout/index.ts | 3 +- src/shell/index.ts | 30 ++- src/shell/server.ts | 77 +++++++- src/shell/types.ts | 48 +++++ 11 files changed, 838 insertions(+), 15 deletions(-) create mode 100644 src/auth/types.ts create mode 100644 src/layout/AppLayout.tsx create mode 100644 src/shell/types.ts diff --git a/package-lock.json b/package-lock.json index 625977c..ebc0a2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@gsc/web-kit", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gsc/web-kit", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@limitless/ui": "file:../limitless-ui", @@ -15,6 +15,7 @@ "zod": "^3.23.0" }, "devDependencies": { + "@types/node": "^20.11.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "typescript": "^5.4.0" @@ -1297,6 +1298,16 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@types/node": { + "version": "20.19.40", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.40.tgz", + "integrity": "sha512-xxx6M2IpSTnnKcR0cMvIiohkiCx20/oRPtWGbenFygKCGl3zqUzdNjQ/1V4solq1LU+dgv0nQzeGOuqkqZGg0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -1879,6 +1890,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/use-intl": { "version": "4.11.1", "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.11.1.tgz", diff --git a/package.json b/package.json index 954fde0..53a60e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gsc/web-kit", - "version": "0.1.0", + "version": "0.2.0", "description": "GSC web app skeleton — layout, auth, data, forms, feedback, navigation. Built on @limitless/ui. Drop into a Next.js app and just write pages.", "license": "MIT", "type": "module", @@ -44,6 +44,7 @@ "react-dom": "^18.2.0 || ^19.0.0" }, "devDependencies": { + "@types/node": "^20.11.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "typescript": "^5.4.0" diff --git a/src/auth/index.ts b/src/auth/index.ts index af26ee7..21df4af 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,2 +1,24 @@ -// @gsc/web-kit/auth — Phase 1 stub. Real surface lands in later phases. -export {}; +/** + * @gsc/web-kit/auth — client-side auth helpers. + * + * Server-side surface (createAuth, requireAuth) lives in + * `@gsc/web-kit/auth/server`. The split keeps client bundles from + * pulling in next-auth's server runtime. + */ + +export type { SessionUser } from "./types"; + +/** + * Client-side hard navigation to the sign-in endpoint. Use when a + * component detects an auth-required action without a session — e.g. + * a 401 from an API call. + * + * Default target matches createAuth's default (`/api/auth/signin/keycloak`). + */ +export function signInRedirect(callbackUrl?: string): void { + if (typeof window === "undefined") return; + const target = "/api/auth/signin/keycloak"; + const url = new URL(target, window.location.origin); + url.searchParams.set("callbackUrl", callbackUrl ?? window.location.pathname); + window.location.href = url.toString(); +} diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts index ebfce64..a69a540 100644 --- a/src/auth/middleware.ts +++ b/src/auth/middleware.ts @@ -1,2 +1,72 @@ -// @gsc/web-kit/auth/middleware — Phase 1 stub. Real createAuthMiddleware() lands in Phase 2. -export {}; +import { NextResponse, type NextRequest } from "next/server"; + +export interface AuthMiddlewareOptions { + /** + * Path prefixes that should bypass the session check. Use sparingly — + * pages without auth shouldn't be the default. The kit always allows + * `/api/auth/*`, `/_next/*`, `/images/*`, and `/favicon.ico` regardless. + */ + publicRoutes?: string[]; + + /** + * Where to send unauth'd users. Defaults to NextAuth's auto-redirect + * endpoint (`/api/auth/signin/keycloak`); the original path is + * preserved as `?callbackUrl=` so the user lands where they started. + */ + signInPath?: string; +} + +/** + * Returns a Next.js middleware function that redirects unauthenticated + * requests to the sign-in endpoint. Wire it from `middleware.ts`: + * + * @example + * // middleware.ts + * import { createAuthMiddleware } from "@gsc/web-kit/auth/middleware"; + * export default createAuthMiddleware({ + * publicRoutes: ["/api/health", "/llms.txt"], + * }); + * export const config = { + * matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], + * }; + * + * The cookie check is intentionally cheap (presence-only): full token + * validation still happens in NextAuth on every server render. This + * keeps the middleware fast and edge-friendly. + */ +export function createAuthMiddleware(opts: AuthMiddlewareOptions = {}) { + const publicRoutes = opts.publicRoutes ?? []; + const signInPath = opts.signInPath ?? "/api/auth/signin/keycloak"; + + return function middleware(req: NextRequest) { + const { pathname } = req.nextUrl; + + if (isAlwaysAllowed(pathname)) { + return NextResponse.next(); + } + if (publicRoutes.some((p) => pathname === p || pathname.startsWith(p + "/"))) { + return NextResponse.next(); + } + + const sessionCookie = + req.cookies.get("authjs.session-token") ?? + req.cookies.get("__Secure-authjs.session-token"); + if (sessionCookie) { + return NextResponse.next(); + } + + const url = new URL(signInPath, req.nextUrl); + url.searchParams.set("callbackUrl", req.nextUrl.pathname + req.nextUrl.search); + return NextResponse.redirect(url); + }; +} + +function isAlwaysAllowed(pathname: string): boolean { + return ( + pathname.startsWith("/api/auth/") || + pathname.startsWith("/_next/") || + pathname.startsWith("/images/") || + pathname === "/favicon.ico" || + pathname === "/robots.txt" + ); +} diff --git a/src/auth/server.ts b/src/auth/server.ts index 3f3db60..a33c593 100644 --- a/src/auth/server.ts +++ b/src/auth/server.ts @@ -1,2 +1,124 @@ -// @gsc/web-kit/auth/server — Phase 1 stub. Real createAuth() / requireAuth() lands in Phase 2. -export {}; +import NextAuth, { type NextAuthResult } from "next-auth"; +import Keycloak from "next-auth/providers/keycloak"; +import { redirect } from "next/navigation"; + +import type { CreateAuthOptions, SessionUser } from "./types"; + +export type { SessionUser, CreateAuthOptions }; + +/** + * Result of `createAuth()`. Mirrors NextAuth's return value plus + * `requireAuth` and the resolved `signInPath` so consumers don't have to + * thread these around. + */ +export interface AuthBundle { + handlers: NextAuthResult["handlers"]; + signIn: NextAuthResult["signIn"]; + signOut: NextAuthResult["signOut"]; + auth: NextAuthResult["auth"]; + + /** + * Server-only. Returns the current session user or redirects to the + * sign-in path. Use as the first line of an RSC layout/page when the + * route should be authenticated. + */ + requireAuth: () => Promise; + + /** Resolved sign-in URL; useful for client-side redirect helpers. */ + signInPath: string; +} + +/** + * One-line factory that wires NextAuth v5 to Keycloak with the canonical + * GSC session shape. + * + * @example + * // src/auth.ts + * import { createAuth } from "@gsc/web-kit/auth/server"; + * export const { handlers, signIn, signOut, auth, requireAuth } = + * createAuth({ + * keycloak: { + * clientId: process.env.AUTH_KEYCLOAK_ID!, + * clientSecret: process.env.AUTH_KEYCLOAK_SECRET!, + * issuer: process.env.AUTH_KEYCLOAK_ISSUER!, + * }, + * }); + */ +export function createAuth(opts: CreateAuthOptions): AuthBundle { + const signInPath = opts.signInPath ?? "/api/auth/signin/keycloak"; + const defaultTenantId = + opts.defaultTenantId ?? "00000000-0000-0000-0000-000000000000"; + + const na = NextAuth({ + providers: [ + Keycloak({ + clientId: opts.keycloak.clientId, + clientSecret: opts.keycloak.clientSecret, + issuer: opts.keycloak.issuer, + }), + ], + trustHost: true, + callbacks: { + async jwt({ token, account, profile }) { + // `account` + `profile` are only set on the initial sign-in. + // On subsequent calls the token is read from the encrypted JWT + // cookie, so the fields we set here persist for the session. + if (account && profile) { + token.keycloakId = profile.sub; + token.tenantId = + (profile as { tenant_id?: string }).tenant_id ?? defaultTenantId; + token.displayName = + profile.name ?? + (profile as { preferred_username?: string }).preferred_username ?? + ""; + token.givenName = profile.given_name ?? ""; + token.familyName = profile.family_name ?? ""; + token.roles = + (profile as { realm_access?: { roles?: string[] } }).realm_access + ?.roles ?? []; + token.accessToken = account.access_token; + token.idToken = account.id_token; + } + return token; + }, + async session({ session, token }) { + if (!token) return session; + const user: SessionUser = { + id: token.sub as string, + keycloakId: (token.keycloakId as string) ?? (token.sub as string), + tenantId: (token.tenantId as string) ?? defaultTenantId, + email: session.user?.email ?? "", + displayName: (token.displayName as string) ?? "", + givenName: (token.givenName as string) ?? "", + familyName: (token.familyName as string) ?? "", + roles: (token.roles as string[]) ?? [], + accessToken: (token.accessToken as string) ?? "", + idToken: token.idToken as string | undefined, + }; + // NextAuth's default `session.user` type is narrow; we replace + // it wholesale with our canonical shape. The cast keeps TS quiet + // while preserving the runtime payload. + (session as unknown as { user: SessionUser }).user = user; + return session; + }, + }, + }); + + async function requireAuth(): Promise { + const session = await na.auth(); + const user = (session as unknown as { user?: SessionUser } | null)?.user; + if (!user || !user.accessToken) { + redirect(signInPath); + } + return user; + } + + return { + handlers: na.handlers, + signIn: na.signIn, + signOut: na.signOut, + auth: na.auth, + requireAuth, + signInPath, + }; +} diff --git a/src/auth/types.ts b/src/auth/types.ts new file mode 100644 index 0000000..14b6e70 --- /dev/null +++ b/src/auth/types.ts @@ -0,0 +1,41 @@ +/** + * The session shape every GSC app gets from `createAuth()`. Apps that + * need to add their own fields should extend rather than fork. + */ +export interface SessionUser { + /** NextAuth's `sub` — typically the Keycloak user UUID. */ + id: string; + /** Same as `id`. Exposed separately so callers can name-distinguish. */ + keycloakId: string; + /** Tenant the user is operating in. May fall back to defaultTenantId. */ + tenantId: string; + email: string; + displayName: string; + givenName: string; + familyName: string; + roles: string[]; + + /** Keycloak access token. Server-side use only — never log, never leak. */ + accessToken: string; + /** Keycloak ID token. Optional; only present if the IdP returns it. */ + idToken?: string; +} + +/** + * Options passed to `createAuth()`. + */ +export interface CreateAuthOptions { + keycloak: { + clientId: string; + clientSecret: string; + issuer: string; + }; + /** Used when Keycloak claims don't carry a `tenant_id`. */ + defaultTenantId?: string; + /** + * Where `requireAuth()` / the middleware redirects unauthenticated + * users. Default: `/api/auth/signin/keycloak` (NextAuth's auto-redirect + * endpoint). Override if the app has a custom landing page. + */ + signInPath?: string; +} diff --git a/src/layout/AppLayout.tsx b/src/layout/AppLayout.tsx new file mode 100644 index 0000000..62df102 --- /dev/null +++ b/src/layout/AppLayout.tsx @@ -0,0 +1,403 @@ +"use client"; + +import React, { useEffect, useState } from "react"; + +import { ShellProvider } from "../shell/index"; +import type { + ShellConfig, + ShellMenuItem, +} from "../shell/types"; + +const SIDEBAR_COLLAPSED_KEY = "gsc-web-kit-sidebar-collapsed"; + +export interface AppLayoutProps { + /** Pre-resolved chrome config. Fetch with `fetchShellConfig()` server-side. */ + config: ShellConfig; + /** Current pathname for active-route highlight. Default: `window.location.pathname`. */ + currentPath?: string; + /** Translate a `translation_key` to a display string. Default: identity. */ + translate?: (key: string) => string; + /** Sign-out callback (hooked to NextAuth's signOut). */ + onSignOut?: () => void; + /** Extra nodes to render in the navbar (search, notifications, etc.). */ + navbarExtras?: React.ReactNode; + /** Page content. */ + children: React.ReactNode; +} + +/** + * The chrome. Renders a Bootstrap-Layout-3 navbar / sidebar / footer + * shell from a `ShellConfig`. Apps wrap their `[locale]/layout.tsx` + * children in this and get a consistent app look for free. + */ +export function AppLayout({ + config, + currentPath, + translate, + onSignOut, + navbarExtras, + children, +}: AppLayoutProps) { + const t = translate ?? ((k: string) => k); + const path = + currentPath ?? + (typeof window !== "undefined" ? window.location.pathname : "/"); + + return ( + + +
+ +
+ {children} +
+
+ +
+ ); +} + +// ─── Navbar ──────────────────────────────────────────────────────────────────── + +function AdminNavbar({ + config, + t, + onSignOut, + navbarExtras, +}: { + config: ShellConfig; + t: (k: string) => string; + onSignOut?: () => void; + navbarExtras?: React.ReactNode; +}) { + const [showUserMenu, setShowUserMenu] = useState(false); + + const initials = + config.user.displayName + ?.split(" ") + .map((n) => n[0]) + .filter(Boolean) + .join("") + .slice(0, 2) + .toUpperCase() || "?"; + + const userMenuItems = config.menus["user-menu"] ?? []; + + return ( + + ); +} + +// ─── Sidebar ────────────────────────────────────────────────────────────────── + +function AdminSidebar({ + config, + t, + pathname, +}: { + config: ShellConfig; + t: (k: string) => string; + pathname: string; +}) { + const [collapsed, setCollapsed] = useState(false); + + useEffect(() => { + if (typeof window === "undefined") return; + const saved = window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY); + if (saved === "true") setCollapsed(true); + }, []); + useEffect(() => { + if (typeof window === "undefined") return; + window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(collapsed)); + }, [collapsed]); + + const sidebarClasses = [ + "sidebar", + "sidebar-light", + "sidebar-main", + "sidebar-expand-lg", + "align-self-start", + collapsed ? "sidebar-main-resized" : "", + ] + .filter(Boolean) + .join(" "); + + const items = config.menus.sidebar ?? []; + const strippedPath = stripLocale(pathname); + + return ( +
+
+
+
+ {!collapsed && ( +
+ {config.branding.productName} +
+ )} + +
+
+ +
+
    + {items.map((item) => ( + + ))} +
+
+
+
+ ); +} + +function SidebarNavItem({ + item, + pathname, + collapsed, + t, +}: { + item: ShellMenuItem; + pathname: string; + collapsed: boolean; + t: (k: string) => string; +}) { + const hasChildren = !!item.children?.length; + const active = isActiveHref(item.href, pathname); + const childActive = !!item.children?.some((c) => + isActiveHref(c.href, pathname), + ); + const [open, setOpen] = useState(childActive); + const [hovered, setHovered] = useState(false); + + const icon = item.icon ? : null; + + if (hasChildren) { + const showFlyout = collapsed && hovered; + const showInline = !collapsed && open; + const navItemClasses = [ + "nav-item", + "nav-item-submenu", + showInline ? "nav-item-open" : "", + showFlyout ? "nav-group-sub-visible" : "", + ] + .filter(Boolean) + .join(" "); + + return ( +
  • setHovered(true) : undefined} + onMouseLeave={collapsed ? () => setHovered(false) : undefined} + > + { + e.preventDefault(); + setOpen((o) => !o); + }} + > + {icon} + {!collapsed && {t(item.translationKey)}} + + +
  • + ); + } + + return ( +
  • + + {icon} + {!collapsed && {t(item.translationKey)}} + +
  • + ); +} + +// ─── Footer ─────────────────────────────────────────────────────────────────── + +function AdminFooter({ + config, + t, +}: { + config: ShellConfig; + t: (k: string) => string; +}) { + const items = config.menus.footer ?? []; + return ( +
    +
    + + {config.branding.footerHtml ? ( + + ) : ( + <>© {new Date().getFullYear()} GoSec Cloud + )} + + {items.length > 0 && ( + + )} +
    +
    + ); +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function isActiveHref(href: string, currentPath: string): boolean { + if (!href || href === "#") return false; + if (href === "/") return currentPath === "/"; + return currentPath === href || currentPath.startsWith(`${href}/`); +} + +function stripLocale(p: string): string { + return p.replace(/^\/[a-z]{2}(?=\/|$)/, "") || "/"; +} diff --git a/src/layout/index.ts b/src/layout/index.ts index 90912e1..db3ecbc 100644 --- a/src/layout/index.ts +++ b/src/layout/index.ts @@ -1,2 +1 @@ -// @gsc/web-kit/layout — Phase 1 stub. Real surface lands in later phases. -export {}; +export { AppLayout, type AppLayoutProps } from "./AppLayout"; diff --git a/src/shell/index.ts b/src/shell/index.ts index a1c1123..dae7c3f 100644 --- a/src/shell/index.ts +++ b/src/shell/index.ts @@ -1,2 +1,28 @@ -// @gsc/web-kit/shell — Phase 1 stub. Real surface lands in later phases. -export {}; +"use client"; + +import { createContext, useContext } from "react"; + +import type { ShellConfig } from "./types"; + +export type { + ShellApp, + ShellBranding, + ShellConfig, + ShellMenuItem, + ShellMenuZone, + ShellUser, +} from "./types"; + +const ShellContext = createContext(null); + +/** Provider used by ``; rarely needed directly. */ +export const ShellProvider = ShellContext.Provider; + +/** Read the current ShellConfig anywhere inside ``. */ +export function useShell(): ShellConfig { + const cfg = useContext(ShellContext); + if (!cfg) { + throw new Error("useShell must be used inside "); + } + return cfg; +} diff --git a/src/shell/server.ts b/src/shell/server.ts index 100771e..8b124d4 100644 --- a/src/shell/server.ts +++ b/src/shell/server.ts @@ -1,2 +1,75 @@ -// @gsc/web-kit/shell/server — Phase 1 stub. Real fetchShellConfig() lands in Phase 2. -export {}; +import "server-only"; + +import type { ShellConfig } from "./types"; + +export type { ShellConfig } from "./types"; + +export interface FetchShellConfigOptions { + /** App identifier as registered in shell-api (e.g. `"gsc-crm"`). */ + appKey: string; + /** Keycloak access token; forwarded as the Bearer credential. */ + accessToken: string; + /** + * Base URL of gsc-shell-api. Reads SHELL_API_URL env if omitted; + * falls back to the in-cluster service hostname. + */ + apiUrl?: string; + /** Request timeout in ms. Default 3000 — short, so a flaky shell-api + * never blank-screens the host app. */ + timeoutMs?: number; +} + +/** + * Server-only fetcher. Apps call this from an RSC layout, hand the + * result down to ``. Never invoked in the + * browser. + * + * Returns a minimal fallback ShellConfig on any error (network, + * 4xx/5xx, JSON parse). Apps should still render with no menus when + * shell-api is down or the session token is missing. + */ +export async function fetchShellConfig( + opts: FetchShellConfigOptions, +): Promise { + const apiUrl = + opts.apiUrl ?? + process.env.SHELL_API_URL ?? + "http://gsc-shell-api.gsc-shell.svc.cluster.local:8080"; + const timeoutMs = opts.timeoutMs ?? 3000; + const url = `${apiUrl}/api/v1/shell/${encodeURIComponent(opts.appKey)}`; + + try { + const res = await fetch(url, { + headers: opts.accessToken + ? { Authorization: `Bearer ${opts.accessToken}` } + : {}, + cache: "no-store", + signal: AbortSignal.timeout(timeoutMs), + }); + if (!res.ok) { + console.warn( + `[shell-api] ${url} returned ${res.status}; using fallback`, + ); + return fallbackConfig(opts.appKey); + } + return (await res.json()) as ShellConfig; + } catch (err) { + console.warn( + `[shell-api] fetch failed: ${(err as Error).message}; using fallback`, + ); + return fallbackConfig(opts.appKey); + } +} + +function fallbackConfig(appKey: string): ShellConfig { + return { + version: 1, + app: { key: appKey, displayName: appKey, baseUrl: "/" }, + branding: { + logoUrl: "https://assets.gosec.cloud/logos/logo.svg", + productName: appKey, + }, + user: { id: "anonymous", displayName: "", roles: [] }, + menus: {}, + }; +} diff --git a/src/shell/types.ts b/src/shell/types.ts new file mode 100644 index 0000000..6efb0e7 --- /dev/null +++ b/src/shell/types.ts @@ -0,0 +1,48 @@ +/** + * Shape of what gsc-shell-api returns. Mirror of the Go service's DTO. + * If you change one, change both — there's a runtime contract in + * between, not a code-generator. + */ + +export type ShellMenuZone = "topbar" | "sidebar" | "footer" | "user-menu"; + +export interface ShellMenuItem { + id: string; + key: string; + translationKey: string; + href: string; + icon?: string; + isExternal?: boolean; + children?: ShellMenuItem[]; +} + +export interface ShellApp { + key: string; + displayName: string; + baseUrl: string; +} + +export interface ShellBranding { + logoUrl: string; + productName: string; + footerHtml?: string; + brandColor?: string; +} + +export interface ShellUser { + id: string; + email?: string; + displayName: string; + givenName?: string; + familyName?: string; + tenantId?: string; + roles: string[]; +} + +export interface ShellConfig { + version: number; + app: ShellApp; + branding: ShellBranding; + user: ShellUser; + menus: Partial>; +}