From 23210e35c3abebdf3c6dc97625a752fb586df548 Mon Sep 17 00:00:00 2001 From: Super User Date: Tue, 19 May 2026 20:51:42 +0200 Subject: [PATCH] Adopt @gsc/web-kit i18n factory + LogoutButton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package-lock.json | 142 ++++++++++++++++++++++---------- src/components/LogoutButton.tsx | 19 ++--- src/i18n/request.ts | 79 ++---------------- 3 files changed, 115 insertions(+), 125 deletions(-) diff --git a/package-lock.json b/package-lock.json index b03e724..7465ba8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,18 +7,24 @@ "": { "name": "gsc-my", "version": "1.0.0", + "hasInstallScript": true, "dependencies": { - "@auth/core": "^0.37.0", "@gsc/chat": "file:../../infra/gscAICoreSystem/frontends/gscBicameralFrontend", + "@gsc/web-kit": "file:../../templates/gsc-web-kit", "@limitless/ui": "file:../../templates/limitless-ui", + "@phosphor-icons/web": "2.1.1", "@prisma/client": "^6.1.0", "bootstrap": "^5.3.3", + "clsx": "^2.1.0", + "ldapts": "^8.0.36", "next": "^16.1.1", "next-auth": "^5.0.0-beta.25", "next-intl": "^4.6.1", + "nodemailer": "^7.0.7", "pg": "^8.20.0", "react": "^19.2.3", "react-dom": "^19.2.3", + "undici": "^6.25.0", "zod": "^3.23.0" }, "devDependencies": { @@ -37,7 +43,7 @@ }, "../../infra/gscAICoreSystem/frontends/gscBicameralFrontend": { "name": "@gsc/chat", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "@limitless/ui": "file:./limitless-ui" @@ -55,6 +61,41 @@ "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": { "name": "@limitless/ui", "version": "0.1.0", @@ -70,8 +111,8 @@ "devDependencies": { "@types/google.maps": "^3.55.0", "@types/nodemailer": "^6.4.0", - "@types/react": "^18.3.0", - "@types/react-dom": "^18.3.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", "typescript": "^5.4.0" }, "peerDependencies": { @@ -106,35 +147,6 @@ "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": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -647,6 +659,10 @@ "resolved": "../../infra/gscAICoreSystem/frontends/gscBicameralFrontend", "link": true }, + "node_modules/@gsc/web-kit": { + "resolved": "../../templates/gsc-web-kit", + "link": true + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1728,6 +1744,12 @@ "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": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -3267,6 +3289,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5240,15 +5271,6 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5362,6 +5384,18 @@ "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": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5763,6 +5797,15 @@ "dev": true, "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": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", @@ -6845,6 +6888,12 @@ "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": { "version": "2.0.1", "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" } }, + "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": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/src/components/LogoutButton.tsx b/src/components/LogoutButton.tsx index 4bfe7dc..ba68d6f 100644 --- a/src/components/LogoutButton.tsx +++ b/src/components/LogoutButton.tsx @@ -1,19 +1,12 @@ -"use client"; - -import { signOut } from "next-auth/react"; - export function LogoutButton({ label }: { label: string }) { - const handleLogout = async () => { - const res = await fetch("/api/auth/logout"); - const { logoutUrl } = await res.json(); - await signOut({ redirect: false }); - window.location.href = logoutUrl; - }; - + // Plain anchor → /api/auth/signout. The route handler runs + // RP-initiated logout in a single redirect: kills the NextAuth cookie, + // ends the Keycloak SSO session, lands on /signed-out. No client-side + // signOut() dance, no two-step fetch. return ( - + ); } diff --git a/src/i18n/request.ts b/src/i18n/request.ts index dde574d..3e2d5c2 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -1,78 +1,17 @@ -import { getRequestConfig } from "next-intl/server"; -import { cookies, headers } from "next/headers"; +import { createI18nConfig } from "@gsc/web-kit/i18n/server"; import { auth } from "@/auth"; export const locales = ["en", "de", "fr"] as const; export type Locale = (typeof locales)[number]; export const defaultLocale: Locale = "en"; -function isLocale(v: unknown): v is Locale { - return typeof v === "string" && (locales as readonly string[]).includes(v); -} - -/** - * 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 { - try { +export default createI18nConfig({ + locales, + defaultLocale, + getAccessToken: async () => { const session = await auth(); - const accessToken = (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 }) { - return key; - }, - }; + return (session?.user as { accessToken?: string } | undefined)?.accessToken; + }, + loadMessages: async (locale) => + (await import(`../../public/locales/${locale}/common.json`)).default, });