Compare commits

..

5 Commits

Author SHA1 Message Date
Claude
a11353577d chrome: brandIcons() — derive Next.js favicon metadata from Brand
Every app that imports the kit was shipping a /favicon.ico 404
because none of them wired up Next.js's metadata.icons. This adds
a tiny helper so an app only has to:

  export const metadata: Metadata = {
    title: brand.product,
    icons: brandIcons(brand),
  };

brandIcons() returns icon/shortcut/apple entries pointing at
brand.faviconUrl (new optional field, defaults to brand.logoUrl).
MIME type inferred from the URL extension (svg/png/ico).

Brand gains the optional faviconUrl field. Existing apps that just
pass logoUrl keep working — they'll now render the logo as the
favicon by default. Apps that want a separate icon set
faviconUrl explicitly.

First consumer: gscSounds layout — verified /favicon.ico now
serves the proper icon and /icon.svg works too.
2026-05-23 13:39:49 +02:00
Claude
71bce1bd56 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>
2026-05-19 20:50:37 +02:00
Claude
85a31eb3d6 feat(auth): force re-auth on failed refresh
requireAuth() now redirects on user.error so SSR catches stale sessions
that the cookie-presence middleware can't see. New SessionExpirationGuard
(client) listens on useSession() and calls signIn("keycloak") when the
JWT carries RefreshAccessTokenError or RefreshTokenMissing. Without it,
a tab idle past the Keycloak SSO lifetime sat on a dead accessToken
until a downstream API 401'd, with no UI-level redirect.

Bumps to 0.4.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:44:29 +02:00
Claude
ec33b7bcb8 fix(migrations): nav.apps demo placeholders → Phosphor icons
The four entries pointing at /images/demo/logos/{1-4}.svg (gsc-meet,
gsc-ai-hub, gsc-surveillance, gsc-dam) shipped icon URLs that don't
resolve in any consumer app's public/, producing 404s in every
AdminShell app-switcher render. Replaced with Phosphor classes +
bg-*-lt tinted tiles, matching the existing gsc-voice/gsc-archive
pattern in the same seed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:26:01 +02:00
Claude
08a62d550c feat(auth): refresh-token rotation, per-app role gate, custom error page
- jwt callback stores refresh_token + expires_at on initial sign-in and
  exchanges via Keycloak's token endpoint at expiresAt-30s. On refresh
  failure, marks session.user.error so consumers can force re-auth.
- New `requireClientRole(roles): boolean` option runs in the signIn
  callback against the access_token's realm_access.roles; false bounces
  the user to the configured error page with ?error=AccessDenied.
- New `pages` passthrough so consumers can route AccessDenied to a
  branded full-page message instead of NextAuth's default error UI.
- Realm roles are read by decoding the access_token, NOT from the OIDC
  `profile` (the userinfo endpoint never carries realm_access). Earlier
  code that read profile.realm_access.roles produced empty role arrays
  in both jwt and signIn — both callsites fixed via a shared
  rolesFromAccessToken helper.

Consumers: gscCRM v2.6.11..v2.6.19 (CRM enforces gsccrm_* groups via
requireClientRole + a /access-denied public route).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:25:50 +02:00
15 changed files with 541 additions and 65 deletions

View File

