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