Skip to content

Commit f18adce

Browse files
feat(app-shell): prefill the first organization name from the sign-up e-mail (#30305)
Fixes #30136 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:云端注册后自动弹出的「创建组织」对话框,名称不再是空的。企业邮箱用域名推出公司名(`jane@acme.com` → `Acme`),公共邮箱(gmail、qq、163 等,代码里的闭合清单)预填现成的「我的组织」/ "My Organization"。名称可改,也可直接点创建;不会自动建组织,只对第一个组织生效。 - 为什么改:objectstack-ai/cloud#3559 裁决 A(预填默认名、一键继续)。公共邮箱的写法按 #30136 上的裁决 6652952865(A:复用 `workspace.default`),不新增文案键,不碰 ADR-0060 冻结的 `workspace` 命名空间。 - 风险与回滚:只改控制台对话框的初始值和一个中立的域名工具函数,不改接口与数据;回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/utils/emailDomain.ts`(域名规则与公共邮箱清单),以及 `CreateWorkspaceDialog.tsx` 里只在打开那一刻取默认值的那段。 - 两分钟验证:用 gmail 账号注册,看到「我的组织」;用 `@acme.com` 账号注册,看到 `Acme`。 - 需要你做的:无,席位复核后落地。 ## What changes - **New `ui/packages/app-shell/src/utils/emailDomain.ts`**: product-neutral helpers named for what they compute: `emailDomain`, `registrableLabel` (multi-part public suffixes such as `acme.co.uk`, `acme.com.cn` → `acme`), `isPublicMailboxDomain` and the closed `PUBLIC_MAILBOX_DOMAINS` list (subdomains included). It decides nothing about what a caller shows, and it is not added to the utils barrel or the package index. - **`CreateWorkspaceDialog.tsx`** keeps only the company-versus-default choice: a work domain gives its registrable label, capitalised (`acme.com` → `Acme`). A public mailbox, or a punycode label, gives `t('workspace.default')`. An address with no domain gives an empty name. The default is read once when the dialog opens, for a user with no organization yet; the field stays editable; nothing is created automatically. - **No i18n change.** `@object-ui/i18n` is untouched (`workspace.default` already exists in all ten packs). ADR-0060 and `product-specific.allowlist.json` are untouched. The product-neutral ratchet passes. - Changeset: `@object-ui/app-shell` patch. ## Tests - `utils/__tests__/emailDomain.test.ts` (new) covers the domain rule (multi-part suffixes, the generic-TLD control), every entry of the closed list, subdomain and look-alike domains, punycode, and null inputs. - `CreateWorkspaceDialog.test.tsx` covers a gmail user (en "My Organization", zh 「我的组织」, with one-submit create plus environment provision); an `@acme.com` user (`Acme`); an editable field; empty-name controls; and the name staying put while the organization list moves off zero mid-submit. - Local readings are in the dev's report on #30136 (comment 6653627193). On the ratchet, the organizations tests, the util test and the i18n parity/vocabulary tests: 22 files / 327 tests passed. `@object-ui/app-shell` type-check passes. One ablation (public-mailbox branch forced off) turns exactly the three public-mailbox cases red. - The whole app-shell suite runs in CI's 16-shard Test UI. ## Acceptance notes - Public-mailbox users get the generic 「我的组织」, not 「张三的组织」. This is the accepted cost of ruling 6652952865, which keeps ADR-0060's frozen `workspace` namespace shrink-only. - ADR-0060 rule 3 says a frozen surface may only shrink. This PR still grows `CreateWorkspaceDialog.tsx`, and objectstack-ai/cloud#3559 A (2026-11-04), which names this dialog, is read as authorizing that growth (recorded in 6652952865). - Hyphenated labels keep their hyphen (`acme-labs.com` → `Acme-labs`); only the first letter is capitalised. - Not browser-verified against a live cloud stack; the behaviour is pinned at component level with the real en/zh pack values. - Out of scope, held for the cloud lane: the stale comment in cloud's `packages/service-cloud/src/cloud-stack.ts` (objectstack-ai/cloud#3559's cloud half). ## Verification `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `7749a01a` derived 35 commands, and all 35 ran with exit 0 (`Run reconciliation — 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN`). An earlier `Lint UI` red at `spec check:liveness` (`query/searchFields → packages/objectql/src/engine.ts`) came from main, not this diff (#30367). The seat merged `origin/main` into this branch to pick up that fix. --- _Generated by [Claude Code](https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2)_ <!-- signed: repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2 --> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent 7b10a30 commit f18adce

5 files changed

Lines changed: 391 additions & 8 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
feat(app-shell): the first organization's create dialog opens with a default name, so one click continues
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
For a user with no organization yet — the dialog that opens right after sign-up — the organization name is prefilled from the user's e-mail. A work domain names the company from the domain's registrable label, capitalised (`jane@acme.com` → `Acme`, `jane@acme.co.uk` → `Acme`). A public mailbox domain (a closed list in code: gmail.com, outlook.com, qq.com, 163.com, proton.me and similar, subdomains included) names no company, so it takes the existing default organization name, "My Organization" / 「我的组织」 (`workspace.default`, already in every built-in pack; no translation key is added). An unlisted domain is treated as a work domain. The field stays editable, nothing is created until the user clicks, and creating a further organization still opens with an empty name. The e-mail domain rules live in a product-neutral app-shell util (`utils/emailDomain`), outside the organization pages.

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

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,15 @@
88
* owner can still change it later, in organization settings (`SettingsPage`'s
99
* slug input).
1010
*
11+
* For a user with no organization yet — the dialog that opens right after
12+
* sign-up — the name is prefilled (`firstOrganizationName`), so one click
13+
* continues; the field stays editable and nothing is created until the user
14+
* clicks. Creating a further organization opens with an empty name.
15+
*
1116
* @module
1217
*/
1318

14-
import { useState, useCallback, useEffect } from 'react';
19+
import { useState, useCallback, useEffect, useRef } from 'react';
1520
import {
1621
Dialog,
1722
DialogContent,
@@ -29,6 +34,7 @@ import { useObjectTranslation } from '@object-ui/i18n';
2934
import { Loader2 } from 'lucide-react';
3035
import { provisionProductionEnvironment } from './provisionEnvironment.js';
3136
import { resolveOrgErrorMessage } from './orgErrorMessage.js';
37+
import { emailDomain, isPublicMailboxDomain, registrableLabel } from '../../utils/emailDomain.js';
3238

3339
/**
3440
* Convert a display name to a URL-friendly slug.
@@ -136,13 +142,45 @@ interface CreateWorkspaceDialogProps {
136142
onCreated?: (org: AuthOrganization) => void;
137143
}
138144

145+
/**
146+
* The name a user's FIRST organization is prefilled with: a work e-mail names
147+
* the company from its domain's registrable label, capitalised
148+
* (`jane@acme.com` → `Acme`); a public mailbox — or a label that would not
149+
* read as typed (punycode, `xn--…`) — names no company, so it takes the
150+
* generic default `fallback`. An address with no domain gives nothing.
151+
*/
152+
function firstOrganizationName(email: string | null | undefined, fallback: string): string {
153+
const domain = emailDomain(email);
154+
if (domain === undefined) return '';
155+
if (isPublicMailboxDomain(domain)) return fallback;
156+
const label = registrableLabel(domain);
157+
if (!label || label.startsWith('xn--')) return fallback;
158+
return label.charAt(0).toUpperCase() + label.slice(1);
159+
}
160+
139161
export function CreateWorkspaceDialog({
140162
open,
141163
onOpenChange,
142164
onCreated,
143165
}: CreateWorkspaceDialogProps) {
144166
const { t } = useObjectTranslation();
145-
const { createOrganization, getAuthConfig } = useAuth();
167+
const { createOrganization, getAuthConfig, user, organizations, isOrganizationsLoading } = useAuth();
168+
169+
// Prefill only a FIRST organization (maintainer ruling objectstack-ai/cloud#3559
170+
// A scopes the default to the naming step after sign-up): a known-empty list,
171+
// not one still loading. A second organization derived from the same e-mail
172+
// would only repeat the first one's name.
173+
const isFirstOrganization =
174+
Array.isArray(organizations) && organizations.length === 0 && !isOrganizationsLoading;
175+
const defaultName =
176+
isFirstOrganization && user
177+
? firstOrganizationName(user.email, t('workspace.default', { defaultValue: 'My Organization' }))
178+
: '';
179+
// Read when the dialog opens, never after: creating the organization moves
180+
// `organizations` off zero mid-submit, and the name being submitted must not
181+
// change under the user.
182+
const defaultNameRef = useRef(defaultName);
183+
defaultNameRef.current = defaultName;
146184

147185
const [name, setName] = useState('');
148186
// The slug is not asked for (objectui#11659): it is derived from the name on
@@ -173,10 +211,10 @@ export function CreateWorkspaceDialog({
173211
};
174212
}, [open, getAuthConfig]);
175213

176-
// Reset form when dialog opens
214+
// Reset form when dialog opens: the first-organization default, else empty.
177215
useEffect(() => {
178216
if (open) {
179-
setName('');
217+
setName(defaultNameRef.current);
180218
setError(null);
181219
}
182220
}, [open]);

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

Lines changed: 120 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,33 @@
1717
* `Intl.DateTimeFormat.prototype.resolvedOptions`, to `Asia/Shanghai`, so the
1818
* create call's argument does not depend on the zone of the machine running
1919
* the suite (objectui#11908).
20+
*
21+
* `t` answers the `defaultValue` unless a case sets `packLang`, in which case
22+
* it answers that built-in pack's value; either way `{{x}}` is interpolated.
2023
*/
2124

2225
import '@testing-library/jest-dom/vitest';
2326
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
2427
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
2528
import { CreateWorkspaceDialog } from '../CreateWorkspaceDialog';
2629
import { provisionProductionEnvironment } from '../provisionEnvironment';
30+
import { builtInLocales } from '@object-ui/i18n/locales';
31+
32+
/** The pack `t` reads from; `null` answers the call site's `defaultValue`. */
33+
let packLang: 'en' | 'zh' | null = null;
34+
function translate(key: string, options?: Record<string, unknown>): string {
35+
const fromPack = packLang
36+
? key.split('.').reduce<unknown>((n, k) => (n as Record<string, unknown> | undefined)?.[k], builtInLocales[packLang])
37+
: undefined;
38+
const template = String(typeof fromPack === 'string' ? fromPack : (options?.defaultValue ?? key));
39+
return template.replace(/\{\{(\w+)\}\}/g, (whole, name: string) =>
40+
options && name in options ? String(options[name]) : whole,
41+
);
42+
}
2743

2844
vi.mock('@object-ui/i18n', async (importOriginal) => ({
2945
...(await importOriginal<Record<string, unknown>>()),
30-
useObjectTranslation: () => ({
31-
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
32-
}),
46+
useObjectTranslation: () => ({ t: translate }),
3347
}));
3448

3549
vi.mock('../provisionEnvironment', () => ({
@@ -38,9 +52,11 @@ vi.mock('../provisionEnvironment', () => ({
3852

3953
const createOrganization = vi.fn();
4054
const getAuthConfig = vi.fn();
55+
/** Extra `useAuth()` fields a case sets: `user`, `organizations`, `isOrganizationsLoading`. */
56+
let authState: Record<string, unknown> = {};
4157
vi.mock('@object-ui/auth', async (importOriginal) => ({
4258
...(await importOriginal<Record<string, unknown>>()),
43-
useAuth: () => ({ createOrganization, getAuthConfig }),
59+
useAuth: () => ({ createOrganization, getAuthConfig, ...authState }),
4460
}));
4561

4662
const provisionMock = vi.mocked(provisionProductionEnvironment);
@@ -59,6 +75,8 @@ beforeEach(() => {
5975
createOrganization.mockResolvedValue(NEW_ORG);
6076
provisionMock.mockResolvedValue({ id: 'env-1', hostname: 'os-abc123.objectstack.app' });
6177
browserZone = BROWSER_ZONE;
78+
packLang = null;
79+
authState = {};
6280
zoneReader = vi
6381
.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions')
6482
.mockImplementation(function (this: Intl.DateTimeFormat) {
@@ -263,3 +281,101 @@ describe('CreateWorkspaceDialog', () => {
263281
});
264282
});
265283
});
284+
285+
// Maintainer ruling objectstack-ai/cloud#3559 A: the dialog that opens right
286+
// after sign-up prefills a default name so one click continues; the field stays
287+
// editable, and nothing is created until the user clicks. A work e-mail names
288+
// the company; a public mailbox takes the existing `workspace.default` copy.
289+
describe('prefills a first organization from the e-mail (objectstack-ai/cloud#3559 A)', () => {
290+
const GMAIL_USER = { id: 'u1', email: 'zhang.san@gmail.com', name: 'Zhang San' };
291+
const WORK_USER = { id: 'u2', email: 'jane@acme.com', name: 'Jane Doe' };
292+
293+
function submitAsIs() {
294+
fireEvent.submit(screen.getByTestId('create-workspace-dialog').querySelector('form') as HTMLFormElement);
295+
}
296+
297+
it.each([
298+
['en', 'My Organization', 'my-organization'],
299+
['zh', '我的组织', null],
300+
] as const)('%s: a public-mailbox user sees the generic default and one click creates it', async (lang, expected, slug) => {
301+
packLang = lang;
302+
authState = { user: GMAIL_USER, organizations: [], isOrganizationsLoading: false };
303+
render(<CreateWorkspaceDialog open onOpenChange={() => {}} onCreated={() => {}} />);
304+
await settleAuthConfig();
305+
306+
expect(screen.getByTestId('workspace-name-input')).toHaveValue(expected);
307+
expect(createOrganization).not.toHaveBeenCalled();
308+
submitAsIs();
309+
310+
await waitFor(() => expect(createOrganization).toHaveBeenCalledTimes(1));
311+
const sent = createOrganization.mock.calls[0][0] as { name: string; slug: string };
312+
expect(sent.name).toBe(expected);
313+
if (slug) expect(sent.slug).toBe(slug);
314+
else expect(sent.slug).toMatch(/^workspace-[0-9a-z]+$/);
315+
await waitFor(() => expect(provisionMock).toHaveBeenCalledWith({ organizationId: 'org-123', displayName: expected }));
316+
});
317+
318+
it('a public mailbox subdomain (vip.qq.com) and a punycode work label take the generic default', () => {
319+
packLang = 'zh';
320+
for (const email of ['li@vip.qq.com', 'li@xn--fiqs8s.com']) {
321+
authState = { user: { ...GMAIL_USER, email }, organizations: [], isOrganizationsLoading: false };
322+
const { unmount } = render(<CreateWorkspaceDialog open onOpenChange={() => {}} />);
323+
expect(screen.getByTestId('workspace-name-input'), email).toHaveValue('我的组织');
324+
unmount();
325+
}
326+
});
327+
328+
it('a work-domain user sees the company name in both locales', async () => {
329+
for (const lang of ['en', 'zh'] as const) {
330+
packLang = lang;
331+
authState = { user: WORK_USER, organizations: [], isOrganizationsLoading: false };
332+
const { unmount } = render(<CreateWorkspaceDialog open onOpenChange={() => {}} />);
333+
expect(screen.getByTestId('workspace-name-input')).toHaveValue('Acme');
334+
unmount();
335+
}
336+
for (const [email, company] of [
337+
['jane@mail.acme.co.uk', 'Acme'],
338+
['jane@notgmail.com', 'Notgmail'],
339+
] as const) {
340+
authState = { user: { ...WORK_USER, email }, organizations: [], isOrganizationsLoading: false };
341+
const { unmount } = render(<CreateWorkspaceDialog open onOpenChange={() => {}} />);
342+
expect(screen.getByTestId('workspace-name-input'), email).toHaveValue(company);
343+
unmount();
344+
}
345+
});
346+
347+
it('the default stays editable: what the user types is what is created', async () => {
348+
authState = { user: WORK_USER, organizations: [], isOrganizationsLoading: false };
349+
render(<CreateWorkspaceDialog open onOpenChange={() => {}} />);
350+
await settleAuthConfig();
351+
352+
fillAndSubmit('Acme Robotics');
353+
354+
await waitFor(() => expect(createOrganization).toHaveBeenCalledTimes(1));
355+
expect((createOrganization.mock.calls[0][0] as { name: string }).name).toBe('Acme Robotics');
356+
});
357+
358+
it.each([
359+
['already has an organization', { organizations: [NEW_ORG], isOrganizationsLoading: false }],
360+
['has an organization list still loading', { organizations: [], isOrganizationsLoading: true }],
361+
['has no organization list at all', {}],
362+
] as const)('control: a user who %s opens with an empty name', async (_label, orgState) => {
363+
authState = { user: WORK_USER, ...orgState };
364+
render(<CreateWorkspaceDialog open onOpenChange={() => {}} />);
365+
await settleAuthConfig();
366+
367+
expect(screen.getByTestId('workspace-name-input')).toHaveValue('');
368+
expect(screen.getByTestId('workspace-create-submit')).toBeDisabled();
369+
});
370+
371+
it('the name does not change while open when the organization list moves off zero', async () => {
372+
authState = { user: WORK_USER, organizations: [], isOrganizationsLoading: false };
373+
const { rerender } = render(<CreateWorkspaceDialog open onOpenChange={() => {}} />);
374+
expect(screen.getByTestId('workspace-name-input')).toHaveValue('Acme');
375+
376+
authState = { user: WORK_USER, organizations: [NEW_ORG], isOrganizationsLoading: false };
377+
rerender(<CreateWorkspaceDialog open onOpenChange={() => {}} />);
378+
379+
expect(screen.getByTestId('workspace-name-input')).toHaveValue('Acme');
380+
});
381+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* emailDomain — the address parse, the closed public-mailbox list and the
5+
* registrable-label rule.
6+
*/
7+
8+
import { describe, it, expect } from 'vitest';
9+
import {
10+
emailDomain,
11+
isPublicMailboxDomain,
12+
registrableLabel,
13+
PUBLIC_MAILBOX_DOMAINS,
14+
} from '../emailDomain';
15+
16+
describe('emailDomain', () => {
17+
it.each([
18+
['jane@acme.com', 'acme.com'],
19+
[' Jane@ACME.COM ', 'acme.com'],
20+
['jane@acme.com.', 'acme.com'],
21+
// The LAST at sign separates the domain.
22+
['"a@b"@acme.com', 'acme.com'],
23+
])('%s → %s', (email, expected) => {
24+
expect(emailDomain(email)).toBe(expected);
25+
});
26+
27+
it.each([
28+
['no e-mail', undefined],
29+
['null', null],
30+
['an empty e-mail', ''],
31+
['no domain', 'jane@'],
32+
['no local part', '@acme.com'],
33+
['no at sign', 'jane.acme.com'],
34+
['a domain that is only a dot', 'jane@.'],
35+
])('%s → undefined', (_label, email) => {
36+
expect(emailDomain(email)).toBeUndefined();
37+
});
38+
});
39+
40+
describe('isPublicMailboxDomain', () => {
41+
it.each([...PUBLIC_MAILBOX_DOMAINS])('%s is public', (domain) => {
42+
expect(isPublicMailboxDomain(domain)).toBe(true);
43+
});
44+
45+
it('the closed list carries the common providers by name', () => {
46+
for (const named of [
47+
'gmail.com',
48+
'outlook.com',
49+
'hotmail.com',
50+
'yahoo.com',
51+
'icloud.com',
52+
'qq.com',
53+
'163.com',
54+
'126.com',
55+
'foxmail.com',
56+
'proton.me',
57+
]) {
58+
expect(PUBLIC_MAILBOX_DOMAINS.has(named), named).toBe(true);
59+
}
60+
});
61+
62+
it('a subdomain of a listed provider is that provider (vip.qq.com)', () => {
63+
expect(isPublicMailboxDomain('vip.qq.com')).toBe(true);
64+
});
65+
66+
it.each([
67+
// Merely ENDING in a listed name is not that provider.
68+
'notgmail.com',
69+
'acme.com',
70+
'mail.acme.co.uk',
71+
])('%s is not public', (domain) => {
72+
expect(isPublicMailboxDomain(domain)).toBe(false);
73+
});
74+
});
75+
76+
describe('registrableLabel', () => {
77+
it.each([
78+
['acme.com', 'acme'],
79+
['mail.acme.com', 'acme'],
80+
['a.b.acme.com', 'acme'],
81+
['acme.io', 'acme'],
82+
['acme.co.uk', 'acme'],
83+
['acme.com.cn', 'acme'],
84+
['acme.com.au', 'acme'],
85+
['eu.acme.co.jp', 'acme'],
86+
['acme-labs.com', 'acme-labs'],
87+
// `co` under a three-letter TLD is an ordinary label, not a registry pair.
88+
['acme.co.com', 'co'],
89+
['localhost', undefined],
90+
])('%s → %s', (domain, expected) => {
91+
expect(registrableLabel(domain)).toBe(expected);
92+
});
93+
});

0 commit comments

Comments
 (0)