From e7102e1ddeaad8fdb0e17bfadcca7191a7ff04d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 20:30:42 +0000 Subject: [PATCH 1/2] fix(app-shell): organization & invitation UI translates its six English holdouts (#4474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three string families across `console/organizations/`, one sweep: 1. Role names route through the single shared ORG_ROLE_LABELS map at every site. The members/invitations badges and the accept page rendered the raw server identifier under CSS `capitalize`; the map's four `organization.roles.*` keys existed in no pack, so even the dropdown that did consult it resolved to English. All ten packs now carry them. 2. Server-echoed errors are mapped by better-auth's stable `code`, never by matching English text. `createAuthClient` dropped that code for every `organization.*` call while preserving it for sign-in/sign-up, so no consumer could have keyed on it; all sixteen now share `toAuthError`. Messages are unchanged. An unmapped code degrades to the server's sentence. 3. Icon-only aria-labels are translated — the only name a screen reader gets. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .../org-invitation-i18n-holdouts-4474.md | 27 ++ .../organizations/CreateWorkspaceDialog.tsx | 10 +- .../__tests__/org-i18n-holdouts-4474.test.tsx | 424 ++++++++++++++++++ .../__tests__/orgErrorMessage-4474.test.ts | 188 ++++++++ .../manage/AcceptInvitationPage.tsx | 33 +- .../organizations/manage/InvitationsPage.tsx | 37 +- .../manage/InviteMemberDialog.tsx | 33 +- .../organizations/manage/MembersPage.tsx | 51 ++- .../console/organizations/orgErrorMessage.ts | 165 +++++++ .../src/console/organizations/orgRoleLabel.ts | 84 ++++ .../src/__tests__/org-error-code-4474.test.ts | 124 +++++ packages/auth/src/createAuthClient.ts | 63 ++- packages/i18n/src/locales/ar.ts | 23 + packages/i18n/src/locales/de.ts | 23 + packages/i18n/src/locales/en.ts | 23 + packages/i18n/src/locales/es.ts | 23 + packages/i18n/src/locales/fr.ts | 23 + packages/i18n/src/locales/ja.ts | 23 + packages/i18n/src/locales/ko.ts | 23 + packages/i18n/src/locales/pt.ts | 23 + packages/i18n/src/locales/ru.ts | 23 + packages/i18n/src/locales/zh.ts | 23 + 22 files changed, 1411 insertions(+), 58 deletions(-) create mode 100644 .changeset/org-invitation-i18n-holdouts-4474.md create mode 100644 packages/app-shell/src/console/organizations/__tests__/org-i18n-holdouts-4474.test.tsx create mode 100644 packages/app-shell/src/console/organizations/__tests__/orgErrorMessage-4474.test.ts create mode 100644 packages/app-shell/src/console/organizations/orgErrorMessage.ts create mode 100644 packages/app-shell/src/console/organizations/orgRoleLabel.ts create mode 100644 packages/auth/src/__tests__/org-error-code-4474.test.ts diff --git a/.changeset/org-invitation-i18n-holdouts-4474.md b/.changeset/org-invitation-i18n-holdouts-4474.md new file mode 100644 index 0000000000..36b9280f52 --- /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/packages/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx b/packages/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx index 7875b4faaa..9b362710a4 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__/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 0000000000..7da0155155 --- /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 ?
{children}
: null), + DialogContent: passthrough('div'), + DialogDescription: passthrough('p'), + DialogFooter: passthrough('div'), + DialogHeader: passthrough('div'), + DialogTitle: passthrough('h2'), + Button: ({ children, ...rest }: any) => , + Input: (p: any) => , + Label: passthrough('label'), + Badge: ({ children, ...rest }: any) => {children}, + Avatar: passthrough('div'), + AvatarFallback: passthrough('div'), + AvatarImage: (p: any) => , + Separator: () =>
, + AlertDialog: ({ open, children }: any) => (open ?
{children}
: null), + AlertDialogAction: ({ children, ...rest }: any) => , + AlertDialogCancel: ({ children, ...rest }: any) => , + AlertDialogContent: passthrough('div'), + AlertDialogDescription: passthrough('p'), + AlertDialogFooter: passthrough('div'), + AlertDialogHeader: passthrough('div'), + AlertDialogTitle: passthrough('h2'), + DropdownMenu: ({ children }: any) =>
{children}
, + 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 0000000000..9e2a67fd6b --- /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 170446554a..32dd81fb05 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), })}

