diff --git a/.changeset/26022-retire-position-grant-via-name.md b/.changeset/26022-retire-position-grant-via-name.md new file mode 100644 index 00000000000..143384090c0 --- /dev/null +++ b/.changeset/26022-retire-position-grant-via-name.md @@ -0,0 +1,24 @@ +--- +'@objectstack/spec': patch +'@object-ui/app-shell': patch +--- + +**BREAKING** — `PositionGrantVia` (`@objectstack/spec/contracts`) loses its `'name'` member, and the Setup position list drops its "(by name)" badge (#26022). + +Clause-②: no (narrowing) +Metadata-protocol: none +Ruling-ref: #13419 ruling B (`5472608110`); 「甲」 as amended (`6303865025`); triage unlock on #26022 (`6424661273`) + +**Why.** The security plugin no longer grants a position's holders the permission set that only shares the position's name: a position grants the sets its definition names in `permissionSets`, and nothing else (#24757). Since then nothing serves `via: 'name'` on `describePositionGrants` or on `GET /api/v1/meta/position?include=grants`. The spec still published the member and the console still drew a badge for it, so both described a grant the runtime no longer makes. #13419 ruling B retires the channel in 18.x with no retirement stage. + +What changes: + +- `@objectstack/spec`: `PositionGrantVia` is `'definition'`. Every entry of `PositionGrantSummary.permissionSets` carries `via: 'definition'`, as the security plugin already serves it. +- `@object-ui/app-shell`, Setup → Positions: the granted-sets column shows each set's name alone. The "(by name)" / 「(同名)」 badge and its hint are gone, with the i18n keys `engine.position.grants.byName` and `engine.position.grants.byNameHint` (en and zh). +- `@object-ui/app-shell`, Setup → Users on a managed environment: the 「未授权」 marker judges a held position only by the sets its definition lists. It no longer counts a permission set that shares the position's name, and it no longer reads the permission-set catalogue to do so. A holder of such a position is now marked when nothing else grants them a set beyond the baseline, which is what the server enforces. Before this change, such a holder was left unmarked. + +For TypeScript consumers: code that compares a `PositionGrantVia` value with `'name'` no longer compiles (TS2367). Delete that branch, because no server serves the value. To have a position grant the set that shares its name, name the set in the position's `permissionSets`. No stored row or authored metadata carries the type, so nothing is converted. + +The ADR-0087 D3 semantic entry `position-grant-via-name-retired` records the retirement. `PositionGrantVia` types a served description, not a stack collection or a metadata type, so there is no source for a D2 conversion to rewrite. + + diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 8583db07e3c..d261d27030f 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -3373,7 +3373,8 @@ function withItems(raw: unknown, items: any[]): unknown { * organization, or `null` when no count is a fact for this caller (see * `PositionGrantSummary.holderCount`); * - `_grantedPermissionSets` — the sets it grants its holders, in force, each - * with `via: 'definition' | 'name'` (`name`: the position-name fold). + * with `via: 'definition'` (a set that only shares the position's name is + * not granted, so it is not listed). * * Both are the security service's answer (`describePositionGrants`), which reads * them where the resolver enforces them — this step only places them, so the diff --git a/packages/runtime/src/domains/meta-list-projection-parity.test.ts b/packages/runtime/src/domains/meta-list-projection-parity.test.ts index 4c6a210b1a9..1a8af3f249f 100644 --- a/packages/runtime/src/domains/meta-list-projection-parity.test.ts +++ b/packages/runtime/src/domains/meta-list-projection-parity.test.ts @@ -344,7 +344,7 @@ const securityFor = (caller: Caller) => ({ holderCount: caller.holdings.length > 0 ? (position === 'sales_rep' ? 2 : 1) : null, permissionSets: position === 'sales_rep' ? [{ name: 'crm_sales_user', via: 'definition' }] - : [{ name: 'sales_manager', via: 'name' }], + : [{ name: 'forecasting', via: 'definition' }], })), }); @@ -676,7 +676,7 @@ describe('[#20320] row A: the reference moves — each probe changes RestServer\ const described = (await read('/meta/position', { include: 'grants' })).items; expect(described?.map((p: any) => [p.name, p._holderCount, p._grantedPermissionSets])).toEqual([ ['sales_rep', 2, [{ name: 'crm_sales_user', via: 'definition' }]], - ['sales_manager', 1, [{ name: 'sales_manager', via: 'name' }]], + ['sales_manager', 1, [{ name: 'forecasting', via: 'definition' }]], ]); expect(text(await read('/meta/positions'))).not.toContain('_holderCount'); expect((await bootRest('non-holder').read('/meta/position', { include: 'grants' })).items?.[0]?._holderCount).toBeNull(); diff --git a/packages/spec/src/contracts/security-service.ts b/packages/spec/src/contracts/security-service.ts index f1cad29dfaa..ef3704921a9 100644 --- a/packages/spec/src/contracts/security-service.ts +++ b/packages/spec/src/contracts/security-service.ts @@ -121,13 +121,14 @@ export interface DelegableScope { * {@link PositionGrantSummary}: * * - `definition` — the position's definition names it in `permissionSets` - * (ADR-0131 D3/D4), the binding the spec declares; - * - `name` — it shares the position's NAME and the definition does not list - * it: the resolver's position-name fold grants it anyway. That grant is - * deprecated (the runtime warns `position_name_fold_grant`); it is reported so - * that what a position is shown to grant is what it is enforced to grant. + * (ADR-0131 D3/D4), the binding the spec declares and the only one the + * resolver grants by. + * + * A set that merely shares the position's name is not granted, so it is never + * listed: the position-name fold is retired, and with it the `name` member + * (ADR-0087 D3 entry `position-grant-via-name-retired`). */ -export type PositionGrantVia = 'definition' | 'name'; +export type PositionGrantVia = 'definition'; /** One position, as {@link ISecurityService.describePositionGrants} describes it. */ export interface PositionGrantSummary { @@ -727,8 +728,8 @@ export interface ISecurityService { * get?" from the enforcement path rather than from a second copy of it. * * Answers one {@link PositionGrantSummary} per name, in input order. A name - * that names no position is described like any other: no holders, and only - * what the position-name fold would grant a holder of that name. + * that names no position is described like any other: no holders and no + * permission sets. * * **THROWS on a read failure** — a holder count or a grant that could not be * read is never reported as `0` or as none. diff --git a/packages/spec/src/migrations/entries/semantic/18.position-grant-via-name-retired.ts b/packages/spec/src/migrations/entries/semantic/18.position-grant-via-name-retired.ts new file mode 100644 index 00000000000..19cd897f04e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.position-grant-via-name-retired.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// A TS/API surface, never stored in stack metadata: `PositionGrantVia` types a +// served description (`describePositionGrants`, `GET /meta/position?include=grants`), +// so there is no source for the chain to rewrite and this entry is the whole +// ADR-0087 registration. The runtime half (the position-name fold itself) carries +// no entry of its own: it moved no authorable key, spelling or stored shape. +export const entry: SemanticMigration = { + id: 'position-grant-via-name-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'PositionGrantVia on @objectstack/spec/contracts (the via of each entry of ' + + 'PositionGrantSummary.permissionSets, served as _grantedPermissionSets on the position list ' + + 'read with include=grants) loses its name member', + replacement: + '(no replacement member — `via` is `\'definition\'` on every entry. Code that branched on ' + + '`via === \'name\'` deletes that branch: the compiler names it (TS2367, the two types have no ' + + 'overlap). To have a position grant the permission set that shares its name, name the set in the ' + + 'position\'s `permissionSets`.)', + reason: + 'ADR-0131 D3/D4 and the retirement of the position-name fold: a position grants only the sets its ' + + 'definition names, so a set that merely shares its name is neither granted nor described. Since ' + + 'the security plugin stopped requesting a held position\'s name as a set name, nothing serves ' + + '`via: \'name\'`; a published member no producer can emit invites a consumer to keep a branch, ' + + 'such as a by-name badge, that can only be dead or wrong (ADR-0049 enforce-or-remove).', + acceptanceCriteria: + 'No code compares a `PositionGrantVia` value with `\'name\'` (TS2367 after upgrade). ' + + '`GET /api/v1/meta/position?include=grants` serves `via: \'definition\'` on every entry of ' + + '`_grantedPermissionSets`, exactly as before this change. No authored metadata document ever carried ' + + 'the type, so `os migrate meta` has nothing to visit.', +}; diff --git a/ui/content/docs/guide/console.md b/ui/content/docs/guide/console.md index 9e841d5cae6..04b102dae9f 100644 --- a/ui/content/docs/guide/console.md +++ b/ui/content/docs/guide/console.md @@ -39,7 +39,7 @@ The console opens at **http://localhost:5180** (the port is fixed in `apps/conso | **Studio Copilot Drafts** | When the AI copilot saves a change to a live app as a draft, its card offers **Review changes and publish** and **Undo**. Review opens Studio's **Changes** drawer for the package: in the Studio copilot it opens in place (same pillar, same selection), and from the full-page chat it opens the package's Studio with the drawer open. The drawer's link is `/studio/:packageId/:tab?changes=review`; Studio opens the drawer to review the pending drafts and drops the parameter. Undo discards the package's pending drafts (`POST /api/v1/packages/:id/discard-drafts`) and asks first when other drafts are pending. The top bar's **Changes** count follows the copilot as its tools answer, including a change the environment publishes immediately. The Studio copilot draws no floating pending-changes bar over its composer: there the top bar's **Changes** and **Publish** are the way to review and publish. The full-page chat and the console dock show that bar while the package has pending drafts. | | **Dashboard Refresh** | A dashboard page shows a **Refresh All** button at the right of its page header, and a dashboard that sets `refreshIntervalSeconds` (Studio's auto-refresh field) re-reads its widgets' data every that many seconds. Widgets re-read in place, so they are not remounted, and a dataset-bound single-value (KPI) tile re-reads too. `0` or no value means no automatic refresh. | | **Refused Dashboards and List Views** | The server lists only the dashboards and list views a user may open, so one gated by `requiredPermissions` is not in their lists. When such a dashboard or list view is opened by its URL, the console asks the server about it by name. If the server refuses it with `403 PERMISSION_DENIED`, the page says "You don't have access" and shows the server's sentence, which names the item. The page does not say "not found", and the list page does not switch to the default view. For a name that does not exist, the dashboard page still says it was not found and the list page still opens the default view. An object's own list view that the server sends whole to a user who may edit the object is listed and opens. | -| **Setup Positions** | Setup → Positions lists, for each position, how many people hold it in the active organization and which permission sets it grants. Both columns come from the server (`GET /meta/position?include=grants`), which computes them where it enforces them. A permission set that shares the position's name is granted even though the position does not list it, so it is shown and marked "(by name)". A holder count the server did not serve (no active organization, or no read on position assignments) shows "—", not 0. On every Setup metadata list, a click anywhere on a row opens that item. New Position asks for the permission sets with the label and name, so a position is created and bound in one save. On a position's page, Add holder offers the members of the active organization only (its `sys_member` rows), and Remove asks first, naming the person. | +| **Setup Positions** | Setup → Positions lists, for each position, how many people hold it in the active organization and which permission sets it grants. Both columns come from the server (`GET /meta/position?include=grants`), which computes them where it enforces them. The column lists only the permission sets the position's definition names; a set that merely shares the position's name is not granted and not listed. A holder count the server did not serve (no active organization, or no read on position assignments) shows "—", not 0. On every Setup metadata list, a click anywhere on a row opens that item. New Position asks for the permission sets with the label and name, so a position is created and bound in one save. On a position's page, Add holder offers the members of the active organization only (its `sys_member` rows), and Remove asks first, naming the person. | | **App Creation Wizard** | 4-step wizard (Basic Info → Objects → Navigation → Branding) to create or edit apps. | | **Record Approvals Tab** | A record with approval requests grows an Approvals tab on its detail page (peer of Details/Related, with a request-count badge) — current step, decision progress, resolved "waiting on" approvers, the merged decision timeline, and a submitter remind button — visible to every viewer who can read the record, not just approvers. | | **Approval Request Pages** | A request opens on its record page, read through the approvals routes rather than the data API: the request with its decision tally and the `viewer` block its decision actions gate on, and its Timeline from the request's actions route. Those routes serve a request to each party to it, so an approver who is not an administrator sees the full page — fields, decision actions and Timeline — although the data API stays closed to them on both approval objects. | diff --git a/ui/packages/app-shell/src/views/ObjectView.managedEnvironmentUsers-13330.test.tsx b/ui/packages/app-shell/src/views/ObjectView.managedEnvironmentUsers-13330.test.tsx index 93d3bec74c0..a7dd23445f7 100644 --- a/ui/packages/app-shell/src/views/ObjectView.managedEnvironmentUsers-13330.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.managedEnvironmentUsers-13330.test.tsx @@ -66,10 +66,11 @@ const POSITIONS = [ { name: 'everyone', permissionSets: ['member_default'] }, { name: 'guest', permissionSets: [] }, { name: 'sales_rep', permissionSets: ['sales'] }, - // Lists only the baseline; its holders get the `closer` set by name. + // Lists only the baseline, and shares its name with a permission set + // `closer`: its holders are not granted that set (the position-name fold is + // retired, objectstack-ai/objectstack#24757). { name: 'closer', permissionSets: ['member_default'] }, ]; -const PERMISSION_SETS = [{ name: 'member_default' }, { name: 'sales' }, { name: 'closer' }]; vi.mock('@object-ui/react', async (importOriginal) => { const actual = await importOriginal(); const none: never[] = []; @@ -83,10 +84,9 @@ vi.mock('@object-ui/react', async (importOriginal) => { error: null, refresh: async () => {}, invalidate: () => {}, - ensureType: async (type: string) => (type === 'position' ? POSITIONS : type === 'permission' ? PERMISSION_SETS : []), + ensureType: async (type: string) => (type === 'position' ? POSITIONS : []), getItem: async () => null, getItemsByType: () => [], - getTypeStatus: () => 'ready' as const, }); return { ...actual, useMetadata: () => store }; }); @@ -138,7 +138,7 @@ const GRANTS = [ ]; const ASSIGNMENTS = [ { id: 'a1', user_id: 'u_cy', position: 'sales_rep' }, - // Granted the `closer` set by the position's name alone. + // Holds a position that only shares its name with a set: nothing beyond the baseline. { id: 'a3', user_id: 'u_eve', position: 'closer' }, // The anchor: every user holds it, and it lists only the baseline. { id: 'a2', user_id: 'u_ada', position: 'everyone' }, @@ -206,15 +206,16 @@ describe('objectui#13330 — Setup → Users on a managed environment', () => { expect(line).toHaveTextContent(/Invite people to your organization there/); }); - it('marks the users who hold nothing beyond the baseline, and only them', async () => { + it('marks the users who hold nothing beyond the baseline, and only them — a position grants by its definition, never by its name', async () => { mountUsers({ admin: true, cloudManaged: true }); - for (const name of ['Ada Baseline', 'Dee Expired']) { + // Eve holds `closer`, which lists only the baseline; the set named `closer` is not granted to her. + for (const name of ['Ada Baseline', 'Dee Expired', 'Eve Folded']) { const row = await rowOf(name); await waitFor(() => expect(within(row).getByTestId('managed-users-unauthorized')).toHaveTextContent('Not authorized'), { timeout: 5000, }); } - for (const name of ['Bob Direct', 'Cy Position', 'Eve Folded']) { + for (const name of ['Bob Direct', 'Cy Position']) { expect(within(await rowOf(name)).queryByTestId('managed-users-unauthorized')).toBeNull(); } // One read per object answers the whole page, asked by user. diff --git a/ui/packages/app-shell/src/views/__tests__/managedEnvironmentUsers-13330.test.tsx b/ui/packages/app-shell/src/views/__tests__/managedEnvironmentUsers-13330.test.tsx index e666fb3d8c9..2c0b4487494 100644 --- a/ui/packages/app-shell/src/views/__tests__/managedEnvironmentUsers-13330.test.tsx +++ b/ui/packages/app-shell/src/views/__tests__/managedEnvironmentUsers-13330.test.tsx @@ -24,10 +24,9 @@ const POSITIONS = [ { name: 'guest', permissionSets: ['guest_read'] }, { name: 'sales_rep', permissionSets: ['sales'] }, { name: 'greeter', permissionSets: ['member_default'] }, - // Lists only a baseline set, and shares its name with a set that exists. + // Lists only a baseline set, and shares its name with a permission set `closer`. { name: 'closer', permissionSets: ['member_default'] }, ]; -const PERMISSION_SETS = ['member_default', 'accepted_default', 'guest_read', 'sales', 'closer'].map((name) => ({ name })); function sourceOf(grants: unknown, assignments: unknown) { return { @@ -37,8 +36,7 @@ function sourceOf(grants: unknown, assignments: unknown) { }; } const registry = { - ensureType: vi.fn(async (type: string) => (type === 'position' ? POSITIONS : type === 'permission' ? PERMISSION_SETS : [])), - getTypeStatus: () => 'ready', + ensureType: vi.fn(async (type: string) => (type === 'position' ? POSITIONS : [])), }; afterEach(() => cleanup()); @@ -124,40 +122,24 @@ describe('readUserGrantStates', () => { expect(states.get('u4')).toBe('baseline-only'); }); - it('a position counts the set of its own name, though its definition does not list it (the name fold)', async () => { + it('a position grants nothing by sharing its name with a permission set; only its definition counts', async () => { + registry.ensureType.mockClear(); const states = await readUserGrantStates( sourceOf( { data: [] }, - { data: [{ user_id: 'u1', position: 'closer' }, { user_id: 'u2', position: 'greeter' }] }, + { data: [{ user_id: 'u1', position: 'closer' }, { user_id: 'u2', position: 'sales_rep' }] }, ), registry, ['u1', 'u2'], NOW, ); - // The server grants holders of `closer` the `closer` set (objectstack-ai/objectstack#24757). - expect(states.get('u1')).toBe('beyond-baseline'); - // No set is named `greeter`: nothing folds. - expect(states.get('u2')).toBe('baseline-only'); - }); - - it('a position held while the permission-set catalogue cannot be read leaves its user unknown', async () => { - const refused = { - ensureType: vi.fn(async (type: string) => (type === 'position' ? POSITIONS : [])), - getTypeStatus: (type: string) => (type === 'permission' ? 'error' : 'ready'), - }; - const states = await readUserGrantStates( - sourceOf( - { data: [{ user_id: 'u3', permission_set: 'sales' }] }, - { data: [{ user_id: 'u1', position: 'greeter' }] }, - ), - refused, - ['u1', 'u2', 'u3'], - NOW, - ); - expect(states.get('u1')).toBe('unknown'); - // A user who holds no position and no grant row holds the baseline, catalogue or not. - expect(states.get('u2')).toBe('baseline-only'); - expect(states.get('u3')).toBe('beyond-baseline'); + // `closer` lists only a baseline set; the set named `closer` is not granted + // to its holders (the position-name fold is retired, objectstack-ai/objectstack#24757). + expect(states.get('u1')).toBe('baseline-only'); + // Control: a position whose definition lists a set beyond the baseline. + expect(states.get('u2')).toBe('beyond-baseline'); + // The permission-set catalogue is not read: no set is judged by its name. + expect(registry.ensureType.mock.calls.map(([type]) => type)).toEqual(['position']); }); it('a position the registry does not define leaves its user unknown', async () => { diff --git a/ui/packages/app-shell/src/views/managedEnvironmentUsers.tsx b/ui/packages/app-shell/src/views/managedEnvironmentUsers.tsx index fceb7b2816f..044d3b83d42 100644 --- a/ui/packages/app-shell/src/views/managedEnvironmentUsers.tsx +++ b/ui/packages/app-shell/src/views/managedEnvironmentUsers.tsx @@ -25,12 +25,11 @@ * - a `sys_user_permission_set` row of theirs names a set the anchor does not * list; * - a `sys_user_position` row of theirs names a position, other than an anchor, - * whose registry definition lists such a set; - * - or names a position that shares its name with such a set. The server still - * grants a position's holders the permission set of the same name though the - * definition does not list it (the deprecated name fold, - * objectstack-ai/objectstack#24757, removed in the next major), so the - * permission-set catalogue is read beside the positions. + * whose registry definition lists such a set. + * + * A position grants only the sets its definition lists: a set that merely + * shares the position's name is not granted (the position-name fold is + * retired, objectstack-ai/objectstack#24757), so it says nothing here. * * These are the reads the permission set's Assigned Users section makes * (`AssignedUsersSection`), asked by user instead of by set, batched: every @@ -38,9 +37,8 @@ * whole page of them. * * ⛔ Unknown is never said as "not authorized". A refused read, a page of grant - * rows the server reports as longer than one read, a position the registry - * does not define, or a position held while the permission-set catalogue could - * not be read (the name fold cannot be judged) all leave the user unmarked. + * rows the server reports as longer than one read, or a position the registry + * does not define all leave the user unmarked. * * @module */ @@ -86,12 +84,11 @@ export interface UserGrantReadSource { /** * The slice of the metadata store the read uses. `ensureType` answers `[]` on a - * failed read, so `getTypeStatus` is what tells a refused catalogue from an - * empty one; a store without it is taken at its answer. + * failed read; a held position then has no definition, which leaves its user + * unknown. */ export interface PositionRegistrySource { ensureType(type: string): Promise; - getTypeStatus?(type: string): string; } /** The audience anchors: implicit memberships every user holds. */ @@ -153,18 +150,8 @@ export async function readUserGrantStates( const out = new Map(); if (userIds.length === 0) return out; - // The registry: which sets the anchor lists, which sets each position lists, - // and which sets exist at all (for the name fold). - const [positions, permissionSets] = await Promise.all([ - registry.ensureType('position'), - registry.ensureType('permission'), - ]); - const catalogueRead = registry.getTypeStatus?.('permission') !== 'error'; - const setNames = new Set(); - for (const raw of permissionSets) { - const name = (raw as { name?: unknown } | null)?.name; - if (typeof name === 'string' && name) setNames.add(name); - } + // The registry: which sets the anchor lists, and which sets each position lists. + const positions = await registry.ensureType('position'); const setsByPosition = new Map(); for (const raw of positions) { const p = raw as { name?: unknown; permissionSets?: unknown } | null; @@ -195,9 +182,8 @@ export async function readUserGrantStates( if (uid === undefined || uid === null || uid === '' || typeof position !== 'string') continue; if (ANCHOR_POSITIONS.has(position) || !isGrantActive(a, now)) continue; const sets = setsByPosition.get(position); - const folded = catalogueRead && setNames.has(position) && beyondBaseline(position); - if (folded || sets?.some(beyondBaseline)) beyond.add(String(uid)); - else if (sets === undefined || !catalogueRead) uncertain.add(String(uid)); + if (sets?.some(beyondBaseline)) beyond.add(String(uid)); + else if (sets === undefined) uncertain.add(String(uid)); } // A page longer than one read may hold a row of any user it did not return. diff --git a/ui/packages/app-shell/src/views/metadata-admin/PositionGrantCells.tsx b/ui/packages/app-shell/src/views/metadata-admin/PositionGrantCells.tsx index 72ee400160f..da5a016051e 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PositionGrantCells.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PositionGrantCells.tsx @@ -9,10 +9,10 @@ * computed by the reader the authorization resolver enforces with. This file * renders them and computes neither. * - * - `_grantedPermissionSets` is `[{ name, via }]`. `via: 'name'` is the set - * that shares the position's name, which the position-name fold grants - * though the definition does not list it (objectstack#24757). It is marked, - * never hidden: it is in force. + * - `_grantedPermissionSets` is `[{ name, via }]`, each `via: 'definition'`: + * the sets the position's definition names. A set that only shares the + * position's name is not granted, so the server never lists it + * (objectstack#24757, objectstack#26022). * - `_holderCount` is a number, or `null` when the server serves no count * (no active organization, a caller who may not read the assignments, a * derived position). `null` and an absent key both read "—", ⛔ never 0: a @@ -26,7 +26,7 @@ import { t, useMetadataLocale } from './i18n.js'; /** One entry of the served `_grantedPermissionSets`. */ export interface PositionGrantedSet { name: string; - via: 'definition' | 'name'; + via: 'definition'; } function readGrantedSets(value: unknown): PositionGrantedSet[] | undefined { @@ -34,7 +34,7 @@ function readGrantedSets(value: unknown): PositionGrantedSet[] | undefined { return value.filter( (v): v is PositionGrantedSet => !!v && typeof v === 'object' && typeof (v as PositionGrantedSet).name === 'string' - && ((v as PositionGrantedSet).via === 'definition' || (v as PositionGrantedSet).via === 'name'), + && (v as PositionGrantedSet).via === 'definition', ); } @@ -75,14 +75,8 @@ export function PositionGrantedSetsCell({ value }: { value: unknown }): React.Re variant="outline" className="font-mono text-[11px] font-normal" data-via={set.via} - title={set.via === 'name' ? t('engine.position.grants.byNameHint', locale) : undefined} > {set.name} - {set.via === 'name' && ( - - {t('engine.position.grants.byName', locale)} - - )} ))} diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.positionGrants-25333.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.positionGrants-25333.test.tsx index 152b0da2414..eb2bef41500 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.positionGrants-25333.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.positionGrants-25333.test.tsx @@ -15,8 +15,9 @@ * (control) asks the plain list; * - the holder count as served, and "—" — ⛔ never 0 — for a count the server * did not serve (`null`) or a server that predates the key (absent); - * - the granted sets in served order, the same-named set the position-name - * fold grants marked "by name" (objectstack#24757), a declared set unmarked; + * - the granted sets in served order, each a plain badge: every served set is + * one the definition names, and none carries a "by name" mark — the + * position-name fold is retired (objectstack#24757, objectstack#26022); * - a click anywhere on the row opens the position; a click on the Active * switch is the switch's, and a modified click is the browser's; * - the served `description` shows in its own column right after Label, under @@ -41,7 +42,6 @@ const POSITIONS: Record[] = [ _grantedPermissionSets: [ { name: 'crm_sales_user', via: 'definition' }, { name: 'forecasting', via: 'definition' }, - { name: 'sales_manager', via: 'name' }, ], }, // The server served no count (a caller who may not read the assignments). @@ -162,21 +162,16 @@ describe('the position list reads its two columns from the server (objectstack#2 expect(count('auditor')).not.toHaveTextContent('0'); }); - it('lists the granted sets as served, the same-named set marked "by name" and the declared ones not', async () => { + it('lists the granted sets as served, each badge the set name alone', async () => { mount(); expect(await screen.findByRole('columnheader', { name: t('engine.list.col._grantedPermissionSets', LOCALE) })).toBeInTheDocument(); const cell = within(screen.getByTestId('metadata-row-sales_manager')).getByTestId('position-granted-sets'); const badges = Array.from(cell.querySelectorAll('[data-via]')); - expect(badges.map((b) => [b.firstChild?.textContent, b.getAttribute('data-via')])).toEqual([ + expect(badges.map((b) => [b.textContent, b.getAttribute('data-via')])).toEqual([ ['crm_sales_user', 'definition'], ['forecasting', 'definition'], - ['sales_manager', 'name'], ]); - const byName = badges[2]; - expect(byName).toHaveTextContent(t('engine.position.grants.byName', LOCALE)); - expect(byName).toHaveAttribute('title', t('engine.position.grants.byNameHint', LOCALE)); - expect(badges[0]).not.toHaveTextContent(t('engine.position.grants.byName', LOCALE)); - expect(badges[0]).not.toHaveAttribute('title'); + for (const badge of badges) expect(badge).not.toHaveAttribute('title'); expect(within(screen.getByTestId('metadata-row-support_agent')).getByTestId('position-granted-sets')) .toHaveTextContent(t('engine.position.grants.none', LOCALE)); diff --git a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts index 2442babdc39..8bcc7f589df 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -252,9 +252,6 @@ const ENGINE_STRINGS_EN: Record = { 'engine.list.col._grantedPermissionSets': 'Permission sets', 'engine.position.holders.unknown': 'The server did not count the holders for you (no active organization, or no read on position assignments).', 'engine.position.grants.none': 'None', - 'engine.position.grants.byName': '(by name)', - 'engine.position.grants.byNameHint': - 'Granted because this permission set has the same name as the position. The position does not list it, but holders get it.', // objectstack#26187 — the Setup position page's read-only previews of the // permission sets the position names (`PositionPermissionSetsSection.tsx`). 'engine.position.sets.title': 'Permission sets', @@ -4083,8 +4080,6 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.list.col._grantedPermissionSets': '权限集', 'engine.position.holders.unknown': '服务器未为你统计持有人数(没有当前组织,或无权读取岗位分配)。', 'engine.position.grants.none': '无', - 'engine.position.grants.byName': '(同名)', - 'engine.position.grants.byNameHint': '因与岗位同名而授予。岗位定义未列出它,但持有人会获得它。', 'engine.position.sets.title': '权限集', 'engine.position.sets.hint': '此岗位列出的每个权限集授予的权限,按列出顺序显示。打开权限集可修改它。', 'engine.position.sets.empty': '此岗位未列出任何权限集。', diff --git a/ui/packages/app-shell/src/views/objectViewIdentity.tsx b/ui/packages/app-shell/src/views/objectViewIdentity.tsx index e87d4f09dfa..47c65d90e36 100644 --- a/ui/packages/app-shell/src/views/objectViewIdentity.tsx +++ b/ui/packages/app-shell/src/views/objectViewIdentity.tsx @@ -61,7 +61,6 @@ export function useObjectViewIdentity({ objectDef, isAdmin, dataSource, refreshK source: dataSource, loader: new UserGrantLoader(dataSource as any, { ensureType: (type: string) => metadataStoreRef.current.ensureType(type), - getTypeStatus: (type: string) => metadataStoreRef.current.getTypeStatus?.(type) ?? 'ready', }), }; } diff --git a/ui/packages/data-objectstack/src/metadata-client.listInclude-25333.test.ts b/ui/packages/data-objectstack/src/metadata-client.listInclude-25333.test.ts index a0b8307cbf5..5cd4cf561c2 100644 --- a/ui/packages/data-objectstack/src/metadata-client.listInclude-25333.test.ts +++ b/ui/packages/data-objectstack/src/metadata-client.listInclude-25333.test.ts @@ -35,7 +35,7 @@ const ROWS = [ _holderCount: 1, _grantedPermissionSets: [ { name: 'crm_sales_user', via: 'definition' }, - { name: 'sales_manager', via: 'name' }, + { name: 'forecasting', via: 'definition' }, ], }, ];