feat(settings): write preferredLanguage to FreeIPA + propagate
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -31,7 +31,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: my-ui
|
- 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
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 3000
|
- containerPort: 3000
|
||||||
@@ -104,6 +104,28 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: my-ui
|
name: my-ui
|
||||||
key: nextauth-secret
|
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
|
# gsc-ops-api (mTLS) for chat contacts route. Cert files are
|
||||||
# mounted from a separate secret if/when the route is used;
|
# mounted from a separate secret if/when the route is used;
|
||||||
# leaving the URL unset disables the contacts provider
|
# leaving the URL unset disables the contacts provider
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@prisma/client": "^6.1.0",
|
"@prisma/client": "^6.1.0",
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"clsx": "^2.1.0",
|
"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",
|
||||||
|
|||||||
@@ -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<string | null>(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 (
|
|
||||||
<div className="card shadow-sm" style={{ maxWidth: 480 }}>
|
|
||||||
<div className="card-body">
|
|
||||||
<h5 className="card-title mb-3">
|
|
||||||
<i className="ph ph-globe me-2" aria-hidden="true"></i>
|
|
||||||
Language
|
|
||||||
</h5>
|
|
||||||
<p className="text-muted small mb-3">
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mb-3">
|
|
||||||
<label htmlFor="locale" className="form-label">
|
|
||||||
Display language
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="locale"
|
|
||||||
className="form-select"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => {
|
|
||||||
setValue(e.target.value);
|
|
||||||
setSaved(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{OPTIONS.map((o) => (
|
|
||||||
<option key={o.value} value={o.value}>
|
|
||||||
{o.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={pending || value === ""}
|
|
||||||
onClick={onSave}
|
|
||||||
>
|
|
||||||
{pending ? "Saving…" : "Save"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{saved && (
|
|
||||||
<span className="text-success small ms-3">
|
|
||||||
<i className="ph ph-check-circle me-1"></i>Saved. Reloading…
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<span className="text-danger small ms-3">{error}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -1,38 +1,38 @@
|
|||||||
import { auth } from "@/auth";
|
import { getAuthenticatedUser } from "@/auth";
|
||||||
import { prisma } from "@/database/prisma";
|
import { getUserEffectiveSettings } from "@/database/settings";
|
||||||
import { getLocale } from "next-intl/server";
|
import AccountSettings from "@/components/account/AccountSettings";
|
||||||
import LanguageForm from "./LanguageForm";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
const SETTINGS_CATEGORIES = ["user.general", "user.notifications", "user.email", "user.calendar"];
|
||||||
|
|
||||||
export const metadata = { title: "Settings — GSC My" };
|
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
// Prefer the persisted preference; fall back to whatever next-intl
|
const user = await getAuthenticatedUser();
|
||||||
// resolved for this request (cookie / Accept-Language / default).
|
|
||||||
const session = await auth();
|
let initialSettings: Record<string, unknown> = {};
|
||||||
const gscsid = (session?.user as { gscSid?: string } | undefined)?.gscSid;
|
|
||||||
let stored: string | null = null;
|
if (user?.gscUserId) {
|
||||||
if (gscsid) {
|
|
||||||
try {
|
try {
|
||||||
const row = await prisma.user.findUnique({
|
const effective = await getUserEffectiveSettings(
|
||||||
where: { gscsid },
|
user.gscUserId,
|
||||||
select: { locale: true },
|
user.tenantId,
|
||||||
});
|
user.gscCustomerId,
|
||||||
stored = row?.locale ?? null;
|
SETTINGS_CATEGORIES
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const cat of Object.values(effective)) {
|
||||||
|
for (const [key, setting] of Object.entries(cat)) {
|
||||||
|
initialSettings[key] = setting.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[settings] locale lookup failed:", err);
|
console.warn("[settings page] Failed to load settings:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const initial = stored ?? (await getLocale());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container py-4">
|
<AccountSettings
|
||||||
<h2 className="mb-4">
|
displayName={user?.displayName || "User"}
|
||||||
<i className="ph ph-gear me-2" aria-hidden="true"></i>
|
email={user?.email || ""}
|
||||||
Settings
|
initialSettings={initialSettings}
|
||||||
</h2>
|
/>
|
||||||
<LanguageForm initialLocale={initial} />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requireAuth } from "@/auth";
|
import { cookies } from "next/headers";
|
||||||
import { batchUpsertUserSettings } from "@/database/settings";
|
import { auth } from "@/auth";
|
||||||
|
import { setUserPreferredLanguage, uidFromGscSid } from "@/lib/freeipa-ldap";
|
||||||
|
|
||||||
export interface ActionResult {
|
export interface ActionResult {
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
@@ -10,58 +11,68 @@ export interface ActionResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACCOUNT_SETTINGS_KEYS = [
|
// Locales the chrome ships translations for. Keep in sync with
|
||||||
// General
|
// src/i18n/request.ts.
|
||||||
"language", "timezone", "dateFormat", "theme", "compactMode", "showWelcomeScreen", "defaultLandingPage",
|
const ALLOWED_LOCALES = new Set(["en", "de", "fr"]);
|
||||||
// Notifications
|
|
||||||
"notifyEmail", "notifyBrowser", "notifyActivity", "notifySecurityAlerts", "notifyProductUpdates",
|
|
||||||
// Email
|
|
||||||
"messagesPerPage", "defaultReplyMode", "emailSignature", "composeFormat", "readReceipts",
|
|
||||||
// Calendar
|
|
||||||
"calendarDefaultView", "weekStartsOn", "workingHoursStart", "workingHoursEnd", "defaultReminder", "defaultEventDuration",
|
|
||||||
];
|
|
||||||
|
|
||||||
const CATEGORY_MAP: Record<string, string> = {
|
const COOKIE_NAME = "NEXT_LOCALE";
|
||||||
language: "user.general", timezone: "user.general", dateFormat: "user.general",
|
const COOKIE_DOMAIN = ".gosec.internal";
|
||||||
theme: "user.general", compactMode: "user.general", showWelcomeScreen: "user.general",
|
const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365;
|
||||||
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",
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, unknown>): Promise<ActionResult> {
|
export async function saveAccountSettings(data: Record<string, unknown>): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
const user = await requireAuth();
|
const session = await auth();
|
||||||
if (!user.gscUserId) {
|
const user = session?.user as { gscSid?: string } | undefined;
|
||||||
return { error: "User not linked to database. Please re-login." };
|
const gscsid = user?.gscSid;
|
||||||
|
if (!gscsid) {
|
||||||
|
return { error: "Not signed in." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = ACCOUNT_SETTINGS_KEYS
|
const language = typeof data.language === "string" ? data.language : null;
|
||||||
.filter((key) => data[key] !== undefined)
|
if (language) {
|
||||||
.map((key) => ({
|
if (!ALLOWED_LOCALES.has(language)) {
|
||||||
category: CATEGORY_MAP[key],
|
return { error: `Unsupported language '${language}'.` };
|
||||||
key,
|
}
|
||||||
value: BOOLEAN_KEYS.has(key) ? Boolean(data[key]) : data[key],
|
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");
|
revalidatePath("/settings");
|
||||||
return { success: true, message: "Settings saved successfully." };
|
return {
|
||||||
} catch (error) {
|
success: true,
|
||||||
console.error("[settings] Save error:", error);
|
message: language
|
||||||
return { error: "Failed to save settings." };
|
? "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." };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,65 @@
|
|||||||
import { getRequestConfig } from "next-intl/server";
|
import { getRequestConfig } from "next-intl/server";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
|
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";
|
||||||
|
|
||||||
export default getRequestConfig(async () => {
|
function isLocale(v: unknown): v is Locale {
|
||||||
// Try to get locale from cookie
|
return typeof v === "string" && (locales as readonly string[]).includes(v);
|
||||||
const cookieStore = await cookies();
|
}
|
||||||
let locale = cookieStore.get("NEXT_LOCALE")?.value as Locale | undefined;
|
|
||||||
|
|
||||||
// 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<Locale | undefined> {
|
||||||
|
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 headersList = await headers();
|
||||||
const acceptLanguage = headersList.get("accept-language");
|
const acceptLanguage = headersList.get("accept-language");
|
||||||
if (acceptLanguage) {
|
if (acceptLanguage) {
|
||||||
const preferredLocale = acceptLanguage
|
const candidate = acceptLanguage.split(",")[0]?.split("-")[0];
|
||||||
.split(",")[0]
|
if (isLocale(candidate)) locale = candidate;
|
||||||
?.split("-")[0] as Locale;
|
|
||||||
if (locales.includes(preferredLocale)) {
|
|
||||||
locale = preferredLocale;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to default
|
if (!locale) locale = defaultLocale;
|
||||||
if (!locale || !locales.includes(locale)) {
|
|
||||||
locale = defaultLocale;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locale,
|
locale,
|
||||||
@@ -39,7 +71,7 @@ export default getRequestConfig(async () => {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getMessageFallback({ namespace, key }) {
|
getMessageFallback({ key }) {
|
||||||
return key;
|
return key;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
89
src/lib/freeipa-ldap.ts
Normal file
89
src/lib/freeipa-ldap.ts
Normal file
@@ -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<T>(fn: (c: Client) => Promise<T>): Promise<T> {
|
||||||
|
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<void> {
|
||||||
|
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 `<customer-8h>-<tenant-4h>-0000-0000-<uid>`,
|
||||||
|
* 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("-");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user