diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 68b548a..3bf2a0f 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.5 + image: registry.gosec.internal/gsc-my/ui:v0.1.6 imagePullPolicy: Always ports: - containerPort: 3000 diff --git a/src/app/(my)/settings/LanguageForm.tsx b/src/app/(my)/settings/LanguageForm.tsx new file mode 100644 index 0000000..c89edf7 --- /dev/null +++ b/src/app/(my)/settings/LanguageForm.tsx @@ -0,0 +1,87 @@ +"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 new file mode 100644 index 0000000..1dc151f --- /dev/null +++ b/src/app/(my)/settings/actions.ts @@ -0,0 +1,67 @@ +"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 c263ceb..caae76e 100644 --- a/src/app/(my)/settings/page.tsx +++ b/src/app/(my)/settings/page.tsx @@ -1,38 +1,38 @@ -import { getAuthenticatedUser } from "@/auth"; -import { getUserEffectiveSettings } from "@/database/settings"; -import AccountSettings from "@/components/account/AccountSettings"; +import { auth } from "@/auth"; +import { prisma } from "@/database/prisma"; +import { getLocale } from "next-intl/server"; +import LanguageForm from "./LanguageForm"; -const SETTINGS_CATEGORIES = ["user.general", "user.notifications", "user.email", "user.calendar"]; +export const dynamic = "force-dynamic"; + +export const metadata = { title: "Settings — GSC My" }; export default async function SettingsPage() { - const user = await getAuthenticatedUser(); - - let initialSettings: Record = {}; - - if (user?.gscUserId) { + // 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) { try { - 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; - } - } + const row = await prisma.user.findUnique({ + where: { gscsid }, + select: { locale: true }, + }); + stored = row?.locale ?? null; } catch (err) { - console.warn("[settings page] Failed to load settings:", err); + console.warn("[settings] locale lookup failed:", err); } } + const initial = stored ?? (await getLocale()); return ( - +
+

+ + Settings +

+ +
); }