diff --git a/plugins/tracker-resources/src/components/milestones/MilestoneSelector.svelte b/plugins/tracker-resources/src/components/milestones/MilestoneSelector.svelte index b15f51cc29d..430d1b5330c 100644 --- a/plugins/tracker-resources/src/components/milestones/MilestoneSelector.svelte +++ b/plugins/tracker-resources/src/components/milestones/MilestoneSelector.svelte @@ -13,14 +13,26 @@ // limitations under the License. --> dispatch('close')} + on:close={() => dispatch('close', createdMilestone)} on:changeContent > diff --git a/plugins/tracker-resources/src/components/milestones/__tests__/milestonePermissions.test.ts b/plugins/tracker-resources/src/components/milestones/__tests__/milestonePermissions.test.ts new file mode 100644 index 00000000000..f0b69fca2cb --- /dev/null +++ b/plugins/tracker-resources/src/components/milestones/__tests__/milestonePermissions.test.ts @@ -0,0 +1,115 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { PermissionsStore } from '@hcengineering/contact' +import type { Permission, Ref } from '@hcengineering/core' +import type { Project } from '@hcengineering/tracker' +import { canCreateMilestone, canSaveMilestone } from '../milestonePermissions' + +jest.mock('@hcengineering/view-resources', () => jest.requireActual('../../../../../view-resources/src/permissions')) +jest.mock('@hcengineering/platform', () => ({ + ...jest.requireActual('@hcengineering/platform'), + getMetadata: jest.fn() +})) +jest.mock('@hcengineering/presentation', () => ({ + getClient: () => ({ + getHierarchy: () => ({ getAncestors: (objectClass: string) => [objectClass] }), + getModel: () => ({ + findAllSync: (_class: string, query: { txClass: string }) => + query.txClass === 'core:class:TxCreateDoc' + ? [ + { + _id: 'allow-milestone', + objectClass: 'tracker:class:Milestone' + }, + { + _id: 'forbid-milestone', + objectClass: 'tracker:class:Milestone', + forbid: true + } + ] + : [] + }) + }) +})) +jest.mock('../../../plugin', () => ({ + __esModule: true, + default: { class: { Milestone: 'tracker:class:Milestone' } } +})) + +const project = 'project' as Ref +const otherProject = 'other-project' as Ref + +function permissions (permission?: string, restricted: boolean = false): PermissionsStore { + return { + ps: permission === undefined ? {} : { [project]: new Set([permission as Ref]) }, + ap: {}, + ms: {}, + whitelist: new Set(), + restrictedSpaces: new Set(restricted ? [project] : []) + } +} + +describe('milestone creation permissions', () => { + it('offers creation to a permitted user in a restricted project', () => { + expect(canCreateMilestone(project, true, false, permissions('allow-milestone', true))).toBe(true) + }) + + it('hides creation from a forbidden user even in an unrestricted project', () => { + expect(canCreateMilestone(project, true, false, permissions('forbid-milestone'))).toBe(false) + }) + + it('hides creation in a restricted project without a creation grant', () => { + const store = permissions(undefined, true) + store.ps[project] = new Set() + expect(canCreateMilestone(project, true, false, store)).toBe(false) + }) + + it('appears after permissions load and disappears when permission is revoked', () => { + expect(canCreateMilestone(project, true, false, permissions())).toBe(false) + expect(canCreateMilestone(project, true, false, permissions('allow-milestone', true))).toBe(true) + expect(canCreateMilestone(project, true, false, permissions('forbid-milestone', true))).toBe(false) + }) + + it('does not carry permission over to another project', () => { + expect(canCreateMilestone(otherProject, true, false, permissions('allow-milestone'))).toBe(false) + }) + + it('allows creation in a whitelisted project', () => { + const store = permissions() + store.whitelist.add(project) + expect(canCreateMilestone(project, true, false, store)).toBe(true) + }) + + it('requires an editable selector bound to a single project outside action mode', () => { + const store = permissions('allow-milestone') + expect(canCreateMilestone(project, false, false, store)).toBe(false) + expect(canCreateMilestone(project, true, true, store)).toBe(false) + expect(canCreateMilestone(undefined, true, false, store)).toBe(false) + expect(canCreateMilestone({ $in: [project, otherProject] }, true, false, store)).toBe(false) + }) +}) + +describe('milestone dialog save permissions', () => { + it('requires a label even when the user may create milestones', () => { + const store = permissions('allow-milestone', true) + expect(canSaveMilestone(project, '', store)).toBe(false) + expect(canSaveMilestone(project, 'Release', store)).toBe(true) + }) + + it('disables saving when the dialog switches to a forbidden project and enables it on return', () => { + const store = permissions('allow-milestone', true) + store.ps[otherProject] = new Set(['forbid-milestone' as Ref]) + expect(canSaveMilestone(project, 'Release', store)).toBe(true) + expect(canSaveMilestone(otherProject, 'Release', store)).toBe(false) + expect(canSaveMilestone(project, 'Release', store)).toBe(true) + }) + + it('waits for permissions and disables saving when permission is revoked', () => { + expect(canSaveMilestone(project, 'Release', permissions())).toBe(false) + expect(canSaveMilestone(project, 'Release', permissions('allow-milestone', true))).toBe(true) + expect(canSaveMilestone(project, 'Release', permissions('forbid-milestone', true))).toBe(false) + }) +}) diff --git a/plugins/tracker-resources/src/components/milestones/milestonePermissions.ts b/plugins/tracker-resources/src/components/milestones/milestonePermissions.ts new file mode 100644 index 00000000000..bef1c5ffeee --- /dev/null +++ b/plugins/tracker-resources/src/components/milestones/milestonePermissions.ts @@ -0,0 +1,30 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { PermissionsStore } from '@hcengineering/contact' +import type { DocumentQuery } from '@hcengineering/core' +import type { Milestone } from '@hcengineering/tracker' +import { canCreateObject } from '@hcengineering/view-resources' +import tracker from '../../plugin' + +export function canCreateMilestone ( + space: DocumentQuery['space'] | undefined, + isEditable: boolean, + isAction: boolean, + permissions: PermissionsStore +): boolean { + if (!isEditable || isAction || typeof space !== 'string') return false + // The permissions query loads asynchronously. Do not allow creation before this project is known. + if (!permissions.whitelist.has(space) && permissions.ps[space] === undefined) return false + return canCreateObject(tracker.class.Milestone, space, permissions) +} + +export function canSaveMilestone ( + space: DocumentQuery['space'] | undefined, + label: string, + permissions: PermissionsStore +): boolean { + return label !== '' && canCreateMilestone(space, true, false, permissions) +}