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:
Claude
2026-05-11 00:20:08 +02:00
parent 957880e5c5
commit 1f2141118d
11 changed files with 838 additions and 15 deletions

View File

@@ -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,
};
}