Skip to content

Commit a1d243e

Browse files
fix(console): localize the organization-limit refusal in the create-workspace dialog (#31465)
<!-- claude-projects-attribution: {"github_login":"os-zhuang"} --> _Requested by **Sam** · [project thread](https://claude.ai/code/project/chan_01GsBogozeZG5sdpAARrN7HP?thread=cmsg_01GsBogozeZG5sdpAARrN7HPFXdEVvapaU7V47g3TwNSXj)_ Closes #28124 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:控制台「新建组织」对话框遇到组织数上限的拒绝(403 `YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS`)时,改为显示当前语言的说明文案,全部 11 个语言包都加了这条。 - 为什么改:cloud 把每人可建组织数降到 2 之后,用户建第 3 个组织时看到的是 better-auth 的英文原句,中文用户看不懂,也不知道被邀请加入的组织不算在内。 - 风险与回滚:只加了一行错误码映射和一个文案键,不碰服务端;回滚就是撤掉这个 PR。文案不写具体数字,因为拒绝响应体和 `/auth/config` 都不带上限值。 - 先看:`ui/packages/app-shell/src/console/organizations/orgErrorMessage.ts`(新增的映射行和注释)、`ui/packages/i18n/src/locales/zh-Hans.ts`(中文文案)、`ui/packages/app-shell/src/console/organizations/__tests__/CreateWorkspaceDialog.test.tsx`(末尾新增的 zh/en 用例)。 - 两分钟验证:在 `ui/` 下跑 `npx vitest run packages/app-shell/src/console/organizations/__tests__/CreateWorkspaceDialog.test.tsx`,应全绿,其中 zh-Hans 用例显示「您创建的组织数量已达上限。被邀请加入的组织不计入此上限。」 - 需要你做的:无,席位落地。 ## What changes - `orgErrorMessage.ts`: `ORG_ERROR_MESSAGE_KEYS` gains a row for better-auth's `YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS`, mapped to the new key `organization.errors.organizationLimitReached`, with an English `defaultValue`. The header docblock lists the code among the create-path rows. - All 11 console packs (`en`, `zh-Hans`, `zh-Hant`, `ja`, `ko`, `de`, `es`, `fr`, `pt-BR`, `ru`, `ar`) define the key. `zh-Hant` is regenerated from `zh-Hans` with `pnpm --filter @object-ui/i18n gen:zh-hant`, not hand-written. - The sentence says the cap counts only organizations the user created, never ones they were invited to. That is the part a refused user cannot guess, and it is how the server counts (`role = owner` rows in `sys_member`). - Changeset: `@object-ui/i18n` and `@object-ui/app-shell`, patch. ## Why The hosted control plane caps self-created organizations at 2 (`OS_ORG_LIMIT`, better-auth `organizationLimit`). A third create is refused with 403 and the code above. The console had no row for that code, so `resolveOrgErrorMessage` fell through to the server's English sentence in every locale. Work item 2 (name the number only if it is exposed), measured on objectstack `9728ab15` and cloud `ac69aac`: - The refusal is thrown at `better-auth@1.7.12` `dist/plugins/organization/routes/crud-org.mjs:61` as `APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS)`. The body is the bare code and message, with no limit in it. - The limit is read inside the `organizationLimit` callback (`packages/plugins/plugin-auth/src/auth-manager-organization-plugin.ts:204`, `resolveOrgLimit()` at `packages/types/src/env.ts:727`) and returned as a boolean. The `/auth/config` `features` object (`packages/plugins/plugin-auth/src/auth-manager.ts:5047` onward) does not publish it. - cloud sets the value in the container env only (`apps/cloud/cloudflare/worker.ts:503`, `OS_ORG_LIMIT: '2'`). Nothing in cloud adds it to a response. So the message names no number, and a test holds every pack to that. ## Tests - `orgErrorMessage-4474.test.ts`, new top-level `describe` for the code: zh renders the zh pack value (Han script, not the server sentence); en renders the en pack value; every pack defines the key, its own sentence differs from `en`, and no pack value contains a digit. The exact-set pin of mapped codes gains the code. - `CreateWorkspaceDialog.test.tsx`, new top-level `describe`: through the real `zh-Hans` and `en` packs, a create refused with the limit code shows the pack sentence in `workspace-create-error`, not the server's English, and the dialog neither retries nor provisions. Ablation (one-off, no permanent file), with `scripts/ablation-replace.mjs` renaming the mapping row's key so the code is unmapped: anchor x1 to x0, blob `335e7a7e` to `2050edb8`. 5 tests went red (zh and en in each file, plus the exact-set pin), 33 stayed green. Restore: blob equals HEAD `335e7a7e`, `git diff HEAD` empty. ## Acceptance notes - No spec change, no server change. - `zh-Hant` is a generated catalogue (`zh-hant-generated.test.ts`), so its sentence is the converter's rendering of the zh-Hans one. - `check:i18n-dead-keys` lists the new key as `needs-review`, exactly like its nine siblings in `organization.errors`: the gate cannot read a key held as a value in a mapping table. It is a report, not a red. - `Type Check UI` for `@object-ui/app-shell` is left to CI: a local `type-check` needs every `@object-ui/*` workspace package built first. ## Verification Local readings, on objectstack-ai/objectstack: - `node scripts/pm/dispatch-gates.mjs --commands` on head `0ad95797`: 38 derived families, every one run on that head and exit 0. `--ran` reconciliation: 38 derived, 38 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero, every row carries its exit code). - i18n gates under `ui/`: `pnpm check:i18n-drift` exit 0 (0 en values changed, 1 key added), `pnpm check:i18n-keys` exit 0, `pnpm check:i18n-dead-keys` exit 0 (report; see the acceptance note). - `npx vitest run packages/i18n/ packages/app-shell/src/console/organizations/` under `ui/`, on `b1443871`'s tree (the last code commit; `0ad95797` adds only the changeset): 116/116 files, 1967 passed, 13 skipped. This covers `all-locales-key-parity.test.ts`, `en-zh-key-parity.test.ts` and `zh-hant-generated.test.ts`. - Ablation: see Tests. CI runs every other family on the PR. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (No formatter ran; the repo-wide lint is CI's run.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01QUs3BveKCXohEcrrpsCh6c)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ee610fb commit a1d243e

16 files changed

Lines changed: 146 additions & 3 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/i18n': patch
3+
'@object-ui/app-shell': patch
4+
---
5+
6+
fix(console): the create-workspace dialog explains the organization limit in the session language
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **The organization-limit refusal is localized.** When a deployment caps how many organizations one user may create (`OS_ORG_LIMIT`) and the user is past it, the server refuses with `403 YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS`. The create-workspace dialog showed the auth library's English sentence for it in every locale. It now shows `organization.errors.organizationLimitReached`, which says the cap counts only organizations the user created, never ones they were invited to, in all 11 console locales (`en`, `zh-Hans`, `zh-Hant`, `ja`, `ar`, `de`, `es`, `fr`, `ko`, `pt-BR`, `ru`). The message names no number: the refusal body and `/auth/config` do not carry the cap.

‎ui/packages/app-shell/src/console/organizations/__tests__/CreateWorkspaceDialog.test.tsx‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,3 +379,36 @@ describe('prefills a first organization from the e-mail (objectstack-ai/cloud#35
379379
expect(screen.getByTestId('workspace-name-input')).toHaveValue('Acme');
380380
});
381381
});
382+
383+
// objectstack#28124: past the per-user creation cap (`OS_ORG_LIMIT`) the server
384+
// refuses with 403 YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS, and the
385+
// dialog showed better-auth's English sentence in every locale. Rendered through
386+
// the real packs, zh and en.
387+
describe('the organization-limit refusal is shown in the session language (objectstack#28124)', () => {
388+
const SERVER_SENTENCE = 'You have reached the maximum number of organizations';
389+
const limitRefusal = () =>
390+
Object.assign(new Error(SERVER_SENTENCE), {
391+
code: 'YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS',
392+
status: 403,
393+
});
394+
395+
for (const lang of ['zh-Hans', 'en'] as const) {
396+
it(`${lang}: the dialog shows the pack sentence, not the server's English, and does not retry`, async () => {
397+
packLang = lang;
398+
createOrganization.mockRejectedValue(limitRefusal());
399+
render(<CreateWorkspaceDialog open onOpenChange={() => {}} />);
400+
await settleAuthConfig();
401+
402+
fillAndSubmit();
403+
404+
const shown = await screen.findByTestId('workspace-create-error');
405+
const expected = (builtInLocales[lang] as { organization: { errors: Record<string, string> } }).organization
406+
.errors.organizationLimitReached;
407+
expect(typeof expected).toBe('string');
408+
expect(shown).toHaveTextContent(expected);
409+
expect(shown).not.toHaveTextContent(SERVER_SENTENCE);
410+
expect(createOrganization).toHaveBeenCalledTimes(1);
411+
expect(provisionMock).not.toHaveBeenCalled();
412+
});
413+
}
414+
});

