Adopt @gsc/web-kit i18n factory + LogoutButton

i18n/request.ts: delegate to kit's createI18nConfig — locale resolution
chain (cookie → access_token claim → Accept-Language → default) now lives
in the kit instead of being reimplemented here. LogoutButton becomes a
thin re-export of the kit version. Lockfile reflects updated dep graph.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Super User
2026-05-19 20:51:42 +02:00
parent df6fca815a
commit 23210e35c3
3 changed files with 115 additions and 125 deletions

142
package-lock.json generated
View File

@@ -7,18 +7,24 @@
"": { "": {
"name": "gsc-my", "name": "gsc-my",
"version": "1.0.0", "version": "1.0.0",
"hasInstallScript": true,
"dependencies": { "dependencies": {
"@auth/core": "^0.37.0",
"@gsc/chat": "file:../../infra/gscAICoreSystem/frontends/gscBicameralFrontend", "@gsc/chat": "file:../../infra/gscAICoreSystem/frontends/gscBicameralFrontend",
"@gsc/web-kit": "file:../../templates/gsc-web-kit",
"@limitless/ui": "file:../../templates/limitless-ui", "@limitless/ui": "file:../../templates/limitless-ui",
"@phosphor-icons/web": "2.1.1",
"@prisma/client": "^6.1.0", "@prisma/client": "^6.1.0",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"clsx": "^2.1.0",
"ldapts": "^8.0.36",
"next": "^16.1.1", "next": "^16.1.1",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"next-intl": "^4.6.1", "next-intl": "^4.6.1",
"nodemailer": "^7.0.7",
"pg": "^8.20.0", "pg": "^8.20.0",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"undici": "^6.25.0",
"zod": "^3.23.0" "zod": "^3.23.0"
}, },
"devDependencies": { "devDependencies": {
@@ -37,7 +43,7 @@
}, },
"../../infra/gscAICoreSystem/frontends/gscBicameralFrontend": { "../../infra/gscAICoreSystem/frontends/gscBicameralFrontend": {
"name": "@gsc/chat", "name": "@gsc/chat",
"version": "0.2.0", "version": "0.3.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@limitless/ui": "file:./limitless-ui" "@limitless/ui": "file:./limitless-ui"
@@ -55,6 +61,41 @@
"react-dom": "^18.2.0 || ^19.0.0" "react-dom": "^18.2.0 || ^19.0.0"
} }
}, },
"../../templates/gsc-web-kit": {
"name": "@gsc/web-kit",
"version": "0.5.1",
"license": "MIT",
"dependencies": {
"@limitless/ui": "file:../limitless-ui",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"next": "16.1.1",
"next-auth": "^5.0.0-beta.25",
"next-intl": "^4.6.1",
"typescript": "^5.4.0"
},
"peerDependencies": {
"@gsc/chat": "*",
"bootstrap": "^5.3.3",
"next": ">=15.0.0",
"next-auth": "^5.0.0-beta.25",
"next-intl": "^4.6.0",
"react": "^18.2.0 || ^19.0.0",
"react-dom": "^18.2.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@gsc/chat": {
"optional": true
},
"bootstrap": {
"optional": true
}
}
},
"../../templates/limitless-ui": { "../../templates/limitless-ui": {
"name": "@limitless/ui", "name": "@limitless/ui",
"version": "0.1.0", "version": "0.1.0",
@@ -70,8 +111,8 @@
"devDependencies": { "devDependencies": {
"@types/google.maps": "^3.55.0", "@types/google.maps": "^3.55.0",
"@types/nodemailer": "^6.4.0", "@types/nodemailer": "^6.4.0",
"@types/react": "^18.3.0", "@types/react": "^19.0.0",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^19.0.0",
"typescript": "^5.4.0" "typescript": "^5.4.0"
}, },
"peerDependencies": { "peerDependencies": {
@@ -106,35 +147,6 @@
"react-dom": "^18.2.0 || ^19.0.0" "react-dom": "^18.2.0 || ^19.0.0"
} }
}, },
"node_modules/@auth/core": {
"version": "0.37.4",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.37.4.tgz",
"integrity": "sha512-HOXJwXWXQRhbBDHlMU0K/6FT1v+wjtzdKhsNg0ZN7/gne6XPsIrjZ4daMcFnbq0Z/vsAbYBinQhhua0d77v7qw==",
"license": "ISC",
"dependencies": {
"@panva/hkdf": "^1.2.1",
"jose": "^5.9.6",
"oauth4webapi": "^3.1.1",
"preact": "10.24.3",
"preact-render-to-string": "6.5.11"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"nodemailer": "^6.8.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -647,6 +659,10 @@
"resolved": "../../infra/gscAICoreSystem/frontends/gscBicameralFrontend", "resolved": "../../infra/gscAICoreSystem/frontends/gscBicameralFrontend",
"link": true "link": true
}, },
"node_modules/@gsc/web-kit": {
"resolved": "../../templates/gsc-web-kit",
"link": true
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1728,6 +1744,12 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/@phosphor-icons/web": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@phosphor-icons/web/-/web-2.1.1.tgz",
"integrity": "sha512-QjrfbItu5Rb2i37GzsKxmrRHfZPTVk3oXSPBnQ2+oACDbQRWGAeB0AsvZw263n1nFouQuff+khOCtRbrc6+k+A==",
"license": "MIT"
},
"node_modules/@popperjs/core": { "node_modules/@popperjs/core": {
"version": "2.11.8", "version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -3267,6 +3289,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -5240,15 +5271,6 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5362,6 +5384,18 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/ldapts": {
"version": "8.1.7",
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.1.7.tgz",
"integrity": "sha512-TJl6T92eIwMf/OJ0hDfKVa6ISwzo+lqCWCI5Mf//ARlKa3LKQZaSrme/H2rCRBhW0DZCQlrsV+fgoW5YHRNLUw==",
"license": "MIT",
"dependencies": {
"strict-event-emitter-types": "2.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -5763,6 +5797,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nypm": { "node_modules/nypm": {
"version": "0.6.5", "version": "0.6.5",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
@@ -6845,6 +6888,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/strict-event-emitter-types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
"integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==",
"license": "ISC"
},
"node_modules/string.prototype.includes": { "node_modules/string.prototype.includes": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -7250,6 +7299,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/undici": {
"version": "6.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
"integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",

View File

@@ -1,19 +1,12 @@
"use client";
import { signOut } from "next-auth/react";
export function LogoutButton({ label }: { label: string }) { export function LogoutButton({ label }: { label: string }) {
const handleLogout = async () => { // Plain anchor → /api/auth/signout. The route handler runs
const res = await fetch("/api/auth/logout"); // RP-initiated logout in a single redirect: kills the NextAuth cookie,
const { logoutUrl } = await res.json(); // ends the Keycloak SSO session, lands on /signed-out. No client-side
await signOut({ redirect: false }); // signOut() dance, no two-step fetch.
window.location.href = logoutUrl;
};
return ( return (
<button type="button" onClick={handleLogout} className="dropdown-item"> <a href="/api/auth/signout" className="dropdown-item">
<i className="ph-sign-out me-2"></i> <i className="ph-sign-out me-2"></i>
{label} {label}
</button> </a>
); );
} }

View File

@@ -1,78 +1,17 @@
import { getRequestConfig } from "next-intl/server"; import { createI18nConfig } from "@gsc/web-kit/i18n/server";
import { cookies, headers } from "next/headers";
import { auth } from "@/auth"; import { auth } from "@/auth";
export const locales = ["en", "de", "fr"] as const; export const locales = ["en", "de", "fr"] as const;
export type Locale = (typeof locales)[number]; export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en"; export const defaultLocale: Locale = "en";
function isLocale(v: unknown): v is Locale { export default createI18nConfig({
return typeof v === "string" && (locales as readonly string[]).includes(v); locales,
} defaultLocale,
getAccessToken: async () => {
/**
* Read the `preferred_language` claim from the user's Keycloak access
* token. The claim is sourced from FreeIPA's `preferredLanguage` user
* attribute via the gosecCloud realm's LDAP attribute mapper and the
* per-client OIDC protocol mapper. Returns `undefined` if no session,
* no claim, or the value isn't in our supported `locales`.
*/
async function localeFromKeycloak(): Promise<Locale | undefined> {
try {
const session = await auth(); const session = await auth();
const accessToken = (session?.user as { accessToken?: string } | undefined)?.accessToken; return (session?.user as { accessToken?: string } | undefined)?.accessToken;
if (!accessToken) return undefined;
const payload = accessToken.split(".")[1];
if (!payload) return undefined;
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
const decoded = Buffer.from(padded, "base64").toString("utf8");
const json = JSON.parse(decoded) as { preferred_language?: string };
return isLocale(json.preferred_language) ? json.preferred_language : undefined;
} catch {
return undefined;
}
}
export default getRequestConfig(async () => {
// Resolution order:
// 1. NEXT_LOCALE cookie — recent /settings change, beats the
// stale token claim until the next refresh.
// 2. Keycloak `preferred_language` claim — FreeIPA-backed
// durable preference.
// 3. Accept-Language header.
// 4. defaultLocale.
const cookieStore = await cookies();
const cookieLocale = cookieStore.get("NEXT_LOCALE")?.value;
let locale: Locale | undefined = isLocale(cookieLocale) ? cookieLocale : undefined;
if (!locale) {
locale = await localeFromKeycloak();
}
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 = defaultLocale;
return {
locale,
messages: (await import(`../../public/locales/${locale}/common.json`)).default,
onError(error) {
if (error.code === "MISSING_MESSAGE") {
console.warn(error.message);
} else {
console.error(error);
}
}, },
getMessageFallback({ key }) { loadMessages: async (locale) =>
return key; (await import(`../../public/locales/${locale}/common.json`)).default,
},
};
}); });