feat(settings): cross-app language preference
gscMy /settings now hosts a single Language picker. - Persisted in admin.users.locale (keyed by gscSID). Per the gsc-identity-boundaries decision, locale is one of the explicitly allowed app-local preference columns. - Cross-app effect via NEXT_LOCALE cookie set with Domain=.gosec.internal — every sibling host on *.gosec.internal picks it up on the next request (no relogin, no DB read). - Server action prisma.user.upsert + cookies().set; client form reloads after save so next-intl re-resolves immediately. This deliberately collapses the previous kitchen-sink AccountSettings page (which depended on the broken gscUserId/getUserEffectiveSettings path) — full settings UI will return when the database/users.ts redesign in Phase-2 lands. 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.5
|
image: registry.gosec.internal/gsc-my/ui:v0.1.6
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 3000
|
- containerPort: 3000
|
||||||
|
|||||||
87
src/app/(my)/settings/LanguageForm.tsx
Normal file
87
src/app/(my)/settings/LanguageForm.tsx
Normal file
@@ -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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/app/(my)/settings/actions.ts
Normal file
67
src/app/(my)/settings/actions.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,38 +1,38 @@
|
|||||||
import { getAuthenticatedUser } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { getUserEffectiveSettings } from "@/database/settings";
|
import { prisma } from "@/database/prisma";
|
||||||
import AccountSettings from "@/components/account/AccountSettings";
|
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() {
|
export default async function SettingsPage() {
|
||||||
const user = await getAuthenticatedUser();
|
// Prefer the persisted preference; fall back to whatever next-intl
|
||||||
|
// resolved for this request (cookie / Accept-Language / default).
|
||||||
let initialSettings: Record<string, unknown> = {};
|
const session = await auth();
|
||||||
|
const gscsid = (session?.user as { gscSid?: string } | undefined)?.gscSid;
|
||||||
if (user?.gscUserId) {
|
let stored: string | null = null;
|
||||||
|
if (gscsid) {
|
||||||
try {
|
try {
|
||||||
const effective = await getUserEffectiveSettings(
|
const row = await prisma.user.findUnique({
|
||||||
user.gscUserId,
|
where: { gscsid },
|
||||||
user.tenantId,
|
select: { locale: true },
|
||||||
user.gscCustomerId,
|
});
|
||||||
SETTINGS_CATEGORIES
|
stored = row?.locale ?? null;
|
||||||
);
|
|
||||||
|
|
||||||
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 page] Failed to load settings:", err);
|
console.warn("[settings] locale lookup failed:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const initial = stored ?? (await getLocale());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccountSettings
|
<div className="container py-4">
|
||||||
displayName={user?.displayName || "User"}
|
<h2 className="mb-4">
|
||||||
email={user?.email || ""}
|
<i className="ph ph-gear me-2" aria-hidden="true"></i>
|
||||||
initialSettings={initialSettings}
|
Settings
|
||||||
/>
|
</h2>
|
||||||
|
<LanguageForm initialLocale={initial} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user