Compare commits
5 Commits
960dfeba7c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a11353577d | ||
|
|
71bce1bd56 | ||
|
|
85a31eb3d6 | ||
|
|
ec33b7bcb8 | ||
|
|
08a62d550c |
@@ -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,
|
||||
|
||||
10
package.json
10
package.json
@@ -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"
|
||||
|
||||
50
src/auth/SessionExpirationGuard.tsx
Normal file
50
src/auth/SessionExpirationGuard.tsx
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
37
src/chrome/brandIcons.ts
Normal file
37
src/chrome/brandIcons.ts
Normal 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 }],
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
type CustomerOption,
|
||||
} from "./header";
|
||||
export { useChromeLabels, DEFAULT_CHROME_LABELS } from "./labels";
|
||||
export { brandIcons } from "./brandIcons";
|
||||
export type {
|
||||
AdminShellProps,
|
||||
ActivityFeedItem,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -59,7 +59,6 @@ export {
|
||||
useValidation,
|
||||
useFieldValidation,
|
||||
useAddressAutocomplete,
|
||||
loadGoogleMapsScript,
|
||||
} from "@limitless/ui";
|
||||
|
||||
// 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