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.description', {
+ defaultValue:
+ 'The language used for notifications and messages sent to you. The interface language is chosen separately, from the globe menu.',
+ })}
+
+
+
+
+
+
+ );
+}
+
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..60bf316cbb
--- /dev/null
+++ b/apps/console/src/pages/system/__tests__/ProfilePage.language.test.tsx
@@ -0,0 +1,241 @@
+// 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
+ * `