From 5959bfab8727045a50a58df5233d88d9e0531889 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 03:44:51 +0000 Subject: [PATCH 1/6] feat(console): the user page's Permission Sets tab shows the person's effective sets and where each comes from A new console widget, security:effective-access, reads security.describeEffectiveAccess(userId) for the bound sys_user record and lists every permission set in force with its origins (default binding, a position by name, a direct grant, or a derived standing). A 403 or 501 leaves one quiet line and no list. It sits above the stored grant list on the Permission Sets tab, where a direct grant keeps its remove action. The zod arm and its TypeScript twin land in @object-ui/types with the registration, the CLI known-types list is regenerated, and the strings are added in all eleven locale packs. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5kxrRdCiSKcPqREG4w --- .../canonical-expression-envelopes.test.ts | 3 + .../pages/sys-user-effective-access.test.ts | 60 ++++ .../src/pages/sys-user.page.ts | 2 + ui/packages/app-shell/package.json | 2 + .../security/UserEffectiveAccess.test.tsx | 270 ++++++++++++++++++ .../console/security/UserEffectiveAccess.tsx | 254 ++++++++++++++++ ui/packages/app-shell/src/index.ts | 1 + ...tered-types-validate-ratchet-10859.test.ts | 10 + .../cli/src/utils/known-schema-types.ts | 1 + ui/packages/i18n/src/locales/ar.ts | 16 ++ ui/packages/i18n/src/locales/de.ts | 16 ++ ui/packages/i18n/src/locales/en.ts | 16 ++ ui/packages/i18n/src/locales/es.ts | 16 ++ ui/packages/i18n/src/locales/fr.ts | 16 ++ ui/packages/i18n/src/locales/ja.ts | 16 ++ ui/packages/i18n/src/locales/ko.ts | 16 ++ ui/packages/i18n/src/locales/pt-BR.ts | 16 ++ ui/packages/i18n/src/locales/ru.ts | 16 ++ ui/packages/i18n/src/locales/zh-Hans.ts | 16 ++ ui/packages/i18n/src/locales/zh-Hant.ts | 16 ++ .../any-component-emit-headroom-11573.test.ts | 1 + .../security-effective-access-arm.test.ts | 115 ++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 7 +- ui/packages/types/src/index.ts | 8 + .../types/src/security-effective-access.ts | 60 ++++ ui/packages/types/src/zod/README.md | 1 + ui/packages/types/src/zod/index.zod.ts | 8 + .../src/zod/security-effective-access.zod.ts | 63 ++++ 28 files changed, 1041 insertions(+), 1 deletion(-) create mode 100644 packages/platform-objects/src/pages/sys-user-effective-access.test.ts create mode 100644 ui/packages/app-shell/src/console/security/UserEffectiveAccess.test.tsx create mode 100644 ui/packages/app-shell/src/console/security/UserEffectiveAccess.tsx create mode 100644 ui/packages/types/src/__tests__/security-effective-access-arm.test.ts create mode 100644 ui/packages/types/src/security-effective-access.ts create mode 100644 ui/packages/types/src/zod/security-effective-access.zod.ts diff --git a/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts b/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts index 6f075585eea..0b7aead9764 100644 --- a/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts +++ b/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts @@ -172,6 +172,9 @@ const UNMAPPED_TYPE_EXEMPTIONS: Readonly> = { 'mcp:authorized-assistants': 'the user page\'s AI Assistants tab body (@object-ui/app-shell): the widget takes no props and reads ' + 'the bound sys_user record, so its authored `properties` is `{}` and holds no expression key', + 'security:effective-access': + 'the user page\'s Permission Sets tab, above the grant list (@object-ui/app-shell): the widget takes no props ' + + 'and reads the bound sys_user record, so its authored `properties` is `{}` and holds no expression key', }; /** The authored node at an audit path such as `regions[0].components[2].properties.items[10].children[0]`. */ diff --git a/packages/platform-objects/src/pages/sys-user-effective-access.test.ts b/packages/platform-objects/src/pages/sys-user-effective-access.test.ts new file mode 100644 index 00000000000..13759566321 --- /dev/null +++ b/packages/platform-objects/src/pages/sys-user-effective-access.test.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The user page's "Permission Sets" tab shows the person's EFFECTIVE sets, not + * only their stored grant rows. + * + * A person who holds access only through the default binding or a position has + * no `sys_user_permission_set` row, so a tab holding only the grant list reads + * as "no records" for them. The tab's first child is the console widget + * `security:effective-access` (`@object-ui/app-shell`), which reads the bound + * `sys_user` record and lists every set the server resolves for the person + * with its origins. The grant list stays below it: a direct grant keeps its + * remove action there. + * + * Read from the shipped page metadata, so the pin fails if the widget leaves + * the tab, moves below the list, or starts authoring props. + */ + +import { describe, expect, it } from 'vitest'; +import { PageSchema } from '@objectstack/spec/ui'; +import { SysUserDetailPage } from './sys-user.page.js'; + +type Rec = Record; + +describe('sys_user_detail — the Permission Sets tab', () => { + const tabs = (SysUserDetailPage.regions![0]!.components as Rec[]).find((c) => c.type === 'page:tabs') as Rec; + const items = (tabs.properties as Rec).items as Rec[]; + const tab = items.find((it) => (it.label as Rec).en === 'Permission Sets')!; + const children = tab.children as Rec[]; + + it('parses through PageSchema', () => { + const parsed = PageSchema.safeParse(SysUserDetailPage); + expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true); + }); + + it('opens with the `security:effective-access` widget, with no props', () => { + expect(children[0]).toEqual({ type: 'security:effective-access', properties: {} }); + }); + + it('keeps the stored grant list below it, where a direct grant is removed', () => { + expect(children.map((c) => c.type)).toEqual(['security:effective-access', 'record:related_list']); + expect((children[1]!.properties as Rec).objectName).toBe('sys_user_permission_set'); + }); + + it('mounts the widget nowhere else on the page', () => { + const found: unknown[] = []; + const walk = (n: unknown): void => { + if (Array.isArray(n)) { n.forEach(walk); return; } + if (!n || typeof n !== 'object') return; + if ((n as Rec).type === 'security:effective-access') found.push(n); + Object.values(n as Rec).forEach(walk); + }; + walk(SysUserDetailPage); + expect(found).toHaveLength(1); + }); + + it('carries no tab-level gate: the server refuses a viewer who may not manage the person', () => { + expect(tab.visibleWhen).toBeUndefined(); + }); +}); diff --git a/packages/platform-objects/src/pages/sys-user.page.ts b/packages/platform-objects/src/pages/sys-user.page.ts index 109bc3d71e2..3b5c6540056 100644 --- a/packages/platform-objects/src/pages/sys-user.page.ts +++ b/packages/platform-objects/src/pages/sys-user.page.ts @@ -287,6 +287,8 @@ export const SysUserDetailPage: Page = { label: { en: 'Permission Sets', 'zh-CN': '权限集', 'ja-JP': '権限セット', 'es-ES': 'Conjuntos de permisos', 'zh-Hant': '權限集', ar: 'مجموعات الأذونات', fr: 'Ensembles d’autorisations' }, icon: 'lock', children: [ + // Effective sets with their origins (default / position / direct / derived), read from the server; the stored grant rows stay below. + { type: 'security:effective-access', properties: {} }, { type: 'record:related_list', properties: { diff --git a/ui/packages/app-shell/package.json b/ui/packages/app-shell/package.json index 748c271af79..bb58101a5f5 100644 --- a/ui/packages/app-shell/package.json +++ b/ui/packages/app-shell/package.json @@ -12,6 +12,7 @@ "./dist/console/connect/ManagedPersonAssistants.js", "./dist/console/diagnostics/CloudAiModelStatus.js", "./dist/console/home/CloudWorkspaceTimezoneNotice.js", + "./dist/console/security/UserEffectiveAccess.js", "./dist/services/builtinComponents.js", "./dist/views/global-notifications-renderer.js", "./dist/views/global-search-renderer.js", @@ -31,6 +32,7 @@ "./src/console/connect/ManagedPersonAssistants.tsx", "./src/console/diagnostics/CloudAiModelStatus.tsx", "./src/console/home/CloudWorkspaceTimezoneNotice.tsx", + "./src/console/security/UserEffectiveAccess.tsx", "./src/services/builtinComponents.tsx", "./src/views/global-notifications-renderer.tsx", "./src/views/global-search-renderer.tsx", diff --git a/ui/packages/app-shell/src/console/security/UserEffectiveAccess.test.tsx b/ui/packages/app-shell/src/console/security/UserEffectiveAccess.test.tsx new file mode 100644 index 00000000000..f4eb47bbbd1 --- /dev/null +++ b/ui/packages/app-shell/src/console/security/UserEffectiveAccess.test.tsx @@ -0,0 +1,270 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `security:effective-access` — a person's effective permission sets on their + * `sys_user` record page, each with every origin the server's answer carries. + * + * Driven through the real input pipeline: the node a page authors + * (`{ type: 'security:effective-access', properties: {} }`) is rendered by + * `SchemaRenderer` inside the record context the page binds, and the widget + * is fed `security.describeEffectiveAccess(userId)`'s answer through the + * adapter's client. + * + * - §1 a person with access only through the default binding and a position + * shows those sets, each with its origin, and the set label; + * - §2 CONTROL: a person with one direct grant shows exactly that set; + * - §3 a set reached two ways is one row with both origins; derived origins + * say which standing; + * - §4 a 403 and a 501 show no list, each a quiet line; another failure + * names itself; an empty answer says so; + * - §5 off a `sys_user` record it renders nothing and reads nothing; a grant + * written on the page reads the answer again. + * + * `t` echoes the key with any interpolation values appended, so assertions + * read which string was asked for and with what. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, opts?: Record) => { + const vals = opts ? Object.values(opts).filter((v) => typeof v === 'string' || typeof v === 'number') : []; + return vals.length ? `«${key}|${vals.join('|')}»` : `«${key}»`; + }, + language: 'en', + }), +})); + +let adapter: unknown = null; +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => adapter, +})); + +import '@object-ui/components'; +import { ComponentRegistry } from '@object-ui/core'; +import { + MetadataCtx, + notifyDataChanged, + RecordContextProvider, + SchemaRenderer, + type MetadataContextValue, +} from '@object-ui/react'; +import { describeOrigin, type EffectiveAccessAnswer } from './UserEffectiveAccess'; + +const NODE = { type: 'security:effective-access', properties: {} }; + +/** The permission-set catalog the labels come from (`/meta/permission`). */ +const CATALOG = [ + { name: 'member_default', label: 'Member default' }, + { name: 'sales_rep', label: 'Sales rep' }, + { name: 'admin_full_access', label: 'Full administrator' }, +]; + +const describeMock = vi.fn<(userId: string) => Promise>(); +const ensureTypeMock = vi.fn(async (_type: string): Promise => CATALOG); + +function refusal(status: number, message: string): Error { + return Object.assign(new Error(message), { httpStatus: status }); +} + +const metadata = { + ensureType: (type: string) => ensureTypeMock(type), +} as unknown as MetadataContextValue; + +function renderOn(objectName: string, recordId: string | null) { + return render( + + + + + , + ); +} + +/** The rendered rows, as set name → origin badge texts. */ +async function rowsShown(): Promise> { + const list = await screen.findByTestId('effective-access-list'); + const out: Record = {}; + for (const row of within(list).getAllByRole('listitem').filter((li) => li.dataset.testid?.startsWith('effective-access-row-'))) { + const name = row.dataset.testid!.slice('effective-access-row-'.length); + const origins = within(row).getByRole('list'); + out[name] = within(origins).getAllByRole('listitem').map((li) => li.textContent ?? ''); + } + return out; +} + +beforeEach(() => { + describeMock.mockReset(); + ensureTypeMock.mockClear(); + adapter = { getClient: () => ({ security: { describeEffectiveAccess: describeMock } }) }; +}); + +afterEach(() => { + cleanup(); +}); + +describe('§0 registration', () => { + it('is registered as security:effective-access, with no bare fallback', () => { + expect(ComponentRegistry.get('security:effective-access')).toBeDefined(); + expect(ComponentRegistry.get('effective-access')).toBeUndefined(); + }); +}); + +describe('§1 access only through the default binding and a position', () => { + it('shows those sets with their origins, not an empty list', async () => { + describeMock.mockResolvedValueOnce({ + userId: 'user_mia', + organizationId: 'org_here', + positions: ['org_member', 'sales', 'everyone'], + permissionSets: [ + { name: 'sales_rep', origins: [{ kind: 'position', position: 'sales' }] }, + { name: 'member_default', origins: [{ kind: 'default' }] }, + ], + }); + renderOn('sys_user', 'user_mia'); + + expect(await rowsShown()).toEqual({ + sales_rep: ['«effectiveAccess.origin.position|sales»'], + member_default: ['«effectiveAccess.origin.default»'], + }); + expect(describeMock).toHaveBeenCalledWith('user_mia'); + expect(screen.queryByTestId('effective-access-empty')).toBeNull(); + }); + + it('shows each set by its catalog label, with the name beside it', async () => { + describeMock.mockResolvedValueOnce({ + userId: 'user_mia', + organizationId: 'org_here', + positions: ['everyone'], + permissionSets: [{ name: 'member_default', origins: [{ kind: 'default' }] }], + }); + renderOn('sys_user', 'user_mia'); + const row = await screen.findByTestId('effective-access-row-member_default'); + await waitFor(() => expect(within(row).getByText('Member default')).toBeInTheDocument()); + expect(within(row).getByText('member_default')).toBeInTheDocument(); + expect(ensureTypeMock).toHaveBeenCalledWith('permission'); + }); + + it('shows a set the catalog does not list by its name, never drops it', async () => { + ensureTypeMock.mockResolvedValueOnce([]); + describeMock.mockResolvedValueOnce({ + userId: 'user_mia', + organizationId: 'org_here', + positions: [], + permissionSets: [{ name: 'uncatalogued_set', origins: [{ kind: 'default' }] }], + }); + renderOn('sys_user', 'user_mia'); + const row = await screen.findByTestId('effective-access-row-uncatalogued_set'); + expect(within(row).getAllByText('uncatalogued_set')).toHaveLength(1); + }); +}); + +describe('§2 CONTROL — one direct grant', () => { + it('shows exactly that set, as a direct grant', async () => { + describeMock.mockResolvedValueOnce({ + userId: 'user_ana', + organizationId: 'org_here', + positions: [], + permissionSets: [{ name: 'sales_rep', origins: [{ kind: 'direct' }] }], + }); + renderOn('sys_user', 'user_ana'); + expect(await rowsShown()).toEqual({ sales_rep: ['«effectiveAccess.origin.direct»'] }); + }); +}); + +describe('§3 a set reached two ways', () => { + it('is one row carrying both origins, in the answer\'s order', async () => { + describeMock.mockResolvedValueOnce({ + userId: 'user_mia', + organizationId: 'org_here', + positions: ['sales'], + permissionSets: [ + { name: 'sales_rep', origins: [{ kind: 'direct' }, { kind: 'position', position: 'sales' }] }, + { + name: 'admin_full_access', + origins: [{ kind: 'direct' }, { kind: 'derived', source: 'platform_admin_config' }], + }, + { name: 'ai_seat', origins: [{ kind: 'derived', source: 'ai_access' }] }, + ], + }); + renderOn('sys_user', 'user_mia'); + expect(await rowsShown()).toEqual({ + sales_rep: ['«effectiveAccess.origin.direct»', '«effectiveAccess.origin.position|sales»'], + admin_full_access: ['«effectiveAccess.origin.direct»', '«effectiveAccess.origin.derivedPlatformAdmin»'], + ai_seat: ['«effectiveAccess.origin.derivedAiAccess»'], + }); + expect(screen.getAllByTestId(/^effective-access-row-/)).toHaveLength(3); + }); + + it('describes every origin kind the contract declares', () => { + const t = (key: string, opts?: Record) => (opts ? `${key}|${Object.values(opts).join('|')}` : key); + expect(describeOrigin({ kind: 'direct' }, t)).toBe('effectiveAccess.origin.direct'); + expect(describeOrigin({ kind: 'position', position: 'org_external' }, t)).toBe('effectiveAccess.origin.position|org_external'); + expect(describeOrigin({ kind: 'default' }, t)).toBe('effectiveAccess.origin.default'); + expect(describeOrigin({ kind: 'derived', source: 'platform_admin_config' }, t)).toBe('effectiveAccess.origin.derivedPlatformAdmin'); + expect(describeOrigin({ kind: 'derived', source: 'ai_access' }, t)).toBe('effectiveAccess.origin.derivedAiAccess'); + }); +}); + +describe('§4 refusals never draw a list', () => { + it('a 403 (a person the caller may not manage) leaves one quiet line and no list', async () => { + describeMock.mockRejectedValueOnce(refusal(403, 'Forbidden')); + renderOn('sys_user', 'user_other'); + expect(await screen.findByTestId('effective-access-refused')).toHaveTextContent('«effectiveAccess.refused»'); + expect(screen.queryByTestId('effective-access-list')).toBeNull(); + expect(screen.queryByTestId('effective-access-empty')).toBeNull(); + }); + + it('a 501 (a server without the read) leaves one quiet line and no list', async () => { + describeMock.mockRejectedValueOnce(refusal(501, 'Not implemented')); + renderOn('sys_user', 'user_mia'); + expect(await screen.findByTestId('effective-access-unserved')).toHaveTextContent('«effectiveAccess.unserved»'); + expect(screen.queryByTestId('effective-access-list')).toBeNull(); + expect(screen.queryByTestId('effective-access-empty')).toBeNull(); + }); + + it('any other failure names itself, and draws no list', async () => { + describeMock.mockRejectedValueOnce(refusal(500, 'authorization store could not be read')); + renderOn('sys_user', 'user_mia'); + expect(await screen.findByTestId('effective-access-failed')).toHaveTextContent( + '«effectiveAccess.loadFailed|authorization store could not be read»', + ); + expect(screen.queryByTestId('effective-access-list')).toBeNull(); + }); + + it('an answer with no set says so', async () => { + describeMock.mockResolvedValueOnce({ userId: 'user_mia', organizationId: null, positions: [], permissionSets: [] }); + renderOn('sys_user', 'user_mia'); + expect(await screen.findByTestId('effective-access-empty')).toHaveTextContent('«effectiveAccess.empty»'); + }); +}); + +describe('§5 where it draws, and when it reads again', () => { + it('renders nothing and reads nothing off a sys_user record', () => { + const other = renderOn('crm_account', 'acc_1'); + expect(other.container).toBeEmptyDOMElement(); + other.unmount(); + const noId = renderOn('sys_user', null); + expect(noId.container).toBeEmptyDOMElement(); + expect(describeMock).not.toHaveBeenCalled(); + }); + + it('reads the answer again when a grant on the page changes', async () => { + describeMock.mockResolvedValue({ + userId: 'user_mia', + organizationId: 'org_here', + positions: [], + permissionSets: [{ name: 'sales_rep', origins: [{ kind: 'direct' }] }], + }); + renderOn('sys_user', 'user_mia'); + await screen.findByTestId('effective-access-list'); + expect(describeMock).toHaveBeenCalledTimes(1); + notifyDataChanged({ objectName: 'sys_user_permission_set' }); + await waitFor(() => expect(describeMock).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/ui/packages/app-shell/src/console/security/UserEffectiveAccess.tsx b/ui/packages/app-shell/src/console/security/UserEffectiveAccess.tsx new file mode 100644 index 00000000000..e20b474faa7 --- /dev/null +++ b/ui/packages/app-shell/src/console/security/UserEffectiveAccess.tsx @@ -0,0 +1,254 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * One person's effective permission sets, each with every way it reaches them + * — the SDUI widget `security:effective-access`, for a `sys_user` record page. + * + * The page's Permission Sets tab otherwise lists only the person's stored + * `sys_user_permission_set` rows, so someone who holds access through the + * default binding or a position reads as having none. This widget draws the + * server's answer instead: `security.describeEffectiveAccess(userId)` of + * `@objectstack/client`, resolved by the resolver enforcement uses. It + * computes nothing of its own: every set and every origin is the answer's. + * + * It reads one thing from the page: the bound record (`useRecordContext()`), + * which must be a `sys_user` record with an id. Of the node it reads only + * `className`; it takes no props, so its `@object-ui/types` arm + * (`SecurityEffectiveAccessSchema`) accepts only an empty `properties` bag. + * Outside such a record it renders nothing. + * + * Refusals never draw a list: a `403` (a person the caller may not manage, or + * no such person) and a `501` (a server whose security service does not + * describe effective access) each leave one quiet line, and any other failure + * names itself. + * + * A direct grant is a stored row: the page lists those rows beside this widget, + * where each keeps its remove action. Every other origin says where the set + * comes from (the default binding, a position by name, or the standing the + * server derived it from). + */ + +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { Badge, Card, CardContent, CardHeader, CardTitle, Skeleton, cn } from '@object-ui/components'; +import { ComponentRegistry } from '@object-ui/core'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { useAdapter, useDataInvalidation, useMetadata, useRecordContext } from '@object-ui/react'; +import type { ObjectStackAdapter } from '@object-ui/data-objectstack'; +import { AlertCircle, ShieldCheck } from 'lucide-react'; +import { catalogEntries } from '../../views/setup/peopleAssignments.js'; + +type Translate = (key: string, options?: Record) => string; + +type DescribeEffectiveAccess = ReturnType['security']['describeEffectiveAccess']; +/** The answer (`EffectiveAccess` of `@objectstack/spec/contracts`, through the client). */ +export type EffectiveAccessAnswer = Awaited>; +/** One way a set reaches the person (`EffectivePermissionSetOrigin`). */ +export type EffectiveAccessOrigin = EffectiveAccessAnswer['permissionSets'][number]['origins'][number]; + +/** The object whose record pages this widget belongs on. */ +export const EFFECTIVE_ACCESS_OBJECT = 'sys_user'; + +/** The registry type the set labels are read from. */ +const PERMISSION_SET_TYPE = 'permission'; + +type Read = + | { state: 'loading' } + | { state: 'ready'; value: EffectiveAccessAnswer } + | { state: 'refused' } + | { state: 'unserved' } + | { state: 'failed'; message: string }; + +/** The status the client stamps on a refused call (`httpStatus`, from `res.status`). */ +function httpStatusOf(err: unknown): number | undefined { + const status = (err as { httpStatus?: unknown } | null | undefined)?.httpStatus; + return typeof status === 'number' ? status : undefined; +} + +function failureText(err: unknown): string { + const message = (err as { message?: unknown } | null | undefined)?.message; + return typeof message === 'string' && message ? message : String(err); +} + +/** Where one origin says the set comes from. */ +export function describeOrigin(origin: EffectiveAccessOrigin, t: Translate): string { + switch (origin.kind) { + case 'direct': + return t('effectiveAccess.origin.direct'); + case 'position': + return t('effectiveAccess.origin.position', { position: origin.position }); + case 'default': + return t('effectiveAccess.origin.default'); + case 'derived': + return origin.source === 'platform_admin_config' + ? t('effectiveAccess.origin.derivedPlatformAdmin') + : t('effectiveAccess.origin.derivedAiAccess'); + } +} + +/** A stable key for one origin of a row. */ +function originKey(origin: EffectiveAccessOrigin): string { + switch (origin.kind) { + case 'position': + return `position-${origin.position}`; + case 'derived': + return `derived-${origin.source}`; + default: + return origin.kind; + } +} + +export interface UserEffectiveAccessProps { + /** Carries the scope class of the node's `responsiveStyles`. */ + className?: string; +} + +export function UserEffectiveAccess({ className }: UserEffectiveAccessProps = {}) { + const record = useRecordContext(); + const userId = record?.objectName === EFFECTIVE_ACCESS_OBJECT && record.recordId ? String(record.recordId) : null; + if (!userId) return null; + return ; +} + +function EffectiveAccessCard({ userId, className }: { userId: string; className?: string }) { + const { t } = useObjectTranslation(); + const adapter = useAdapter() as ObjectStackAdapter | null; + const metadata = useMetadata(); + const [read, setRead] = useState({ state: 'loading' }); + const [labels, setLabels] = useState>(() => new Map()); + // A grant or a position written on this page changes the answer: read again. + const grantsNonce = useDataInvalidation('sys_user_permission_set'); + const positionsNonce = useDataInvalidation('sys_user_position'); + + useEffect(() => { + if (!adapter) return; + let live = true; + adapter + .getClient() + .security.describeEffectiveAccess(userId) + .then( + (value) => live && setRead({ state: 'ready', value }), + (err: unknown) => { + if (!live) return; + const status = httpStatusOf(err); + if (status === 403) setRead({ state: 'refused' }); + else if (status === 501) setRead({ state: 'unserved' }); + else setRead({ state: 'failed', message: failureText(err) }); + }, + ); + return () => { + live = false; + }; + }, [adapter, userId, grantsNonce, positionsNonce]); + + // Read the store through a ref: its context value is rebuilt whenever any + // cached type settles, and the label read must not be keyed on it + // (ui/AGENTS.md #10). + const metadataRef = useRef(metadata); + useEffect(() => { + metadataRef.current = metadata; + }); + + // Labels only: a set the catalog does not list (or a catalog that fails to + // load) is shown by its name, never dropped. + useEffect(() => { + let live = true; + void metadataRef.current.ensureType(PERMISSION_SET_TYPE).then( + (items) => { + if (live) setLabels(new Map(catalogEntries(items).map((e) => [e.name, e.label] as const))); + }, + () => undefined, + ); + return () => { + live = false; + }; + }, []); + + const rows = useMemo( + () => + read.state === 'ready' + ? read.value.permissionSets.map((set) => ({ ...set, label: labels.get(set.name) ?? set.name })) + : [], + [read, labels], + ); + + if (!adapter) return null; + + let body: ReactNode; + if (read.state === 'loading') { + body = ; + } else if (read.state === 'refused') { + body = ( +

+ {t('effectiveAccess.refused')} +

+ ); + } else if (read.state === 'unserved') { + body = ( +

+ {t('effectiveAccess.unserved')} +

+ ); + } else if (read.state === 'failed') { + body = ( +

+

+ ); + } else if (rows.length === 0) { + body = ( +

+ {t('effectiveAccess.empty')} +

+ ); + } else { + body = ( +
    + {rows.map((row) => ( +
  • +
    + {row.label} + {row.label !== row.name ? ( + {row.name} + ) : null} +
    +
      + {row.origins.map((origin) => ( +
    • + {describeOrigin(origin, t)} +
    • + ))} +
    +
  • + ))} +
+ ); + } + + return ( + + + + +

{t('effectiveAccess.body')}

+
+ {body} +
+ ); +} + +// SDUI registration: a `sys_user` record page names this widget by type. +// Registered under the `security` namespace with no bare fallback, so the one +// key is `security:effective-access`: the spelling a page authors, and the one +// literal its `@object-ui/types` arm claims. +ComponentRegistry.register('effective-access', (props: UserEffectiveAccessProps) => ( + +), { + namespace: 'security', + skipFallback: true, + label: 'Effective Access', + category: 'plugin', + inputs: [], +}); diff --git a/ui/packages/app-shell/src/index.ts b/ui/packages/app-shell/src/index.ts index 2746b6c3b0f..c08b477ffd4 100644 --- a/ui/packages/app-shell/src/index.ts +++ b/ui/packages/app-shell/src/index.ts @@ -364,6 +364,7 @@ import './services/builtinComponents.js'; import './console/cloud-connection/CloudConnectionPanel.js'; import './console/connect/ConnectAgentWidget.js'; import './console/connect/ManagedPersonAssistants.js'; +import './console/security/UserEffectiveAccess.js'; // Generic product-page blocks — hero, stat, usage-bar, checklist, tile-grid // (objectui#12197). import './console/blocks/productBlocks.js'; diff --git a/ui/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts b/ui/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts index 381a4bbc21e..52d7d52cb85 100644 --- a/ui/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts +++ b/ui/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts @@ -688,6 +688,16 @@ describe('registered NAMESPACED component types refused at `type` — a ratchet expect(refusedAtType('mcp:authorized-assistants')).toBe(false); }); + it('counts `security:effective-access` armed — it registered WITH its arm', () => { + // One registry key (`skipFallback: true`, so no bare `effective-access` + // and no `app-shell:` spelling), armed in `@object-ui/types/zod` in the same + // change, so the pin above did not move. + expect(NAMESPACED_KEYS).toContain('security:effective-access'); + expect(NAMESPACED_KEYS).not.toContain('app-shell:security:effective-access'); + expect(BARE_KEYS).not.toContain('effective-access'); + expect(refusedAtType('security:effective-access')).toBe(false); + }); + it('counts `record:approval_decision` armed — it registered WITH its arm (objectui#12342)', () => { // One registry key (`skipFallback: true`, so no bare `approval_decision`), // armed in `@object-ui/types/zod` in the same change, so the pin above did not move. diff --git a/ui/packages/cli/src/utils/known-schema-types.ts b/ui/packages/cli/src/utils/known-schema-types.ts index 068d1d267c1..249661f7851 100644 --- a/ui/packages/cli/src/utils/known-schema-types.ts +++ b/ui/packages/cli/src/utils/known-schema-types.ts @@ -407,6 +407,7 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'resizable', 'scroll-area', 'section', + 'security:effective-access', 'select', 'separator', 'sheet', diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 7e46e10958d..b6bc4fb3c89 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -4221,6 +4221,22 @@ const ar = { managedTruncated: 'تُعرض الأحدث فقط؛ منح هذا الشخص الإذن لمساعدين آخرين أيضًا.', }, }, + effectiveAccess: { + title: 'مجموعات الأذونات الفعلية', + body: 'كل مجموعة أذونات سارية لهذا الشخص في مؤسستك الحالية، ومصدر كل منها. تظهر المنح المباشرة أيضًا أدناه، حيث يمكن إزالتها.', + refused: 'غير مسموح لك بعرض الوصول الفعلي لهذا الشخص.', + unserved: 'لا يُبلغ هذا الخادم عن الوصول الفعلي.', + loadFailed: 'تعذّر تحميل الوصول الفعلي: {{message}}', + empty: 'لا توجد مجموعة أذونات سارية لهذا الشخص في هذه المؤسسة.', + originsLabel: 'المصدر', + origin: { + direct: 'منح مباشر', + position: 'المنصب: {{position}}', + default: 'ربط افتراضي', + derivedPlatformAdmin: 'مسؤول منصة مُكوَّن', + derivedAiAccess: 'الوصول إلى الذكاء الاصطناعي مُفعَّل', + }, + }, excelImport: { noImportableFields: "لا تحتوي هذه الكائن على حقول قابلة للاستيراد.", schemaReadFailed: "تعذّرت قراءة مخطط الكائن.", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 018419f2dc5..50e09a111ed 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -3916,6 +3916,22 @@ const de = { managedTruncated: 'Nur die neuesten werden angezeigt; diese Person hat weitere autorisiert.', }, }, + effectiveAccess: { + title: 'Wirksame Berechtigungssätze', + body: 'Jeder Berechtigungssatz, der für diese Person in deiner aktuellen Organisation gilt, und woher er stammt. Direkte Zuweisungen stehen auch unten, wo sie entfernt werden können.', + refused: 'Du darfst den wirksamen Zugriff dieser Person nicht ansehen.', + unserved: 'Dieser Server meldet keinen wirksamen Zugriff.', + loadFailed: 'Wirksamer Zugriff konnte nicht geladen werden: {{message}}', + empty: 'Für diese Person gilt in dieser Organisation kein Berechtigungssatz.', + originsLabel: 'Stammt aus', + origin: { + direct: 'Direkte Zuweisung', + position: 'Position: {{position}}', + default: 'Standardbindung', + derivedPlatformAdmin: 'Konfigurierter Plattformadministrator', + derivedAiAccess: 'KI-Zugriff aktiviert', + }, + }, excelImport: { noImportableFields: "Dieses Objekt hat keine importierbaren Felder.", schemaReadFailed: "Das Objektschema konnte nicht gelesen werden.", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index a6df6134870..296039de0dd 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -4727,6 +4727,22 @@ const en = { managedTruncated: 'Only the newest are shown; this person has authorized more.', }, }, + effectiveAccess: { + title: 'Effective permission sets', + body: 'Every permission set in force for this person in your current organization, and where each one comes from. Direct grants are also listed below, where they can be removed.', + refused: 'You are not allowed to view this person’s effective access.', + unserved: 'This server does not report effective access.', + loadFailed: 'Could not load effective access: {{message}}', + empty: 'No permission set is in force for this person in this organization.', + originsLabel: 'Comes from', + origin: { + direct: 'Direct grant', + position: 'Position: {{position}}', + default: 'Default binding', + derivedPlatformAdmin: 'Configured platform administrator', + derivedAiAccess: 'AI access turned on', + }, + }, excelImport: { noImportableFields: 'That object has no importable fields.', schemaReadFailed: 'Could not read the object schema.', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 91457135e45..6b6551fa212 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -3993,6 +3993,22 @@ const es = { managedTruncated: 'Solo se muestran los más recientes; esta persona ha autorizado más.', }, }, + effectiveAccess: { + title: 'Conjuntos de permisos efectivos', + body: 'Cada conjunto de permisos vigente para esta persona en tu organización actual, y de dónde viene cada uno. Las asignaciones directas también aparecen abajo, donde se pueden quitar.', + refused: 'No tienes permiso para ver el acceso efectivo de esta persona.', + unserved: 'Este servidor no informa del acceso efectivo.', + loadFailed: 'No se pudo cargar el acceso efectivo: {{message}}', + empty: 'Esta persona no tiene ningún conjunto de permisos vigente en esta organización.', + originsLabel: 'Procede de', + origin: { + direct: 'Asignación directa', + position: 'Puesto: {{position}}', + default: 'Vinculación predeterminada', + derivedPlatformAdmin: 'Administrador de plataforma configurado', + derivedAiAccess: 'Acceso a IA activado', + }, + }, excelImport: { noImportableFields: "Ese objeto no tiene campos importables.", schemaReadFailed: "No se pudo leer el esquema del objeto.", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 08a9c1036da..bd5706f0a59 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -3989,6 +3989,22 @@ const fr = { managedTruncated: 'Seuls les plus récents sont affichés ; cette personne en a autorisé d’autres.', }, }, + effectiveAccess: { + title: 'Ensembles d’autorisations effectifs', + body: 'Chaque ensemble d’autorisations en vigueur pour cette personne dans votre organisation actuelle, et sa provenance. Les attributions directes sont aussi listées ci-dessous, où elles peuvent être retirées.', + refused: 'Vous n’êtes pas autorisé à voir l’accès effectif de cette personne.', + unserved: 'Ce serveur n’indique pas l’accès effectif.', + loadFailed: 'Impossible de charger l’accès effectif : {{message}}', + empty: 'Aucun ensemble d’autorisations n’est en vigueur pour cette personne dans cette organisation.', + originsLabel: 'Provient de', + origin: { + direct: 'Attribution directe', + position: 'Poste : {{position}}', + default: 'Liaison par défaut', + derivedPlatformAdmin: 'Administrateur de plateforme configuré', + derivedAiAccess: 'Accès IA activé', + }, + }, excelImport: { noImportableFields: "Cet objet n'a aucun champ importable.", schemaReadFailed: "Impossible de lire le schéma de l'objet.", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 8d3fc390a43..8bb30432f11 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -3918,6 +3918,22 @@ const ja = { managedTruncated: '新しいものだけを表示しています。この人が認可したアシスタントはほかにもあります。', }, }, + effectiveAccess: { + title: '有効な権限セット', + body: '現在の組織でこの人に有効なすべての権限セットと、それぞれの付与元です。直接付与は下にも一覧表示され、そこで削除できます。', + refused: 'この人の有効なアクセス権を表示する権限がありません。', + unserved: 'このサーバーは有効なアクセス権を返しません。', + loadFailed: '有効なアクセス権を読み込めませんでした: {{message}}', + empty: 'この組織でこの人に有効な権限セットはありません。', + originsLabel: '付与元', + origin: { + direct: '直接付与', + position: 'ポジション: {{position}}', + default: '既定のバインド', + derivedPlatformAdmin: '設定されたプラットフォーム管理者', + derivedAiAccess: 'AI アクセスが有効', + }, + }, excelImport: { noImportableFields: "そのオブジェクトにはインポート可能なフィールドがありません。", schemaReadFailed: "オブジェクトのスキーマを読み取れませんでした。", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index e9e0f99cd75..76233184b4c 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -3915,6 +3915,22 @@ const ko = { managedTruncated: '최신 항목만 표시합니다. 이 사람이 승인한 어시스턴트가 더 있습니다.', }, }, + effectiveAccess: { + title: '유효한 권한 세트', + body: '현재 조직에서 이 사람에게 적용되는 모든 권한 세트와 각각의 출처입니다. 직접 부여는 아래에도 나열되며 거기서 제거할 수 있습니다.', + refused: '이 사람의 유효한 액세스를 볼 권한이 없습니다.', + unserved: '이 서버는 유효한 액세스를 제공하지 않습니다.', + loadFailed: '유효한 액세스를 불러오지 못했습니다: {{message}}', + empty: '이 조직에서 이 사람에게 적용되는 권한 세트가 없습니다.', + originsLabel: '출처', + origin: { + direct: '직접 부여', + position: '직책: {{position}}', + default: '기본 바인딩', + derivedPlatformAdmin: '구성된 플랫폼 관리자', + derivedAiAccess: 'AI 액세스 켜짐', + }, + }, excelImport: { noImportableFields: "해당 오브젝트에는 가져올 수 있는 필드가 없습니다.", schemaReadFailed: "오브젝트 스키마를 읽을 수 없습니다.", diff --git a/ui/packages/i18n/src/locales/pt-BR.ts b/ui/packages/i18n/src/locales/pt-BR.ts index 992f958256a..7211879c806 100644 --- a/ui/packages/i18n/src/locales/pt-BR.ts +++ b/ui/packages/i18n/src/locales/pt-BR.ts @@ -3988,6 +3988,22 @@ const pt = { managedTruncated: 'Só os mais recentes são mostrados; esta pessoa autorizou mais.', }, }, + effectiveAccess: { + title: 'Conjuntos de permissões efetivos', + body: 'Cada conjunto de permissões em vigor para esta pessoa na sua organização atual, e de onde cada um vem. As atribuições diretas também aparecem abaixo, onde podem ser removidas.', + refused: 'Você não tem permissão para ver o acesso efetivo desta pessoa.', + unserved: 'Este servidor não informa o acesso efetivo.', + loadFailed: 'Não foi possível carregar o acesso efetivo: {{message}}', + empty: 'Nenhum conjunto de permissões está em vigor para esta pessoa nesta organização.', + originsLabel: 'Vem de', + origin: { + direct: 'Atribuição direta', + position: 'Cargo: {{position}}', + default: 'Vínculo padrão', + derivedPlatformAdmin: 'Administrador de plataforma configurado', + derivedAiAccess: 'Acesso à IA ativado', + }, + }, excelImport: { noImportableFields: "Esse objeto não tem campos importáveis.", schemaReadFailed: "Não foi possível ler o esquema do objeto.", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 022044c407c..0edad9746db 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -4079,6 +4079,22 @@ const ru = { managedTruncated: 'Показаны только самые новые; этот человек авторизовал больше.', }, }, + effectiveAccess: { + title: 'Действующие наборы разрешений', + body: 'Все наборы разрешений, действующие для этого человека в вашей текущей организации, и откуда каждый из них. Прямые назначения также перечислены ниже, где их можно удалить.', + refused: 'У вас нет права просматривать действующий доступ этого человека.', + unserved: 'Этот сервер не сообщает действующий доступ.', + loadFailed: 'Не удалось загрузить действующий доступ: {{message}}', + empty: 'Для этого человека в этой организации не действует ни один набор разрешений.', + originsLabel: 'Источник', + origin: { + direct: 'Прямое назначение', + position: 'Должность: {{position}}', + default: 'Привязка по умолчанию', + derivedPlatformAdmin: 'Настроенный администратор платформы', + derivedAiAccess: 'Доступ к ИИ включён', + }, + }, excelImport: { noImportableFields: "У этого объекта нет полей, доступных для импорта.", schemaReadFailed: "Не удалось прочитать схему объекта.", diff --git a/ui/packages/i18n/src/locales/zh-Hans.ts b/ui/packages/i18n/src/locales/zh-Hans.ts index be78347a904..d576e1cf5bb 100644 --- a/ui/packages/i18n/src/locales/zh-Hans.ts +++ b/ui/packages/i18n/src/locales/zh-Hans.ts @@ -4126,6 +4126,22 @@ const zh = { managedTruncated: '仅显示最新的部分,此人授权的助手还有更多。', }, }, + effectiveAccess: { + title: '实际生效的权限集', + body: '此人在你当前组织中实际生效的每个权限集,以及各自的来源。直接分配的权限集也列在下方,可以在那里移除。', + refused: '你无权查看此人实际生效的权限。', + unserved: '此服务器不提供实际生效权限的查询。', + loadFailed: '无法加载实际生效的权限:{{message}}', + empty: '此人在此组织中没有生效的权限集。', + originsLabel: '来源', + origin: { + direct: '直接分配', + position: '岗位:{{position}}', + default: '默认绑定', + derivedPlatformAdmin: '配置的平台管理员', + derivedAiAccess: '已开启 AI 访问', + }, + }, excelImport: { noImportableFields: '该对象没有可导入的字段。', schemaReadFailed: '无法读取对象结构。', diff --git a/ui/packages/i18n/src/locales/zh-Hant.ts b/ui/packages/i18n/src/locales/zh-Hant.ts index 8af88286d03..6f80ac6f2f8 100644 --- a/ui/packages/i18n/src/locales/zh-Hant.ts +++ b/ui/packages/i18n/src/locales/zh-Hant.ts @@ -4126,6 +4126,22 @@ const zhHant = { managedTruncated: '僅顯示最新的部分,此人授權的助手還有更多。', }, }, + effectiveAccess: { + title: '實際生效的權限集', + body: '此人在你目前組織中實際生效的每個權限集,以及各自的來源。直接指派的權限集也列在下方,可以在那裡移除。', + refused: '你無權檢視此人實際生效的權限。', + unserved: '此伺服器不提供實際生效權限的查詢。', + loadFailed: '無法載入實際生效的權限:{{message}}', + empty: '此人在此組織中沒有生效的權限集。', + originsLabel: '來源', + origin: { + direct: '直接指派', + position: '職位:{{position}}', + default: '預設繫結', + derivedPlatformAdmin: '設定的平台管理員', + derivedAiAccess: '已開啟 AI 存取', + }, + }, excelImport: { noImportableFields: '該物件沒有可匯入的欄位。', schemaReadFailed: '無法讀取物件結構。', diff --git a/ui/packages/types/src/__tests__/any-component-emit-headroom-11573.test.ts b/ui/packages/types/src/__tests__/any-component-emit-headroom-11573.test.ts index 06b6f64a7a7..e64143e509c 100644 --- a/ui/packages/types/src/__tests__/any-component-emit-headroom-11573.test.ts +++ b/ui/packages/types/src/__tests__/any-component-emit-headroom-11573.test.ts @@ -317,6 +317,7 @@ describe('`AnyComponentSchema` declaration emit headroom (objectui#11573)', () = { member: 'RetiredCloudPlanStatusNodeSchema', kind: 'arm', printsAs: 'inline' }, { member: 'CloudWorkspaceTimezoneNoticeSchema', kind: 'arm', printsAs: 'inline' }, { member: 'McpAuthorizedAssistantsSchema', kind: 'arm', printsAs: 'inline' }, + { member: 'SecurityEffectiveAccessSchema', kind: 'arm', printsAs: 'inline' }, { member: 'RecordApprovalDecisionBlockSchema', kind: 'arm', printsAs: 'inline' }, { member: 'RecordApprovalsBlockSchema', kind: 'arm', printsAs: 'inline' }, { member: 'RecordAttachmentsBlockSchema', kind: 'arm', printsAs: 'inline' }, diff --git a/ui/packages/types/src/__tests__/security-effective-access-arm.test.ts b/ui/packages/types/src/__tests__/security-effective-access-arm.test.ts new file mode 100644 index 00000000000..8dd39ac7840 --- /dev/null +++ b/ui/packages/types/src/__tests__/security-effective-access-arm.test.ts @@ -0,0 +1,115 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The `security:effective-access` arm (`../zod/security-effective-access.zod.ts`). + * + * `@object-ui/app-shell` registers `security:effective-access` for a `sys_user` + * record page, and this arm lands with it so `AnyComponentSchema` claims the + * type instead of refusing it at `type`. The arm is declared from the widget's + * read points: the widget reads no prop (the person is the bound record), so + * the only `properties` bag it takes is `{}`, and both content channels are + * refused by name. + * + * Refusals are asserted by issue `code` and `path` — the envelope a consumer + * reads — and not by message wording. + */ + +import { describe, expect, it } from 'vitest'; + +import { + AnyComponentSchema, + SecurityEffectiveAccessSchema, + StrictAnyComponentSchema, + safeValidateSchema, +} from '../zod/index.zod.js'; + +const TYPE = 'security:effective-access'; + +/** The issues of a refused parse, as `code` + dotted `path`. */ +function issuesOf(document: unknown): { code: string; path: string; message: string }[] { + const result = safeValidateSchema(document); + expect(result.success, 'expected a refusal').toBe(false); + if (result.success) return []; + return result.error.issues.map((issue) => ({ + code: issue.code, + path: issue.path.join('.'), + message: issue.message, + })); +} + +describe('`security:effective-access` — the node a `sys_user` record page authors', () => { + it('accepts the bare node on every face', () => { + const node = { type: TYPE }; + expect(SecurityEffectiveAccessSchema.safeParse(node).success).toBe(true); + expect(safeValidateSchema(node).success).toBe(true); + expect(StrictAnyComponentSchema.safeParse(node).success).toBe(true); + }); + + it('accepts an empty `properties` bag on every face', () => { + const node = { type: TYPE, properties: {} }; + expect(SecurityEffectiveAccessSchema.safeParse(node).success).toBe(true); + expect(safeValidateSchema(node).success).toBe(true); + expect(StrictAnyComponentSchema.safeParse(node).success).toBe(true); + }); + + it('accepts the node inside a page, with the spec page-component keys a page writes beside it', () => { + const page = { + type: 'page', + children: [{ type: TYPE, id: 'user_effective_access', className: 'mt-2' }], + }; + expect(safeValidateSchema(page).success).toBe(true); + expect(StrictAnyComponentSchema.safeParse(page).success).toBe(true); + }); + + it('is claimed at `type` — the union no longer refuses the literal', () => { + // The ratchet's reading (`registered-types-validate-ratchet-10859.test.ts`). + expect(safeValidateSchema({ type: TYPE }).success).toBe(true); + // Control: an unclaimed `security:` literal IS refused there. + const unclaimed = issuesOf({ type: 'security:no-such-widget' }); + expect(unclaimed.some((i) => i.code === 'invalid_union' && i.path === 'type')).toBe(true); + }); +}); + +describe('`security:effective-access` — what the arm refuses', () => { + it('refuses any key inside the bag — the widget reads none, the person included', () => { + expect(issuesOf({ type: TYPE, properties: { userId: 'user_mia' } })).toEqual([ + expect.objectContaining({ code: 'unrecognized_keys', path: 'properties' }), + ]); + }); + + it('refuses a bag that is not an object', () => { + expect(issuesOf({ type: TYPE, properties: 'user_mia' })).toEqual([ + expect.objectContaining({ code: 'invalid_type', path: 'properties' }), + ]); + }); + + it('refuses a flat key the widget does not read, on the strict face', () => { + const strict = StrictAnyComponentSchema.safeParse({ type: TYPE, userId: 'user_mia' }); + expect(strict.success).toBe(false); + expect(strict.error?.issues.map((i) => i.code)).toContain('unrecognized_keys'); + }); + + it.each(['children', 'body'])('refuses `%s` by name — the widget reads neither content channel', (key) => { + const issues = issuesOf({ type: TYPE, [key]: [{ type: 'text', content: 'x' }] }); + expect(issues).toEqual([expect.objectContaining({ code: 'invalid_type', path: key })]); + expect(issues[0].message).toContain(TYPE); + }); + + it('reaches the arm through the union, not a second schema', () => { + // The union dispatches on the literal to this arm: the same refusal either way. + const document = { type: TYPE, properties: { userId: 'user_mia' } }; + const bare = SecurityEffectiveAccessSchema.safeParse(document); + const viaUnion = AnyComponentSchema.safeParse(document); + expect(bare.success).toBe(false); + expect(viaUnion.success).toBe(false); + expect(viaUnion.error?.issues.map((i) => [i.code, i.path.join('.')])).toEqual( + bare.error?.issues.map((i) => [i.code, i.path.join('.')]), + ); + }); +}); diff --git a/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts b/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts index 0b49b20702d..47b0cb12565 100644 --- a/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -985,6 +985,7 @@ import { AppComponentSchema, AppSchemaRendererNodeSchema, MenuItemSchema as AppM import { BaseSchema, ComponentConfigSchema, ComponentInputSchema, ComponentMetaSchema, KeyedI18nLabelSchema, SchemaNodeSchema } from '../zod/base.zod.js'; import { CloudWorkspaceTimezoneNoticeSchema } from '../zod/cloud.zod.js'; import { McpAuthorizedAssistantsSchema } from '../zod/mcp-authorized-assistants.zod.js'; +import { SecurityEffectiveAccessSchema } from '../zod/security-effective-access.zod.js'; import { ChecklistBlockItemSchema, ChecklistBlockSchema, HeroBlockSchema, StatBlockSchema, TileGridBlockAddTileSchema, TileGridBlockItemSchema, TileGridBlockSchema, UsageBarBlockSchema } from '../zod/product-blocks.zod.js'; import { RecordApprovalDecisionBlockSchema } from '../zod/record-approval-decision.zod.js'; import { RecordApprovalsBlockSchema, RecordAttachmentsBlockSchema } from '../zod/record-panels.zod.js'; @@ -1006,6 +1007,7 @@ import type { AppComponentSchema as Ts_AppComponentSchema, AppSchemaRendererNode import type { BaseSchema as Ts_BaseSchema, ComponentConfig as Ts_ComponentConfig, ComponentInput as Ts_ComponentInput, ComponentMeta as Ts_ComponentMeta, KeyedI18nLabel as Ts_KeyedI18nLabel } from '../base'; import type { CloudWorkspaceTimezoneNoticeSchema as Ts_CloudWorkspaceTimezoneNoticeSchema } from '../cloud'; import type { McpAuthorizedAssistantsSchema as Ts_McpAuthorizedAssistantsSchema } from '../mcp-authorized-assistants'; +import type { SecurityEffectiveAccessSchema as Ts_SecurityEffectiveAccessSchema } from '../security-effective-access'; import type { ChecklistBlockItem as Ts_ChecklistBlockItem, ChecklistBlockSchema as Ts_ChecklistBlockSchema, HeroBlockSchema as Ts_HeroBlockSchema, StatBlockSchema as Ts_StatBlockSchema, TileGridBlockAddTile as Ts_TileGridBlockAddTile, TileGridBlockItem as Ts_TileGridBlockItem, TileGridBlockSchema as Ts_TileGridBlockSchema, UsageBarBlockSchema as Ts_UsageBarBlockSchema } from '../product-blocks'; import type { RecordApprovalDecisionBlockSchema as Ts_RecordApprovalDecisionBlockSchema } from '../record-approval-decision'; import type { RecordApprovalsBlockSchema as Ts_RecordApprovalsBlockSchema, RecordAttachmentsBlockSchema as Ts_RecordAttachmentsBlockSchema } from '../record-panels'; @@ -1856,6 +1858,8 @@ const MIRRORS = { 'cloud.zod.ts#CloudWorkspaceTimezoneNoticeSchema': CloudWorkspaceTimezoneNoticeSchema, // The arm and its TS twin landed together with the widget's registration. 'mcp-authorized-assistants.zod.ts#McpAuthorizedAssistantsSchema': McpAuthorizedAssistantsSchema, + // The arm and its TS twin landed together with the widget's registration. + 'security-effective-access.zod.ts#SecurityEffectiveAccessSchema': SecurityEffectiveAccessSchema, // objectui#12197 — the product-page blocks' arms and their TS twins landed together. 'product-blocks.zod.ts#ChecklistBlockItemSchema': ChecklistBlockItemSchema, 'product-blocks.zod.ts#ChecklistBlockSchema': ChecklistBlockSchema, @@ -2063,6 +2067,7 @@ interface Declared { 'base.zod.ts#KeyedI18nLabelSchema': Ts_KeyedI18nLabel; 'cloud.zod.ts#CloudWorkspaceTimezoneNoticeSchema': Ts_CloudWorkspaceTimezoneNoticeSchema; 'mcp-authorized-assistants.zod.ts#McpAuthorizedAssistantsSchema': Ts_McpAuthorizedAssistantsSchema; + 'security-effective-access.zod.ts#SecurityEffectiveAccessSchema': Ts_SecurityEffectiveAccessSchema; 'product-blocks.zod.ts#ChecklistBlockItemSchema': Ts_ChecklistBlockItem; 'product-blocks.zod.ts#ChecklistBlockSchema': Ts_ChecklistBlockSchema; 'product-blocks.zod.ts#HeroBlockSchema': Ts_HeroBlockSchema; @@ -5402,7 +5407,7 @@ const ZOD_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'zod'); * MINUEND under it had moved. Nothing failed on any of those days, because nothing * compared the registry to a number. objectui#7433 is that absence, not the digits. */ -const EXPECTED_MIRROR_PAIRS = 198; +const EXPECTED_MIRROR_PAIRS = 199; /** * A ledger this file can size from its own AST. `WiderThanDeclared` joined at diff --git a/ui/packages/types/src/index.ts b/ui/packages/types/src/index.ts index 66b70354130..95a6a430433 100644 --- a/ui/packages/types/src/index.ts +++ b/ui/packages/types/src/index.ts @@ -885,6 +885,7 @@ import type { ObjectQLComponentSchema, ListViewSchema } from './objectql.js'; import type { AppComponentSchema, AppSchemaRendererNodeSchema } from './app.js'; import type { CloudWorkspaceTimezoneNoticeSchema } from './cloud.js'; import type { McpAuthorizedAssistantsSchema } from './mcp-authorized-assistants.js'; +import type { SecurityEffectiveAccessSchema } from './security-effective-access.js'; import type { HeroBlockSchema, StatBlockSchema, @@ -1025,6 +1026,11 @@ export type { McpAuthorizedAssistantsSchema, } from './mcp-authorized-assistants.js'; +export type { + // The `security:effective-access` node's TS twin, landed with its zod arm. + SecurityEffectiveAccessSchema, +} from './security-effective-access.js'; + export type { // The product-page blocks' TS twins, landed with their zod arms and their // `@object-ui/app-shell` registrations (objectui#12197). @@ -1156,6 +1162,8 @@ export type AnySchema = | CloudWorkspaceTimezoneNoticeSchema // Landed with its zod arm and its `@object-ui/app-shell` registration. | McpAuthorizedAssistantsSchema + // Landed with its zod arm and its `@object-ui/app-shell` registration. + | SecurityEffectiveAccessSchema // objectui#12342 — landed with its zod arm. | RecordApprovalDecisionBlockSchema // objectui#12363 — landed with their zod arms. diff --git a/ui/packages/types/src/security-effective-access.ts b/ui/packages/types/src/security-effective-access.ts new file mode 100644 index 00000000000..bc9e5fa5553 --- /dev/null +++ b/ui/packages/types/src/security-effective-access.ts @@ -0,0 +1,60 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @object-ui/types - `security:effective-access` Schema + * + * The TypeScript twin of `zod/security-effective-access.zod.ts`. + * + * @module security-effective-access + * @packageDocumentation + */ + +import type { BaseSchema } from './base.js'; + +/** + * `security:effective-access` — one person's effective permission sets, each + * with every origin it reaches them through (default, a position, direct, + * derived), for a `sys_user` record page: the SDUI widget + * `@object-ui/app-shell` registers. The TypeScript twin of + * `zod/security-effective-access.zod.ts`'s `SecurityEffectiveAccessSchema`, + * landed with the arm. + * + * The members are read from the widget's read points: + * + * - `properties` — optional and empty: the widget reads no key of the bag. + * The person is the `sys_user` record the page is bound to, never a key of + * the node. + * - Neither content channel is read, so both are refused by name, the twin + * of the arm's two `retirementTombstone` members. + * + * The parity pair is `security-effective-access.zod.ts#SecurityEffectiveAccessSchema` + * in `__tests__/zod-mirror-parity.test.ts`. + */ +export interface SecurityEffectiveAccessSchema extends BaseSchema { + type: 'security:effective-access'; + /** + * The props bag — the widget reads no prop, so the only bag it accepts is + * `{}`. The person comes from the `sys_user` record the page is bound to. + */ + properties?: Record; + /** + * REFUSED BY NAME — `security:effective-access` reads neither content + * channel: `UserEffectiveAccess` reads only `className`, and + * `SchemaRenderer` strips both channels out of the props it spreads. + * + * @deprecated Not a channel `security:effective-access` reads — nothing renders it. + */ + body?: never; + /** + * REFUSED BY NAME, for the reason `body` gives. + * + * @deprecated Not a channel `security:effective-access` reads — nothing renders it. + */ + children?: never; +} diff --git a/ui/packages/types/src/zod/README.md b/ui/packages/types/src/zod/README.md index 4a29cffb586..aaa6c4528ba 100644 --- a/ui/packages/types/src/zod/README.md +++ b/ui/packages/types/src/zod/README.md @@ -328,6 +328,7 @@ The last six arrived with `@objectstack/spec` 17.5.0, whose rows for them were m ### Cloud Widgets (1) `@object-ui/app-shell`'s `cloud:` SDUI widgets. `@objectstack/spec` has no `ComponentPropsMap` row for them, so each arm is declared here from the keys its widget reads. +- `SecurityEffectiveAccessSchema` — `security:effective-access`, one person's effective permission sets, each with every origin it reaches them through, for a `sys_user` record page: the widget reads no prop, so `properties` may only be `{}`. The person comes from the record the page is bound to. - `McpAuthorizedAssistantsSchema` — `mcp:authorized-assistants`, an administrator's view of the AI assistants one person authorized, for a `sys_user` record page: the widget reads no prop, so `properties` may only be `{}`. The person comes from the record the page is bound to. - `CloudWorkspaceTimezoneNoticeSchema` — `cloud:workspace-timezone-notice`, the welcome page's line naming the workspace's seeded timezone: the widget reads no prop, so `properties` may only be `{}`. The zone comes from the organization's entitlements summary. diff --git a/ui/packages/types/src/zod/index.zod.ts b/ui/packages/types/src/zod/index.zod.ts index d9d240bd06e..e3ec7440683 100644 --- a/ui/packages/types/src/zod/index.zod.ts +++ b/ui/packages/types/src/zod/index.zod.ts @@ -555,6 +555,11 @@ export { CloudWorkspaceTimezoneNoticeSchema } from './cloud.zod.js'; // ============================================================================ export { McpAuthorizedAssistantsSchema } from './mcp-authorized-assistants.zod.js'; +// ============================================================================ +// `security:effective-access` - `@object-ui/app-shell`'s person's-effective-access widget +// ============================================================================ +export { SecurityEffectiveAccessSchema } from './security-effective-access.zod.js'; + // ============================================================================ // `record:approval_decision` - `@object-ui/app-shell`'s approval decision panel (objectui#12342) // ============================================================================ @@ -590,6 +595,7 @@ import { DesignerUnionSchema } from './designer.zod.js'; import { PublicBlockComponentSchema } from './public-blocks.zod.js'; import { CloudWorkspaceTimezoneNoticeSchema, RetiredCloudPlanStatusNodeSchema } from './cloud.zod.js'; import { McpAuthorizedAssistantsSchema } from './mcp-authorized-assistants.zod.js'; +import { SecurityEffectiveAccessSchema } from './security-effective-access.zod.js'; import { RecordApprovalDecisionBlockSchema } from './record-approval-decision.zod.js'; import { RecordApprovalsBlockSchema, RecordAttachmentsBlockSchema } from './record-panels.zod.js'; import { nestedComponentJudgment } from './nested-component-walk.js'; @@ -703,6 +709,8 @@ export const AnyComponentSchema = defineNodeComponentUnion(z.discriminatedUnion( CloudWorkspaceTimezoneNoticeSchema, // Landed with its `@object-ui/app-shell` registration. McpAuthorizedAssistantsSchema, + // Landed with its `@object-ui/app-shell` registration. + SecurityEffectiveAccessSchema, // objectui#12342 — landed with its registration. RecordApprovalDecisionBlockSchema, // objectui#12363 — the two record panels the default record page emits. diff --git a/ui/packages/types/src/zod/security-effective-access.zod.ts b/ui/packages/types/src/zod/security-effective-access.zod.ts new file mode 100644 index 00000000000..a7b7b260f2d --- /dev/null +++ b/ui/packages/types/src/zod/security-effective-access.zod.ts @@ -0,0 +1,63 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @object-ui/types/zod - `security:effective-access` Zod Validator + * + * The arm for `@object-ui/app-shell`'s `security:effective-access` SDUI widget + * (`console/security/UserEffectiveAccess.tsx`): one person's effective + * permission sets, each with every origin it reaches them through, for a + * `sys_user` record page. + * + * ## Why the arm lands with the registration + * + * `AnyComponentSchema` refuses a registered type it has no arm for, with one + * `invalid_union` issue at `type`, so a page placing the widget could not pass + * `safeValidateSchema` or `objectui validate`. The count of registered types + * still refused there is ratcheted by + * `packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts`, + * which never rises, so the registration and this arm ship together. + * + * ## Declared from the widget's read points + * + * `@objectstack/spec` has no `ComponentPropsMap` row for a `security:` widget, + * so the members are the keys the widget reads. It reads `className` (from + * `BaseSchema`) and no prop: the person is the record the page is bound to, + * never a key of the node. So the only `properties` bag it takes is `{}`, and + * both content channels are refused by name. + */ + +import { z } from 'zod'; +import { BaseSchema } from './base.zod.js'; +import { retirementTombstone } from './tombstone.zod.js'; + +/** One refusal string for both content channels of `SecurityEffectiveAccessSchema`. */ +const SECURITY_EFFECTIVE_ACCESS_NEITHER_CHANNEL = + 'REFUSED — `security:effective-access` reads NEITHER content channel: its registration hands ' + + 'the node to `UserEffectiveAccess`, which reads only `className`, and `SchemaRenderer` strips ' + + 'both channels out of the props bag it spreads. An authored value would render NOTHING — no ' + + 'render-time error or warning and no element. What it renders instead: the effective permission ' + + 'sets of the person of the bound `sys_user` record, each with where it comes from.'; + +/** + * `security:effective-access` — a person's effective permission sets and their + * origins on their `sys_user` record page. Pinned by + * `../__tests__/security-effective-access-arm.test.ts`. + */ +export const SecurityEffectiveAccessSchema = BaseSchema.extend({ + type: z.literal('security:effective-access'), + properties: z + .strictObject({}) + .optional() + .describe( + 'The `security:effective-access` props bag — the widget reads no prop, so the only bag it ' + + 'accepts is `{}`. The person comes from the `sys_user` record the page is bound to.', + ), + body: retirementTombstone(SECURITY_EFFECTIVE_ACCESS_NEITHER_CHANNEL), + children: retirementTombstone(SECURITY_EFFECTIVE_ACCESS_NEITHER_CHANNEL), +}); From 6db8ab2c2f7dbfc71dd4e3556e03378efecb0200 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 03:45:57 +0000 Subject: [PATCH 2/6] chore(changeset): note the effective-access widget and its page placement Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5kxrRdCiSKcPqREG4w --- .changeset/31216-user-effective-access.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/31216-user-effective-access.md diff --git a/.changeset/31216-user-effective-access.md b/.changeset/31216-user-effective-access.md new file mode 100644 index 00000000000..f8ff70bc728 --- /dev/null +++ b/.changeset/31216-user-effective-access.md @@ -0,0 +1,18 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +'@object-ui/types': patch +'@object-ui/cli': patch +'@objectstack/platform-objects': patch +--- + +feat(console): the user page's Permission Sets tab shows the person's effective permission sets and where each comes from + +Clause-②: yes (widening) +Metadata-protocol: none + +- **`@object-ui/app-shell`**: new SDUI widget `security:effective-access` for a `sys_user` record page. It reads the person from the bound record (no props) and lists every permission set in force for them in the caller's organization through `security.describeEffectiveAccess(userId)` of `@objectstack/client`. Each set appears once, by its catalog label with its name beside it, with every origin the server reports: a direct grant, a position by name, the default binding, or a derived standing (the configured platform administrator, AI access). A `403` (a person the viewer may not manage) and a `501` (a server that does not report effective access) each show one quiet line and no list; any other failure is named. The widget reads again when a grant or a position is written on the page. Off a `sys_user` record it renders nothing. +- **`@objectstack/platform-objects`**: the `sys_user` record page's Permission Sets tab opens with the `security:effective-access` widget, above the list of stored grants. A person who holds access only through the default binding or a position no longer reads as having no permission sets; direct grants keep their remove action in the list below. +- **`@object-ui/i18n`**: `effectiveAccess.*` (the widget's title, body, refused, unserved, failed and empty lines, and one label per origin kind) added in all eleven packs. +- **`@object-ui/types`**: `SecurityEffectiveAccessSchema` (zod arm) and its TypeScript twin, so a page placing `{ type: 'security:effective-access' }` validates. The widget reads only `className`: `properties` may only be `{}`, and `body` / `children` are refused by name. +- **`@object-ui/cli`**: `objectui check` knows the `security:effective-access` type (the generated known-types list). From 3555fac5f2e3ca764420bc54a9471eeda8784ba2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 03:47:12 +0000 Subject: [PATCH 3/6] chore(i18n): regenerate zh-Hant from zh-Hans for the effective-access strings Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5kxrRdCiSKcPqREG4w --- ui/packages/i18n/src/locales/zh-Hant.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/packages/i18n/src/locales/zh-Hant.ts b/ui/packages/i18n/src/locales/zh-Hant.ts index 6f80ac6f2f8..e4feb282661 100644 --- a/ui/packages/i18n/src/locales/zh-Hant.ts +++ b/ui/packages/i18n/src/locales/zh-Hant.ts @@ -4128,18 +4128,18 @@ const zhHant = { }, effectiveAccess: { title: '實際生效的權限集', - body: '此人在你目前組織中實際生效的每個權限集,以及各自的來源。直接指派的權限集也列在下方,可以在那裡移除。', + body: '此人在你當前組織中實際生效的每個權限集,以及各自的來源。直接分配的權限集也列在下方,可以在那裡移除。', refused: '你無權檢視此人實際生效的權限。', unserved: '此伺服器不提供實際生效權限的查詢。', loadFailed: '無法載入實際生效的權限:{{message}}', empty: '此人在此組織中沒有生效的權限集。', originsLabel: '來源', origin: { - direct: '直接指派', - position: '職位:{{position}}', + direct: '直接分配', + position: '崗位:{{position}}', default: '預設繫結', - derivedPlatformAdmin: '設定的平台管理員', - derivedAiAccess: '已開啟 AI 存取', + derivedPlatformAdmin: '配置的平台管理員', + derivedAiAccess: '已開啟 AI 訪問', }, }, excelImport: { From cf2908d8e920f5083ce7058d2bf708ca1506a668 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 03:54:31 +0000 Subject: [PATCH 4/6] refactor(console): keep the effective-access widget module to components only Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5kxrRdCiSKcPqREG4w --- .../security/UserEffectiveAccess.test.tsx | 3 +- .../console/security/UserEffectiveAccess.tsx | 58 +++--------------- .../src/console/security/effectiveAccess.ts | 59 +++++++++++++++++++ 3 files changed, 69 insertions(+), 51 deletions(-) create mode 100644 ui/packages/app-shell/src/console/security/effectiveAccess.ts diff --git a/ui/packages/app-shell/src/console/security/UserEffectiveAccess.test.tsx b/ui/packages/app-shell/src/console/security/UserEffectiveAccess.test.tsx index f4eb47bbbd1..28b94c1b638 100644 --- a/ui/packages/app-shell/src/console/security/UserEffectiveAccess.test.tsx +++ b/ui/packages/app-shell/src/console/security/UserEffectiveAccess.test.tsx @@ -54,7 +54,8 @@ import { SchemaRenderer, type MetadataContextValue, } from '@object-ui/react'; -import { describeOrigin, type EffectiveAccessAnswer } from './UserEffectiveAccess'; +import './UserEffectiveAccess'; +import { describeOrigin, type EffectiveAccessAnswer } from './effectiveAccess'; const NODE = { type: 'security:effective-access', properties: {} }; diff --git a/ui/packages/app-shell/src/console/security/UserEffectiveAccess.tsx b/ui/packages/app-shell/src/console/security/UserEffectiveAccess.tsx index e20b474faa7..8525c1078fe 100644 --- a/ui/packages/app-shell/src/console/security/UserEffectiveAccess.tsx +++ b/ui/packages/app-shell/src/console/security/UserEffectiveAccess.tsx @@ -36,17 +36,14 @@ import { useAdapter, useDataInvalidation, useMetadata, useRecordContext } from ' import type { ObjectStackAdapter } from '@object-ui/data-objectstack'; import { AlertCircle, ShieldCheck } from 'lucide-react'; import { catalogEntries } from '../../views/setup/peopleAssignments.js'; - -type Translate = (key: string, options?: Record) => string; - -type DescribeEffectiveAccess = ReturnType['security']['describeEffectiveAccess']; -/** The answer (`EffectiveAccess` of `@objectstack/spec/contracts`, through the client). */ -export type EffectiveAccessAnswer = Awaited>; -/** One way a set reaches the person (`EffectivePermissionSetOrigin`). */ -export type EffectiveAccessOrigin = EffectiveAccessAnswer['permissionSets'][number]['origins'][number]; - -/** The object whose record pages this widget belongs on. */ -export const EFFECTIVE_ACCESS_OBJECT = 'sys_user'; +import { + EFFECTIVE_ACCESS_OBJECT, + describeOrigin, + failureText, + httpStatusOf, + originKey, + type EffectiveAccessAnswer, +} from './effectiveAccess.js'; /** The registry type the set labels are read from. */ const PERMISSION_SET_TYPE = 'permission'; @@ -58,45 +55,6 @@ type Read = | { state: 'unserved' } | { state: 'failed'; message: string }; -/** The status the client stamps on a refused call (`httpStatus`, from `res.status`). */ -function httpStatusOf(err: unknown): number | undefined { - const status = (err as { httpStatus?: unknown } | null | undefined)?.httpStatus; - return typeof status === 'number' ? status : undefined; -} - -function failureText(err: unknown): string { - const message = (err as { message?: unknown } | null | undefined)?.message; - return typeof message === 'string' && message ? message : String(err); -} - -/** Where one origin says the set comes from. */ -export function describeOrigin(origin: EffectiveAccessOrigin, t: Translate): string { - switch (origin.kind) { - case 'direct': - return t('effectiveAccess.origin.direct'); - case 'position': - return t('effectiveAccess.origin.position', { position: origin.position }); - case 'default': - return t('effectiveAccess.origin.default'); - case 'derived': - return origin.source === 'platform_admin_config' - ? t('effectiveAccess.origin.derivedPlatformAdmin') - : t('effectiveAccess.origin.derivedAiAccess'); - } -} - -/** A stable key for one origin of a row. */ -function originKey(origin: EffectiveAccessOrigin): string { - switch (origin.kind) { - case 'position': - return `position-${origin.position}`; - case 'derived': - return `derived-${origin.source}`; - default: - return origin.kind; - } -} - export interface UserEffectiveAccessProps { /** Carries the scope class of the node's `responsiveStyles`. */ className?: string; diff --git a/ui/packages/app-shell/src/console/security/effectiveAccess.ts b/ui/packages/app-shell/src/console/security/effectiveAccess.ts new file mode 100644 index 00000000000..48622979eed --- /dev/null +++ b/ui/packages/app-shell/src/console/security/effectiveAccess.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `security:effective-access` widget's vocabulary: the client's answer, + * typed off the adapter's own client, and the words each origin is shown in. + */ + +import type { ObjectStackAdapter } from '@object-ui/data-objectstack'; + +type Translate = (key: string, options?: Record) => string; + +type DescribeEffectiveAccess = ReturnType['security']['describeEffectiveAccess']; +/** The answer (`EffectiveAccess` of `@objectstack/spec/contracts`, through the client). */ +export type EffectiveAccessAnswer = Awaited>; +/** One way a set reaches the person (`EffectivePermissionSetOrigin`). */ +export type EffectiveAccessOrigin = EffectiveAccessAnswer['permissionSets'][number]['origins'][number]; + +/** The object whose record pages the widget belongs on. */ +export const EFFECTIVE_ACCESS_OBJECT = 'sys_user'; + +/** The status the client stamps on a refused call (`httpStatus`, from `res.status`). */ +export function httpStatusOf(err: unknown): number | undefined { + const status = (err as { httpStatus?: unknown } | null | undefined)?.httpStatus; + return typeof status === 'number' ? status : undefined; +} + +/** A failed call's words: the client throws an `Error` carrying the server's message. */ +export function failureText(err: unknown): string { + const message = (err as { message?: unknown } | null | undefined)?.message; + return typeof message === 'string' && message ? message : String(err); +} + +/** Where one origin says the set comes from. */ +export function describeOrigin(origin: EffectiveAccessOrigin, t: Translate): string { + switch (origin.kind) { + case 'direct': + return t('effectiveAccess.origin.direct'); + case 'position': + return t('effectiveAccess.origin.position', { position: origin.position }); + case 'default': + return t('effectiveAccess.origin.default'); + case 'derived': + return origin.source === 'platform_admin_config' + ? t('effectiveAccess.origin.derivedPlatformAdmin') + : t('effectiveAccess.origin.derivedAiAccess'); + } +} + +/** A stable key for one origin of a row. */ +export function originKey(origin: EffectiveAccessOrigin): string { + switch (origin.kind) { + case 'position': + return `position-${origin.position}`; + case 'derived': + return `derived-${origin.source}`; + default: + return origin.kind; + } +} From a27aa0719f02465885edfa926295267d12786bd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 03:59:57 +0000 Subject: [PATCH 5/6] test(console): type the authored effective-access node on its types twin Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5kxrRdCiSKcPqREG4w --- .../src/console/security/UserEffectiveAccess.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/packages/app-shell/src/console/security/UserEffectiveAccess.test.tsx b/ui/packages/app-shell/src/console/security/UserEffectiveAccess.test.tsx index 28b94c1b638..457f6e565d8 100644 --- a/ui/packages/app-shell/src/console/security/UserEffectiveAccess.test.tsx +++ b/ui/packages/app-shell/src/console/security/UserEffectiveAccess.test.tsx @@ -55,9 +55,11 @@ import { type MetadataContextValue, } from '@object-ui/react'; import './UserEffectiveAccess'; +import type { SecurityEffectiveAccessSchema } from '@object-ui/types'; import { describeOrigin, type EffectiveAccessAnswer } from './effectiveAccess'; -const NODE = { type: 'security:effective-access', properties: {} }; +/** The node a page authors, typed on its `@object-ui/types` twin. */ +const NODE: SecurityEffectiveAccessSchema = { type: 'security:effective-access', properties: {} }; /** The permission-set catalog the labels come from (`/meta/permission`). */ const CATALOG = [ From ce83a535f90037f4315ecf2a1caabfddb6293ba7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 04:17:54 +0000 Subject: [PATCH 6/6] fix(types): the effective-access arm's refusal names the parser tier Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5kxrRdCiSKcPqREG4w --- ui/packages/types/src/zod/security-effective-access.zod.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/packages/types/src/zod/security-effective-access.zod.ts b/ui/packages/types/src/zod/security-effective-access.zod.ts index a7b7b260f2d..f96715970f8 100644 --- a/ui/packages/types/src/zod/security-effective-access.zod.ts +++ b/ui/packages/types/src/zod/security-effective-access.zod.ts @@ -41,8 +41,10 @@ const SECURITY_EFFECTIVE_ACCESS_NEITHER_CHANNEL = 'REFUSED — `security:effective-access` reads NEITHER content channel: its registration hands ' + 'the node to `UserEffectiveAccess`, which reads only `className`, and `SchemaRenderer` strips ' + 'both channels out of the props bag it spreads. An authored value would render NOTHING — no ' - + 'render-time error or warning and no element. What it renders instead: the effective permission ' - + 'sets of the person of the bound `sys_user` record, each with where it comes from.'; + + 'render-time error or warning and no element; only the parser tier\'s `not-a-container` warning ' + + '(objectui#9910) noticed it, because the registration declares no `children` input. What it ' + + 'renders instead: the effective permission sets of the person of the bound `sys_user` record, ' + + 'each with where it comes from.'; /** * `security:effective-access` — a person's effective permission sets and their