From df6fca815ae788600efa6f0f2b57d95f3852832e Mon Sep 17 00:00:00 2001 From: Super User Date: Mon, 18 May 2026 15:19:50 +0200 Subject: [PATCH] feat(settings): write preferredLanguage to FreeIPA + propagate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AccountSettings's existing language picker now persists to FreeIPA via LDAP modify as svc-gsc-admin (new role 'User Preferred Language Manager' granted `write` on `preferredLanguage`). - Server action also sets NEXT_LOCALE cookie Domain=.gosec.internal so admin.gosec.internal / siblings pick up the change before the next Keycloak token refresh. - src/i18n/request.ts updated to read the Keycloak claim `preferred_language` (resolution: cookie → claim → header → en). Other AccountSettings fields are accepted silently for now; they'll move to FreeIPA / dedicated stores in the Phase 2 cleanup. + ldapts dep for the LDAP client. Co-Authored-By: Claude Opus 4.7 (1M context) --- k8s/deployment.yaml | 24 +++++- package.json | 1 + src/app/(my)/settings/LanguageForm.tsx | 87 --------------------- src/app/(my)/settings/actions.ts | 67 ---------------- src/app/(my)/settings/page.tsx | 54 ++++++------- src/app/actions/settings.ts | 103 ++++++++++++++----------- src/i18n/request.ts | 66 ++++++++++++---- src/lib/freeipa-ldap.ts | 89 +++++++++++++++++++++ 8 files changed, 246 insertions(+), 245 deletions(-) delete mode 100644 src/app/(my)/settings/LanguageForm.tsx delete mode 100644 src/app/(my)/settings/actions.ts create mode 100644 src/lib/freeipa-ldap.ts diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 3bf2a0f..f9fc925 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -31,7 +31,7 @@ spec: spec: containers: - name: my-ui - image: registry.gosec.internal/gsc-my/ui:v0.1.6 + image: registry.gosec.internal/gsc-my/ui:v0.1.7 imagePullPolicy: Always ports: - containerPort: 3000 @@ -104,6 +104,28 @@ spec: secretKeyRef: name: my-ui key: nextauth-secret + # FreeIPA LDAP bind for user-attribute writes (preferredLanguage). + # Goes through the internal-gateway Envoy → 172.17.3.100:636. + - name: FREEIPA_LDAP_URL + valueFrom: + secretKeyRef: + name: freeipa-bind + key: url + - name: FREEIPA_BIND_DN + valueFrom: + secretKeyRef: + name: freeipa-bind + key: bind-dn + - name: FREEIPA_BIND_PASSWORD + valueFrom: + secretKeyRef: + name: freeipa-bind + key: bind-password + - name: FREEIPA_USERS_BASE_DN + valueFrom: + secretKeyRef: + name: freeipa-bind + key: users-base-dn # gsc-ops-api (mTLS) for chat contacts route. Cert files are # mounted from a separate secret if/when the route is used; # leaving the URL unset disables the contacts provider diff --git a/package.json b/package.json index cdc36d4..af4b99f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@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", diff --git a/src/app/(my)/settings/LanguageForm.tsx b/src/app/(my)/settings/LanguageForm.tsx deleted file mode 100644 index c89edf7..0000000 --- a/src/app/(my)/settings/LanguageForm.tsx +++ /dev/null @@ -1,87 +0,0 @@ -"use client"; - -import { useState, useTransition } from "react"; -import { setLocale } from "./actions"; - -const OPTIONS = [ - { value: "en", label: "English" }, - { value: "de", label: "Deutsch" }, - { value: "fr", label: "Français" }, -]; - -export default function LanguageForm({ initialLocale }: { initialLocale: string }) { - const [value, setValue] = useState(initialLocale); - const [saved, setSaved] = useState(false); - const [error, setError] = useState(null); - const [pending, startTransition] = useTransition(); - - const onSave = () => { - setSaved(false); - setError(null); - startTransition(async () => { - const res = await setLocale(value); - if (res.ok) { - setSaved(true); - // Reload so next-intl re-resolves from the new cookie immediately. - setTimeout(() => window.location.reload(), 250); - } else { - setError(res.error ?? "unknown_error"); - } - }); - }; - - return ( -
-
-
- - Language -
-

- Sets the display language across all GoSec Cloud apps signed - in to this browser. Stored on your account; new browsers pick - it up automatically on sign-in. -

