Skip to content

Commit 8c2191d

Browse files
os-zhuangclaude
andauthored
fix(console): polished, localized 'Assigned Users' management (real-user UX pass) (#2002)
* fix(console): polished, localized 'Assigned Users' management for permission sets UX review (from a real admin's POV) found the prior generic-related-list embed unusable: it showed a raw user_id (couldn't tell who held a seat), English text in a Chinese UI, and a dev-flavored '[ObjectStack] AI-seat cap reached…' banner. Rewrite AssignedUsersSection as a people-first widget over sys_user_permission_set: - resolves each user_id to a real name/email (fetch+join sys_user; no lookup- timing dependence) → you can see WHO holds the seat - localized (zh/en via detectLocale): title, count, Add, Remove, empty state - friendly inline cap message (amber, no [Tag] prefix): maps the server cap error to 'AI 席位已用完(N/N)…' - people rows (avatar initial + name + email) with inline remove; '添加用户' opens the reusable RecordPickerDialog (multi-select) The generic add-by-picker engine (spec RecordRelatedListProps.add) still ships as the platform capability; this is the polished surface for the high-value case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(changeset): Assigned Users UX --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d6e7a84 commit 8c2191d

2 files changed

Lines changed: 239 additions & 49 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(console): polished, localized "Assigned Users" management for permission sets — resolves users to name/email (no raw id), zh/en localized, friendly inline cap message (drops the dev `[Tag]` prefix), people-rows with visible remove + add-via-picker.

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

Lines changed: 234 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -7,75 +7,260 @@
77
*
88
* AssignedUsersSection — "Manage Assignments" for a permission set.
99
*
10-
* Renders the GENERIC `<RelatedList>` (with its add-by-picker affordance) over
11-
* the `sys_user_permission_set` junction, so an admin can grant/revoke a
12-
* permission set to users WITHOUT raw data editing. This is the Salesforce
13-
* "Assigned Users" pattern, expressed through the reusable related-list
14-
* primitive rather than a bespoke editor: pick a `sys_user` → a junction row
15-
* `{permission_set_id, user_id}` is created; remove deletes the junction row.
16-
* Server-side insert rules still apply and surface inline — e.g. the AI-seat
17-
* cap rejects the N+1 assignment for the `ai_seat` permission set.
10+
* The admin's mental model is "who holds this role / AI seat" — so this is a
11+
* people-first list (name + email + remove), not a raw junction table. It reads
12+
* `sys_user_permission_set` for the set, resolves each `user_id` to a real
13+
* person, and uses the reusable `RecordPickerDialog` to assign more. Server-side
14+
* rules on the junction insert (e.g. the AI-seat cap) are caught and shown as a
15+
* friendly, localized inline message — not a raw developer error.
1816
*
19-
* It is deliberately permission-set-agnostic: every role/permission set gets
20-
* the same management UI, and `ai_seat` (the AI-seat) is just one of them.
17+
* Permission-set-agnostic: every role gets the same UI, and the AI seat
18+
* (`ai_seat`) is just one of them. The generic add-by-picker engine (spec
19+
* RecordRelatedListProps.add) powers the capability; this is the polished
20+
* surface for the high-value case.
2121
*/
2222

2323
import * as React from 'react';
24+
import { Button } from '@object-ui/components';
25+
import { RecordPickerDialog } from '@object-ui/fields';
2426
import { useAdapter } from '@object-ui/react';
25-
import { RelatedList } from '@object-ui/plugin-detail';
27+
import { Plus, X, Users, Loader2, AlertCircle } from 'lucide-react';
28+
import { detectLocale } from './i18n';
2629

2730
export interface AssignedUsersSectionProps {
2831
/** The permission set's machine name (e.g. `ai_seat`, `admin_full_access`). */
2932
permissionSetName: string;
3033
}
3134

35+
interface AssignedRow {
36+
grantId: string;
37+
userId: string;
38+
name: string;
39+
email: string;
40+
}
41+
42+
/** Minimal locale-aware copy (zh vs everything-else) — keeps the surface in the user's language. */
43+
function useCopy() {
44+
const zh = React.useMemo(() => detectLocale().toLowerCase().startsWith('zh'), []);
45+
return React.useMemo(
46+
() =>
47+
zh
48+
? {
49+
title: '已分配用户',
50+
add: '添加用户',
51+
remove: '移除',
52+
empty: '还没有分配任何用户。点击「添加用户」来分配。',
53+
loading: '加载中…',
54+
pickTitle: '选择要分配的用户',
55+
seatFull: (n: number) =>
56+
'AI 席位已用完(' + n + '/' + n + ')。请先移除一个用户,或在许可证中提升席位上限,再分配新用户。',
57+
addFailed: '分配失败,请重试。',
58+
countOf: (n: number) => n + ' 人',
59+
}
60+
: {
61+
title: 'Assigned Users',
62+
add: 'Add user',
63+
remove: 'Remove',
64+
empty: 'No users assigned yet. Click "Add user" to assign.',
65+
loading: 'Loading…',
66+
pickTitle: 'Select users to assign',
67+
seatFull: (n: number) =>
68+
'All ' + n + ' AI seat(s) are in use. Remove a user or raise the license cap before assigning another.',
69+
addFailed: 'Failed to assign. Please try again.',
70+
countOf: (n: number) => String(n),
71+
},
72+
[zh],
73+
);
74+
}
75+
76+
const asArray = (res: any): any[] =>
77+
Array.isArray(res) ? res : res?.records ?? res?.items ?? res?.data ?? [];
78+
79+
const personLabel = (u: any): string =>
80+
u?.full_name || u?.name || u?.display_name || u?.email || String(u?.id ?? '');
81+
3282
export function AssignedUsersSection({ permissionSetName }: AssignedUsersSectionProps) {
33-
const adapter = useAdapter();
83+
const adapter = useAdapter() as any;
84+
const c = useCopy();
85+
3486
const [setId, setSetId] = React.useState<string | null>(null);
87+
const [rows, setRows] = React.useState<AssignedRow[]>([]);
88+
const [loading, setLoading] = React.useState(true);
89+
const [pickerOpen, setPickerOpen] = React.useState(false);
90+
const [busy, setBusy] = React.useState(false);
91+
const [error, setError] = React.useState<string | null>(null);
92+
93+
const load = React.useCallback(async () => {
94+
setLoading(true);
95+
try {
96+
const sets = asArray(
97+
await adapter.find('sys_permission_set', { $filter: { name: permissionSetName }, limit: 1 }),
98+
);
99+
const id = sets[0]?.id ? String(sets[0].id) : null;
100+
setSetId(id);
101+
if (!id) {
102+
setRows([]);
103+
return;
104+
}
105+
const grants = asArray(
106+
await adapter.find('sys_user_permission_set', { $filter: { permission_set_id: id }, $top: 500 }),
107+
);
108+
const userIds = [...new Set(grants.map((g: any) => g.user_id).filter(Boolean).map(String))];
109+
const users = userIds.length
110+
? asArray(await adapter.find('sys_user', { $filter: { id: { $in: userIds } }, $top: 500 }))
111+
: [];
112+
const byId = new Map(users.map((u: any) => [String(u.id), u]));
113+
setRows(
114+
grants
115+
.filter((g: any) => g.user_id)
116+
.map((g: any) => {
117+
const u = byId.get(String(g.user_id));
118+
return {
119+
grantId: String(g.id),
120+
userId: String(g.user_id),
121+
name: u ? personLabel(u) : String(g.user_id),
122+
email: u?.email ?? '',
123+
};
124+
}),
125+
);
126+
} catch {
127+
setRows([]);
128+
} finally {
129+
setLoading(false);
130+
}
131+
}, [adapter, permissionSetName]);
35132

36-
// Resolve the permission set's id from its name (the junction links by id).
37133
React.useEffect(() => {
38-
let cancelled = false;
39-
(async () => {
134+
void load();
135+
}, [load]);
136+
137+
const assignedIds = React.useMemo(() => new Set(rows.map((r) => r.userId)), [rows]);
138+
139+
const addUsers = React.useCallback(
140+
async (records: any[]) => {
141+
if (!setId) return;
142+
setBusy(true);
143+
setError(null);
40144
try {
41-
const res: any = await (adapter as any).find('sys_permission_set', {
42-
$filter: { name: permissionSetName },
43-
limit: 1,
44-
});
45-
const rows: any[] = Array.isArray(res) ? res : res?.data ?? res?.records ?? [];
46-
const id = rows?.[0]?.id;
47-
if (!cancelled) setSetId(id != null ? String(id) : null);
48-
} catch {
49-
if (!cancelled) setSetId(null);
145+
for (const u of records || []) {
146+
const uid = u?.id != null ? String(u.id) : null;
147+
if (!uid || assignedIds.has(uid)) continue;
148+
await adapter.create('sys_user_permission_set', { permission_set_id: setId, user_id: uid });
149+
}
150+
await load();
151+
} catch (err: any) {
152+
const raw = String(err?.body?.error ?? err?.error ?? err?.message ?? '');
153+
const capMatch = raw.match(/(\d+)\s*of\s*(\d+)\s*seat/i);
154+
if (/cap reached|seat cap|ai[-_ ]?seat/i.test(raw)) {
155+
setError(c.seatFull(capMatch ? Number(capMatch[2]) : rows.length));
156+
} else {
157+
const cleaned = raw.replace(/^\s*\[[^\]]*\]\s*/, '').trim();
158+
setError(cleaned || c.addFailed);
159+
}
160+
} finally {
161+
setBusy(false);
162+
setPickerOpen(false);
50163
}
51-
})();
52-
return () => {
53-
cancelled = true;
54-
};
55-
}, [adapter, permissionSetName]);
164+
},
165+
[adapter, setId, assignedIds, load, rows.length, c],
166+
);
56167

