Skip to content

Commit 198da49

Browse files
fix(app-shell): the permission set's Add user picker shows each person's email beside their name (#27808)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgDFH4S5pR1C8YvYCWWsZfXx)_ ## 维护者速读 - 改了什么:设置 › 权限集 › 已分配用户 › 添加用户 的选人框,原来只有姓名一列,现在多一列邮箱。 - 为什么改:同名的两个人在选人框里分不出来,授予权限集前也没法确认账号;同页已分配的列表本来就显示邮箱。 - 风险与回滚:只改选人框的列与表头,授予写法、成员过滤、AI 席位上限提示都不变;回滚即 revert 本 PR。 - 先看:`AssignedUsersSection.tsx` 里 `RecordPickerDialog` 新增的 `objectSchema` 与 `columns` 两个属性;新测试 `AssignedUsersSection.pickerColumns-27433.test.tsx`。 - 两分钟验证:打开任一权限集 › 已分配用户 › 添加用户,预期选人框出现「姓名」「邮箱」两列;无权读 `sys_user.email` 的角色只看到姓名列。 - 需要你做的:无,席位落地。 Fixes #27433 Clause-②: no Metadata-protocol: none ## What changes On a Setup permission-set page, **Assigned users › Add user** opened `RecordPickerDialog` on `sys_user` with no `columns`, so the picker drew the name alone. It now passes, in the same shape as Position › Add holder (#27427): - `columns` for `name` and `email`, headed from the section's own en/zh copy (`Name`/`Email`, `姓名`/`邮箱`) — no new `@object-ui/i18n` key, so `Clause-②: no`; - `objectSchema` set to `sys_user` from the `useMetadata()` objects the section already holds — no new fetch; - no permission check of its own: the picker's own `readableColumns` rule decides what is drawn. Files: `ui/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx`, one new test beside it, `.changeset/27433-permission-set-user-picker-email.md` (`@object-ui/app-shell` + `@object-ui/console` patch, as #27427's). ## Tests `AssignedUsersSection.pickerColumns-27433.test.tsx`, against the real `RecordPickerDialog` (not a stub) and a real `PermissionProvider`: - the picker draws a Name column and an Email column (cells read by `data-lookup-cell`); - CONTROL: the picker offers the active organization's members only (a `sys_user` in another organization is not drawn); - CONTROL: choosing a person still writes `sys_user_permission_set` `{ user_id, permission_set }` by name; - CONTROL: an AI-seat cap refusal from the grant write still shows inline; - CONTROL: under a policy that denies `sys_user.email` (grant writes allowed), only the name column is drawn and no email text appears in the dialog. Ablation (one-shot, not kept): `scripts/ablation-replace.mjs` deleted the `columns` prop (anchor 1 → 0, blob `6e003bf1` → `495a0d0e`) → the column test went red (1 failed / 4 passed), the four CONTROLs stayed green; restored to blob == HEAD, `git diff HEAD` empty. The later commit `3b0005b0` only changes the schema cast's type. ## Acceptance notes - Every acceptance line of #27433 is pinned by a test above. - The seat-cap test asserts the inline sentence by a regex on its leading words only (the card names the inline refusal as the CONTROL; the copy is the section's own). - No out-of-scope findings. ## Verification All on head `3b0005b0` (base `968cacfd`), locally: <details><summary>Tests</summary> - `pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/AssignedUsersSection.pickerColumns-27433.test.tsx` — Test Files 1 passed, Tests 5 passed (verify lock VERDICT command-exit 0). - Every test file naming `AssignedUsersSection` (44 files: the section's own six, the PermissionMatrixEditor family, `setupHoldersGrant-12082`, `trailDoorsGate-12475`, `openPathObjectReaders-12811`, `affordanceGrantMap-12082` and others) — Test Files 44 passed, Tests 332 passed (VERDICT command-exit 0). - `pnpm --filter @object-ui/app-shell run type-check` — VERDICT command-exit 0. - Ablation as above: deleting `columns` → 1 failed / 4 passed; restored, `git diff HEAD` empty. </details> <details><summary>Derived gates (34)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, each run with its exit code recorded before any pipe; reconciled with `--ran`: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> `origin/main` moved 13 commits since the base; none touches the three changed files or `RecordPickerDialog`, so the branch is not merged with it (no overlap). Every CI family (Test UI, Type Check UI, Lint & Repo Gates, …) is the PR's own check-runs. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CazvDXxebLfghuZZ7t)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 07585bf commit 198da49

3 files changed

Lines changed: 206 additions & 0 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(setup): Permission set › Add user's picker shows each person's email beside their name
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Before.** On a Setup permission-set page, Assigned users › Add user opened a user picker that drew one column, the user's name. An administrator could not tell two people of the same name apart, nor confirm the account before granting the set, although the assigned-user list on the same page already shows each person's email under the name.
12+
- **Now.** The picker draws a Name column and an Email column (`sys_user`'s `name` and `email`), and titles each person by `sys_user` as the registry serves it.
13+
- **Unchanged.** The picker still draws only the columns the caller may read: for a caller whose policy denies `sys_user.email`, the Email column is not drawn and the Name column stands alone. Choosing people still writes the grant by name (`{ user_id, permission_set }`), offers the active organization's members only, and shows the AI-seat cap refusal inline.
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the MIT license.
2+
3+
/**
4+
* objectstack#27433 — a permission set's Add user picker shows each person's
5+
* email beside their name, as the assigned rows do, so two people of one name
6+
* can be told apart before either is granted the set. The same shape as the
7+
* positions page (objectstack#27410).
8+
*
9+
* Pinned against the real `RecordPickerDialog` (not a stub), so what is read
10+
* is the table the admin sees:
11+
*
12+
* - the picker draws a name column and an email column;
13+
* - CONTROL: choosing a person still writes the grant BY NAME, the picker
14+
* still offers the active organization's members only, and the AI-seat cap
15+
* refusal still shows inline;
16+
* - CONTROL: under a loaded policy that denies `sys_user.email`, the picker's
17+
* own read rule leaves the name column alone — the section adds no check of
18+
* its own and reads the email no other way.
19+
*/
20+
import * as React from 'react';
21+
import '@testing-library/jest-dom/vitest';
22+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
23+
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
24+
import { PermissionProvider } from '@object-ui/permissions';
25+
import type { ObjectPermissionConfig } from '@object-ui/types';
26+
27+
vi.mock('@object-ui/auth', async (importOriginal) => ({
28+
...(await importOriginal<Record<string, unknown>>()),
29+
useAuth: () => ({ activeOrganization: { id: 'org_1' } }),
30+
}));
31+
32+
const users = [
33+
{ id: 'u_1', name: 'Ada Admin', email: 'ada@example.com' },
34+
{ id: 'u_2', name: 'Sam Lee', email: 'sam.lee@example.com' },
35+
{ id: 'u_3', name: 'Sam Lee', email: 'slee@example.com' },
36+
{ id: 'u_9', name: 'Olga Outsider', email: 'olga@example.com' },
37+
];
38+
const grants = [{ id: 'grant_1', permission_set: 'ai_seat', user_id: 'u_1' }];
39+
/** `u_9` holds no membership row in `org_1`. */
40+
const members = [
41+
{ id: 'm_1', user_id: 'u_1', organization_id: 'org_1' },
42+
{ id: 'm_2', user_id: 'u_2', organization_id: 'org_1' },
43+
{ id: 'm_3', user_id: 'u_3', organization_id: 'org_1' },
44+
{ id: 'm_9', user_id: 'u_9', organization_id: 'org_2' },
45+
];
46+
const registered = [{ name: 'sys_user_permission_set' }, { name: 'sys_member' }, { name: 'sys_user' }];
47+
48+
/** Every `id.$in` list anywhere in a filter — the members filter, however the picker nests it. */
49+
function idLists(filter: unknown, out: string[][] = []): string[][] {
50+
if (Array.isArray(filter)) filter.forEach((f) => idLists(f, out));
51+
else if (filter && typeof filter === 'object') {
52+
for (const [k, v] of Object.entries(filter as Record<string, unknown>)) {
53+
if (k === 'id' && Array.isArray((v as { $in?: unknown } | null)?.$in)) out.push((v as { $in: string[] }).$in);
54+
else idLists(v, out);
55+
}
56+
}
57+
return out;
58+
}
59+
60+
const adapter = {
61+
find: vi.fn(async (object: string, query: any) => {
62+
if (object === 'sys_user_permission_set') return { data: grants };
63+
if (object === 'sys_member') {
64+
return { data: members.filter((m) => m.organization_id === query?.$filter?.organization_id) };
65+
}
66+
if (object === 'sys_user') {
67+
const lists = idLists(query?.$filter);
68+
const data = users.filter((u) => lists.every((ids) => ids.includes(u.id)));
69+
return { data, total: data.length };
70+
}
71+
return { data: [] };
72+
}),
73+
create: vi.fn(async (_object: string, _row: unknown): Promise<unknown> => ({})),
74+
delete: vi.fn(async () => ({})),
75+
};
76+
vi.mock('@object-ui/react', async (importOriginal) => ({
77+
...(await importOriginal<Record<string, unknown>>()),
78+
useAdapter: () => adapter,
79+
useMetadata: () => ({ objects: registered, ensureType: async () => [] }),
80+
}));
81+
82+
import { AssignedUsersSection } from './AssignedUsersSection';
83+
84+
/** A policy under which `viewer` may write grants but may not read `sys_user.email`. */
85+
const EMAIL_DENIED: ObjectPermissionConfig[] = [
86+
{
87+
object: 'sys_user',
88+
roles: { viewer: { actions: ['read'], fieldPermissions: [{ field: 'email', read: false }] } },
89+
} as ObjectPermissionConfig,
90+
{
91+
object: 'sys_user_permission_set',
92+
roles: { viewer: { actions: ['read', 'create', 'delete'] } },
93+
} as ObjectPermissionConfig,
94+
];
95+
96+
function cells(field: string): string[] {
97+
return Array.from(document.querySelectorAll(`[data-lookup-cell="${field}"]`)).map((el) => el.textContent ?? '');
98+
}
99+
100+
async function openPicker(): Promise<void> {
101+
await waitFor(() => expect(screen.getByText('Ada Admin')).toBeInTheDocument());
102+
fireEvent.click(screen.getByRole('button', { name: /Add user/ }));
103+
await waitFor(() => expect(screen.getByTestId('record-row-u_3')).toBeInTheDocument());
104+
}
105+
106+
beforeEach(() => {
107+
adapter.find.mockClear();
108+
adapter.create.mockReset();
109+
adapter.create.mockImplementation(async () => ({}));
110+
});
111+
112+
afterEach(() => {
113+
cleanup();
114+
});
115+
116+
describe('AssignedUsersSection — the picker shows email beside the name (objectstack#27433)', () => {
117+
it('draws a name column and an email column', async () => {
118+
render(<AssignedUsersSection permissionSetName="ai_seat" />);
119+
await openPicker();
120+
121+
const dialog = screen.getByTestId('record-picker-dialog');
122+
expect(dialog).toHaveTextContent('Email');
123+
expect(cells('name')).toEqual(['Ada Admin', 'Sam Lee', 'Sam Lee']);
124+
expect(cells('email')).toEqual(['ada@example.com', 'sam.lee@example.com', 'slee@example.com']);
125+
});
126+
127+
it('CONTROL: offers the active organization’s members only', async () => {
128+
render(<AssignedUsersSection permissionSetName="ai_seat" />);
129+
await openPicker();
130+
131+
expect(screen.queryByTestId('record-row-u_9')).toBeNull();
132+
expect(screen.getByTestId('record-picker-dialog')).not.toHaveTextContent('Olga Outsider');
133+
});
134+
135+
it('CONTROL: choosing a person still writes the grant by name', async () => {
136+
render(<AssignedUsersSection permissionSetName="ai_seat" />);
137+
await openPicker();
138+
139+
fireEvent.click(screen.getByTestId('record-row-u_3'));
140+
fireEvent.click(screen.getByTestId('record-picker-confirm'));
141+
await waitFor(() => expect(adapter.create).toHaveBeenCalledTimes(1));
142+
expect(adapter.create).toHaveBeenCalledWith('sys_user_permission_set', { user_id: 'u_3', permission_set: 'ai_seat' });
143+
});
144+
145+
it('CONTROL: the AI-seat cap refusal still shows inline', async () => {
146+
adapter.create.mockImplementation(async () => {
147+
throw new Error('AI seat cap reached: 1 of 1 seats in use');
148+
});
149+
render(<AssignedUsersSection permissionSetName="ai_seat" />);
150+
await openPicker();
151+
152+
fireEvent.click(screen.getByTestId('record-row-u_3'));
153+
fireEvent.click(screen.getByTestId('record-picker-confirm'));
154+
expect(await screen.findByText(/All 1 AI seat\(s\) are in use/)).toBeInTheDocument();
155+
});
156+
157+
it('CONTROL: a viewer who may not read email gets the name column alone', async () => {
158+
render(
159+
<PermissionProvider roles={[]} userRoles={['viewer']} permissions={EMAIL_DENIED}>
160+
<AssignedUsersSection permissionSetName="ai_seat" />
161+
</PermissionProvider>,
162+
);
163+
await openPicker();
164+
165+
expect(cells('name')).toEqual(['Ada Admin', 'Sam Lee', 'Sam Lee']);
166+
expect(cells('email')).toEqual([]);
167+
expect(screen.getByTestId('record-picker-dialog')).not.toHaveTextContent('slee@example.com');
168+
});
169+
});

‎ui/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ function useCopy() {
142142
sentenceGap: '',
143143
loading: '加载中…',
144144
pickTitle: '选择要分配的组织成员',
145+
nameColumn: '姓名',
146+
emailColumn: '邮箱',
145147
noMembers: '本组织还没有可分配的成员。',
146148
membersFailed: '无法读取本组织的成员。',
147149
removeTitle: (who: string) => `将 ${who} 从此权限集移除?`,
@@ -168,6 +170,8 @@ function useCopy() {
168170
sentenceGap: ' ',
169171
loading: 'Loading…',
170172
pickTitle: 'Select organization members to assign',
173+
nameColumn: 'Name',
174+
emailColumn: 'Email',
171175
noMembers: 'This organization has no members to assign yet.',
172176
membersFailed: 'Could not read the members of this organization.',
173177
removeTitle: (who: string) => `Remove ${who} from this permission set?`,
@@ -222,6 +226,18 @@ const refusalSentence = (err: unknown): string => extractWriteErrorMessage(err)
222226
const personLabel = (u: any): string =>
223227
u?.full_name || u?.name || u?.display_name || u?.email || String(u?.id ?? '');
224228

229+
/**
230+
* objectstack#27433 — the picker shows what an assigned row shows: the
231+
* person's name and, beside it, their email, so two people of one name can be
232+
* told apart before either is granted the set. `name` is the field `sys_user`
233+
* declares as its `nameField`, and `email` is its login identity. The picker
234+
* draws only the columns the caller may read (`RecordPickerDialog`'s own read
235+
* rule), so for a caller who may not read `email` the name column stands alone.
236+
* The same shape as `PositionHoldersSection` (objectstack#27410).
237+
*/
238+
const PICKER_NAME_FIELD = 'name';
239+
const PICKER_EMAIL_FIELD = 'email';
240+
225241
/** The audience anchors: implicit memberships, noted rather than enumerated. */
226242
const ANCHOR_POSITIONS = new Set(['everyone', 'guest']);
227243

@@ -321,6 +337,9 @@ export function AssignedUsersSection({ permissionSetName, onHoldersRead }: Assig
321337
const canAdd = resolveAffordance('relatedNew', grantSource).allowed;
322338
const canRemove = resolveAffordance('relatedRowDelete', grantSource).allowed;
323339
const memberOrganization = membershipOrganization(activeOrganizationId, metadataStore.objects);
340+
/** `sys_user` as the registry serves it, for the picker's column read rule. */
341+
const userSchema = ((metadataStore.objects ?? []).find((o) => (o as { name?: unknown } | null)?.name === 'sys_user') ??
342+
null) as Record<string, unknown> | null;
324343

325344
const load = React.useCallback(async () => {
326345
setLoading(true);
@@ -615,6 +634,11 @@ export function AssignedUsersSection({ permissionSetName, onHoldersRead }: Assig
615634
multiple
616635
dataSource={adapter}
617636
objectName="sys_user"
637+
objectSchema={userSchema}
638+
columns={[
639+
{ field: PICKER_NAME_FIELD, label: c.nameColumn },
640+
{ field: PICKER_EMAIL_FIELD, label: c.emailColumn },
641+
]}
618642
title={c.pickTitle}
619643
baseFilter={memberFilter}
620644
onSelect={() => {}}

0 commit comments

Comments
 (0)