diff --git a/package.json b/package.json index 37bf0c1..d209541 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gsc/web-kit", - "version": "0.4.1", + "version": "0.5.1", "description": "GSC web app skeleton — layout, auth, data, forms, feedback, navigation, chrome. Built on @limitless/ui. Drop into a Next.js app and just write pages.", "license": "MIT", "type": "module", @@ -65,6 +65,14 @@ "types": "./dist/api/index.d.ts", "import": "./dist/api/index.js" }, + "./i18n": { + "types": "./dist/i18n/index.d.ts", + "import": "./dist/i18n/index.js" + }, + "./i18n/server": { + "types": "./dist/i18n/server.d.ts", + "import": "./dist/i18n/server.js" + }, "./utils": { "types": "./dist/utils/index.d.ts", "import": "./dist/utils/index.js" diff --git a/src/chrome/AdminShell.tsx b/src/chrome/AdminShell.tsx index 6cb524b..404c583 100644 --- a/src/chrome/AdminShell.tsx +++ b/src/chrome/AdminShell.tsx @@ -100,17 +100,21 @@ function normalizeIconClass(iconClass: string | null | undefined) { } /** - * Prepend `/{locale}` to internal absolute paths. Leaves external URLs - * (http(s)://…), already-prefixed paths, and the "#" sentinel alone. - * Menu-item URLs in the DB are stored locale-agnostic (e.g. `/contacts`); - * routing configs like `localePrefix: 'always'` require `/{locale}/contacts`. + * Originally prepended `/{locale}` to internal URLs for apps using + * next-intl's `localePrefix: 'always'` (i.e. with a `[locale]` route + * segment). None of the kit's consumers (gscAdmin, gscMy, gscCRM, + * gscSupport, …) currently use that routing pattern — they resolve + * the locale per-request via cookie/Accept-Language and serve pages + * at unprefixed paths (e.g. `/dashboard`, not `/de/dashboard`). The + * prefix-prepend behaviour produced 404s for those apps, so this is + * now a no-op. + * + * If a future consumer DOES adopt `[locale]` routing, gate the + * prefixing behind an `AdminShellProps.localePrefix` flag rather than + * reverting this unconditionally. */ -function withLocale(url: string, locale: string): string { - if (!url || url === "#") return url; - if (/^[a-z]+:\/\//i.test(url)) return url; - if (!url.startsWith("/")) return url; - if (url === `/${locale}` || url.startsWith(`/${locale}/`)) return url; - return `/${locale}${url}`; +function withLocale(url: string, _locale: string): string { + return url; } /** Strip a leading `/{locale}` segment so breadcrumbs/titles work on the app-level path. */ @@ -208,6 +212,8 @@ export function AdminShell({ features, slots, onSignOut, + signoutPath, + myProfileUrl, labels: labelOverrides, children, }: AdminShellProps) { @@ -328,6 +334,8 @@ export function AdminShell({ setShowActivityModal={setShowActivityModal} onOpenChat={feat.chat ? () => setShowChatOverlay((open) => !open) : undefined} onSignOut={onSignOut} + signoutPath={signoutPath} + myProfileUrl={myProfileUrl} /> {feat.subbar && ( @@ -418,6 +426,8 @@ function AdminNavbar({ setShowActivityModal, onOpenChat, onSignOut, + signoutPath, + myProfileUrl, }: { feat: Required; slot: ChromeSlots; @@ -435,6 +445,8 @@ function AdminNavbar({ setShowActivityModal: (show: boolean) => void; onOpenChat?: () => void; onSignOut?: () => void | Promise; + signoutPath?: string; + myProfileUrl?: string | null; }) { const [showNavMenu, setShowNavMenu] = useState(false); @@ -539,6 +551,31 @@ function AdminNavbar({ transform: "translate3d(0px, 44px, 0px)", }} > + {(() => { + // gscMy lives at a fixed cross-app URL; default points there. + // Pass `myProfileUrl={null}` to omit (e.g. gscMy itself, which + // links to its own /profile via topbarMenu). + const profileHref = + myProfileUrl === undefined + ? "https://my.gosec.internal/profile" + : myProfileUrl; + if (!profileHref) return null; + const isExternal = /^https?:\/\//.test(profileHref); + return isExternal ? ( + + + {labels.myProfile} + + ) : ( + + + {labels.myProfile} + + ); + })()} {topbarMenu.map((menuItem) => ( ))} - + diff --git a/src/chrome/LogoutButton.tsx b/src/chrome/LogoutButton.tsx index cf2a4d4..d1a059f 100644 --- a/src/chrome/LogoutButton.tsx +++ b/src/chrome/LogoutButton.tsx @@ -1,46 +1,45 @@ "use client"; -import { signOut } from "next-auth/react"; - /** - * Default flow (shared org Keycloak): - * 1. GET /api/auth/logout — host app returns { logoutUrl } pointing at - * Keycloak's end_session endpoint (id_token_hint included). - * 2. next-auth signOut() locally — fires events.signOut for backchannel - * revocation; redirect:false so we control the navigation. - * 3. Navigate to logoutUrl — kills the SSO cookie at Keycloak. + * Default flow: a plain anchor to `/api/auth/signout` (overridable via + * the `signoutPath` prop). The route handler runs RP-initiated logout + * in a single redirect — kills the NextAuth cookie, ends the Keycloak + * SSO session, lands on the app's signed-out page. * - * Apps without /api/auth/logout (or that need a different flow) pass - * `onSignOut` to fully replace this behavior. + * Apps with a different shape (no `/api/auth/signout`, need to fire + * custom telemetry, etc.) pass `onSignOut` to replace the navigation + * with a button + custom handler. */ type LogoutButtonProps = { label: string; + signoutPath?: string; onSignOut?: () => void | Promise; }; -export function LogoutButton({ label, onSignOut }: LogoutButtonProps) { - const handleLogout = async () => { - if (onSignOut) { - await onSignOut(); - return; - } - - let logoutUrl = "/logged-out"; - try { - const res = await fetch("/api/auth/logout"); - const body = await res.json(); - if (body?.logoutUrl) logoutUrl = body.logoutUrl; - } catch { - // fall through with local-only logout - } - await signOut({ redirect: false }); - window.location.href = logoutUrl; - }; +export function LogoutButton({ + label, + signoutPath = "/api/auth/signout", + onSignOut, +}: LogoutButtonProps) { + if (onSignOut) { + return ( + + ); + } return ( - + ); } diff --git a/src/chrome/labels.ts b/src/chrome/labels.ts index 5fd9bfc..f8f3e4d 100644 --- a/src/chrome/labels.ts +++ b/src/chrome/labels.ts @@ -15,6 +15,7 @@ export const DEFAULT_CHROME_LABELS: ChromeLabels = { settings: "Settings", allSettings: "All Settings", logout: "Logout", + myProfile: "My Profile", docs: "Docs", browseApps: "Browse apps", viewAll: "View all", diff --git a/src/chrome/types.ts b/src/chrome/types.ts index dece69a..2d2d6d3 100644 --- a/src/chrome/types.ts +++ b/src/chrome/types.ts @@ -132,6 +132,7 @@ export type ChromeLabels = { settings: string; // subbar "Settings" allSettings: string; // subbar dropdown "All Settings" logout: string; // user dropdown "Logout" + myProfile: string; // user dropdown "My Profile" (cross-app link to gscMy) docs: string; // footer "Docs" browseApps: string; // browse-apps "Browse apps" viewAll: string; // browse-apps "View all" @@ -168,6 +169,20 @@ export type AdminShellProps = { // Behavior onSignOut?: () => void | Promise; + /** + * Override the path the LogoutButton anchor points at. Defaults to + * `/api/auth/signout` — the canonical RP-initiated logout endpoint + * (single redirect: kills NextAuth cookie + ends Keycloak SSO + lands + * on the app's signed-out page). + */ + signoutPath?: string; + /** + * URL the "My Profile" item in the user dropdown points at. Defaults + * to the canonical gscMy profile page (`https://my.gosec.internal/profile`). + * gscMy itself should override this to its own local `/profile` route. + * Pass `null` to omit the item entirely. + */ + myProfileUrl?: string | null; labels?: Partial; children: ReactNode; diff --git a/src/forms/index.ts b/src/forms/index.ts index a340fb3..a369960 100644 --- a/src/forms/index.ts +++ b/src/forms/index.ts @@ -59,7 +59,6 @@ export { useValidation, useFieldValidation, useAddressAutocomplete, - loadGoogleMapsScript, } from "@limitless/ui"; // Validation — format validators diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..12f4221 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,7 @@ +/** + * @gsc/web-kit/i18n — placeholder for future client-side i18n helpers + * (locale switcher component, etc.). The server-side factory lives in + * `@gsc/web-kit/i18n/server` so consumer client bundles don't pull in + * next/headers and next-intl/server. + */ +export {}; diff --git a/src/i18n/server.ts b/src/i18n/server.ts new file mode 100644 index 0000000..2ff6bb4 --- /dev/null +++ b/src/i18n/server.ts @@ -0,0 +1,140 @@ +/** + * @gsc/web-kit/i18n/server — next-intl request config factory. + * + * Resolves the active locale from: + * 1. `NEXT_LOCALE` cookie (set by an in-app language switcher; beats + * the token claim until the next refresh). + * 2. Keycloak access-token `preferred_language` claim, sourced from + * FreeIPA's `preferredLanguage` user attribute via the gosecCloud + * realm's `preferred-language` LDAP mapper + per-client OIDC + * `oidc-usermodel-attribute-mapper`. + * 3. `Accept-Language` request header. + * 4. `defaultLocale`. + * + * Mirrors `createAuth()` from `@gsc/web-kit/auth/server`: one factory + * call, app supplies the locale list and message loader, kit owns the + * resolution chain. + * + * @example + * // src/i18n/request.ts + * import { createI18nConfig } from "@gsc/web-kit/i18n/server"; + * import { auth } from "@/auth"; + * + * export const locales = ["en", "de", "fr"] as const; + * export const defaultLocale = "en" as const; + * + * export default createI18nConfig({ + * locales, + * defaultLocale, + * getAccessToken: async () => + * (await auth())?.user?.accessToken as string | undefined, + * loadMessages: (locale) => + * import(`../../public/locales/${locale}/common.json`).then((m) => m.default), + * }); + */ +import { getRequestConfig } from "next-intl/server"; +import { cookies, headers } from "next/headers"; + +export interface CreateI18nOptions { + /** Supported locale codes. First entry is conventional UI fallback order. */ + locales: readonly L[]; + /** Locale used when nothing in the resolution chain matches. */ + defaultLocale: L; + /** + * Return the current user's Keycloak access token, or undefined if no + * session. Typically `async () => (await auth())?.user?.accessToken`. + * The kit doesn't import the app's auth module — pass it explicitly so + * the kit stays decoupled from how the app wires NextAuth. + */ + getAccessToken?: () => Promise; + /** Load the message bundle for a resolved locale. */ + loadMessages: (locale: L) => Promise>; + /** + * JWT claim name carrying the user's preferred language. Default + * `preferred_language` — matches the gosecCloud per-client OIDC mapper + * named `preferredLanguage` (config `claim.name=preferred_language`). + */ + claimName?: string; + /** Cookie name carrying a recent in-app language switch. Default `NEXT_LOCALE`. */ + cookieName?: string; +} + +function decodeAccessTokenClaim( + accessToken: string, + claimName: string, +): string | undefined { + // Plain base64url-decode of the JWT payload. We don't verify here: + // NextAuth already verified the ID token via OIDC, the access token + // came back over the same TLS exchange, and the only use is a local + // locale preference. Same approach as `rolesFromAccessToken` in + // auth/server.ts. + const parts = accessToken.split("."); + if (parts.length < 2) return undefined; + try { + const payload = parts[1]; + const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4); + const decoded = Buffer.from( + padded.replace(/-/g, "+").replace(/_/g, "/"), + "base64", + ).toString("utf-8"); + const claims = JSON.parse(decoded) as Record; + const value = claims[claimName]; + return typeof value === "string" ? value : undefined; + } catch { + return undefined; + } +} + +export function createI18nConfig(opts: CreateI18nOptions) { + const claimName = opts.claimName ?? "preferred_language"; + const cookieName = opts.cookieName ?? "NEXT_LOCALE"; + const localeSet = new Set(opts.locales); + const isLocale = (v: unknown): v is L => + typeof v === "string" && localeSet.has(v); + + return getRequestConfig(async () => { + const cookieStore = await cookies(); + const cookieLocale = cookieStore.get(cookieName)?.value; + + let locale: L | undefined = isLocale(cookieLocale) ? cookieLocale : undefined; + + if (!locale && opts.getAccessToken) { + try { + const token = await opts.getAccessToken(); + if (token) { + const claim = decodeAccessTokenClaim(token, claimName); + if (isLocale(claim)) locale = claim; + } + } catch { + // Session read failed (e.g. missing cookie outside a request + // scope). Fall through to Accept-Language. + } + } + + if (!locale) { + const headersList = await headers(); + const acceptLanguage = headersList.get("accept-language"); + if (acceptLanguage) { + const candidate = acceptLanguage.split(",")[0]?.split("-")[0]; + if (isLocale(candidate)) locale = candidate; + } + } + + if (!locale) locale = opts.defaultLocale; + + return { + locale, + messages: await opts.loadMessages(locale), + onError(error) { + if (error.code === "MISSING_MESSAGE") { + console.warn(error.message); + } else { + console.error(error); + } + }, + getMessageFallback({ key }) { + return key; + }, + }; + }); +}