Merge pull request 'app: minimal root-layout shell - theme + env-config session providers (M3 slice 1, coreci-chat#16)' (#17) from po/m3-slice-1-root-shell into main
Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
@@ -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 <html> and <body> 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 (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 <html> 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<string, unknown> } | 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 };
|
||||
}
|
||||
@@ -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
|
||||
* <html>; 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<ThemeContextValue>({
|
||||
pref: "system",
|
||||
theme: "light",
|
||||
setPref: () => undefined,
|
||||
});
|
||||
|
||||
function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [pref, setPrefState] = useState<ThemeName>(() =>
|
||||
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 <ThemeContext.Provider value={{ pref, theme, setPref }}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
/* ----------------------------- session ---------------------------- */
|
||||
|
||||
interface SessionContextValue extends SessionSnapshot {
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const SessionContext = createContext<SessionContextValue>({
|
||||
status: "loading",
|
||||
user: null,
|
||||
refresh: () => undefined,
|
||||
});
|
||||
|
||||
function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const apiBase = useMemo(
|
||||
() => resolveApiBase(process.env[SHELL_ENV.apiBaseUrl]),
|
||||
[],
|
||||
);
|
||||
const [snapshot, setSnapshot] = useState<SessionSnapshot>({
|
||||
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 <SessionContext.Provider value={{ ...snapshot, refresh }}>{children}</SessionContext.Provider>;
|
||||
}
|
||||
|
||||
/* ----------------------------- exports ---------------------------- */
|
||||
|
||||
export function useShellTheme(): ThemeContextValue {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
export function useShellSession(): SessionContextValue {
|
||||
return useContext(SessionContext);
|
||||
}
|
||||
|
||||
export default function Providers({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user