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

@@ -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<ChromeFeatures>;
slot: ChromeSlots;
@@ -435,6 +445,8 @@ function AdminNavbar({
setShowActivityModal: (show: boolean) => void;
onOpenChat?: () => void;
onSignOut?: () => void | Promise<void>;
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 ? (
<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) => (
<Link
href={withLocale(menuItem.url, locale)}
@@ -551,7 +588,11 @@ function AdminNavbar({
{tMenu(normalizeTranslationKey(menuItem.translationKey))}
</Link>
))}
<LogoutButton label={labels.logout} onSignOut={onSignOut} />
<LogoutButton
label={labels.logout}
signoutPath={signoutPath}
onSignOut={onSignOut}
/>
</div>
</li>
</ul>

View File

@@ -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<void>;
};
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 (
<button
type="button"
onClick={() => {
void onSignOut();
}}
className="dropdown-item"
>
<i className="ph-sign-out me-2"></i>
{label}
</button>
);
}
return (
<button type="button" onClick={handleLogout} className="dropdown-item">
<a href={signoutPath} className="dropdown-item">
<i className="ph-sign-out me-2"></i>
{label}
</button>
</a>
);
}

View File

@@ -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",

View File

@@ -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<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>;
children: ReactNode;