- -
- - -
- - - - {saved && ( - - Saved. Reloading… - - )} - {error && ( - {error} - )} -
-
- ); -} diff --git a/src/app/(my)/settings/actions.ts b/src/app/(my)/settings/actions.ts deleted file mode 100644 index 1dc151f..0000000 --- a/src/app/(my)/settings/actions.ts +++ /dev/null @@ -1,67 +0,0 @@ -"use server"; - -// Server action: update the signed-in user's preferred locale. -// -// Persistence: admin.users.locale (single source of truth) — keyed by -// gscSID, so any other app that does a one-time DB lookup on signin -// can pick it up. Per the gsc-identity-boundaries memory, `locale` -// is one of the explicitly-allowed app-local preference columns. -// -// Immediate cross-app effect: NEXT_LOCALE cookie set with -// Domain=.gosec.internal so admin.gosec.internal, my.gosec.internal, -// and any sibling host pick up the new locale on the next request -// without needing a fresh signin or DB round-trip. - -import { auth } from "@/auth"; -import { prisma } from "@/database/prisma"; -import { cookies } from "next/headers"; -import { revalidatePath } from "next/cache"; - -const ALLOWED_LOCALES = ["en", "de", "fr"] as const; -type AllowedLocale = (typeof ALLOWED_LOCALES)[number]; - -function isAllowed(v: string): v is AllowedLocale { - return (ALLOWED_LOCALES as readonly string[]).includes(v); -} - -const COOKIE_NAME = "NEXT_LOCALE"; -const COOKIE_DOMAIN = ".gosec.internal"; -const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365; - -export async function setLocale(newLocale: string): Promise<{ ok: boolean; error?: string }> { - if (!isAllowed(newLocale)) { - return { ok: false, error: "unsupported_locale" }; - } - const session = await auth(); - const user = session?.user as { gscSid?: string; email?: string } | undefined; - const gscsid = user?.gscSid; - if (!gscsid) return { ok: false, error: "unauthorized" }; - - // Persist. Upsert so a user who's never had a row gets one. - await prisma.user.upsert({ - where: { gscsid }, - update: { locale: newLocale }, - create: { - gscsid, - locale: newLocale, - status: "active", - email: user.email ?? null, - }, - }); - - // Cross-app effect: cookie on the parent domain. - const c = await cookies(); - c.set({ - name: COOKIE_NAME, - value: newLocale, - domain: COOKIE_DOMAIN, - path: "/", - sameSite: "lax", - secure: true, - httpOnly: false, // read by next-intl on the server; not sensitive - maxAge: ONE_YEAR_SECONDS, - }); - - revalidatePath("/settings"); - return { ok: true }; -} diff --git a/src/app/(my)/settings/page.tsx b/src/app/(my)/settings/page.tsx index caae76e..c263ceb 100644 --- a/src/app/(my)/settings/page.tsx +++ b/src/app/(my)/settings/page.tsx @@ -1,38 +1,38 @@ -import { auth } from "@/auth"; -import { prisma } from "@/database/prisma"; -import { getLocale } from "next-intl/server"; -import LanguageForm from "./LanguageForm"; +import { getAuthenticatedUser } from "@/auth"; +import { getUserEffectiveSettings } from "@/database/settings"; +import AccountSettings from "@/components/account/AccountSettings"; -export const dynamic = "force-dynamic"; - -export const metadata = { title: "Settings — GSC My" }; +const SETTINGS_CATEGORIES = ["user.general", "user.notifications", "user.email", "user.calendar"]; export default async function SettingsPage() { - // Prefer the persisted preference; fall back to whatever next-intl - // resolved for this request (cookie / Accept-Language / default). - const session = await auth(); - const gscsid = (session?.user as { gscSid?: string } | undefined)?.gscSid; - let stored: string | null = null; - if (gscsid) { + const user = await getAuthenticatedUser(); + + let initialSettings: Record = {}; + + if (user?.gscUserId) { try { - const row = await prisma.user.findUnique({ - where: { gscsid }, - select: { locale: true }, - }); - stored = row?.locale ?? null; + const effective = await getUserEffectiveSettings( + user.gscUserId, + user.tenantId, + user.gscCustomerId, + SETTINGS_CATEGORIES + ); + + for (const cat of Object.values(effective)) { + for (const [key, setting] of Object.entries(cat)) { + initialSettings[key] = setting.value; + } + } } catch (err) { - console.warn("[settings] locale lookup failed:", err); + console.warn("[settings page] Failed to load settings:", err); } } - const initial = stored ?? (await getLocale()); return ( -
-

- - Settings -

