Add i18n factory + AdminShell My Profile + LogoutButton anchor

- src/i18n/server.ts: createI18nConfig factory consolidating the locale
  resolution chain (cookie → access_token preferred_language claim →
  Accept-Language → default). Reusable across apps; previously each
  frontend reimplemented it.
- AdminShell: thread signoutPath + myProfileUrl (default
  https://my.gosec.internal/profile) into the navbar; render My Profile
  link alongside logout.
- LogoutButton: replace two-step fetch+signOut+redirect with a plain
  anchor pointing at signoutPath — the NextAuth POST-only signout
  endpoint plus form-CSRF flow doesn't need client JS.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-05-19 20:50:37 +02:00
parent 85a31eb3d6
commit 71bce1bd56
8 changed files with 253 additions and 43 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@gsc/web-kit", "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.", "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", "license": "MIT",
"type": "module", "type": "module",
@@ -65,6 +65,14 @@
"types": "./dist/api/index.d.ts", "types": "./dist/api/index.d.ts",
"import": "./dist/api/index.js" "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": { "./utils": {
"types": "./dist/utils/index.d.ts", "types": "./dist/utils/index.d.ts",
"import": "./dist/utils/index.js" "import": "./dist/utils/index.js"

View File

@@ -100,17 +100,21 @@ function normalizeIconClass(iconClass: string | null | undefined) {
} }
/** /**
* Prepend `/{locale}` to internal absolute paths. Leaves external URLs * Originally prepended `/{locale}` to internal URLs for apps using
* (http(s)://…), already-prefixed paths, and the "#" sentinel alone. * next-intl's `localePrefix: 'always'` (i.e. with a `[locale]` route
* Menu-item URLs in the DB are stored locale-agnostic (e.g. `/contacts`); * segment). None of the kit's consumers (gscAdmin, gscMy, gscCRM,
* routing configs like `localePrefix: 'always'` require `/{locale}/contacts`. * 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 { function withLocale(url: string, _locale: string): string {
if (!url || url === "#") return 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}`;
} }
/** Strip a leading `/{locale}` segment so breadcrumbs/titles work on the app-level path. */ /** Strip a leading `/{locale}` segment so breadcrumbs/titles work on the app-level path. */
@@ -208,6 +212,8 @@ export function AdminShell({
features, features,
slots, slots,
onSignOut, onSignOut,
signoutPath,
myProfileUrl,
labels: labelOverrides, labels: labelOverrides,
children, children,
}: AdminShellProps) { }: AdminShellProps) {
@@ -328,6 +334,8 @@ export function AdminShell({
setShowActivityModal={setShowActivityModal} setShowActivityModal={setShowActivityModal}
onOpenChat={feat.chat ? () => setShowChatOverlay((open) => !open) : undefined} onOpenChat={feat.chat ? () => setShowChatOverlay((open) => !open) : undefined}
onSignOut={onSignOut} onSignOut={onSignOut}
signoutPath={signoutPath}
myProfileUrl={myProfileUrl}
/> />
{feat.subbar && ( {feat.subbar && (
@@ -418,6 +426,8 @@ function AdminNavbar({
setShowActivityModal, setShowActivityModal,
onOpenChat, onOpenChat,
onSignOut, onSignOut,
signoutPath,
myProfileUrl,
}: { }: {
feat: Required<ChromeFeatures>; feat: Required<ChromeFeatures>;
slot: ChromeSlots; slot: ChromeSlots;
@@ -435,6 +445,8 @@ function AdminNavbar({
setShowActivityModal: (show: boolean) => void; setShowActivityModal: (show: boolean) => void;
onOpenChat?: () => void; onOpenChat?: () => void;
onSignOut?: () => void | Promise<void>; onSignOut?: () => void | Promise<void>;
signoutPath?: string;
myProfileUrl?: string | null;
}) { }) {
const [showNavMenu, setShowNavMenu] = useState(false); const [showNavMenu, setShowNavMenu] = useState(false);
@@ -539,6 +551,31 @@ function AdminNavbar({
transform: "translate3d(0px, 44px, 0px)", 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 ? (
<a href={profileHref} className="dropdown-item">
<i className="ph-user-circle me-2"></i>
{labels.myProfile}
</a>
) : (
<Link
href={withLocale(profileHref, locale)}
className="dropdown-item"
>
<i className="ph-user-circle me-2"></i>
{labels.myProfile}
</Link>
);
})()}
{topbarMenu.map((menuItem) => ( {topbarMenu.map((menuItem) => (
<Link <Link
href={withLocale(menuItem.url, locale)} href={withLocale(menuItem.url, locale)}
@@ -551,7 +588,11 @@ function AdminNavbar({
{tMenu(normalizeTranslationKey(menuItem.translationKey))} {tMenu(normalizeTranslationKey(menuItem.translationKey))}
</Link> </Link>
))} ))}
<LogoutButton label={labels.logout} onSignOut={onSignOut} /> <LogoutButton
label={labels.logout}
signoutPath={signoutPath}
onSignOut={onSignOut}
/>
</div> </div>
</li> </li>
</ul> </ul>

View File

@@ -1,46 +1,45 @@
"use client"; "use client";
import { signOut } from "next-auth/react";
/** /**
* Default flow (shared org Keycloak): * Default flow: a plain anchor to `/api/auth/signout` (overridable via
* 1. GET /api/auth/logout — host app returns { logoutUrl } pointing at * the `signoutPath` prop). The route handler runs RP-initiated logout
* Keycloak's end_session endpoint (id_token_hint included). * in a single redirect — kills the NextAuth cookie, ends the Keycloak
* 2. next-auth signOut() locally — fires events.signOut for backchannel * SSO session, lands on the app's signed-out page.
* revocation; redirect:false so we control the navigation.
* 3. Navigate to logoutUrl — kills the SSO cookie at Keycloak.
* *
* Apps without /api/auth/logout (or that need a different flow) pass * Apps with a different shape (no `/api/auth/signout`, need to fire
* `onSignOut` to fully replace this behavior. * custom telemetry, etc.) pass `onSignOut` to replace the navigation
* with a button + custom handler.
*/ */
type LogoutButtonProps = { type LogoutButtonProps = {
label: string; label: string;
signoutPath?: string;
onSignOut?: () => void | Promise<void>; onSignOut?: () => void | Promise<void>;
}; };
export function LogoutButton({ label, onSignOut }: LogoutButtonProps) { export function LogoutButton({
const handleLogout = async () => { label,
signoutPath = "/api/auth/signout",
onSignOut,
}: LogoutButtonProps) {
if (onSignOut) { 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;
};
return ( return (
<button type="button" onClick={handleLogout} className="dropdown-item"> <button
type="button"
onClick={() => {
void onSignOut();
}}
className="dropdown-item"
>
<i className="ph-sign-out me-2"></i> <i className="ph-sign-out me-2"></i>
{label} {label}
</button> </button>
); );
} }
return (
<a href={signoutPath} className="dropdown-item">
<i className="ph-sign-out me-2"></i>
{label}
</a>
);
}

View File

@@ -15,6 +15,7 @@ export const DEFAULT_CHROME_LABELS: ChromeLabels = {
settings: "Settings", settings: "Settings",
allSettings: "All Settings", allSettings: "All Settings",
logout: "Logout", logout: "Logout",
myProfile: "My Profile",
docs: "Docs", docs: "Docs",
browseApps: "Browse apps", browseApps: "Browse apps",
viewAll: "View all", viewAll: "View all",

View File

@@ -132,6 +132,7 @@ export type ChromeLabels = {
settings: string; // subbar "Settings" settings: string; // subbar "Settings"
allSettings: string; // subbar dropdown "All Settings" allSettings: string; // subbar dropdown "All Settings"
logout: string; // user dropdown "Logout" logout: string; // user dropdown "Logout"
myProfile: string; // user dropdown "My Profile" (cross-app link to gscMy)
docs: string; // footer "Docs" docs: string; // footer "Docs"
browseApps: string; // browse-apps "Browse apps" browseApps: string; // browse-apps "Browse apps"
viewAll: string; // browse-apps "View all" viewAll: string; // browse-apps "View all"
@@ -168,6 +169,20 @@ export type AdminShellProps = {
// Behavior // Behavior
onSignOut?: () => void | Promise<void>; onSignOut?: () => void | Promise<void>;
/**
* 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<ChromeLabels>; labels?: Partial<ChromeLabels>;
children: ReactNode; children: ReactNode;

View File

@@ -59,7 +59,6 @@ export {
useValidation, useValidation,
useFieldValidation, useFieldValidation,
useAddressAutocomplete, useAddressAutocomplete,
loadGoogleMapsScript,
} from "@limitless/ui"; } from "@limitless/ui";
// Validation — format validators // Validation — format validators

7
src/i18n/index.ts Normal file
View File

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

140
src/i18n/server.ts Normal file
View File

@@ -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<L extends string> {
/** 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<string | undefined>;
/** Load the message bundle for a resolved locale. */
loadMessages: (locale: L) => Promise<Record<string, unknown>>;
/**
* 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<string, unknown>;
const value = claims[claimName];
return typeof value === "string" ? value : undefined;
} catch {
return undefined;
}
}
export function createI18nConfig<L extends string>(opts: CreateI18nOptions<L>) {
const claimName = opts.claimName ?? "preferred_language";
const cookieName = opts.cookieName ?? "NEXT_LOCALE";
const localeSet = new Set<string>(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;
},
};
});
}