…r's own sys_user.locale
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
Fixes #7501
Clause-②: no
Session:
https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq— the seat this branch was dispatched from.What this adds
A Language card on the console profile page that reads and writes the signed-in user's own
sys_user.locale(a BCP-47 tag). The platform made that column user-writable on 2026-09-03; the page editednameand the avatar only — exactly the{name, image}whitelist that ruling widened — so the capability existed as a database column and nowhere else.The four decisions the order asked me to make and report
1. Which language list. The control offers
offerableLanguagesfromuseObjectTranslation()— the i18n provider's own answer, which is the deployment's published locale list (the console feeds it from the platform's locales endpoint) intersected with what this renderer can resolve. That is the same setLocaleSwitcherrenders, so no second source of truth is introduced. A stored tag the deployment no longer publishes is prepended so the control shows the account's truth instead of rendering blank.2. UI language vs
sys_user.locale— two different things, deliberately not wired together. Read off the i18n provider: the interface language is a per-device preference kept inlocalStorageunderLOCALE_STORAGE_KEY, seeded per device from the tenant's/auth/me/localizationanswer throughcacheLanguageSeed, and switched by the globe menu.sys_user.localeis a per-user, server-stored column the messaging channels read per recipient at delivery time. Saving here therefore does not callchangeLanguage, and the globe menu does not write the column. Whether the two should be joined is a product decision nobody has made — it is in the report'sopen_questions, not decided here. The card's description states the distinction to the user instead of leaving them to discover it.3. The refusal path. A malformed tag comes back as
400 VALIDATION_FAILEDwith a per-fieldlocaleentry. The adapter'snormaliseClientErrorturns that into a typed error carryingvalidationErrors, andextractFieldErrors— this repo's one normaliser for that envelope — reads it. The message is rendered on the item, witharia-invalidandaria-describedbypointing at it, and no success alert appears. Anything else falls back toextractWriteErrorMessagein the card's own alert.4. Save semantics — same feedback, and the writer CANNOT match.
nameand the avatar are written byuseAuth().updateUser, which is a thin pass-through to better-auth's/update-user;localeis deliberately not a better-authadditionalFieldsentry, so that endpoint does not know the column. The write therefore goes through the data adapter on thesys_userrow. A different writer gets its own card with its own submit — which is the shapePasswordCardalready establishes in this same file, for the same reason. What is kept identical to thenameform is the FEEDBACK: the same success / failureAlertpair and the same disabled-while-saving submit.Acceptance, item by item
ProfilePage, againstuser.id; nothing else mounts itUse the deployment defaultoption writesnull, not an empty stringsys_user.localedecides read-only-with-reason; an unread or refused row read renders nothing at allEvidence
Every command was run from the repo root in a dedicated worktree, after the last edit it measures.
pnpm exec vitest run apps/console/src/pages/system/__tests__/ProfilePage.language.test.tsxTest Files 1 passed (1)/Tests 7 passed (7)pnpm --filter @object-ui/console type-checkpnpm --filter @object-ui/i18n type-checkpnpm --filter @object-ui/console lint+--filter @object-ui/i18n lint212 problems (0 errors, 212 warnings); the one warning on the edited file is the pre-existingsetNameeffect, not newnode scripts/check-i18n-call-site-keys.mjsenvaluenode scripts/check-i18n-en-drift.mjs0 en value(s) changed (7 key(s) added, 0 removed)node scripts/check-i18n-dead-keys.mjsnode scripts/check-changeset-presence.mjsnode scripts/check-changeset-no-major.mjsmajordeclarednode scripts/check-new-cross-file-line-citations.mjs0 new citation(s)node scripts/check-control-bytes.mjsReverse verification — both legs, run from the committed fix
Leg A — restore the page to its
origin/mainstate (no card at all): 7 failed (7), exit 1. Restored blob hash compared equal to theHEADblob andgit diff HEADempty.Leg B — keep everything and re-point only the write at
useAuth().updateUser: 3 failed | 4 passed (7), exit 1. The three reds are exactly the save cases; the menu, seeding, permission and hidden cases stay green. That is the leg that shows these assertions are about the write and not merely about something rendering. Restored blob hash compared equal toHEADagain.Both legs verified the mutation reached disk before running (occurrence counts in both directions plus a blob-hash comparison), and both restored with an explicit
checkout HEADunder a trap.Not measured here
domain:ui#2, PM sessionsession_018HrVaotisyhgmot9o2MLRq), because the dev writes this body once and does not PATCH it: the paragraph that stood here said the two project suites were unmeasured after a verify-lock queue timeout (exit 99). That was true when this PR was opened and is now stale — the slot was re-taken and both suites ran green afterwards, reported in the dev's hand-back:packages/i18n69 files / 1145 tests,apps/console103 files / 1173 tests, bothexit 0.⇒ The one genuinely unmeasured item is the browser / live-stack path: no backend was started, so the 403-free write at the installed 17.4.0 is read from the published packages and the permission instrument, ⛔ not exercised end to end.
Acceptance notes
localeLabelis unreachable from this app.@object-ui/app-shellowns the hand-kept native names for the built-in packs, but exports onlyLocaleSwitcherfrom its entry and itsexportsmap has no subpath, so the console cannot import the helper. This card names its languages throughIntl.DisplayNamesinstead, deliberately not by re-typing the ten strings — a copy would be a second source of truth for language NAMES that nothing re-derives. Filing the export gap is proposed in the report;packages/app-shellis out of bounds on this card (in flight on objectui#9954).Blocked-by:line still points at a platform card that stopped being the blocker on 2026-09-05. Two seats said so at the time and neither could edit the card face. Not touched here.userEvent.selectOptionsleaves this controlledselect's value unchanged, and auserEvent.clickon the submit button never reaches the form'sonSubmit, under the DOM environment these console tests run in. The tests usefireEvent.change+fireEvent.submit— the idiom this repo's other form tests already use — and still assert the submit button is present and ENABLED at that moment.🤖 Generated with Claude Code
https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
Generated by Claude Code
Generated by Claude Code