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:
10
package.json
10
package.json
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
7
src/i18n/index.ts
Normal 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
140
src/i18n/server.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user