@@ -176,7 +187,9 @@ export function AcceptInvitationPage() { {t('organization.accept.role', { defaultValue: 'Role' })} - {invitation.role} + + {resolveOrgRoleLabel(invitation.role, t)} + {invitation.expiresAt && (
diff --git a/packages/app-shell/src/console/organizations/manage/InvitationsPage.tsx b/packages/app-shell/src/console/organizations/manage/InvitationsPage.tsx index 1b2c0a4964..012d37e910 100644 --- a/packages/app-shell/src/console/organizations/manage/InvitationsPage.tsx +++ b/packages/app-shell/src/console/organizations/manage/InvitationsPage.tsx @@ -27,6 +27,8 @@ import { Loader2, Copy, Check, X, Mail } from 'lucide-react'; import { toast } from 'sonner'; import { useOrgContext } from './orgContext'; import { resolveConsoleUrl } from '../resolveHomeUrl'; +import { resolveOrgRoleLabel } from '../orgRoleLabel'; +import { resolveOrgErrorMessage } from '../orgErrorMessage'; type StatusFilter = 'all' | 'pending' | 'accepted' | 'rejected' | 'canceled'; @@ -63,11 +65,16 @@ export function InvitationsPage() { const data = await listInvitations(org.id); setInvitations(data); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load invitations'); + setError( + resolveOrgErrorMessage(err, t, { + key: 'organization.invitations.loadFailed', + defaultValue: 'Failed to load invitations', + }), + ); } finally { setIsLoading(false); } - }, [org.id, listInvitations]); + }, [org.id, listInvitations, t]); useEffect(() => { fetchInvitations(); @@ -98,9 +105,10 @@ export function InvitationsPage() { fetchInvitations(); } catch (err) { toast.error( - err instanceof Error - ? err.message - : t('organization.invitations.cancelFailed', { defaultValue: 'Failed to cancel invitation' }), + resolveOrgErrorMessage(err, t, { + key: 'organization.invitations.cancelFailed', + defaultValue: 'Failed to cancel invitation', + }), ); } }; @@ -197,8 +205,15 @@ export function InvitationsPage() { )}
- - {inv.role} + {/* Same shared role map as the members badge and the invite + dropdown (objectui#4474) — this one was a sibling holdout the + card's table did not reach. */} + + {resolveOrgRoleLabel(inv.role, t)} @@ -212,7 +227,9 @@ export function InvitationsPage() { size="icon" className="h-8 w-8" onClick={() => handleCopyLink(inv)} - aria-label="Copy invitation link" + aria-label={t('organization.invitations.copyLinkLabel', { + defaultValue: 'Copy invitation link', + })} > {copiedId === inv.id ? ( @@ -225,7 +242,9 @@ export function InvitationsPage() { size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => setCancelingInvitation(inv)} - aria-label="Cancel invitation" + aria-label={t('organization.invitations.cancelAction', { + defaultValue: 'Cancel invitation', + })} > diff --git a/packages/app-shell/src/console/organizations/manage/InviteMemberDialog.tsx b/packages/app-shell/src/console/organizations/manage/InviteMemberDialog.tsx index 5b2f9c825b..f98e736c0f 100644 --- a/packages/app-shell/src/console/organizations/manage/InviteMemberDialog.tsx +++ b/packages/app-shell/src/console/organizations/manage/InviteMemberDialog.tsx @@ -23,12 +23,14 @@ import { SelectTrigger, SelectValue, } from '@object-ui/components'; -import { useAuth, invitableOrgRoles, ORG_ROLE_LABELS, ORG_ROLE_MEMBER } from '@object-ui/auth'; +import { useAuth, invitableOrgRoles, ORG_ROLE_MEMBER } from '@object-ui/auth'; import type { AuthInvitation, DelegableScope, OrgRole } from '@object-ui/auth'; import { useObjectTranslation } from '@object-ui/i18n'; import { Loader2, Copy, Check } from 'lucide-react'; import { toast } from 'sonner'; import { resolveConsoleUrl } from '../resolveHomeUrl'; +import { resolveOrgRoleLabel } from '../orgRoleLabel'; +import { resolveOrgErrorMessage } from '../orgErrorMessage'; interface InviteMemberDialogProps { organizationId: string; @@ -125,12 +127,19 @@ export function InviteMemberDialog({ setCreatedInvitation(inv); onInvited?.(inv); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to invite member'); + // objectui#4474 — better-auth's refusal carries a machine `code` beside + // its English sentence; map the code, never the text. + setError( + resolveOrgErrorMessage(err, t, { + key: 'organization.invitations.inviteFailed', + defaultValue: 'Failed to invite member', + }), + ); } finally { setIsSubmitting(false); } }, - [email, role, organizationId, inviteMember, onInvited, canPlace, businessUnitId, positions], + [email, role, organizationId, inviteMember, onInvited, canPlace, businessUnitId, positions, t], ); // The invitee opens this link outside React Router, so it must carry the @@ -178,7 +187,19 @@ export function InviteMemberDialog({ className="font-mono text-xs" onFocus={(e) => e.currentTarget.select()} /> - @@ -186,7 +207,7 @@ export function InviteMemberDialog({ {t('organization.invitations.invitedAs', { defaultValue: '{{email}} invited as {{role}}', email: createdInvitation.email, - role: createdInvitation.role, + role: resolveOrgRoleLabel(createdInvitation.role, t), })}

@@ -235,7 +256,7 @@ export function InviteMemberDialog({ {roleOptions.map((r) => ( - {t(ORG_ROLE_LABELS[r].key, { defaultValue: ORG_ROLE_LABELS[r].defaultValue })} + {resolveOrgRoleLabel(r, t)} ))} diff --git a/packages/app-shell/src/console/organizations/manage/MembersPage.tsx b/packages/app-shell/src/console/organizations/manage/MembersPage.tsx index 08b40e81a7..c26850a951 100644 --- a/packages/app-shell/src/console/organizations/manage/MembersPage.tsx +++ b/packages/app-shell/src/console/organizations/manage/MembersPage.tsx @@ -32,6 +32,8 @@ import { Loader2, MoreHorizontal, UserMinus, ShieldCheck } from 'lucide-react'; import { toast } from 'sonner'; import { useOrgContext } from './orgContext'; import { InviteMemberDialog } from './InviteMemberDialog'; +import { resolveOrgRoleLabel } from '../orgRoleLabel'; +import { resolveOrgErrorMessage } from '../orgErrorMessage'; function getMemberInitials(name?: string): string { if (!name) return '?'; @@ -63,11 +65,16 @@ export function MembersPage() { const data = await getMembers(org.id); setMembers(data); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load members'); + setError( + resolveOrgErrorMessage(err, t, { + key: 'organization.members.loadFailed', + defaultValue: 'Failed to load members', + }), + ); } finally { setIsLoading(false); } - }, [org.id, getMembers]); + }, [org.id, getMembers, t]); useEffect(() => { fetchMembers(); @@ -80,9 +87,10 @@ export function MembersPage() { fetchMembers(); } catch (err) { toast.error( - err instanceof Error - ? err.message - : t('organization.members.roleUpdateFailed', { defaultValue: 'Failed to update role' }), + resolveOrgErrorMessage(err, t, { + key: 'organization.members.roleUpdateFailed', + defaultValue: 'Failed to update role', + }), ); } }; @@ -96,9 +104,10 @@ export function MembersPage() { fetchMembers(); } catch (err) { toast.error( - err instanceof Error - ? err.message - : t('organization.members.removeFailed', { defaultValue: 'Failed to remove member' }), + resolveOrgErrorMessage(err, t, { + key: 'organization.members.removeFailed', + defaultValue: 'Failed to remove member', + }), ); } }; @@ -156,8 +165,19 @@ export function MembersPage() {
{member.user?.email ?? '—'}
- - {member.role} + {/* objectui#4474 — the role badge used to render the raw server + identifier under CSS `capitalize`, which made `owner` look like + a label in English and left it untranslated everywhere else. It + now reads the same shared map the role dropdown and the + role-change menu below already read, so one role has one name + across the whole feature. `capitalize` goes with it: the map's + values are already cased. */} + + {resolveOrgRoleLabel(member.role, t)} {member.createdAt && ( @@ -168,7 +188,16 @@ export function MembersPage() { - diff --git a/packages/app-shell/src/console/organizations/orgErrorMessage.ts b/packages/app-shell/src/console/organizations/orgErrorMessage.ts new file mode 100644 index 0000000000..2ce23f2613 --- /dev/null +++ b/packages/app-shell/src/console/organizations/orgErrorMessage.ts @@ -0,0 +1,165 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * ONE `code` -> message mapping for every server-echoed organization error + * (objectui#4474). + * + * ## The stable half of the pair + * + * better-auth answers a refused organization call with BOTH an English sentence + * and a machine `code` (`ORGANIZATION_ALREADY_EXISTS`, + * `YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION`, …). Only the code is stable: + * the sentence is a third party's copy, free to be reworded in any release. So + * this maps the code and never reads the text. Translating by matching the + * English would go silently English again the day upstream rewrites a string, + * with no test able to see it — which is the failure mode the ruling forbids by + * name. + * + * ## The code has to survive the client boundary to be readable here + * + * It did not, before this change. `packages/auth/src/createAuthClient.ts` had a + * `toAuthError` helper that preserves `code` — used by sign-in and sign-up only — + * while every `organization.*` method threw `new Error(error.message ?? '…')` and + * dropped it. The three sites in the card therefore held an English sentence and + * nothing else, so no consumer-side cleverness short of string matching could + * have localized them. That producer now routes through `toAuthError` too; this + * module is the consumer half, and the two are useless apart. + * + * ## Prefer mapped, degrade to verbatim + * + * The shape mirrors what `LoginForm`/`RegisterForm` already do — + * `(code && map[code]) || error.message`. An unmapped code still shows the + * server's own sentence: English is worse than Chinese and far better than a + * blank box or a generic "something went wrong", both of which delete the only + * diagnostic the user has. Never swallow an error. + * + * ## Why these eight codes and not better-auth's sixty + * + * Every row is a rejection the five console files under + * `console/organizations/` can actually provoke — the three the card measured, + * plus five siblings on the same three call paths (the role-cap refusal that + * `invitableOrgRoles` narrows for but the server re-checks; a duplicate invite; + * the slug collision that is the create dialog's other outcome; the + * multi-org-disabled refusal `CreateWorkspaceDialog` already comments about; and + * the expired link `AcceptInvitationPage` already has a hand-written fallback + * for). The other ~50 are teams, dynamic roles and org surfaces this console has + * no screen for. Each row costs ten pack translations, and an unlisted code + * already degrades correctly, so the cost of omitting one is bounded and the + * cost of speculating is not. + */ + +/** + * The translate function shape this module needs — structurally what + * `useObjectTranslation().t` and `createSafeTranslation()` both provide. + */ +export type OrgTranslate = (key: string, options?: Record) => string; + +export interface OrgErrorEntry { + /** i18n key in the `organization.errors` namespace. */ + key: string; + /** English fallback, for a pack that does not (yet) define the key. */ + defaultValue: string; +} + +/** + * better-auth organization error code -> the console's own i18n key. + * + * Keyed by the code, valued by a key of OUR naming rather than a transliteration + * of theirs: the pack vocabulary stays camelCase like every other namespace, and + * an upstream constant rename becomes a one-line edit here instead of a rename + * across ten packs. + */ +export const ORG_ERROR_MESSAGE_KEYS: Record = { + // --- invite path (InviteMemberDialog) --- + YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION: { + key: 'organization.errors.notAllowedToInvite', + defaultValue: 'You are not allowed to invite users to this organization', + }, + YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE: { + key: 'organization.errors.notAllowedToInviteWithRole', + defaultValue: 'You are not allowed to invite a user with this role', + }, + USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION: { + key: 'organization.errors.alreadyInvited', + defaultValue: 'This user has already been invited to this organization', + }, + + // --- create-workspace path (CreateWorkspaceDialog) --- + ORGANIZATION_ALREADY_EXISTS: { + key: 'organization.errors.organizationExists', + defaultValue: 'Organization already exists', + }, + ORGANIZATION_SLUG_ALREADY_TAKEN: { + key: 'organization.errors.slugTaken', + defaultValue: 'That URL slug is already taken', + }, + YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION: { + key: 'organization.errors.notAllowedToCreate', + defaultValue: 'You are not allowed to create a new organization', + }, + + // --- accept path (AcceptInvitationPage) --- + YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION: { + key: 'organization.errors.notTheRecipient', + defaultValue: 'You are not the recipient of the invitation', + }, + INVITATION_NOT_FOUND: { + key: 'organization.errors.invitationNotFound', + defaultValue: 'This invitation no longer exists or has expired', + }, +}; + +/** Last-resort text when the rejection carries neither a known code nor a message. */ +const UNKNOWN_ERROR = { + key: 'organization.errors.unknown', + defaultValue: 'Something went wrong. Please try again.', +}; + +/** Read the machine code off a thrown value, if it carries one. */ +function errorCode(err: unknown): string | undefined { + if (typeof err !== 'object' || err === null) return undefined; + const code = (err as { code?: unknown }).code; + return typeof code === 'string' && code ? code : undefined; +} + +/** + * Resolve a rejected organization call into the message to show the user. + * + * Order — mapped code, then the server's own sentence, then the site's own + * fallback, then a translated last resort: + * + * 1. a code this console maps -> the localized message + * 2. any other `Error` message -> verbatim, whatever language it is in + * 3. `fallback`, when given -> the caller's own localized sentence + * 4. none of the above -> `organization.errors.unknown` + * + * Step 3 exists so adopting this helper cannot *lose* information: several call + * sites already carried a specific translated fallback ("Failed to update role") + * for the non-`Error` branch, and collapsing those into one generic sentence + * would have been a regression bought with the fix. + * + * @param err - the value a rejected auth-client promise threw + * @param t - the active translator + * @param fallback - the caller's own message for a rejection carrying no text + */ +export function resolveOrgErrorMessage( + err: unknown, + t: OrgTranslate, + fallback?: OrgErrorEntry, +): string { + const code = errorCode(err); + if (code) { + const entry = ORG_ERROR_MESSAGE_KEYS[code]; + if (entry) return t(entry.key, { defaultValue: entry.defaultValue }); + } + const message = err instanceof Error ? err.message : undefined; + if (typeof message === 'string' && message.trim()) return message; + if (fallback) return t(fallback.key, { defaultValue: fallback.defaultValue }); + return t(UNKNOWN_ERROR.key, { defaultValue: UNKNOWN_ERROR.defaultValue }); +} diff --git a/packages/app-shell/src/console/organizations/orgRoleLabel.ts b/packages/app-shell/src/console/organizations/orgRoleLabel.ts new file mode 100644 index 0000000000..62acc8d171 --- /dev/null +++ b/packages/app-shell/src/console/organizations/orgRoleLabel.ts @@ -0,0 +1,84 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * ONE display name per membership role, for every organization screen + * (objectui#4474). + * + * ## What was actually drifting + * + * The card describes "two hand-authored lists that disagree" — `Owner`/`Member` + * badges beside a 所有者/成员 dropdown. Measured on `origin/main`, the shape is one + * step worse than that: there is only ONE authored list, + * `ORG_ROLE_LABELS` in `@object-ui/auth`, and the dropdown and the role-change + * menu both read it correctly. The badges read **nothing** — they render + * `member.role` / `inv.role`, the raw server identifier, under a CSS + * `capitalize` that turns `owner` into `Owner` and passes for a label. + * + * So the two sides never disagreed about a *translation*; one side was not + * translating at all, and CSS made that invisible in English. Collapsing them + * means routing every role display through this module, which is the map's only + * consumer-facing entry point. + * + * ## Why the keys resolved to English before this issue + * + * `ORG_ROLE_LABELS` names four i18n keys (`organization.roles.*`) that **no pack + * defined** — so every consumer fell through to its inline English + * `defaultValue`, including the dropdown the card reports as already Chinese. + * `scripts/check-i18n-call-site-keys.mjs` is exactly the gate for that class and + * it paid off 258 of them, but it scans string LITERALS and these are reached as + * `ORG_ROLE_LABELS[r].key` — a variable. The packs now carry the four keys; + * `packages/app-shell/src/console/organizations/__tests__/org-i18n-holdouts-4474.test.tsx` + * is the standing replacement for the coverage the indirection costs. + * + * ## Unknown roles degrade, they never blank + * + * The membership vocabulary is closed and framework-owned (ADR-0108 / #3723), so + * a role outside the four is not supposed to exist. `member.role` is still a + * server string, and the console's job when it does not recognize one is to show + * what the server said — the same "prefer mapped, degrade to verbatim" rule the + * error mapping next door follows. Rendering an empty badge, or guessing, would + * destroy the only information an operator has about that member. + * + * Deliberately NOT handled: the comma-joined form (`owner,admin`) that + * `orgRoleGrade` splits. That split exists to compute a fail-closed authority + * GRADE, which is a different question from what to print; a joined value here + * falls to the verbatim leg and shows `owner,admin`, which is honest. Inventing + * a localized list-joiner for a shape this surface has never been measured + * producing would be building vocabulary ahead of the need. + */ + +import { ORG_ROLE_LABELS } from '@object-ui/auth'; +import type { OrgRole } from '@object-ui/auth'; + +/** + * The translate function shape this module needs — structurally what + * `useObjectTranslation().t` and `createSafeTranslation()` both provide. + */ +export type OrgTranslate = (key: string, options?: Record) => string; + +/** True when `role` is one of the four framework-owned membership roles. */ +function isKnownRole(role: string): role is OrgRole { + return Object.prototype.hasOwnProperty.call(ORG_ROLE_LABELS, role); +} + +/** + * Resolve a role identifier to its display name in the active locale. + * + * @param role - the identifier as the SERVER spells it (`sys_member.role`) + * @param t - the active translator + * @returns the localized name, or the raw identifier when it is not a known role + */ +export function resolveOrgRoleLabel(role: unknown, t: OrgTranslate): string { + if (typeof role !== 'string') return ''; + const trimmed = role.trim(); + if (!trimmed) return ''; + if (!isKnownRole(trimmed)) return trimmed; + const label = ORG_ROLE_LABELS[trimmed]; + return t(label.key, { defaultValue: label.defaultValue }); +} diff --git a/packages/auth/src/__tests__/org-error-code-4474.test.ts b/packages/auth/src/__tests__/org-error-code-4474.test.ts new file mode 100644 index 0000000000..0f92289437 --- /dev/null +++ b/packages/auth/src/__tests__/org-error-code-4474.test.ts @@ -0,0 +1,124 @@ +/** + * objectui#4474 — the machine `code` survives the organization client boundary. + * + * ## Why this file exists separately from the console's mapping test + * + * Family 2 of #4474 is a two-link chain: better-auth answers a refused call with + * `{ message, code }`, `createAuthClient` turns that into a thrown `Error`, and + * the console maps `.code` to a translated sentence. The console test + * (`packages/app-shell/src/console/organizations/__tests__/org-i18n-holdouts-4474.test.tsx`) + * mocks `useAuth` and therefore constructs its own coded error — it pins the + * SECOND link and is structurally blind to the first. Without this file the + * producer could be reverted to `new Error(error.message ?? '…')`, the console + * suite would stay entirely green, and the feature would be dead in the browser: + * every code arrives `undefined`, every message degrades to verbatim English, + * and nothing anywhere goes red. + * + * So this drives the REAL better-auth client through a mocked `fetch`, the same + * way `createAuthClient.test.ts` next door does. The assertion is on `.code`, + * because `.message` was already correct before the fix and cannot discriminate. + * + * ## What was wrong + * + * `toAuthError` — which preserves `code` — was wired to sign-in and sign-up + * only. All sixteen `organization.*` methods threw a bare `Error`, dropping the + * code at the one boundary that had it. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { createAuthClient } from '../createAuthClient'; + +/** A mocked fetch that answers one path with a better-auth error envelope. */ +function rejectingFetch(pathFragment: string, status: number, body: unknown) { + return vi.fn(async (input: string | URL | Request) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes(pathFragment)) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); +} + +const clientWith = (fetchFn: ReturnType) => + createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: fetchFn as unknown as typeof fetch }); + +/** Read `.code` off a rejection the way the console's mapping helper does. */ +async function codeOf(promise: Promise): Promise<{ code?: string; message: string }> { + try { + await promise; + throw new Error('expected the call to reject, but it resolved'); + } catch (err) { + const e = err as Error & { code?: string }; + return { code: e.code, message: e.message }; + } +} + +describe('#4474 — organization rejections carry the machine code', () => { + it('inviteMember preserves YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION', async () => { + const client = clientWith( + rejectingFetch('/organization/invite-member', 403, { + message: 'You are not allowed to invite users to this organization', + code: 'YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION', + }), + ); + const { code, message } = await codeOf( + client.inviteMember({ organizationId: 'org-1', email: 'p@x.test', role: 'member' }), + ); + expect(code).toBe('YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION'); + // MUST NOT CHANGE: the message is exactly what it was before the fix. This + // change adds the code; it does not reword anything. + expect(message).toBe('You are not allowed to invite users to this organization'); + }); + + it('createOrganization preserves ORGANIZATION_ALREADY_EXISTS', async () => { + const client = clientWith( + rejectingFetch('/organization/create', 400, { + message: 'Organization already exists', + code: 'ORGANIZATION_ALREADY_EXISTS', + }), + ); + const { code, message } = await codeOf( + client.createOrganization({ name: 'Acme', slug: 'acme' }), + ); + expect(code).toBe('ORGANIZATION_ALREADY_EXISTS'); + expect(message).toBe('Organization already exists'); + }); + + it('acceptInvitation preserves YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION', async () => { + const client = clientWith( + rejectingFetch('/organization/accept-invitation', 403, { + message: 'You are not the recipient of the invitation', + code: 'YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION', + }), + ); + const { code, message } = await codeOf(client.acceptInvitation('inv-1')); + expect(code).toBe('YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION'); + expect(message).toBe('You are not the recipient of the invitation'); + }); + + it('a rejection with no code still throws the server message, unchanged', async () => { + // The degrade leg at the producer: an error envelope without a `code` must + // not grow one, and must keep its sentence for the console to show verbatim. + const client = clientWith( + rejectingFetch('/organization/accept-invitation', 500, { message: 'Upstream exploded' }), + ); + const { code, message } = await codeOf(client.acceptInvitation('inv-1')); + expect(code).toBeUndefined(); + expect(message).toBe('Upstream exploded'); + }); + + it('an empty error envelope falls back to the method fallback message', async () => { + // `toAuthError`'s new second argument. Before #4474 each org method spelled + // this fallback inline; the behaviour is preserved exactly. + const client = clientWith(rejectingFetch('/organization/accept-invitation', 500, {})); + const { message } = await codeOf(client.acceptInvitation('inv-1')); + expect(message).toBe('Failed to accept invitation'); + }); +}); diff --git a/packages/auth/src/createAuthClient.ts b/packages/auth/src/createAuthClient.ts index f3739c48e9..8bd1cadc95 100644 --- a/packages/auth/src/createAuthClient.ts +++ b/packages/auth/src/createAuthClient.ts @@ -62,13 +62,34 @@ interface BetterAuthErrorLike { /** * Build an `Error` from a better-auth client error, preserving the machine - * `code` on the thrown Error so callers (LoginForm/RegisterForm) can map it to - * a localized message instead of surfacing the raw English server text. Falls - * back to the server message, then the HTTP status. + * `code` on the thrown Error so callers (LoginForm/RegisterForm, and every + * organization screen) can map it to a localized message instead of surfacing + * the raw English server text. Falls back to the server message, then + * `fallbackMessage`, then the HTTP status. + * + * ## Why every organization route now comes through here (objectui#4474) + * + * This helper existed for sign-in/sign-up only. Every `organization.*` method + * below threw `new Error(error.message ?? '…')` directly and therefore **dropped + * `code` at the boundary** — so a console screen holding the rejection had the + * English sentence and nothing else to key on. That is what forced the + * organization UI to echo better-auth's English verbatim in a zh session: the + * only remaining way to localize it would have been matching the English text, + * which binds the console to a third party's copy-editing and breaks silently + * the day they reword a sentence. + * + * The repair belongs HERE rather than at the console call sites: the code is + * produced at this boundary, and a consumer cannot recover information the + * producer discarded. `message` is unchanged for every caller — this only stops + * throwing away the half of the pair that was already being computed. + * `packages/auth/src/__tests__/org-error-code-4474.test.ts` pins both halves. */ -function toAuthError(error: BetterAuthErrorLike): Error & { code?: string } { +function toAuthError( + error: BetterAuthErrorLike, + fallbackMessage?: string, +): Error & { code?: string } { const err = new Error( - error.message ?? `Auth request failed with status ${error.status}`, + error.message ?? fallbackMessage ?? `Auth request failed with status ${error.status}`, ) as Error & { code?: string }; if (error.code) err.code = error.code; return err; @@ -718,7 +739,7 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { async listOrganizations(): Promise { const { data, error } = await (betterAuth as any).organization.list(); - if (error) throw new Error(error.message ?? 'Failed to list organizations'); + if (error) throw toAuthError(error, 'Failed to list organizations'); return (data ?? []) as AuthOrganization[]; }, @@ -728,7 +749,7 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { slug: orgData.slug, logo: orgData.logo, }); - if (error) throw new Error(error.message ?? 'Failed to create organization'); + if (error) throw toAuthError(error, 'Failed to create organization'); return data as unknown as AuthOrganization; }, @@ -736,7 +757,7 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { const { data, error } = await (betterAuth as any).organization.setActive({ organizationId: orgId, }); - if (error) throw new Error(error.message ?? 'Failed to set active organization'); + if (error) throw toAuthError(error, 'Failed to set active organization'); return (data ?? null) as AuthOrganization | null; }, @@ -764,7 +785,7 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { const { data, error } = await (betterAuth as any).organization.listMembers({ query: { organizationId: orgId }, }); - if (error) throw new Error(error.message ?? 'Failed to get members'); + if (error) throw toAuthError(error, 'Failed to get members'); const result = data as unknown as { members?: AuthOrganizationMember[] } | AuthOrganizationMember[]; if (Array.isArray(result)) return result; return (result?.members ?? []) as AuthOrganizationMember[]; @@ -789,7 +810,7 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { ...(inviteData.businessUnitId ? { businessUnitId: inviteData.businessUnitId } : {}), ...(inviteData.positions?.length ? { positions: inviteData.positions } : {}), }); - if (error) throw new Error(error.message ?? 'Failed to invite member'); + if (error) throw toAuthError(error, 'Failed to invite member'); return data as unknown as AuthInvitation; }, @@ -798,7 +819,7 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { organizationId: removeData.organizationId, memberIdOrUserId: removeData.memberIdOrUserId, }); - if (error) throw new Error(error.message ?? 'Failed to remove member'); + if (error) throw toAuthError(error, 'Failed to remove member'); }, async updateMemberRole(payload: { organizationId: string; memberId: string; role: string }): Promise { @@ -807,7 +828,7 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { memberId: payload.memberId, role: payload.role, }); - if (error) throw new Error(error.message ?? 'Failed to update member role'); + if (error) throw toAuthError(error, 'Failed to update member role'); }, async updateOrganization(orgId: string, orgData: Partial>): Promise { @@ -815,7 +836,7 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { organizationId: orgId, data: orgData, }); - if (error) throw new Error(error.message ?? 'Failed to update organization'); + if (error) throw toAuthError(error, 'Failed to update organization'); return data as unknown as AuthOrganization; }, @@ -823,14 +844,14 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { const { error } = await (betterAuth as any).organization.delete({ organizationId: orgId, }); - if (error) throw new Error(error.message ?? 'Failed to delete organization'); + if (error) throw toAuthError(error, 'Failed to delete organization'); }, async leaveOrganization(orgId: string): Promise { const { error } = await (betterAuth as any).organization.leave({ organizationId: orgId, }); - if (error) throw new Error(error.message ?? 'Failed to leave organization'); + if (error) throw toAuthError(error, 'Failed to leave organization'); }, // --- Invitation methods --- @@ -839,36 +860,36 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { const { data, error } = await (betterAuth as any).organization.listInvitations({ query: { organizationId: orgId }, }); - if (error) throw new Error(error.message ?? 'Failed to list invitations'); + if (error) throw toAuthError(error, 'Failed to list invitations'); return (data ?? []) as AuthInvitation[]; }, async cancelInvitation(invitationId: string): Promise { const { error } = await (betterAuth as any).organization.cancelInvitation({ invitationId }); - if (error) throw new Error(error.message ?? 'Failed to cancel invitation'); + if (error) throw toAuthError(error, 'Failed to cancel invitation'); }, async getInvitation(invitationId: string): Promise { const { data, error } = await (betterAuth as any).organization.getInvitation({ query: { id: invitationId }, }); - if (error) throw new Error(error.message ?? 'Failed to load invitation'); + if (error) throw toAuthError(error, 'Failed to load invitation'); return data as unknown as AuthInvitation; }, async acceptInvitation(invitationId: string): Promise { const { error } = await (betterAuth as any).organization.acceptInvitation({ invitationId }); - if (error) throw new Error(error.message ?? 'Failed to accept invitation'); + if (error) throw toAuthError(error, 'Failed to accept invitation'); }, async rejectInvitation(invitationId: string): Promise { const { error } = await (betterAuth as any).organization.rejectInvitation({ invitationId }); - if (error) throw new Error(error.message ?? 'Failed to reject invitation'); + if (error) throw toAuthError(error, 'Failed to reject invitation'); }, async listUserInvitations(): Promise { const { data, error } = await (betterAuth as any).organization.listUserInvitations(); - if (error) throw new Error(error.message ?? 'Failed to list invitations'); + if (error) throw toAuthError(error, 'Failed to list invitations'); return (data ?? []) as AuthInvitation[]; }, }; diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 3199867831..8c3f770b3f 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -2169,6 +2169,7 @@ const ar = { members: "الأعضاء", settings: "إعدادات مساحة العمل", multiOrgDisabled: "إنشاء مؤسسات جديدة معطّل في هذا النشر.", + createFailed: 'تعذّر إنشاء مساحة العمل', }, help: { onThisPage: "في هذه الصفحة", @@ -2530,6 +2531,23 @@ const ar = { noMatches: "لا توجد مؤسسات تطابق بحثك.", }, organization: { + roles: { + owner: 'المالك', + admin: 'المسؤول', + delegatedAdmin: 'مسؤول مفوَّض', + member: 'عضو', + }, + errors: { + notAllowedToInvite: 'لا يُسمح لك بدعوة مستخدمين إلى هذه المؤسسة.', + notAllowedToInviteWithRole: 'لا يُسمح لك بدعوة مستخدم بهذا الدور.', + alreadyInvited: 'تمت دعوة هذا المستخدم إلى هذه المؤسسة بالفعل.', + organizationExists: 'هذه المؤسسة موجودة بالفعل.', + slugTaken: 'هذا المُعرِّف مستخدَم بالفعل.', + notAllowedToCreate: 'لا يُسمح لك بإنشاء مؤسسة جديدة.', + notTheRecipient: 'لست المستلم المقصود بهذه الدعوة.', + invitationNotFound: 'لم تعد هذه الدعوة موجودة أو أنها منتهية الصلاحية.', + unknown: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.', + }, backToList: "العودة إلى المؤسسات", notFound: "المؤسسة غير موجودة", notFoundDescription: "هذه المؤسسة غير موجودة أو ليس لديك صلاحية الوصول إليها.", @@ -2549,6 +2567,8 @@ const ar = { removeFailed: "فشل إزالة العضو", roleUpdated: "تم تحديث الدور", roleUpdateFailed: "فشل تحديث الدور", + memberActions: 'إجراءات العضو', + loadFailed: 'تعذّر تحميل الأعضاء', }, invitations: { title: "الدعوات", @@ -2575,6 +2595,9 @@ const ar = { sentDescription: "شارك الرابط أدناه مع المدعوّ. سيحتاج إلى تسجيل الدخول للقبول.", linkLabel: "رابط القبول", invitedAs: "تمت دعوة {{email}} بصفة {{role}}", + copyLinkLabel: 'نسخ رابط الدعوة', + loadFailed: 'تعذّر تحميل الدعوات', + inviteFailed: 'تعذّرت دعوة العضو', status: { all: "الكل", pending: "قيد الانتظار", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 13f18cd3b7..a4b942c58b 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -2162,6 +2162,7 @@ const de = { members: "Mitglieder", settings: "Arbeitsbereichseinstellungen", multiOrgDisabled: "Das Erstellen neuer Organisationen ist auf dieser Instanz deaktiviert.", + createFailed: 'Arbeitsbereich konnte nicht erstellt werden', }, help: { onThisPage: "Auf dieser Seite", @@ -2523,6 +2524,23 @@ const de = { noMatches: "Keine Organisationen entsprechen Ihrer Suche.", }, organization: { + roles: { + owner: 'Eigentümer', + admin: 'Administrator', + delegatedAdmin: 'Delegierter Administrator', + member: 'Mitglied', + }, + errors: { + notAllowedToInvite: 'Sie dürfen keine Benutzer in diese Organisation einladen.', + notAllowedToInviteWithRole: 'Sie dürfen keine Benutzer mit dieser Rolle einladen.', + alreadyInvited: 'Dieser Benutzer wurde bereits in diese Organisation eingeladen.', + organizationExists: 'Diese Organisation existiert bereits.', + slugTaken: 'Dieser URL-Slug ist bereits vergeben.', + notAllowedToCreate: 'Sie dürfen keine neue Organisation erstellen.', + notTheRecipient: 'Sie sind nicht der Empfänger dieser Einladung.', + invitationNotFound: 'Diese Einladung existiert nicht mehr oder ist abgelaufen.', + unknown: 'Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.', + }, backToList: "Zurück zu den Organisationen", notFound: "Organisation nicht gefunden", notFoundDescription: "Diese Organisation existiert nicht oder Sie haben keinen Zugriff darauf.", @@ -2542,6 +2560,8 @@ const de = { removeFailed: "Mitglied konnte nicht entfernt werden", roleUpdated: "Rolle aktualisiert", roleUpdateFailed: "Rolle konnte nicht aktualisiert werden", + memberActions: 'Mitgliedsaktionen', + loadFailed: 'Mitglieder konnten nicht geladen werden', }, invitations: { title: "Einladungen", @@ -2568,6 +2588,9 @@ const de = { sentDescription: "Teilen Sie den folgenden Link mit der eingeladenen Person. Sie muss sich anmelden, um anzunehmen.", linkLabel: "Annahme-Link", invitedAs: "{{email}} als {{role}} eingeladen", + copyLinkLabel: 'Einladungslink kopieren', + loadFailed: 'Einladungen konnten nicht geladen werden', + inviteFailed: 'Mitglied konnte nicht eingeladen werden', status: { all: "Alle", pending: "Ausstehend", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 0d3a60f3e1..4c40333721 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -2336,6 +2336,7 @@ const en = { members: 'Members', settings: 'Workspace settings', multiOrgDisabled: 'Creating new organizations is disabled on this instance.', + createFailed: 'Failed to create workspace', }, help: { onThisPage: 'On this page', @@ -2755,6 +2756,23 @@ const en = { // is the org PICKER — same domain, different surface, and the singular / // plural spelling is the only thing telling them apart at a call site. organization: { + roles: { + owner: 'Owner', + admin: 'Admin', + delegatedAdmin: 'Delegated Admin', + member: 'Member', + }, + errors: { + notAllowedToInvite: 'You are not allowed to invite users to this organization', + notAllowedToInviteWithRole: 'You are not allowed to invite a user with this role', + alreadyInvited: 'This user has already been invited to this organization', + organizationExists: 'Organization already exists', + slugTaken: 'That URL slug is already taken', + notAllowedToCreate: 'You are not allowed to create a new organization', + notTheRecipient: 'You are not the recipient of the invitation', + invitationNotFound: 'This invitation no longer exists or has expired', + unknown: 'Something went wrong. Please try again.', + }, backToList: 'Back to organizations', notFound: 'Organization not found', notFoundDescription: 'This organization does not exist or you do not have access.', @@ -2774,6 +2792,8 @@ const en = { removeFailed: 'Failed to remove member', roleUpdated: 'Role updated', roleUpdateFailed: 'Failed to update role', + memberActions: 'Member actions', + loadFailed: 'Failed to load members', }, invitations: { title: 'Invitations', @@ -2803,6 +2823,9 @@ const en = { sentDescription: 'Share the link below with the invitee. They will need to sign in to accept.', linkLabel: 'Accept link', invitedAs: '{{email}} invited as {{role}}', + copyLinkLabel: 'Copy invitation link', + loadFailed: 'Failed to load invitations', + inviteFailed: 'Failed to invite member', status: { all: 'All', pending: 'Pending', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 8039bfd4da..d9142c0da8 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -2166,6 +2166,7 @@ const es = { members: "Miembros", settings: "Configuración del espacio de trabajo", multiOrgDisabled: "La creación de nuevas organizaciones está deshabilitada en esta instancia.", + createFailed: 'No se pudo crear el espacio de trabajo', }, help: { onThisPage: "En esta página", @@ -2527,6 +2528,23 @@ const es = { noMatches: "Ninguna organización coincide con tu búsqueda.", }, organization: { + roles: { + owner: 'Propietario', + admin: 'Administrador', + delegatedAdmin: 'Administrador delegado', + member: 'Miembro', + }, + errors: { + notAllowedToInvite: 'No tiene permiso para invitar usuarios a esta organización.', + notAllowedToInviteWithRole: 'No tiene permiso para invitar a un usuario con este rol.', + alreadyInvited: 'Este usuario ya ha sido invitado a esta organización.', + organizationExists: 'Esa organización ya existe.', + slugTaken: 'Ese slug ya está en uso.', + notAllowedToCreate: 'No tiene permiso para crear una organización nueva.', + notTheRecipient: 'No es el destinatario de esta invitación.', + invitationNotFound: 'Esta invitación ya no existe o ha caducado.', + unknown: 'Algo salió mal. Inténtelo de nuevo.', + }, backToList: "Volver a las organizaciones", notFound: "Organización no encontrada", notFoundDescription: "Esta organización no existe o no tienes acceso.", @@ -2546,6 +2564,8 @@ const es = { removeFailed: "No se pudo eliminar al miembro", roleUpdated: "Rol actualizado", roleUpdateFailed: "No se pudo actualizar el rol", + memberActions: 'Acciones del miembro', + loadFailed: 'No se pudieron cargar los miembros', }, invitations: { title: "Invitaciones", @@ -2572,6 +2592,9 @@ const es = { sentDescription: "Comparte el enlace de abajo con la persona invitada. Tendrá que iniciar sesión para aceptarla.", linkLabel: "Enlace de aceptación", invitedAs: "{{email}} invitado como {{role}}", + copyLinkLabel: 'Copiar enlace de invitación', + loadFailed: 'No se pudieron cargar las invitaciones', + inviteFailed: 'No se pudo invitar al miembro', status: { all: "Todas", pending: "Pendiente", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 7ea6cc0e23..0efb5852a2 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -2164,6 +2164,7 @@ const fr = { members: "Membres", settings: "Paramètres de l'espace de travail", multiOrgDisabled: "La création de nouvelles organisations est désactivée sur cette instance.", + createFailed: "Échec de la création de l'espace de travail", }, help: { onThisPage: "Sur cette page", @@ -2525,6 +2526,23 @@ const fr = { noMatches: "Aucune organisation ne correspond à votre recherche.", }, organization: { + roles: { + owner: 'Propriétaire', + admin: 'Administrateur', + delegatedAdmin: 'Administrateur délégué', + member: 'Membre', + }, + errors: { + notAllowedToInvite: "Vous n'êtes pas autorisé à inviter des utilisateurs dans cette organisation.", + notAllowedToInviteWithRole: "Vous n'êtes pas autorisé à inviter un utilisateur avec ce rôle.", + alreadyInvited: 'Cet utilisateur a déjà été invité dans cette organisation.', + organizationExists: 'Cette organisation existe déjà.', + slugTaken: 'Ce slug est déjà utilisé.', + notAllowedToCreate: "Vous n'êtes pas autorisé à créer une nouvelle organisation.", + notTheRecipient: "Vous n'êtes pas le destinataire de cette invitation.", + invitationNotFound: "Cette invitation n'existe plus ou a expiré.", + unknown: 'Une erreur est survenue. Veuillez réessayer.', + }, backToList: "Retour aux organisations", notFound: "Organisation introuvable", notFoundDescription: "Cette organisation n'existe pas ou vous n'y avez pas accès.", @@ -2544,6 +2562,8 @@ const fr = { removeFailed: "Impossible de retirer le membre", roleUpdated: "Rôle mis à jour", roleUpdateFailed: "Impossible de mettre à jour le rôle", + memberActions: 'Actions du membre', + loadFailed: 'Échec du chargement des membres', }, invitations: { title: "Invitations", @@ -2570,6 +2590,9 @@ const fr = { sentDescription: "Partagez le lien ci-dessous avec la personne invitée. Elle devra se connecter pour accepter.", linkLabel: "Lien d'acceptation", invitedAs: "{{email}} invité en tant que {{role}}", + copyLinkLabel: "Copier le lien d'invitation", + loadFailed: 'Échec du chargement des invitations', + inviteFailed: "Échec de l'invitation du membre", status: { all: "Toutes", pending: "En attente", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index fd0ec6b304..87358da2bd 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -2162,6 +2162,7 @@ const ja = { members: "メンバー", settings: "ワークスペース設定", multiOrgDisabled: "このインスタンスでは新しい組織の作成が無効です。", + createFailed: 'ワークスペースの作成に失敗しました', }, help: { onThisPage: "このページの内容", @@ -2523,6 +2524,23 @@ const ja = { noMatches: "検索条件に一致する組織がありません。", }, organization: { + roles: { + owner: 'オーナー', + admin: '管理者', + delegatedAdmin: '委任管理者', + member: 'メンバー', + }, + errors: { + notAllowedToInvite: 'この組織にユーザーを招待する権限がありません。', + notAllowedToInviteWithRole: 'このロールでユーザーを招待する権限がありません。', + alreadyInvited: 'このユーザーはすでにこの組織に招待されています。', + organizationExists: 'その組織はすでに存在します。', + slugTaken: 'そのスラッグはすでに使用されています。', + notAllowedToCreate: '新しい組織を作成する権限がありません。', + notTheRecipient: 'この招待の宛先はあなたではありません。', + invitationNotFound: 'この招待は存在しないか、有効期限が切れています。', + unknown: '問題が発生しました。もう一度お試しください。', + }, backToList: "組織一覧に戻る", notFound: "組織が見つかりません", notFoundDescription: "この組織は存在しないか、アクセス権がありません。", @@ -2542,6 +2560,8 @@ const ja = { removeFailed: "メンバーの削除に失敗しました", roleUpdated: "ロールを更新しました", roleUpdateFailed: "ロールの更新に失敗しました", + memberActions: 'メンバーの操作', + loadFailed: 'メンバーの読み込みに失敗しました', }, invitations: { title: "招待", @@ -2568,6 +2588,9 @@ const ja = { sentDescription: "以下のリンクを招待相手に共有してください。承諾にはサインインが必要です。", linkLabel: "承諾リンク", invitedAs: "{{email}} を {{role}} として招待しました", + copyLinkLabel: '招待リンクをコピー', + loadFailed: '招待の読み込みに失敗しました', + inviteFailed: 'メンバーの招待に失敗しました', status: { all: "すべて", pending: "待機中", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 2b531f364a..90de540f52 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -2162,6 +2162,7 @@ const ko = { members: "구성원", settings: "워크스페이스 설정", multiOrgDisabled: "이 인스턴스에서는 새 조직을 만들 수 없습니다.", + createFailed: '워크스페이스를 만들지 못했습니다', }, help: { onThisPage: "이 페이지에서", @@ -2522,6 +2523,23 @@ const ko = { noMatches: "검색과 일치하는 조직이 없습니다.", }, organization: { + roles: { + owner: '소유자', + admin: '관리자', + delegatedAdmin: '위임 관리자', + member: '멤버', + }, + errors: { + notAllowedToInvite: '이 조직에 사용자를 초대할 권한이 없습니다.', + notAllowedToInviteWithRole: '이 역할로 사용자를 초대할 권한이 없습니다.', + alreadyInvited: '이 사용자는 이미 이 조직에 초대되었습니다.', + organizationExists: '해당 조직이 이미 존재합니다.', + slugTaken: '해당 슬러그는 이미 사용 중입니다.', + notAllowedToCreate: '새 조직을 만들 권한이 없습니다.', + notTheRecipient: '이 초대의 수신자가 아닙니다.', + invitationNotFound: '이 초대는 더 이상 존재하지 않거나 만료되었습니다.', + unknown: '문제가 발생했습니다. 다시 시도해 주세요.', + }, backToList: "조직 목록으로 돌아가기", notFound: "조직을 찾을 수 없음", notFoundDescription: "이 조직이 존재하지 않거나 접근 권한이 없습니다.", @@ -2541,6 +2559,8 @@ const ko = { removeFailed: "구성원 제거 실패", roleUpdated: "역할이 업데이트됨", roleUpdateFailed: "역할 업데이트 실패", + memberActions: '멤버 작업', + loadFailed: '멤버를 불러오지 못했습니다', }, invitations: { title: "초대", @@ -2567,6 +2587,9 @@ const ko = { sentDescription: "아래 링크를 초대 대상자와 공유하세요. 수락하려면 로그인해야 합니다.", linkLabel: "수락 링크", invitedAs: "{{email}}을(를) {{role}}(으)로 초대함", + copyLinkLabel: '초대 링크 복사', + loadFailed: '초대를 불러오지 못했습니다', + inviteFailed: '멤버를 초대하지 못했습니다', status: { all: "전체", pending: "대기 중", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 2693426a6b..1c428c3e2f 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -2161,6 +2161,7 @@ const pt = { members: "Membros", settings: "Configurações do espaço de trabalho", multiOrgDisabled: "A criação de novas organizações está desativada nesta instância.", + createFailed: 'Falha ao criar o espaço de trabalho', }, help: { onThisPage: "Nesta página", @@ -2522,6 +2523,23 @@ const pt = { noMatches: "Nenhuma organização corresponde à sua pesquisa.", }, organization: { + roles: { + owner: 'Proprietário', + admin: 'Administrador', + delegatedAdmin: 'Administrador delegado', + member: 'Membro', + }, + errors: { + notAllowedToInvite: 'Você não tem permissão para convidar usuários para esta organização.', + notAllowedToInviteWithRole: 'Você não tem permissão para convidar um usuário com esta função.', + alreadyInvited: 'Este usuário já foi convidado para esta organização.', + organizationExists: 'Essa organização já existe.', + slugTaken: 'Esse slug já está em uso.', + notAllowedToCreate: 'Você não tem permissão para criar uma nova organização.', + notTheRecipient: 'Você não é o destinatário deste convite.', + invitationNotFound: 'Este convite não existe mais ou expirou.', + unknown: 'Algo deu errado. Tente novamente.', + }, backToList: "Voltar para as organizações", notFound: "Organização não encontrada", notFoundDescription: "Esta organização não existe ou você não tem acesso.", @@ -2541,6 +2559,8 @@ const pt = { removeFailed: "Falha ao remover o membro", roleUpdated: "Função atualizada", roleUpdateFailed: "Falha ao atualizar a função", + memberActions: 'Ações do membro', + loadFailed: 'Falha ao carregar os membros', }, invitations: { title: "Convites", @@ -2567,6 +2587,9 @@ const pt = { sentDescription: "Compartilhe o link abaixo com a pessoa convidada. Ela precisará entrar para aceitar.", linkLabel: "Link de aceitação", invitedAs: "{{email}} convidado como {{role}}", + copyLinkLabel: 'Copiar link do convite', + loadFailed: 'Falha ao carregar os convites', + inviteFailed: 'Falha ao convidar o membro', status: { all: "Todos", pending: "Pendente", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index cee831bb11..e4b7215541 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -2172,6 +2172,7 @@ const ru = { members: "Участники", settings: "Настройки рабочего пространства", multiOrgDisabled: "Создание новых организаций отключено в этой среде.", + createFailed: 'Не удалось создать рабочее пространство', }, help: { onThisPage: "На этой странице", @@ -2534,6 +2535,23 @@ const ru = { noMatches: "Нет организаций, соответствующих запросу.", }, organization: { + roles: { + owner: 'Владелец', + admin: 'Администратор', + delegatedAdmin: 'Делегированный администратор', + member: 'Участник', + }, + errors: { + notAllowedToInvite: 'У вас нет прав приглашать пользователей в эту организацию.', + notAllowedToInviteWithRole: 'У вас нет прав приглашать пользователя с этой ролью.', + alreadyInvited: 'Этот пользователь уже приглашён в эту организацию.', + organizationExists: 'Такая организация уже существует.', + slugTaken: 'Этот идентификатор уже занят.', + notAllowedToCreate: 'У вас нет прав создавать новую организацию.', + notTheRecipient: 'Вы не являетесь получателем этого приглашения.', + invitationNotFound: 'Это приглашение больше не существует или истекло.', + unknown: 'Что-то пошло не так. Попробуйте ещё раз.', + }, backToList: "Назад к организациям", notFound: "Организация не найдена", notFoundDescription: "Эта организация не существует или у вас нет к ней доступа.", @@ -2553,6 +2571,8 @@ const ru = { removeFailed: "Не удалось удалить участника", roleUpdated: "Роль обновлена", roleUpdateFailed: "Не удалось обновить роль", + memberActions: 'Действия с участником', + loadFailed: 'Не удалось загрузить участников', }, invitations: { title: "Приглашения", @@ -2579,6 +2599,9 @@ const ru = { sentDescription: "Отправьте ссылку ниже приглашённому. Чтобы принять приглашение, ему нужно войти в систему.", linkLabel: "Ссылка для принятия", invitedAs: "{{email}} приглашён как {{role}}", + copyLinkLabel: 'Скопировать ссылку-приглашение', + loadFailed: 'Не удалось загрузить приглашения', + inviteFailed: 'Не удалось пригласить участника', status: { all: "Все", pending: "Ожидает", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 36f07472a4..d0b6984c31 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -2236,6 +2236,7 @@ const zh = { members: '成员', settings: '工作区设置', multiOrgDisabled: '此实例已禁用创建新组织。', + createFailed: '创建工作区失败', }, help: { keyboardShortcuts: '键盘快捷键', @@ -2640,6 +2641,23 @@ const zh = { noMatches: '没有匹配的工作区。', }, organization: { + roles: { + owner: '所有者', + admin: '管理员', + delegatedAdmin: '受托管理员', + member: '成员', + }, + errors: { + notAllowedToInvite: '您无权邀请用户加入该组织。', + notAllowedToInviteWithRole: '您无权以该角色邀请用户。', + alreadyInvited: '该用户已被邀请加入本组织。', + organizationExists: '该组织已存在。', + slugTaken: '该标识已被占用。', + notAllowedToCreate: '您无权创建新组织。', + notTheRecipient: '您不是该邀请的收件人。', + invitationNotFound: '该邀请已失效或已过期。', + unknown: '出了点问题,请重试。', + }, backToList: '返回组织列表', notFound: '未找到组织', notFoundDescription: '该组织不存在,或您没有访问权限。', @@ -2659,6 +2677,8 @@ const zh = { removeFailed: '移除成员失败', roleUpdated: '角色已更新', roleUpdateFailed: '更新角色失败', + memberActions: '成员操作', + loadFailed: '加载成员失败', }, invitations: { title: '邀请', @@ -2685,6 +2705,9 @@ const zh = { sentDescription: '请将下方链接发送给受邀人。对方需要登录后才能接受。', linkLabel: '接受链接', invitedAs: '{{email}} 已以 {{role}} 身份受邀', + copyLinkLabel: '复制邀请链接', + loadFailed: '加载邀请失败', + inviteFailed: '邀请成员失败', status: { all: '全部', pending: '等待中', From f3ef307837b09c44c3a77d59944259a8ac651311 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 21:13:17 +0000 Subject: [PATCH 2/2] test: retarget two role/label selectors the #4474 fix moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both assert the same capability as before; only the string they query changed. - acceptInvitationLink.mount.test.tsx (#4472/#4480): the share-link copy button was labelled a bare "Copy" and now carries the Invitations tab's own "Copy invitation link". Every URL assertion is untouched. - AcceptInvitationRoute.test.tsx (#3811): the role row rendered the raw `sys_member.role` identifier and now renders the display name, so `admin` reads as `Admin` in en and 管理员 in zh. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .../src/pages/auth/__tests__/AcceptInvitationRoute.test.tsx | 6 +++++- .../__tests__/acceptInvitationLink.mount.test.tsx | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/console/src/pages/auth/__tests__/AcceptInvitationRoute.test.tsx b/apps/console/src/pages/auth/__tests__/AcceptInvitationRoute.test.tsx index b504207a62..96b7613715 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/__tests__/acceptInvitationLink.mount.test.tsx b/packages/app-shell/src/console/organizations/__tests__/acceptInvitationLink.mount.test.tsx index db5ff86c8f..b3eaa1ac8c 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 }; }