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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user