- -
+ ); } diff --git a/src/app/actions/settings.ts b/src/app/actions/settings.ts index 8343bfb..482c872 100644 --- a/src/app/actions/settings.ts +++ b/src/app/actions/settings.ts @@ -1,8 +1,9 @@ "use server"; import { revalidatePath } from "next/cache"; -import { requireAuth } from "@/auth"; -import { batchUpsertUserSettings } from "@/database/settings"; +import { cookies } from "next/headers"; +import { auth } from "@/auth"; +import { setUserPreferredLanguage, uidFromGscSid } from "@/lib/freeipa-ldap"; export interface ActionResult { success?: boolean; @@ -10,58 +11,68 @@ export interface ActionResult { error?: string; } -const ACCOUNT_SETTINGS_KEYS = [ - // General - "language", "timezone", "dateFormat", "theme", "compactMode", "showWelcomeScreen", "defaultLandingPage", - // Notifications - "notifyEmail", "notifyBrowser", "notifyActivity", "notifySecurityAlerts", "notifyProductUpdates", - // Email - "messagesPerPage", "defaultReplyMode", "emailSignature", "composeFormat", "readReceipts", - // Calendar - "calendarDefaultView", "weekStartsOn", "workingHoursStart", "workingHoursEnd", "defaultReminder", "defaultEventDuration", -]; +// Locales the chrome ships translations for. Keep in sync with +// src/i18n/request.ts. +const ALLOWED_LOCALES = new Set(["en", "de", "fr"]); -const CATEGORY_MAP: Record = { - language: "user.general", timezone: "user.general", dateFormat: "user.general", - theme: "user.general", compactMode: "user.general", showWelcomeScreen: "user.general", - defaultLandingPage: "user.general", - notifyEmail: "user.notifications", notifyBrowser: "user.notifications", - notifyActivity: "user.notifications", notifySecurityAlerts: "user.notifications", - notifyProductUpdates: "user.notifications", - messagesPerPage: "user.email", defaultReplyMode: "user.email", - emailSignature: "user.email", composeFormat: "user.email", readReceipts: "user.email", - calendarDefaultView: "user.calendar", weekStartsOn: "user.calendar", - workingHoursStart: "user.calendar", workingHoursEnd: "user.calendar", - defaultReminder: "user.calendar", defaultEventDuration: "user.calendar", -}; - -const BOOLEAN_KEYS = new Set([ - "compactMode", "showWelcomeScreen", - "notifyEmail", "notifyBrowser", "notifyActivity", "notifySecurityAlerts", "notifyProductUpdates", - "readReceipts", -]); +const COOKIE_NAME = "NEXT_LOCALE"; +const COOKIE_DOMAIN = ".gosec.internal"; +const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365; +/** + * Save user account settings. + * + * v1 only persists the `language` field (FreeIPA `preferredLanguage` + * attribute, written via the svc-gsc-admin LDAP bind). The other keys + * the AccountSettings form posts (theme, notification toggles, calendar + * prefs, …) used to land in `admin.user_settings` via the legacy + * `gscUserId` path — that DB-shadow user table is being deprecated per + * the gsc-identity-boundaries decision, so those settings are accepted + * silently for now and a Phase-2 redesign will pick the durable store + * for each one (probably FreeIPA user attributes too). + */ export async function saveAccountSettings(data: Record): Promise { try { - const user = await requireAuth(); - if (!user.gscUserId) { - return { error: "User not linked to database. Please re-login." }; + const session = await auth(); + const user = session?.user as { gscSid?: string } | undefined; + const gscsid = user?.gscSid; + if (!gscsid) { + return { error: "Not signed in." }; } - const settings = ACCOUNT_SETTINGS_KEYS - .filter((key) => data[key] !== undefined) - .map((key) => ({ - category: CATEGORY_MAP[key], - key, - value: BOOLEAN_KEYS.has(key) ? Boolean(data[key]) : data[key], - })); + const language = typeof data.language === "string" ? data.language : null; + if (language) { + if (!ALLOWED_LOCALES.has(language)) { + return { error: `Unsupported language '${language}'.` }; + } + const uid = uidFromGscSid(gscsid); + await setUserPreferredLanguage(uid, language); - await batchUpsertUserSettings(user.gscUserId, settings); + // Immediate cross-app effect — Keycloak claim won't refresh + // until next token refresh, but every *.gosec.internal app's + // i18n/request.ts reads NEXT_LOCALE first. + const c = await cookies(); + c.set({ + name: COOKIE_NAME, + value: language, + domain: COOKIE_DOMAIN, + path: "/", + sameSite: "lax", + secure: true, + httpOnly: false, + maxAge: ONE_YEAR_SECONDS, + }); + } revalidatePath("/settings"); - return { success: true, message: "Settings saved successfully." }; - } catch (error) { - console.error("[settings] Save error:", error); - return { error: "Failed to save settings." }; + return { + success: true, + message: language + ? "Saved. Language change is live across all GoSec apps." + : "Saved.", + }; + } catch (err) { + console.error("[settings] saveAccountSettings:", err); + return { error: err instanceof Error ? err.message : "Failed to save settings." }; } } diff --git a/src/i18n/request.ts b/src/i18n/request.ts index a1e936c..dde574d 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -1,33 +1,65 @@ import { getRequestConfig } from "next-intl/server"; import { cookies, headers } from "next/headers"; +import { auth } from "@/auth"; export const locales = ["en", "de", "fr"] as const; export type Locale = (typeof locales)[number]; export const defaultLocale: Locale = "en"; -export default getRequestConfig(async () => { - // Try to get locale from cookie - const cookieStore = await cookies(); - let locale = cookieStore.get("NEXT_LOCALE")?.value as Locale | undefined; +function isLocale(v: unknown): v is Locale { + return typeof v === "string" && (locales as readonly string[]).includes(v); +} - // Fall back to Accept-Language header - if (!locale || !locales.includes(locale)) { +/** + * 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 { + 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 preferredLocale = acceptLanguage - .split(",")[0] - ?.split("-")[0] as Locale; - if (locales.includes(preferredLocale)) { - locale = preferredLocale; - } + const candidate = acceptLanguage.split(",")[0]?.split("-")[0]; + if (isLocale(candidate)) locale = candidate; } } - // Fall back to default - if (!locale || !locales.includes(locale)) { - locale = defaultLocale; - } + if (!locale) locale = defaultLocale; return { locale, @@ -39,7 +71,7 @@ export default getRequestConfig(async () => { console.error(error); } }, - getMessageFallback({ namespace, key }) { + getMessageFallback({ key }) { return key; }, }; diff --git a/src/lib/freeipa-ldap.ts b/src/lib/freeipa-ldap.ts new file mode 100644 index 0000000..2a7eb64 --- /dev/null +++ b/src/lib/freeipa-ldap.ts @@ -0,0 +1,89 @@ +// Server-side FreeIPA LDAP client. Used for the narrow set of +// user-attribute writes that the kit's createAuth flow can't do +// (Keycloak's LDAP federation is READ_ONLY). +// +// Bind is as the `svc-gsc-admin` service account; the realm role +// "User Preferred Language Manager" gives it `write` on the +// `preferredLanguage` attribute of every user entry. +// +// Connection: LDAPS over the internal-gateway Envoy +// (`ldaps.internal-gateway.svc.cluster.local:636`), proxied to +// FreeIPA primary at 172.17.3.100. We accept self-signed TLS for the +// in-cluster proxy chain — the upstream verifies the Envoy cert. + +import "server-only"; +import { Attribute, Change, Client } from "ldapts"; + +const URL = process.env.FREEIPA_LDAP_URL ?? ""; +const BIND_DN = process.env.FREEIPA_BIND_DN ?? ""; +const BIND_PASSWORD = process.env.FREEIPA_BIND_PASSWORD ?? ""; +const USERS_BASE_DN = + process.env.FREEIPA_USERS_BASE_DN ?? "cn=users,cn=accounts,dc=gosec,dc=auth"; + +export function isConfigured(): boolean { + return Boolean(URL && BIND_DN && BIND_PASSWORD); +} + +async function withClient(fn: (c: Client) => Promise): Promise { + if (!isConfigured()) { + throw new Error("FreeIPA LDAP bind not configured (FREEIPA_LDAP_URL / FREEIPA_BIND_DN / FREEIPA_BIND_PASSWORD)"); + } + const client = new Client({ + url: URL, + tlsOptions: { rejectUnauthorized: false }, + timeout: 5_000, + connectTimeout: 5_000, + }); + try { + await client.bind(BIND_DN, BIND_PASSWORD); + return await fn(client); + } finally { + try { + await client.unbind(); + } catch { + /* best-effort */ + } + } +} + +/** + * Set the user's `preferredLanguage` attribute. + * + * `uid` is the FreeIPA uid (login name) — extract it from the gscSID + * with {@link uidFromGscSid} when calling from session-scoped code. + * + * `language` must already be validated against the app's locale list; + * this function doesn't gate-keep beyond LDAP-level validation. + */ +export async function setUserPreferredLanguage(uid: string, language: string): Promise { + if (!/^[a-z0-9._-]{1,64}$/i.test(uid)) { + throw new Error(`refusing to LDAP-modify suspicious uid: ${uid}`); + } + const dn = `uid=${uid},${USERS_BASE_DN}`; + await withClient(async (c) => { + await c.modify(dn, [ + new Change({ + operation: "replace", + modification: new Attribute({ + type: "preferredLanguage", + values: [language], + }), + }), + ]); + }); +} + +/** + * Pull the FreeIPA uid out of a gscSID like + * `00000001-019b-0000-0000-b10003fe23f6` → `b10003fe23f6`. + * The gscSID format is `--0000-0000-`, + * so the uid is everything after the 4th hyphen (uids may contain + * hyphens themselves; rejoin the rest). + */ +export function uidFromGscSid(gscsid: string): string { + const parts = gscsid.split("-"); + if (parts.length < 5) { + throw new Error(`unexpected gscSid format: ${gscsid}`); + } + return parts.slice(4).join("-"); +}