diff --git a/.changeset/28241-setup-people-assignment-pages.md b/.changeset/28241-setup-people-assignment-pages.md new file mode 100644 index 00000000000..57e03fa16df --- /dev/null +++ b/.changeset/28241-setup-people-assignment-pages.md @@ -0,0 +1,18 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(console): Setup pages for position holders and permission set assignment + +Clause-②: no +Metadata-protocol: none + +An administrator can now manage who holds a position, and who holds a permission set, without opening the position's or the set's own page: + +- **Position holders** (`security:position-holders`): pick a position from the environment's catalog, then list, add and remove its holders. +- **Permission set assignment** (`security:permission-set-assignment`): pick a permission set, then list its users (direct grants and holders through a position), and assign or unassign direct grants. + +Each page renders the same section the item page renders, so who may add or remove, and every refusal the server answers (the delegated-administration gate's included), are unchanged and shown in the server's own words. The picked item rides the page's URL (`?position=` / `?set=`), so a shared or refreshed link opens it again. A failed read of the catalog says so and offers a retry instead of showing an empty list. The sections inside the position and permission set pages keep working as before. + +The pages are registered as component refs and open at `/apps/setup/component/security/position-holders` and `/apps/setup/component/security/permission-set-assignment`. No Setup navigation entry names them yet: those entries are contributed by the server's security plugin and follow separately. New copy is translated in all ten console languages. diff --git a/ui/packages/app-shell/src/services/builtinComponents.tsx b/ui/packages/app-shell/src/services/builtinComponents.tsx index 2a956abe6e6..2022040b08d 100644 --- a/ui/packages/app-shell/src/services/builtinComponents.tsx +++ b/ui/packages/app-shell/src/services/builtinComponents.tsx @@ -24,6 +24,9 @@ import { PositionGrantedSetsCell, PositionHolderCountCell } from '../views/metad import { PackagedAutomationPage } from '../views/setup/PackagedAutomationPage.js'; import { ConnectorCredentialsPage } from '../views/setup/ConnectorCredentialsPage.js'; import { CONNECTOR_CREDENTIALS_REF } from '../views/setup/connectorCredentials.js'; +import { PositionHoldersPage } from '../views/setup/PositionHoldersPage.js'; +import { PermissionSetAssignmentPage } from '../views/setup/PermissionSetAssignmentPage.js'; +import { PERMISSION_SET_ASSIGNMENT_REF, POSITION_HOLDERS_REF } from '../views/setup/peopleAssignments.js'; import { isAggregatedViewContainer, viewDisplayType, @@ -164,6 +167,26 @@ registerAppComponent({ component: ConnectorCredentialsPage, }); +/** + * ADR-0146 D7 — Setup → People & organization: who holds a position, and who + * holds a permission set. Each page picks a catalog item and renders the + * section that item's own page renders; the entries naming these refs are the + * security plugin's Setup navigation contributions. + */ +registerAppComponent({ + ref: POSITION_HOLDERS_REF, + label: 'Position Holders', + source: '@object-ui/app-shell', + component: PositionHoldersPage, +}); + +registerAppComponent({ + ref: PERMISSION_SET_ASSIGNMENT_REF, + label: 'Permission Set Assignment', + source: '@object-ui/app-shell', + component: PermissionSetAssignmentPage, +}); + /* -------------------------------------------------------------------------- */ /* 2) Generic resources — list + JSONSchema-driven form for every type. */ /* -------------------------------------------------------------------------- */ diff --git a/ui/packages/app-shell/src/views/setup/CatalogAssignmentPage.tsx b/ui/packages/app-shell/src/views/setup/CatalogAssignmentPage.tsx new file mode 100644 index 00000000000..8eeed6e33f5 --- /dev/null +++ b/ui/packages/app-shell/src/views/setup/CatalogAssignmentPage.tsx @@ -0,0 +1,216 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The shape both Setup assignment pages share (ADR-0146 D7): the catalog items + * of one registry type on the left, the picked item's assignment section on + * the right. The picked item lives in the URL (AGENTS.md #8), so a refreshed + * or shared link opens it again. + * + * Honesty: a failed read of the catalog is said as a failure with a retry, + * never drawn as "no items" — the metadata store answers a failed list with + * an empty one and records the failure on the type's status. + * + * Who may add or remove is not judged here. The section asks the caller's + * grant on the assignment object and the server judges every write, its + * delegated-administration gate included; this page only picks the item. + */ + +import * as React from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { + Button, + Empty, + EmptyDescription, + EmptyTitle, + Input, + Skeleton, + cn, +} from '@object-ui/components'; +import { useMetadata } from '@object-ui/react'; +import { AlertCircle } from 'lucide-react'; +import { PageHeader } from '../../layout/PageHeader.js'; +import { catalogEntries, filterCatalogEntries, type CatalogEntry } from './peopleAssignments.js'; + +export interface CatalogAssignmentCopy { + title: string; + subtitle: string; + listLabel: string; + filterPlaceholder: string; + emptyTitle: string; + emptyBody: string; + pick: string; + loadFailed: string; + noMatch: string; + notFound: (name: string) => string; + retry: string; + refresh: string; +} + +export interface CatalogAssignmentPageProps { + /** The registry type whose items are picked (`position`, `permission`). */ + type: string; + /** The page-scoped query parameter that names the picked item. */ + param: string; + icon: React.ReactNode; + copy: CatalogAssignmentCopy; + /** The assignment section for the picked item. */ + renderSection: (name: string) => React.ReactNode; + testId: string; +} + +type CatalogRead = { kind: 'loading' } | { kind: 'failed' } | { kind: 'ready'; entries: CatalogEntry[] }; + +export function CatalogAssignmentPage({ type, param, icon, copy, renderSection, testId }: CatalogAssignmentPageProps) { + const metadata = useMetadata(); + // Read through a ref: the store's context value is rebuilt whenever any + // cached type settles, and AGENTS.md #10 forbids keying the read on it. + const metadataRef = React.useRef(metadata); + metadataRef.current = metadata; + + const [read, setRead] = React.useState({ kind: 'loading' }); + const [nonce, setNonce] = React.useState(0); + const [query, setQuery] = React.useState(''); + const [search, setSearch] = useSearchParams(); + const picked = search.get(param) ?? ''; + + React.useEffect(() => { + let live = true; + setRead({ kind: 'loading' }); + metadataRef.current.ensureType(type).then((items) => { + if (!live) return; + if (metadataRef.current.getTypeStatus?.(type) === 'error') setRead({ kind: 'failed' }); + else setRead({ kind: 'ready', entries: catalogEntries(items) }); + }); + return () => { + live = false; + }; + }, [type, nonce]); + + /** Read the catalog again, past the store's cache and its failure cooldown. */ + const reload = React.useCallback(() => { + setRead({ kind: 'loading' }); + void metadataRef.current.refresh(type).finally(() => setNonce((n) => n + 1)); + }, [type]); + + const pick = (name: string) => { + setSearch( + (prev) => { + const next = new URLSearchParams(prev); + next.set(param, name); + return next; + }, + { replace: true }, + ); + }; + + const entries = read.kind === 'ready' ? read.entries : []; + const shown = filterCatalogEntries(entries, query); + const pickedKnown = entries.some((e) => e.name === picked); + + let list: React.ReactNode; + if (read.kind === 'loading') { + list = ( +
+ + + +
+ ); + } else if (read.kind === 'failed') { + list = ( +
+
+
+ +
+ ); + } else if (entries.length === 0) { + list = ( + + {copy.emptyTitle} + {copy.emptyBody} + + ); + } else { + list = ( +
+ setQuery(e.target.value)} + placeholder={copy.filterPlaceholder} + aria-label={copy.filterPlaceholder} + className="h-8 text-sm" + /> + {shown.length === 0 ? ( +

{copy.noMatch}

+ ) : ( + + )} +
+ ); + } + + let detail: React.ReactNode; + if (!picked) { + detail =

{copy.pick}

; + } else if (read.kind === 'ready' && !pickedKnown) { + detail = ( +

+ {copy.notFound(picked)} +

+ ); + } else if (read.kind !== 'ready') { + detail = null; + } else { + const entry = entries.find((e) => e.name === picked)!; + detail = ( +
+

{entry.label}

+ {entry.name} + {entry.description &&

{entry.description}

} + {/* Keyed by the item: the section's state is that item's, never carried to the next one. */} + {renderSection(entry.name)} +
+ ); + } + + return ( +
+ +
+ +
{detail}
+
+
+ ); +} diff --git a/ui/packages/app-shell/src/views/setup/PermissionSetAssignmentPage.test.tsx b/ui/packages/app-shell/src/views/setup/PermissionSetAssignmentPage.test.tsx new file mode 100644 index 00000000000..181fe2b1fd9 --- /dev/null +++ b/ui/packages/app-shell/src/views/setup/PermissionSetAssignmentPage.test.tsx @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup → People & organization → Permission set assignment (ADR-0146 D7): + * pick a permission set from the registry's catalog, then list, assign and + * unassign its users through the section the set's own editor renders. The + * picked set lives in the URL; a refused write is shown in the server's own + * words, the delegated-administration gate's included. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; + +// The picker stands in as one button, rendered while it is open, that selects +// `pickedUsers` — the same `onSelectRecords` hand-off the real dialog makes. +vi.mock('@object-ui/fields', async (importOriginal) => { + const { createElement } = await import('react'); + return { + ...(await importOriginal()), + RecordPickerDialog: (props: { open: boolean; onSelectRecords?: (records: unknown[]) => void }) => + props.open + ? createElement('button', { type: 'button', onClick: () => props.onSelectRecords?.(pickedUsers) }, 'pick') + : null, + }; +}); +const pickedUsers = [{ id: 'u_new', name: 'New Nora' }]; + +const permissionSets = [ + { name: 'sales_user', label: 'Sales user' }, + { name: 'auditor_read', label: 'Auditor read' }, +]; +const positions = [{ name: 'sales_rep', label: 'Sales rep', permissionSets: ['sales_user'] }]; + +let data: Record = {}; +function resetData() { + data = { + sys_user_permission_set: [{ id: 'grant_1', permission_set: 'sales_user', user_id: 'u_direct' }], + sys_user_position: [{ id: 'up_1', user_id: 'u_held', position: 'sales_rep' }], + sys_user: [ + { id: 'u_direct', name: 'Direct Dana', email: 'dana@example.com' }, + { id: 'u_held', name: 'Held Henry', email: 'henry@example.com' }, + { id: 'u_new', name: 'New Nora', email: 'nora@example.com' }, + ], + }; +} + +const adapter = { + find: vi.fn(async (object: string, query: any) => { + const rows = data[object] ?? []; + const filter = query?.$filter ?? {}; + return rows.filter((r) => + Object.entries(filter).every(([k, v]: [string, any]) => { + if (v && typeof v === 'object' && Array.isArray(v.$in)) return v.$in.includes(r[k]); + return r[k] === v; + }), + ); + }), + create: vi.fn(async (object: string, row: Record) => { + const created = { id: `grant_${(data[object]?.length ?? 0) + 10}`, ...row }; + data[object] = [...(data[object] ?? []), created]; + return created; + }), + delete: vi.fn(async (object: string, id: string) => { + data[object] = (data[object] ?? []).filter((r) => r.id !== id); + return {}; + }), +}; + +const metadata = { + objects: [], + ensureType: vi.fn(async (type: string) => + type === 'permission' ? permissionSets : type === 'position' ? positions : [], + ), + getTypeStatus: vi.fn(() => 'ready' as const), + refresh: vi.fn(async () => {}), +}; +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => adapter, + useMetadata: () => metadata, +})); + +import { PermissionSetAssignmentPage } from './PermissionSetAssignmentPage'; + +function LocationProbe() { + const location = useLocation(); + return {location.search}; +} + +function renderPage(search = '') { + return render( + + + + , + ); +} + +beforeEach(() => { + resetData(); + adapter.find.mockClear(); + adapter.create.mockClear(); + adapter.delete.mockClear(); + metadata.ensureType.mockClear(); +}); + +afterEach(cleanup); + +describe('Setup → Permission set assignment', () => { + it('lists the catalog permission sets and asks for a pick before reading any grant', async () => { + renderPage(); + const list = await screen.findByRole('list', { name: 'Permission sets' }); + expect(within(list).getByRole('button', { name: /Sales user/ })).toBeInTheDocument(); + expect(within(list).getByRole('button', { name: /Auditor read/ })).toBeInTheDocument(); + expect(screen.getByTestId('setup-permission-set-assignment-pick')).toBeInTheDocument(); + expect(metadata.ensureType).toHaveBeenCalledWith('permission'); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('picking a set puts it in the URL and lists its direct and position-held users', async () => { + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: /Sales user/ })); + expect(screen.getByTestId('location')).toHaveTextContent('?set=sales_user'); + expect(await screen.findByText('Direct Dana')).toBeInTheDocument(); + expect(screen.getByText('Held Henry')).toBeInTheDocument(); + }); + + it('assigns a user by the set name and the list reads it back', async () => { + renderPage('?set=sales_user'); + await screen.findByText('Direct Dana'); + fireEvent.click(screen.getByRole('button', { name: 'Add user' })); + fireEvent.click(await screen.findByRole('button', { name: 'pick' })); + await waitFor(() => + expect(adapter.create).toHaveBeenCalledWith('sys_user_permission_set', { + user_id: 'u_new', + permission_set: 'sales_user', + }), + ); + expect(await screen.findByText('New Nora')).toBeInTheDocument(); + }); + + it('unassigns a direct grant after the confirm and the list reads it gone', async () => { + renderPage('?set=sales_user'); + await screen.findByText('Direct Dana'); + fireEvent.click(screen.getByLabelText('Remove')); + fireEvent.click(await screen.findByTestId('assigned-user-remove-confirm')); + await waitFor(() => expect(adapter.delete).toHaveBeenCalledWith('sys_user_permission_set', 'grant_1')); + await waitFor(() => expect(screen.queryByText('Direct Dana')).toBeNull()); + // The holder through a position stays: a position's holder is not unassigned here. + expect(screen.getByText('Held Henry')).toBeInTheDocument(); + }); + + it("shows a delegated administrator's out-of-scope refusal verbatim", async () => { + // The delegated-administration gate's refusal as the server throws it; the + // console shows its sentence without the `[Security] ` machine prefix, as + // every write refusal is read (`extractWriteErrorMessage`). + const sentence = "Outside your delegated scope: granting permission set 'sales_user' — the row belongs to organization 'org_b', not the one you administer."; + const refusal = Object.assign(new Error(`[Security] ${sentence}`), { + code: 'DELEGATION_SCOPE_VIOLATION', + httpStatus: 403, + }); + adapter.create.mockRejectedValueOnce(refusal); + renderPage('?set=sales_user'); + await screen.findByText('Direct Dana'); + fireEvent.click(screen.getByRole('button', { name: 'Add user' })); + fireEvent.click(await screen.findByRole('button', { name: 'pick' })); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toBe(sentence); + expect(screen.queryByText('New Nora')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/setup/PermissionSetAssignmentPage.tsx b/ui/packages/app-shell/src/views/setup/PermissionSetAssignmentPage.tsx new file mode 100644 index 00000000000..1c69e4cbd97 --- /dev/null +++ b/ui/packages/app-shell/src/views/setup/PermissionSetAssignmentPage.tsx @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup → People & organization → Permission set assignment (ADR-0146 D7): + * pick a permission set, then list, assign and unassign its users. The list + * is the registry's `permission` catalog; the users are `AssignedUsersSection`, + * the section the permission set's own editor renders under its matrix, so + * the reads (direct grants and holders via a position), the writes, the grant + * gate on Add / Remove and every server refusal are that section's. + */ + +import { Users } from 'lucide-react'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { AssignedUsersSection } from '../metadata-admin/AssignedUsersSection.js'; +import { CatalogAssignmentPage } from './CatalogAssignmentPage.js'; +import { PERMISSION_SET_PARAM } from './peopleAssignments.js'; + +export function PermissionSetAssignmentPage() { + const { t } = useObjectTranslation(); + return ( + } + copy={{ + title: t('setupAssignments.setsTitle', { defaultValue: 'Permission set assignment' }), + subtitle: t('setupAssignments.setsSubtitle', { + defaultValue: + 'Pick a permission set to see who holds it, directly or through a position, and assign or unassign users. What a set grants is defined on the set itself.', + }), + listLabel: t('setupAssignments.setsList', { defaultValue: 'Permission sets' }), + filterPlaceholder: t('setupAssignments.setsFilter', { defaultValue: 'Filter permission sets' }), + emptyTitle: t('setupAssignments.setsEmptyTitle', { defaultValue: 'No permission sets' }), + emptyBody: t('setupAssignments.setsEmptyBody', { defaultValue: 'No permission set is defined in this environment yet.' }), + pick: t('setupAssignments.setsPick', { defaultValue: 'Pick a permission set to see its users.' }), + loadFailed: t('setupAssignments.setsLoadFailed', { defaultValue: 'Could not read the permission sets.' }), + noMatch: t('setupAssignments.noMatch', { defaultValue: 'Nothing matches the filter.' }), + notFound: (name) => t('setupAssignments.notFound', { defaultValue: '"{{name}}" is not in this list.', name }), + retry: t('setupAssignments.retry', { defaultValue: 'Try again' }), + refresh: t('setupAssignments.refresh', { defaultValue: 'Refresh' }), + }} + renderSection={(name) => ( + // The bordered box the permission set's editor draws around the same section. +
+ +
+ )} + /> + ); +} diff --git a/ui/packages/app-shell/src/views/setup/PositionHoldersPage.test.tsx b/ui/packages/app-shell/src/views/setup/PositionHoldersPage.test.tsx new file mode 100644 index 00000000000..8c5ec8088d4 --- /dev/null +++ b/ui/packages/app-shell/src/views/setup/PositionHoldersPage.test.tsx @@ -0,0 +1,192 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup → People & organization → Position holders (ADR-0146 D7): pick a + * position from the registry's catalog, then list, add and remove its holders + * through the section the position's item page renders. The picked position + * lives in the URL; a refused write is shown in the server's own words, the + * delegated-administration gate's included; a failed catalog read is a + * failure, never "no positions". + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; + +let pickRecords: ((records: any[]) => void) | null = null; +vi.mock('@object-ui/fields', async (importOriginal) => ({ + ...(await importOriginal()), + RecordPickerDialog: (props: { onSelectRecords: (records: any[]) => void }) => { + pickRecords = props.onSelectRecords; + return null; + }, +})); + +const positions = [ + { name: 'auditor', label: 'Auditor', description: 'Reads the books.' }, + { name: 'sales_rep', label: 'Sales rep' }, +]; +const users = [ + { id: 'u_1', name: 'Ada Auditor', email: 'ada@example.com' }, + { id: 'u_2', name: 'Bob Builder', email: 'bob@example.com' }, +]; +let assignments: Array<{ id: string; user_id: string; position: string }> = []; + +const adapter = { + find: vi.fn(async (object: string, query: any) => { + if (object === 'sys_user_position') { + return { data: assignments.filter((a) => a.position === query?.$filter?.position) }; + } + if (object === 'sys_user') { + const ids: string[] = query?.$filter?.id?.$in ?? []; + return { data: users.filter((u) => ids.includes(u.id)) }; + } + return { data: [] }; + }), + create: vi.fn(async (_object: string, row: { user_id: string; position: string }) => { + const created = { id: `up_${assignments.length + 10}`, ...row }; + assignments.push(created); + return created; + }), + delete: vi.fn(async (_object: string, id: string) => { + assignments = assignments.filter((a) => a.id !== id); + return {}; + }), +}; + +let typeStatus: 'ready' | 'error' = 'ready'; +const metadata = { + objects: [], + ensureType: vi.fn(async (type: string) => (type === 'position' && typeStatus === 'ready' ? positions : [])), + getTypeStatus: vi.fn(() => typeStatus), + refresh: vi.fn(async () => {}), +}; +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => adapter, + useMetadata: () => metadata, +})); + +import { PositionHoldersPage } from './PositionHoldersPage'; + +function LocationProbe() { + const location = useLocation(); + return {location.search}; +} + +function renderPage(search = '') { + return render( + + + + , + ); +} + +beforeEach(() => { + assignments = [{ id: 'up_1', user_id: 'u_1', position: 'auditor' }]; + typeStatus = 'ready'; + pickRecords = null; + adapter.find.mockClear(); + adapter.create.mockClear(); + adapter.delete.mockClear(); + metadata.ensureType.mockClear(); + metadata.refresh.mockClear(); +}); + +afterEach(cleanup); + +describe('Setup → Position holders', () => { + it('lists the catalog positions and asks for a pick before reading any holder', async () => { + renderPage(); + const list = await screen.findByRole('list', { name: 'Positions' }); + expect(within(list).getByRole('button', { name: /Auditor/ })).toBeInTheDocument(); + expect(within(list).getByRole('button', { name: /Sales rep/ })).toBeInTheDocument(); + expect(screen.getByTestId('setup-position-holders-pick')).toBeInTheDocument(); + expect(metadata.ensureType).toHaveBeenCalledWith('position'); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('picking a position puts it in the URL and lists its holders', async () => { + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: /Auditor/ })); + expect(screen.getByTestId('location')).toHaveTextContent('?position=auditor'); + expect(await screen.findByText('Ada Auditor')).toBeInTheDocument(); + expect(screen.getByText('Reads the books.')).toBeInTheDocument(); + expect(adapter.find).toHaveBeenCalledWith('sys_user_position', { $filter: { position: 'auditor' }, $top: 1000 }); + }); + + it('adds a holder by name and the list reads it back', async () => { + renderPage('?position=auditor'); + await screen.findByText('Ada Auditor'); + pickRecords!([{ id: 'u_2' }]); + await waitFor(() => + expect(adapter.create).toHaveBeenCalledWith('sys_user_position', { user_id: 'u_2', position: 'auditor' }), + ); + expect(await screen.findByText('Bob Builder')).toBeInTheDocument(); + }); + + it('removes a holder after the confirm and the list reads it gone', async () => { + renderPage('?position=auditor'); + await screen.findByText('Ada Auditor'); + fireEvent.click(screen.getByLabelText('Remove')); + fireEvent.click(await screen.findByTestId('position-holder-remove-confirm')); + await waitFor(() => expect(adapter.delete).toHaveBeenCalledWith('sys_user_position', 'up_1')); + await waitFor(() => expect(screen.queryByText('Ada Auditor')).toBeNull()); + expect(screen.getByText('Nobody holds this position yet.')).toBeInTheDocument(); + }); + + it("shows a delegated administrator's out-of-scope refusal verbatim", async () => { + // The delegated-administration gate's refusal as the server throws it; the + // console shows its sentence without the `[Security] ` machine prefix, as + // every write refusal is read (`extractWriteErrorMessage`). + const sentence = "Outside your delegated scope: assigning position 'auditor' — business unit 'bu_east' is outside the delegated subtree."; + const refusal = Object.assign(new Error(`[Security] ${sentence}`), { + code: 'DELEGATION_SCOPE_VIOLATION', + httpStatus: 403, + }); + adapter.create.mockRejectedValueOnce(refusal); + renderPage('?position=auditor'); + await screen.findByText('Ada Auditor'); + pickRecords!([{ id: 'u_2' }]); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toBe(sentence); + expect(screen.queryByText('Bob Builder')).toBeNull(); + }); + + it('says a failed catalog read failed, never "no positions", and retries past the cache', async () => { + typeStatus = 'error'; + renderPage('?position=auditor'); + expect(await screen.findByTestId('setup-position-holders-load-failed')).toHaveTextContent( + 'Could not read the positions.', + ); + expect(screen.queryByTestId('setup-position-holders-empty')).toBeNull(); + expect(adapter.find).not.toHaveBeenCalled(); + + typeStatus = 'ready'; + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(metadata.refresh).toHaveBeenCalledWith('position'); + expect(await screen.findByText('Ada Auditor')).toBeInTheDocument(); + }); + + it('says a URL naming no catalog position is not in the list, and reads no holder for it', async () => { + renderPage('?position=ghost'); + expect(await screen.findByTestId('setup-position-holders-not-found')).toHaveTextContent( + '"ghost" is not in this list.', + ); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('filters the list by label or name', async () => { + renderPage(); + const filter = await screen.findByRole('textbox', { name: 'Filter positions' }); + fireEvent.change(filter, { target: { value: 'sales_' } }); + const list = screen.getByRole('list', { name: 'Positions' }); + expect(within(list).queryByRole('button', { name: /Auditor/ })).toBeNull(); + expect(within(list).getByRole('button', { name: /Sales rep/ })).toBeInTheDocument(); + fireEvent.change(filter, { target: { value: 'zzz' } }); + expect(screen.getByText('Nothing matches the filter.')).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/app-shell/src/views/setup/PositionHoldersPage.tsx b/ui/packages/app-shell/src/views/setup/PositionHoldersPage.tsx new file mode 100644 index 00000000000..40635bda646 --- /dev/null +++ b/ui/packages/app-shell/src/views/setup/PositionHoldersPage.tsx @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup → People & organization → Position holders (ADR-0146 D7): pick a + * position, then list, add and remove its holders. The list is the registry's + * `position` catalog; the holders are `PositionHoldersSection`, the section + * the position's own item page renders, so the reads, the writes, the grant + * gate on Add / Remove and every server refusal are that section's. + */ + +import { UserCheck } from 'lucide-react'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { PositionHoldersSection } from '../metadata-admin/PositionHoldersSection.js'; +import { CatalogAssignmentPage } from './CatalogAssignmentPage.js'; +import { POSITION_PARAM } from './peopleAssignments.js'; + +export function PositionHoldersPage() { + const { t } = useObjectTranslation(); + return ( + } + copy={{ + title: t('setupAssignments.positionsTitle', { defaultValue: 'Position holders' }), + subtitle: t('setupAssignments.positionsSubtitle', { + defaultValue: + 'Pick a position to see who holds it, and add or remove holders. What a position grants is defined on the position itself.', + }), + listLabel: t('setupAssignments.positionsList', { defaultValue: 'Positions' }), + filterPlaceholder: t('setupAssignments.positionsFilter', { defaultValue: 'Filter positions' }), + emptyTitle: t('setupAssignments.positionsEmptyTitle', { defaultValue: 'No positions' }), + emptyBody: t('setupAssignments.positionsEmptyBody', { defaultValue: 'No position is defined in this environment yet.' }), + pick: t('setupAssignments.positionsPick', { defaultValue: 'Pick a position to see its holders.' }), + loadFailed: t('setupAssignments.positionsLoadFailed', { defaultValue: 'Could not read the positions.' }), + noMatch: t('setupAssignments.noMatch', { defaultValue: 'Nothing matches the filter.' }), + notFound: (name) => t('setupAssignments.notFound', { defaultValue: '"{{name}}" is not in this list.', name }), + retry: t('setupAssignments.retry', { defaultValue: 'Try again' }), + refresh: t('setupAssignments.refresh', { defaultValue: 'Refresh' }), + }} + renderSection={(name) => } + /> + ); +} diff --git a/ui/packages/app-shell/src/views/setup/peopleAssignments.navContribution.test.tsx b/ui/packages/app-shell/src/views/setup/peopleAssignments.navContribution.test.tsx new file mode 100644 index 00000000000..ffcd0ae2904 --- /dev/null +++ b/ui/packages/app-shell/src/views/setup/peopleAssignments.navContribution.test.tsx @@ -0,0 +1,78 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `security:position-holders` and `security:permission-set-assignment` — the + * component-registry keys the Setup → People & organization entries name + * (ADR-0146 D7). The same three properties as the other Setup pages' files: + * + * 1. each key is registered by the production registration module, imported + * the way `index.ts` does; + * 2. each addresses `component/security/`, the URL its entry opens, + * built from the ref by the same helper; + * 3. each page mounts through that route. + * + * ⛔ No bespoke ``: the Setup nav contribution is the one way in. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +// Side effect: the production registration, imported the way `index.ts` does. +import '../../services/builtinComponents.js'; +import { + componentRefToUrlSegments, + getAppComponent, +} from '../../services/componentRegistry.js'; +import { ComponentNavView } from '../ComponentNavView.js'; +import { + PERMISSION_SET_ASSIGNMENT_REF, + POSITION_HOLDERS_REF, + permissionSetAssignmentPagePath, + positionHoldersPagePath, +} from './peopleAssignments.js'; + +afterEach(cleanup); + +function mount(path: string) { + render( + + + } /> + + , + ); +} + +describe('the People & organization assignment pages as Setup nav contributions', () => { + it('registers both refs, owned by app-shell', () => { + expect(POSITION_HOLDERS_REF).toBe('security:position-holders'); + expect(PERMISSION_SET_ASSIGNMENT_REF).toBe('security:permission-set-assignment'); + for (const ref of [POSITION_HOLDERS_REF, PERMISSION_SET_ASSIGNMENT_REF]) { + expect(getAppComponent(ref)?.source).toBe('@object-ui/app-shell'); + } + }); + + it('addresses component/security/, and the item rides the page-scoped param', () => { + expect(componentRefToUrlSegments(POSITION_HOLDERS_REF)).toEqual(['security', 'position-holders']); + expect(componentRefToUrlSegments(PERMISSION_SET_ASSIGNMENT_REF)).toEqual(['security', 'permission-set-assignment']); + expect(positionHoldersPagePath('sales rep')).toBe('/apps/setup/component/security/position-holders?position=sales%20rep'); + expect(permissionSetAssignmentPagePath()).toBe('/apps/setup/component/security/permission-set-assignment'); + }); + + it('mounts the Position holders page through the component route its ref builds', async () => { + mount(positionHoldersPagePath()); + expect(await screen.findByRole('heading', { name: 'Position holders' })).toBeInTheDocument(); + expect(await screen.findByTestId('setup-position-holders-empty')).toBeInTheDocument(); + expect(screen.queryByText('Component not registered')).toBeNull(); + }); + + it('mounts the Permission set assignment page through the component route its ref builds', async () => { + mount(permissionSetAssignmentPagePath()); + expect(await screen.findByRole('heading', { name: 'Permission set assignment' })).toBeInTheDocument(); + expect(await screen.findByTestId('setup-permission-set-assignment-empty')).toBeInTheDocument(); + expect(screen.queryByText('Component not registered')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/setup/peopleAssignments.test.ts b/ui/packages/app-shell/src/views/setup/peopleAssignments.test.ts new file mode 100644 index 00000000000..31da457c302 --- /dev/null +++ b/ui/packages/app-shell/src/views/setup/peopleAssignments.test.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { catalogEntries, filterCatalogEntries } from './peopleAssignments.js'; + +describe('catalogEntries', () => { + it('keeps named items, labels a label-less one by its name, and sorts by label then name', () => { + const entries = catalogEntries([ + { name: 'zeta', label: 'Auditor' }, + { name: 'alpha' }, + { label: 'no name' }, + null, + { name: 'beta', label: ' ', description: 'Reads.' }, + { name: 'alpha', label: 'Duplicate' }, + ]); + expect(entries).toEqual([ + { name: 'alpha', label: 'alpha', description: '' }, + { name: 'zeta', label: 'Auditor', description: '' }, + { name: 'beta', label: 'beta', description: 'Reads.' }, + ]); + }); +}); + +describe('filterCatalogEntries', () => { + const entries = catalogEntries([{ name: 'sales_rep', label: 'Sales rep' }, { name: 'auditor', label: 'Auditor' }]); + + it('matches the label or the name, ignoring case and surrounding space', () => { + expect(filterCatalogEntries(entries, ' SALES ').map((e) => e.name)).toEqual(['sales_rep']); + expect(filterCatalogEntries(entries, 'audi').map((e) => e.name)).toEqual(['auditor']); + expect(filterCatalogEntries(entries, 'rep').map((e) => e.name)).toEqual(['sales_rep']); + }); + + it('returns every entry for an empty query', () => { + expect(filterCatalogEntries(entries, '')).toHaveLength(2); + }); +}); diff --git a/ui/packages/app-shell/src/views/setup/peopleAssignments.ts b/ui/packages/app-shell/src/views/setup/peopleAssignments.ts new file mode 100644 index 00000000000..359926748ee --- /dev/null +++ b/ui/packages/app-shell/src/views/setup/peopleAssignments.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup → People & organization: the two assignment pages (ADR-0146 D7). + * + * Assigning people is operational work, not authoring: a position's holders and + * a permission set's direct grants are rows of the caller's organization, and + * an administrator reaches them here without opening a catalog item. Each page + * picks one catalog item and renders the section the item page already renders + * for it (`PositionHoldersSection`, `AssignedUsersSection`), so the reads, the + * writes and every server refusal are the sections' own. + * + * The entries that open them are Setup navigation contributions, which the + * server's security plugin declares; these refs are what those entries name. + */ + +import { componentRefToUrlSegments } from '../../services/componentRegistry.js'; + +/** The component ref of the Position holders page. */ +export const POSITION_HOLDERS_REF = 'security:position-holders'; + +/** The component ref of the Permission set assignment page. */ +export const PERMISSION_SET_ASSIGNMENT_REF = 'security:permission-set-assignment'; + +/** + * The page-scoped query parameter that names the picked item. Addressable + * state (AGENTS.md #8): a shared or refreshed link opens the same item. + */ +export const POSITION_PARAM = 'position'; +export const PERMISSION_SET_PARAM = 'set'; + +/** Where a page opens inside the Setup app, optionally on one item. */ +function pagePath(ref: string, param: string, name?: string): string { + const base = `/apps/setup/component/${componentRefToUrlSegments(ref).join('/')}`; + return name ? `${base}?${param}=${encodeURIComponent(name)}` : base; +} + +export function positionHoldersPagePath(position?: string): string { + return pagePath(POSITION_HOLDERS_REF, POSITION_PARAM, position); +} + +export function permissionSetAssignmentPagePath(set?: string): string { + return pagePath(PERMISSION_SET_ASSIGNMENT_REF, PERMISSION_SET_PARAM, set); +} + +/** One pickable catalog item: its machine name and what the list shows. */ +export interface CatalogEntry { + name: string; + /** The served label, or the name when the item has none. */ + label: string; + description: string; +} + +/** + * The pickable items of a registry list (`/meta/`), by label then name. + * An item without a string name cannot be addressed, so it is left out. + */ +export function catalogEntries(items: readonly unknown[]): CatalogEntry[] { + const out: CatalogEntry[] = []; + const seen = new Set(); + for (const raw of items) { + const it = raw as { name?: unknown; label?: unknown; description?: unknown } | null; + if (typeof it?.name !== 'string' || !it.name || seen.has(it.name)) continue; + seen.add(it.name); + const label = typeof it.label === 'string' && it.label.trim() ? it.label : it.name; + const description = typeof it.description === 'string' ? it.description : ''; + out.push({ name: it.name, label, description }); + } + return out.sort((a, b) => a.label.localeCompare(b.label) || a.name.localeCompare(b.name)); +} + +/** The entries whose label or name contains `query`, ignoring case. */ +export function filterCatalogEntries(entries: readonly CatalogEntry[], query: string): CatalogEntry[] { + const q = query.trim().toLocaleLowerCase(); + if (!q) return [...entries]; + return entries.filter((e) => e.label.toLocaleLowerCase().includes(q) || e.name.toLocaleLowerCase().includes(q)); +} diff --git a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index 30912cb8c93..ad2273f6c38 100644 --- a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -366,7 +366,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // 101 once objectstack-ai/objectstack#27823 added // `objectActions.deletedNamed`, the delete toast that names the record // („{{name}}“) — one interpolated span, runtime data. - expect(okSpans, 'correctly paired spans').toBe(101); + // 102 once objectstack-ai/objectstack#28241 added `setupAssignments.notFound`, + // the Setup assignment pages' line that names an unknown item („{{name}}“) — + // one interpolated span, runtime data. + expect(okSpans, 'correctly paired spans').toBe(102); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -437,8 +440,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // 100 / 100 / 0 after objectstack-ai/objectstack#27480 added // `chatbot.plan.iterateOrNew.question` and `.extend`, one matched pair each. // 101 / 101 / 0 after objectstack-ai/objectstack#27823 added - // `objectActions.deletedNamed`, one more matched pair. - expect({ open, close, rdq }).toEqual({ open: 101, close: 101, rdq: 0 }); + // `objectActions.deletedNamed`, one more matched pair. 102 / 102 / 0 after + // objectstack-ai/objectstack#28241 added `setupAssignments.notFound`, one + // more matched pair. + expect({ open, close, rdq }).toEqual({ open: 102, close: 102, rdq: 0 }); // The durable shape: every „ closed by a “, every surplus “ an English // opener answered by a ”. Survived translating the two English values. expect(close).toBe(open + rdq); diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 2122b9dff6c..8644b230e7c 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -5264,6 +5264,28 @@ const ar = { skippedBody: "تبقى الموصلات التي تستخدمها غير متصلة حتى يُعيَّن كل منها.", openPage: "عيّنها في بيانات اعتماد الموصلات", }, + setupAssignments: { + positionsTitle: "شاغلو المناصب", + positionsSubtitle: "اختر منصبًا لترى من يشغله، وأضف شاغلين أو أزلهم. ما يمنحه المنصب محدد في المنصب نفسه.", + positionsList: "المناصب", + positionsFilter: "تصفية المناصب", + positionsEmptyTitle: "لا توجد مناصب", + positionsEmptyBody: "لم يُعرَّف أي منصب في هذه البيئة بعد.", + positionsPick: "اختر منصبًا لعرض شاغليه.", + positionsLoadFailed: "تعذّرت قراءة المناصب.", + setsTitle: "إسناد مجموعات الأذونات", + setsSubtitle: "اختر مجموعة أذونات لترى من يملكها، مباشرةً أو عبر منصب، وأسندها إلى المستخدمين أو ألغِ إسنادها. ما تمنحه المجموعة محدد في المجموعة نفسها.", + setsList: "مجموعات الأذونات", + setsFilter: "تصفية مجموعات الأذونات", + setsEmptyTitle: "لا توجد مجموعات أذونات", + setsEmptyBody: "لم تُعرَّف أي مجموعة أذونات في هذه البيئة بعد.", + setsPick: "اختر مجموعة أذونات لعرض مستخدميها.", + setsLoadFailed: "تعذّرت قراءة مجموعات الأذونات.", + noMatch: "لا شيء يطابق عامل التصفية.", + notFound: "\"{{name}}\" ليس في هذه القائمة.", + retry: "إعادة المحاولة", + refresh: "تحديث", + }, element: { number: { noObject: "لم يُحدَّد أي كائن: عيّن dataSource.object.", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 8a9803d2ff4..89897c88683 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -4778,6 +4778,28 @@ const de = { skippedBody: "Die Connectors, die sie verwenden, bleiben getrennt, bis jeder Wert gesetzt ist.", openPage: "Unter Connector-Zugangsdaten festlegen", }, + setupAssignments: { + positionsTitle: "Positionsinhaber", + positionsSubtitle: "Wählen Sie eine Position, um zu sehen, wer sie innehat, und fügen Sie Inhaber hinzu oder entfernen Sie sie. Was eine Position gewährt, ist in der Position selbst festgelegt.", + positionsList: "Positionen", + positionsFilter: "Positionen filtern", + positionsEmptyTitle: "Keine Positionen", + positionsEmptyBody: "In dieser Umgebung ist noch keine Position definiert.", + positionsPick: "Wählen Sie eine Position, um ihre Inhaber zu sehen.", + positionsLoadFailed: "Die Positionen konnten nicht gelesen werden.", + setsTitle: "Berechtigungssatz-Zuweisung", + setsSubtitle: "Wählen Sie einen Berechtigungssatz, um zu sehen, wer ihn direkt oder über eine Position innehat, und weisen Sie Benutzer zu oder heben Sie die Zuweisung auf. Was ein Satz gewährt, ist im Satz selbst festgelegt.", + setsList: "Berechtigungssätze", + setsFilter: "Berechtigungssätze filtern", + setsEmptyTitle: "Keine Berechtigungssätze", + setsEmptyBody: "In dieser Umgebung ist noch kein Berechtigungssatz definiert.", + setsPick: "Wählen Sie einen Berechtigungssatz, um seine Benutzer zu sehen.", + setsLoadFailed: "Die Berechtigungssätze konnten nicht gelesen werden.", + noMatch: "Nichts entspricht dem Filter.", + notFound: "„{{name}}“ ist nicht in dieser Liste.", + retry: "Erneut versuchen", + refresh: "Aktualisieren", + }, element: { number: { noObject: "Kein Objekt angegeben: Legen Sie dataSource.object fest.", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index fc159b3d4fa..41f43484c34 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -5595,6 +5595,30 @@ const en = { skippedBody: 'The connectors that use them stay disconnected until each one is set.', openPage: 'Set them in Connector credentials', }, + // Setup → People & organization: Position holders and Permission set + // assignment (ADR-0146 D7). + setupAssignments: { + positionsTitle: 'Position holders', + positionsSubtitle: 'Pick a position to see who holds it, and add or remove holders. What a position grants is defined on the position itself.', + positionsList: 'Positions', + positionsFilter: 'Filter positions', + positionsEmptyTitle: 'No positions', + positionsEmptyBody: 'No position is defined in this environment yet.', + positionsPick: 'Pick a position to see its holders.', + positionsLoadFailed: 'Could not read the positions.', + setsTitle: 'Permission set assignment', + setsSubtitle: 'Pick a permission set to see who holds it, directly or through a position, and assign or unassign users. What a set grants is defined on the set itself.', + setsList: 'Permission sets', + setsFilter: 'Filter permission sets', + setsEmptyTitle: 'No permission sets', + setsEmptyBody: 'No permission set is defined in this environment yet.', + setsPick: 'Pick a permission set to see its users.', + setsLoadFailed: 'Could not read the permission sets.', + noMatch: 'Nothing matches the filter.', + notFound: '"{{name}}" is not in this list.', + retry: 'Try again', + refresh: 'Refresh', + }, element: { // objectui#10951 — `element:number` authored with an aggregate and no // `dataSource.object` (objectui#11880: the flat `object` is not read). Kept diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index f68aff15eb1..6b9c1114f6e 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -4899,6 +4899,28 @@ const es = { skippedBody: "Los conectores que las usan siguen desconectados hasta que se configure cada una.", openPage: "Configúralas en Credenciales de conectores", }, + setupAssignments: { + positionsTitle: "Titulares de puestos", + positionsSubtitle: "Elija un puesto para ver quién lo ocupa y añadir o quitar titulares. Lo que concede un puesto se define en el propio puesto.", + positionsList: "Puestos", + positionsFilter: "Filtrar puestos", + positionsEmptyTitle: "No hay puestos", + positionsEmptyBody: "Aún no hay ningún puesto definido en este entorno.", + positionsPick: "Elija un puesto para ver sus titulares.", + positionsLoadFailed: "No se pudieron leer los puestos.", + setsTitle: "Asignación de conjuntos de permisos", + setsSubtitle: "Elija un conjunto de permisos para ver quién lo tiene, directamente o a través de un puesto, y asignar o desasignar usuarios. Lo que concede un conjunto se define en el propio conjunto.", + setsList: "Conjuntos de permisos", + setsFilter: "Filtrar conjuntos de permisos", + setsEmptyTitle: "No hay conjuntos de permisos", + setsEmptyBody: "Aún no hay ningún conjunto de permisos definido en este entorno.", + setsPick: "Elija un conjunto de permisos para ver sus usuarios.", + setsLoadFailed: "No se pudieron leer los conjuntos de permisos.", + noMatch: "Nada coincide con el filtro.", + notFound: "\"{{name}}\" no está en esta lista.", + retry: "Reintentar", + refresh: "Actualizar", + }, element: { number: { noObject: "No se ha indicado ningún objeto: define dataSource.object.", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 274c0725279..29267a79324 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -4895,6 +4895,28 @@ const fr = { skippedBody: "Les connecteurs qui les utilisent restent déconnectés tant que chacun n'est pas défini.", openPage: "Les définir dans Identifiants des connecteurs", }, + setupAssignments: { + positionsTitle: "Titulaires de postes", + positionsSubtitle: "Choisissez un poste pour voir qui l’occupe, et ajoutez ou retirez des titulaires. Ce qu’un poste accorde est défini sur le poste lui-même.", + positionsList: "Postes", + positionsFilter: "Filtrer les postes", + positionsEmptyTitle: "Aucun poste", + positionsEmptyBody: "Aucun poste n’est encore défini dans cet environnement.", + positionsPick: "Choisissez un poste pour voir ses titulaires.", + positionsLoadFailed: "Impossible de lire les postes.", + setsTitle: "Attribution des ensembles d’autorisations", + setsSubtitle: "Choisissez un ensemble d’autorisations pour voir qui le détient, directement ou par un poste, et attribuez-le ou retirez-le à des utilisateurs. Ce qu’un ensemble accorde est défini sur l’ensemble lui-même.", + setsList: "Ensembles d’autorisations", + setsFilter: "Filtrer les ensembles d’autorisations", + setsEmptyTitle: "Aucun ensemble d’autorisations", + setsEmptyBody: "Aucun ensemble d’autorisations n’est encore défini dans cet environnement.", + setsPick: "Choisissez un ensemble d’autorisations pour voir ses utilisateurs.", + setsLoadFailed: "Impossible de lire les ensembles d’autorisations.", + noMatch: "Rien ne correspond au filtre.", + notFound: "« {{name}} » ne figure pas dans cette liste.", + retry: "Réessayer", + refresh: "Actualiser", + }, element: { number: { noObject: "Aucun objet indiqué : définissez dataSource.object.", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 3e0c055e100..07eab8dc875 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -4779,6 +4779,28 @@ const ja = { skippedBody: "これらを使うコネクタは、それぞれが設定されるまで切断されたままです。", openPage: "コネクタ認証情報で設定する", }, + setupAssignments: { + positionsTitle: "役職の保持者", + positionsSubtitle: "役職を選んで保持者を確認し、保持者を追加または削除します。役職が付与する内容は役職そのものに定義されています。", + positionsList: "役職", + positionsFilter: "役職を絞り込む", + positionsEmptyTitle: "役職がありません", + positionsEmptyBody: "この環境にはまだ役職が定義されていません。", + positionsPick: "役職を選ぶと保持者が表示されます。", + positionsLoadFailed: "役職を読み込めませんでした。", + setsTitle: "権限セットの割り当て", + setsSubtitle: "権限セットを選んで、直接または役職を通じて保持しているユーザーを確認し、ユーザーを割り当てまたは割り当て解除します。セットが付与する内容はセットそのものに定義されています。", + setsList: "権限セット", + setsFilter: "権限セットを絞り込む", + setsEmptyTitle: "権限セットがありません", + setsEmptyBody: "この環境にはまだ権限セットが定義されていません。", + setsPick: "権限セットを選ぶとユーザーが表示されます。", + setsLoadFailed: "権限セットを読み込めませんでした。", + noMatch: "絞り込み条件に一致する項目はありません。", + notFound: "「{{name}}」はこの一覧にありません。", + retry: "再試行", + refresh: "更新", + }, element: { number: { noObject: "オブジェクトが指定されていません。dataSource.object を設定してください。", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index a9de46c5142..43bcc761d22 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -4776,6 +4776,28 @@ const ko = { skippedBody: "이를 사용하는 커넥터는 각각 설정될 때까지 연결이 끊긴 상태로 유지됩니다.", openPage: "커넥터 자격 증명에서 설정", }, + setupAssignments: { + positionsTitle: "직책 보유자", + positionsSubtitle: "직책을 선택해 보유자를 확인하고 보유자를 추가하거나 제거하세요. 직책이 부여하는 권한은 직책 자체에 정의되어 있습니다.", + positionsList: "직책", + positionsFilter: "직책 필터", + positionsEmptyTitle: "직책 없음", + positionsEmptyBody: "이 환경에는 아직 정의된 직책이 없습니다.", + positionsPick: "직책을 선택하면 보유자가 표시됩니다.", + positionsLoadFailed: "직책을 읽을 수 없습니다.", + setsTitle: "권한 집합 할당", + setsSubtitle: "권한 집합을 선택해 직접 또는 직책을 통해 보유한 사용자를 확인하고 사용자를 할당하거나 할당 해제하세요. 집합이 부여하는 권한은 집합 자체에 정의되어 있습니다.", + setsList: "권한 집합", + setsFilter: "권한 집합 필터", + setsEmptyTitle: "권한 집합 없음", + setsEmptyBody: "이 환경에는 아직 정의된 권한 집합이 없습니다.", + setsPick: "권한 집합을 선택하면 사용자가 표시됩니다.", + setsLoadFailed: "권한 집합을 읽을 수 없습니다.", + noMatch: "필터와 일치하는 항목이 없습니다.", + notFound: "\"{{name}}\"은(는) 이 목록에 없습니다.", + retry: "다시 시도", + refresh: "새로 고침", + }, element: { number: { noObject: "개체가 지정되지 않았습니다. dataSource.object를 설정하세요.", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 1ba5dab8ea3..ad50cd187a0 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -4894,6 +4894,28 @@ const pt = { skippedBody: "Os conectores que as usam continuam desconectados até que cada uma seja definida.", openPage: "Defina-as em Credenciais de conectores", }, + setupAssignments: { + positionsTitle: "Titulares de cargos", + positionsSubtitle: "Escolha um cargo para ver quem o ocupa e adicionar ou remover titulares. O que um cargo concede é definido no próprio cargo.", + positionsList: "Cargos", + positionsFilter: "Filtrar cargos", + positionsEmptyTitle: "Nenhum cargo", + positionsEmptyBody: "Ainda não há nenhum cargo definido neste ambiente.", + positionsPick: "Escolha um cargo para ver seus titulares.", + positionsLoadFailed: "Não foi possível ler os cargos.", + setsTitle: "Atribuição de conjuntos de permissões", + setsSubtitle: "Escolha um conjunto de permissões para ver quem o possui, diretamente ou por meio de um cargo, e atribuir ou remover usuários. O que um conjunto concede é definido no próprio conjunto.", + setsList: "Conjuntos de permissões", + setsFilter: "Filtrar conjuntos de permissões", + setsEmptyTitle: "Nenhum conjunto de permissões", + setsEmptyBody: "Ainda não há nenhum conjunto de permissões definido neste ambiente.", + setsPick: "Escolha um conjunto de permissões para ver seus usuários.", + setsLoadFailed: "Não foi possível ler os conjuntos de permissões.", + noMatch: "Nada corresponde ao filtro.", + notFound: "\"{{name}}\" não está nesta lista.", + retry: "Tentar novamente", + refresh: "Atualizar", + }, element: { number: { noObject: "Nenhum objeto indicado: defina dataSource.object.", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index b6928304408..a078a38330e 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -5029,6 +5029,28 @@ const ru = { skippedBody: "Коннекторы, которые их используют, остаются отключёнными, пока каждое значение не будет задано.", openPage: "Задать их в разделе учётных данных коннекторов", }, + setupAssignments: { + positionsTitle: "Занимающие должность", + positionsSubtitle: "Выберите должность, чтобы увидеть, кто её занимает, и добавить или убрать сотрудников. То, что даёт должность, определено в самой должности.", + positionsList: "Должности", + positionsFilter: "Фильтр должностей", + positionsEmptyTitle: "Нет должностей", + positionsEmptyBody: "В этой среде пока не определено ни одной должности.", + positionsPick: "Выберите должность, чтобы увидеть, кто её занимает.", + positionsLoadFailed: "Не удалось прочитать должности.", + setsTitle: "Назначение наборов разрешений", + setsSubtitle: "Выберите набор разрешений, чтобы увидеть, у кого он есть — напрямую или через должность, — и назначить или снять его с пользователей. То, что даёт набор, определено в самом наборе.", + setsList: "Наборы разрешений", + setsFilter: "Фильтр наборов разрешений", + setsEmptyTitle: "Нет наборов разрешений", + setsEmptyBody: "В этой среде пока не определено ни одного набора разрешений.", + setsPick: "Выберите набор разрешений, чтобы увидеть его пользователей.", + setsLoadFailed: "Не удалось прочитать наборы разрешений.", + noMatch: "Ничего не соответствует фильтру.", + notFound: "«{{name}}» нет в этом списке.", + retry: "Повторить", + refresh: "Обновить", + }, element: { number: { noObject: "Объект не указан: задайте dataSource.object.", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 9ef5be1a8b8..2026dcdb5eb 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -4836,6 +4836,28 @@ const zh = { skippedBody: "在各自设置之前,使用它们的连接器保持断开。", openPage: "在连接器凭据页面中设置", }, + setupAssignments: { + positionsTitle: '岗位持有人', + positionsSubtitle: '选择一个岗位,查看谁持有它,并添加或移除持有人。岗位授予什么,在岗位本身中定义。', + positionsList: '岗位', + positionsFilter: '筛选岗位', + positionsEmptyTitle: '没有岗位', + positionsEmptyBody: '此环境尚未定义任何岗位。', + positionsPick: '选择一个岗位以查看其持有人。', + positionsLoadFailed: '无法读取岗位。', + setsTitle: '权限集分配', + setsSubtitle: '选择一个权限集,查看谁直接或通过岗位持有它,并分配或取消分配用户。权限集授予什么,在权限集本身中定义。', + setsList: '权限集', + setsFilter: '筛选权限集', + setsEmptyTitle: '没有权限集', + setsEmptyBody: '此环境尚未定义任何权限集。', + setsPick: '选择一个权限集以查看其用户。', + setsLoadFailed: '无法读取权限集。', + noMatch: '没有与筛选条件匹配的项。', + notFound: '“{{name}}”不在此列表中。', + retry: '重试', + refresh: '刷新', + }, element: { number: { noObject: '未指定对象:请设置 dataSource.object。',