From 754222083cc946e859a24407cfa438fac17a09d3 Mon Sep 17 00:00:00 2001 From: coreci-chat-po Date: Tue, 3 Nov 2026 18:57:24 +0000 Subject: [PATCH] feat(app): minimal root-layout shell - theme + env-config session providers M3 slice 1 (coreci-chat#16): wrap the root layout's children with a theme provider (light/dark/system, persisted, no external dep) and a session wrapper over @coreci/auth's session lane (identity via GET /api/me, client config from NEXT_PUBLIC_API_BASE_URL, default same-origin). Additive only: no redirects or gates, SSR pages keep server-side session resolution, and no boot-time DDL (deploy owns DDL, app boots schema-check only). Pure logic extracted to app/shell/_lib.ts + unit tests, repo Wave-J convention. --- apps/control-plane/app/layout.tsx | 21 ++- apps/control-plane/app/shell/_lib.ts | 108 +++++++++++++ apps/control-plane/app/shell/providers.tsx | 169 +++++++++++++++++++++ apps/control-plane/tests/shell-lib.test.ts | 95 ++++++++++++ apps/control-plane/vitest.config.ts | 2 + 5 files changed, 387 insertions(+), 8 deletions(-) create mode 100644 apps/control-plane/app/shell/_lib.ts create mode 100644 apps/control-plane/app/shell/providers.tsx create mode 100644 apps/control-plane/tests/shell-lib.test.ts diff --git a/apps/control-plane/app/layout.tsx b/apps/control-plane/app/layout.tsx index c482c82..71a7fa2 100644 --- a/apps/control-plane/app/layout.tsx +++ b/apps/control-plane/app/layout.tsx @@ -1,18 +1,21 @@ /** - * / - App Router root layout (coreci-chat#10). + * / - App Router root layout (coreci-chat#10 minimum; coreci-chat#16 slice 1). * * Next.js 15 requires a root layout that renders and above * every route. Without one, `next build` fails with "login/page.tsx does not * have a root layout", so apps/control-plane has never been buildable - the * pipeline never built the app, so nothing caught it. * - * This is the build-enabling minimum only. The shell content (theme, - * providers, session wrapper) is an M3 scope call for the line head and is - * deliberately NOT decided here - it rides the M3 kickoff verdict on - * coreci-chat#1. + * Slice 1 (M3) adds the minimal shell content around {children}: the theme + * provider and the auth/session provider (env-based client config), both + * defined in app/shell/providers.tsx. The shell is additive: it never + * redirects or gates - /login and every SSR page keep their server-side + * session resolution - and no boot-time DDL rides it (deploy owns DDL, the + * app boots schema-check only, coreci-chat#1 comment 16428). */ import type { ReactNode } from "react"; +import Providers from "./shell/providers"; export const metadata = { title: "CoreCI Chat - control plane", @@ -20,8 +23,10 @@ export const metadata = { export default function RootLayout({ children }: { children: ReactNode }) { return ( - - {children} + + + {children} + ); -} +} \ No newline at end of file diff --git a/apps/control-plane/app/shell/_lib.ts b/apps/control-plane/app/shell/_lib.ts new file mode 100644 index 0000000..683b12f --- /dev/null +++ b/apps/control-plane/app/shell/_lib.ts @@ -0,0 +1,108 @@ +/** + * Root-shell pure logic (coreci-chat#16, M3 slice 1). + * + * Extracted from the React providers for node-testability, one repo + * convention applied from Wave J (app/_lib.ts files tested via vitest): + * no DOM, no Next imports — only decisions. The provider components in + * app/shell/providers.tsx consume these; env names live here too: + * NEXT_PUBLIC_API_BASE_URL — client session config (default same-origin) + * NEXT_PUBLIC_DEFAULT_THEME — fallback theme before user preference + */ + +export type ThemeName = "light" | "dark" | "system"; +export type ResolvedTheme = "light" | "dark"; + +export const THEME_STORAGE_KEY = "coreci.theme"; + +/** Env names the shell reads (build-time inlined, undefined-safe). */ +export const SHELL_ENV = { + apiBaseUrl: "NEXT_PUBLIC_API_BASE_URL", + defaultTheme: "NEXT_PUBLIC_DEFAULT_THEME", +} as const; + +/** Coerce a stored/default candidate into a theme name. */ +export function normalizeTheme( + value: string | null | undefined, + fallback: ThemeName = "system", +): ThemeName { + return value === "light" || value === "dark" || value === "system" + ? value + : fallback; +} + +/** + * Resolve a concrete theme: "system" follows prefers-color-scheme, + * everything else is explicit. + */ +export function resolveTheme( + pref: ThemeName, + systemPrefersDark: boolean, +): ResolvedTheme { + if (pref === "system") return systemPrefersDark ? "dark" : "light"; + return pref; +} + +/** CSS class applied to for the resolved theme. */ +export function themeClass(theme: ResolvedTheme): string { + return `theme-${theme}`; +} + +/** + * Session client config (the GO's env-decided shape): the client's API base + * comes from NEXT_PUBLIC_API_BASE_URL and defaults to same-origin ("") — no + * runner-image or deployment assumption is baked in. Only same-origin or + * absolute http(s) URLs are accepted; anything else falls back to same-origin. + */ +export function resolveApiBase(value: string | null | undefined): string { + if (typeof value !== "string") return ""; + const v = value.trim().replace(/\/+$/, ""); + if (v === "") return ""; + if (v.startsWith("/") && !v.startsWith("//")) return v; + if (v.startsWith("http://") || v.startsWith("https://")) return v; + return ""; +} + +/** Client-visible session shape — mirrors GET /api/me. */ +export interface SessionUser { + id: string; + tenantId: string; + role: "admin" | "operator" | "viewer"; +} + +export type SessionStatus = "loading" | "guest" | "authenticated" | "error"; + +export interface SessionSnapshot { + status: SessionStatus; + user: SessionUser | null; +} + +/** + * Classify a /api/me response into a session snapshot. 200 → authenticated, + * 401/403 → guest (the login route itself renders without a session — never + * redirect from the root shell), anything else → error. + */ +export function classifyMe( + httpStatus: number, + payload: unknown, +): SessionSnapshot { + if (httpStatus === 200) { + const rec = payload as { user?: Record } | null; + const user = rec?.user ?? undefined; + if ( + user && + typeof user.id === "string" && + typeof user.tenantId === "string" && + (user.role === "admin" || user.role === "operator" || user.role === "viewer") + ) { + return { + status: "authenticated", + user: { id: user.id, tenantId: user.tenantId, role: user.role }, + }; + } + return { status: "error", user: null }; + } + if (httpStatus === 401 || httpStatus === 403) { + return { status: "guest", user: null }; + } + return { status: "error", user: null }; +} \ No newline at end of file diff --git a/apps/control-plane/app/shell/providers.tsx b/apps/control-plane/app/shell/providers.tsx new file mode 100644 index 0000000..d3e5350 --- /dev/null +++ b/apps/control-plane/app/shell/providers.tsx @@ -0,0 +1,169 @@ +"use client"; + +/** + * Root-shell providers (coreci-chat#16, M3 slice 1). + * + * Minimal shell content for the app router root layout: + * 1. ThemeProvider — light/dark/system, persisted, applied as a class on + * ; no external dependency. + * 2. SessionProvider — the client-side session wrapper over @coreci/auth's + * server session lane: it reads the coreci_session cookie's identity via + * GET /api/me and exposes it as context. The client config comes from + * NEXT_PUBLIC_API_BASE_URL (default same-origin) — env-based, no + * runner-image assumption. + * + * Deliberate limits (slice 1, per the task scope): + * - The root shell NEVER redirects or gates: /login and every SSR page + * keep their existing server-side session resolution (lib/session via + * getMe()); this context is additive for the chat UI. + * - No boot-time DDL and no DB access here — deploy owns DDL, the app + * boots schema-check only (coreci-chat#1 comment 16428). + */ + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { + classifyMe, + normalizeTheme, + resolveApiBase, + resolveTheme, + themeClass, + THEME_STORAGE_KEY, + SHELL_ENV, + type SessionSnapshot, + type ThemeName, + type ResolvedTheme, +} from "./_lib.js"; + +/* ------------------------------ theme ------------------------------ */ + +interface ThemeContextValue { + pref: ThemeName; + theme: ResolvedTheme; + setPref: (next: ThemeName) => void; +} + +const ThemeContext = createContext({ + pref: "system", + theme: "light", + setPref: () => undefined, +}); + +function ThemeProvider({ children }: { children: ReactNode }) { + const [pref, setPrefState] = useState(() => + normalizeTheme(process.env[SHELL_ENV.defaultTheme], "system"), + ); + + useEffect(() => { + const stored = window.localStorage.getItem(THEME_STORAGE_KEY); + setPrefState(normalizeTheme(stored, normalizeTheme(process.env[SHELL_ENV.defaultTheme], "system"))); + }, []); + + const setPref = useCallback((next: ThemeName) => { + setPrefState(next); + try { + window.localStorage.setItem(THEME_STORAGE_KEY, next); + } catch { + // Storage may be unavailable; the preference still applies this session. + } + }, []); + + // SSR-safe: "system" reads prefers-color-scheme only in the browser, after + // hydration, and tracks changes (the server render never touches window). + const [systemDark, setSystemDark] = useState(false); + useEffect(() => { + const query = window.matchMedia("(prefers-color-scheme: dark)"); + setSystemDark(query.matches); + const onChange = (e: MediaQueryListEvent) => { + setSystemDark(e.matches); + }; + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); + }, []); + + const theme = resolveTheme(pref, systemDark); + + useEffect(() => { + const el = document.documentElement; + el.classList.add(themeClass(theme)); + return () => el.classList.remove(themeClass(theme)); + }, [theme]); + + return {children}; +} + +/* ----------------------------- session ---------------------------- */ + +interface SessionContextValue extends SessionSnapshot { + refresh: () => void; +} + +const SessionContext = createContext({ + status: "loading", + user: null, + refresh: () => undefined, +}); + +function SessionProvider({ children }: { children: ReactNode }) { + const apiBase = useMemo( + () => resolveApiBase(process.env[SHELL_ENV.apiBaseUrl]), + [], + ); + const [snapshot, setSnapshot] = useState({ + status: "loading", + user: null, + }); + + const fetchMe = useCallback(async () => { + try { + const res = await fetch(`${apiBase}/api/me`, { + credentials: "same-origin", + cache: "no-store", + }); + let payload: unknown = null; + try { + payload = await res.json(); + } catch { + payload = null; + } + setSnapshot(classifyMe(res.status, payload)); + } catch { + setSnapshot({ status: "error", user: null }); + } + }, [apiBase]); + + useEffect(() => { + void fetchMe(); + }, [fetchMe]); + + const refresh = useCallback(() => { + void fetchMe(); + }, [fetchMe]); + + return {children}; +} + +/* ----------------------------- exports ---------------------------- */ + +export function useShellTheme(): ThemeContextValue { + return useContext(ThemeContext); +} + +export function useShellSession(): SessionContextValue { + return useContext(SessionContext); +} + +export default function Providers({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/apps/control-plane/tests/shell-lib.test.ts b/apps/control-plane/tests/shell-lib.test.ts new file mode 100644 index 0000000..69f22a5 --- /dev/null +++ b/apps/control-plane/tests/shell-lib.test.ts @@ -0,0 +1,95 @@ +/** + * Root-shell pure logic (coreci-chat#16, M3 slice 1). + */ + +import { describe, expect, it } from "vitest"; +import { + classifyMe, + normalizeTheme, + resolveApiBase, + resolveTheme, + themeClass, + THEME_STORAGE_KEY, +} from "../app/shell/_lib.js"; + +describe("normalizeTheme", () => { + it("accepts the three theme names", () => { + expect(normalizeTheme("light")).toBe("light"); + expect(normalizeTheme("dark")).toBe("dark"); + expect(normalizeTheme("system")).toBe("system"); + }); + + it("falls back for stored garbage and absence", () => { + expect(normalizeTheme("blue")).toBe("system"); + expect(normalizeTheme(null)).toBe("system"); + expect(normalizeTheme(undefined, "dark")).toBe("dark"); + }); + + it("uses the env default as fallback (the shipped env default)", () => { + expect(normalizeTheme(null, "light")).toBe("light"); + }); +}); + +describe("resolveTheme + themeClass", () => { + it("resolves system to the media query result", () => { + expect(resolveTheme("system", false)).toBe("light"); + expect(resolveTheme("system", true)).toBe("dark"); + }); + + it("resolves explicit preferences over the media query", () => { + expect(resolveTheme("dark", false)).toBe("dark"); + expect(resolveTheme("light", true)).toBe("light"); + }); + + it("produces the html class name", () => { + expect(themeClass("dark")).toBe("theme-dark"); + expect(themeClass("light")).toBe("theme-light"); + }); + + it("exposes the storage key shape", () => { + expect(THEME_STORAGE_KEY).toBe("coreci.theme"); + }); +}); + +describe("resolveApiBase (env-based session client config)", () => { + it("defaults to same-origin when the env var is absent", () => { + expect(resolveApiBase(undefined)).toBe(""); + expect(resolveApiBase(null)).toBe(""); + expect(resolveApiBase("")).toBe(""); + expect(resolveApiBase(" ")).toBe(""); + }); + + it("accepts same-origin-relative and absolute http(s) bases", () => { + expect(resolveApiBase("/gw")).toBe("/gw"); + expect(resolveApiBase("https://chat.coreci.dev/")).toBe("https://chat.coreci.dev"); + expect(resolveApiBase(" http://localhost:3000/ ")).toBe("http://localhost:3000"); + }); + + it("falls back to same-origin for unsupported forms", () => { + expect(resolveApiBase("//evil.example")).toBe(""); + expect(resolveApiBase("ftp://x")).toBe(""); + expect(resolveApiBase("javascript:alert(1)")).toBe(""); + }); +}); + +describe("classifyMe (/api/me responses)", () => { + it("classifies a valid 200 payload as authenticated with the user", () => { + const out = classifyMe(200, { user: { id: "u1", tenantId: "t1", role: "operator" } }); + expect(out.status).toBe("authenticated"); + expect(out.user).toEqual({ id: "u1", tenantId: "t1", role: "operator" }); + }); + + it("treats a malformed 200 payload as an error, not a crash", () => { + expect(classifyMe(200, { user: { id: 7 } }).status).toBe("error"); + expect(classifyMe(200, null).status).toBe("error"); + }); + + it("treats 401/403 as guest — the shell never redirects", () => { + expect(classifyMe(401, null)).toEqual({ status: "guest", user: null }); + expect(classifyMe(403, null)).toEqual({ status: "guest", user: null }); + }); + + it("treats other statuses as an error state", () => { + expect(classifyMe(500, null)).toEqual({ status: "error", user: null }); + }); +}); \ No newline at end of file diff --git a/apps/control-plane/vitest.config.ts b/apps/control-plane/vitest.config.ts index 8ac1c28..252dfa9 100644 --- a/apps/control-plane/vitest.config.ts +++ b/apps/control-plane/vitest.config.ts @@ -15,6 +15,8 @@ export default defineConfig({ "app/dashboard/settings/adapters/_lib.ts", "app/dashboard/test-call/_helpers.ts", "app/dashboard/test-call/_lib.ts", + // M3 slice 1 root-shell pure logic (coreci-chat#16). + "app/shell/_lib.ts", ], reporter: ["text", "json"], },