@@ -12,12 +12,12 @@ INSERT INTO nav.apps (key, name, description, url, icon_class, icon_url, icon_bg
VALUES
('gsc-crm', 'CRM', 'Customer relationship management', 'https://crm.gosec.internal', NULL, 'https://assets.gosec.cloud/logos/crm.svg', NULL, 10, TRUE),
('gsc-chronos', 'Chronos', 'Time tracking and timesheets', 'https://chronos.gosec.internal', NULL, 'https://assets.gosec.cloud/logos/chronos.svg', NULL, 20, TRUE),
('gsc-meet', 'GSC Meet', 'Video conferencing with AI features', '/apps/gsc-meet', NULL, '/images/demo/logos/1.svg', NULL, 30, TRUE),
('gsc-voice', 'Voice', 'PBX and telephony management', '/apps/gsc-voice', 'ph-phone', NULL, 'bg-primary-lt', 40, TRUE),
('gsc-ai-hub', 'AI Hub', 'AI models and services management', '/apps/gsc-ai-hub', NULL, '/images/demo/logos/2.svg', NULL, 50, TRUE),
('gsc-surveillance', 'Surveillance', 'Video surveillance and security', '/apps/surveillance', NULL, '/images/demo/logos/3.svg', NULL, 60, TRUE),
('gsc-archive', 'Archive', 'Email archiving and eDiscovery', '/apps/gsc-archive', 'ph-archive-box', NULL, 'bg-info-lt', 70, TRUE),
('gsc-dam', 'Digital Asset Manager', 'Media and asset management platform', '/apps/gsc-dam', NULL, '/images/demo/logos/4.svg', NULL, 80, TRUE)
('gsc-meet', 'GSC Meet', 'Video conferencing with AI features', '/apps/gsc-meet', 'ph-video-camera', NULL, 'bg-success-lt', 30, TRUE),
('gsc-voice', 'Voice', 'PBX and telephony management', '/apps/gsc-voice', 'ph-phone', NULL, 'bg-primary-lt', 40, TRUE),
('gsc-ai-hub', 'AI Hub', 'AI models and services management', '/apps/gsc-ai-hub', 'ph-brain', NULL, 'bg-warning-lt', 50, TRUE),
('gsc-surveillance', 'Surveillance', 'Video surveillance and security', '/apps/surveillance', 'ph-shield-check', NULL, 'bg-danger-lt', 60, TRUE),
('gsc-archive', 'Archive', 'Email archiving and eDiscovery', '/apps/gsc-archive', 'ph-archive-box', NULL, 'bg-info-lt', 70, TRUE),
('gsc-dam', 'Digital Asset Manager', 'Media and asset management platform', '/apps/gsc-dam', 'ph-images-square', NULL, 'bg-secondary-lt', 80, TRUE)
ON CONFLICT (key) DO UPDATE
SET name = EXCLUDED.name,
description = EXCLUDED.description,

View File

@@ -1,6 +1,6 @@
{
"name": "@gsc/web-kit",
"version": "0.4.0",
"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"

View File

@@ -0,0 +1,50 @@
"use client";
import { useEffect, useRef } from "react";
import { useSession, signIn } from "next-auth/react";
import type { SessionUser } from "./types";
/**
* Drop-in client component that re-triggers Keycloak sign-in when the
* kit's server-side refresh flow gives up (`session.user.error` set to
* `RefreshAccessTokenError` or `RefreshTokenMissing`).
*
* Without this, a UI tab left open past the Keycloak SSO session lifetime
* holds a stale `accessToken`: the middleware sees a cookie, `requireAuth`
* sees an `accessToken` string, and the user only finds out things are
* broken when a downstream API returns 401. Mount this inside a
* `<SessionProvider>` (typically alongside other providers in the locale
* root) so the session refetch interval surfaces the error within minutes.
*
* Renders nothing.
*/
export function SessionExpirationGuard() {
const { data: session } = useSession();
const error = (session?.user as Partial<SessionUser> | undefined)?.error;
// Guard against React's StrictMode double-invoke + the brief window
// between calling signIn and the page navigating away.
const firedRef = useRef(false);
useEffect(() => {
if (!error || firedRef.current) return;
if (
error !== "RefreshAccessTokenError" &&
error !== "RefreshTokenMissing"
) {
return;
}
firedRef.current = true;
const callbackUrl =
typeof window !== "undefined"
? window.location.pathname + window.location.search
: "/";
// signIn() POSTs the CSRF-protected provider endpoint, which then
// initiates a fresh OIDC flow; the stale session cookie is replaced
// atomically when the flow completes. Keycloak's own SSO cookie may
// still log the user in transparently if it hasn't also expired.
void signIn("keycloak", { callbackUrl });
}, [error]);
return null;
}

View File

@@ -7,6 +7,7 @@
*/
export type { SessionUser } from "./types";
export { SessionExpirationGuard } from "./SessionExpirationGuard";
/**
* Client-side hard navigation to the sign-in endpoint. Use when a

View File

@@ -1,9 +1,75 @@
import NextAuth, { type NextAuthResult } from "next-auth";
import Keycloak from "next-auth/providers/keycloak";
import type { JWT } from "next-auth/jwt";
import { redirect } from "next/navigation";
import type { CreateAuthOptions, SessionUser } from "./types";
/**
* Refresh the Keycloak access token using the stored refresh_token. Returns
* an updated JWT with new accessToken/expiresAt/refreshToken/idToken, or
* the original JWT marked with `error: "RefreshAccessTokenError"` if the
* refresh failed (caller surfaces this through the session so the client
* can re-trigger sign-in).
*
* Pre-refresh skew is handled by the caller; this function unconditionally
* exchanges the refresh_token.
*/
async function refreshAccessToken(
token: JWT,
keycloak: CreateAuthOptions["keycloak"],
): Promise<JWT> {
const refreshToken = (token as { refreshToken?: string }).refreshToken;
if (!refreshToken) {
return { ...token, error: "RefreshTokenMissing" };
}
try {
const res = await fetch(
`${keycloak.issuer}/protocol/openid-connect/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: keycloak.clientId,
client_secret: keycloak.clientSecret,
refresh_token: refreshToken,
}),
},
);
const body = (await res.json()) as {
access_token?: string;
expires_in?: number;
refresh_token?: string;
id_token?: string;
error?: string;
error_description?: string;
};
if (!res.ok || !body.access_token) {
console.error(
"@gsc/web-kit: refresh_token exchange failed",
res.status,
body,
);
return { ...token, error: "RefreshAccessTokenError" };
}
return {
...token,
accessToken: body.access_token,
expiresAt:
Math.floor(Date.now() / 1000) + (body.expires_in ?? 300),
// Keycloak rotates refresh tokens by default; fall back to the
// existing one when rotation is disabled at the realm.
refreshToken: body.refresh_token ?? refreshToken,
idToken: body.id_token ?? token.idToken,
error: undefined,
} as JWT;
} catch (err) {
console.error("@gsc/web-kit: refresh_token exchange threw", err);
return { ...token, error: "RefreshAccessTokenError" };
}
}
export type { SessionUser, CreateAuthOptions };
/**
@@ -62,6 +128,35 @@ export interface AuthBundle {
* },
* });
*/
/**
* Decode the Keycloak access_token (JWS) and return its `realm_access.roles`.
* Realm roles are only present in the access_token, never in the OIDC
* userinfo `profile` — so callbacks that need to gate on roles must reach
* into `account.access_token`, not `profile`. We don't verify the signature
* here: NextAuth has already verified the ID token via the OIDC flow, the
* access_token came back over the same TLS exchange, and the only use of
* the decoded roles is local sign-in policy.
*/
function rolesFromAccessToken(accessToken: string | undefined): string[] {
if (!accessToken) return [];
const parts = accessToken.split(".");
if (parts.length < 2) return [];
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 {
realm_access?: { roles?: string[] };
};
return claims.realm_access?.roles ?? [];
} catch {
return [];
}
}
export function createAuth(opts: CreateAuthOptions): AuthBundle {
// NextAuth v5: provider-specific paths like /api/auth/signin/keycloak
// are POST-only (CSRF-protected form submit). A GET redirect there
@@ -70,8 +165,6 @@ export function createAuth(opts: CreateAuthOptions): AuthBundle {
// lists configured providers. Apps wanting one-click Keycloak can
// override signInPath with a custom page that calls signIn('keycloak').
const signInPath = opts.signInPath ?? "/api/auth/signin";
const defaultTenantId =
opts.defaultTenantId ?? "00000000-0000-0000-0000-000000000000";
const na = NextAuth({
providers: [
@@ -82,35 +175,76 @@ export function createAuth(opts: CreateAuthOptions): AuthBundle {
}),
],
trustHost: true,
pages: opts.pages,
callbacks: {
async signIn({ account }) {
// Per-client access gate. The kit's consumers pass a predicate
// (e.g. "must be in gsccrm_* group") via `requireClientRole`; we
// run it against the access_token's `realm_access.roles`. Realm
// roles are NOT present in the OIDC userinfo `profile`, only in
// the access_token — so we decode it. Return false → NextAuth
// refuses to mint a session and bounces the user to the configured
// error page with `?error=AccessDenied`.
if (!opts.requireClientRole) return true;
const roles = rolesFromAccessToken(account?.access_token);
return opts.requireClientRole(roles);
},
async jwt({ token, account, profile }) {
// `account` + `profile` are only set on the initial sign-in.
// On subsequent calls the token is read from the encrypted JWT
// cookie, so the fields we set here persist for the session.
// Initial sign-in: capture identity claims + refresh material.
if (account && profile) {
// Realm convention: GSC identity ships as three claims sourced
// from FreeIPA user attributes via the gosecCloud realm's LDAP
// federation + per-client OIDC protocol mappers.
const p = profile as {
gscTenantId?: string;
gscCustomerId?: string;
gscSID?: string;
};
if (!p.gscTenantId || !p.gscCustomerId || !p.gscSID) {
throw new Error(
`Keycloak token missing required GSC identity claims (gscTenantId=${!!p.gscTenantId}, gscCustomerId=${!!p.gscCustomerId}, gscSID=${!!p.gscSID})`,
);
}
token.keycloakId = profile.sub;
token.tenantId =
(profile as { tenant_id?: string }).tenant_id ?? defaultTenantId;
token.tenantId = p.gscTenantId;
token.customerId = p.gscCustomerId;
token.gscSid = p.gscSID;
token.displayName =
profile.name ??
(profile as { preferred_username?: string }).preferred_username ??
"";
token.givenName = profile.given_name ?? "";
token.familyName = profile.family_name ?? "";
token.roles =
(profile as { realm_access?: { roles?: string[] } }).realm_access
?.roles ?? [];
// Realm roles are in the access_token, not the OIDC profile.
token.roles = rolesFromAccessToken(account.access_token);
token.accessToken = account.access_token;
token.idToken = account.id_token;
token.refreshToken = account.refresh_token;
// `expires_at` is Unix seconds (NextAuth's normalized form);
// fall back to `expires_in` (relative) if the provider omitted it.
token.expiresAt =
(account.expires_at as number | undefined) ??
Math.floor(Date.now() / 1000) +
((account.expires_in as number | undefined) ?? 300);
return token;
}
return token;
// Subsequent calls: refresh if the access token is about to expire.
// Skew of 30s avoids handing out a token that will time out mid-flight.
const expiresAt = (token as { expiresAt?: number }).expiresAt ?? 0;
if (Math.floor(Date.now() / 1000) < expiresAt - 30) {
return token;
}
return refreshAccessToken(token, opts.keycloak);
},
async session({ session, token }) {
if (!token) return session;
const user: SessionUser = {
id: token.sub as string,
keycloakId: (token.keycloakId as string) ?? (token.sub as string),
tenantId: (token.tenantId as string) ?? defaultTenantId,
tenantId: token.tenantId as string,
customerId: token.customerId as string,
gscSid: token.gscSid as string,
email: session.user?.email ?? "",
displayName: (token.displayName as string) ?? "",
givenName: (token.givenName as string) ?? "",
@@ -118,6 +252,7 @@ export function createAuth(opts: CreateAuthOptions): AuthBundle {
roles: (token.roles as string[]) ?? [],
accessToken: (token.accessToken as string) ?? "",
idToken: token.idToken as string | undefined,
error: (token as { error?: string }).error,
};
// NextAuth's default `session.user` type is narrow; we replace
// it wholesale with our canonical shape. The cast keeps TS quiet
@@ -131,7 +266,11 @@ export function createAuth(opts: CreateAuthOptions): AuthBundle {
async function requireAuth(): Promise<SessionUser> {
const session = await na.auth();
const user = (session as unknown as { user?: SessionUser } | null)?.user;
if (!user || !user.accessToken) {
// `user.error` is set when the kit's silent refresh failed (refresh
// token expired/revoked or Keycloak rejected the exchange). The
// session cookie is still present, so the middleware won't catch
// this — bouncing here is what forces a fresh sign-in flow.
if (!user || !user.accessToken || user.error) {
redirect(signInPath);
}
return user;

View File

@@ -7,8 +7,16 @@ export interface SessionUser {
id: string;
/** Same as `id`. Exposed separately so callers can name-distinguish. */
keycloakId: string;
/** Tenant the user is operating in. May fall back to defaultTenantId. */
/** Tenant the user is operating in. From the Keycloak `gscTenantId` claim. */
tenantId: string;
/** Customer the user belongs to. From the Keycloak `gscCustomerId` claim. */
customerId: string;
/**
* GSC composite security identifier (tenant + customer + user parts).
* From the Keycloak `gscSID` claim. Distinct from any Keycloak/OIDC
* session `sid`.
*/
gscSid: string;
email: string;
displayName: string;
givenName: string;
@@ -19,6 +27,13 @@ export interface SessionUser {
accessToken: string;
/** Keycloak ID token. Optional; only present if the IdP returns it. */
idToken?: string;
/**
* Set when the kit's silent refresh failed (refresh_token expired or
* revoked). Clients should treat this as a forced re-auth signal —
* any value here means the accessToken is stale and won't be refreshed
* by the kit on its own.
*/
error?: string;
}
/**
@@ -30,12 +45,34 @@ export interface CreateAuthOptions {
clientSecret: string;
issuer: string;
};
/** Used when Keycloak claims don't carry a `tenant_id`. */
defaultTenantId?: string;
/**
* Where `requireAuth()` / the middleware redirects unauthenticated
* users. Default: `/api/auth/signin/keycloak` (NextAuth's auto-redirect
* endpoint). Override if the app has a custom landing page.
*/
signInPath?: string;
/**
* Optional gate: predicate that runs on initial sign-in against the
* Keycloak `realm_access.roles` array. Return `true` to allow the
* sign-in, `false` to deny — denial bounces the user to NextAuth's
* error route with `?error=AccessDenied` and no session is created.
*
* Use this to enforce "must be in <app>_* group" semantics without
* relying solely on per-endpoint role gates in downstream services.
* Example for gscCRM:
* requireClientRole: (roles) => roles.some((r) => r.startsWith("gsccrm_"))
*/
requireClientRole?: (roles: string[]) => boolean;
/**
* NextAuth page overrides. Set `pages.error` to a custom branded
* full-page message route when `requireClientRole` may deny sign-in,
* so users see something meaningful instead of NextAuth's default
* error page. The route should be public (skipped by auth middleware).
*/
pages?: {
error?: string;
signIn?: string;
};
}

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>
);
}

37
src/chrome/brandIcons.ts Normal file
View File

@@ -0,0 +1,37 @@
// Returns a Next.js Metadata `icons` object derived from a Brand.
// Apps drop this into their root layout's `metadata` export to ship
// the brand logo as the favicon — fixes the "favicon 404" every
// consumer of the kit was shipping with.
//
// import type { Metadata } from "next";
// import { brandIcons } from "@gsc/web-kit/chrome";
// import { brand } from "@/config/brand";
//
// export const metadata: Metadata = {
// title: brand.product,
// icons: brandIcons(brand),
// };
import type { Brand } from "./types";
interface BrandMetaIcons {
icon: { url: string; type?: string }[];
shortcut?: { url: string; type?: string }[];
apple?: { url: string; type?: string }[];
}
export function brandIcons(brand: Brand): BrandMetaIcons {
const url = brand.faviconUrl ?? brand.logoUrl;
// Heuristic: trust the file extension to set the MIME type. Most
// brand logos in the GoSec assets bucket are SVG.
const type =
/\.svg(\?|$)/i.test(url) ? "image/svg+xml" :
/\.png(\?|$)/i.test(url) ? "image/png" :
/\.ico(\?|$)/i.test(url) ? "image/x-icon" :
undefined;
return {
icon: [{ url, ...(type ? { type } : {}) }],
shortcut: [{ url }],
apple: [{ url }],
};
}

View File

@@ -11,6 +11,7 @@ export {
type CustomerOption,
} from "./header";
export { useChromeLabels, DEFAULT_CHROME_LABELS } from "./labels";
export { brandIcons } from "./brandIcons";
export type {
AdminShellProps,
ActivityFeedItem,

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

@@ -61,6 +61,7 @@ export type Brand = {
product: string; // "GoSec CRM"
logoUrl: string; // full navbar logo
logoSmallUrl?: string; // optional compact logo
faviconUrl?: string; // optional favicon override (defaults to logoUrl)
websiteUrl: string; // footer brand link
supportUrl: string; // subbar Support + footer Support link
docsUrl: string; // footer docs link
@@ -132,6 +133,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 +170,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;

View File

@@ -59,7 +59,6 @@ export {
useValidation,
useFieldValidation,
useAddressAutocomplete,
loadGoogleMapsScript,
} from "@limitless/ui";
// 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;
},
};
});
}