57-
if (!setId) return null;
168+
const removeUser = React.useCallback(
169+
async (grantId: string) => {
170+
setError(null);
171+
try {
172+
await adapter.delete('sys_user_permission_set', grantId);
173+
await load();
174+
} catch {
175+
/* keep the row; a failed delete is non-destructive */
176+
}
177+
},
178+
[adapter, load],
179+
);
58180

59181
return (
60-
<RelatedList
61-
title="Assigned Users"
62-
type="table"
63-
api="sys_user_permission_set"
64-
objectName="sys_user_permission_set"
65-
referenceField="permission_set_id"
66-
parentId={setId}
67-
dataSource={adapter as any}
68-
columns={['user_id', 'granted_by']}
69-
add={{
70-
picker: { object: 'sys_user', valueField: 'id', labelField: 'email' },
71-
linkField: 'user_id',
72-
label: 'Add user',
73-
}}
74-
onRowDelete={async (row: any) => {
75-
const id = row?.id ?? row?._id;
76-
if (id != null) await (adapter as any).delete?.('sys_user_permission_set', String(id));
77-
}}
78-
/>
182+
<div className="px-4 py-4">
183+
<div className="flex items-center justify-between mb-3">
184+
<div className="flex items-center gap-2 text-sm font-medium">
185+
<Users className="h-4 w-4 text-muted-foreground" />
186+
<span>{c.title}</span>
187+
{!loading && (
188+
<span className="text-xs text-muted-foreground font-normal">{c.countOf(rows.length)}</span>
189+
)}
190+
</div>
191+
<Button
192+
variant="outline"
193+
size="sm"
194+
disabled={busy || !setId}
195+
onClick={() => {
196+
setError(null);
197+
setPickerOpen(true);
198+
}}
199+
className="gap-1 h-8 text-xs"
200+
>
201+
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
202+
{c.add}
203+
</Button>
204+
</div>
205+
206+
{error && (
207+
<div
208+
className="mb-3 flex items-start gap-2 rounded-md border border-amber-300/60 bg-amber-50 dark:bg-amber-950/30 px-3 py-2 text-xs text-amber-800 dark:text-amber-200"
209+
role="alert"
210+
>
211+
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
212+
<span>{error}</span>
213+
</div>
214+
)}
215+
216+
{loading ? (
217+
<div className="flex items-center gap-2 text-xs text-muted-foreground py-3">
218+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
219+
{c.loading}
220+
</div>
221+
) : rows.length === 0 ? (
222+
<div className="text-xs text-muted-foreground italic py-3">{c.empty}</div>
223+
) : (
224+
<ul className="divide-y rounded-md border">
225+
{rows.map((r) => (
226+
<li key={r.grantId} className="flex items-center gap-3 px-3 py-2">
227+
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-medium shrink-0">
228+
{(r.name || '?').slice(0, 1).toUpperCase()}
229+
</div>
230+
<div className="min-w-0 flex-1">
231+
<div className="text-sm truncate">{r.name}</div>
232+
{r.email && r.email !== r.name && (
233+
<div className="text-xs text-muted-foreground truncate">{r.email}</div>
234+
)}
235+
</div>
236+
<Button
237+
variant="ghost"
238+
size="sm"
239+
onClick={() => void removeUser(r.grantId)}
240+
aria-label={c.remove}
241+
title={c.remove}
242+
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0"
243+
>
244+
<X className="h-4 w-4" />
245+
</Button>
246+
</li>
247+
))}
248+
</ul>
249+
)}
250+
251+
{setId && (
252+
<RecordPickerDialog
253+
open={pickerOpen}
254+
onOpenChange={(o: boolean) => setPickerOpen(o)}
255+
multiple
256+
dataSource={adapter}
257+
objectName="sys_user"
258+
title={c.pickTitle}
259+
onSelect={() => {}}
260+
onSelectRecords={(records: any[]) => void addUsers(records)}
261+
/>
262+
)}
263+
</div>
79264
);
80265
}
81266

0 commit comments

Comments
 (0)