From 3e687bd8723b0b3d9dece3e8eea5c1ffe92f868b Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Wed, 24 Jul 2024 14:22:08 -0400 Subject: [PATCH 01/14] SWC-6800 - EntityAclEditor works + tests Still needs light refactoring + SWC integration --- .../AccessRequirementAclEditor.tsx | 6 +- .../AclEditor/AclEditor.stories.tsx | 4 +- .../components/AclEditor/AclEditor.test.tsx | 204 +++++- .../src/components/AclEditor/AclEditor.tsx | 149 ++++- .../AclEditor/AclEditorSkeleton.tsx | 17 + .../AclEditor/AclEditorTestUtils.ts | 113 +++- .../AclEditor/PermissionLevelMenu.tsx | 37 +- .../AclEditor/ReadOnlyPermissionLevel.tsx | 5 +- .../AclEditor/ResourceAccessItem.tsx | 61 +- .../AclEditor/useSortResourceAccessList.ts | 8 +- .../components/AclEditor/useUpdateAcl.test.ts | 15 + .../src/components/AclEditor/useUpdateAcl.ts | 98 +-- ...eateOrDeleteLocalSharingSettingsButton.tsx | 55 ++ .../EntityAclEditor.stories.tsx | 65 ++ .../EntityAclEditor/EntityAclEditor.test.tsx | 612 ++++++++++++++++++ .../EntityAclEditor/EntityAclEditor.tsx | 352 ++++++++++ .../EntityAclEditor/EntityAclEditorModal.tsx | 55 ++ .../EntityAclEditor/InheritanceMessage.tsx | 50 ++ .../EntityAclEditor/OpenData.stories.ts | 36 ++ .../components/EntityAclEditor/OpenData.tsx | 75 +++ .../useNotifyNewACLUsers.test.ts | 22 +- .../EntityHeaderTable.integration.test.tsx | 2 +- .../src/components/TeamBadge.tsx | 9 +- .../src/mocks/discussion/mock_discussion.ts | 3 +- .../src/mocks/entity/index.ts | 26 +- .../src/mocks/entity/mockFileEntity.ts | 53 +- .../mocks/entity/mockFileEntityACLVariants.ts | 125 ++++ .../mocks/entity/mockGeneratedEntityData.ts | 34 + .../src/mocks/entity/mockProject.ts | 108 +--- .../src/mocks/faker/generateFakeEntity.ts | 131 +++- .../src/mocks/msw/handlers.ts | 2 + .../src/mocks/msw/handlers/entityHandlers.ts | 85 ++- .../src/mocks/msw/handlers/fileHandlers.ts | 40 +- .../src/mocks/msw/handlers/messageHandlers.ts | 13 + .../src/synapse-client/SynapseClient.ts | 48 ++ .../src/synapse-queries/entity/useEntity.ts | 121 +++- .../synapse-queries/entity/useEntityBundle.ts | 53 +- .../src/synapse-queries/user/useUserBundle.ts | 33 +- .../src/utils/functions/DisplayUtils.ts | 42 ++ .../IsEqualTreatArraysAsSets.test.ts | 30 + .../functions/isEqualTreatArraysAsSets.ts | 23 + .../tree/EntityTree.integration.test.tsx | 9 +- packages/synapse-types/src/EntityBundle.ts | 2 +- 43 files changed, 2738 insertions(+), 293 deletions(-) create mode 100644 packages/synapse-react-client/src/components/AclEditor/AclEditorSkeleton.tsx create mode 100644 packages/synapse-react-client/src/components/EntityAclEditor/CreateOrDeleteLocalSharingSettingsButton.tsx create mode 100644 packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.stories.tsx create mode 100644 packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.test.tsx create mode 100644 packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx create mode 100644 packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditorModal.tsx create mode 100644 packages/synapse-react-client/src/components/EntityAclEditor/InheritanceMessage.tsx create mode 100644 packages/synapse-react-client/src/components/EntityAclEditor/OpenData.stories.ts create mode 100644 packages/synapse-react-client/src/components/EntityAclEditor/OpenData.tsx create mode 100644 packages/synapse-react-client/src/mocks/entity/mockFileEntityACLVariants.ts create mode 100644 packages/synapse-react-client/src/mocks/entity/mockGeneratedEntityData.ts create mode 100644 packages/synapse-react-client/src/mocks/msw/handlers/messageHandlers.ts create mode 100644 packages/synapse-react-client/src/utils/functions/DisplayUtils.ts create mode 100644 packages/synapse-react-client/src/utils/functions/IsEqualTreatArraysAsSets.test.ts create mode 100644 packages/synapse-react-client/src/utils/functions/isEqualTreatArraysAsSets.ts diff --git a/packages/synapse-react-client/src/components/AccessRequirementAclEditor/AccessRequirementAclEditor.tsx b/packages/synapse-react-client/src/components/AccessRequirementAclEditor/AccessRequirementAclEditor.tsx index 0714c99266..4a3647270e 100644 --- a/packages/synapse-react-client/src/components/AccessRequirementAclEditor/AccessRequirementAclEditor.tsx +++ b/packages/synapse-react-client/src/components/AccessRequirementAclEditor/AccessRequirementAclEditor.tsx @@ -69,6 +69,7 @@ export const AccessRequirementAclEditor = React.forwardRef( addResourceAccessItem, updateResourceAccessItem, removeResourceAccessItem, + resetDirtyState, } = useUpdateAcl({ onChange: () => setError(null), onError: setError, @@ -77,6 +78,7 @@ export const AccessRequirementAclEditor = React.forwardRef( // set resourceAccessList when the fetched acl changes useEffect(() => { if (originalAcl) { + resetDirtyState() setResourceAccessList(originalAcl.resourceAccess) } }, [originalAcl, setResourceAccessList]) @@ -164,13 +166,15 @@ export const AccessRequirementAclEditor = React.forwardRef( resourceAccessList={resourceAccessList} availablePermissionLevels={availablePermissionLevels} isLoading={isLoadingOriginalAcl} - isInEditMode={true} + canEdit={true} emptyText={EMPTY_RESOURCE_ACCESS_LIST_TEXT} onAddPrincipalToAcl={id => addResourceAccessItem(id, [ACCESS_TYPE.REVIEW_SUBMISSIONS]) } updateResourceAccessItem={updateResourceAccessItem} removeResourceAccessItem={removeResourceAccessItem} + showAddRemovePublicButton={false} + showNotifyCheckbox={false} /> {error && {error}} diff --git a/packages/synapse-react-client/src/components/AclEditor/AclEditor.stories.tsx b/packages/synapse-react-client/src/components/AclEditor/AclEditor.stories.tsx index 2ece0f6974..1fb68f6e1d 100644 --- a/packages/synapse-react-client/src/components/AclEditor/AclEditor.stories.tsx +++ b/packages/synapse-react-client/src/components/AclEditor/AclEditor.stories.tsx @@ -17,8 +17,10 @@ const meta: Meta = { updateResourceAccessItem: fn(), removeResourceAccessItem: fn(), isLoading: false, - isInEditMode: true, + canEdit: true, emptyText: 'No permissions have been granted.', + showAddRemovePublicButton: true, + showNotifyCheckbox: true, }, } export default meta diff --git a/packages/synapse-react-client/src/components/AclEditor/AclEditor.test.tsx b/packages/synapse-react-client/src/components/AclEditor/AclEditor.test.tsx index c03287bfc8..178bbe7e92 100644 --- a/packages/synapse-react-client/src/components/AclEditor/AclEditor.test.tsx +++ b/packages/synapse-react-client/src/components/AclEditor/AclEditor.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import React from 'react' import { server } from '../../mocks/msw/server' @@ -20,9 +20,16 @@ import { PermissionLevel } from '../../utils/PermissionLevelToAccessType' import { addUserToAcl, confirmItem, + queryForAddUserCombobox, removeItem, updatePermissionLevel, } from './AclEditorTestUtils' +import { + ANONYMOUS_PRINCIPAL_ID, + AUTHENTICATED_PRINCIPAL_ID, + PUBLIC_PRINCIPAL_ID, + PUBLIC_PRINCIPAL_IDS, +} from '../../utils/SynapseConstants' const DEFAULT_RESOURCE_ACCESS: ResourceAccess[] = [ { @@ -59,10 +66,12 @@ const defaultProps: AclEditorProps = { availablePermissionLevels: DEFAULT_AVAILABLE_PERMISSION_LEVELS, isLoading: false, emptyText: DEFAULT_EMPTY_TEXT, - isInEditMode: true, + canEdit: true, onAddPrincipalToAcl: mockAddResourceAccessItem, updateResourceAccessItem: mockUpdateResourceAccessItem, removeResourceAccessItem: mockRemoveResourceAccessItem, + showNotifyCheckbox: false, + showAddRemovePublicButton: false, } function renderComponent(props: AclEditorProps) { @@ -81,10 +90,16 @@ async function setUp( const user = userEvent.setup() const { component } = renderComponent(props) + const numberOfPublicPrincipalsInResourceAccess = + props.resourceAccessList.filter(ra => + PUBLIC_PRINCIPAL_IDS.includes(ra.principalId), + ).length + // wait for UserOrTeamBadge(s) to finish loading await waitFor(() => { expect(screen.queryAllByRole('link')).toHaveLength( - expectedResourceAccessItems, + // public principals do not get a link, so subtract that amount from the expected count + expectedResourceAccessItems - numberOfPublicPrincipalsInResourceAccess, ) }) @@ -158,8 +173,8 @@ describe('AclEditor', () => { expect(mockRemoveResourceAccessItem).toHaveBeenCalledWith(MOCK_TEAM_ID) }) - it('does not show edit controls when isInEditMode is false', async () => { - const { itemRows } = await setUp({ isInEditMode: false }) + it('does not show edit controls when canEdit is false', async () => { + const { itemRows } = await setUp({ canEdit: false }) // Data should still be present confirmItem(itemRows[0], mockTeamData.name, 'Can Review') @@ -174,4 +189,183 @@ describe('AclEditor', () => { expect(screen.queryByRole('combobox')).not.toBeInTheDocument() expect(screen.queryByRole('button')).not.toBeInTheDocument() }) + + it('allows customizing which entries are editable by passing a function to canEdit', async () => { + function allowEditingOnlyTeam1(resourceAccess: ResourceAccess) { + return resourceAccess.principalId === MOCK_TEAM_ID + } + + const { itemRows } = await setUp({ + canEdit: allowEditingOnlyTeam1, + canRemoveEntry: true, + }) + + // Data should still be present + confirmItem(itemRows[0], mockTeamData.name, 'Can Review') + confirmItem(itemRows[1], mockTeamData2.name, 'Exempt Eligible') + confirmItem( + itemRows[2], + `@${mockUserData1.userProfile?.userName}`, + 'Can Review & Exempt Eligible', + ) + + // team 1 is editable and deletable + within(itemRows[0]).getByRole('combobox') + within(itemRows[0]).getByRole('button') + // team2 and user1 are deletable but not editable + expect(within(itemRows[1]).queryByRole('combobox')).not.toBeInTheDocument() + within(itemRows[1]).getByRole('button') + expect(within(itemRows[2]).queryByRole('combobox')).not.toBeInTheDocument() + within(itemRows[2]).getByRole('button') + + // Users can still be added + expect(queryForAddUserCombobox()).toBeInTheDocument() + }) + + it('respects the value of canRemoveEntry (boolean)', async () => { + const { itemRows } = await setUp({ + canEdit: true, + canRemoveEntry: false, + }) + + // Data should still be present + confirmItem(itemRows[0], mockTeamData.name, 'Can Review') + confirmItem(itemRows[1], mockTeamData2.name, 'Exempt Eligible') + confirmItem( + itemRows[2], + `@${mockUserData1.userProfile?.userName}`, + 'Can Review & Exempt Eligible', + ) + + // all rows are editable but not deletable + within(itemRows[0]).getByRole('combobox') + expect(within(itemRows[0]).queryByRole('button')).not.toBeInTheDocument() + within(itemRows[1]).getByRole('combobox') + expect(within(itemRows[1]).queryByRole('button')).not.toBeInTheDocument() + within(itemRows[2]).getByRole('combobox') + expect(within(itemRows[2]).queryByRole('button')).not.toBeInTheDocument() + + // Users can still be added + expect(queryForAddUserCombobox()).toBeInTheDocument() + }) + + it('respects the value of canRemoveEntry (function)', async () => { + function allowRemovingOnlyTeam1(resourceAccess: ResourceAccess) { + return resourceAccess.principalId === MOCK_TEAM_ID + } + + const { itemRows } = await setUp({ + canEdit: true, + canRemoveEntry: allowRemovingOnlyTeam1, + }) + + // Data should still be present + confirmItem(itemRows[0], mockTeamData.name, 'Can Review') + confirmItem(itemRows[1], mockTeamData2.name, 'Exempt Eligible') + confirmItem( + itemRows[2], + `@${mockUserData1.userProfile?.userName}`, + 'Can Review & Exempt Eligible', + ) + + // team 1 is editable and deletable + within(itemRows[0]).getByRole('combobox') + within(itemRows[0]).getByRole('button') + // team2 and user1 are editable but not deletable + within(itemRows[1]).getByRole('combobox') + expect(within(itemRows[1]).queryByRole('button')).not.toBeInTheDocument() + within(itemRows[2]).getByRole('combobox') + expect(within(itemRows[2]).queryByRole('button')).not.toBeInTheDocument() + + // Users can still be added + expect(queryForAddUserCombobox()).toBeInTheDocument() + }) + + it('allows overriding a displayed permission value', async () => { + const overrideText = 'Can take over the world' + function displayedPermissionLevelOverride(resourceAccess: ResourceAccess) { + if (resourceAccess.principalId === MOCK_TEAM_ID) { + return overrideText + } + return undefined + } + const { itemRows } = await setUp({ + canEdit: false, + displayedPermissionLevelOverride, + }) + + // Team 1 gets the override + confirmItem(itemRows[0], mockTeamData.name, overrideText) + + // Remaining teams are unchanged + confirmItem(itemRows[1], mockTeamData2.name, 'Exempt Eligible') + confirmItem( + itemRows[2], + `@${mockUserData1.userProfile?.userName}`, + 'Can Review & Exempt Eligible', + ) + }) + + it('shows a checkbox to notify users', async () => { + const onCheckboxChange = jest.fn() + const { user } = await setUp({ + showNotifyCheckbox: true, + notifyCheckboxValue: true, + onNotifyCheckboxChange: onCheckboxChange, + }) + + const checkbox = screen.getByLabelText('Notify people via email') + expect(checkbox).toHaveAttribute('value', 'true') + + await user.click(checkbox) + + expect(onCheckboxChange).toHaveBeenLastCalledWith(false) + }) + + it('shows a button to add public', async () => { + const { user } = await setUp({ + showAddRemovePublicButton: true, + }) + + const makePublicButton = screen.getByRole('button', { name: 'Make Public' }) + await user.click(makePublicButton) + + expect(mockAddResourceAccessItem).toHaveBeenCalledWith( + String(PUBLIC_PRINCIPAL_ID), + ) + expect(mockAddResourceAccessItem).toHaveBeenCalledWith( + String(AUTHENTICATED_PRINCIPAL_ID), + ) + }) + + it('shows a button to remove public if any public principals are in the ACL', async () => { + const { user } = await setUp({ + resourceAccessList: [ + { + principalId: PUBLIC_PRINCIPAL_ID, + accessType: [ACCESS_TYPE.REVIEW_SUBMISSIONS], + }, + { + principalId: AUTHENTICATED_PRINCIPAL_ID, + accessType: [ACCESS_TYPE.REVIEW_SUBMISSIONS], + }, + ], + showAddRemovePublicButton: true, + }) + + const removePublicAccessButton = screen.getByRole('button', { + name: 'Remove Public Access', + }) + await user.click(removePublicAccessButton) + + expect(mockRemoveResourceAccessItem).toHaveBeenCalledWith( + PUBLIC_PRINCIPAL_ID, + ) + expect(mockRemoveResourceAccessItem).toHaveBeenCalledWith( + AUTHENTICATED_PRINCIPAL_ID, + ) + expect(mockRemoveResourceAccessItem).toHaveBeenCalledWith( + ANONYMOUS_PRINCIPAL_ID, + ) + }) }) diff --git a/packages/synapse-react-client/src/components/AclEditor/AclEditor.tsx b/packages/synapse-react-client/src/components/AclEditor/AclEditor.tsx index 0c79cb830f..62bd69c543 100644 --- a/packages/synapse-react-client/src/components/AclEditor/AclEditor.tsx +++ b/packages/synapse-react-client/src/components/AclEditor/AclEditor.tsx @@ -1,18 +1,40 @@ import React from 'react' -import { Box, Collapse, Typography } from '@mui/material' +import { + Box, + Button, + ButtonProps, + Checkbox, + Collapse, + FormControlLabel, + Tooltip, + Typography, +} from '@mui/material' import { ResourceAccessItem } from './ResourceAccessItem' import UserSearchBoxV2 from '../UserSearchBox/UserSearchBoxV2' import { ResourceAccess } from '@sage-bionetworks/synapse-types' import useUpdateAcl from './useUpdateAcl' import { TransitionGroup } from 'react-transition-group' import { PermissionLevel } from '../../utils/PermissionLevelToAccessType' -import { SynapseSpinner } from '../LoadingScreen/LoadingScreen' +import { + AUTHENTICATED_PRINCIPAL_ID, + PUBLIC_PRINCIPAL_ID, + PUBLIC_PRINCIPAL_IDS, +} from '../../utils/SynapseConstants' +import IconSvg from '../IconSvg' +import { noop } from 'lodash-es' +import { AclEditorSkeleton } from './AclEditorSkeleton' export type AclEditorProps = { - isInEditMode: boolean - isLoading: boolean resourceAccessList: ResourceAccess[] availablePermissionLevels: PermissionLevel[] + /** If true, the user can edit the ACL. If a function, it will be called with the ResourceAccess to determine if the user can edit it. */ + canEdit: boolean | ((resourceAccess: ResourceAccess) => boolean) + /** + * If true, the user can remove any entry from the ACL. a function, it will be called with the ResourceAccess to determine if the user can remove it. + * If undefined, then the behavior will fall back to the value of `canEdit` + */ + canRemoveEntry?: boolean | ((resourceAccess: ResourceAccess) => boolean) + isLoading?: boolean emptyText: React.ReactNode onAddPrincipalToAcl: (id: string) => void updateResourceAccessItem: ReturnType< @@ -21,27 +43,70 @@ export type AclEditorProps = { removeResourceAccessItem: ReturnType< typeof useUpdateAcl >['removeResourceAccessItem'] + /** If true, shows a button to add/remove AUTHENTICATED and PUBLIC groups when in edit mode */ + showAddRemovePublicButton: boolean + /** If present, a checkbox to notify those added to the email will be shown. */ + showNotifyCheckbox: boolean + notifyCheckboxValue?: boolean + onNotifyCheckboxChange?: (checked: boolean) => void + /** + * In special cases, can be used to display a permission level that is different from the typical permission levels. + * For example, the PUBLIC group "Can download" an entity if they have READ access and the entity is marked as + * "open data" (open data status is not captured in the ResourceAccess) + */ + displayedPermissionLevelOverride?: ( + resourceAccess: ResourceAccess, + ) => string | undefined } export function AclEditor(props: AclEditorProps) { const { - isInEditMode, - isLoading, resourceAccessList, availablePermissionLevels, + canEdit, + canRemoveEntry = canEdit, + isLoading = false, emptyText, onAddPrincipalToAcl, updateResourceAccessItem, removeResourceAccessItem, + showAddRemovePublicButton, + showNotifyCheckbox, + notifyCheckboxValue, + onNotifyCheckboxChange = noop, + displayedPermissionLevelOverride, } = props + if (isLoading) { - return ( - - - - ) + return } + const resourceAccessListCurrentlyIncludesPublic = Boolean( + resourceAccessList.find(resourceAccess => + PUBLIC_PRINCIPAL_IDS.includes(resourceAccess.principalId), + ), + ) + + const addOrRemovePublicButtonProps: ButtonProps = + resourceAccessListCurrentlyIncludesPublic + ? { + startIcon: , + children: 'Remove Public Access', + onClick: () => { + PUBLIC_PRINCIPAL_IDS.forEach(publicId => { + removeResourceAccessItem(publicId) + }) + }, + } + : { + startIcon: , + children: 'Make Public', + onClick: () => { + onAddPrincipalToAcl(String(PUBLIC_PRINCIPAL_ID)) + onAddPrincipalToAcl(String(AUTHENTICATED_PRINCIPAL_ID)) + }, + } + return ( @@ -54,13 +119,27 @@ export function AclEditor(props: AclEditorProps) { ) : ( {resourceAccessList.map(resourceAccess => { + const canChangePermission = + typeof canEdit === 'function' + ? canEdit(resourceAccess) + : canEdit + const canDelete = + typeof canRemoveEntry === 'function' + ? canRemoveEntry(resourceAccess) + : canRemoveEntry + + const permissionLevelOverride = displayedPermissionLevelOverride + ? displayedPermissionLevelOverride(resourceAccess) + : undefined return ( updateResourceAccessItem( resourceAccess.principalId, @@ -77,7 +156,7 @@ export function AclEditor(props: AclEditorProps) { )} - {isInEditMode && ( + {canEdit && ( Add More @@ -87,12 +166,12 @@ export function AclEditor(props: AclEditorProps) { variant: 'body1', lineHeight: '20px', fontStyle: 'italic', - color: 'grey.900', + color: 'text.secondary', }} mb="20px" > Search for a username or team to add. You can search by username, - first or last names, or team name + first or last names, or team name. + + + {showAddRemovePublicButton && ( + + + ) + } + return ( +
+ + By default the sharing settings are inherited from the parent folder or + project. If you want to have different settings on a specific file, + folder, or table you need to create local sharing settings and then + modify them. + + +
+ ) +} diff --git a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.stories.tsx b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.stories.tsx new file mode 100644 index 0000000000..7bc650aba1 --- /dev/null +++ b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.stories.tsx @@ -0,0 +1,65 @@ +import { Meta, StoryObj } from '@storybook/react' +import mockProject from '../../mocks/entity/mockProject' +import mockFileEntity from '../../mocks/entity/mockFileEntity' +import EntityAclEditorModal, { + EntityAclEditorModalProps, +} from './EntityAclEditorModal' +import { mockFileEntityCurrentUserCannotEdit } from '../../mocks/entity/mockFileEntityACLVariants' + +const meta: Meta = { + title: 'Synapse/Entity ACL Editor', + component: EntityAclEditorModal, + args: { + open: true, + }, +} +export default meta +type Story = StoryObj + +export const Project: Story = { + args: { + entityId: mockProject.id, + }, + parameters: { + stack: 'mock', + }, +} + +export const ReadOnly: Story = { + args: { + entityId: mockFileEntityCurrentUserCannotEdit.id, + }, + parameters: { + stack: 'mock', + }, +} + +export const InheritedFile: Story = { + args: { + entityId: mockFileEntity.id, + }, + parameters: { + stack: 'mock', + }, +} + +export const ProdCustomACL: Story = { + args: { + entityId: 'syn61833062', + }, + + parameters: { + stack: 'production', + }, +} + +export const TestUserCannotReadParent: Story = { + args: { + open: true, + entityId: 'syn61843528', + }, + + parameters: { + stack: 'production', + }, +} diff --git a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.test.tsx b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.test.tsx new file mode 100644 index 0000000000..27a46452c5 --- /dev/null +++ b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.test.tsx @@ -0,0 +1,612 @@ +import React from 'react' +import { act, render, screen, waitFor, within } from '@testing-library/react' +import { createWrapper } from '../../testutils/TestingLibraryUtils' +import userEvent from '@testing-library/user-event' +import EntityAclEditor, { + EntityAclEditorHandle, + EntityAclEditorProps, +} from './EntityAclEditor' +import { server } from '../../mocks/msw/server' +import SynapseClient from '../../synapse-client' +import mockFileEntity from '../../mocks/entity/mockFileEntity' +import mockFileEntityData from '../../mocks/entity/mockFileEntity' +import { + addPublicToAcl, + addUserToAcl, + confirmItem, + confirmItemViaQuery, + queryForAddUserCombobox, + updatePermissionLevel, +} from '../AclEditor/AclEditorTestUtils' +import { + MOCK_USER_ID_2, + MOCK_USER_NAME, + MOCK_USER_NAME_2, +} from '../../mocks/user/mock_user_profile' +import { MOCK_ACCESS_TOKEN } from '../../mocks/MockSynapseContext' +import mockProjectEntityData from '../../mocks/entity/mockProject' +import mockProject from '../../mocks/entity/mockProject' +import { + mockFileEntityCurrentUserCannotEdit, + mockFileEntityWithLocalSharingSettingsData, + mockFileOpenDataWithNoPublicRead, + mockFileOpenDataWithPublicRead, + mockFilePublicReadNoOpenData, +} from '../../mocks/entity/mockFileEntityACLVariants' +import { rest } from 'msw' +import { ENTITY_ID } from '../../utils/APIConstants' +import { SynapseApiResponse } from '../../mocks/msw/handlers' +import { AccessControlList } from '@sage-bionetworks/synapse-types' +import { BackendDestinationEnum, getEndpoint } from '../../utils/functions' +import { + CREATE_LOCAL_SHARING_SETTINGS, + DELETE_LOCAL_SHARING_SETTINGS, +} from './CreateOrDeleteLocalSharingSettingsButton' + +const onUpdateSuccess = jest.fn() +const onCanSaveChange = jest.fn() +const createAclSpy = jest.spyOn(SynapseClient, 'createEntityACL') +const updateAclSpy = jest.spyOn(SynapseClient, 'updateEntityACL') +const deleteAclSpy = jest.spyOn(SynapseClient, 'deleteEntityACL') +const sendMessageSpy = jest.spyOn(SynapseClient, 'sendMessage') + +function renderComponent(props: EntityAclEditorProps) { + const ref = React.createRef() + const component = render(, { + wrapper: createWrapper(), + }) + return { ref, component } +} + +async function setUp( + props: EntityAclEditorProps, + expectedResourceAccessItems: number, +) { + const user = userEvent.setup() + const { ref, component } = renderComponent(props) + + let itemRows: HTMLElement[] = [] + await waitFor(() => { + itemRows = screen.queryAllByRole('row') + expect(itemRows).toHaveLength(expectedResourceAccessItems) + }) + + return { ref, component, itemRows, user } +} + +async function checkNotifyUsers(user: ReturnType<(typeof userEvent)['setup']>) { + const notifyCheckbox = screen.getByLabelText('Notify people via email') + await user.click(notifyCheckbox) +} + +function verifyHasLocalSharingSettingsMessage() { + const localSharingSettingsAlert = screen.getByText( + /The local sharing settings/, + { exact: false }, + ) + expect(localSharingSettingsAlert.textContent).toEqual( + 'The local sharing settings shown below are not being inherited from a parent resource.', + ) +} + +function verifyInheritsSharingSettingsFromBenefactorMessage() { + screen.getByText( + /The sharing settings shown below are currently being inherited/, + { exact: false }, + ) +} + +describe('EntityAclEditor', () => { + beforeEach(() => jest.clearAllMocks()) + beforeAll(() => server.listen()) + afterEach(() => server.restoreHandlers()) + afterAll(() => server.close()) + + it('can update an ACL', async () => { + const { ref, user } = await setUp( + { + entityId: mockFileEntityWithLocalSharingSettingsData.id, + onCanSaveChange, + onUpdateSuccess, + }, + mockFileEntityWithLocalSharingSettingsData.bundle!.benefactorAcl + .resourceAccess.length, + ) + verifyHasLocalSharingSettingsMessage() + + // Add a user to the ACL + const newUserRow = await addUserToAcl(user, MOCK_USER_NAME_2) + confirmItem(newUserRow, MOCK_USER_NAME_2, 'Can download') + + // Update the permission level of the new user + await updatePermissionLevel(newUserRow, user, 'Can edit & delete') + + await waitFor(() => expect(onCanSaveChange).toHaveBeenLastCalledWith(true)) + + act(() => { + ref.current!.save() + }) + + await waitFor(() => { + expect(updateAclSpy).toHaveBeenCalledWith( + { + ...mockFileEntityWithLocalSharingSettingsData.bundle! + .accessControlList, + resourceAccess: [ + ...mockFileEntityWithLocalSharingSettingsData.bundle! + .accessControlList!.resourceAccess, + { + principalId: MOCK_USER_ID_2, + accessType: expect.arrayContaining([ + 'READ', + 'DOWNLOAD', + 'UPDATE', + 'DELETE', + ]), + }, + ], + }, + MOCK_ACCESS_TOKEN, + ) + expect(onUpdateSuccess).toHaveBeenCalled() + expect(sendMessageSpy).not.toHaveBeenCalled() + }) + }) + it('can create an ACL on an entity that inherits from a benefactor', async () => { + const { ref, user } = await setUp( + { + entityId: mockFileEntity.id, + onCanSaveChange, + onUpdateSuccess, + }, + mockFileEntityData.bundle.benefactorAcl.resourceAccess.length, + ) + verifyInheritsSharingSettingsFromBenefactorMessage() + + // None of the ACL items should be editable since they are inherited + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + const createLocalSharingSettingsButton = screen.getByRole('button', { + name: CREATE_LOCAL_SHARING_SETTINGS, + }) + + await user.click(createLocalSharingSettingsButton) + + // Message about local sharing settings should change + verifyHasLocalSharingSettingsMessage() + + // The shown sharing settings should not change, but should now be editable + expect(screen.getAllByRole('row')).toHaveLength( + mockFileEntityData.bundle.benefactorAcl.resourceAccess.length, + ) + expect(screen.queryAllByRole('combobox')).toHaveLength( + mockFileEntityData.bundle.benefactorAcl.resourceAccess.length, + ) + + // Add a user to the ACL + const newUserRow = await addUserToAcl(user, MOCK_USER_NAME_2) + confirmItem(newUserRow, MOCK_USER_NAME_2, 'Can download') + + await waitFor(() => expect(onCanSaveChange).toHaveBeenLastCalledWith(true)) + + act(() => { + ref.current!.save() + }) + + await waitFor(() => { + expect(createAclSpy).toHaveBeenCalledWith( + { + id: mockFileEntityData.id, + resourceAccess: expect.arrayContaining([ + ...mockFileEntityData.bundle.benefactorAcl.resourceAccess.map( + ra => { + return { + ...ra, + accessType: expect.arrayContaining(ra.accessType), + } + }, + ), + { + principalId: MOCK_USER_ID_2, + accessType: expect.arrayContaining(['READ', 'DOWNLOAD']), + }, + ]), + }, + MOCK_ACCESS_TOKEN, + ) + expect(onUpdateSuccess).toHaveBeenCalled() + expect(sendMessageSpy).not.toHaveBeenCalled() + }) + }) + it('can delete an ACL on an entity that can inherit from a benefactor', async () => { + const { ref, user } = await setUp( + { + entityId: mockFileEntityWithLocalSharingSettingsData.id, + onCanSaveChange, + onUpdateSuccess, + }, + mockFileEntityWithLocalSharingSettingsData.bundle!.benefactorAcl + .resourceAccess.length, + ) + verifyHasLocalSharingSettingsMessage() + + // None of the ACL items should be editable since they are inherited + const deleteLocalSharingSettingsButton = screen.getByRole('button', { + name: /Delete local sharing settings/, + }) + await user.click(deleteLocalSharingSettingsButton) + + // The shown items should now match the parent benefactor's ACL + expect(screen.getAllByRole('row')).toHaveLength( + mockProjectEntityData.bundle.benefactorAcl.resourceAccess.length, + ) + + // The items should not be editable + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + + // Message about local sharing settings should change + verifyInheritsSharingSettingsFromBenefactorMessage() + + act(() => { + ref.current!.save() + }) + + await waitFor(() => { + expect(deleteAclSpy).toHaveBeenCalledWith( + mockFileEntityWithLocalSharingSettingsData.id, + MOCK_ACCESS_TOKEN, + ) + expect(onUpdateSuccess).toHaveBeenCalled() + expect(sendMessageSpy).not.toHaveBeenCalled() + }) + }) + + it('cannot delete an ACL if permissions do not allow', async () => { + // The mock project has the permission `canEnableInheritance: false` + await setUp( + { + entityId: mockProject.id, + onCanSaveChange, + onUpdateSuccess, + }, + mockProject.bundle.benefactorAcl.resourceAccess.length, + ) + verifyHasLocalSharingSettingsMessage() + + expect( + screen.queryByRole('button', { + name: /Delete local sharing settings/, + }), + ).not.toBeInTheDocument() + }) + + it('can send messages to users added to an updated ACL', async () => { + const { ref, user } = await setUp( + { + entityId: mockFileEntityWithLocalSharingSettingsData.id, + onCanSaveChange, + onUpdateSuccess, + }, + mockFileEntityWithLocalSharingSettingsData.bundle!.benefactorAcl + .resourceAccess.length, + ) + + await checkNotifyUsers(user) + + // Add a user to the ACL + const newUserRow = await addUserToAcl(user, MOCK_USER_NAME_2) + confirmItem(newUserRow, MOCK_USER_NAME_2, 'Can download') + + // Update the permission level of the new user + await updatePermissionLevel(newUserRow, user, 'Can edit & delete') + + await waitFor(() => expect(onCanSaveChange).toHaveBeenLastCalledWith(true)) + + act(() => { + ref.current!.save() + }) + + await waitFor(() => { + expect(updateAclSpy).toHaveBeenCalledWith( + { + ...mockFileEntityWithLocalSharingSettingsData.bundle! + .accessControlList, + resourceAccess: [ + ...mockFileEntityWithLocalSharingSettingsData.bundle! + .accessControlList!.resourceAccess, + { + principalId: MOCK_USER_ID_2, + accessType: expect.arrayContaining([ + 'READ', + 'DOWNLOAD', + 'UPDATE', + 'DELETE', + ]), + }, + ], + }, + MOCK_ACCESS_TOKEN, + ) + expect(onUpdateSuccess).toHaveBeenCalled() + expect(sendMessageSpy).toHaveBeenCalledWith( + [String(MOCK_USER_ID_2)], + `${mockFileEntityWithLocalSharingSettingsData.name} (shared on Synapse)`, + expect.stringMatching( + /[\w\s]+has shared an item with you on Synapse:\s.+Synapse:\w+\d+/, + ), + MOCK_ACCESS_TOKEN, + ) + }) + }) + it('can send messages to users added a new ACL', async () => { + const { ref, user } = await setUp( + { + entityId: mockFileEntity.id, + onCanSaveChange, + onUpdateSuccess, + }, + mockFileEntityData.bundle.benefactorAcl.resourceAccess.length, + ) + verifyInheritsSharingSettingsFromBenefactorMessage() + + // None of the ACL items should be editable since they are inherited + const createLocalSharingSettingsButton = screen.getByRole('button', { + name: CREATE_LOCAL_SHARING_SETTINGS, + }) + + await user.click(createLocalSharingSettingsButton) + + // Message about local sharing settings should change + verifyHasLocalSharingSettingsMessage() + + await checkNotifyUsers(user) + + // Add a user to the ACL + const newUserRow = await addUserToAcl(user, MOCK_USER_NAME_2) + confirmItem(newUserRow, MOCK_USER_NAME_2, 'Can download') + + await waitFor(() => expect(onCanSaveChange).toHaveBeenLastCalledWith(true)) + + act(() => { + ref.current!.save() + }) + + await waitFor(() => { + expect(createAclSpy).toHaveBeenCalledWith( + { + id: mockFileEntityData.id, + resourceAccess: expect.arrayContaining([ + ...mockFileEntityData.bundle.benefactorAcl.resourceAccess.map( + ra => { + return { + ...ra, + accessType: expect.arrayContaining(ra.accessType), + } + }, + ), + { + principalId: MOCK_USER_ID_2, + accessType: expect.arrayContaining(['READ', 'DOWNLOAD']), + }, + ]), + }, + MOCK_ACCESS_TOKEN, + ) + expect(onUpdateSuccess).toHaveBeenCalled() + expect(sendMessageSpy).toHaveBeenCalledWith( + [String(MOCK_USER_ID_2)], + `${mockFileEntityData.name} (shared on Synapse)`, + expect.stringMatching( + /[\w\s]+has shared an item with you on Synapse:\s.+Synapse:\w+\d+/, + ), + MOCK_ACCESS_TOKEN, + ) + }) + }) + it('displays information when the entity is OPEN_DATA and allows public to download', async () => { + const { itemRows } = await setUp( + { + entityId: mockFileOpenDataWithPublicRead.id, + onCanSaveChange, + onUpdateSuccess, + }, + mockFileOpenDataWithPublicRead.bundle!.benefactorAcl.resourceAccess + .length, + ) + await screen.findByText('This is anonymous access data.') + await screen.findByText( + 'Anyone can download it, even if they aren’t logged in to Synapse.', + ) + + // Verify that the public and authenticated are displayed as 'Can download' + await confirmItemViaQuery(itemRows, 'Anyone on the web', 'Can download') + await confirmItemViaQuery( + itemRows, + 'All registered Synapse users', + 'Can download', + ) + }) + it('displays information when the entity is OPEN_DATA and does not allow public to view', async () => { + await setUp( + { + entityId: mockFileOpenDataWithNoPublicRead.id, + onCanSaveChange, + onUpdateSuccess, + }, + mockFileOpenDataWithNoPublicRead.bundle!.benefactorAcl.resourceAccess + .length, + ) + await screen.findByText('This is not anonymous access data.') + await screen.findByText( + 'You must grant public access for all users to be able to anonymously download it.', + ) + }) + + it('displays information when the entity not OPEN_DATA and allows public to view', async () => { + await setUp( + { + entityId: mockFilePublicReadNoOpenData.id, + onCanSaveChange, + onUpdateSuccess, + }, + mockFilePublicReadNoOpenData.bundle!.benefactorAcl.resourceAccess.length, + ) + await screen.findByText( + 'Users must be logged in to download public access data.', + ) + await screen.findByText( + 'This data is publicly viewable, but only registered and logged-in users can download it.', + ) + }) + + it('adding the PUBLIC group assigns the correct permissions', async () => { + const initialNumberOfAccessors = + mockFileEntityWithLocalSharingSettingsData.bundle!.benefactorAcl + .resourceAccess.length + const { user } = await setUp( + { + entityId: mockFileEntityWithLocalSharingSettingsData.id, + onCanSaveChange, + onUpdateSuccess, + }, + initialNumberOfAccessors, + ) + + verifyHasLocalSharingSettingsMessage() + + // Add public to the ACL + const { publicRow, authenticatedUsersRow } = await addPublicToAcl(user) + + // Verify the initial permissions + confirmItem(publicRow, 'Anyone on the web', 'Can view') + confirmItem( + authenticatedUsersRow, + 'All registered Synapse users', + 'Can download', + ) + + // Verify that the public permissions are not editable + expect(within(publicRow).queryByRole('combobox')).not.toBeInTheDocument() + // Verify that the authenticated group permissions are editable + expect( + within(authenticatedUsersRow).queryByRole('combobox'), + ).toBeInTheDocument() + }) + + it('displays an error on mutate failure', async () => { + const errorReason = 'Something was invalid' + server.use( + rest.put( + `${getEndpoint(BackendDestinationEnum.REPO_ENDPOINT)}${ENTITY_ID( + ':entityId', + )}/acl`, + async (req, res, ctx) => { + const status = 400 + let response: SynapseApiResponse = { + reason: errorReason, + } + + return res(ctx.status(status), ctx.json(response)) + }, + ), + ) + + const { ref, user } = await setUp( + { + entityId: mockFileEntityWithLocalSharingSettingsData.id, + onCanSaveChange, + onUpdateSuccess, + }, + mockFileEntityWithLocalSharingSettingsData.bundle!.benefactorAcl + .resourceAccess.length, + ) + + // Enable sending a message, so we can verify that it is not sent when the update fails + await checkNotifyUsers(user) + + // Add a user to the ACL + const newUserRow = await addUserToAcl(user, MOCK_USER_NAME_2) + confirmItem(newUserRow, MOCK_USER_NAME_2, 'Can download') + + await waitFor(() => expect(onCanSaveChange).toHaveBeenLastCalledWith(true)) + + act(() => { + ref.current!.save() + }) + + const alert = await screen.findByRole('alert') + within(alert).getByText(errorReason) + + await waitFor(() => { + expect(updateAclSpy).toHaveBeenCalled() + // Verify callback and sendMessage were not called + expect(onUpdateSuccess).not.toHaveBeenCalled() + expect(sendMessageSpy).not.toHaveBeenCalled() + }) + }) + + it('current user cannot remove themselves', async () => { + const { itemRows } = await setUp( + { + entityId: mockFileEntityWithLocalSharingSettingsData.id, + onCanSaveChange, + onUpdateSuccess, + }, + mockFileEntityWithLocalSharingSettingsData.bundle!.benefactorAcl + .resourceAccess.length, + ) + + const currentUserRow = await confirmItemViaQuery( + itemRows, + MOCK_USER_NAME, + 'Administrator', + ) + + expect(within(currentUserRow).queryByRole('button')).not.toBeInTheDocument() + }) + + it('is not editable if the current user does not have permission', async () => { + const { itemRows } = await setUp( + { + entityId: mockFileEntityCurrentUserCannotEdit.id, + onCanSaveChange, + onUpdateSuccess, + }, + mockFileEntityCurrentUserCannotEdit.bundle!.benefactorAcl.resourceAccess + .length, + ) + + verifyHasLocalSharingSettingsMessage() + + // Verify there are no edit controls on any item + itemRows.forEach(row => { + expect(within(row).queryByRole('combobox')).not.toBeInTheDocument() + expect(within(row).queryByRole('button')).not.toBeInTheDocument() + }) + + // No controls to toggle inheritance + expect( + screen.queryByRole('button', { name: CREATE_LOCAL_SHARING_SETTINGS }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: DELETE_LOCAL_SHARING_SETTINGS }), + ).not.toBeInTheDocument() + + // No controls to add to the ACL + expect(queryForAddUserCombobox()).not.toBeInTheDocument() + expect( + screen.queryByLabelText('Notify people via email'), + ).not.toBeInTheDocument() + + // No controls to toggle public access + expect( + screen.queryByRole('button', { name: 'Remove Public Access' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Make Public' }), + ).not.toBeInTheDocument() + + screen.getByText( + 'You do not have sufficient privileges to modify the sharing settings.', + ) + }) +}) diff --git a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx new file mode 100644 index 0000000000..93347a466c --- /dev/null +++ b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx @@ -0,0 +1,352 @@ +import React, { useEffect, useImperativeHandle, useMemo, useState } from 'react' +import OpenData from './OpenData' +import { AclEditor } from '../AclEditor/AclEditor' +import useUpdateAcl from '../AclEditor/useUpdateAcl' +import { + getAccessTypeFromPermissionLevel, + PermissionLevel, + permissionLevelToLabel, +} from '../../utils/PermissionLevelToAccessType' +import { + ANONYMOUS_PRINCIPAL_ID, + AUTHENTICATED_PRINCIPAL_ID, + PUBLIC_PRINCIPAL_ID, +} from '../../utils/SynapseConstants' +import { + ALL_ENTITY_BUNDLE_FIELDS, + EntityType, + ResourceAccess, + UserProfile, +} from '@sage-bionetworks/synapse-types' +import { + useCreateEntityACL, + useDeleteEntityACL, + useGetEntityBenefactorACL, + useSuspenseGetCurrentUserProfile, + useSuspenseGetEntityBundle, + useUpdateEntityACL, +} from '../../synapse-queries' +import { Alert, Stack } from '@mui/material' +import { InheritanceMessage } from './InheritanceMessage' +import { CreateOrDeleteLocalSharingSettingsButton } from './CreateOrDeleteLocalSharingSettingsButton' +import isEqualTreatArraysAsSets from '../../utils/functions/isEqualTreatArraysAsSets' +import useNotifyNewACLUsers from './useNotifyNewACLUsers' +import { BackendDestinationEnum, getEndpoint } from '../../utils/functions' +import { getDisplayNameFromProfile } from '../../utils/functions/DisplayUtils' +import { AclEditorSkeleton } from '../AclEditor/AclEditorSkeleton' + +const availablePermissionLevels: PermissionLevel[] = [ + 'CAN_VIEW', + 'CAN_DOWNLOAD', + 'CAN_EDIT', + 'CAN_EDIT_DELETE', + 'CAN_ADMINISTER', +] + +export type EntityAclEditorProps = { + entityId: string + onCanSaveChange: (canSaveChanges: boolean) => void + onUpdateSuccess: () => void +} + +export type EntityAclEditorHandle = { + save: () => void +} + +function getSubject(entityName: string) { + return `${entityName} (shared on Synapse)` +} + +function getBody(profile: UserProfile, entityId: string) { + return `${getDisplayNameFromProfile( + profile, + )} has shared an item with you on Synapse:\n${getEndpoint( + BackendDestinationEnum.PORTAL_ENDPOINT, + )}Synapse:${entityId}` +} + +const EntityAclEditor = React.forwardRef(function EntityAclEditor( + props: EntityAclEditorProps, + ref: React.ForwardedRef, +) { + const { entityId, onCanSaveChange, onUpdateSuccess } = props + + const { data: ownProfile } = useSuspenseGetCurrentUserProfile() + const { data: entityBundle } = useSuspenseGetEntityBundle( + entityId, + undefined, + ALL_ENTITY_BUNDLE_FIELDS, + { staleTime: Infinity }, + ) + const isProject = EntityType.PROJECT == entityBundle.entityType + + const parentId = entityBundle.entity.parentId + const { data: parentAcl } = useGetEntityBenefactorACL(parentId!, { + enabled: parentId != null && !isProject, + staleTime: Infinity, + }) + + const originalResourceAccess = entityBundle.benefactorAcl.resourceAccess + const parentResourceAccess = useMemo( + () => parentAcl?.resourceAccess ?? [], + [parentAcl], + ) + const canEdit = entityBundle.permissions.canChangePermissions + const isOpenData = entityBundle.permissions.isEntityOpenData + const originalIsInherited = !(entityBundle.benefactorAcl.id == entityId) + const [updatedIsInherited, setUpdatedIsInherited] = useState(false) + const [notifyNewAdditions, setNotifyNewAdditions] = useState(false) + const [error, setError] = useState() + + const { + resourceAccessList: updatedResourceAccessList, + setResourceAccessList, + addResourceAccessItem, + updateResourceAccessItem, + removeResourceAccessItem, + resetDirtyState, + } = useUpdateAcl() + + // If `originalResourceAccess` changes, reset state + useEffect(() => { + if (originalResourceAccess) { + resetDirtyState() + setResourceAccessList(originalResourceAccess) + } + }, [originalResourceAccess, resetDirtyState, setResourceAccessList]) + + // If `originalIsInherited` changes, reset state + useEffect(() => { + resetDirtyState() + setUpdatedIsInherited(originalIsInherited) + }, [originalIsInherited, resetDirtyState]) + + // If the editor toggles the inherited state, reset the resource access list + useEffect(() => { + if (originalIsInherited == updatedIsInherited) { + // The user reverted to the original state (regardless of inherited or not) + setResourceAccessList(originalResourceAccess) + } else if (updatedIsInherited) { + // The user toggled to inherited, update the resource access list to match the parent + setResourceAccessList(parentResourceAccess) + } else { + // The user toggled to local, no need to update the list (the parent's list is already in the editor) + } + resetDirtyState() + }, [ + originalIsInherited, + originalResourceAccess, + parentResourceAccess, + resetDirtyState, + setResourceAccessList, + updatedIsInherited, + ]) + + const isPublic = updatedResourceAccessList.some(ra => + [ + AUTHENTICATED_PRINCIPAL_ID, + PUBLIC_PRINCIPAL_ID, + ANONYMOUS_PRINCIPAL_ID, + ].includes(ra.principalId), + ) + + const canEditResourceAccess = canEdit + ? (resourceAccess: ResourceAccess): boolean => { + // Users cannot change their own permissions + const isSelf = ownProfile.ownerId === String(resourceAccess.principalId) + // Users cannot change permission level for the public group, only add/remove it. + // To give the public group DOWNLOAD access, ACT must mark it as anonymous access. + const isPublicGroup = resourceAccess.principalId === PUBLIC_PRINCIPAL_ID + if (isSelf || isPublicGroup) { + return false + } + return true + } + : false + + function canDeleteResourceAccess(resourceAccess: ResourceAccess): boolean { + // Users cannot delete their own permissions + const isSelf = ownProfile.ownerId === String(resourceAccess.principalId) + if (isSelf) { + return false + } + return canEdit + } + + function getDisplayedPermissionLevelOverride( + resourceAccess: ResourceAccess, + ): string | undefined { + if (resourceAccess.principalId === PUBLIC_PRINCIPAL_ID) { + return isOpenData + ? permissionLevelToLabel['CAN_DOWNLOAD'] + : permissionLevelToLabel['CAN_VIEW'] + } + return undefined + } + + const { + sendNotification, + isLoading: isLoadingSendMessageToNewACLUsers, + isPending: isPendingSendMessageToNewACLUsers, + } = useNotifyNewACLUsers({ + subject: getSubject(entityBundle.entity.name || ''), + body: getBody(ownProfile, entityId), + initialResourceAccessList: originalResourceAccess, + newResourceAccessList: updatedResourceAccessList, + }) + + const mutationOptions = { + onSuccess: () => { + if (notifyNewAdditions) { + sendNotification() + } + onUpdateSuccess() + }, + onError: (e: Error) => { + setError(e) + }, + } + + const { mutate: createAcl, isPending: isPendingCreateAcl } = + useCreateEntityACL(mutationOptions) + const { mutate: updateAcl, isPending: isPendingUpdateAcl } = + useUpdateEntityACL(mutationOptions) + const { mutate: deleteAcl, isPending: isPendingDeleteAcl } = + useDeleteEntityACL(mutationOptions) + + const isPending = + isPendingCreateAcl || + isPendingUpdateAcl || + isPendingDeleteAcl || + isPendingSendMessageToNewACLUsers + + const hasAclChanged = useMemo(() => { + return ( + originalIsInherited != updatedIsInherited || + !isEqualTreatArraysAsSets( + originalResourceAccess, + updatedResourceAccessList, + ) + ) + }, [ + originalResourceAccess, + originalIsInherited, + updatedIsInherited, + updatedResourceAccessList, + ]) + + const canSave = + hasAclChanged || isLoadingSendMessageToNewACLUsers || isPending + + useEffect(() => { + onCanSaveChange(canSave) + }, [onCanSaveChange, canSave]) + + useImperativeHandle( + ref, + () => { + return { + save() { + if (canSave) { + // Check if the inheritance changed; if so, the ACL should be created/deleted + if (originalIsInherited != updatedIsInherited) { + if (updatedIsInherited) { + deleteAcl(entityId) + } else { + createAcl({ + id: entityId, + resourceAccess: updatedResourceAccessList, + }) + } + } else { + // Inheritance did not change; update the ACL + updateAcl({ + // ensure we get all fields from the original ACL, including the etag + ...entityBundle.accessControlList!, + resourceAccess: updatedResourceAccessList, + }) + } + } else { + console.error('EntityAclEditor: save() called but canSave is false') + } + }, + } + }, + [ + canSave, + createAcl, + deleteAcl, + entityBundle, + entityId, + originalIsInherited, + updateAcl, + updatedIsInherited, + updatedResourceAccessList, + ], + ) + + return ( + + + + { + if (id === String(PUBLIC_PRINCIPAL_ID)) { + addResourceAccessItem( + id, + getAccessTypeFromPermissionLevel('CAN_VIEW'), + ) + } else { + addResourceAccessItem( + id, + getAccessTypeFromPermissionLevel('CAN_DOWNLOAD'), + ) + } + }} + updateResourceAccessItem={updateResourceAccessItem} + removeResourceAccessItem={removeResourceAccessItem} + showNotifyCheckbox={true} + notifyCheckboxValue={notifyNewAdditions} + onNotifyCheckboxChange={setNotifyNewAdditions} + showAddRemovePublicButton={true} + /> + {/* Create / delete local sharing settings button */} + {!isProject && entityBundle.permissions.canEnableInheritance && ( + + )} + {error && {error.message}} + + ) +}) + +const EntityAclEditorWithSuspense = React.forwardRef( + function EntityAclEditorWithSuspense( + props: EntityAclEditorProps, + ref: React.ForwardedRef, + ) { + return ( + }> + + + ) + }, +) + +export default EntityAclEditorWithSuspense diff --git a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditorModal.tsx b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditorModal.tsx new file mode 100644 index 0000000000..049b228cb3 --- /dev/null +++ b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditorModal.tsx @@ -0,0 +1,55 @@ +import React, { useRef, useState } from 'react' +import { ConfirmationDialog } from '../ConfirmationDialog' +import EntityAclEditor, { EntityAclEditorHandle } from './EntityAclEditor' +import { displayToast } from '../ToastMessage' +import { useGetEntityBundle } from '../../synapse-queries' +import { entityTypeToFriendlyName } from '../../utils/functions/EntityTypeUtils' + +export type EntityAclEditorModalProps = { + entityId: string + open: boolean + onClose: () => void +} + +export default function EntityAclEditorModal(props: EntityAclEditorModalProps) { + const { entityId, open, onClose } = props + const [isDirty, setIsDirty] = useState(false) + const entityAclEditorRef = useRef(null) + + // We just need the entity type, but the editor will also fetch the bundle, so we can use that and rely on/preload the cache + const { data: entityBundle } = useGetEntityBundle(entityId) + const entityTypeDisplay = entityBundle?.entityType + ? entityTypeToFriendlyName(entityBundle?.entityType) + : '' + const canEdit = entityBundle?.permissions?.canChangePermissions + + return ( + setIsDirty(isDirty)} + onUpdateSuccess={() => { + onClose() + displayToast( + 'Permissions were successfully saved to Synapse', + 'info', + ) + }} + /> + } + onConfirm={() => { + entityAclEditorRef.current!.save() + }} + confirmButtonProps={{ + children: canEdit ? 'Save' : 'OK', + disabled: !isDirty, + }} + /> + ) +} diff --git a/packages/synapse-react-client/src/components/EntityAclEditor/InheritanceMessage.tsx b/packages/synapse-react-client/src/components/EntityAclEditor/InheritanceMessage.tsx new file mode 100644 index 0000000000..49ffbd11f1 --- /dev/null +++ b/packages/synapse-react-client/src/components/EntityAclEditor/InheritanceMessage.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import { EntityLink } from '../EntityLink' +import { Typography } from '@mui/material' + +type InheritanceMessageProps = { + isProject: boolean + isInherited: boolean + benefactorId?: string +} + +export function InheritanceMessage(props: InheritanceMessageProps) { + const { isProject, isInherited, benefactorId } = props + let content: React.ReactNode = '' + if (isProject) { + content = ( + <> + The sharing settings shown below apply to this project and are inherited + by all project contents unless local sharing settings have been set. + + ) + } + if (isInherited) { + content = ( + <> + The sharing settings shown below are currently being inherited{' '} + {benefactorId ? ( + <> + from {' '} + + ) : ( + '' + )} + and cannot be modified here. + + ) + } else { + content = ( + <> + The local sharing settings shown below are not being + inherited from a parent resource. + + ) + } + + return ( + + {content} + + ) +} diff --git a/packages/synapse-react-client/src/components/EntityAclEditor/OpenData.stories.ts b/packages/synapse-react-client/src/components/EntityAclEditor/OpenData.stories.ts new file mode 100644 index 0000000000..776a536e98 --- /dev/null +++ b/packages/synapse-react-client/src/components/EntityAclEditor/OpenData.stories.ts @@ -0,0 +1,36 @@ +import { Meta, StoryObj } from '@storybook/react' +import OpenData, { OpenDataProps } from './OpenData' + +const meta: Meta = { + title: 'Synapse/OpenData', + component: OpenData, +} +export default meta +type Story = StoryObj + +export const PublicOpenData: Story = { + name: 'Public & Open Data', + args: { + isOpenData: true, + isPublic: true, + currentUserCanUpdateSharingSettings: false, + }, +} + +export const NonPublicOpenDataWithChangePermissions: Story = { + name: 'Non-Public & Open Data', + args: { + isOpenData: true, + isPublic: false, + currentUserCanUpdateSharingSettings: true, + }, +} + +export const NonOpenPublicDataWithChangePermissions: Story = { + name: 'Public & Non-Open Data', + args: { + isOpenData: false, + isPublic: true, + currentUserCanUpdateSharingSettings: true, + }, +} diff --git a/packages/synapse-react-client/src/components/EntityAclEditor/OpenData.tsx b/packages/synapse-react-client/src/components/EntityAclEditor/OpenData.tsx new file mode 100644 index 0000000000..0fdcf71514 --- /dev/null +++ b/packages/synapse-react-client/src/components/EntityAclEditor/OpenData.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import IconSvg from '../IconSvg' +import { Box, BoxProps, styled, Typography } from '@mui/material' +import { StyledComponent } from '@emotion/styled' +import tinycolor from 'tinycolor2' + +const OpenDataContainer: StyledComponent = styled(Box, { + label: 'OpenDataContainer', +})(({ theme }) => ({ + background: + theme.palette.mode === 'light' + ? theme.palette.grey[100] + : tinycolor(theme.palette.background.paper).desaturate(1).toString(), + padding: `${theme.spacing(2.5)} ${theme.spacing(4)}`, + border: + theme.palette.mode === 'light' + ? `1px solid ${theme.palette.grey[300]}` + : 'none', + borderRadius: '3px', + marginBottom: theme.spacing(2), +})) + +export type OpenDataProps = { + isOpenData: boolean + isPublic: boolean + currentUserCanUpdateSharingSettings: boolean +} + +export default function OpenData(props: OpenDataProps) { + const { isOpenData, isPublic, currentUserCanUpdateSharingSettings } = props + + if (isOpenData && isPublic) { + return ( + + +
+ + This is anonymous access data. + + + Anyone can download it, even if they aren’t logged in to Synapse. + +
+
+ ) + } else if (isOpenData && !isPublic && currentUserCanUpdateSharingSettings) { + return ( + + + This is not anonymous access data. + + + You must grant public access for all users to be able to anonymously + download it. + + + ) + } else if (!isOpenData && isPublic && currentUserCanUpdateSharingSettings) { + return ( + + + + Users must be logged in to download public access data. + + + + This data is publicly viewable, but only registered and logged-in + users can download it. + + + ) + } + + return null +} diff --git a/packages/synapse-react-client/src/components/EntityAclEditor/useNotifyNewACLUsers.test.ts b/packages/synapse-react-client/src/components/EntityAclEditor/useNotifyNewACLUsers.test.ts index 5dd560058d..e2442f096a 100644 --- a/packages/synapse-react-client/src/components/EntityAclEditor/useNotifyNewACLUsers.test.ts +++ b/packages/synapse-react-client/src/components/EntityAclEditor/useNotifyNewACLUsers.test.ts @@ -92,6 +92,10 @@ describe('useNotifyNewACLUsers', () => { principalId: mockUserData2.id, accessType: getAccessTypeFromPermissionLevel('CAN_DOWNLOAD'), }, + { + principalId: mockUserData3.id, + accessType: getAccessTypeFromPermissionLevel('CAN_DOWNLOAD'), + }, ] const newResourceAccessList: ResourceAccess[] = [ CURRENT_USER_RESOURCE_ACCESS, @@ -100,6 +104,7 @@ describe('useNotifyNewACLUsers', () => { principalId: mockUserData2.id, accessType: getAccessTypeFromPermissionLevel('CAN_EDIT_DELETE'), }, + // removed user 3 ] const { result } = renderHook( @@ -129,6 +134,7 @@ describe('useNotifyNewACLUsers', () => { }) describe('shouldNotifyUserInNewResourceAccess', () => { + const CURRENT_USER_ID = String(mockUserData1.id) it('is true for new users and false for existing users', () => { const initialResourceAccessList: ResourceAccess[] = [ CURRENT_USER_RESOURCE_ACCESS, @@ -144,7 +150,7 @@ describe('shouldNotifyUserInNewResourceAccess', () => { mockUserData1.id, initialResourceAccessList, mockUserData2.userGroupHeader, - String(mockUserData1.id), + CURRENT_USER_ID, ), ).toBe(false) // User 2 is in the list @@ -162,7 +168,7 @@ describe('shouldNotifyUserInNewResourceAccess', () => { mockUserData3.id, initialResourceAccessList, mockUserData3.userGroupHeader, - String(mockUserData1.id), + CURRENT_USER_ID, ), ).toBe(true) }) @@ -181,7 +187,7 @@ describe('shouldNotifyUserInNewResourceAccess', () => { initialResourceAccessList, mockTeamUserGroups.find(team => team.id === MOCK_TEAM_ID)! .userGroupHeader, - String(mockUserData1.id), + CURRENT_USER_ID, ), ).toBe(false) // User 1 is not in the list, but is the current user, so they should not be notified @@ -190,7 +196,7 @@ describe('shouldNotifyUserInNewResourceAccess', () => { mockUserData1.id, initialResourceAccessList, mockUserData1.userGroupHeader, - String(mockUserData1.id), + CURRENT_USER_ID, ), ).toBe(false) }) @@ -205,7 +211,7 @@ describe('shouldNotifyUserInNewResourceAccess', () => { AUTHENTICATED_PRINCIPAL_ID, initialResourceAccessList, mockAuthenticatedGroupData.userGroupHeader, - String(mockUserData1.id), + CURRENT_USER_ID, ), ).toBe(false) expect( @@ -213,7 +219,7 @@ describe('shouldNotifyUserInNewResourceAccess', () => { PUBLIC_PRINCIPAL_ID, initialResourceAccessList, mockPublicGroupData.userGroupHeader, - String(mockUserData1.id), + CURRENT_USER_ID, ), ).toBe(false) expect( @@ -221,7 +227,7 @@ describe('shouldNotifyUserInNewResourceAccess', () => { ANONYMOUS_PRINCIPAL_ID, initialResourceAccessList, mockAnonymousUserGroupData.userGroupHeader, - String(mockUserData1.id), + CURRENT_USER_ID, ), ).toBe(false) }) @@ -237,7 +243,7 @@ describe('shouldNotifyUserInNewResourceAccess', () => { initialResourceAccessList, mockTeamUserGroups.find(team => team.id === MOCK_TEAM_ID)! .userGroupHeader, - String(mockUserData1.id), + CURRENT_USER_ID, ), ).toBe(false) }) diff --git a/packages/synapse-react-client/src/components/EntityHeaderTable/EntityHeaderTable.integration.test.tsx b/packages/synapse-react-client/src/components/EntityHeaderTable/EntityHeaderTable.integration.test.tsx index 9b972f083e..4c7e81adf3 100644 --- a/packages/synapse-react-client/src/components/EntityHeaderTable/EntityHeaderTable.integration.test.tsx +++ b/packages/synapse-react-client/src/components/EntityHeaderTable/EntityHeaderTable.integration.test.tsx @@ -11,13 +11,13 @@ import { EntityFinderModal } from '../EntityFinder/EntityFinderModal' import mockFileEntityData, { MOCK_FILE_ENTITY_ID, MOCK_FILE_NAME, - mockFileEntities, } from '../../mocks/entity/mockFileEntity' import mockDatasetData, { MOCK_DATASET_ENTITY_ID, MOCK_DATASET_NAME, } from '../../mocks/entity/mockDataset' import userEvent from '@testing-library/user-event' +import { mockFileEntities } from '../../mocks/entity' function renderTable(props: EntityHeaderTableProps) { return render(, { diff --git a/packages/synapse-react-client/src/components/TeamBadge.tsx b/packages/synapse-react-client/src/components/TeamBadge.tsx index 372ab48891..6d332a54dd 100644 --- a/packages/synapse-react-client/src/components/TeamBadge.tsx +++ b/packages/synapse-react-client/src/components/TeamBadge.tsx @@ -5,6 +5,7 @@ import { AUTHENTICATED_PRINCIPAL_ID, PUBLIC_PRINCIPAL_ID, } from '../utils/SynapseConstants' +import { Box, Link } from '@mui/material' export type TeamBadgeProps = { teamId: string | number @@ -29,11 +30,11 @@ export default function TeamBadge(props: TeamBadgeProps) { disableHref = true } - const Tag = disableHref ? 'span' : 'a' + const Tag = disableHref ? 'span' : Link return ( - - + + {teamName} - +
) } diff --git a/packages/synapse-react-client/src/mocks/discussion/mock_discussion.ts b/packages/synapse-react-client/src/mocks/discussion/mock_discussion.ts index b1388701dd..f11e1616b6 100644 --- a/packages/synapse-react-client/src/mocks/discussion/mock_discussion.ts +++ b/packages/synapse-react-client/src/mocks/discussion/mock_discussion.ts @@ -11,7 +11,8 @@ import { generateDiscussionThreadBundle, generateForum, } from '../faker/generateDiscussion' -import mockProject, { mockProjects } from '../entity/mockProject' +import mockProject from '../entity/mockProject' +import { mockProjects } from '../entity' export const MOCK_FORUM_ID = '984321189' diff --git a/packages/synapse-react-client/src/mocks/entity/index.ts b/packages/synapse-react-client/src/mocks/entity/index.ts index 9a899ffb68..792b147268 100644 --- a/packages/synapse-react-client/src/mocks/entity/index.ts +++ b/packages/synapse-react-client/src/mocks/entity/index.ts @@ -1,22 +1,40 @@ import mockDatasetData from './mockDataset' import mockDatasetCollectionData from './mockDatasetCollection' import { MockEntityData } from './MockEntityData' -import { mockFileEntities } from './mockFileEntity' -import { mockProjectsEntityData } from './mockProject' import mockTableEntityData from './mockTableEntity' import { mockFileViewData } from './mockFileView' import { mockProjectViewData } from './mockProjectView' import mockReleaseCardsTableData from './mockReleaseCardsTable' +import { FileEntity, Project } from '@sage-bionetworks/synapse-types' +import { mockGeneratedEntityData } from './mockGeneratedEntityData' +import mockProjectEntityData from './mockProject' +import mockFileEntityData from './mockFileEntity' const mockEntities: MockEntityData[] = [ - ...mockFileEntities, - ...mockProjectsEntityData, + mockFileEntityData, + mockProjectEntityData, mockDatasetData, mockDatasetCollectionData, mockTableEntityData, mockFileViewData, mockProjectViewData, mockReleaseCardsTableData, + ...mockGeneratedEntityData, ] +export const mockFileEntities = mockEntities.filter( + (data): data is MockEntityData => + data.entity.concreteType === 'org.sagebionetworks.repo.model.FileEntity', +) + +export const mockProjectsEntityData: MockEntityData[] = + mockEntities.filter( + (data): data is MockEntityData => + data.entity.concreteType === 'org.sagebionetworks.repo.model.Project', + ) + +export const mockProjects: Project[] = mockProjectsEntityData.map( + projectData => projectData.entity, +) + export default mockEntities diff --git a/packages/synapse-react-client/src/mocks/entity/mockFileEntity.ts b/packages/synapse-react-client/src/mocks/entity/mockFileEntity.ts index 7c140968eb..a6cf4fb39d 100644 --- a/packages/synapse-react-client/src/mocks/entity/mockFileEntity.ts +++ b/packages/synapse-react-client/src/mocks/entity/mockFileEntity.ts @@ -1,5 +1,4 @@ import { - ACCESS_TYPE, AnnotationsValueType, EntityBundle, EntityHeader, @@ -18,8 +17,7 @@ import { import { MOCK_USER_ID, MOCK_USER_ID_2 } from '../user/mock_user_profile' import { MockEntityData } from './MockEntityData' import mockProject from './mockProject' -import { times } from 'lodash-es' -import { generateBaseEntity } from '../faker/generateFakeEntity' +import mockProjectEntityData from './mockProject' const parentId = mockProject.id const projectName = mockProject.name @@ -146,34 +144,7 @@ const mockFileEntityBundle: EntityBundle = { }, rootWikiId: MOCK_WIKI_ID, fileName: mockFileEntity.name, - benefactorAcl: { - id: parentId, - creationDate: '2020-11-18T20:05:06.540Z', - etag: 'f143bbfd-ba09-4a42-b1e9-f9368777ad9b', - resourceAccess: [ - { - principalId: MOCK_USER_ID, - accessType: [ - ACCESS_TYPE.DELETE, - ACCESS_TYPE.CHANGE_SETTINGS, - ACCESS_TYPE.MODERATE, - ACCESS_TYPE.CHANGE_PERMISSIONS, - ACCESS_TYPE.UPDATE, - ACCESS_TYPE.READ, - ACCESS_TYPE.DOWNLOAD, - ACCESS_TYPE.CREATE, - ], - }, - { - principalId: 273948, - accessType: [ACCESS_TYPE.READ], - }, - { - principalId: 273949, - accessType: [ACCESS_TYPE.READ], - }, - ], - }, + benefactorAcl: mockProjectEntityData.bundle.accessControlList!, permissions: { canView: true, canEdit: true, @@ -201,10 +172,6 @@ const mockFileEntityBundle: EntityBundle = { hasUnmetAccessRequirement: false, }, hasChildren: false, - accessControlList: { - id: '65165198', - resourceAccess: [], - }, } const mockFileEntityJson: EntityJson = { @@ -236,17 +203,6 @@ const mockFileEntityHeader: EntityHeader = { isLatestVersion: true, } -const generatedFileEntityData: MockEntityData[] = times(50).map( - i => - generateBaseEntity( - { - concreteType: 'org.sagebionetworks.repo.model.FileEntity', - parentId: mockProject.id, - }, - 30000 + i + 1, - ) as MockEntityData, -) - const mockFileEntityData = { id: MOCK_FILE_ENTITY_ID, name: MOCK_FILE_NAME, @@ -259,9 +215,4 @@ const mockFileEntityData = { path: filePath, } satisfies MockEntityData -export const mockFileEntities = [ - mockFileEntityData, - ...generatedFileEntityData, -] satisfies MockEntityData[] - export default mockFileEntityData diff --git a/packages/synapse-react-client/src/mocks/entity/mockFileEntityACLVariants.ts b/packages/synapse-react-client/src/mocks/entity/mockFileEntityACLVariants.ts new file mode 100644 index 0000000000..1657d310f4 --- /dev/null +++ b/packages/synapse-react-client/src/mocks/entity/mockFileEntityACLVariants.ts @@ -0,0 +1,125 @@ +import { generateBaseEntity } from '../faker/generateFakeEntity' +import { EntityType, FileEntity } from '@sage-bionetworks/synapse-types' +import { MOCK_USER_ID, MOCK_USER_ID_2 } from '../user/mock_user_profile' +import { getAccessTypeFromPermissionLevel } from '../../utils/PermissionLevelToAccessType' +import { + AUTHENTICATED_PRINCIPAL_ID, + PUBLIC_PRINCIPAL_ID, +} from '../../utils/SynapseConstants' +import { MockEntityData } from './MockEntityData' + +/* + * Mock FileEntities for testing different ACL/permissions scenarios. + */ + +export const mockFileOpenDataWithPublicRead: MockEntityData = + generateBaseEntity({ + type: EntityType.FILE, + acl: { + resourceAccess: [ + { + principalId: MOCK_USER_ID, + accessType: getAccessTypeFromPermissionLevel('CAN_ADMINISTER'), + }, + { + principalId: AUTHENTICATED_PRINCIPAL_ID, + accessType: getAccessTypeFromPermissionLevel('CAN_DOWNLOAD'), + }, + { + principalId: PUBLIC_PRINCIPAL_ID, + accessType: getAccessTypeFromPermissionLevel('CAN_VIEW'), + }, + ], + }, + permissions: { + isEntityOpenData: true, + }, + }) + +export const mockFileOpenDataWithNoPublicRead: MockEntityData = + generateBaseEntity({ + type: EntityType.FILE, + acl: { + resourceAccess: [ + { + principalId: MOCK_USER_ID, + accessType: getAccessTypeFromPermissionLevel('CAN_ADMINISTER'), + }, + ], + }, + permissions: { + isEntityOpenData: true, + }, + }) + +export const mockFilePublicReadNoOpenData: MockEntityData = + generateBaseEntity({ + type: EntityType.FILE, + acl: { + resourceAccess: [ + { + principalId: MOCK_USER_ID, + accessType: getAccessTypeFromPermissionLevel('CAN_ADMINISTER'), + }, + { + principalId: AUTHENTICATED_PRINCIPAL_ID, + accessType: getAccessTypeFromPermissionLevel('CAN_DOWNLOAD'), + }, + { + principalId: PUBLIC_PRINCIPAL_ID, + accessType: getAccessTypeFromPermissionLevel('CAN_VIEW'), + }, + ], + }, + permissions: { + isEntityOpenData: false, + }, + }) + +export const mockFileEntityWithLocalSharingSettingsData: MockEntityData = + generateBaseEntity({ + type: EntityType.FILE, + entity: { + name: 'mock file with local sharing settings', + }, + acl: { + resourceAccess: [ + { + principalId: MOCK_USER_ID, + accessType: getAccessTypeFromPermissionLevel('CAN_ADMINISTER'), + }, + ], + }, + }) + +export const mockFileEntityCurrentUserCannotEdit: MockEntityData = + generateBaseEntity({ + type: EntityType.FILE, + entity: { + name: 'mock file with local sharing settings', + }, + acl: { + resourceAccess: [ + { + principalId: MOCK_USER_ID_2, + accessType: getAccessTypeFromPermissionLevel('CAN_ADMINISTER'), + }, + { + principalId: MOCK_USER_ID, + accessType: getAccessTypeFromPermissionLevel('CAN_DOWNLOAD'), + }, + ], + }, + permissions: { + canChangePermissions: false, + canEnableInheritance: false, + }, + }) + +export const aclCustomizedMockFileEntities = [ + mockFileOpenDataWithPublicRead, + mockFileOpenDataWithNoPublicRead, + mockFilePublicReadNoOpenData, + mockFileEntityWithLocalSharingSettingsData, + mockFileEntityCurrentUserCannotEdit, +] diff --git a/packages/synapse-react-client/src/mocks/entity/mockGeneratedEntityData.ts b/packages/synapse-react-client/src/mocks/entity/mockGeneratedEntityData.ts new file mode 100644 index 0000000000..8d4704c9d5 --- /dev/null +++ b/packages/synapse-react-client/src/mocks/entity/mockGeneratedEntityData.ts @@ -0,0 +1,34 @@ +import { MockEntityData } from './MockEntityData' +import { + generateBaseEntity, + generateProject, +} from '../faker/generateFakeEntity' +import mockProjectEntityData from './mockProject' +import mockProject, { mockProjectIds } from './mockProject' +import { aclCustomizedMockFileEntities } from './mockFileEntityACLVariants' +import { EntityType, FileEntity } from '@sage-bionetworks/synapse-types' +import { times } from 'lodash-es' + +const generatedFileEntityData: MockEntityData[] = times(50).map( + i => + generateBaseEntity({ + id: 30000 + i + 1, + type: EntityType.FILE, + entity: { + parentId: mockProject.id, + }, + }) as MockEntityData, +) + +const generatedProjectsEntityData = mockProjectIds.map(id => { + if (`syn${id}` === mockProjectEntityData.id) { + return mockProjectEntityData + } + return generateProject(undefined, id) +}) + +export const mockGeneratedEntityData: MockEntityData[] = [ + ...generatedProjectsEntityData, + ...generatedFileEntityData, + ...aclCustomizedMockFileEntities, +] diff --git a/packages/synapse-react-client/src/mocks/entity/mockProject.ts b/packages/synapse-react-client/src/mocks/entity/mockProject.ts index 6df6b12b53..d19bfe4a6a 100644 --- a/packages/synapse-react-client/src/mocks/entity/mockProject.ts +++ b/packages/synapse-react-client/src/mocks/entity/mockProject.ts @@ -13,8 +13,8 @@ import { } from '@sage-bionetworks/synapse-types' import { MOCK_USER_ID } from '../user/mock_user_profile' import { MockEntityData } from './MockEntityData' -import { generateProject } from '../faker/generateFakeEntity' import { times } from 'lodash-es' +import { MOCK_TEAM_ID } from '../team/mockTeam' export const mockProjectIds = times(20).map(i => i + 10001) @@ -46,6 +46,36 @@ const mockProjectEntity = { concreteType: 'org.sagebionetworks.repo.model.Project', } satisfies Project +const mockProjectAcl = { + id: MOCK_PROJECT_ID, + creationDate: '2020-11-18T20:05:06.540Z', + etag: 'f143bbfd-ba09-4a42-b1e9-f9368777ad9b', + resourceAccess: [ + { + principalId: MOCK_USER_ID, + accessType: [ + ACCESS_TYPE.DELETE, + ACCESS_TYPE.CHANGE_SETTINGS, + ACCESS_TYPE.MODERATE, + ACCESS_TYPE.CHANGE_PERMISSIONS, + ACCESS_TYPE.UPDATE, + ACCESS_TYPE.READ, + ACCESS_TYPE.DOWNLOAD, + ACCESS_TYPE.CREATE, + ], + }, + { + principalId: MOCK_TEAM_ID, + accessType: [ + ACCESS_TYPE.READ, + ACCESS_TYPE.DOWNLOAD, + ACCESS_TYPE.MODERATE, + ACCESS_TYPE.CREATE, + ], + }, + ], +} + const mockProjectEntityBundle: EntityBundle = { entity: mockProjectEntity, entityType: EntityType.PROJECT, @@ -84,7 +114,7 @@ const mockProjectEntityBundle: EntityBundle = { canPublicRead: true, canModerate: true, canMove: true, - isEntityOpenData: false, + isEntityOpenData: true, isCertificationRequired: true, }, path: { @@ -102,64 +132,10 @@ const mockProjectEntityBundle: EntityBundle = { ], }, hasChildren: true, - accessControlList: { - id: MOCK_PROJECT_ID, - creationDate: '2020-11-18T20:05:06.540Z', - etag: 'f143bbfd-ba09-4a42-b1e9-f9368777ad9b', - resourceAccess: [ - { - principalId: MOCK_USER_ID, - accessType: [ - ACCESS_TYPE.DELETE, - ACCESS_TYPE.CHANGE_SETTINGS, - ACCESS_TYPE.MODERATE, - ACCESS_TYPE.CHANGE_PERMISSIONS, - ACCESS_TYPE.UPDATE, - ACCESS_TYPE.READ, - ACCESS_TYPE.DOWNLOAD, - ACCESS_TYPE.CREATE, - ], - }, - { - principalId: 273948, - accessType: [ACCESS_TYPE.READ], - }, - { - principalId: 273949, - accessType: [ACCESS_TYPE.READ], - }, - ], - }, + accessControlList: mockProjectAcl, fileHandles: [], rootWikiId: '607416', - benefactorAcl: { - id: MOCK_PROJECT_ID, - creationDate: '2020-11-18T20:05:06.540Z', - etag: 'f143bbfd-ba09-4a42-b1e9-f9368777ad9b', - resourceAccess: [ - { - principalId: MOCK_USER_ID, - accessType: [ - ACCESS_TYPE.DELETE, - ACCESS_TYPE.CHANGE_SETTINGS, - ACCESS_TYPE.MODERATE, - ACCESS_TYPE.CHANGE_PERMISSIONS, - ACCESS_TYPE.UPDATE, - ACCESS_TYPE.READ, - ACCESS_TYPE.DOWNLOAD, - ACCESS_TYPE.CREATE, - ], - }, - { - principalId: 273948, - accessType: [ACCESS_TYPE.READ], - }, - { - principalId: 273949, - accessType: [ACCESS_TYPE.READ], - }, - ], - }, + benefactorAcl: mockProjectAcl, doiAssociation: mockDoiAssociation, threadCount: 2, restrictionInformation: { @@ -212,20 +188,4 @@ const mockProjectEntityData = { json: mockProjectJson, } satisfies MockEntityData -const generatedProjectsEntityData = mockProjectIds.map(id => { - if (`syn${id}` === MOCK_PROJECT_ID) { - return mockProjectEntityData - } - return generateProject(undefined, id) -}) - -export const mockProjects: Project[] = generatedProjectsEntityData.map( - projectData => projectData.entity, -) - -export const mockProjectsEntityData: MockEntityData[] = [ - mockProjectEntityData, - ...generatedProjectsEntityData, -] - export default mockProjectEntityData diff --git a/packages/synapse-react-client/src/mocks/faker/generateFakeEntity.ts b/packages/synapse-react-client/src/mocks/faker/generateFakeEntity.ts index 47ce0543ac..8c8a5e3b2e 100644 --- a/packages/synapse-react-client/src/mocks/faker/generateFakeEntity.ts +++ b/packages/synapse-react-client/src/mocks/faker/generateFakeEntity.ts @@ -1,23 +1,48 @@ import { faker } from '@faker-js/faker' -import { Entity, EntityHeader, Project } from '@sage-bionetworks/synapse-types' +import { + AccessControlList, + Entity, + EntityHeader, + EntityType, + Project, + RestrictionLevel, + UserEntityPermissions, +} from '@sage-bionetworks/synapse-types' import { pickRandomMockUser } from './fakerUtils' import { MockEntityData } from '../entity/MockEntityData' +import { + convertToConcreteEntityType, + isContainerType, +} from '../../utils/functions/EntityTypeUtils' +import mockProjectEntityData from '../entity/mockProject' -export function generateBaseEntity( - entityDataOverrides?: Partial, - idOverride?: number, -): MockEntityData { - const id = idOverride ?? faker.number.int({ min: 10000, max: 99999 }) +export function generateBaseEntity( + overrides: { + id?: number + type?: EntityType + entity?: Omit, 'id' | 'concreteType'> + acl?: Pick + permissions?: Partial + } = {}, +): MockEntityData { + const { + id = faker.number.int({ min: 10000, max: 99999 }), + type = EntityType.FILE, + entity: entityOverrides, + acl: aclOverride, + permissions: permissionsOverride, + } = overrides const entity = { id: `syn${id}`, + name: faker.lorem.words({ min: 1, max: 4 }), createdBy: String(pickRandomMockUser().id), createdOn: faker.date.anytime().toISOString(), etag: faker.string.uuid(), modifiedBy: String(pickRandomMockUser().id), modifiedOn: faker.date.anytime().toISOString(), - name: faker.lorem.words({ min: 1, max: 4 }), - concreteType: 'org.sagebionetworks.repo.model.FileEntity', - ...entityDataOverrides, + concreteType: convertToConcreteEntityType(type), + parentId: mockProjectEntityData.id, + ...(entityOverrides as Partial), } satisfies Entity const header: EntityHeader = { id: entity.id, @@ -27,7 +52,11 @@ export function generateBaseEntity( createdOn: entity.createdOn, modifiedBy: entity.modifiedBy, modifiedOn: entity.modifiedOn, - benefactorId: id, + benefactorId: parseInt( + (aclOverride ? entity.id : mockProjectEntityData.id).substring( + 'syn'.length, + ), + ), isLatestVersion: true, versionLabel: 'versionLabel' in entity ? (entity.versionLabel as string) : undefined, @@ -35,11 +64,81 @@ export function generateBaseEntity( 'versionNumber' in entity ? (entity.versionNumber as number) : undefined, } + const acl: AccessControlList | undefined = aclOverride + ? { + ...aclOverride, + id: entity.id, + etag: faker.string.uuid(), + createdBy: entity.createdBy, + modifiedBy: entity.modifiedBy, + modifiedOn: entity.modifiedOn, + } + : undefined + return { id: entity.id!, - entity: entity, + entity: entity as T, name: entity.name, entityHeader: header, + bundle: { + entity: entity, + entityType: type, + accessControlList: acl, + benefactorAcl: acl ?? mockProjectEntityData.bundle.accessControlList!, + permissions: { + ownerPrincipalId: parseInt(entity.createdBy), + canView: true, + canEdit: true, + canMove: true, + canAddChild: isContainerType(type), + canCertifiedUserEdit: true, + canCertifiedUserAddChild: isContainerType(type), + isCertifiedUser: true, + canChangePermissions: true, + canChangeSettings: true, + canDelete: true, + canDownload: true, + canUpload: true, + canEnableInheritance: true, + canPublicRead: true, + canModerate: true, + isCertificationRequired: true, + isEntityOpenData: false, + ...permissionsOverride, + }, + annotations: { + id: entity.id, + etag: faker.string.uuid(), + annotations: {}, + }, + path: { + // TODO: Properly generate a path given the parent entity + path: [ + { + name: 'Redacted', + id: 'syn4489', + type: 'org.sagebionetworks.repo.model.Folder', + }, + { + name: mockProjectEntityData.name, + id: mockProjectEntityData.id, + type: mockProjectEntityData.entity.concreteType, + }, + { + name: entity.name, + id: entity.id, + type: entity.concreteType, + }, + ], + }, + hasChildren: false, + fileHandles: [], + threadCount: 0, + restrictionInformation: { + restrictionLevel: RestrictionLevel.OPEN, + hasUnmetAccessRequirement: false, + }, + }, } } @@ -47,12 +146,12 @@ export function generateProject( entityDataOverrides?: Partial, idOverride?: number, ): MockEntityData { - return generateBaseEntity( - { + return generateBaseEntity({ + id: idOverride, + type: EntityType.PROJECT, + entity: { name: faker.lorem.words({ min: 1, max: 4 }), - concreteType: 'org.sagebionetworks.repo.model.Project', ...entityDataOverrides, }, - idOverride, - ) as MockEntityData + }) as MockEntityData } diff --git a/packages/synapse-react-client/src/mocks/msw/handlers.ts b/packages/synapse-react-client/src/mocks/msw/handlers.ts index ffac10beb0..cfb16e9655 100644 --- a/packages/synapse-react-client/src/mocks/msw/handlers.ts +++ b/packages/synapse-react-client/src/mocks/msw/handlers.ts @@ -28,6 +28,7 @@ import getAllChallengeHandlers from './handlers/challengeHandlers' import getAllTeamHandlers from './handlers/teamHandlers' import { getAllAccessRequirementAclHandlers } from './handlers/accessRequirementAclHandlers' import { getResetTwoFactorAuthHandlers } from './handlers/resetTwoFactorAuthHandlers' +import { getMessageHandlers } from './handlers/messageHandlers' // Simple utility type that just indicates that the response body could be an error like the Synapse backend may send. export type SynapseApiResponse = T | SynapseError @@ -64,6 +65,7 @@ const getHandlers = (backendOrigin: string) => [ ...getAllTeamHandlers(backendOrigin), ...getAllChallengeHandlers(backendOrigin), ...getResetTwoFactorAuthHandlers(backendOrigin), + ...getMessageHandlers(backendOrigin), ] const handlers = getHandlers(getEndpoint(BackendDestinationEnum.REPO_ENDPOINT)) diff --git a/packages/synapse-react-client/src/mocks/msw/handlers/entityHandlers.ts b/packages/synapse-react-client/src/mocks/msw/handlers/entityHandlers.ts index 5f4aed0e17..f53acf3fac 100644 --- a/packages/synapse-react-client/src/mocks/msw/handlers/entityHandlers.ts +++ b/packages/synapse-react-client/src/mocks/msw/handlers/entityHandlers.ts @@ -10,6 +10,7 @@ import { ENTITY_SCHEMA_BINDING, } from '../../../utils/APIConstants' import { + AccessControlList, Entity, EntityBundle, EntityHeader, @@ -23,12 +24,11 @@ import { VersionableEntity, VersionInfo, } from '@sage-bionetworks/synapse-types' -import mockEntities from '../../entity' +import mockEntities, { mockProjectsEntityData } from '../../entity' import { MOCK_INVALID_PROJECT_NAME } from '../../entity/mockEntity' import { mockSchemaBinding } from '../../mockSchema' import { SynapseApiResponse } from '../handlers' import { uniqueId } from 'lodash-es' -import { mockProjectsEntityData } from '../../entity/mockProject' import { mockUploadDestinations } from '../../mock_upload_destination' import { normalizeSynPrefix } from '../../../utils/functions/EntityTypeUtils' import { MockEntityData } from '../../entity/MockEntityData' @@ -324,4 +324,85 @@ export const getEntityHandlers = (backendOrigin: string) => [ } return res(ctx.status(200), ctx.json(response)) }), + + rest.post( + `${backendOrigin}${ENTITY_ID(':entityId')}/acl`, + async (req, res, ctx) => { + const entityData = getMatchingMockEntity(req.params.entityId as string) + let status: number + let response: SynapseApiResponse + if (!entityData) { + status = 404 + response = { + reason: `Mock Service worker could not find a mock entity bundle with ID ${req.params.entityId}`, + } + } else if (entityData.bundle?.accessControlList) { + status = 403 + response = { + reason: 'Resource already has an ACL.', + } + } else { + response = await req.json() + status = 201 + } + + return res(ctx.status(status), ctx.json(response)) + }, + ), + + rest.put( + `${backendOrigin}${ENTITY_ID(':entityId')}/acl`, + async (req, res, ctx) => { + const entityData = getMatchingMockEntity(req.params.entityId as string) + + let status: number + let response: SynapseApiResponse + + if (!entityData) { + status = 404 + response = { + reason: `Mock Service worker could not find a mock entity bundle with ID ${req.params.entityId}`, + } + } else if (!entityData?.bundle?.accessControlList) { + response = { + reason: + 'Cannot update ACL for a resource which inherits its permissions.', + } + status = 403 + } else { + response = await req.json() + status = 200 + } + + return res(ctx.status(status), ctx.json(response)) + }, + ), + + rest.delete( + `${backendOrigin}${ENTITY_ID(':entityId')}/acl`, + async (req, res, ctx) => { + const entityData = getMatchingMockEntity(req.params.entityId as string) + + let status: number + let response: SynapseApiResponse<''> + + if (!entityData) { + status = 404 + response = { + reason: `Mock Service worker could not find a mock entity bundle with ID ${req.params.entityId}`, + } + } else if (!entityData?.bundle?.accessControlList) { + response = { + reason: + 'Cannot delete ACL for a resource which inherits its permissions.', + } + status = 403 + } else { + response = '' + status = 200 + } + + return res(ctx.status(status), ctx.json(response)) + }, + ), ] diff --git a/packages/synapse-react-client/src/mocks/msw/handlers/fileHandlers.ts b/packages/synapse-react-client/src/mocks/msw/handlers/fileHandlers.ts index bc9e32d6bc..f683fa7263 100644 --- a/packages/synapse-react-client/src/mocks/msw/handlers/fileHandlers.ts +++ b/packages/synapse-react-client/src/mocks/msw/handlers/fileHandlers.ts @@ -1,10 +1,13 @@ import { BatchFileRequest, BatchFileResult, + MultipartUploadStatus, } from '@sage-bionetworks/synapse-types' import { rest } from 'msw' -import { FILE_HANDLE_BATCH } from '../../../utils/APIConstants' -import { mockFileHandles } from '../../mock_file_handle' +import { FILE, FILE_HANDLE_BATCH } from '../../../utils/APIConstants' +import { MOCK_FILE_HANDLE_ID, mockFileHandles } from '../../mock_file_handle' +import { SynapseApiResponse } from '../handlers' +import { MOCK_USER_ID } from '../../user/mock_user_profile' export function getFileHandlers(backendOrigin: string) { return [ @@ -29,5 +32,38 @@ export function getFileHandlers(backendOrigin: string) { return res(ctx.status(201), ctx.json(response)) }), + + rest.post( + `${backendOrigin}${FILE}/file/multipart`, + async (req, res, ctx) => { + const response: SynapseApiResponse = { + state: 'COMPLETED', + resultFileHandleId: MOCK_FILE_HANDLE_ID, + uploadId: 'mockUploadId', + startedBy: String(MOCK_USER_ID), + startedOn: new Date().toISOString(), + updatedOn: new Date().toISOString(), + partsState: '1', + } + + return res(ctx.status(201), ctx.json(response)) + }, + ), + rest.put( + `${backendOrigin}${FILE}/file/multipart/:id/complete`, + async (req, res, ctx) => { + const response: SynapseApiResponse = { + state: 'COMPLETED', + resultFileHandleId: MOCK_FILE_HANDLE_ID, + uploadId: 'mockUploadId', + startedBy: String(MOCK_USER_ID), + startedOn: new Date().toISOString(), + updatedOn: new Date().toISOString(), + partsState: '1', + } + + return res(ctx.status(201), ctx.json(response)) + }, + ), ] } diff --git a/packages/synapse-react-client/src/mocks/msw/handlers/messageHandlers.ts b/packages/synapse-react-client/src/mocks/msw/handlers/messageHandlers.ts new file mode 100644 index 0000000000..cc51357e7c --- /dev/null +++ b/packages/synapse-react-client/src/mocks/msw/handlers/messageHandlers.ts @@ -0,0 +1,13 @@ +import { rest } from 'msw' +import { REPO } from '../../../utils/APIConstants' +import { MessageToUser } from '@sage-bionetworks/synapse-types' + +export function getMessageHandlers(backendOrigin: string) { + return [ + rest.post(`${backendOrigin}${REPO}/message`, async (req, res, ctx) => { + const request: MessageToUser = await req.json() + + return res(ctx.status(201), ctx.json(request)) + }), + ] +} diff --git a/packages/synapse-react-client/src/synapse-client/SynapseClient.ts b/packages/synapse-react-client/src/synapse-client/SynapseClient.ts index d8c95455a0..40db92f161 100644 --- a/packages/synapse-react-client/src/synapse-client/SynapseClient.ts +++ b/packages/synapse-react-client/src/synapse-client/SynapseClient.ts @@ -1162,6 +1162,30 @@ export const getEntityHeader = async ( return batchResult.results[0] } +/** + * Create a new Access Control List (ACL), overriding inheritance. + * + * By default, Entities such as FileEntity and Folder inherit their permission from their containing Project. For such + * Entities the Project is the Entity's 'benefactor'. This permission inheritance can be overridden by creating an ACL + * for the Entity. When this occurs the Entity becomes its own benefactor and all permission are determined by its own ACL. + * + * If the ACL of an Entity is deleted, then its benefactor will automatically be set to its parent's benefactor. + * + * Note: The caller must be granted ACCESS_TYPE.CHANGE_PERMISSIONS on the Entity to call this method. + * https://rest-docs.synapse.org/rest/POST/entity/id/acl.html + */ +export const createEntityACL = ( + acl: AccessControlList, + accessToken: string | undefined = undefined, +): Promise => { + return doPost( + ENTITY_ACL(acl.id), + acl, + accessToken, + BackendDestinationEnum.REPO_ENDPOINT, + ) +} + /** * Update an Entity's ACL * Note: The caller must be granted ACCESS_TYPE.CHANGE_PERMISSIONS on the Entity to call this method. @@ -1179,6 +1203,30 @@ export const updateEntityACL = ( ) } +/** + * Delete the Access Control List (ACL) for a given Entity. + * + * By default, Entities such as FileEntity and Folder inherit their permission from their containing Project. For such + * Entities the Project is the Entity's 'benefactor'. This permission inheritance can be overridden by creating an ACL + * for the Entity. When this occurs the Entity becomes its own benefactor and all permission are determined by its own ACL. + * + * If the ACL of an Entity is deleted, then its benefactor will automatically be set to its parent's benefactor. The ACL + * for a Project cannot be deleted. + * + * Note: The caller must be granted ACCESS_TYPE.CHANGE_PERMISSIONS on the Entity to call this method. + * https://rest-docs.synapse.org/rest/PUT/entity/id/acl.html + */ +export const deleteEntityACL = ( + id: string, + accessToken: string | undefined = undefined, +): Promise => { + return doDelete( + ENTITY_ACL(id), + accessToken, + BackendDestinationEnum.REPO_ENDPOINT, + ) +} + export const updateEntity = ( entity: T, accessToken: string | undefined = undefined, diff --git a/packages/synapse-react-client/src/synapse-queries/entity/useEntity.ts b/packages/synapse-react-client/src/synapse-queries/entity/useEntity.ts index 678ca67f04..0bff3a6a04 100644 --- a/packages/synapse-react-client/src/synapse-queries/entity/useEntity.ts +++ b/packages/synapse-react-client/src/synapse-queries/entity/useEntity.ts @@ -6,6 +6,7 @@ import { omit, pick } from 'lodash-es' import { useMemo } from 'react' import { InfiniteData, + QueryClient, QueryKey, useInfiniteQuery, UseInfiniteQueryOptions, @@ -27,6 +28,7 @@ import { AccessControlList, ColumnModel, Entity, + EntityBundle, EntityHeader, EntityId, EntityJson, @@ -40,6 +42,8 @@ import { import { invalidateAllQueriesForEntity } from '../QueryFilterUtils' import { SetOptional } from 'type-fest' import { getNextPageParamForPaginatedResults } from '../InfiniteQueryUtils' +import { KeyFactory } from '../KeyFactory' +import { useGetEntityBundleQueryOptions } from './useEntityBundle' export function useGetEntity( entityId: string, @@ -385,6 +389,51 @@ export function useGetEntityPermissions( }) } +const onMutateEntityAclSuccess = async ( + entityId: string, + updatedACL: AccessControlList | null, + queryClient: QueryClient, + keyFactory: KeyFactory, +) => { + const entityAclQueryKey = keyFactory.getEntityACLQueryKey(entityId) + if (updatedACL) { + queryClient.setQueryData(entityAclQueryKey, updatedACL) + } + await invalidateAllQueriesForEntity( + queryClient, + keyFactory, + entityId, + entityAclQueryKey, + ) +} + +export function useCreateEntityACL( + options?: Partial< + UseMutationOptions + >, +) { + const queryClient = useQueryClient() + const { accessToken, keyFactory } = useSynapseContext() + + return useMutation({ + ...options, + mutationFn: (acl: AccessControlList) => + SynapseClient.createEntityACL(acl, accessToken), + onSuccess: async (updatedACL: AccessControlList, variables, ctx) => { + await onMutateEntityAclSuccess( + updatedACL.id, + updatedACL, + queryClient, + keyFactory, + ) + + if (options?.onSuccess) { + await options.onSuccess(updatedACL, variables, ctx) + } + }, + }) +} + export function useUpdateEntityACL( options?: Partial< UseMutationOptions @@ -398,13 +447,11 @@ export function useUpdateEntityACL( mutationFn: (acl: AccessControlList) => SynapseClient.updateEntityACL(acl, accessToken), onSuccess: async (updatedACL: AccessControlList, variables, ctx) => { - const entityAclQueryKey = keyFactory.getEntityACLQueryKey(updatedACL.id) - queryClient.setQueryData(entityAclQueryKey, updatedACL) - await invalidateAllQueriesForEntity( + await onMutateEntityAclSuccess( + updatedACL.id, + updatedACL, queryClient, keyFactory, - updatedACL.id, - entityAclQueryKey, ) if (options?.onSuccess) { @@ -414,6 +461,70 @@ export function useUpdateEntityACL( }) } +export function useDeleteEntityACL( + options?: Partial>, +) { + const queryClient = useQueryClient() + const { accessToken, keyFactory } = useSynapseContext() + + return useMutation({ + ...options, + mutationFn: (entityId: string) => + SynapseClient.deleteEntityACL(entityId, accessToken), + onSuccess: async (result: void, entityId, ctx) => { + await onMutateEntityAclSuccess(entityId, null, queryClient, keyFactory) + + if (options?.onSuccess) { + await options.onSuccess(result, entityId, ctx) + } + }, + }) +} + +function useGetEntityBenefactorACLQueryOptions( + entityId: string, +): UseQueryOptions< + EntityBundle<{ includeBenefactorACL: true }>, + SynapseClientError, + AccessControlList +> { + const opts = useGetEntityBundleQueryOptions<{ includeBenefactorACL: true }>( + entityId, + undefined, + { + includeBenefactorACL: true, + }, + ) + + return { + ...opts, + select: data => data.benefactorAcl, + } +} + +/** + * Retrieve the ACL of an entity. This call will succeed even for entities where the caller + * does not have READ permission. + * @param entityId + * @param options + */ +export function useGetEntityBenefactorACL( + entityId: string, + options?: Partial< + UseQueryOptions< + EntityBundle<{ includeBenefactorACL: true }>, + SynapseClientError, + AccessControlList + > + >, +) { + const queryOptions = useGetEntityBenefactorACLQueryOptions(entityId) + return useQuery({ + ...options, + ...queryOptions, + }) +} + type UpdateTableMutationRequest = { entityId: string originalColumnModels: ColumnModel[] diff --git a/packages/synapse-react-client/src/synapse-queries/entity/useEntityBundle.ts b/packages/synapse-react-client/src/synapse-queries/entity/useEntityBundle.ts index 503379a410..d871cf7910 100644 --- a/packages/synapse-react-client/src/synapse-queries/entity/useEntityBundle.ts +++ b/packages/synapse-react-client/src/synapse-queries/entity/useEntityBundle.ts @@ -1,4 +1,8 @@ -import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { + useQuery, + UseQueryOptions, + useSuspenseQuery, +} from '@tanstack/react-query' import SynapseClient from '../../synapse-client' import { SynapseClientError, useSynapseContext } from '../../utils' import { @@ -7,17 +11,15 @@ import { EntityBundleRequest, } from '@sage-bionetworks/synapse-types' -export function useGetEntityBundle< +export function useGetEntityBundleQueryOptions< T extends EntityBundleRequest = typeof ALL_ENTITY_BUNDLE_FIELDS, >( entityId: string, version?: number, bundleRequest: T = ALL_ENTITY_BUNDLE_FIELDS as T, - options?: Partial, SynapseClientError>>, ) { const { accessToken, keyFactory } = useSynapseContext() - return useQuery({ - ...options, + return { queryKey: keyFactory.getEntityBundleQueryKey( entityId, version, @@ -31,6 +33,47 @@ export function useGetEntityBundle< version, accessToken, ), + } +} + +export function useGetEntityBundle< + T extends EntityBundleRequest = typeof ALL_ENTITY_BUNDLE_FIELDS, + TSelect = EntityBundle, +>( + entityId: string, + version?: number, + bundleRequest: T = ALL_ENTITY_BUNDLE_FIELDS as T, + options?: Partial< + UseQueryOptions, SynapseClientError, TSelect> + >, +) { + const queryOptions = useGetEntityBundleQueryOptions( + entityId, + version, + bundleRequest, + ) + return useQuery, SynapseClientError, TSelect>({ + ...options, + ...queryOptions, + }) +} + +export function useSuspenseGetEntityBundle< + T extends EntityBundleRequest = typeof ALL_ENTITY_BUNDLE_FIELDS, +>( + entityId: string, + version?: number, + bundleRequest: T = ALL_ENTITY_BUNDLE_FIELDS as T, + options?: Partial, SynapseClientError>>, +) { + const queryOptions = useGetEntityBundleQueryOptions( + entityId, + version, + bundleRequest, + ) + return useSuspenseQuery({ + ...options, + ...queryOptions, }) } diff --git a/packages/synapse-react-client/src/synapse-queries/user/useUserBundle.ts b/packages/synapse-react-client/src/synapse-queries/user/useUserBundle.ts index 4fb8bae1ad..b6d82ebe0a 100644 --- a/packages/synapse-react-client/src/synapse-queries/user/useUserBundle.ts +++ b/packages/synapse-react-client/src/synapse-queries/user/useUserBundle.ts @@ -1,4 +1,8 @@ -import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { + useQuery, + UseQueryOptions, + useSuspenseQuery, +} from '@tanstack/react-query' import SynapseClient from '../../synapse-client' import { SynapseClientError } from '../../utils/SynapseClientError' import { useSynapseContext } from '../../utils/context/SynapseContext' @@ -32,16 +36,33 @@ export function useGetNotificationEmail( }) } -export function useGetCurrentUserProfile( - options?: Partial>, -) { +function useGetCurrentUserProfileQueryOptions() { const { accessToken, keyFactory } = useSynapseContext() const queryKey = keyFactory.getCurrentUserProfileQueryKey() + return { + queryKey: queryKey, + queryFn: () => SynapseClient.getUserProfile(accessToken), + } +} +export function useGetCurrentUserProfile( + options?: Partial>, +) { + const queryOptions = useGetCurrentUserProfileQueryOptions() return useQuery({ ...options, - queryKey: queryKey, - queryFn: () => SynapseClient.getUserProfile(accessToken), + ...queryOptions, + }) +} + +export function useSuspenseGetCurrentUserProfile( + options?: Partial>, +) { + const queryOptions = useGetCurrentUserProfileQueryOptions() + + return useSuspenseQuery({ + ...options, + ...queryOptions, }) } diff --git a/packages/synapse-react-client/src/utils/functions/DisplayUtils.ts b/packages/synapse-react-client/src/utils/functions/DisplayUtils.ts new file mode 100644 index 0000000000..99220699f9 --- /dev/null +++ b/packages/synapse-react-client/src/utils/functions/DisplayUtils.ts @@ -0,0 +1,42 @@ +import { UserProfile } from '@sage-bionetworks/synapse-types' + +function getUserName(userName: string, inParens: boolean): string { + if (userName != null) { + if (inParens) { + // if the name is filled in, then put the username in parens + return ` (${userName})` + } + return userName + } + + return '' +} + +export function getDisplayName( + firstName: string, + lastName: string, + userName: string, +): string { + let displayName = '' + let hasDisplayName = false + if (firstName != null && firstName.length > 0) { + displayName += firstName.trim() + hasDisplayName = true + } + if (lastName != null && lastName.length > 0) { + displayName += ' ' + lastName.trim() + hasDisplayName = true + } + + displayName += getUserName(userName, hasDisplayName) + + return displayName +} + +export function getDisplayNameFromProfile(userProfile: UserProfile) { + return getDisplayName( + userProfile.firstName, + userProfile.lastName, + userProfile.userName, + ) +} diff --git a/packages/synapse-react-client/src/utils/functions/IsEqualTreatArraysAsSets.test.ts b/packages/synapse-react-client/src/utils/functions/IsEqualTreatArraysAsSets.test.ts new file mode 100644 index 0000000000..1f93e0c416 --- /dev/null +++ b/packages/synapse-react-client/src/utils/functions/IsEqualTreatArraysAsSets.test.ts @@ -0,0 +1,30 @@ +import isEqualTreatArraysAsSets from './isEqualTreatArraysAsSets' + +describe('isEqualTreatArraysAsSets', () => { + test('arrays are compared without considering order', () => { + expect(isEqualTreatArraysAsSets(['a', 'b'], ['b', 'a'])).toBe(true) + expect( + isEqualTreatArraysAsSets({ foo: ['a', 'b'] }, { foo: ['b', 'a'] }), + ).toBe(true) + expect( + isEqualTreatArraysAsSets( + [{ foo: ['a', 'b'] }, { foo: ['c', 'd'] }], + [{ foo: ['d', 'c'] }, { foo: ['b', 'a'] }], + ), + ).toBe(true) + + expect( + isEqualTreatArraysAsSets( + [{ foo: ['a', 'b'] }, { foo: ['c', 'd'] }], + [{ foo: ['a', 'c'] }, { foo: ['b', 'd'] }], + ), + ).toBe(false) + + expect(isEqualTreatArraysAsSets(['a', 'a', 'b'], ['b', 'a', 'a'])).toBe( + true, + ) + expect(isEqualTreatArraysAsSets(['a', 'a', 'b'], ['b', 'b', 'a'])).toBe( + true, + ) + }) +}) diff --git a/packages/synapse-react-client/src/utils/functions/isEqualTreatArraysAsSets.ts b/packages/synapse-react-client/src/utils/functions/isEqualTreatArraysAsSets.ts new file mode 100644 index 0000000000..aba7dfdef5 --- /dev/null +++ b/packages/synapse-react-client/src/utils/functions/isEqualTreatArraysAsSets.ts @@ -0,0 +1,23 @@ +import { cloneDeep, isArray, isEqualWith } from 'lodash-es' + +/** + * Compares two objects for equality, ignoring the order of elements in arrays. + * @param a + * @param b + */ +export default function isEqualTreatArraysAsSets(a: unknown, b: unknown) { + a = cloneDeep(a) + b = cloneDeep(b) + return isEqualWith(a, b, (a: unknown, b: unknown): boolean | undefined => { + if (isArray(a) && isArray(b)) { + return ( + a.length === b.length && + a.every(aItem => + b.some(bItem => isEqualTreatArraysAsSets(aItem, bItem)), + ) + ) + } + // Fall back to default comparison + return undefined + }) +} diff --git a/packages/synapse-react-client/test/containers/entity_finder/tree/EntityTree.integration.test.tsx b/packages/synapse-react-client/test/containers/entity_finder/tree/EntityTree.integration.test.tsx index f60d029665..b3989a4cea 100644 --- a/packages/synapse-react-client/test/containers/entity_finder/tree/EntityTree.integration.test.tsx +++ b/packages/synapse-react-client/test/containers/entity_finder/tree/EntityTree.integration.test.tsx @@ -13,7 +13,6 @@ import { EntityPath, EntityType, PaginatedResults, - Project, ProjectHeader, ProjectHeaderList, } from '@sage-bionetworks/synapse-types' @@ -30,10 +29,7 @@ import { getEndpoint, } from '../../../../src/utils/functions/getEndpoint' import { - ENTITY, - ENTITY_HEADER_BY_ID, ENTITY_HEADERS, - ENTITY_ID, ENTITY_PATH, FAVORITES, PROJECTS, @@ -42,10 +38,9 @@ import { createWrapper } from '../../../../src/testutils/TestingLibraryUtils' import mockFileEntityData from '../../../../src/mocks/entity/mockFileEntity' import mockFileEntity from '../../../../src/mocks/entity/mockFileEntity' import * as ToastMessageModule from '../../../../src/components/ToastMessage/ToastMessage' -import mockProject, { - mockProjects, -} from '../../../../src/mocks/entity/mockProject' +import mockProject from '../../../../src/mocks/entity/mockProject' import { mockFolderEntity } from '../../../../src/mocks/entity/mockEntity' +import { mockProjects } from '../../../../src/mocks/entity' const VIRTUALIZED_TREE_TEST_ID = 'VirtualizedTreeComponent' diff --git a/packages/synapse-types/src/EntityBundle.ts b/packages/synapse-types/src/EntityBundle.ts index 7e2a638c28..57c71c73d1 100644 --- a/packages/synapse-types/src/EntityBundle.ts +++ b/packages/synapse-types/src/EntityBundle.ts @@ -21,12 +21,12 @@ type _EntityBundle = { permissions: UserEntityPermissions path: EntityPath hasChildren: boolean - accessControlList: AccessControlList fileHandles: FileHandle[] benefactorAcl: AccessControlList threadCount: number restrictionInformation: RestrictionInformationResponse // The following fields may be undefined even if they are requested + accessControlList?: AccessControlList tableBundle?: TableBundle rootWikiId?: string doiAssociation?: DoiAssociation From 00b0e2efc3de0b088d8973eef174763f028d2221 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Thu, 25 Jul 2024 16:00:32 -0400 Subject: [PATCH 02/14] SWC-6800 - minor refactoring --- .../components/AclEditor/AclEditor.test.tsx | 16 +++++-- .../src/components/AclEditor/AclEditor.tsx | 25 +++++++---- .../AclEditor/AclEditorTestUtils.ts | 22 +++++++--- .../src/components/AclEditor/useUpdateAcl.ts | 44 +++++++++++++++---- .../EntityAclEditor.stories.tsx | 38 ++++++++++++---- .../EntityAclEditor/EntityAclEditor.test.tsx | 33 ++++++++++---- .../EntityAclEditor/EntityAclEditor.tsx | 2 +- .../SynapseTableCell.integration.test.tsx | 3 +- .../src/components/TeamBadge.tsx | 7 ++- 9 files changed, 143 insertions(+), 47 deletions(-) diff --git a/packages/synapse-react-client/src/components/AclEditor/AclEditor.test.tsx b/packages/synapse-react-client/src/components/AclEditor/AclEditor.test.tsx index 178bbe7e92..659a436f00 100644 --- a/packages/synapse-react-client/src/components/AclEditor/AclEditor.test.tsx +++ b/packages/synapse-react-client/src/components/AclEditor/AclEditor.test.tsx @@ -3,7 +3,13 @@ import userEvent from '@testing-library/user-event' import React from 'react' import { server } from '../../mocks/msw/server' import { createWrapper } from '../../testutils/TestingLibraryUtils' -import { AclEditor, AclEditorProps } from './AclEditor' +import { + AclEditor, + AclEditorProps, + ADD_PUBLIC_PRINCIPALS_BUTTON_TEXT, + NOTIFY_NEW_ACL_USERS_CHECKBOX_LABEL, + REMOVE_PUBLIC_PRINCIPALS_BUTTON_TEXT, +} from './AclEditor' import { MOCK_TEAM_ID, MOCK_TEAM_ID_2, @@ -314,7 +320,7 @@ describe('AclEditor', () => { onNotifyCheckboxChange: onCheckboxChange, }) - const checkbox = screen.getByLabelText('Notify people via email') + const checkbox = screen.getByLabelText(NOTIFY_NEW_ACL_USERS_CHECKBOX_LABEL) expect(checkbox).toHaveAttribute('value', 'true') await user.click(checkbox) @@ -327,7 +333,9 @@ describe('AclEditor', () => { showAddRemovePublicButton: true, }) - const makePublicButton = screen.getByRole('button', { name: 'Make Public' }) + const makePublicButton = screen.getByRole('button', { + name: ADD_PUBLIC_PRINCIPALS_BUTTON_TEXT, + }) await user.click(makePublicButton) expect(mockAddResourceAccessItem).toHaveBeenCalledWith( @@ -354,7 +362,7 @@ describe('AclEditor', () => { }) const removePublicAccessButton = screen.getByRole('button', { - name: 'Remove Public Access', + name: REMOVE_PUBLIC_PRINCIPALS_BUTTON_TEXT, }) await user.click(removePublicAccessButton) diff --git a/packages/synapse-react-client/src/components/AclEditor/AclEditor.tsx b/packages/synapse-react-client/src/components/AclEditor/AclEditor.tsx index 62bd69c543..89b4dab9b3 100644 --- a/packages/synapse-react-client/src/components/AclEditor/AclEditor.tsx +++ b/packages/synapse-react-client/src/components/AclEditor/AclEditor.tsx @@ -24,6 +24,10 @@ import IconSvg from '../IconSvg' import { noop } from 'lodash-es' import { AclEditorSkeleton } from './AclEditorSkeleton' +export const ADD_PRINCIPAL_TO_ACL_COMBOBOX_LABEL = 'Add a user or team' +export const ADD_PUBLIC_PRINCIPALS_BUTTON_TEXT = 'Make Public' +export const REMOVE_PUBLIC_PRINCIPALS_BUTTON_TEXT = 'Remove Public Access' + export type AclEditorProps = { resourceAccessList: ResourceAccess[] availablePermissionLevels: PermissionLevel[] @@ -36,7 +40,7 @@ export type AclEditorProps = { canRemoveEntry?: boolean | ((resourceAccess: ResourceAccess) => boolean) isLoading?: boolean emptyText: React.ReactNode - onAddPrincipalToAcl: (id: string) => void + onAddPrincipalToAcl: (id: number) => void updateResourceAccessItem: ReturnType< typeof useUpdateAcl >['updateResourceAccessItem'] @@ -59,6 +63,8 @@ export type AclEditorProps = { ) => string | undefined } +export const NOTIFY_NEW_ACL_USERS_CHECKBOX_LABEL = 'Notify people via email' + export function AclEditor(props: AclEditorProps) { const { resourceAccessList, @@ -91,7 +97,7 @@ export function AclEditor(props: AclEditorProps) { resourceAccessListCurrentlyIncludesPublic ? { startIcon: , - children: 'Remove Public Access', + children: REMOVE_PUBLIC_PRINCIPALS_BUTTON_TEXT, onClick: () => { PUBLIC_PRINCIPAL_IDS.forEach(publicId => { removeResourceAccessItem(publicId) @@ -100,10 +106,10 @@ export function AclEditor(props: AclEditorProps) { } : { startIcon: , - children: 'Make Public', + children: ADD_PUBLIC_PRINCIPALS_BUTTON_TEXT, onClick: () => { - onAddPrincipalToAcl(String(PUBLIC_PRINCIPAL_ID)) - onAddPrincipalToAcl(String(AUTHENTICATED_PRINCIPAL_ID)) + onAddPrincipalToAcl(PUBLIC_PRINCIPAL_ID) + onAddPrincipalToAcl(AUTHENTICATED_PRINCIPAL_ID) }, } @@ -179,15 +185,16 @@ export function AclEditor(props: AclEditorProps) { variant="smallText2" htmlFor="reviewer-search" > - Add a user or team + {ADD_PRINCIPAL_TO_ACL_COMBOBOX_LABEL} { - if (id) { - onAddPrincipalToAcl(id) + const parsedId = parseInt(id || '') + if (parsedId) { + onAddPrincipalToAcl(parsedId) } }} /> @@ -220,7 +227,7 @@ export function AclEditor(props: AclEditorProps) { } label={ - Notify people via email + {NOTIFY_NEW_ACL_USERS_CHECKBOX_LABEL} } /> diff --git a/packages/synapse-react-client/src/components/AclEditor/AclEditorTestUtils.ts b/packages/synapse-react-client/src/components/AclEditor/AclEditorTestUtils.ts index 46d643d77d..ca09af26c2 100644 --- a/packages/synapse-react-client/src/components/AclEditor/AclEditorTestUtils.ts +++ b/packages/synapse-react-client/src/components/AclEditor/AclEditorTestUtils.ts @@ -1,6 +1,14 @@ import userEvent from '@testing-library/user-event' import { screen, waitFor, within } from '@testing-library/react' import { REMOVE_BUTTON_LABEL } from './ResourceAccessItem' +import { + AUTHENTICATED_GROUP_DISPLAY_TEXT, + PUBLIC_GROUP_DISPLAY_TEXT, +} from '../TeamBadge' +import { + ADD_PRINCIPAL_TO_ACL_COMBOBOX_LABEL, + ADD_PUBLIC_PRINCIPALS_BUTTON_TEXT, +} from './AclEditor' /** * Find a row in the ACL editor that contains the specified principal name. @@ -18,7 +26,7 @@ export function queryForRowWithPrincipalName( /** * Verify that a row in the ACL editor contains the expected principal name and access type. - * @param row + * @param rows * @param principalName * @param accessTypeLabel */ @@ -35,7 +43,7 @@ export async function confirmItemViaQuery( }) } catch (e) { screen.debug() - throw new Error(`Principal ${principalName} not found in ACL`, e) + throw new Error(`Principal ${principalName} not found in ACL`, { cause: e }) } const editorCombobox = within(row!).queryByRole('combobox') @@ -118,7 +126,7 @@ export async function updatePermissionLevel( export function queryForAddUserCombobox() { return screen.queryByRole('combobox', { - name: 'Add a user or team', + name: ADD_PRINCIPAL_TO_ACL_COMBOBOX_LABEL, }) } @@ -150,7 +158,9 @@ export async function addUserToAcl( export async function addPublicToAcl( user: ReturnType<(typeof userEvent)['setup']>, ) { - const makePublicButton = screen.getByRole('button', { name: 'Make Public' }) + const makePublicButton = screen.getByRole('button', { + name: ADD_PUBLIC_PRINCIPALS_BUTTON_TEXT, + }) await user.click(makePublicButton) let rows: HTMLElement[] = [] @@ -158,10 +168,10 @@ export async function addPublicToAcl( let authenticatedUsersRow: HTMLElement | undefined = undefined await waitFor(() => { rows = screen.getAllByRole('row') - publicRow = queryForRowWithPrincipalName(rows, 'Anyone on the web') + publicRow = queryForRowWithPrincipalName(rows, PUBLIC_GROUP_DISPLAY_TEXT) authenticatedUsersRow = queryForRowWithPrincipalName( rows, - 'All registered Synapse users', + AUTHENTICATED_GROUP_DISPLAY_TEXT, ) expect(publicRow).toBeInTheDocument() expect(authenticatedUsersRow).toBeInTheDocument() diff --git a/packages/synapse-react-client/src/components/AclEditor/useUpdateAcl.ts b/packages/synapse-react-client/src/components/AclEditor/useUpdateAcl.ts index c3958f33ea..b52c79b9fe 100644 --- a/packages/synapse-react-client/src/components/AclEditor/useUpdateAcl.ts +++ b/packages/synapse-react-client/src/components/AclEditor/useUpdateAcl.ts @@ -1,5 +1,11 @@ import { ACCESS_TYPE, ResourceAccess } from '@sage-bionetworks/synapse-types' -import { useCallback, useEffect, useState } from 'react' +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useState, +} from 'react' import { noop } from 'lodash-es' import useSortResourceAccessList from './useSortResourceAccessList' @@ -11,7 +17,30 @@ type UseUpdateAclOptions = { onError?: (e: string) => void } -export default function useUpdateAcl(options: UseUpdateAclOptions = {}) { +type UseUpdateAclReturn = { + /** The ResourceAccess list that is being updated. It will automatically be sorted unless the `add`, `update` or `remove` functions are called. */ + resourceAccessList: ResourceAccess[] + /** Set the ResourceAccess list. Does not prevent re-sorting */ + setResourceAccessList: Dispatch> + /** Adds a principal to the list with the provided accessTypes */ + addResourceAccessItem: ( + principalId: number, + accessTypes: ACCESS_TYPE[], + ) => void + /** Updates the principal in the list with the provided accessTypes */ + updateResourceAccessItem: ( + principalId: number, + accessType: ACCESS_TYPE[], + ) => void + /** Removes the principal from the list */ + removeResourceAccessItem: (principalId: number) => void + /** Resets the dirty state of the form, which will immediately trigger sorting the resourceAccessList */ + resetDirtyState: () => void +} + +export default function useUpdateAcl( + options: UseUpdateAclOptions = {}, +): UseUpdateAclReturn { const { onChange = noop, onError = noop } = options const [isDirty, setIsDirty] = useState(false) const [resourceAccessList, setResourceAccessList] = useState< @@ -41,19 +70,18 @@ export default function useUpdateAcl(options: UseUpdateAclOptions = {}) { }, [resourceAccessList]) const addResourceAccessItem = useCallback( - (newReviewerId: string | null, accessTypes: ACCESS_TYPE[]) => { + (principalId: number, accessTypes: ACCESS_TYPE[]) => { setIsDirty(true) - if (newReviewerId) { + if (principalId) { setResourceAccessList(resourceAccessList => { const alreadyReviewer = resourceAccessList.some( - resourceAccess => - resourceAccess.principalId === Number(newReviewerId), + resourceAccess => resourceAccess.principalId === principalId, ) if (alreadyReviewer) { onError(PRINCIPAL_ALREADY_ADDED_ERROR_MESSAGE) } else { const newResourceAccess: ResourceAccess = { - principalId: Number(newReviewerId), + principalId: principalId, accessType: accessTypes, } return [...resourceAccessList, newResourceAccess] @@ -93,7 +121,7 @@ export default function useUpdateAcl(options: UseUpdateAclOptions = {}) { }, []) return { - resourceAccessList: resourceAccessList, + resourceAccessList, setResourceAccessList, addResourceAccessItem, updateResourceAccessItem, diff --git a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.stories.tsx b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.stories.tsx index 7bc650aba1..3d02bda9a3 100644 --- a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.stories.tsx +++ b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.stories.tsx @@ -4,7 +4,13 @@ import mockFileEntity from '../../mocks/entity/mockFileEntity' import EntityAclEditorModal, { EntityAclEditorModalProps, } from './EntityAclEditorModal' -import { mockFileEntityCurrentUserCannotEdit } from '../../mocks/entity/mockFileEntityACLVariants' +import { + mockFileEntityCurrentUserCannotEdit, + mockFileEntityWithLocalSharingSettingsData, + mockFileOpenDataWithNoPublicRead, + mockFileOpenDataWithPublicRead, + mockFilePublicReadNoOpenData, +} from '../../mocks/entity/mockFileEntityACLVariants' const meta: Meta = { title: 'Synapse/Entity ACL Editor', @@ -43,23 +49,39 @@ export const InheritedFile: Story = { }, } -export const ProdCustomACL: Story = { +export const LocalSharingSettings: Story = { args: { - entityId: 'syn61833062', + entityId: mockFileEntityWithLocalSharingSettingsData.id, }, parameters: { - stack: 'production', + stack: 'mock', }, } -export const TestUserCannotReadParent: Story = { +export const OpenDataPublicCanRead: Story = { args: { - open: true, - entityId: 'syn61843528', + entityId: mockFileOpenDataWithPublicRead.id, + }, + parameters: { + stack: 'mock', + }, +} + +export const OpenDataNoPublicRead: Story = { + args: { + entityId: mockFileOpenDataWithNoPublicRead.id, + }, + parameters: { + stack: 'mock', }, +} +export const NoOpenDataWithPublicRead: Story = { + args: { + entityId: mockFilePublicReadNoOpenData.id, + }, parameters: { - stack: 'production', + stack: 'mock', }, } diff --git a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.test.tsx b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.test.tsx index 27a46452c5..081db01360 100644 --- a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.test.tsx +++ b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.test.tsx @@ -42,6 +42,15 @@ import { CREATE_LOCAL_SHARING_SETTINGS, DELETE_LOCAL_SHARING_SETTINGS, } from './CreateOrDeleteLocalSharingSettingsButton' +import { + AUTHENTICATED_GROUP_DISPLAY_TEXT, + PUBLIC_GROUP_DISPLAY_TEXT, +} from '../TeamBadge' +import { + ADD_PUBLIC_PRINCIPALS_BUTTON_TEXT, + NOTIFY_NEW_ACL_USERS_CHECKBOX_LABEL, + REMOVE_PUBLIC_PRINCIPALS_BUTTON_TEXT, +} from '../AclEditor/AclEditor' const onUpdateSuccess = jest.fn() const onCanSaveChange = jest.fn() @@ -75,7 +84,9 @@ async function setUp( } async function checkNotifyUsers(user: ReturnType<(typeof userEvent)['setup']>) { - const notifyCheckbox = screen.getByLabelText('Notify people via email') + const notifyCheckbox = screen.getByLabelText( + NOTIFY_NEW_ACL_USERS_CHECKBOX_LABEL, + ) await user.click(notifyCheckbox) } @@ -418,10 +429,14 @@ describe('EntityAclEditor', () => { ) // Verify that the public and authenticated are displayed as 'Can download' - await confirmItemViaQuery(itemRows, 'Anyone on the web', 'Can download') await confirmItemViaQuery( itemRows, - 'All registered Synapse users', + PUBLIC_GROUP_DISPLAY_TEXT, + 'Can download', + ) + await confirmItemViaQuery( + itemRows, + AUTHENTICATED_GROUP_DISPLAY_TEXT, 'Can download', ) }) @@ -477,10 +492,10 @@ describe('EntityAclEditor', () => { const { publicRow, authenticatedUsersRow } = await addPublicToAcl(user) // Verify the initial permissions - confirmItem(publicRow, 'Anyone on the web', 'Can view') + confirmItem(publicRow, PUBLIC_GROUP_DISPLAY_TEXT, 'Can view') confirmItem( authenticatedUsersRow, - 'All registered Synapse users', + AUTHENTICATED_GROUP_DISPLAY_TEXT, 'Can download', ) @@ -594,15 +609,17 @@ describe('EntityAclEditor', () => { // No controls to add to the ACL expect(queryForAddUserCombobox()).not.toBeInTheDocument() expect( - screen.queryByLabelText('Notify people via email'), + screen.queryByLabelText(NOTIFY_NEW_ACL_USERS_CHECKBOX_LABEL), ).not.toBeInTheDocument() // No controls to toggle public access expect( - screen.queryByRole('button', { name: 'Remove Public Access' }), + screen.queryByRole('button', { + name: REMOVE_PUBLIC_PRINCIPALS_BUTTON_TEXT, + }), ).not.toBeInTheDocument() expect( - screen.queryByRole('button', { name: 'Make Public' }), + screen.queryByRole('button', { name: ADD_PUBLIC_PRINCIPALS_BUTTON_TEXT }), ).not.toBeInTheDocument() screen.getByText( diff --git a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx index 93347a466c..8e5f094ba2 100644 --- a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx +++ b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx @@ -305,7 +305,7 @@ const EntityAclEditor = React.forwardRef(function EntityAclEditor( emptyText={/* This should never happen */ ''} displayedPermissionLevelOverride={getDisplayedPermissionLevelOverride} onAddPrincipalToAcl={id => { - if (id === String(PUBLIC_PRINCIPAL_ID)) { + if (id === PUBLIC_PRINCIPAL_ID) { addResourceAccessItem( id, getAccessTypeFromPermissionLevel('CAN_VIEW'), diff --git a/packages/synapse-react-client/src/components/SynapseTable/SynapseTableCell/SynapseTableCell.integration.test.tsx b/packages/synapse-react-client/src/components/SynapseTable/SynapseTableCell/SynapseTableCell.integration.test.tsx index 9668db6017..809c5e89a0 100644 --- a/packages/synapse-react-client/src/components/SynapseTable/SynapseTableCell/SynapseTableCell.integration.test.tsx +++ b/packages/synapse-react-client/src/components/SynapseTable/SynapseTableCell/SynapseTableCell.integration.test.tsx @@ -38,6 +38,7 @@ import { mockTableEntity } from '../../../mocks/entity/mockTableEntity' import { mockFileViewEntity } from '../../../mocks/entity/mockFileView' import { MOCK_TEAM_ID, mockTeamData } from '../../../mocks/team/mockTeam' import { uniqueId } from 'lodash-es' +import { AUTHENTICATED_GROUP_DISPLAY_TEXT } from '../../TeamBadge' const queryResultBundle: QueryResultBundle = queryResultBundleJson as QueryResultBundle @@ -241,7 +242,7 @@ describe('SynapseTableCell tests', () => { columnValue: AUTHENTICATED_PRINCIPAL_ID.toString(), }) - await screen.findByText('All registered Synapse users', { exact: false }) + await screen.findByText(AUTHENTICATED_GROUP_DISPLAY_TEXT, { exact: false }) }) it('renders a link for a team', async () => { diff --git a/packages/synapse-react-client/src/components/TeamBadge.tsx b/packages/synapse-react-client/src/components/TeamBadge.tsx index 6d332a54dd..b188bb2072 100644 --- a/packages/synapse-react-client/src/components/TeamBadge.tsx +++ b/packages/synapse-react-client/src/components/TeamBadge.tsx @@ -7,6 +7,9 @@ import { } from '../utils/SynapseConstants' import { Box, Link } from '@mui/material' +export const AUTHENTICATED_GROUP_DISPLAY_TEXT = 'All registered Synapse users' +export const PUBLIC_GROUP_DISPLAY_TEXT = 'Anyone on the web' + export type TeamBadgeProps = { teamId: string | number teamName: string @@ -21,12 +24,12 @@ export default function TeamBadge(props: TeamBadgeProps) { if (teamId == AUTHENTICATED_PRINCIPAL_ID) { icon = 'public' - teamName = 'All registered Synapse users' + teamName = AUTHENTICATED_GROUP_DISPLAY_TEXT disableHref = true } if (teamId == PUBLIC_PRINCIPAL_ID) { icon = 'public' - teamName = 'Anyone on the web' + teamName = PUBLIC_GROUP_DISPLAY_TEXT disableHref = true } From 40c35e22009224c6a084b15325a430416d1881e5 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Thu, 25 Jul 2024 17:01:22 -0400 Subject: [PATCH 03/14] SWC-6800 - make ResourceAccess list equality utility more specific --- .../EntityAclEditor/EntityAclEditor.tsx | 4 +- .../functions/AccessControlListUtils.test.ts | 115 ++++++++++++++++++ .../utils/functions/AccessControlListUtils.ts | 28 +++++ .../IsEqualTreatArraysAsSets.test.ts | 30 ----- .../functions/isEqualTreatArraysAsSets.ts | 23 ---- 5 files changed, 145 insertions(+), 55 deletions(-) create mode 100644 packages/synapse-react-client/src/utils/functions/AccessControlListUtils.test.ts create mode 100644 packages/synapse-react-client/src/utils/functions/AccessControlListUtils.ts delete mode 100644 packages/synapse-react-client/src/utils/functions/IsEqualTreatArraysAsSets.test.ts delete mode 100644 packages/synapse-react-client/src/utils/functions/isEqualTreatArraysAsSets.ts diff --git a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx index 8e5f094ba2..0f0508ca75 100644 --- a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx +++ b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx @@ -29,7 +29,7 @@ import { import { Alert, Stack } from '@mui/material' import { InheritanceMessage } from './InheritanceMessage' import { CreateOrDeleteLocalSharingSettingsButton } from './CreateOrDeleteLocalSharingSettingsButton' -import isEqualTreatArraysAsSets from '../../utils/functions/isEqualTreatArraysAsSets' +import resourceAccessListIsEqual from '../../utils/functions/AccessControlListUtils' import useNotifyNewACLUsers from './useNotifyNewACLUsers' import { BackendDestinationEnum, getEndpoint } from '../../utils/functions' import { getDisplayNameFromProfile } from '../../utils/functions/DisplayUtils' @@ -223,7 +223,7 @@ const EntityAclEditor = React.forwardRef(function EntityAclEditor( const hasAclChanged = useMemo(() => { return ( originalIsInherited != updatedIsInherited || - !isEqualTreatArraysAsSets( + !resourceAccessListIsEqual( originalResourceAccess, updatedResourceAccessList, ) diff --git a/packages/synapse-react-client/src/utils/functions/AccessControlListUtils.test.ts b/packages/synapse-react-client/src/utils/functions/AccessControlListUtils.test.ts new file mode 100644 index 0000000000..79023a176e --- /dev/null +++ b/packages/synapse-react-client/src/utils/functions/AccessControlListUtils.test.ts @@ -0,0 +1,115 @@ +import resourceAccessListIsEqual from './AccessControlListUtils' +import { ACCESS_TYPE, ResourceAccess } from '@sage-bionetworks/synapse-types' + +describe('AccessControlListUtils', () => { + describe('resourceAccessListIsEqual', () => { + test('empty list', () => { + expect(resourceAccessListIsEqual([], [])).toBe(true) + }) + test('principal ids out of order', () => { + const a: ResourceAccess[] = [ + { + principalId: 1, + accessType: [ACCESS_TYPE.READ], + }, + { + principalId: 2, + accessType: [ACCESS_TYPE.READ], + }, + ] + const b: ResourceAccess[] = [ + { + principalId: 2, + accessType: [ACCESS_TYPE.READ], + }, + { + principalId: 1, + accessType: [ACCESS_TYPE.READ], + }, + ] + + expect(resourceAccessListIsEqual(a, b)).toBe(true) + }) + test('accessType out of order', () => { + const a: ResourceAccess[] = [ + { + principalId: 1, + accessType: [ACCESS_TYPE.READ, ACCESS_TYPE.DOWNLOAD], + }, + { + principalId: 2, + accessType: [ACCESS_TYPE.READ, ACCESS_TYPE.DOWNLOAD], + }, + ] + + const b: ResourceAccess[] = [ + { + principalId: 1, + accessType: [ACCESS_TYPE.DOWNLOAD, ACCESS_TYPE.READ], + }, + { + principalId: 2, + accessType: [ACCESS_TYPE.DOWNLOAD, ACCESS_TYPE.READ], + }, + ] + + expect(resourceAccessListIsEqual(a, b)).toBe(true) + }) + + test('missing entry', () => { + const a: ResourceAccess[] = [ + { + principalId: 1, + accessType: [ACCESS_TYPE.READ, ACCESS_TYPE.DOWNLOAD], + }, + { + principalId: 2, + accessType: [ACCESS_TYPE.READ, ACCESS_TYPE.DOWNLOAD], + }, + ] + + const b: ResourceAccess[] = [ + { + principalId: 1, + accessType: [ACCESS_TYPE.DOWNLOAD, ACCESS_TYPE.READ], + }, + ] + + expect(resourceAccessListIsEqual(a, b)).toBe(false) + }) + + test('changed accessType', () => { + const a: ResourceAccess[] = [ + { + principalId: 1, + accessType: [ACCESS_TYPE.READ], + }, + ] + + const b: ResourceAccess[] = [ + { + principalId: 1, + accessType: [ACCESS_TYPE.DOWNLOAD, ACCESS_TYPE.READ], + }, + ] + + expect(resourceAccessListIsEqual(a, b)).toBe(false) + }) + test('changed principal', () => { + const a: ResourceAccess[] = [ + { + principalId: 1, + accessType: [ACCESS_TYPE.READ], + }, + ] + + const b: ResourceAccess[] = [ + { + principalId: 2, + accessType: [ACCESS_TYPE.READ], + }, + ] + expect(resourceAccessListIsEqual(a, b)).toBe(false) + }) + }) +}) diff --git a/packages/synapse-react-client/src/utils/functions/AccessControlListUtils.ts b/packages/synapse-react-client/src/utils/functions/AccessControlListUtils.ts new file mode 100644 index 0000000000..3539917d0d --- /dev/null +++ b/packages/synapse-react-client/src/utils/functions/AccessControlListUtils.ts @@ -0,0 +1,28 @@ +import { cloneDeep, isEqual } from 'lodash-es' +import { ResourceAccess } from '@sage-bionetworks/synapse-types' + +function sortResourceAccessList( + resourceAccessList: ResourceAccess[], +): ResourceAccess[] { + const clone = cloneDeep(resourceAccessList) + // Sort the resource access list by principal ID + clone.sort((a, b) => b.principalId - a.principalId) + // In each resource access, sort the access type set + clone.forEach(ra => ra.accessType.sort()) + return clone +} + +/** + * Compares two objects for equality, ignoring the order of elements in arrays. + * @param a + * @param b + */ +export default function resourceAccessListIsEqual( + a: ResourceAccess[], + b: ResourceAccess[], +) { + const aSorted = sortResourceAccessList(a) + const bSorted = sortResourceAccessList(b) + + return isEqual(aSorted, bSorted) +} diff --git a/packages/synapse-react-client/src/utils/functions/IsEqualTreatArraysAsSets.test.ts b/packages/synapse-react-client/src/utils/functions/IsEqualTreatArraysAsSets.test.ts deleted file mode 100644 index 1f93e0c416..0000000000 --- a/packages/synapse-react-client/src/utils/functions/IsEqualTreatArraysAsSets.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import isEqualTreatArraysAsSets from './isEqualTreatArraysAsSets' - -describe('isEqualTreatArraysAsSets', () => { - test('arrays are compared without considering order', () => { - expect(isEqualTreatArraysAsSets(['a', 'b'], ['b', 'a'])).toBe(true) - expect( - isEqualTreatArraysAsSets({ foo: ['a', 'b'] }, { foo: ['b', 'a'] }), - ).toBe(true) - expect( - isEqualTreatArraysAsSets( - [{ foo: ['a', 'b'] }, { foo: ['c', 'd'] }], - [{ foo: ['d', 'c'] }, { foo: ['b', 'a'] }], - ), - ).toBe(true) - - expect( - isEqualTreatArraysAsSets( - [{ foo: ['a', 'b'] }, { foo: ['c', 'd'] }], - [{ foo: ['a', 'c'] }, { foo: ['b', 'd'] }], - ), - ).toBe(false) - - expect(isEqualTreatArraysAsSets(['a', 'a', 'b'], ['b', 'a', 'a'])).toBe( - true, - ) - expect(isEqualTreatArraysAsSets(['a', 'a', 'b'], ['b', 'b', 'a'])).toBe( - true, - ) - }) -}) diff --git a/packages/synapse-react-client/src/utils/functions/isEqualTreatArraysAsSets.ts b/packages/synapse-react-client/src/utils/functions/isEqualTreatArraysAsSets.ts deleted file mode 100644 index aba7dfdef5..0000000000 --- a/packages/synapse-react-client/src/utils/functions/isEqualTreatArraysAsSets.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { cloneDeep, isArray, isEqualWith } from 'lodash-es' - -/** - * Compares two objects for equality, ignoring the order of elements in arrays. - * @param a - * @param b - */ -export default function isEqualTreatArraysAsSets(a: unknown, b: unknown) { - a = cloneDeep(a) - b = cloneDeep(b) - return isEqualWith(a, b, (a: unknown, b: unknown): boolean | undefined => { - if (isArray(a) && isArray(b)) { - return ( - a.length === b.length && - a.every(aItem => - b.some(bItem => isEqualTreatArraysAsSets(aItem, bItem)), - ) - ) - } - // Fall back to default comparison - return undefined - }) -} From c431bcbdaa81b9e0e73d828c91d807b5bb7e2c48 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Fri, 26 Jul 2024 09:46:04 -0400 Subject: [PATCH 04/14] SWC-6800 - fix flaky tests by using `getBy` instead of `queryBy` - use suspense for fetching parent ACL - add mock root entity --- .../EntityAclEditor/EntityAclEditor.test.tsx | 2 +- .../EntityAclEditor/EntityAclEditor.tsx | 129 +++++++++++------- .../src/mocks/entity/index.ts | 2 + .../src/mocks/entity/mockRootEntity.ts | 49 +++++++ .../src/synapse-queries/entity/useEntity.ts | 5 +- .../functions/AccessControlListUtils.test.ts | 2 +- .../utils/functions/AccessControlListUtils.ts | 2 +- 7 files changed, 138 insertions(+), 53 deletions(-) create mode 100644 packages/synapse-react-client/src/mocks/entity/mockRootEntity.ts diff --git a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.test.tsx b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.test.tsx index 081db01360..e79abdc82f 100644 --- a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.test.tsx +++ b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.test.tsx @@ -76,7 +76,7 @@ async function setUp( let itemRows: HTMLElement[] = [] await waitFor(() => { - itemRows = screen.queryAllByRole('row') + itemRows = screen.getAllByRole('row') expect(itemRows).toHaveLength(expectedResourceAccessItems) }) diff --git a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx index 0f0508ca75..0868e6efce 100644 --- a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx +++ b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useImperativeHandle, useMemo, useState } from 'react' import OpenData from './OpenData' -import { AclEditor } from '../AclEditor/AclEditor' +import { AclEditor, AclEditorProps } from '../AclEditor/AclEditor' import useUpdateAcl from '../AclEditor/useUpdateAcl' import { getAccessTypeFromPermissionLevel, @@ -21,7 +21,7 @@ import { import { useCreateEntityACL, useDeleteEntityACL, - useGetEntityBenefactorACL, + useSuspenseGetEntityBenefactorACL, useSuspenseGetCurrentUserProfile, useSuspenseGetEntityBundle, useUpdateEntityACL, @@ -29,11 +29,12 @@ import { import { Alert, Stack } from '@mui/material' import { InheritanceMessage } from './InheritanceMessage' import { CreateOrDeleteLocalSharingSettingsButton } from './CreateOrDeleteLocalSharingSettingsButton' -import resourceAccessListIsEqual from '../../utils/functions/AccessControlListUtils' +import { resourceAccessListIsEqual } from '../../utils/functions/AccessControlListUtils' import useNotifyNewACLUsers from './useNotifyNewACLUsers' import { BackendDestinationEnum, getEndpoint } from '../../utils/functions' import { getDisplayNameFromProfile } from '../../utils/functions/DisplayUtils' import { AclEditorSkeleton } from '../AclEditor/AclEditorSkeleton' +import { SynapseErrorBoundary } from '../error' const availablePermissionLevels: PermissionLevel[] = [ 'CAN_VIEW', @@ -65,6 +66,58 @@ function getBody(profile: UserProfile, entityId: string) { )}Synapse:${entityId}` } +function getCanEditResourceAccess( + canEdit: boolean, + isInherited: boolean, + ownProfile: UserProfile, +): AclEditorProps['canEdit'] { + if (!canEdit || isInherited) { + return false + } + return (resourceAccess: ResourceAccess): boolean => { + // Users cannot change their own permissions + const isSelf = ownProfile.ownerId === String(resourceAccess.principalId) + // Users cannot change permission level for the public group, only add/remove it. + // To give the public group DOWNLOAD access, ACT must mark it as anonymous access. + const isPublicGroup = resourceAccess.principalId === PUBLIC_PRINCIPAL_ID + if (isSelf || isPublicGroup) { + return false + } + return true + } +} + +function getCanDeleteResourceAccess( + canEdit: boolean, + isInherited: boolean, + ownProfile: UserProfile, +): AclEditorProps['canRemoveEntry'] { + if (!canEdit || isInherited) { + return false + } + return (resourceAccess: ResourceAccess): boolean => { + // Users cannot delete their own permissions + const isSelf = ownProfile.ownerId === String(resourceAccess.principalId) + if (isSelf) { + return false + } + return canEdit + } +} + +function getDisplayedPermissionLevelOverride( + isOpenData: boolean, +): AclEditorProps['displayedPermissionLevelOverride'] { + return (resourceAccess: ResourceAccess) => { + if (resourceAccess.principalId === PUBLIC_PRINCIPAL_ID) { + return isOpenData + ? permissionLevelToLabel['CAN_DOWNLOAD'] + : permissionLevelToLabel['CAN_VIEW'] + } + return undefined + } +} + const EntityAclEditor = React.forwardRef(function EntityAclEditor( props: EntityAclEditorProps, ref: React.ForwardedRef, @@ -80,11 +133,13 @@ const EntityAclEditor = React.forwardRef(function EntityAclEditor( ) const isProject = EntityType.PROJECT == entityBundle.entityType - const parentId = entityBundle.entity.parentId - const { data: parentAcl } = useGetEntityBenefactorACL(parentId!, { - enabled: parentId != null && !isProject, - staleTime: Infinity, - }) + // The parent's benefactor ACL will be shown if the user removes the ACL on the current entity + const { data: parentAcl } = useSuspenseGetEntityBenefactorACL( + entityBundle.entity.parentId!, + { + staleTime: Infinity, + }, + ) const originalResourceAccess = entityBundle.benefactorAcl.resourceAccess const parentResourceAccess = useMemo( @@ -150,40 +205,6 @@ const EntityAclEditor = React.forwardRef(function EntityAclEditor( ].includes(ra.principalId), ) - const canEditResourceAccess = canEdit - ? (resourceAccess: ResourceAccess): boolean => { - // Users cannot change their own permissions - const isSelf = ownProfile.ownerId === String(resourceAccess.principalId) - // Users cannot change permission level for the public group, only add/remove it. - // To give the public group DOWNLOAD access, ACT must mark it as anonymous access. - const isPublicGroup = resourceAccess.principalId === PUBLIC_PRINCIPAL_ID - if (isSelf || isPublicGroup) { - return false - } - return true - } - : false - - function canDeleteResourceAccess(resourceAccess: ResourceAccess): boolean { - // Users cannot delete their own permissions - const isSelf = ownProfile.ownerId === String(resourceAccess.principalId) - if (isSelf) { - return false - } - return canEdit - } - - function getDisplayedPermissionLevelOverride( - resourceAccess: ResourceAccess, - ): string | undefined { - if (resourceAccess.principalId === PUBLIC_PRINCIPAL_ID) { - return isOpenData - ? permissionLevelToLabel['CAN_DOWNLOAD'] - : permissionLevelToLabel['CAN_VIEW'] - } - return undefined - } - const { sendNotification, isLoading: isLoadingSendMessageToNewACLUsers, @@ -298,12 +319,22 @@ const EntityAclEditor = React.forwardRef(function EntityAclEditor( benefactorId={updatedIsInherited ? parentAcl?.id : entityId} /> { if (id === PUBLIC_PRINCIPAL_ID) { addResourceAccessItem( @@ -342,9 +373,11 @@ const EntityAclEditorWithSuspense = React.forwardRef( ref: React.ForwardedRef, ) { return ( - }> - - + + }> + + + ) }, ) diff --git a/packages/synapse-react-client/src/mocks/entity/index.ts b/packages/synapse-react-client/src/mocks/entity/index.ts index 792b147268..33037fa984 100644 --- a/packages/synapse-react-client/src/mocks/entity/index.ts +++ b/packages/synapse-react-client/src/mocks/entity/index.ts @@ -9,8 +9,10 @@ import { FileEntity, Project } from '@sage-bionetworks/synapse-types' import { mockGeneratedEntityData } from './mockGeneratedEntityData' import mockProjectEntityData from './mockProject' import mockFileEntityData from './mockFileEntity' +import mockRootEntityData from './mockRootEntity' const mockEntities: MockEntityData[] = [ + mockRootEntityData, mockFileEntityData, mockProjectEntityData, mockDatasetData, diff --git a/packages/synapse-react-client/src/mocks/entity/mockRootEntity.ts b/packages/synapse-react-client/src/mocks/entity/mockRootEntity.ts new file mode 100644 index 0000000000..027ef2a5de --- /dev/null +++ b/packages/synapse-react-client/src/mocks/entity/mockRootEntity.ts @@ -0,0 +1,49 @@ +import { + ACCESS_TYPE, + AccessControlList, + EntityType, + Folder, +} from '@sage-bionetworks/synapse-types' +import { MockEntityData } from './MockEntityData' +import { generateBaseEntity } from '../faker/generateFakeEntity' + +const rootEntityBenefactorAcl: AccessControlList = { + id: 'syn4489', + creationDate: '2011-08-07T01:14:53.898Z', + etag: 'e5edca05-4442-4073-8474-1a6a384053e9', + resourceAccess: [ + { + principalId: 273948, + accessType: [ACCESS_TYPE.CREATE], + }, + ], +} + +const mockRootEntityData: MockEntityData = generateBaseEntity({ + id: 4489, + entity: { name: '' }, + acl: rootEntityBenefactorAcl, + type: EntityType.FOLDER, + permissions: { + canView: false, + canEdit: false, + canMove: false, + canAddChild: false, + canCertifiedUserEdit: false, + canCertifiedUserAddChild: false, + isCertifiedUser: false, + canChangePermissions: false, + canChangeSettings: false, + canDelete: false, + canDownload: false, + canUpload: false, + canEnableInheritance: false, + ownerPrincipalId: 0, + canPublicRead: false, + canModerate: false, + isCertificationRequired: false, + isEntityOpenData: false, + }, +}) + +export default mockRootEntityData diff --git a/packages/synapse-react-client/src/synapse-queries/entity/useEntity.ts b/packages/synapse-react-client/src/synapse-queries/entity/useEntity.ts index 0bff3a6a04..964dee0ee6 100644 --- a/packages/synapse-react-client/src/synapse-queries/entity/useEntity.ts +++ b/packages/synapse-react-client/src/synapse-queries/entity/useEntity.ts @@ -16,6 +16,7 @@ import { useQuery, useQueryClient, UseQueryOptions, + useSuspenseQuery, } from '@tanstack/react-query' import SynapseClient from '../../synapse-client' import { entityJsonKeys } from '../../utils/functions/EntityTypeUtils' @@ -508,7 +509,7 @@ function useGetEntityBenefactorACLQueryOptions( * @param entityId * @param options */ -export function useGetEntityBenefactorACL( +export function useSuspenseGetEntityBenefactorACL( entityId: string, options?: Partial< UseQueryOptions< @@ -519,7 +520,7 @@ export function useGetEntityBenefactorACL( >, ) { const queryOptions = useGetEntityBenefactorACLQueryOptions(entityId) - return useQuery({ + return useSuspenseQuery({ ...options, ...queryOptions, }) diff --git a/packages/synapse-react-client/src/utils/functions/AccessControlListUtils.test.ts b/packages/synapse-react-client/src/utils/functions/AccessControlListUtils.test.ts index 79023a176e..70a1f4507a 100644 --- a/packages/synapse-react-client/src/utils/functions/AccessControlListUtils.test.ts +++ b/packages/synapse-react-client/src/utils/functions/AccessControlListUtils.test.ts @@ -1,4 +1,4 @@ -import resourceAccessListIsEqual from './AccessControlListUtils' +import { resourceAccessListIsEqual } from './AccessControlListUtils' import { ACCESS_TYPE, ResourceAccess } from '@sage-bionetworks/synapse-types' describe('AccessControlListUtils', () => { diff --git a/packages/synapse-react-client/src/utils/functions/AccessControlListUtils.ts b/packages/synapse-react-client/src/utils/functions/AccessControlListUtils.ts index 3539917d0d..56d48acbfc 100644 --- a/packages/synapse-react-client/src/utils/functions/AccessControlListUtils.ts +++ b/packages/synapse-react-client/src/utils/functions/AccessControlListUtils.ts @@ -17,7 +17,7 @@ function sortResourceAccessList( * @param a * @param b */ -export default function resourceAccessListIsEqual( +export function resourceAccessListIsEqual( a: ResourceAccess[], b: ResourceAccess[], ) { From 24869734557976fdf79e9f56ea70043932efe120 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Fri, 26 Jul 2024 09:49:39 -0400 Subject: [PATCH 05/14] SWC-6800 - fix test --- .../src/components/AclEditor/AclEditor.test.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/synapse-react-client/src/components/AclEditor/AclEditor.test.tsx b/packages/synapse-react-client/src/components/AclEditor/AclEditor.test.tsx index 659a436f00..a1cfbd1b45 100644 --- a/packages/synapse-react-client/src/components/AclEditor/AclEditor.test.tsx +++ b/packages/synapse-react-client/src/components/AclEditor/AclEditor.test.tsx @@ -158,9 +158,7 @@ describe('AclEditor', () => { await addUserToAcl(user, mockUserData2.userProfile!.userName) - expect(mockAddResourceAccessItem).toHaveBeenCalledWith( - String(mockUserData2.id), - ) + expect(mockAddResourceAccessItem).toHaveBeenCalledWith(mockUserData2.id) }) it('Handles updating the permissions of a user or team', async () => { From add860cc5213ce7c7e44d7638bdc167f5cff70cb Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Fri, 26 Jul 2024 10:13:12 -0400 Subject: [PATCH 06/14] SWC-6800 - add tests for getDisplayName --- .../src/utils/functions/DisplayUtils.test.ts | 16 ++++++++++++++++ .../src/utils/functions/DisplayUtils.ts | 8 ++++---- 2 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 packages/synapse-react-client/src/utils/functions/DisplayUtils.test.ts diff --git a/packages/synapse-react-client/src/utils/functions/DisplayUtils.test.ts b/packages/synapse-react-client/src/utils/functions/DisplayUtils.test.ts new file mode 100644 index 0000000000..2cf909abc4 --- /dev/null +++ b/packages/synapse-react-client/src/utils/functions/DisplayUtils.test.ts @@ -0,0 +1,16 @@ +import { getDisplayName } from './DisplayUtils' + +describe('DisplayUtils', () => { + test('getDisplayName', () => { + const fName = 'first' + const lName = 'last' + const userName = 'username' + expect(getDisplayName(fName, lName, userName)).toEqual( + 'first last (username)', + ) + + // possible new user state, where first and last names are not filled in during registration + expect(getDisplayName(null, null, userName)).toEqual('username') + expect(getDisplayName('', '', userName)).toEqual('username') + }) +}) diff --git a/packages/synapse-react-client/src/utils/functions/DisplayUtils.ts b/packages/synapse-react-client/src/utils/functions/DisplayUtils.ts index 99220699f9..eed5288caf 100644 --- a/packages/synapse-react-client/src/utils/functions/DisplayUtils.ts +++ b/packages/synapse-react-client/src/utils/functions/DisplayUtils.ts @@ -13,17 +13,17 @@ function getUserName(userName: string, inParens: boolean): string { } export function getDisplayName( - firstName: string, - lastName: string, + firstName: string | null, + lastName: string | null, userName: string, ): string { let displayName = '' let hasDisplayName = false - if (firstName != null && firstName.length > 0) { + if (firstName) { displayName += firstName.trim() hasDisplayName = true } - if (lastName != null && lastName.length > 0) { + if (lastName) { displayName += ' ' + lastName.trim() hasDisplayName = true } From 53debe21973b27fc74a9979f177f94c67f689027 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Fri, 26 Jul 2024 10:15:58 -0400 Subject: [PATCH 07/14] SWC-6800 - remove dark mode tweaks --- .../src/components/EntityAclEditor/OpenData.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/synapse-react-client/src/components/EntityAclEditor/OpenData.tsx b/packages/synapse-react-client/src/components/EntityAclEditor/OpenData.tsx index 0fdcf71514..4f937d9417 100644 --- a/packages/synapse-react-client/src/components/EntityAclEditor/OpenData.tsx +++ b/packages/synapse-react-client/src/components/EntityAclEditor/OpenData.tsx @@ -2,20 +2,13 @@ import React from 'react' import IconSvg from '../IconSvg' import { Box, BoxProps, styled, Typography } from '@mui/material' import { StyledComponent } from '@emotion/styled' -import tinycolor from 'tinycolor2' const OpenDataContainer: StyledComponent = styled(Box, { label: 'OpenDataContainer', })(({ theme }) => ({ - background: - theme.palette.mode === 'light' - ? theme.palette.grey[100] - : tinycolor(theme.palette.background.paper).desaturate(1).toString(), + background: theme.palette.grey[100], padding: `${theme.spacing(2.5)} ${theme.spacing(4)}`, - border: - theme.palette.mode === 'light' - ? `1px solid ${theme.palette.grey[300]}` - : 'none', + border: `1px solid ${theme.palette.grey[300]}`, borderRadius: '3px', marginBottom: theme.spacing(2), })) From f347ec235bf11a9389f352be29d50e6766a73d3f Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Fri, 26 Jul 2024 10:31:58 -0400 Subject: [PATCH 08/14] SWC-6800 - add contrastText color to success palette --- packages/synapse-react-client/src/theme/palette/Palettes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/synapse-react-client/src/theme/palette/Palettes.ts b/packages/synapse-react-client/src/theme/palette/Palettes.ts index b3e6b53115..52cffe33af 100644 --- a/packages/synapse-react-client/src/theme/palette/Palettes.ts +++ b/packages/synapse-react-client/src/theme/palette/Palettes.ts @@ -81,7 +81,7 @@ export const palette: PaletteOptions = { darkPrimary: generatePalette('#164B6E'), lightPrimary: { ...generatePalette('#f8f9fa'), contrastText: '#164B6E' }, light: { ...generatePalette('#f8f9fa'), contrastText: '#22252a' }, // grey-1000 - success: { main: '#32a330' }, + success: { main: '#32a330', contrastText: '#ffffff' }, info: { main: '#017fa5' }, warning: { main: '#cc9f00' }, error: { main: '#c13415' }, From 26430cd6dfedefbeaad01bd569bba77d9a06a1bb Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Fri, 26 Jul 2024 13:21:02 -0400 Subject: [PATCH 09/14] SWC-6800 - add to UMD bundle --- .../src/components/AclEditor/useUpdateAcl.test.ts | 8 ++------ .../src/components/EntityAclEditor/EntityAclEditor.tsx | 3 ++- .../components/EntityAclEditor/EntityAclEditorModal.tsx | 7 +++++-- .../src/synapse-queries/user/useUserGroupHeader.ts | 2 +- packages/synapse-react-client/src/umd.index.ts | 2 ++ 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/synapse-react-client/src/components/AclEditor/useUpdateAcl.test.ts b/packages/synapse-react-client/src/components/AclEditor/useUpdateAcl.test.ts index 86fae6620e..1a9df7457c 100644 --- a/packages/synapse-react-client/src/components/AclEditor/useUpdateAcl.test.ts +++ b/packages/synapse-react-client/src/components/AclEditor/useUpdateAcl.test.ts @@ -37,9 +37,7 @@ describe('useUpdateAcl', () => { expect(result.current.resourceAccessList).toHaveLength(0) act(() => { - result.current.addResourceAccessItem(String(MOCK_USER_ID), [ - ACCESS_TYPE.READ, - ]) + result.current.addResourceAccessItem(MOCK_USER_ID, [ACCESS_TYPE.READ]) }) await waitFor(() => @@ -151,9 +149,7 @@ describe('useUpdateAcl', () => { // Individually adding @AnotherUser should NOT trigger a sort, since the editor is 'dirty'. act(() => { - result.current.addResourceAccessItem(String(MOCK_USER_ID_2), [ - ACCESS_TYPE.READ, - ]) + result.current.addResourceAccessItem(MOCK_USER_ID_2, [ACCESS_TYPE.READ]) }) // Verify that the entries are not sorted diff --git a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx index 0868e6efce..f9be09c8ba 100644 --- a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx +++ b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditor.tsx @@ -149,7 +149,8 @@ const EntityAclEditor = React.forwardRef(function EntityAclEditor( const canEdit = entityBundle.permissions.canChangePermissions const isOpenData = entityBundle.permissions.isEntityOpenData const originalIsInherited = !(entityBundle.benefactorAcl.id == entityId) - const [updatedIsInherited, setUpdatedIsInherited] = useState(false) + const [updatedIsInherited, setUpdatedIsInherited] = + useState(originalIsInherited) const [notifyNewAdditions, setNotifyNewAdditions] = useState(false) const [error, setError] = useState() diff --git a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditorModal.tsx b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditorModal.tsx index 049b228cb3..4b73210ea3 100644 --- a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditorModal.tsx +++ b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditorModal.tsx @@ -4,15 +4,17 @@ import EntityAclEditor, { EntityAclEditorHandle } from './EntityAclEditor' import { displayToast } from '../ToastMessage' import { useGetEntityBundle } from '../../synapse-queries' import { entityTypeToFriendlyName } from '../../utils/functions/EntityTypeUtils' +import { noop } from 'lodash-es' export type EntityAclEditorModalProps = { entityId: string open: boolean + onUpdateSuccess?: () => void onClose: () => void } export default function EntityAclEditorModal(props: EntityAclEditorModalProps) { - const { entityId, open, onClose } = props + const { entityId, open, onUpdateSuccess = noop, onClose } = props const [isDirty, setIsDirty] = useState(false) const entityAclEditorRef = useRef(null) @@ -35,11 +37,12 @@ export default function EntityAclEditorModal(props: EntityAclEditorModalProps) { entityId={entityId} onCanSaveChange={isDirty => setIsDirty(isDirty)} onUpdateSuccess={() => { - onClose() displayToast( 'Permissions were successfully saved to Synapse', 'info', ) + onUpdateSuccess() + onClose() }} /> } diff --git a/packages/synapse-react-client/src/synapse-queries/user/useUserGroupHeader.ts b/packages/synapse-react-client/src/synapse-queries/user/useUserGroupHeader.ts index 208ff47492..aeaf27d45f 100644 --- a/packages/synapse-react-client/src/synapse-queries/user/useUserGroupHeader.ts +++ b/packages/synapse-react-client/src/synapse-queries/user/useUserGroupHeader.ts @@ -41,7 +41,7 @@ export function useGetUserGroupHeader( /** * Get an array of UserGroupHeaders, utilizing a react-query cache. This is always an unauthenticated call * (the users current email addresses will never be returned in the result). - * @param principalId + * @param principalIds * @param options * @returns */ diff --git a/packages/synapse-react-client/src/umd.index.ts b/packages/synapse-react-client/src/umd.index.ts index 122b14dd66..b4649e4073 100644 --- a/packages/synapse-react-client/src/umd.index.ts +++ b/packages/synapse-react-client/src/umd.index.ts @@ -85,6 +85,7 @@ import { SynapseFooter } from './components/SynapseFooter/SynapseFooter' import { GoogleAnalytics } from './components/GoogleAnalytics/GoogleAnalytics' import { CookiesNotification } from './components/CookiesNotification' import { getCurrentCookiePreferences } from './utils/hooks' +import EntityAclEditorModal from './components/EntityAclEditor/EntityAclEditorModal' // Also include scss in the bundle import './style/main.scss' @@ -175,6 +176,7 @@ const SynapseComponents = { GoogleAnalytics, CookiesNotification, getCurrentCookiePreferences, + EntityAclEditorModal, } // Include the version in the build From 945f59499d7aa2607fbd00ee62f7c621831d51d3 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Sat, 27 Jul 2024 09:04:24 -0400 Subject: [PATCH 10/14] SWC-6800 - add help text --- .../synapse-react-client/src/components/DialogBase.tsx | 6 ++++-- .../components/EntityAclEditor/EntityAclEditorModal.tsx | 8 ++++++++ .../src/components/HelpPopover/HelpPopover.tsx | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/synapse-react-client/src/components/DialogBase.tsx b/packages/synapse-react-client/src/components/DialogBase.tsx index 61893e8313..74b7b66392 100644 --- a/packages/synapse-react-client/src/components/DialogBase.tsx +++ b/packages/synapse-react-client/src/components/DialogBase.tsx @@ -12,7 +12,7 @@ import { SxProps, } from '@mui/material' import React from 'react' -import { HelpPopover, HelpPopoverProps } from './HelpPopover/HelpPopover' +import { HelpPopover, HelpPopoverProps } from './HelpPopover' const EMPTY_OBJECT = {} @@ -53,7 +53,9 @@ export function DialogBaseTitle(props: DialogBaseTitleProps) { {title} - {titleHelpPopoverProps && } + + {titleHelpPopoverProps && } + {hasCloseButton && onCancel()} />} diff --git a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditorModal.tsx b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditorModal.tsx index 4b73210ea3..915dd76829 100644 --- a/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditorModal.tsx +++ b/packages/synapse-react-client/src/components/EntityAclEditor/EntityAclEditorModal.tsx @@ -6,6 +6,10 @@ import { useGetEntityBundle } from '../../synapse-queries' import { entityTypeToFriendlyName } from '../../utils/functions/EntityTypeUtils' import { noop } from 'lodash-es' +const ENTITY_SHARING_SETTINGS_HELP_MARKDOWN = `Sharing settings determine who can access your content, and what kind of access they have. Choose people/teams and define their level of access below.\n\n_Only Administrators can add, delete, or change access levels for other people._` +const ENTITY_SHARING_SETTINGS_HELP_URL = + 'https://help.synapse.org/docs/Sharing-Settings,-Permissions,-and-Conditions-for-Use.2024276030.html' + export type EntityAclEditorModalProps = { entityId: string open: boolean @@ -31,6 +35,10 @@ export default function EntityAclEditorModal(props: EntityAclEditorModalProps) { onCancel={onClose} open={open} maxWidth={'md'} + titleHelpPopoverProps={{ + markdownText: ENTITY_SHARING_SETTINGS_HELP_MARKDOWN, + helpUrl: ENTITY_SHARING_SETTINGS_HELP_URL, + }} content={ = ({ showCloseButton={showCloseButton} maxWidth="350px" > - + ) From ca635da46b54913661ef2aedfe93a6ac51ae2cdc Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Mon, 29 Jul 2024 10:37:41 -0400 Subject: [PATCH 11/14] SWC-6800 - revert change in mock --- packages/synapse-react-client/src/mocks/entity/mockProject.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/synapse-react-client/src/mocks/entity/mockProject.ts b/packages/synapse-react-client/src/mocks/entity/mockProject.ts index d19bfe4a6a..8cbc9db7ed 100644 --- a/packages/synapse-react-client/src/mocks/entity/mockProject.ts +++ b/packages/synapse-react-client/src/mocks/entity/mockProject.ts @@ -114,7 +114,7 @@ const mockProjectEntityBundle: EntityBundle = { canPublicRead: true, canModerate: true, canMove: true, - isEntityOpenData: true, + isEntityOpenData: false, isCertificationRequired: true, }, path: { From bd8cc55a664da69667b2a7899a4725ef574103b7 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Mon, 29 Jul 2024 10:44:50 -0400 Subject: [PATCH 12/14] SWC-6800 - fix typo --- .../src/components/HelpPopover/HelpPopover.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/synapse-react-client/src/components/HelpPopover/HelpPopover.tsx b/packages/synapse-react-client/src/components/HelpPopover/HelpPopover.tsx index 829e6040a6..977b5d7ed5 100644 --- a/packages/synapse-react-client/src/components/HelpPopover/HelpPopover.tsx +++ b/packages/synapse-react-client/src/components/HelpPopover/HelpPopover.tsx @@ -35,7 +35,7 @@ export const HelpPopover: React.FunctionComponent = ({ showCloseButton={showCloseButton} maxWidth="350px" > - + ) From 1183fabbaa7dd4dfcb7290a771b8b20f4e6fc049 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Mon, 29 Jul 2024 11:35:15 -0400 Subject: [PATCH 13/14] SWC-6800 - fix test --- .../src/components/AclEditor/AclEditor.test.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/synapse-react-client/src/components/AclEditor/AclEditor.test.tsx b/packages/synapse-react-client/src/components/AclEditor/AclEditor.test.tsx index a1cfbd1b45..66281ba419 100644 --- a/packages/synapse-react-client/src/components/AclEditor/AclEditor.test.tsx +++ b/packages/synapse-react-client/src/components/AclEditor/AclEditor.test.tsx @@ -336,11 +336,9 @@ describe('AclEditor', () => { }) await user.click(makePublicButton) + expect(mockAddResourceAccessItem).toHaveBeenCalledWith(PUBLIC_PRINCIPAL_ID) expect(mockAddResourceAccessItem).toHaveBeenCalledWith( - String(PUBLIC_PRINCIPAL_ID), - ) - expect(mockAddResourceAccessItem).toHaveBeenCalledWith( - String(AUTHENTICATED_PRINCIPAL_ID), + AUTHENTICATED_PRINCIPAL_ID, ) }) From 36c0c485277cf3dd76ba132366810deb78c08d61 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Mon, 12 Aug 2024 08:32:16 -0400 Subject: [PATCH 14/14] SWC-6925 - Update sharing settings copy --- .../CreateOrDeleteLocalSharingSettingsButton.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/synapse-react-client/src/components/EntityAclEditor/CreateOrDeleteLocalSharingSettingsButton.tsx b/packages/synapse-react-client/src/components/EntityAclEditor/CreateOrDeleteLocalSharingSettingsButton.tsx index 5fba02f4d6..830f2ab85d 100644 --- a/packages/synapse-react-client/src/components/EntityAclEditor/CreateOrDeleteLocalSharingSettingsButton.tsx +++ b/packages/synapse-react-client/src/components/EntityAclEditor/CreateOrDeleteLocalSharingSettingsButton.tsx @@ -36,10 +36,9 @@ export function CreateOrDeleteLocalSharingSettingsButton( return (
- By default the sharing settings are inherited from the parent folder or - project. If you want to have different settings on a specific file, - folder, or table you need to create local sharing settings and then - modify them. + Sharing settings are initially inherited from the parent folder or + project by default. To customize settings for a specific file, folder, + or table, you must create and adjust local sharing settings.