diff --git a/.changeset/org-invitation-i18n-holdouts-4474.md b/.changeset/org-invitation-i18n-holdouts-4474.md
new file mode 100644
index 000000000..36b9280f5
--- /dev/null
+++ b/.changeset/org-invitation-i18n-holdouts-4474.md
@@ -0,0 +1,27 @@
+---
+'@object-ui/app-shell': patch
+'@object-ui/auth': patch
+'@object-ui/i18n': patch
+---
+
+Organization & invitation console: translate the English holdouts a zh session was left reading (#4474)
+
+Three families of string, one sweep over `console/organizations/`:
+
+- **Role names** now come from the single shared `ORG_ROLE_LABELS` map at every
+ site. The role badges on the members and invitations pages were rendering the
+ raw server identifier (`owner`) under a CSS `capitalize` that made it look like
+ a label in English and left it untranslated everywhere else; the accept page
+ did the same in its role row and inside its otherwise-translated sentence. The
+ map's four `organization.roles.*` keys existed in no locale pack, so even the
+ dropdown that did consult it fell through to English — all ten packs now carry
+ them. An unrecognized role renders verbatim rather than blank.
+- **Server-echoed errors** are mapped by better-auth's stable `code`, never by
+ matching its English text. `createAuthClient` was dropping that code for every
+ `organization.*` call while preserving it for sign-in/sign-up, so the console
+ had nothing to key on; all sixteen organization methods now go through the same
+ `toAuthError` helper. Messages are unchanged — the code simply stops being
+ thrown away. An unmapped code still shows the server's own sentence.
+- **Icon-only `aria-label`s** (member actions, copy invitation link, cancel
+ invitation, and a fourth on the share-link copy button) are translated — for an
+ icon-only control this is the only name a screen reader gets.
diff --git a/apps/console/src/pages/auth/__tests__/AcceptInvitationRoute.test.tsx b/apps/console/src/pages/auth/__tests__/AcceptInvitationRoute.test.tsx
index b504207a6..96b761371 100644
--- a/apps/console/src/pages/auth/__tests__/AcceptInvitationRoute.test.tsx
+++ b/apps/console/src/pages/auth/__tests__/AcceptInvitationRoute.test.tsx
@@ -184,7 +184,11 @@ describe('objectui#3811 — console routes DefaultAcceptInvitationPage', () => {
expect(screen.getAllByText(/Acme Corp/).length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('Organization')).toBeInTheDocument();
expect(screen.getByText('Role')).toBeInTheDocument();
- expect(screen.getByText('admin')).toBeInTheDocument();
+ // The DISPLAY name, not the raw `sys_member.role` identifier: since
+ // objectui#4474 the role row resolves through the shared role-label map,
+ // so `admin` renders as `Admin` here and as 管理员 in a zh session. The
+ // capability this case pins — "shows which role" — is unchanged.
+ expect(screen.getByText('Admin')).toBeInTheDocument();
expect(screen.getByText('Expires')).toBeInTheDocument();
expect(
screen.getByText(new Date(INVITATION.expiresAt).toLocaleDateString()),
diff --git a/packages/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx b/packages/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx
index 7875b4faa..9b362710a 100644
--- a/packages/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx
+++ b/packages/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx
@@ -24,6 +24,7 @@ import type { AuthOrganization } from '@object-ui/auth';
import { useObjectTranslation } from '@object-ui/i18n';
import { Loader2 } from 'lucide-react';
import { provisionProductionEnvironment } from './provisionEnvironment';
+import { resolveOrgErrorMessage } from './orgErrorMessage';
/**
* Convert a display name to a URL-friendly slug.
@@ -156,7 +157,14 @@ export function CreateWorkspaceDialog({
}
onCreated?.(org);
} catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to create workspace');
+ // objectui#4474 — the card's site 4: a taken slug surfaced better-auth's
+ // `Organization already exists` verbatim in a zh session. Mapped by code.
+ setError(
+ resolveOrgErrorMessage(err, t, {
+ key: 'workspace.createFailed',
+ defaultValue: 'Failed to create workspace',
+ }),
+ );
} finally {
setIsSubmitting(false);
}
diff --git a/packages/app-shell/src/console/organizations/__tests__/acceptInvitationLink.mount.test.tsx b/packages/app-shell/src/console/organizations/__tests__/acceptInvitationLink.mount.test.tsx
index db5ff86c8..b3eaa1ac8 100644
--- a/packages/app-shell/src/console/organizations/__tests__/acceptInvitationLink.mount.test.tsx
+++ b/packages/app-shell/src/console/organizations/__tests__/acceptInvitationLink.mount.test.tsx
@@ -192,7 +192,10 @@ async function shareLinkFromInviteDialog(): Promise<{ shown: string; copied: str
// The "share link" view: a read-only field holding the accept URL, plus a
// copy button. Both must carry the same openable URL.
const field = await screen.findByRole('textbox');
- fireEvent.click(screen.getByLabelText('Copy'));
+ // The label was a bare "Copy" until objectui#4474 gave this icon-only button
+ // the same name as the Invitations tab's copy control — same action, same
+ // words. Only the query moved; every URL assertion below is unchanged.
+ fireEvent.click(screen.getByLabelText('Copy invitation link'));
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
return { shown: (field as HTMLInputElement).value, copied: writeText.mock.calls[0][0] as string };
}
diff --git a/packages/app-shell/src/console/organizations/__tests__/org-i18n-holdouts-4474.test.tsx b/packages/app-shell/src/console/organizations/__tests__/org-i18n-holdouts-4474.test.tsx
new file mode 100644
index 000000000..7da015515
--- /dev/null
+++ b/packages/app-shell/src/console/organizations/__tests__/org-i18n-holdouts-4474.test.tsx
@@ -0,0 +1,424 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectui#4474 — the organization / invitation console renders **zh** where a
+ * zh session asks for it.
+ *
+ * ## Why the provider is real here and the components are mocked around it
+ *
+ * Every sibling test in this directory mocks `@object-ui/i18n` with
+ * `t: (key, o) => o.defaultValue`. That mock is an **en oracle**: it returns the
+ * inline English fallback by construction, so under it the defect is invisible —
+ * `Delegated Admin` is the "right" answer and always was. So this file mounts
+ * the REAL `I18nProvider` (the same choice, for the same reason, as
+ * `packages/i18n/src/__tests__/organization-namespace-3546.test.tsx`) and mocks
+ * only the things that are not the subject: `useAuth`, the router outlet, the
+ * component kit and `sonner`. i18next is the thing answering, which is the only
+ * way a zh assertion can discriminate before from after.
+ *
+ * ## The six measured sites (the card's table), verbatim as they rendered
+ *
+ * 1. invite dialog role dropdown ......... `Delegated Admin`
+ * 2. members page role badges ............ `Owner`, `Member`
+ * 3. invite dialog inline form error ..... `You are not allowed to invite users to this organization`
+ * 4. create-workspace inline form error .. `Organization already exists`
+ * 5. accept page toast body .............. `You are not the recipient of the invitation`
+ * 6. icon-only aria-labels ............... `Member actions`, `Copy invitation link`, `Cancel invitation`
+ *
+ * Sites 3-5 are SERVER-echoed: the fix is a `code` -> message mapping, never a
+ * match on the English text. Sites 1-2 and 6 are client-authored.
+ *
+ * ## What the card measured that this file corrects
+ *
+ * The card reports the dropdown's siblings rendering 所有者 / 管理员 / 成员 beside an
+ * untranslated `Delegated Admin`. On `origin/main` at 338e2c421 that is not what
+ * happens: `organization.roles.*` exists in NO pack (measured — see the pack
+ * assertion below), so ALL FOUR entries fall through to their inline English
+ * `defaultValue`. The defect is the card's, one size larger. The maintainer's
+ * server was on an unverified commit, which is the likeliest source of the
+ * discrepancy; either way the assertions below pin what this tree does.
+ *
+ * The role keys evaded `scripts/check-i18n-call-site-keys.mjs` — which found and
+ * paid off 258 keys of exactly this class — because they are referenced through
+ * `ORG_ROLE_LABELS[r].key`, a VARIABLE. That gate scans literals. This file is
+ * the standing replacement for the coverage the variable indirection costs.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor, fireEvent } from '@testing-library/react';
+import React from 'react';
+import { I18nProvider } from '@object-ui/i18n';
+import { builtInLocales } from '@object-ui/i18n';
+
+// ── Mocks: everything EXCEPT i18n ────────────────────────────────────────────
+
+const inviteMember = vi.fn();
+const describeDelegableScope = vi.fn();
+const getMembers = vi.fn();
+const removeMember = vi.fn();
+const updateMemberRole = vi.fn();
+const listInvitations = vi.fn();
+const cancelInvitation = vi.fn();
+const getInvitation = vi.fn();
+const acceptInvitation = vi.fn();
+const rejectInvitation = vi.fn();
+const switchOrganization = vi.fn();
+const createOrganization = vi.fn();
+const getAuthConfig = vi.fn();
+
+const auth: { role: string | null } = { role: 'owner' };
+
+vi.mock('@object-ui/auth', async (importActual) => ({
+ ...(await importActual()),
+ useAuth: () => ({
+ inviteMember,
+ describeDelegableScope,
+ getMembers,
+ removeMember,
+ updateMemberRole,
+ listInvitations,
+ cancelInvitation,
+ getInvitation,
+ acceptInvitation,
+ rejectInvitation,
+ switchOrganization,
+ createOrganization,
+ getAuthConfig,
+ isAuthenticated: true,
+ isLoading: false,
+ activeMember: auth.role === null ? null : { role: auth.role },
+ }),
+}));
+
+const toastError = vi.fn();
+const toastSuccess = vi.fn();
+vi.mock('sonner', () => ({ toast: { success: (m: string) => toastSuccess(m), error: (m: string) => toastError(m) } }));
+
+vi.mock('react-router-dom', () => ({
+ useOutletContext: () => ({ org: { id: 'org-42', name: 'Acme', slug: 'acme' } }),
+ useNavigate: () => vi.fn(),
+ useLocation: () => ({ pathname: '/accept-invitation/inv-1', search: '' }),
+ useParams: () => ({ invitationId: 'inv-1' }),
+}));
+
+vi.mock('@object-ui/components', () => {
+ const passthrough = (tag: string) => (p: any) => React.createElement(tag, p, p.children);
+ return {
+ Dialog: ({ open, children }: any) => (open ?
,
+ DropdownMenuContent: passthrough('div'),
+ DropdownMenuItem: ({ children, ...rest }: any) => ,
+ DropdownMenuTrigger: ({ children }: any) => <>{children}>,
+ Select: ({ value, onValueChange, children }: any) => (
+
+ ),
+ SelectContent: ({ children }: any) => <>{children}>,
+ SelectItem: ({ value, children, ...rest }: any) => (
+
+ ),
+ SelectTrigger: ({ children }: any) => <>{children}>,
+ SelectValue: () => null,
+ };
+});
+
+// Enumerated, not a Proxy: Vitest validates named ESM exports against the mock
+// object's own keys, and a Proxy has none — every icon reads as undefined. The
+// stub is spelled inside the factory because `vi.mock` is hoisted above every
+// top-level binding in this file.
+vi.mock('lucide-react', () => {
+ const icon = () => ;
+ return {
+ Loader2: icon,
+ Copy: icon,
+ Check: icon,
+ MoreHorizontal: icon,
+ UserMinus: icon,
+ ShieldCheck: icon,
+ X: icon,
+ Mail: icon,
+ Building2: icon,
+ CheckCircle: icon,
+ XCircle: icon,
+ };
+});
+
+vi.mock('../provisionEnvironment', () => ({ provisionProductionEnvironment: vi.fn() }));
+
+import { InviteMemberDialog } from '../manage/InviteMemberDialog';
+import { MembersPage } from '../manage/MembersPage';
+import { InvitationsPage } from '../manage/InvitationsPage';
+import { AcceptInvitationPage } from '../manage/AcceptInvitationPage';
+import { CreateWorkspaceDialog } from '../CreateWorkspaceDialog';
+
+// ── Harness ──────────────────────────────────────────────────────────────────
+
+const inLocale = (lang: 'en' | 'zh') =>
+ function Wrapper({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+ };
+
+const renderIn = (lang: 'en' | 'zh', ui: React.ReactElement) =>
+ render(ui, { wrapper: inLocale(lang) });
+
+/**
+ * A better-auth client error as the org routes actually produce one: a real
+ * `Error` carrying the machine `code` beside the English `message`. The whole
+ * point of family 2 is that the mapping reads `.code` and never the text.
+ */
+const authError = (code: string, message: string): Error => {
+ const err = new Error(message) as Error & { code?: string };
+ err.code = code;
+ return err;
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ window.localStorage.clear();
+ auth.role = 'owner';
+ describeDelegableScope.mockResolvedValue(null);
+ getAuthConfig.mockResolvedValue({ features: { multiOrgEnabled: true } });
+ getMembers.mockResolvedValue([
+ { id: 'm-1', role: 'owner', userId: 'u-1', user: { name: 'Ada', email: 'ada@x.test' } },
+ { id: 'm-2', role: 'member', userId: 'u-2', user: { name: 'Bo', email: 'bo@x.test' } },
+ ]);
+ listInvitations.mockResolvedValue([
+ { id: 'inv-1', email: 'cy@x.test', role: 'admin', status: 'pending' },
+ ]);
+ getInvitation.mockResolvedValue({
+ id: 'inv-1',
+ email: 'cy@x.test',
+ role: 'admin',
+ status: 'pending',
+ organizationId: 'org-42',
+ organizationName: 'Acme',
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Family 1 — client-authored strings (role names)
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('#4474 family 1 — role names come from ONE map and resolve in zh', () => {
+ it('the packs define every role key (the absence that made all four English)', () => {
+ // The root cause, asserted at the pack rather than through a render: pre-fix
+ // `organization.roles` is undefined in all ten packs, so every consumer of
+ // ORG_ROLE_LABELS fell through to its inline English defaultValue.
+ for (const lang of Object.keys(builtInLocales) as Array) {
+ const roles = (builtInLocales[lang] as any).organization?.roles;
+ expect(roles, `${lang} has no organization.roles`).toBeDefined();
+ for (const k of ['owner', 'admin', 'delegatedAdmin', 'member']) {
+ expect(typeof roles?.[k], `${lang}.organization.roles.${k}`).toBe('string');
+ }
+ }
+ });
+
+ it('zh: the invite dropdown renders Chinese for every role — site 1', async () => {
+ renderIn('zh', {}} />);
+ await waitFor(() => expect(screen.getByTestId('select')).toBeInTheDocument());
+ const labels = Array.from(screen.getByTestId('select').querySelectorAll('option'))
+ .filter((o) => o.getAttribute('value'))
+ .map((o) => o.textContent);
+ // Pre-fix this array was ['Owner', 'Admin', 'Delegated Admin', 'Member'].
+ expect(labels).toEqual(['所有者', '管理员', '受托管理员', '成员']);
+ });
+
+ it('zh: the members page role badges render Chinese — site 2', async () => {
+ renderIn('zh', );
+ await waitFor(() => expect(screen.getByTestId('members-page')).toBeInTheDocument());
+ // Pre-fix: `Owner` / `Member` — the raw server identifier under CSS
+ // `capitalize`, beside a dropdown that consulted the shared map.
+ expect(screen.getByTestId('member-role-badge-m-1')).toHaveTextContent('所有者');
+ expect(screen.getByTestId('member-role-badge-m-2')).toHaveTextContent('成员');
+ });
+
+ it('zh: the invitations page role badge renders Chinese — sibling holdout', async () => {
+ renderIn('zh', );
+ await waitFor(() => expect(screen.getByTestId('invitations-page')).toBeInTheDocument());
+ expect(screen.getByTestId('invitation-role-badge-inv-1')).toHaveTextContent('管理员');
+ });
+
+ it('zh: the accept page names the role in Chinese, in the row AND the sentence', async () => {
+ renderIn('zh', );
+ await waitFor(() => expect(screen.getByTestId('accept-invitation-page')).toBeInTheDocument());
+ expect(screen.getByTestId('accept-invitation-role')).toHaveTextContent('管理员');
+ // The interpolated sentence took `role: invitation.role` raw, so a fully
+ // Chinese sentence carried an English word in the middle of it.
+ expect(screen.getByTestId('accept-invitation-page').textContent).toContain(
+ '您受邀以 管理员 身份加入 Acme。',
+ );
+ });
+
+ it('an unknown role degrades to the server value rather than blanking', async () => {
+ // Same principle as family 2: prefer mapped, degrade to verbatim. The
+ // membership vocabulary is closed (ADR-0108) but `member.role` is a server
+ // string, so the display must never swallow one it does not recognize.
+ getMembers.mockResolvedValue([
+ { id: 'm-9', role: 'auditor', userId: 'u-9', user: { name: 'Zed', email: 'z@x.test' } },
+ ]);
+ renderIn('zh', );
+ await waitFor(() => expect(screen.getByTestId('members-page')).toBeInTheDocument());
+ expect(screen.getByTestId('member-role-badge-m-9')).toHaveTextContent('auditor');
+ });
+
+ it('MUST NOT CHANGE — en still renders the correct English through the same channel', async () => {
+ renderIn('en', {}} />);
+ await waitFor(() => expect(screen.getByTestId('select')).toBeInTheDocument());
+ const labels = Array.from(screen.getByTestId('select').querySelectorAll('option'))
+ .filter((o) => o.getAttribute('value'))
+ .map((o) => o.textContent);
+ expect(labels).toEqual(['Owner', 'Admin', 'Delegated Admin', 'Member']);
+ });
+
+ it('MUST NOT CHANGE — en members badges read Owner / Member', async () => {
+ renderIn('en', );
+ await waitFor(() => expect(screen.getByTestId('members-page')).toBeInTheDocument());
+ expect(screen.getByTestId('member-role-badge-m-1')).toHaveTextContent('Owner');
+ expect(screen.getByTestId('member-role-badge-m-2')).toHaveTextContent('Member');
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Family 2 — server-echoed strings, mapped by `code`
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('#4474 family 2 — server errors are mapped by code, at the three sites', () => {
+ it('zh: the invite dialog inline error is Chinese — site 3', async () => {
+ inviteMember.mockRejectedValue(
+ authError(
+ 'YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION',
+ 'You are not allowed to invite users to this organization',
+ ),
+ );
+ renderIn('zh', {}} />);
+ await waitFor(() => expect(screen.getByTestId('invite-email-input')).toBeInTheDocument());
+ fireEvent.change(screen.getByTestId('invite-email-input'), { target: { value: 'p@x.test' } });
+ fireEvent.submit(document.querySelector('form')!);
+ await waitFor(() => expect(screen.getByTestId('invite-error')).toBeInTheDocument());
+ expect(screen.getByTestId('invite-error')).toHaveTextContent('您无权邀请用户加入该组织。');
+ });
+
+ it('zh: the create-workspace inline error is Chinese — site 4', async () => {
+ createOrganization.mockRejectedValue(
+ authError('ORGANIZATION_ALREADY_EXISTS', 'Organization already exists'),
+ );
+ renderIn('zh', {}} />);
+ await waitFor(() => expect(screen.getByTestId('workspace-name-input')).toBeInTheDocument());
+ fireEvent.change(screen.getByTestId('workspace-name-input'), { target: { value: 'Acme' } });
+ fireEvent.submit(document.querySelector('form')!);
+ await waitFor(() => expect(screen.getByTestId('workspace-create-error')).toBeInTheDocument());
+ expect(screen.getByTestId('workspace-create-error')).toHaveTextContent('该组织已存在。');
+ });
+
+ it('zh: the accept-invitation toast body is Chinese — site 5', async () => {
+ acceptInvitation.mockRejectedValue(
+ authError(
+ 'YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION',
+ 'You are not the recipient of the invitation',
+ ),
+ );
+ renderIn('zh', );
+ await waitFor(() => expect(screen.getByTestId('accept-invitation-page')).toBeInTheDocument());
+ fireEvent.click(screen.getByText('接受邀请'));
+ await waitFor(() => expect(toastError).toHaveBeenCalled());
+ expect(toastError).toHaveBeenCalledWith('您不是该邀请的收件人。');
+ });
+
+ it('an UNMAPPED code degrades to the server message verbatim, never swallowed', async () => {
+ inviteMember.mockRejectedValue(
+ authError('SOME_CODE_THIS_CONSOLE_HAS_NEVER_HEARD_OF', 'Something specific went wrong'),
+ );
+ renderIn('zh', {}} />);
+ await waitFor(() => expect(screen.getByTestId('invite-email-input')).toBeInTheDocument());
+ fireEvent.change(screen.getByTestId('invite-email-input'), { target: { value: 'p@x.test' } });
+ fireEvent.submit(document.querySelector('form')!);
+ await waitFor(() => expect(screen.getByTestId('invite-error')).toBeInTheDocument());
+ expect(screen.getByTestId('invite-error')).toHaveTextContent('Something specific went wrong');
+ });
+
+ it('MUST NOT CHANGE — en renders the same English the server sent', async () => {
+ inviteMember.mockRejectedValue(
+ authError(
+ 'YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION',
+ 'You are not allowed to invite users to this organization',
+ ),
+ );
+ renderIn('en', {}} />);
+ await waitFor(() => expect(screen.getByTestId('invite-email-input')).toBeInTheDocument());
+ fireEvent.change(screen.getByTestId('invite-email-input'), { target: { value: 'p@x.test' } });
+ fireEvent.submit(document.querySelector('form')!);
+ await waitFor(() => expect(screen.getByTestId('invite-error')).toBeInTheDocument());
+ expect(screen.getByTestId('invite-error')).toHaveTextContent(
+ 'You are not allowed to invite users to this organization',
+ );
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Family 3 — icon-only aria-labels
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('#4474 family 3 — icon-only buttons carry a translated aria-label — site 6', () => {
+ it('zh: the members row action trigger', async () => {
+ renderIn('zh', );
+ await waitFor(() => expect(screen.getByTestId('members-page')).toBeInTheDocument());
+ // Pre-fix: aria-label="Member actions" — the ONLY label a screen reader gets.
+ expect(screen.getAllByLabelText('成员操作').length).toBeGreaterThan(0);
+ });
+
+ it('zh: the invitation row copy and cancel triggers', async () => {
+ renderIn('zh', );
+ await waitFor(() => expect(screen.getByTestId('invitations-page')).toBeInTheDocument());
+ // Pre-fix: aria-label="Copy invitation link" / "Cancel invitation".
+ expect(screen.getByLabelText('复制邀请链接')).toBeInTheDocument();
+ expect(screen.getByLabelText('取消邀请')).toBeInTheDocument();
+ });
+
+ it('zh: the share-link copy button in the invite dialog — sibling holdout', async () => {
+ inviteMember.mockResolvedValue({ id: 'inv-9', email: 'p@x.test', role: 'member' });
+ renderIn('zh', {}} />);
+ await waitFor(() => expect(screen.getByTestId('invite-email-input')).toBeInTheDocument());
+ fireEvent.change(screen.getByTestId('invite-email-input'), { target: { value: 'p@x.test' } });
+ fireEvent.submit(document.querySelector('form')!);
+ // Pre-fix this button carried a bare aria-label="Copy" — a seventh
+ // icon-only site the card's table did not reach.
+ await waitFor(() => expect(screen.getByLabelText('复制邀请链接')).toBeInTheDocument());
+ });
+
+ it('MUST NOT CHANGE — en aria-labels stay the English the card measured', async () => {
+ renderIn('en', );
+ await waitFor(() => expect(screen.getByTestId('invitations-page')).toBeInTheDocument());
+ expect(screen.getByLabelText('Copy invitation link')).toBeInTheDocument();
+ expect(screen.getByLabelText('Cancel invitation')).toBeInTheDocument();
+ });
+});
diff --git a/packages/app-shell/src/console/organizations/__tests__/orgErrorMessage-4474.test.ts b/packages/app-shell/src/console/organizations/__tests__/orgErrorMessage-4474.test.ts
new file mode 100644
index 000000000..9e2a67fd6
--- /dev/null
+++ b/packages/app-shell/src/console/organizations/__tests__/orgErrorMessage-4474.test.ts
@@ -0,0 +1,188 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectui#4474 family 2 — the code -> message mapping, unit level.
+ *
+ * ## Why a `code` and never the text
+ *
+ * The three server-echoed sites the card measured render better-auth's English
+ * `message` verbatim. Translating that by matching the English string would bind
+ * the console to a third party's copy-editing: better-auth is free to reword
+ * "Organization already exists" in any release, and a string match would go
+ * silently English again with nothing red. The `code` is the stable half of the
+ * pair — `ORGANIZATION_ALREADY_EXISTS` is the identifier, the sentence is its
+ * rendering — so the mapping is keyed by the code and the text is never read.
+ *
+ * ## Prefer mapped, degrade to verbatim
+ *
+ * The shape is the repo's existing one, not a new invention: `LoginForm` and
+ * `RegisterForm` already resolve `(code && map[code]) || error.message`. An
+ * unmapped code must still show the server's own sentence — English beats a
+ * blank box or a generic "something went wrong", both of which destroy the only
+ * information the user has. Both directions are pinned below, because only one
+ * of them is the interesting failure: a mapping that swallows unknown codes
+ * passes every "known code" test there is.
+ *
+ * ## Codes covered, and why exactly these
+ *
+ * Each entry is a rejection the five console files can actually provoke — not a
+ * mirror of better-auth's 60-code table. Speculative rows would be ten pack
+ * translations apiece for a screen that cannot reach them, and an uncovered code
+ * already degrades correctly, so the cost of NOT listing one is bounded.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ ORG_ERROR_MESSAGE_KEYS,
+ resolveOrgErrorMessage,
+} from '../orgErrorMessage';
+import { builtInLocales } from '@object-ui/i18n';
+
+const zh = builtInLocales.zh;
+
+/**
+ * A translator with the real zh pack behind it — the mapping helper's contract
+ * is "return what `t` gives for the key I chose", and a stub that echoes keys
+ * could not tell a right key from a wrong one.
+ */
+const at = (path: string): unknown =>
+ path.split('.').reduce((n, k) => (n as Record | undefined)?.[k], zh);
+
+const tZh = (key: string, options?: Record): string => {
+ const hit = at(key);
+ return typeof hit === 'string' ? hit : String(options?.defaultValue ?? key);
+};
+
+/** `t` that resolves nothing — stands in for a pack that lacks the key. */
+const tEn = (key: string, options?: Record): string =>
+ String(options?.defaultValue ?? key);
+
+const withCode = (code: string, message: string): Error => {
+ const err = new Error(message) as Error & { code?: string };
+ err.code = code;
+ return err;
+};
+
+describe('#4474 — resolveOrgErrorMessage', () => {
+ it('maps a KNOWN code to the zh message, ignoring the English text entirely', () => {
+ expect(
+ resolveOrgErrorMessage(
+ withCode(
+ 'YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION',
+ 'You are not allowed to invite users to this organization',
+ ),
+ tZh,
+ ),
+ ).toBe('您无权邀请用户加入该组织。');
+ expect(
+ resolveOrgErrorMessage(
+ withCode('ORGANIZATION_ALREADY_EXISTS', 'Organization already exists'),
+ tZh,
+ ),
+ ).toBe('该组织已存在。');
+ expect(
+ resolveOrgErrorMessage(
+ withCode(
+ 'YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION',
+ 'You are not the recipient of the invitation',
+ ),
+ tZh,
+ ),
+ ).toBe('您不是该邀请的收件人。');
+ });
+
+ it('reads the code, not the sentence — a reworded message maps identically', () => {
+ // The whole reason the mapping is code-keyed. If better-auth rewrites this
+ // sentence tomorrow, the console still says the right thing in zh.
+ expect(
+ resolveOrgErrorMessage(
+ withCode('ORGANIZATION_ALREADY_EXISTS', 'That workspace name is taken already'),
+ tZh,
+ ),
+ ).toBe('该组织已存在。');
+ });
+
+ it('an English sentence with NO code is passed through, never guessed at', () => {
+ // The inverse of the rule above: matching the text would "translate" this
+ // one, which is exactly the behaviour the ruling forbids.
+ expect(resolveOrgErrorMessage(new Error('Organization already exists'), tZh)).toBe(
+ 'Organization already exists',
+ );
+ });
+
+ it('an UNKNOWN code degrades to the server message verbatim', () => {
+ expect(
+ resolveOrgErrorMessage(
+ withCode('TEAM_MEMBER_LIMIT_REACHED', 'Team member limit reached'),
+ tZh,
+ ),
+ ).toBe('Team member limit reached');
+ });
+
+ it('a known code whose pack entry is missing still yields the English default', () => {
+ // `tEn` resolves nothing, so this exercises the defaultValue leg — the pack
+ // being incomplete must not produce a bare i18n key on screen.
+ expect(
+ resolveOrgErrorMessage(
+ withCode('ORGANIZATION_ALREADY_EXISTS', 'Organization already exists'),
+ tEn,
+ ),
+ ).toBe('Organization already exists');
+ });
+
+ it('a non-Error rejection falls back to a translated generic, not "[object Object]"', () => {
+ expect(resolveOrgErrorMessage({ nope: true }, tZh)).toBe(
+ at('organization.errors.unknown'),
+ );
+ expect(resolveOrgErrorMessage(undefined, tZh)).toBe(at('organization.errors.unknown'));
+ });
+
+ it('an Error with an empty message falls back rather than rendering a blank box', () => {
+ expect(resolveOrgErrorMessage(new Error(''), tZh)).toBe(at('organization.errors.unknown'));
+ expect(resolveOrgErrorMessage(new Error(' '), tZh)).toBe(at('organization.errors.unknown'));
+ });
+});
+
+describe('#4474 — the mapping table itself', () => {
+ it('every mapped code resolves to a key the zh pack actually defines', () => {
+ // Guards the class of typo that only shows up as a raw i18n key on screen,
+ // in a locale the author does not read.
+ for (const [code, entry] of Object.entries(ORG_ERROR_MESSAGE_KEYS)) {
+ expect(typeof at(entry.key), `${code} -> ${entry.key} missing from zh`).toBe('string');
+ expect((at(entry.key) as string).length, `${entry.key} is empty`).toBeGreaterThan(0);
+ }
+ });
+
+ it('every mapped code is a real better-auth organization code, spelled exactly', () => {
+ // A code with a typo is unreachable: it maps nothing and degrades forever,
+ // which reads as "the translation is missing" rather than "the key is wrong".
+ expect(Object.keys(ORG_ERROR_MESSAGE_KEYS).sort()).toEqual(
+ [
+ 'INVITATION_NOT_FOUND',
+ 'ORGANIZATION_ALREADY_EXISTS',
+ 'ORGANIZATION_SLUG_ALREADY_TAKEN',
+ 'USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION',
+ 'YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION',
+ 'YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION',
+ 'YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE',
+ 'YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION',
+ ].sort(),
+ );
+ });
+
+ it('the three codes the card measured are all present', () => {
+ // Named separately from the set above so a future trim of the table cannot
+ // quietly drop one of the sites the issue was filed for.
+ expect(ORG_ERROR_MESSAGE_KEYS).toHaveProperty(
+ 'YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION',
+ );
+ expect(ORG_ERROR_MESSAGE_KEYS).toHaveProperty('ORGANIZATION_ALREADY_EXISTS');
+ expect(ORG_ERROR_MESSAGE_KEYS).toHaveProperty('YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION');
+ });
+
+ it('every English defaultValue is non-empty — the fallback leg is real', () => {
+ for (const [code, entry] of Object.entries(ORG_ERROR_MESSAGE_KEYS)) {
+ expect(entry.defaultValue.trim().length, `${code} has an empty defaultValue`).toBeGreaterThan(0);
+ }
+ });
+});
diff --git a/packages/app-shell/src/console/organizations/manage/AcceptInvitationPage.tsx b/packages/app-shell/src/console/organizations/manage/AcceptInvitationPage.tsx
index 170446554..32dd81fb0 100644
--- a/packages/app-shell/src/console/organizations/manage/AcceptInvitationPage.tsx
+++ b/packages/app-shell/src/console/organizations/manage/AcceptInvitationPage.tsx
@@ -17,6 +17,8 @@ import type { AuthInvitation } from '@object-ui/auth';
import { useObjectTranslation } from '@object-ui/i18n';
import { Loader2, Building2, CheckCircle, XCircle } from 'lucide-react';
import { toast } from 'sonner';
+import { resolveOrgRoleLabel } from '../orgRoleLabel';
+import { resolveOrgErrorMessage } from '../orgErrorMessage';
type InvitationWithOrg = AuthInvitation & {
organizationName?: string;
@@ -65,7 +67,12 @@ export function AcceptInvitationPage() {
})
.catch((err) => {
if (!cancelled)
- setError(err instanceof Error ? err.message : 'Invitation not found or expired');
+ setError(
+ resolveOrgErrorMessage(err, t, {
+ key: 'organization.errors.invitationNotFound',
+ defaultValue: 'This invitation no longer exists or has expired',
+ }),
+ );
})
.finally(() => {
if (!cancelled) setIsLoading(false);
@@ -73,7 +80,7 @@ export function AcceptInvitationPage() {
return () => {
cancelled = true;
};
- }, [invitationId, isAuthenticated, getInvitation]);
+ }, [invitationId, isAuthenticated, getInvitation, t]);
const handleAccept = async () => {
if (!invitation || !invitationId) return;
@@ -84,10 +91,13 @@ export function AcceptInvitationPage() {
toast.success(t('organization.accept.accepted', { defaultValue: 'Invitation accepted' }));
navigate('/home');
} catch (err) {
+ // objectui#4474 — the card's site 5: a wrong recipient produced better-auth's
+ // English sentence under the translated title. Mapped by `code` now.
toast.error(
- err instanceof Error
- ? err.message
- : t('organization.accept.acceptFailed', { defaultValue: 'Failed to accept invitation' }),
+ resolveOrgErrorMessage(err, t, {
+ key: 'organization.accept.acceptFailed',
+ defaultValue: 'Failed to accept invitation',
+ }),
);
setIsAccepting(false);
}
@@ -102,9 +112,10 @@ export function AcceptInvitationPage() {
navigate('/organizations');
} catch (err) {
toast.error(
- err instanceof Error
- ? err.message
- : t('organization.accept.declineFailed', { defaultValue: 'Failed to decline invitation' }),
+ resolveOrgErrorMessage(err, t, {
+ key: 'organization.accept.declineFailed',
+ defaultValue: 'Failed to decline invitation',
+ }),
);
setIsDeclining(false);
}
@@ -158,7 +169,7 @@ export function AcceptInvitationPage() {
defaultValue:
'You have been invited to join {{orgName}} as {{role}}.',
orgName: invitation.organizationName ?? invitation.organizationId,
- role: invitation.role,
+ role: resolveOrgRoleLabel(invitation.role, t),
})}