‎ui/packages/app-shell/src/console/organizations/__tests__/orgErrorMessage-4474.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,53 @@ describe('#12977 — ORGANIZATION_CREATION_DISABLED (objectstack registered code
173173
});
174174
});
175175

176+
describe('#28124 — YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS (the per-user creation cap)', () => {
177+
// better-auth refuses a create past `organizationLimit` (`OS_ORG_LIMIT`) with
178+
// 403 and this code, and the body is its bare `{ code, message }`: no limit
179+
// in it, and none in `/auth/config` either. So the console explains the cap
180+
// without a number, and no pack may carry one.
181+
const CODE = 'YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS';
182+
const SERVER_SENTENCE = 'You have reached the maximum number of organizations';
183+
const KEY = 'organization.errors.organizationLimitReached';
184+
const packValue = (lang: string): unknown =>
185+
KEY.split('.').reduce<unknown>(
186+
(n, k) => (n as Record<string, unknown> | undefined)?.[k],
187+
builtInLocales[lang as keyof typeof builtInLocales],
188+
);
189+
const tPack = (lang: string) => (key: string, options?: Record<string, unknown>): string => {
190+
const hit = key
191+
.split('.')
192+
.reduce<unknown>(
193+
(n, k) => (n as Record<string, unknown> | undefined)?.[k],
194+
builtInLocales[lang as keyof typeof builtInLocales],
195+
);
196+
return typeof hit === 'string' ? hit : String(options?.defaultValue ?? key);
197+
};
198+
199+
it('zh: the refusal renders the zh pack sentence, not better-auth\'s English', () => {
200+
const shown = resolveOrgErrorMessage(withCode(CODE, SERVER_SENTENCE), tPack('zh-Hans'));
201+
expect(shown).toBe(packValue('zh-Hans'));
202+
expect(shown).not.toBe(SERVER_SENTENCE);
203+
expect(shown).toMatch(/\p{Script=Han}/u);
204+
});
205+
206+
it('en: the refusal renders the en pack sentence, which says invited organizations do not count', () => {
207+
const shown = resolveOrgErrorMessage(withCode(CODE, SERVER_SENTENCE), tPack('en'));
208+
expect(shown).toBe(packValue('en'));
209+
expect(shown).not.toBe(SERVER_SENTENCE);
210+
});
211+
212+
it('every pack defines the key, with its own sentence and no number in it', () => {
213+
const en = packValue('en');
214+
for (const lang of Object.keys(builtInLocales)) {
215+
const value = packValue(lang);
216+
expect(typeof value, `${lang} lacks ${KEY}`).toBe('string');
217+
expect(value as string, `${lang} names a number the server never sent`).not.toMatch(/\p{Nd}/u);
218+
if (lang !== 'en') expect(value, `${lang} still serves the English sentence`).not.toBe(en);
219+
}
220+
});
221+
});
222+
176223
describe('#4474 — the mapping table itself', () => {
177224
it('every mapped code resolves to a key the zh pack actually defines', () => {
178225
// Guards the class of typo that only shows up as a raw i18n key on screen,
@@ -196,6 +243,7 @@ describe('#4474 — the mapping table itself', () => {
196243
'ORGANIZATION_SLUG_ALREADY_TAKEN',
197244
'USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION',
198245
'YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION',
246+
'YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS',
199247
'YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION',
200248
'YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE',
201249
'YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION',

‎ui/packages/app-shell/src/console/organizations/orgErrorMessage.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@
4848
* the slug collision that is the create dialog's other outcome; the
4949
* multi-org-disabled refusal `CreateWorkspaceDialog` already comments about; and
5050
* the expired link `AcceptInvitationPage` already has a hand-written fallback
51-
* for), and one objectstack registered code (ADR-0112) on the create path:
51+
* for; the per-user creation cap, which a hosted deployment sets low enough
52+
* that an ordinary user reaches it), and one objectstack registered code (ADR-0112) on the create path:
5253
* `ORGANIZATION_CREATION_DISABLED`, the server's refusal for the same
5354
* deployment posture `CreateWorkspaceDialog`'s own guard reads from
5455
* `/auth/config` (objectui#12977). The rest are teams, dynamic roles and org
@@ -111,6 +112,18 @@ export const ORG_ERROR_MESSAGE_KEYS: Record<string, OrgErrorEntry> = {
111112
key: 'organization.errors.notAllowedToCreate',
112113
defaultValue: 'You are not allowed to create a new organization',
113114
},
115+
// The per-user creation cap (`OS_ORG_LIMIT`, better-auth's
116+
// `organizationLimit`): only organizations the caller OWNS count, never ones
117+
// they were invited into, and the sentence says so because that is the part
118+
// a refused user cannot guess. It names no number on purpose: the refusal
119+
// body is better-auth's bare `{ code, message }` and `/auth/config` does not
120+
// publish the cap, so the console has no number to show, and a number baked
121+
// into every pack would be wrong on every deployment that sets another one.
122+
YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS: {
123+
key: 'organization.errors.organizationLimitReached',
124+
defaultValue:
125+
'You have reached the limit on organizations you can create. Organizations you were invited to do not count toward it.',
126+
},
114127
// objectstack's registered code (ADR-0112) for a deployment whose tenancy
115128
// posture refuses organization creation for every caller — a deployment
116129
// setting, not a missing grant, so it reuses the sentence the dialog's own

‎ui/packages/i18n/src/locales/ar.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3870,6 +3870,7 @@ const ar = {
38703870
organizationExists: 'هذه المؤسسة موجودة بالفعل.',
38713871
slugTaken: 'هذا المُعرِّف مستخدَم بالفعل.',
38723872
notAllowedToCreate: 'لا يُسمح لك بإنشاء مؤسسة جديدة.',
3873+
organizationLimitReached: 'لقد بلغت الحد الأقصى لعدد المؤسسات التي يمكنك إنشاؤها. لا تُحتسب المؤسسات التي دُعيت إليها ضمن هذا الحد.',
38733874
notTheRecipient: 'لست المستلم المقصود بهذه الدعوة.',
38743875
invitationNotFound: 'لم تعد هذه الدعوة موجودة أو أنها منتهية الصلاحية.',
38753876
unknown: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',

‎ui/packages/i18n/src/locales/de.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3589,6 +3589,7 @@ const de = {
35893589
organizationExists: 'Diese Organisation existiert bereits.',
35903590
slugTaken: 'Dieser URL-Slug ist bereits vergeben.',
35913591
notAllowedToCreate: 'Sie dürfen keine neue Organisation erstellen.',
3592+
organizationLimitReached: 'Sie haben die maximale Anzahl an Organisationen erreicht, die Sie erstellen können. Organisationen, zu denen Sie eingeladen wurden, zählen nicht dazu.',
35923593
notTheRecipient: 'Sie sind nicht der Empfänger dieser Einladung.',
35933594
invitationNotFound: 'Diese Einladung existiert nicht mehr oder ist abgelaufen.',
35943595
unknown: 'Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.',

‎ui/packages/i18n/src/locales/en.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4393,6 +4393,7 @@ const en = {
43934393
organizationExists: 'Organization already exists',
43944394
slugTaken: 'That URL slug is already taken',
43954395
notAllowedToCreate: 'You are not allowed to create a new organization',
4396+
organizationLimitReached: 'You have reached the limit on organizations you can create. Organizations you were invited to do not count toward it.',
43964397
notTheRecipient: 'You are not the recipient of the invitation',
43974398
invitationNotFound: 'This invitation no longer exists or has expired',
43984399
unknown: 'Something went wrong. Please try again.',

‎ui/packages/i18n/src/locales/es.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3660,6 +3660,7 @@ const es = {
36603660
organizationExists: 'Esa organización ya existe.',
36613661
slugTaken: 'Ese slug ya está en uso.',
36623662
notAllowedToCreate: 'No tiene permiso para crear una organización nueva.',
3663+
organizationLimitReached: 'Ha alcanzado el número máximo de organizaciones que puede crear. Las organizaciones a las que se le ha invitado no cuentan para este límite.',
36633664
notTheRecipient: 'No es el destinatario de esta invitación.',
36643665
invitationNotFound: 'Esta invitación ya no existe o ha caducado.',
36653666
unknown: 'Algo salió mal. Inténtelo de nuevo.',

‎ui/packages/i18n/src/locales/fr.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3656,6 +3656,7 @@ const fr = {
36563656
organizationExists: 'Cette organisation existe déjà.',
36573657
slugTaken: 'Ce slug est déjà utilisé.',
36583658
notAllowedToCreate: "Vous n'êtes pas autorisé à créer une nouvelle organisation.",
3659+
organizationLimitReached: "Vous avez atteint le nombre maximal d'organisations que vous pouvez créer. Les organisations auxquelles vous avez été invité ne sont pas comptées.",
36593660
notTheRecipient: "Vous n'êtes pas le destinataire de cette invitation.",
36603661
invitationNotFound: "Cette invitation n'existe plus ou a expiré.",
36613662
unknown: 'Une erreur est survenue. Veuillez réessayer.',

‎ui/packages/i18n/src/locales/ja.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3591,6 +3591,7 @@ const ja = {
35913591
organizationExists: 'その組織はすでに存在します。',
35923592
slugTaken: 'そのスラッグはすでに使用されています。',
35933593
notAllowedToCreate: '新しい組織を作成する権限がありません。',
3594+
organizationLimitReached: '作成できる組織の数が上限に達しました。招待されて参加した組織は上限に含まれません。',
35943595
notTheRecipient: 'この招待の宛先はあなたではありません。',
35953596
invitationNotFound: 'この招待は存在しないか、有効期限が切れています。',
35963597
unknown: '問題が発生しました。もう一度お試しください。',

0 commit comments

Comments
 (0)