diff --git a/.changeset/ai-seat-assignment-ux.md b/.changeset/ai-seat-assignment-ux.md new file mode 100644 index 000000000..faa359919 --- /dev/null +++ b/.changeset/ai-seat-assignment-ux.md @@ -0,0 +1,5 @@ +--- +'@object-ui/app-shell': patch +--- + +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. diff --git a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx index 94085873d..169d7288e 100644 --- a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx +++ b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx @@ -7,75 +7,260 @@ * * AssignedUsersSection — "Manage Assignments" for a permission set. * - * Renders the GENERIC `` (with its add-by-picker affordance) over - * the `sys_user_permission_set` junction, so an admin can grant/revoke a - * permission set to users WITHOUT raw data editing. This is the Salesforce - * "Assigned Users" pattern, expressed through the reusable related-list - * primitive rather than a bespoke editor: pick a `sys_user` → a junction row - * `{permission_set_id, user_id}` is created; remove deletes the junction row. - * Server-side insert rules still apply and surface inline — e.g. the AI-seat - * cap rejects the N+1 assignment for the `ai_seat` permission set. + * The admin's mental model is "who holds this role / AI seat" — so this is a + * people-first list (name + email + remove), not a raw junction table. It reads + * `sys_user_permission_set` for the set, resolves each `user_id` to a real + * person, and uses the reusable `RecordPickerDialog` to assign more. Server-side + * rules on the junction insert (e.g. the AI-seat cap) are caught and shown as a + * friendly, localized inline message — not a raw developer error. * - * It is deliberately permission-set-agnostic: every role/permission set gets - * the same management UI, and `ai_seat` (the AI-seat) is just one of them. + * Permission-set-agnostic: every role gets the same UI, and the AI seat + * (`ai_seat`) is just one of them. The generic add-by-picker engine (spec + * RecordRelatedListProps.add) powers the capability; this is the polished + * surface for the high-value case. */ import * as React from 'react'; +import { Button } from '@object-ui/components'; +import { RecordPickerDialog } from '@object-ui/fields'; import { useAdapter } from '@object-ui/react'; -import { RelatedList } from '@object-ui/plugin-detail'; +import { Plus, X, Users, Loader2, AlertCircle } from 'lucide-react'; +import { detectLocale } from './i18n'; export interface AssignedUsersSectionProps { /** The permission set's machine name (e.g. `ai_seat`, `admin_full_access`). */ permissionSetName: string; } +interface AssignedRow { + grantId: string; + userId: string; + name: string; + email: string; +} + +/** Minimal locale-aware copy (zh vs everything-else) — keeps the surface in the user's language. */ +function useCopy() { + const zh = React.useMemo(() => detectLocale().toLowerCase().startsWith('zh'), []); + return React.useMemo( + () => + zh + ? { + title: '已分配用户', + add: '添加用户', + remove: '移除', + empty: '还没有分配任何用户。点击「添加用户」来分配。', + loading: '加载中…', + pickTitle: '选择要分配的用户', + seatFull: (n: number) => + 'AI 席位已用完(' + n + '/' + n + ')。请先移除一个用户,或在许可证中提升席位上限,再分配新用户。', + addFailed: '分配失败,请重试。', + countOf: (n: number) => n + ' 人', + } + : { + title: 'Assigned Users', + add: 'Add user', + remove: 'Remove', + empty: 'No users assigned yet. Click "Add user" to assign.', + loading: 'Loading…', + pickTitle: 'Select users to assign', + seatFull: (n: number) => + 'All ' + n + ' AI seat(s) are in use. Remove a user or raise the license cap before assigning another.', + addFailed: 'Failed to assign. Please try again.', + countOf: (n: number) => String(n), + }, + [zh], + ); +} + +const asArray = (res: any): any[] => + Array.isArray(res) ? res : res?.records ?? res?.items ?? res?.data ?? []; + +const personLabel = (u: any): string => + u?.full_name || u?.name || u?.display_name || u?.email || String(u?.id ?? ''); + export function AssignedUsersSection({ permissionSetName }: AssignedUsersSectionProps) { - const adapter = useAdapter(); + const adapter = useAdapter() as any; + const c = useCopy(); + const [setId, setSetId] = React.useState(null); + const [rows, setRows] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [pickerOpen, setPickerOpen] = React.useState(false); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(null); + + const load = React.useCallback(async () => { + setLoading(true); + try { + const sets = asArray( + await adapter.find('sys_permission_set', { $filter: { name: permissionSetName }, limit: 1 }), + ); + const id = sets[0]?.id ? String(sets[0].id) : null; + setSetId(id); + if (!id) { + setRows([]); + return; + } + const grants = asArray( + await adapter.find('sys_user_permission_set', { $filter: { permission_set_id: id }, $top: 500 }), + ); + const userIds = [...new Set(grants.map((g: any) => g.user_id).filter(Boolean).map(String))]; + const users = userIds.length + ? asArray(await adapter.find('sys_user', { $filter: { id: { $in: userIds } }, $top: 500 })) + : []; + const byId = new Map(users.map((u: any) => [String(u.id), u])); + setRows( + grants + .filter((g: any) => g.user_id) + .map((g: any) => { + const u = byId.get(String(g.user_id)); + return { + grantId: String(g.id), + userId: String(g.user_id), + name: u ? personLabel(u) : String(g.user_id), + email: u?.email ?? '', + }; + }), + ); + } catch { + setRows([]); + } finally { + setLoading(false); + } + }, [adapter, permissionSetName]); - // Resolve the permission set's id from its name (the junction links by id). React.useEffect(() => { - let cancelled = false; - (async () => { + void load(); + }, [load]); + + const assignedIds = React.useMemo(() => new Set(rows.map((r) => r.userId)), [rows]); + + const addUsers = React.useCallback( + async (records: any[]) => { + if (!setId) return; + setBusy(true); + setError(null); try { - const res: any = await (adapter as any).find('sys_permission_set', { - $filter: { name: permissionSetName }, - limit: 1, - }); - const rows: any[] = Array.isArray(res) ? res : res?.data ?? res?.records ?? []; - const id = rows?.[0]?.id; - if (!cancelled) setSetId(id != null ? String(id) : null); - } catch { - if (!cancelled) setSetId(null); + for (const u of records || []) { + const uid = u?.id != null ? String(u.id) : null; + if (!uid || assignedIds.has(uid)) continue; + await adapter.create('sys_user_permission_set', { permission_set_id: setId, user_id: uid }); + } + await load(); + } catch (err: any) { + const raw = String(err?.body?.error ?? err?.error ?? err?.message ?? ''); + const capMatch = raw.match(/(\d+)\s*of\s*(\d+)\s*seat/i); + if (/cap reached|seat cap|ai[-_ ]?seat/i.test(raw)) { + setError(c.seatFull(capMatch ? Number(capMatch[2]) : rows.length)); + } else { + const cleaned = raw.replace(/^\s*\[[^\]]*\]\s*/, '').trim(); + setError(cleaned || c.addFailed); + } + } finally { + setBusy(false); + setPickerOpen(false); } - })(); - return () => { - cancelled = true; - }; - }, [adapter, permissionSetName]); + }, + [adapter, setId, assignedIds, load, rows.length, c], + ); - if (!setId) return null; + const removeUser = React.useCallback( + async (grantId: string) => { + setError(null); + try { + await adapter.delete('sys_user_permission_set', grantId); + await load(); + } catch { + /* keep the row; a failed delete is non-destructive */ + } + }, + [adapter, load], + ); return ( - { - const id = row?.id ?? row?._id; - if (id != null) await (adapter as any).delete?.('sys_user_permission_set', String(id)); - }} - /> +
+
+
+ + {c.title} + {!loading && ( + {c.countOf(rows.length)} + )} +
+ +
+ + {error && ( +
+ + {error} +
+ )} + + {loading ? ( +
+ + {c.loading} +
+ ) : rows.length === 0 ? ( +
{c.empty}
+ ) : ( +
    + {rows.map((r) => ( +
  • +
    + {(r.name || '?').slice(0, 1).toUpperCase()} +
    +
    +
    {r.name}
    + {r.email && r.email !== r.name && ( +
    {r.email}
    + )} +
    + +
  • + ))} +
+ )} + + {setId && ( + setPickerOpen(o)} + multiple + dataSource={adapter} + objectName="sys_user" + title={c.pickTitle} + onSelect={() => {}} + onSelectRecords={(records: any[]) => void addUsers(records)} + /> + )} +
); }