From 9ed246b67fd907788b3184020d190f26f5bb8bc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 06:48:23 +0000 Subject: [PATCH 1/2] feat(console,i18n): a "Language" item on the profile page for the user's own sys_user.locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform made `sys_user.locale` user-writable on 2026-09-03 and nothing in the product let anyone set it: the profile page edited `name` and the avatar only, which is exactly the `{name, image}` whitelist that ruling widened. The new card reads and writes the signed-in user's own `sys_user` row through the data adapter. It is a card of its own rather than another field in the Personal Information form because that form's writer, `useAuth().updateUser`, posts to better-auth's `/update-user`, and `locale` is not a better-auth `additionalFields` entry, so that endpoint cannot carry the column. Its feedback vocabulary is the form's. The offered set is the i18n provider's `offerableLanguages` — the same intersection `LocaleSwitcher` renders — so no second language list is introduced; each entry is named in its own language, and a stored tag the deployment no longer publishes is shown rather than dropped. "Use the deployment default" writes `null`, which is the documented meaning of an unset column and the only way back once a tag is stored. A refused tag renders the server's per-field message on the item, with `aria-invalid` and `aria-describedby` pointing at it. When the field/object permission answer for `sys_user.locale` is no, the control is read-only and says why rather than offering a button that would answer 403; it does not render at all while the row is unread or if the read is refused. The interface language is deliberately untouched: it is a per-device preference the i18n provider keeps in `localStorage`, while `sys_user.locale` is a server column read per recipient at delivery time. Joining them is a product decision nobody has made. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq --- .changeset/7501-profile-my-language.md | 44 +++ apps/console/src/pages/system/ProfilePage.tsx | 274 +++++++++++++++++- .../__tests__/ProfilePage.language.test.tsx | 236 +++++++++++++++ packages/i18n/src/locales/ar.ts | 9 + packages/i18n/src/locales/de.ts | 9 + packages/i18n/src/locales/en.ts | 9 + packages/i18n/src/locales/es.ts | 9 + packages/i18n/src/locales/fr.ts | 9 + packages/i18n/src/locales/ja.ts | 9 + packages/i18n/src/locales/ko.ts | 9 + packages/i18n/src/locales/pt.ts | 9 + packages/i18n/src/locales/ru.ts | 9 + packages/i18n/src/locales/zh.ts | 9 + 13 files changed, 642 insertions(+), 2 deletions(-) create mode 100644 .changeset/7501-profile-my-language.md create mode 100644 apps/console/src/pages/system/__tests__/ProfilePage.language.test.tsx diff --git a/.changeset/7501-profile-my-language.md b/.changeset/7501-profile-my-language.md new file mode 100644 index 0000000000..af4197d0d6 --- /dev/null +++ b/.changeset/7501-profile-my-language.md @@ -0,0 +1,44 @@ +--- +'@object-ui/console': minor +'@object-ui/i18n': minor +--- + +feat(console): a "Language" item on the profile page, writing the signed-in user's own +`sys_user.locale` (objectui#7501). + +The platform made that column user-writable on 2026-09-03 and nothing in the product let +anyone set it — the profile page edited `name` and the avatar and carried no language +control at all, which is the `{name, image}` whitelist the ruling widened. Two people in +one deployment were already receiving their notifications in different languages with no +way for either of them to choose. + +The new card reads and writes the user's own `sys_user` row through the data adapter, and +it is deliberately a card of its own rather than another field in the Personal Information +form: that form is written by `useAuth().updateUser`, which posts to better-auth's +`/update-user`, and `locale` is not a better-auth `additionalFields` entry, so that +endpoint cannot carry the column. Its feedback vocabulary is the form's — the same success +and failure `Alert`s, the same disabled-while-saving submit. + +What it offers is the i18n provider's own `offerableLanguages` (the deployment's published +locales intersected with what this renderer can resolve) — the same set `LocaleSwitcher` +renders, so no second language list is introduced. Each entry is named in its own language. +A stored tag the deployment no longer publishes is shown rather than dropped, so the +control never displays a value that is not the account's. + +Clearing is a first-class option: "Use the deployment default" writes `null`, which is the +documented meaning of an unset column and the only way back once a tag has been stored. + +A refused tag (`400 VALIDATION_FAILED` with a per-field `locale` entry) is rendered on the +item, with `aria-invalid` and `aria-describedby` pointing at it, rather than as an +undirected alert. When the deployment's write route is not this user's — the field/object +permission answer for `sys_user.locale` — the control is read-only and says why instead of +offering a button that would answer 403, and it does not render at all while the row is +unread or if the read is refused. + +**This does not change the interface language.** The UI language is a per-device +preference the i18n provider keeps in `localStorage` and the globe menu switches; +`sys_user.locale` is a per-user server column read per recipient at delivery time. The two +stayed separate here on purpose; whether they should be joined is a product decision that +has not been made. + +`@object-ui/i18n` gains the seven `profile.language.*` keys, in all ten packs. diff --git a/apps/console/src/pages/system/ProfilePage.tsx b/apps/console/src/pages/system/ProfilePage.tsx index b1db0d9272..6b3e07e959 100644 --- a/apps/console/src/pages/system/ProfilePage.tsx +++ b/apps/console/src/pages/system/ProfilePage.tsx @@ -5,7 +5,7 @@ * change their password, and manage account settings. */ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useAuth, getUserInitials } from '@object-ui/auth'; import { Button, @@ -25,7 +25,9 @@ import { } from '@object-ui/components'; import { useUpload } from '@object-ui/providers'; import { useObjectTranslation } from '@object-ui/i18n'; -import { CheckCircle2, AlertCircle, User, Lock, Upload, Loader2, X } from 'lucide-react'; +import { useAdapter, extractFieldErrors, extractWriteErrorMessage } from '@object-ui/react'; +import { usePermissions } from '@object-ui/permissions'; +import { CheckCircle2, AlertCircle, User, Lock, Upload, Loader2, X, Globe } from 'lucide-react'; export function ProfilePage() { const { t } = useObjectTranslation(); @@ -242,6 +244,9 @@ export function ProfilePage() { + {/* Language — the user's own `sys_user.locale` */} + + {/* Password Change */} ` value standing for "no stored tag" — see {@link LanguageCard}. */ +const USE_DEPLOYMENT_DEFAULT = ''; + +interface LanguageCardProps { + /** The signed-in user's `sys_user` record id. */ + userId: string; +} + +/** + * The signed-in user's own `sys_user.locale` — a BCP-47 tag such as `zh-CN`. + * + * ## Why this is not part of the Personal Information form above + * + * `name` and `image` are written by `useAuth().updateUser`, which posts to + * better-auth's `/update-user`. `locale` is deliberately NOT a better-auth + * `additionalFields` entry, so that endpoint does not know the column and + * cannot carry it — the card that asked for this control says so, and + * `createAuthClient`'s `updateUser` is a thin pass-through to + * `betterAuth.updateUser`, so there is no seam to widen on this side either. + * The write therefore goes through the data API (`PATCH` on the `sys_user` + * row) via the adapter, which is a different writer from the form above. + * + * A different writer gets its own card with its own submit and its own + * feedback — the shape `PasswordCard` below already establishes in this file, + * for the same reason (`changePassword` is not `updateUser` either). What is + * kept identical to the `name` form is the FEEDBACK vocabulary: the same + * `Alert` success/failure pair, the same disabled-while-saving submit, the + * same `profile.saving` label. + * + * ## The UI language is a different thing, and is deliberately not touched + * + * `@object-ui/i18n`'s provider keeps the interface language in `localStorage` + * (`LOCALE_STORAGE_KEY`), seeded per device from the tenant's + * `/auth/me/localization`. `sys_user.locale` is a server-stored, per-user + * column that the messaging channels read per recipient at delivery time. + * Saving here therefore does NOT call `changeLanguage`, and switching the UI + * language from the globe menu does NOT write this column. Wiring the two + * together is a product decision nobody has made; this control states the + * distinction in its own description instead of guessing. + * + * ## What is offered, and what happens when the write route is not there + * + * The option list is `offerableLanguages` — the i18n provider's own answer + * (the deployment's published locales ∩ what this renderer can resolve), which + * is what `LocaleSwitcher` renders too. ⛔ No second list is introduced here. + * A stored tag that is not in that set is prepended so the control shows the + * truth rather than rendering blank. + * + * Availability is asked, not discovered from a rejection: the card renders + * nothing until the row has been read, and `checkField('sys_user', 'locale', + * 'write')` — which consults field-level permissions and falls back to the + * object gate's `allowEdit` — decides between an editable control and a + * read-only one carrying the reason. A failed read hides the card, the same + * "render nothing rather than something you will have to retract" idiom + * `LocaleSwitcher` uses for a locale list it does not have yet. That is the + * card's own "hidden ... rather than rendering a control that answers 403". + */ +function LanguageCard({ userId }: LanguageCardProps) { + const { t, offerableLanguages } = useObjectTranslation(); + const adapter = useAdapter(); + const { checkField } = usePermissions(); + + const [stored, setStored] = useState(null); + const [choice, setChoice] = useState(USE_DEPLOYMENT_DEFAULT); + const [rowRead, setRowRead] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + const [fieldError, setFieldError] = useState(null); + + useEffect(() => { + if (!adapter || !userId) return; + let cancelled = false; + adapter + .findOne('sys_user', userId) + .then((row) => { + if (cancelled) return; + const raw = (row as { locale?: unknown } | null)?.locale; + const value = typeof raw === 'string' && raw.length > 0 ? raw : USE_DEPLOYMENT_DEFAULT; + setStored(value); + setChoice(value); + setRowRead(true); + }) + .catch((err) => { + if (cancelled) return; + // Stay hidden. A deployment that does not expose `sys_user` to this + // caller, and one that refuses the read outright, both arrive here — + // and a control that cannot state the current value is worse than no + // control. The warning is the diagnosable half. + console.warn('[profile] Could not read your language preference:', err); + }); + return () => { + cancelled = true; + }; + }, [adapter, userId]); + + const codes = useMemo(() => { + const offered = offerableLanguages ?? []; + // A stored tag the deployment no longer publishes is still this account's + // truth; showing it beats a select whose value matches no option. + return stored && stored.length > 0 && !offered.includes(stored) + ? [stored, ...offered] + : [...offered]; + }, [offerableLanguages, stored]); + + if (!adapter || !rowRead || offerableLanguages === null) return null; + + const writable = checkField('sys_user', 'locale', 'write'); + const dirty = choice !== (stored ?? USE_DEPLOYMENT_DEFAULT); + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setFieldError(null); + setSaved(false); + setSubmitting(true); + try { + // `null`, not `''`: an unset column is the documented "use the + // deployment default", and it is the only way back once a tag has been + // stored. The avatar's remove path in this file clears `image` the same + // way. + await adapter.update('sys_user', userId, { + locale: choice === USE_DEPLOYMENT_DEFAULT ? null : choice, + }); + setStored(choice); + setSaved(true); + } catch (err) { + // A malformed tag comes back as `400 VALIDATION_FAILED` carrying + // `{ field: 'locale', code: 'invalid_format' }` and a localized message. + // `extractFieldErrors` is this repo's one normaliser for that envelope; + // the message it yields is rendered ON the item, which is the whole + // point of asking for it rather than showing an undirected alert. + const perField = extractFieldErrors(err); + const onLocale = perField?.find((entry) => entry.field === 'locale'); + if (onLocale && onLocale.message) { + setFieldError(onLocale.message); + } else { + setError( + extractWriteErrorMessage(err) ?? (err instanceof Error ? err.message : String(err)), + ); + } + } finally { + setSubmitting(false); + } + }; + + return ( + + +
+ + + {t('profile.language.title', { defaultValue: 'Language' })} + +
+ + {t('profile.language.description', { + defaultValue: + 'The language used for notifications and messages sent to you. The interface language is chosen separately, from the globe menu.', + })} + +
+ +
+ {error && ( + + + {error} + + )} + {saved && ( + + + + {t('profile.language.saved', { defaultValue: 'Language preference updated.' })} + + + )} + +
+ + + {fieldError && ( +

+ {fieldError} +

+ )} + {!writable && ( +

+ {t('profile.language.readOnly', { + defaultValue: 'Your administrator manages the language for your account.', + })} +

+ )} +
+ + {writable && ( + + )} +
+
+
+ ); +} + interface PasswordCardProps { changePassword: (currentPassword: string, newPassword: string) => Promise; setInitialPassword: (newPassword: string) => Promise; diff --git a/apps/console/src/pages/system/__tests__/ProfilePage.language.test.tsx b/apps/console/src/pages/system/__tests__/ProfilePage.language.test.tsx new file mode 100644 index 0000000000..39ceea6fcf --- /dev/null +++ b/apps/console/src/pages/system/__tests__/ProfilePage.language.test.tsx @@ -0,0 +1,236 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#7501 — the signed-in user's own `sys_user.locale`, on the profile + * page. + * + * ## What these cases are guarding, and why each one can fail + * + * The column has been user-writable since the 2026-09-03 platform ruling and + * nothing in the product let anyone set it. The control that closes that gap + * has four properties that are easy to render *plausibly* and get wrong, so + * each one is pinned against a thing the control could have done instead: + * + * 1. **The offered set is the i18n provider's `offerableLanguages`**, not a + * list of this file's own. A hard-coded ten would render an identical menu + * on the built-in packs and a WRONG one on a deployment that publishes two + * — so the case below hands the provider a three-code list that is not the + * built-in set and asserts the menu is exactly it. + * 2. **The write goes to the data API**, argument by argument. A control + * wired to `useAuth().updateUser` renders and "succeeds" identically while + * posting to better-auth's `/update-user`, which does not know this column + * (`locale` is deliberately not a better-auth `additionalFields` entry) — + * so the assertion is on `adapter.update`'s arguments, and `updateUser` is + * asserted NOT to have been called. + * 3. **Clearing writes `null`**, which is the documented "use the deployment + * default" and the only way back once a tag is stored. A control that + * wrote `''` would look the same on screen and store a malformed tag. + * 4. **A refusal lands ON the item.** The rejection is built by running the + * platform's own error body through `normaliseClientError` — the adapter's + * real normaliser — rather than by hand-shaping what this test hopes the + * adapter throws, so the case fails if that chain moves. + * + * ## Why the form is driven with `fireEvent`, not `userEvent` + * + * Measured here, not preferred on style. Under the DOM environment these + * console tests run in, `userEvent.selectOptions` leaves this controlled + * `