diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 504ea761f..7171fa36b 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -285,7 +285,8 @@ export const users = pgTable("user", { termsVersion: varchar("termsVersion"), marketingEmailConsent: boolean("marketingEmailConsent").default(false), serverAdmin: boolean("serverAdmin").notNull().default(false), - lastPasswordChange: bigint("lastPasswordChange", { mode: "number" }) + lastPasswordChange: bigint("lastPasswordChange", { mode: "number" }), + locale: varchar("locale") }); export const newts = pgTable("newt", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 2bd11ee0c..47c7f04ae 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -320,7 +320,8 @@ export const users = sqliteTable("user", { serverAdmin: integer("serverAdmin", { mode: "boolean" }) .notNull() .default(false), - lastPasswordChange: integer("lastPasswordChange") + lastPasswordChange: integer("lastPasswordChange"), + locale: text("locale") }); export const securityKeys = sqliteTable("webauthnCredentials", { diff --git a/server/routers/external.ts b/server/routers/external.ts index 45ab58bba..334678944 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -793,6 +793,11 @@ unauthenticated.get( // ); unauthenticated.get("/user", verifySessionMiddleware, user.getUser); +unauthenticated.post( + "/user/locale", + verifySessionMiddleware, + user.updateUserLocale +); unauthenticated.get("/my-device", verifySessionMiddleware, user.myDevice); authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers); diff --git a/server/routers/user/getUser.ts b/server/routers/user/getUser.ts index e33daab60..c2e43e16e 100644 --- a/server/routers/user/getUser.ts +++ b/server/routers/user/getUser.ts @@ -20,7 +20,8 @@ async function queryUser(userId: string) { emailVerified: users.emailVerified, serverAdmin: users.serverAdmin, idpName: idp.name, - idpId: users.idpId + idpId: users.idpId, + locale: users.locale }) .from(users) .leftJoin(idp, eq(users.idpId, idp.idpId)) diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index b6fb05d92..6aa2bf792 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -16,4 +16,5 @@ export * from "./createOrgUser"; export * from "./adminUpdateUser2FA"; export * from "./adminGetUser"; export * from "./updateOrgUser"; +export * from "./updateUserLocale"; export * from "./myDevice"; diff --git a/server/routers/user/updateUserLocale.ts b/server/routers/user/updateUserLocale.ts new file mode 100644 index 000000000..6c28ce067 --- /dev/null +++ b/server/routers/user/updateUserLocale.ts @@ -0,0 +1,57 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { users } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const bodySchema = z.strictObject({ + locale: z.string().min(2).max(10) +}); + +export async function updateUserLocale( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not found") + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { locale } = parsedBody.data; + + await db.update(users).set({ locale }).where(eq(users.userId, userId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "User locale updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/components/LocaleSwitcherSelect.tsx b/src/components/LocaleSwitcherSelect.tsx index 201aeb18a..e647f7dd1 100644 --- a/src/components/LocaleSwitcherSelect.tsx +++ b/src/components/LocaleSwitcherSelect.tsx @@ -12,6 +12,8 @@ import clsx from "clsx"; import { useTransition } from "react"; import { Locale } from "@/i18n/config"; import { setUserLocale } from "@/services/locale"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; type Props = { defaultValue: string; @@ -25,12 +27,17 @@ export default function LocaleSwitcherSelect({ label }: Props) { const [isPending, startTransition] = useTransition(); + const api = createApiClient(useEnvContext()); function onChange(value: string) { const locale = value as Locale; startTransition(() => { setUserLocale(locale); }); + // Persist locale to the database (fire-and-forget) + api.post("/user/locale", { locale }).catch(() => { + // Silently ignore errors — cookie is already set as fallback + }); } const selected = items.find((item) => item.value === defaultValue); diff --git a/src/services/locale.ts b/src/services/locale.ts index 034f4c988..3bdf688bf 100644 --- a/src/services/locale.ts +++ b/src/services/locale.ts @@ -2,10 +2,13 @@ import { cookies, headers } from "next/headers"; import { Locale, defaultLocale, locales } from "@/i18n/config"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; // In this example the locale is read from a cookie. You could alternatively // also read it from a database, backend service, or any other source. const COOKIE_NAME = "NEXT_LOCALE"; +const COOKIE_MAX_AGE = 365 * 24 * 60 * 60; // 1 year in seconds export async function getUserLocale(): Promise { const cookieLocale = (await cookies()).get(COOKIE_NAME)?.value; @@ -14,6 +17,23 @@ export async function getUserLocale(): Promise { return cookieLocale as Locale; } + // No cookie found — try to restore from user's saved locale in DB + try { + const res = await internal.get("/user", await authCookieHeader()); + const userLocale = res.data?.data?.locale; + if (userLocale && locales.includes(userLocale as Locale)) { + // Set the cookie so subsequent requests don't need the API call + (await cookies()).set(COOKIE_NAME, userLocale, { + maxAge: COOKIE_MAX_AGE, + path: "/", + sameSite: "lax" + }); + return userLocale as Locale; + } + } catch { + // User not logged in or API unavailable — fall through + } + const headerList = await headers(); const acceptLang = headerList.get("accept-language"); @@ -33,5 +53,9 @@ export async function getUserLocale(): Promise { } export async function setUserLocale(locale: Locale) { - (await cookies()).set(COOKIE_NAME, locale); + (await cookies()).set(COOKIE_NAME, locale, { + maxAge: COOKIE_MAX_AGE, + path: "/", + sameSite: "lax" + }); }