feat: Phase 2 — layout · auth · shell are real
@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=<original>. 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):
- <ShellProvider> + useShell() — read the resolved config from any
descendant of <AppLayout>.
layout:
- <AppLayout config currentPath translate onSignOut navbarExtras>.
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) <noreply@anthropic.com>
This commit is contained in:
22
package-lock.json
generated
22
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<SessionUser>;
|
||||
|
||||
/** 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<SessionUser> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
41
src/auth/types.ts
Normal file
41
src/auth/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
403
src/layout/AppLayout.tsx
Normal file
403
src/layout/AppLayout.tsx
Normal file
@@ -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 (
|
||||
<ShellProvider value={config}>
|
||||
<AdminNavbar
|
||||
config={config}
|
||||
t={t}
|
||||
onSignOut={onSignOut}
|
||||
navbarExtras={navbarExtras}
|
||||
/>
|
||||
<div
|
||||
className="page-content d-flex align-items-start p-3 gap-3"
|
||||
style={{ minHeight: 0 }}
|
||||
>
|
||||
<AdminSidebar config={config} t={t} pathname={path} />
|
||||
<main className="flex-grow-1" style={{ minWidth: 0 }}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<AdminFooter config={config} t={t} />
|
||||
</ShellProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="navbar navbar-dark navbar-expand-lg navbar-static">
|
||||
<div className="container-fluid">
|
||||
<div className="navbar-brand wmin-200">
|
||||
<a href={config.app.baseUrl} className="d-inline-block">
|
||||
<img
|
||||
src={config.branding.logoUrl}
|
||||
className="h-36px"
|
||||
alt={config.branding.productName}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{navbarExtras}
|
||||
|
||||
<ul className="nav flex-row justify-content-end order-1 order-lg-2 ms-auto">
|
||||
<li
|
||||
className="nav-item nav-item-dropdown-lg dropdown ms-lg-2"
|
||||
onMouseLeave={() => setShowUserMenu(false)}
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
className="navbar-nav-link align-items-center rounded-pill p-1"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowUserMenu((open) => !open);
|
||||
}}
|
||||
>
|
||||
<div className="status-indicator-container">
|
||||
<span
|
||||
className="w-32px h-32px rounded-pill bg-primary bg-opacity-20 text-primary d-inline-flex align-items-center justify-content-center fw-semibold"
|
||||
style={{ fontSize: "0.75rem" }}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
<span className="status-indicator bg-success" />
|
||||
</div>
|
||||
<span className="d-none d-lg-inline-block mx-lg-2">
|
||||
{config.user.displayName || config.user.email || ""}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<div
|
||||
className={
|
||||
showUserMenu
|
||||
? "dropdown-menu dropdown-menu-end show"
|
||||
: "dropdown-menu dropdown-menu-end"
|
||||
}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: "0px 0px auto auto",
|
||||
margin: "0px",
|
||||
transform: "translate3d(0px, 44px, 0px)",
|
||||
}}
|
||||
>
|
||||
{userMenuItems.map((m) => {
|
||||
const isLogout = m.key === "logout";
|
||||
return (
|
||||
<a
|
||||
key={m.id}
|
||||
href={m.href}
|
||||
target={m.isExternal ? "_blank" : undefined}
|
||||
rel={m.isExternal ? "noopener noreferrer" : undefined}
|
||||
className="dropdown-item"
|
||||
onClick={
|
||||
isLogout && onSignOut
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
onSignOut();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{m.icon ? <i className={`${m.icon} me-2`} /> : null}
|
||||
{t(m.translationKey)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className={sidebarClasses}>
|
||||
<div className="sidebar-content">
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-body d-flex justify-content-center">
|
||||
{!collapsed && (
|
||||
<h5 className="sidebar-resize-hide flex-grow-1 my-auto">
|
||||
{config.branding.productName}
|
||||
</h5>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-light btn-icon btn-sm rounded-pill border-transparent sidebar-control sidebar-main-resize d-none d-lg-inline-flex"
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
<i className="ph-arrows-left-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<ul className="nav nav-sidebar" data-nav-type="accordion">
|
||||
{items.map((item) => (
|
||||
<SidebarNavItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
pathname={strippedPath}
|
||||
collapsed={collapsed}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? <i className={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 (
|
||||
<li
|
||||
className={navItemClasses}
|
||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
className={`nav-link ${active || childActive ? "active" : ""}`}
|
||||
title={collapsed ? t(item.translationKey) : undefined}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen((o) => !o);
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{!collapsed && <span>{t(item.translationKey)}</span>}
|
||||
</a>
|
||||
<ul
|
||||
className={
|
||||
collapsed
|
||||
? "nav-group-sub nav-group-sub-flyout collapse"
|
||||
: `nav-group-sub collapse ${open ? "show" : ""}`
|
||||
}
|
||||
data-submenu-title={t(item.translationKey)}
|
||||
>
|
||||
{item.children!.map((c) => (
|
||||
<li className="nav-item" key={c.id}>
|
||||
<a
|
||||
href={c.href}
|
||||
target={c.isExternal ? "_blank" : undefined}
|
||||
rel={c.isExternal ? "noopener noreferrer" : undefined}
|
||||
className={`nav-link ${isActiveHref(c.href, pathname) ? "active" : ""}`}
|
||||
>
|
||||
{c.icon && <i className={c.icon} />}
|
||||
{t(c.translationKey)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="nav-item">
|
||||
<a
|
||||
href={item.href}
|
||||
target={item.isExternal ? "_blank" : undefined}
|
||||
rel={item.isExternal ? "noopener noreferrer" : undefined}
|
||||
className={`nav-link ${active ? "active" : ""}`}
|
||||
title={collapsed ? t(item.translationKey) : undefined}
|
||||
>
|
||||
{icon}
|
||||
{!collapsed && <span>{t(item.translationKey)}</span>}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Footer ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function AdminFooter({
|
||||
config,
|
||||
t,
|
||||
}: {
|
||||
config: ShellConfig;
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
const items = config.menus.footer ?? [];
|
||||
return (
|
||||
<div className="navbar navbar-sm navbar-footer border-top">
|
||||
<div className="container-fluid">
|
||||
<span>
|
||||
{config.branding.footerHtml ? (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{ __html: config.branding.footerHtml }}
|
||||
/>
|
||||
) : (
|
||||
<>© {new Date().getFullYear()} GoSec Cloud</>
|
||||
)}
|
||||
</span>
|
||||
{items.length > 0 && (
|
||||
<ul className="navbar-nav flex-row gap-3 ms-auto">
|
||||
{items.map((m) => (
|
||||
<li className="nav-item" key={m.id}>
|
||||
<a
|
||||
href={m.href}
|
||||
target={m.isExternal ? "_blank" : undefined}
|
||||
rel={m.isExternal ? "noopener noreferrer" : undefined}
|
||||
className="navbar-nav-link"
|
||||
>
|
||||
{m.icon && <i className={`${m.icon} me-1`} />}
|
||||
{t(m.translationKey)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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}(?=\/|$)/, "") || "/";
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
// @gsc/web-kit/layout — Phase 1 stub. Real surface lands in later phases.
|
||||
export {};
|
||||
export { AppLayout, type AppLayoutProps } from "./AppLayout";
|
||||
|
||||
@@ -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<ShellConfig | null>(null);
|
||||
|
||||
/** Provider used by `<AppLayout>`; rarely needed directly. */
|
||||
export const ShellProvider = ShellContext.Provider;
|
||||
|
||||
/** Read the current ShellConfig anywhere inside `<AppLayout>`. */
|
||||
export function useShell(): ShellConfig {
|
||||
const cfg = useContext(ShellContext);
|
||||
if (!cfg) {
|
||||
throw new Error("useShell must be used inside <AppLayout>");
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
@@ -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 `<AppLayout config={...}>`. 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<ShellConfig> {
|
||||
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: {},
|
||||
};
|
||||
}
|
||||
|
||||
48
src/shell/types.ts
Normal file
48
src/shell/types.ts
Normal file
@@ -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<Record<ShellMenuZone, ShellMenuItem[]>>;
|
||||
}
|
||||
Reference in New Issue
Block a user