From: Stephen Smith Date: Thu, 16 Dec 2021 17:27:26 +0000 (-0500) Subject: Merge branch '18123-group-edit-page-rebase1' into main. Closes #18123 X-Git-Tag: 2.4.0~22 X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/58db72fee358d5987139a1b8526c0ca873e07dbf?hp=fc84a3f3932af503d3afd04a58af52270c8fc3b6 Merge branch '18123-group-edit-page-rebase1' into main. Closes #18123 Arvados-DCO-1.1-Signed-off-by: Stephen Smith --- diff --git a/cypress/integration/group-manage.spec.js b/cypress/integration/group-manage.spec.js new file mode 100644 index 00000000..c98c2201 --- /dev/null +++ b/cypress/integration/group-manage.spec.js @@ -0,0 +1,284 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +describe('Group manage tests', function() { + let activeUser; + let adminUser; + let otherUser; + let userThree; + const groupName = `Test group (${Math.floor(999999 * Math.random())})`; + + before(function() { + // Only set up common users once. These aren't set up as aliases because + // aliases are cleaned up after every test. Also it doesn't make sense + // to set the same users on beforeEach() over and over again, so we + // separate a little from Cypress' 'Best Practices' here. + cy.getUser('admin', 'Admin', 'User', true, true) + .as('adminUser').then(function() { + adminUser = this.adminUser; + } + ); + cy.getUser('user', 'Active', 'User', false, true) + .as('activeUser').then(function() { + activeUser = this.activeUser; + } + ); + cy.getUser('otheruser', 'Other', 'User', false, true) + .as('otherUser').then(function() { + otherUser = this.otherUser; + } + ); + cy.getUser('userThree', 'User', 'Three', false, true) + .as('userThree').then(function() { + userThree = this.userThree; + } + ); + }); + + beforeEach(function() { + cy.clearCookies(); + cy.clearLocalStorage(); + }); + + it('creates a new group', function() { + cy.loginAs(activeUser); + + // Navigate to Groups + cy.get('[data-cy=side-panel-tree]').contains('Groups').click(); + + // Create new group + cy.get('[data-cy=groups-panel-new-group]').click(); + cy.get('[data-cy=form-dialog]') + .should('contain', 'Create Group') + .within(() => { + cy.get('input[name=name]').type(groupName); + cy.get('[data-cy=users-field] input').type("three"); + }); + cy.get('[role=tooltip]').click(); + cy.get('[data-cy=form-dialog] button[type=submit]').click(); + + // Check that the group was created + cy.get('[data-cy=groups-panel-data-explorer]').contains(groupName).click(); + cy.get('[data-cy=group-members-data-explorer]').contains(activeUser.user.full_name); + cy.get('[data-cy=group-members-data-explorer]').contains(userThree.user.full_name); + }); + + it('adds users to the group', function() { + // Add other user to the group + cy.get('[data-cy=group-member-add]').click(); + cy.get('.sharing-dialog') + .should('contain', 'Sharing settings') + .within(() => { + cy.get('[data-cy=invite-people-field] input').type("other"); + }); + cy.get('[role=tooltip]').click(); + cy.get('.sharing-dialog').contains('Save').click(); + + // Check that both users are present with appropriate permissions + cy.get('[data-cy=group-members-data-explorer]') + .contains(otherUser.user.full_name) + .parents('tr') + .within(() => { + cy.contains('Read'); + }); + cy.get('[data-cy=group-members-data-explorer] tr') + .contains(activeUser.user.full_name) + .parents('tr') + .within(() => { + cy.contains('Manage'); + }); + }); + + it('changes permission level of a member', function() { + // Test change permission level + cy.get('[data-cy=group-members-data-explorer]') + .contains(otherUser.user.full_name) + .parents('tr') + .within(() => { + cy.contains('Read') + .parents('td') + .within(() => { + cy.get('button').click(); + }); + }); + cy.get('[data-cy=context-menu]') + .contains('Write') + .click(); + cy.get('[data-cy=group-members-data-explorer]') + .contains(otherUser.user.full_name) + .parents('tr') + .within(() => { + cy.contains('Write'); + }); + }); + + it('can unhide and re-hide users', function() { + // Must use admin user to have manage permission on user + cy.loginAs(adminUser); + cy.get('[data-cy=side-panel-tree]').contains('Groups').click(); + cy.get('[data-cy=groups-panel-data-explorer]').contains(groupName).click(); + + // Check that other user is hidden + cy.get('[data-cy=group-details-permissions-tab]').click(); + cy.get('[data-cy=group-permissions-data-explorer]') + .should('not.contain', otherUser.user.full_name) + cy.get('[data-cy=group-details-members-tab]').click(); + + // Test unhide + cy.get('[data-cy=group-members-data-explorer]') + .contains(otherUser.user.full_name) + .parents('tr') + .within(() => { + cy.get('[data-cy=user-visible-checkbox]').click(); + }); + // Check that other user is visible + cy.get('[data-cy=group-details-permissions-tab]').click(); + cy.get('[data-cy=group-permissions-data-explorer]') + .contains(otherUser.user.full_name) + .parents('tr') + .within(() => { + cy.contains('Read'); + }); + // Test re-hide + cy.get('[data-cy=group-details-members-tab]').click(); + cy.get('[data-cy=group-members-data-explorer]') + .contains(otherUser.user.full_name) + .parents('tr') + .within(() => { + cy.get('[data-cy=user-visible-checkbox]').click(); + }); + // Check that other user is hidden + cy.get('[data-cy=group-details-permissions-tab]').click(); + cy.get('[data-cy=group-permissions-data-explorer]') + .should('not.contain', otherUser.user.full_name) + }); + + it('displays resources shared with the group', function() { + // Switch to activeUser + cy.loginAs(activeUser); + cy.get('[data-cy=side-panel-tree]').contains('Groups').click(); + + // Get groupUuid and create shared project + cy.get('[data-cy=groups-panel-data-explorer]') + .contains(groupName) + .parents('tr') + .find('[data-cy=uuid]') + .invoke('text') + .as('groupUuid') + .then((groupUuid) => { + cy.createProject({ + owningUser: activeUser, + projectName: 'test-project', + }).as('testProject').then((testProject) => { + cy.shareWith(activeUser.token, groupUuid, testProject.uuid, 'can_read'); + }); + }); + + // Check that the project is listed in permissions + cy.get('[data-cy=groups-panel-data-explorer]').contains(groupName).click(); + cy.get('[data-cy=group-details-permissions-tab]').click(); + cy.get('[data-cy=group-permissions-data-explorer]') + .contains('test-project') + .parents('tr') + .within(() => { + cy.contains('Read'); + }); + }); + + it('removes users from the group', function() { + cy.get('[data-cy=side-panel-tree]').contains('Groups').click(); + cy.get('[data-cy=groups-panel-data-explorer]').contains(groupName).click(); + + // Remove other user + cy.get('[data-cy=group-members-data-explorer]') + .contains(otherUser.user.full_name) + .parents('tr') + .within(() => { + cy.get('[data-cy=resource-delete-button]').click(); + }); + cy.get('[data-cy=confirmation-dialog-ok-btn]').click(); + cy.get('[data-cy=group-members-data-explorer]') + .should('not.contain', otherUser.user.full_name); + + // Remove user three + cy.get('[data-cy=group-members-data-explorer]') + .contains(userThree.user.full_name) + .parents('tr') + .within(() => { + cy.get('[data-cy=resource-delete-button]').click(); + }); + cy.get('[data-cy=confirmation-dialog-ok-btn]').click(); + cy.get('[data-cy=group-members-data-explorer]') + .should('not.contain', userThree.user.full_name); + }); + + it('renames the group', function() { + // Navigate to Groups + cy.get('[data-cy=side-panel-tree]').contains('Groups').click(); + + // Open rename dialog + cy.get('[data-cy=groups-panel-data-explorer]') + .contains(groupName) + .rightclick(); + cy.get('[data-cy=context-menu]') + .contains('Rename') + .click(); + + // Rename the group + cy.get('[data-cy=form-dialog]') + .should('contain', 'Edit Group') + .within(() => { + cy.get('input[name=name]').clear().type(groupName + ' (renamed)'); + cy.get('button[type=submit]').click(); + }); + + // Check that the group was renamed + cy.get('[data-cy=groups-panel-data-explorer]') + .contains(groupName + ' (renamed)'); + }); + + it('deletes the group', function() { + // Navigate to Groups + cy.get('[data-cy=side-panel-tree]').contains('Groups').click(); + + // Delete the group + cy.get('[data-cy=groups-panel-data-explorer]') + .contains(groupName + ' (renamed)') + .rightclick(); + cy.get('[data-cy=context-menu]') + .contains('Remove') + .click(); + cy.get('[data-cy=confirmation-dialog-ok-btn]').click(); + + // Check that the group was deleted + cy.get('[data-cy=groups-panel-data-explorer]') + .should('not.contain', groupName + ' (renamed)'); + }); + + it('disables group-related controls for built-in groups', function() { + cy.loginAs(adminUser); + + ['All users', 'Anonymous users', 'System group'].forEach((builtInGroup) => { + cy.get('[data-cy=side-panel-tree]').contains('Groups').click(); + cy.get('[data-cy=groups-panel-data-explorer]').contains(builtInGroup).click(); + + // Check group member actions + cy.get('[data-cy=group-members-data-explorer]') + .within(() => { + cy.get('[data-cy=group-member-add]').should('not.exist'); + cy.get('[data-cy=user-visible-checkbox] input').should('be.disabled'); + cy.get('[data-cy=resource-delete-button]').should('be.disabled'); + cy.get('[data-cy=edit-permission-button]').should('not.exist'); + }); + + // Check permissions actions + cy.get('[data-cy=group-details-permissions-tab]').click(); + cy.get('[data-cy=group-permissions-data-explorer]').within(() => { + cy.get('[data-cy=resource-delete-button]').should('be.disabled'); + cy.get('[data-cy=edit-permission-button]').should('not.exist'); + }); + }); + }); + +}); diff --git a/src/common/labels.ts b/src/common/labels.ts index f534bd2b..682513fb 100644 --- a/src/common/labels.ts +++ b/src/common/labels.ts @@ -11,6 +11,8 @@ export const resourceLabel = (type: string, subtype = '') => { case ResourceKind.PROJECT: if (subtype === "filter") { return "Filter group"; + } else if (subtype === "role") { + return "Group"; } return "Project"; case ResourceKind.PROCESS: diff --git a/src/components/data-explorer/data-explorer.tsx b/src/components/data-explorer/data-explorer.tsx index 05125f12..55840ae9 100644 --- a/src/components/data-explorer/data-explorer.tsx +++ b/src/components/data-explorer/data-explorer.tsx @@ -110,18 +110,19 @@ export const DataExplorer = withStyles(styles)( doHidePanel, doMaximizePanel, panelName, panelMaximized } = this.props; - return + const dataCy = this.props["data-cy"]; + return {title && {title}} - {(!hideColumnSelector || !hideSearchInput) && + {(!hideColumnSelector || !hideSearchInput || !!actions) && -
+ {!hideSearchInput &&
{!hideSearchInput && } -
+
} {actions} {!hideColumnSelector && @@ -82,6 +85,11 @@ export const ReadOnlyIcon = (props: any) => ; +export const GroupsIcon = (props: any) => + + + ; + export const CollectionOldVersionIcon = (props: any) => }> @@ -155,3 +163,6 @@ export const WorkflowIcon: IconType = (props) => ; export const WarningIcon: IconType = (props) => ; export const Link: IconType = (props) => ; export const FolderSharedIcon: IconType = (props) => ; +export const CanReadIcon: IconType = (props) => ; +export const CanWriteIcon: IconType = (props) => ; +export const CanManageIcon: IconType = (props) => ; diff --git a/src/index.tsx b/src/index.tsx index 6ad22a55..0b04c29e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -59,6 +59,7 @@ import { linkActionSet } from 'views-components/context-menu/action-sets/link-ac import { loadFileViewersConfig } from 'store/file-viewers/file-viewers-actions'; import { processResourceAdminActionSet } from 'views-components/context-menu/action-sets/process-resource-admin-action-set'; import { filterGroupAdminActionSet, projectAdminActionSet } from 'views-components/context-menu/action-sets/project-admin-action-set'; +import { permissionEditActionSet } from 'views-components/context-menu/action-sets/permission-edit-action-set'; import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions"; import { openNotFoundDialog } from './store/not-found-panel/not-found-panel-action'; import { storeRedirects } from './common/redirect-to'; @@ -99,6 +100,7 @@ addMenuActionSet(ContextMenuKind.COLLECTION_ADMIN, collectionAdminActionSet); addMenuActionSet(ContextMenuKind.PROCESS_ADMIN, processResourceAdminActionSet); addMenuActionSet(ContextMenuKind.PROJECT_ADMIN, projectAdminActionSet); addMenuActionSet(ContextMenuKind.FILTER_GROUP_ADMIN, filterGroupAdminActionSet); +addMenuActionSet(ContextMenuKind.PERMISSION_EDIT, permissionEditActionSet); storeRedirects(); diff --git a/src/models/group.ts b/src/models/group.ts index 365e9cce..a0c22212 100644 --- a/src/models/group.ts +++ b/src/models/group.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { ResourceKind, TrashableResource } from "./resource"; +import { ResourceKind, TrashableResource, ResourceObjectType, RESOURCE_UUID_REGEX } from "./resource"; export interface GroupResource extends TrashableResource { kind: ResourceKind.GROUP; @@ -17,4 +17,17 @@ export interface GroupResource extends TrashableResource { export enum GroupClass { PROJECT = 'project', FILTER = 'filter', + ROLE = 'role', } + +export const BUILTIN_GROUP_IDS = [ + 'fffffffffffffff', + 'anonymouspublic', + '000000000000000', +] + +export const isBuiltinGroup = (uuid: string) => { + const match = RESOURCE_UUID_REGEX.exec(uuid); + const parts = match ? match[0].split('-') : []; + return parts.length === 3 && parts[1] === ResourceObjectType.GROUP && BUILTIN_GROUP_IDS.includes(parts[2]); +}; diff --git a/src/models/project.ts b/src/models/project.ts index 86ac04f6..b47b426f 100644 --- a/src/models/project.ts +++ b/src/models/project.ts @@ -5,7 +5,7 @@ import { GroupClass, GroupResource } from "./group"; export interface ProjectResource extends GroupResource { - groupClass: GroupClass.PROJECT | GroupClass.FILTER; + groupClass: GroupClass.PROJECT | GroupClass.FILTER | GroupClass.ROLE; } export const getProjectUrl = (uuid: string) => { diff --git a/src/store/context-menu/context-menu-actions.ts b/src/store/context-menu/context-menu-actions.ts index 59a6813b..9a8733ba 100644 --- a/src/store/context-menu/context-menu-actions.ts +++ b/src/store/context-menu/context-menu-actions.ts @@ -20,6 +20,7 @@ import { ProcessResource } from 'models/process'; import { CollectionResource } from 'models/collection'; import { GroupClass, GroupResource } from 'models/group'; import { GroupContentsResource } from 'services/groups-service/groups-service'; +import { LinkResource } from 'models/link'; export const contextMenuActions = unionize({ OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition, resource: ContextMenuResource }>(), @@ -193,6 +194,19 @@ export const openProcessContextMenu = (event: React.MouseEvent, pro } }; +export const openPermissionEditContextMenu = (event: React.MouseEvent, link: LinkResource) => + (dispatch: Dispatch, getState: () => RootState) => { + if (link) { + dispatch(openContextMenu(event, { + name: link.name, + uuid: link.uuid, + kind: link.kind, + menuKind: ContextMenuKind.PERMISSION_EDIT, + ownerUuid: link.ownerUuid, + })); + } + }; + export const resourceUuidToContextMenuKind = (uuid: string, readonly = false) => (dispatch: Dispatch, getState: () => RootState) => { const { isAdmin: isAdminUser, uuid: userUuid } = getState().auth.user!; diff --git a/src/store/group-details-panel/group-details-panel-actions.ts b/src/store/group-details-panel/group-details-panel-actions.ts index 01e6c151..e00ff773 100644 --- a/src/store/group-details-panel/group-details-panel-actions.ts +++ b/src/store/group-details-panel/group-details-panel-actions.ts @@ -6,74 +6,57 @@ import { bindDataExplorerActions } from 'store/data-explorer/data-explorer-actio import { Dispatch } from 'redux'; import { propertiesActions } from 'store/properties/properties-actions'; import { getProperty } from 'store/properties/properties'; -import { Participant } from 'views-components/sharing-dialog/participant-select'; import { dialogActions } from 'store/dialog/dialog-actions'; -import { reset, startSubmit } from 'redux-form'; -import { addGroupMember, deleteGroupMember } from 'store/groups-panel/groups-panel-actions'; +import { deleteGroupMember } from 'store/groups-panel/groups-panel-actions'; import { getResource } from 'store/resources/resources'; -import { GroupResource } from 'models/group'; import { RootState } from 'store/store'; import { ServiceRepository } from 'services/services'; -import { PermissionResource } from 'models/permission'; +import { PermissionResource, PermissionLevel } from 'models/permission'; import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; -import { UserResource, getUserDisplayName } from 'models/user'; +import { LinkResource } from 'models/link'; +import { deleteResources } from 'store/resources/resources-actions'; +import { openSharingDialog } from 'store/sharing-dialog/sharing-dialog-actions'; -export const GROUP_DETAILS_PANEL_ID = 'groupDetailsPanel'; -export const ADD_GROUP_MEMBERS_DIALOG = 'addGrupMembers'; -export const ADD_GROUP_MEMBERS_FORM = 'addGrupMembers'; -export const ADD_GROUP_MEMBERS_USERS_FIELD_NAME = 'users'; +export const GROUP_DETAILS_MEMBERS_PANEL_ID = 'groupDetailsMembersPanel'; +export const GROUP_DETAILS_PERMISSIONS_PANEL_ID = 'groupDetailsPermissionsPanel'; export const MEMBER_ATTRIBUTES_DIALOG = 'memberAttributesDialog'; export const MEMBER_REMOVE_DIALOG = 'memberRemoveDialog'; -export const GroupDetailsPanelActions = bindDataExplorerActions(GROUP_DETAILS_PANEL_ID); +export const GroupMembersPanelActions = bindDataExplorerActions(GROUP_DETAILS_MEMBERS_PANEL_ID); +export const GroupPermissionsPanelActions = bindDataExplorerActions(GROUP_DETAILS_PERMISSIONS_PANEL_ID); export const loadGroupDetailsPanel = (groupUuid: string) => (dispatch: Dispatch) => { - dispatch(propertiesActions.SET_PROPERTY({ key: GROUP_DETAILS_PANEL_ID, value: groupUuid })); - dispatch(GroupDetailsPanelActions.REQUEST_ITEMS()); + dispatch(propertiesActions.SET_PROPERTY({ key: GROUP_DETAILS_MEMBERS_PANEL_ID, value: groupUuid })); + dispatch(GroupMembersPanelActions.REQUEST_ITEMS()); + dispatch(propertiesActions.SET_PROPERTY({ key: GROUP_DETAILS_PERMISSIONS_PANEL_ID, value: groupUuid })); + dispatch(GroupPermissionsPanelActions.REQUEST_ITEMS()); }; -export const getCurrentGroupDetailsPanelUuid = getProperty(GROUP_DETAILS_PANEL_ID); - -export interface AddGroupMembersFormData { - [ADD_GROUP_MEMBERS_USERS_FIELD_NAME]: Participant[]; -} +export const getCurrentGroupDetailsPanelUuid = getProperty(GROUP_DETAILS_MEMBERS_PANEL_ID); export const openAddGroupMembersDialog = () => - (dispatch: Dispatch) => { - dispatch(dialogActions.OPEN_DIALOG({ id: ADD_GROUP_MEMBERS_DIALOG, data: {} })); - dispatch(reset(ADD_GROUP_MEMBERS_FORM)); - }; - -export const addGroupMembers = ({ users }: AddGroupMembersFormData) => - - async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => { - + (dispatch: Dispatch, getState: () => RootState) => { const groupUuid = getCurrentGroupDetailsPanelUuid(getState().properties); - if (groupUuid) { + dispatch(openSharingDialog(groupUuid, () => { + dispatch(GroupMembersPanelActions.REQUEST_ITEMS()); + })); + } + }; - dispatch(startSubmit(ADD_GROUP_MEMBERS_FORM)); - - const group = getResource(groupUuid)(getState().resources); - - for (const user of users) { - - await addGroupMember({ - user, - group: { - uuid: groupUuid, - name: group ? group.name : groupUuid, - }, - dispatch, - permissionService, - }); - - } - - dispatch(dialogActions.CLOSE_DIALOG({ id: ADD_GROUP_MEMBERS_FORM })); - dispatch(GroupDetailsPanelActions.REQUEST_ITEMS()); - +export const editPermissionLevel = (uuid: string, level: PermissionLevel) => + async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => { + try { + await permissionService.update(uuid, {name: level}); + dispatch(GroupMembersPanelActions.REQUEST_ITEMS()); + dispatch(GroupPermissionsPanelActions.REQUEST_ITEMS()); + dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Permission level changed.', hideDuration: 2000 })); + } catch (e) { + dispatch(snackbarActions.OPEN_SNACKBAR({ + message: 'Failed to update permission', + kind: SnackbarKind.ERROR, + })); } }; @@ -104,28 +87,63 @@ export const removeGroupMember = (uuid: string) => const groupUuid = getCurrentGroupDetailsPanelUuid(getState().properties); if (groupUuid) { - - const group = getResource(groupUuid)(getState().resources); - const user = getResource(groupUuid)(getState().resources); - dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...', kind: SnackbarKind.INFO })); await deleteGroupMember({ - user: { + link: { uuid, - name: user ? getUserDisplayName(user) : uuid, - }, - group: { - uuid: groupUuid, - name: group ? group.name : groupUuid, }, permissionService, dispatch, }); dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS })); - dispatch(GroupDetailsPanelActions.REQUEST_ITEMS()); + dispatch(GroupMembersPanelActions.REQUEST_ITEMS()); } }; + +export const setMemberIsHidden = (memberLinkUuid: string, permissionLinkUuid: string, visible: boolean) => + async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => { + const memberLink = getResource(memberLinkUuid)(getState().resources); + + if (!visible && permissionLinkUuid) { + // Remove read permission + try { + await permissionService.delete(permissionLinkUuid); + dispatch(deleteResources([permissionLinkUuid])); + dispatch(GroupPermissionsPanelActions.REQUEST_ITEMS()); + dispatch(snackbarActions.OPEN_SNACKBAR({ + message: 'Removed read permission.', + hideDuration: 2000, + kind: SnackbarKind.SUCCESS, + })); + } catch (e) { + dispatch(snackbarActions.OPEN_SNACKBAR({ + message: 'Failed to remove permission', + kind: SnackbarKind.ERROR, + })); + } + } else if (visible && memberLink) { + // Create read permission + try { + await permissionService.create({ + headUuid: memberLink.tailUuid, + tailUuid: memberLink.headUuid, + name: PermissionLevel.CAN_READ, + }); + dispatch(snackbarActions.OPEN_SNACKBAR({ + message: 'Created read permission.', + hideDuration: 2000, + kind: SnackbarKind.SUCCESS, + })); + dispatch(GroupPermissionsPanelActions.REQUEST_ITEMS()); + } catch(e) { + dispatch(snackbarActions.OPEN_SNACKBAR({ + message: 'Failed to create permission', + kind: SnackbarKind.ERROR, + })); + } + } + }; diff --git a/src/store/group-details-panel/group-details-panel-middleware-service.ts b/src/store/group-details-panel/group-details-panel-members-middleware-service.ts similarity index 55% rename from src/store/group-details-panel/group-details-panel-middleware-service.ts rename to src/store/group-details-panel/group-details-panel-members-middleware-service.ts index 834b4c21..e6f18f7f 100644 --- a/src/store/group-details-panel/group-details-panel-middleware-service.ts +++ b/src/store/group-details-panel/group-details-panel-members-middleware-service.ts @@ -10,10 +10,11 @@ import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; import { getDataExplorer } from "store/data-explorer/data-explorer-reducer"; import { FilterBuilder } from 'services/api/filter-builder'; import { updateResources } from 'store/resources/resources-actions'; -import { getCurrentGroupDetailsPanelUuid, GroupDetailsPanelActions } from 'store/group-details-panel/group-details-panel-actions'; +import { getCurrentGroupDetailsPanelUuid, GroupMembersPanelActions } from 'store/group-details-panel/group-details-panel-actions'; import { LinkClass } from 'models/link'; +import { ResourceKind } from 'models/resource'; -export class GroupDetailsPanelMiddlewareService extends DataExplorerMiddlewareService { +export class GroupDetailsPanelMembersMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { super(id); @@ -26,24 +27,41 @@ export class GroupDetailsPanelMiddlewareService extends DataExplorerMiddlewareSe api.dispatch(groupsDetailsPanelDataExplorerIsNotSet()); } else { try { - const permissions = await this.services.permissionService.list({ + const groupResource = await this.services.groupsService.get(groupUuid); + api.dispatch(updateResources([groupResource])); + + const permissionsIn = await this.services.permissionService.list({ filters: new FilterBuilder() - .addEqual('tail_uuid', groupUuid) + .addEqual('head_uuid', groupUuid) .addEqual('link_class', LinkClass.PERMISSION) .getFilters() }); - api.dispatch(updateResources(permissions.items)); - const users = await this.services.userService.list({ + api.dispatch(updateResources(permissionsIn.items)); + + api.dispatch(GroupMembersPanelActions.SET_ITEMS({ + ...listResultsToDataExplorerItemsMeta(permissionsIn), + items: permissionsIn.items.map(item => item.uuid), + })); + + const usersIn = await this.services.userService.list({ filters: new FilterBuilder() - .addIn('uuid', permissions.items.map(item => item.headUuid)) + .addIn('uuid', permissionsIn.items + .filter((item) => item.tailKind === ResourceKind.USER) + .map(item => item.tailUuid)) .getFilters(), count: "none" }); - api.dispatch(GroupDetailsPanelActions.SET_ITEMS({ - ...listResultsToDataExplorerItemsMeta(permissions), - items: users.items.map(item => item.uuid), - })); - api.dispatch(updateResources(users.items)); + api.dispatch(updateResources(usersIn.items)); + + const projectsIn = await this.services.projectService.list({ + filters: new FilterBuilder() + .addIn('uuid', permissionsIn.items + .filter((item) => item.tailKind === ResourceKind.PROJECT) + .map(item => item.tailUuid)) + .getFilters(), + count: "none" + }); + api.dispatch(updateResources(projectsIn.items)); } catch (e) { api.dispatch(couldNotFetchGroupDetailsContents()); } @@ -53,12 +71,12 @@ export class GroupDetailsPanelMiddlewareService extends DataExplorerMiddlewareSe const groupsDetailsPanelDataExplorerIsNotSet = () => snackbarActions.OPEN_SNACKBAR({ - message: 'Group details panel is not ready.', + message: 'Group members panel is not ready.', kind: SnackbarKind.ERROR }); const couldNotFetchGroupDetailsContents = () => snackbarActions.OPEN_SNACKBAR({ - message: 'Could not fetch group details.', + message: 'Could not fetch group members.', kind: SnackbarKind.ERROR }); diff --git a/src/store/group-details-panel/group-details-panel-permissions-middleware-service.ts b/src/store/group-details-panel/group-details-panel-permissions-middleware-service.ts new file mode 100644 index 00000000..9e41409d --- /dev/null +++ b/src/store/group-details-panel/group-details-panel-permissions-middleware-service.ts @@ -0,0 +1,89 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Dispatch, MiddlewareAPI } from "redux"; +import { DataExplorerMiddlewareService, listResultsToDataExplorerItemsMeta } from "store/data-explorer/data-explorer-middleware-service"; +import { RootState } from "store/store"; +import { ServiceRepository } from "services/services"; +import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; +import { getDataExplorer } from "store/data-explorer/data-explorer-reducer"; +import { FilterBuilder } from 'services/api/filter-builder'; +import { updateResources } from 'store/resources/resources-actions'; +import { getCurrentGroupDetailsPanelUuid, GroupPermissionsPanelActions } from 'store/group-details-panel/group-details-panel-actions'; +import { LinkClass } from 'models/link'; +import { ResourceKind } from 'models/resource'; + +export class GroupDetailsPanelPermissionsMiddlewareService extends DataExplorerMiddlewareService { + + constructor(private services: ServiceRepository, id: string) { + super(id); + } + + async requestItems(api: MiddlewareAPI) { + const dataExplorer = getDataExplorer(api.getState().dataExplorer, this.getId()); + const groupUuid = getCurrentGroupDetailsPanelUuid(api.getState().properties); + if (!dataExplorer || !groupUuid) { + api.dispatch(groupsDetailsPanelDataExplorerIsNotSet()); + } else { + try { + const permissionsOut = await this.services.permissionService.list({ + filters: new FilterBuilder() + .addEqual('tail_uuid', groupUuid) + .addEqual('link_class', LinkClass.PERMISSION) + .getFilters() + }); + api.dispatch(updateResources(permissionsOut.items)); + + api.dispatch(GroupPermissionsPanelActions.SET_ITEMS({ + ...listResultsToDataExplorerItemsMeta(permissionsOut), + items: permissionsOut.items.map(item => item.uuid), + })); + + const usersOut = await this.services.userService.list({ + filters: new FilterBuilder() + .addIn('uuid', permissionsOut.items + .filter((item) => item.headKind === ResourceKind.USER) + .map(item => item.headUuid)) + .getFilters(), + count: "none" + }); + api.dispatch(updateResources(usersOut.items)); + + const collectionsOut = await this.services.collectionService.list({ + filters: new FilterBuilder() + .addIn('uuid', permissionsOut.items + .filter((item) => item.headKind === ResourceKind.COLLECTION) + .map(item => item.headUuid)) + .getFilters(), + count: "none" + }); + api.dispatch(updateResources(collectionsOut.items)); + + const projectsOut = await this.services.projectService.list({ + filters: new FilterBuilder() + .addIn('uuid', permissionsOut.items + .filter((item) => item.headKind === ResourceKind.PROJECT) + .map(item => item.headUuid)) + .getFilters(), + count: "none" + }); + api.dispatch(updateResources(projectsOut.items)); + } catch (e) { + api.dispatch(couldNotFetchGroupDetailsContents()); + } + } + } +} + +const groupsDetailsPanelDataExplorerIsNotSet = () => + snackbarActions.OPEN_SNACKBAR({ + message: 'Group permissions panel is not ready.', + kind: SnackbarKind.ERROR + }); + +const couldNotFetchGroupDetailsContents = () => + snackbarActions.OPEN_SNACKBAR({ + message: 'Could not fetch group permissions.', + kind: SnackbarKind.ERROR + }); diff --git a/src/store/groups-panel/groups-panel-actions.ts b/src/store/groups-panel/groups-panel-actions.ts index 0d92e946..c72b0017 100644 --- a/src/store/groups-panel/groups-panel-actions.ts +++ b/src/store/groups-panel/groups-panel-actions.ts @@ -3,25 +3,22 @@ // SPDX-License-Identifier: AGPL-3.0 import { Dispatch } from 'redux'; -import { reset, startSubmit, stopSubmit, FormErrors } from 'redux-form'; +import { reset, startSubmit, stopSubmit, FormErrors, initialize } from 'redux-form'; import { bindDataExplorerActions } from "store/data-explorer/data-explorer-action"; import { dialogActions } from 'store/dialog/dialog-actions'; -import { Participant } from 'views-components/sharing-dialog/participant-select'; import { RootState } from 'store/store'; import { ServiceRepository } from 'services/services'; import { getResource } from 'store/resources/resources'; -import { GroupResource } from 'models/group'; +import { GroupResource, GroupClass } from 'models/group'; import { getCommonResourceServiceError, CommonResourceServiceError } from 'services/common-service/common-resource-service'; import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; import { PermissionLevel } from 'models/permission'; import { PermissionService } from 'services/permission-service/permission-service'; import { FilterBuilder } from 'services/api/filter-builder'; +import { ProjectUpdateFormDialogData, PROJECT_UPDATE_FORM_NAME } from 'store/projects/project-update-actions'; export const GROUPS_PANEL_ID = "groupsPanel"; -export const CREATE_GROUP_DIALOG = "createGroupDialog"; -export const CREATE_GROUP_FORM = "createGroupForm"; -export const CREATE_GROUP_NAME_FIELD_NAME = 'name'; -export const CREATE_GROUP_USERS_FIELD_NAME = 'users'; + export const GROUP_ATTRIBUTES_DIALOG = 'groupAttributesDialog'; export const GROUP_REMOVE_DIALOG = 'groupRemoveDialog'; @@ -30,9 +27,9 @@ export const GroupsPanelActions = bindDataExplorerActions(GROUPS_PANEL_ID); export const loadGroupsPanel = () => GroupsPanelActions.REQUEST_ITEMS(); export const openCreateGroupDialog = () => - (dispatch: Dispatch) => { - dispatch(dialogActions.OPEN_DIALOG({ id: CREATE_GROUP_DIALOG, data: {} })); - dispatch(reset(CREATE_GROUP_FORM)); + (dispatch: Dispatch, getState: () => RootState) => { + dispatch(initialize(PROJECT_UPDATE_FORM_NAME, {})); + dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_UPDATE_FORM_NAME, data: {sourcePanel: GroupClass.ROLE, create: true} })); }; export const openGroupAttributes = (uuid: string) => @@ -63,16 +60,38 @@ export const openRemoveGroupDialog = (uuid: string) => })); }; -export interface CreateGroupFormData { - [CREATE_GROUP_NAME_FIELD_NAME]: string; - [CREATE_GROUP_USERS_FIELD_NAME]?: Participant[]; -} +// Group edit dialog uses project update dialog with sourcePanel set to reload the appropriate parts +export const openGroupUpdateDialog = (resource: ProjectUpdateFormDialogData) => + (dispatch: Dispatch, getState: () => RootState) => { + dispatch(initialize(PROJECT_UPDATE_FORM_NAME, resource)); + dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_UPDATE_FORM_NAME, data: {sourcePanel: GroupClass.ROLE} })); + }; -export const createGroup = ({ name, users = [] }: CreateGroupFormData) => +export const updateGroup = (project: ProjectUpdateFormDialogData) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const uuid = project.uuid || ''; + dispatch(startSubmit(PROJECT_UPDATE_FORM_NAME)); + try { + const updatedGroup = await services.groupsService.update(uuid, { name: project.name, description: project.description }); + dispatch(GroupsPanelActions.REQUEST_ITEMS()); + dispatch(reset(PROJECT_UPDATE_FORM_NAME)); + dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME })); + return updatedGroup; + } catch (e) { + dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME)); + const error = getCommonResourceServiceError(e); + if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) { + dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME, { name: 'Group with the same name already exists.' } as FormErrors)); + } + return ; + } + }; + +export const createGroup = ({ name, users = [], description }: ProjectUpdateFormDialogData) => async (dispatch: Dispatch, _: {}, { groupsService, permissionService }: ServiceRepository) => { - dispatch(startSubmit(CREATE_GROUP_FORM)); + dispatch(startSubmit(PROJECT_UPDATE_FORM_NAME)); try { - const newGroup = await groupsService.create({ name }); + const newGroup = await groupsService.create({ name, description, groupClass: GroupClass.ROLE }); for (const user of users) { await addGroupMember({ user, @@ -81,8 +100,8 @@ export const createGroup = ({ name, users = [] }: CreateGroupFormData) => permissionService, }); } - dispatch(dialogActions.CLOSE_DIALOG({ id: CREATE_GROUP_DIALOG })); - dispatch(reset(CREATE_GROUP_FORM)); + dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME })); + dispatch(reset(PROJECT_UPDATE_FORM_NAME)); dispatch(loadGroupsPanel()); dispatch(snackbarActions.OPEN_SNACKBAR({ message: `${newGroup.name} group has been created`, @@ -92,7 +111,7 @@ export const createGroup = ({ name, users = [] }: CreateGroupFormData) => } catch (e) { const error = getCommonResourceServiceError(e); if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) { - dispatch(stopSubmit(CREATE_GROUP_FORM, { name: 'Group with the same name already exists.' } as FormErrors)); + dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME, { name: 'Group with the same name already exists.' } as FormErrors)); } return; } @@ -113,8 +132,8 @@ interface AddGroupMemberArgs { */ export const addGroupMember = async ({ user, group, ...args }: AddGroupMemberArgs) => { await createPermission({ - head: { ...user }, - tail: { ...group }, + head: { ...group }, + tail: { ...user }, permissionLevel: PermissionLevel.CAN_READ, ...args, }); @@ -144,33 +163,29 @@ const createPermission = async ({ head, tail, permissionLevel, dispatch, permiss }; interface DeleteGroupMemberArgs { - user: { uuid: string, name: string }; - group: { uuid: string, name: string }; + link: { uuid: string }; dispatch: Dispatch; permissionService: PermissionService; } -export const deleteGroupMember = async ({ user, group, ...args }: DeleteGroupMemberArgs) => { +export const deleteGroupMember = async ({ link, ...args }: DeleteGroupMemberArgs) => { await deletePermission({ - tail: group, - head: user, + uuid: link.uuid, ...args, }); }; interface DeletePermissionLinkArgs { - head: { uuid: string, name: string }; - tail: { uuid: string, name: string }; + uuid: string; dispatch: Dispatch; permissionService: PermissionService; } -export const deletePermission = async ({ head, tail, dispatch, permissionService }: DeletePermissionLinkArgs) => { +export const deletePermission = async ({ uuid, dispatch, permissionService }: DeletePermissionLinkArgs) => { try { const permissionsResponse = await permissionService.list({ filters: new FilterBuilder() - .addEqual('tail_uuid', tail.uuid) - .addEqual('head_uuid', head.uuid) + .addEqual('uuid', uuid) .getFilters() }); const [permission] = permissionsResponse.items; @@ -181,8 +196,8 @@ export const deletePermission = async ({ head, tail, dispatch, permissionService } } catch (e) { dispatch(snackbarActions.OPEN_SNACKBAR({ - message: `Could not delete ${tail.name} -> ${head.name} relation`, + message: `Could not delete ${uuid} permission`, kind: SnackbarKind.ERROR, })); } -}; \ No newline at end of file +}; diff --git a/src/store/groups-panel/groups-panel-middleware-service.ts b/src/store/groups-panel/groups-panel-middleware-service.ts index 5fb4718d..28415506 100644 --- a/src/store/groups-panel/groups-panel-middleware-service.ts +++ b/src/store/groups-panel/groups-panel-middleware-service.ts @@ -36,7 +36,7 @@ export class GroupsPanelMiddlewareService extends DataExplorerMiddlewareService order.addOrder(direction, 'name'); } const filters = new FilterBuilder() - .addNotIn('group_class', [GroupClass.PROJECT, GroupClass.FILTER]) + .addEqual('group_class', GroupClass.ROLE) .addILike('name', dataExplorer.searchValue) .getFilters(); const response = await this.services.groupsService @@ -52,7 +52,7 @@ export class GroupsPanelMiddlewareService extends DataExplorerMiddlewareService })); const permissions = await this.services.permissionService.list({ filters: new FilterBuilder() - .addIn('tail_uuid', response.items.map(item => item.uuid)) + .addIn('head_uuid', response.items.map(item => item.uuid)) .getFilters() }); api.dispatch(updateResources(permissions.items)); @@ -74,4 +74,3 @@ const couldNotFetchFavoritesContents = () => message: 'Could not fetch groups.', kind: SnackbarKind.ERROR }); - diff --git a/src/store/navigation/navigation-action.ts b/src/store/navigation/navigation-action.ts index 97082e5a..19cc36ae 100644 --- a/src/store/navigation/navigation-action.ts +++ b/src/store/navigation/navigation-action.ts @@ -9,7 +9,6 @@ import { SidePanelTreeCategory } from '../side-panel-tree/side-panel-tree-action import { Routes, getProcessLogUrl, getGroupUrl, getNavUrl } from 'routes/routes'; import { RootState } from 'store/store'; import { ServiceRepository } from 'services/services'; -import { GROUPS_PANEL_LABEL } from 'store/breadcrumbs/breadcrumbs-actions'; import { pluginConfig } from 'plugins'; import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; @@ -64,7 +63,7 @@ export const navigateTo = (uuid: string) => case SidePanelTreeCategory.TRASH: dispatch(navigateToTrash); return; - case GROUPS_PANEL_LABEL: + case SidePanelTreeCategory.GROUPS: dispatch(navigateToGroups); return; case SidePanelTreeCategory.ALL_PROCESSES: diff --git a/src/store/projects/project-update-actions.ts b/src/store/projects/project-update-actions.ts index 35100eb6..ba176753 100644 --- a/src/store/projects/project-update-actions.ts +++ b/src/store/projects/project-update-actions.ts @@ -3,16 +3,19 @@ // SPDX-License-Identifier: AGPL-3.0 import { Dispatch } from "redux"; -import { FormErrors, initialize, startSubmit, stopSubmit } from 'redux-form'; +import { FormErrors, initialize, reset, startSubmit, stopSubmit } from 'redux-form'; import { RootState } from "store/store"; import { dialogActions } from "store/dialog/dialog-actions"; import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service"; import { ServiceRepository } from "services/services"; import { projectPanelActions } from 'store/project-panel/project-panel-action'; +import { GroupClass } from "models/group"; +import { Participant } from "views-components/sharing-dialog/participant-select"; export interface ProjectUpdateFormDialogData { uuid: string; name: string; + users?: Participant[]; description?: string; } @@ -21,7 +24,7 @@ export const PROJECT_UPDATE_FORM_NAME = 'projectUpdateFormName'; export const openProjectUpdateDialog = (resource: ProjectUpdateFormDialogData) => (dispatch: Dispatch, getState: () => RootState) => { dispatch(initialize(PROJECT_UPDATE_FORM_NAME, resource)); - dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_UPDATE_FORM_NAME, data: {} })); + dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_UPDATE_FORM_NAME, data: {sourcePanel: GroupClass.PROJECT} })); }; export const updateProject = (project: ProjectUpdateFormDialogData) => @@ -31,6 +34,7 @@ export const updateProject = (project: ProjectUpdateFormDialogData) => try { const updatedProject = await services.projectService.update(uuid, { name: project.name, description: project.description }); dispatch(projectPanelActions.REQUEST_ITEMS()); + dispatch(reset(PROJECT_UPDATE_FORM_NAME)); dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME })); return updatedProject; } catch (e) { diff --git a/src/store/resources/resources-actions.ts b/src/store/resources/resources-actions.ts index cdb76e0b..6c05da32 100644 --- a/src/store/resources/resources-actions.ts +++ b/src/store/resources/resources-actions.ts @@ -18,6 +18,8 @@ export type ResourcesAction = UnionOf; export const updateResources = (resources: Resource[]) => resourcesActions.SET_RESOURCES(resources); +export const deleteResources = (resources: string[]) => resourcesActions.DELETE_RESOURCES(resources); + export const loadResource = (uuid: string, showErrors?: boolean) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { try { diff --git a/src/store/sharing-dialog/sharing-dialog-actions.ts b/src/store/sharing-dialog/sharing-dialog-actions.ts index 54ad6791..4c0b8825 100644 --- a/src/store/sharing-dialog/sharing-dialog-actions.ts +++ b/src/store/sharing-dialog/sharing-dialog-actions.ts @@ -21,9 +21,9 @@ import { progressIndicatorActions } from 'store/progress-indicator/progress-indi import { snackbarActions, SnackbarKind } from "../snackbar/snackbar-actions"; import { extractUuidKind, ResourceKind } from "models/resource"; -export const openSharingDialog = (resourceUuid: string) => +export const openSharingDialog = (resourceUuid: string, refresh?: () => void) => (dispatch: Dispatch) => { - dispatch(dialogActions.OPEN_DIALOG({ id: SHARING_DIALOG_NAME, data: resourceUuid })); + dispatch(dialogActions.OPEN_DIALOG({ id: SHARING_DIALOG_NAME, data: {resourceUuid, refresh} })); dispatch(loadSharingDialog); }; @@ -34,16 +34,21 @@ export const connectSharingDialog = withDialog(SHARING_DIALOG_NAME); export const connectSharingDialogProgress = withProgress(SHARING_DIALOG_NAME); -export const saveSharingDialogChanges = async (dispatch: Dispatch) => { +export const saveSharingDialogChanges = async (dispatch: Dispatch, getState: () => RootState) => { dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME)); await dispatch(savePublicPermissionChanges); await dispatch(saveManagementChanges); await dispatch(sendInvitations); dispatch(reset(SHARING_INVITATION_FORM_NAME)); await dispatch(loadSharingDialog); + + const dialog = getDialog(getState().dialog, SHARING_DIALOG_NAME); + if (dialog && dialog.data.refresh) { + dialog.data.refresh(); + } }; -export const sendSharingInvitations = async (dispatch: Dispatch) => { +export const sendSharingInvitations = async (dispatch: Dispatch, getState: () => RootState) => { dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME)); await dispatch(sendInvitations); dispatch(closeSharingDialog()); @@ -52,15 +57,25 @@ export const sendSharingInvitations = async (dispatch: Dispatch) => { kind: SnackbarKind.SUCCESS, })); dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME)); + + const dialog = getDialog(getState().dialog, SHARING_DIALOG_NAME); + if (dialog && dialog.data.refresh) { + dialog.data.refresh(); + } }; +interface SharingDialogData { + resourceUuid: string; + refresh: () => void; +} + const loadSharingDialog = async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => { - const dialog = getDialog(getState().dialog, SHARING_DIALOG_NAME); + const dialog = getDialog(getState().dialog, SHARING_DIALOG_NAME); if (dialog) { dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME)); try { - const { items } = await permissionService.listResourcePermissions(dialog.data); + const { items } = await permissionService.listResourcePermissions(dialog.data.resourceUuid); dispatch(initializePublicAccessForm(items)); await dispatch(initializeManagementForm(items)); dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME)); @@ -133,7 +148,7 @@ const initializePublicAccessForm = (permissionLinks: PermissionResource[]) => const savePublicPermissionChanges = async (_: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => { const state = getState(); const { user } = state.auth; - const dialog = getDialog(state.dialog, SHARING_DIALOG_NAME); + const dialog = getDialog(state.dialog, SHARING_DIALOG_NAME); if (dialog && user) { const { permissionUuid, visibility } = getSharingPublicAccessFormData(state); @@ -150,7 +165,7 @@ const savePublicPermissionChanges = async (_: Dispatch, getState: () => RootStat await permissionService.create({ ownerUuid: user.uuid, - headUuid: dialog.data, + headUuid: dialog.data.resourceUuid, tailUuid: getPublicGroupUuid(state), name: PermissionLevel.CAN_READ, }); @@ -197,7 +212,7 @@ const saveManagementChanges = async (_: Dispatch, getState: () => RootState, { p const sendInvitations = async (_: Dispatch, getState: () => RootState, { permissionService, userService }: ServiceRepository) => { const state = getState(); const { user } = state.auth; - const dialog = getDialog(state.dialog, SHARING_DIALOG_NAME); + const dialog = getDialog(state.dialog, SHARING_DIALOG_NAME); if (dialog && user) { const invitations = getFormValues(SHARING_INVITATION_FORM_NAME)(state) as SharingInvitationFormData; @@ -207,7 +222,7 @@ const sendInvitations = async (_: Dispatch, getState: () => RootState, { permiss const invitationDataUsers = getUsersFromForm .map(person => ({ ownerUuid: user.uuid, - headUuid: dialog.data, + headUuid: dialog.data.resourceUuid, tailUuid: person.uuid, name: invitations.permissions })); @@ -215,7 +230,7 @@ const sendInvitations = async (_: Dispatch, getState: () => RootState, { permiss const invitationsDataGroups = getGroupsFromForm.map( group => ({ ownerUuid: user.uuid, - headUuid: dialog.data, + headUuid: dialog.data.resourceUuid, tailUuid: group.uuid, name: invitations.permissions }) diff --git a/src/store/side-panel-tree/side-panel-tree-actions.ts b/src/store/side-panel-tree/side-panel-tree-actions.ts index 66521f35..895fe79c 100644 --- a/src/store/side-panel-tree/side-panel-tree-actions.ts +++ b/src/store/side-panel-tree/side-panel-tree-actions.ts @@ -26,7 +26,8 @@ export enum SidePanelTreeCategory { WORKFLOWS = 'Workflows', FAVORITES = 'My Favorites', TRASH = 'Trash', - ALL_PROCESSES = 'All Processes' + ALL_PROCESSES = 'All Processes', + GROUPS = 'Groups', } export const SIDE_PANEL_TREE = 'sidePanelTree'; @@ -52,6 +53,7 @@ let SIDE_PANEL_CATEGORIES: string[] = [ SidePanelTreeCategory.PUBLIC_FAVORITES, SidePanelTreeCategory.FAVORITES, SidePanelTreeCategory.WORKFLOWS, + SidePanelTreeCategory.GROUPS, SidePanelTreeCategory.ALL_PROCESSES, SidePanelTreeCategory.TRASH ]; diff --git a/src/store/store.ts b/src/store/store.ts index 59a0cb12..688c8a05 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -51,8 +51,9 @@ import { UserMiddlewareService } from 'store/users/user-panel-middleware-service import { USERS_PANEL_ID } from 'store/users/users-actions'; import { GroupsPanelMiddlewareService } from 'store/groups-panel/groups-panel-middleware-service'; import { GROUPS_PANEL_ID } from 'store/groups-panel/groups-panel-actions'; -import { GroupDetailsPanelMiddlewareService } from 'store/group-details-panel/group-details-panel-middleware-service'; -import { GROUP_DETAILS_PANEL_ID } from 'store/group-details-panel/group-details-panel-actions'; +import { GroupDetailsPanelMembersMiddlewareService } from 'store/group-details-panel/group-details-panel-members-middleware-service'; +import { GroupDetailsPanelPermissionsMiddlewareService } from 'store/group-details-panel/group-details-panel-permissions-middleware-service'; +import { GROUP_DETAILS_MEMBERS_PANEL_ID, GROUP_DETAILS_PERMISSIONS_PANEL_ID } from 'store/group-details-panel/group-details-panel-actions'; import { LINK_PANEL_ID } from 'store/link-panel/link-panel-actions'; import { LinkMiddlewareService } from 'store/link-panel/link-panel-middleware-service'; import { API_CLIENT_AUTHORIZATION_PANEL_ID } from 'store/api-client-authorizations/api-client-authorizations-actions'; @@ -116,8 +117,11 @@ export function configureStore(history: History, services: ServiceRepository, co const groupsPanelMiddleware = dataExplorerMiddleware( new GroupsPanelMiddlewareService(services, GROUPS_PANEL_ID) ); - const groupDetailsPanelMiddleware = dataExplorerMiddleware( - new GroupDetailsPanelMiddlewareService(services, GROUP_DETAILS_PANEL_ID) + const groupDetailsPanelMembersMiddleware = dataExplorerMiddleware( + new GroupDetailsPanelMembersMiddlewareService(services, GROUP_DETAILS_MEMBERS_PANEL_ID) + ); + const groupDetailsPanelPermissionsMiddleware = dataExplorerMiddleware( + new GroupDetailsPanelPermissionsMiddlewareService(services, GROUP_DETAILS_PERMISSIONS_PANEL_ID) ); const linkPanelMiddleware = dataExplorerMiddleware( new LinkMiddlewareService(services, LINK_PANEL_ID) @@ -157,7 +161,8 @@ export function configureStore(history: History, services: ServiceRepository, co workflowPanelMiddleware, userPanelMiddleware, groupsPanelMiddleware, - groupDetailsPanelMiddleware, + groupDetailsPanelMembersMiddleware, + groupDetailsPanelPermissionsMiddleware, linkPanelMiddleware, apiClientAuthorizationMiddlewareService, publicFavoritesMiddleware, diff --git a/src/store/workbench/workbench-actions.ts b/src/store/workbench/workbench-actions.ts index 6ea30855..527d9d74 100644 --- a/src/store/workbench/workbench-actions.ts +++ b/src/store/workbench/workbench-actions.ts @@ -88,7 +88,7 @@ import { apiClientAuthorizationPanelColumns } from 'views/api-client-authorizati import * as groupPanelActions from 'store/groups-panel/groups-panel-actions'; import { groupsPanelColumns } from 'views/groups-panel/groups-panel'; import * as groupDetailsPanelActions from 'store/group-details-panel/group-details-panel-actions'; -import { groupDetailsPanelColumns } from 'views/group-details-panel/group-details-panel'; +import { groupDetailsMembersPanelColumns, groupDetailsPermissionsPanelColumns } from 'views/group-details-panel/group-details-panel'; import { DataTableFetchMode } from "components/data-table/data-table"; import { loadPublicFavoritePanel, publicFavoritePanelActions } from 'store/public-favorites-panel/public-favorites-action'; import { publicFavoritePanelColumns } from 'views/public-favorites-panel/public-favorites-panel'; @@ -136,7 +136,8 @@ export const loadWorkbench = () => dispatch(searchResultsPanelActions.SET_COLUMNS({ columns: searchResultsPanelColumns })); dispatch(userBindedActions.SET_COLUMNS({ columns: userPanelColumns })); dispatch(groupPanelActions.GroupsPanelActions.SET_COLUMNS({ columns: groupsPanelColumns })); - dispatch(groupDetailsPanelActions.GroupDetailsPanelActions.SET_COLUMNS({ columns: groupDetailsPanelColumns })); + dispatch(groupDetailsPanelActions.GroupMembersPanelActions.SET_COLUMNS({ columns: groupDetailsMembersPanelColumns })); + dispatch(groupDetailsPanelActions.GroupPermissionsPanelActions.SET_COLUMNS({ columns: groupDetailsPermissionsPanelColumns })); dispatch(linkPanelActions.SET_COLUMNS({ columns: linkPanelColumns })); dispatch(apiClientAuthorizationsActions.SET_COLUMNS({ columns: apiClientAuthorizationPanelColumns })); dispatch(collectionsContentAddressActions.SET_COLUMNS({ columns: collectionContentAddressPanelColumns })); @@ -271,6 +272,20 @@ export const updateProject = (data: projectUpdateActions.ProjectUpdateFormDialog } }; +export const updateGroup = (data: projectUpdateActions.ProjectUpdateFormDialogData) => + async (dispatch: Dispatch) => { + const updatedGroup = await dispatch(groupPanelActions.updateGroup(data)); + if (updatedGroup) { + dispatch(snackbarActions.OPEN_SNACKBAR({ + message: "Group has been successfully updated.", + hideDuration: 2000, + kind: SnackbarKind.SUCCESS + })); + await dispatch(loadSidePanelTreeProjects(updatedGroup.ownerUuid)); + dispatch(reloadProjectMatchingUuid([updatedGroup.ownerUuid, updatedGroup.uuid])); + } + }; + export const loadCollection = (uuid: string) => handleFirstTimeLoad( async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { diff --git a/src/views-components/context-menu/action-sets/group-action-set.ts b/src/views-components/context-menu/action-sets/group-action-set.ts index ad38cbeb..874a601b 100644 --- a/src/views-components/context-menu/action-sets/group-action-set.ts +++ b/src/views-components/context-menu/action-sets/group-action-set.ts @@ -3,11 +3,17 @@ // SPDX-License-Identifier: AGPL-3.0 import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set"; -import { AdvancedIcon, RemoveIcon, AttributesIcon } from "components/icon/icon"; +import { RenameIcon, AdvancedIcon, RemoveIcon, AttributesIcon } from "components/icon/icon"; import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab"; -import { openGroupAttributes, openRemoveGroupDialog } from "store/groups-panel/groups-panel-actions"; +import { openGroupAttributes, openRemoveGroupDialog, openGroupUpdateDialog } from "store/groups-panel/groups-panel-actions"; export const groupActionSet: ContextMenuActionSet = [[{ + name: "Rename", + icon: RenameIcon, + execute: (dispatch, resource) => { + dispatch(openGroupUpdateDialog(resource)); + } +}, { name: "Attributes", icon: AttributesIcon, execute: (dispatch, { uuid }) => { @@ -25,4 +31,4 @@ export const groupActionSet: ContextMenuActionSet = [[{ execute: (dispatch, { uuid }) => { dispatch(openRemoveGroupDialog(uuid)); } -}]]; \ No newline at end of file +}]]; diff --git a/src/views-components/context-menu/action-sets/permission-edit-action-set.ts b/src/views-components/context-menu/action-sets/permission-edit-action-set.ts new file mode 100644 index 00000000..8663d3c7 --- /dev/null +++ b/src/views-components/context-menu/action-sets/permission-edit-action-set.ts @@ -0,0 +1,28 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set"; +import { CanReadIcon, CanManageIcon, CanWriteIcon } from "components/icon/icon"; +import { editPermissionLevel } from 'store/group-details-panel/group-details-panel-actions'; +import { PermissionLevel } from "models/permission"; + +export const permissionEditActionSet: ContextMenuActionSet = [[{ + name: "Read", + icon: CanReadIcon, + execute: (dispatch, { uuid }) => { + dispatch(editPermissionLevel(uuid, PermissionLevel.CAN_READ)); + } +}, { + name: "Write", + icon: CanWriteIcon, + execute: (dispatch, { uuid }) => { + dispatch(editPermissionLevel(uuid, PermissionLevel.CAN_WRITE)); + } +}, { + name: "Manage", + icon: CanManageIcon, + execute: (dispatch, { uuid }) => { + dispatch(editPermissionLevel(uuid, PermissionLevel.CAN_MANAGE)); + } +}]]; diff --git a/src/views-components/context-menu/context-menu.tsx b/src/views-components/context-menu/context-menu.tsx index 603ee90b..f2c43ced 100644 --- a/src/views-components/context-menu/context-menu.tsx +++ b/src/views-components/context-menu/context-menu.tsx @@ -98,5 +98,6 @@ export enum ContextMenuKind { USER = "User", GROUPS = "Group", GROUP_MEMBER = "GroupMember", + PERMISSION_EDIT = "PermissionEdit", LINK = "Link", } diff --git a/src/views-components/data-explorer/renderers.tsx b/src/views-components/data-explorer/renderers.tsx index 3965e69d..901704d9 100644 --- a/src/views-components/data-explorer/renderers.tsx +++ b/src/views-components/data-explorer/renderers.tsx @@ -6,12 +6,12 @@ import React from 'react'; import { Grid, Typography, withStyles, Tooltip, IconButton, Checkbox } from '@material-ui/core'; import { FavoriteStar, PublicFavoriteStar } from '../favorite-star/favorite-star'; import { Resource, ResourceKind, TrashableResource } from 'models/resource'; -import { ProjectIcon, FilterGroupIcon, CollectionIcon, ProcessIcon, DefaultIcon, ShareIcon, CollectionOldVersionIcon, WorkflowIcon } from 'components/icon/icon'; +import { ProjectIcon, FilterGroupIcon, CollectionIcon, ProcessIcon, DefaultIcon, ShareIcon, CollectionOldVersionIcon, WorkflowIcon, RemoveIcon, RenameIcon } from 'components/icon/icon'; import { formatDate, formatFileSize, formatTime } from 'common/formatters'; import { resourceLabel } from 'common/labels'; import { connect, DispatchProp } from 'react-redux'; import { RootState } from 'store/store'; -import { getResource } from 'store/resources/resources'; +import { getResource, filterResources } from 'store/resources/resources'; import { GroupContentsResource } from 'services/groups-service/groups-service'; import { getProcess, Process, getProcessStatus, getProcessStatusColor, getProcessRuntime } from 'store/processes/process'; import { ArvadosTheme } from 'common/custom-theme'; @@ -20,23 +20,31 @@ import { WorkflowResource } from 'models/workflow'; import { ResourceStatus as WorkflowStatus } from 'views/workflow-panel/workflow-panel-view'; import { getUuidPrefix, openRunProcess } from 'store/workflow-panel/workflow-panel-actions'; import { openSharingDialog } from 'store/sharing-dialog/sharing-dialog-actions'; -import { getUserFullname, User, UserResource } from 'models/user'; +import { getUserFullname, getUserDisplayName, User, UserResource } from 'models/user'; import { toggleIsActive, toggleIsAdmin } from 'store/users/users-actions'; -import { LinkResource } from 'models/link'; -import { navigateTo } from 'store/navigation/navigation-action'; +import { LinkClass, LinkResource } from 'models/link'; +import { navigateTo, navigateToGroupDetails } from 'store/navigation/navigation-action'; import { withResourceData } from 'views-components/data-explorer/with-resources'; import { CollectionResource } from 'models/collection'; import { IllegalNamingWarning } from 'components/warning/warning'; import { loadResource } from 'store/resources/resources-actions'; -import { GroupClass } from 'models/group'; - -const renderName = (dispatch: Dispatch, item: GroupContentsResource) => - +import { GroupClass, GroupResource, isBuiltinGroup } from 'models/group'; +import { openRemoveGroupMemberDialog } from 'store/group-details-panel/group-details-panel-actions'; +import { setMemberIsHidden } from 'store/group-details-panel/group-details-panel-actions'; +import { formatPermissionLevel } from 'views-components/sharing-dialog/permission-select'; +import { PermissionLevel } from 'models/permission'; +import { openPermissionEditContextMenu } from 'store/context-menu/context-menu-actions'; +import { getUserUuid } from 'common/getuser'; + +const renderName = (dispatch: Dispatch, item: GroupContentsResource) => { + + const navFunc = ("groupClass" in item && item.groupClass === GroupClass.ROLE ? navigateToGroupDetails : navigateTo); + return {renderIcon(item)} - dispatch(navigateTo(item.uuid))}> + dispatch(navFunc(item.uuid))}> {item.kind === ResourceKind.PROJECT || item.kind === ResourceKind.COLLECTION ? : null} @@ -50,6 +58,7 @@ const renderName = (dispatch: Dispatch, item: GroupContentsResource) => ; +}; export const ResourceName = connect( (state: RootState, props: { uuid: string }) => { @@ -131,11 +140,11 @@ export const ResourceShare = connect( })((props: { ownerUuid?: string, uuidPrefix: string, uuid?: string } & DispatchProp) => resourceShare(props.dispatch, props.uuidPrefix, props.ownerUuid, props.uuid)); +// User Resources const renderFirstName = (item: { firstName: string }) => { return {item.firstName}; }; -// User Resources export const ResourceFirstName = connect( (state: RootState, props: { uuid: string }) => { const resource = getResource(props.uuid)(state.resources); @@ -151,8 +160,18 @@ export const ResourceLastName = connect( return resource || { lastName: '' }; })(renderLastName); +const renderFullName = (item: { firstName: string, lastName: string }) => + {(item.firstName + " " + item.lastName).trim()}; + +export const ResourceFullName = connect( + (state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + return resource || { firstName: '', lastName: '' }; + })(renderFullName); + + const renderUuid = (item: { uuid: string }) => - {item.uuid}; + {item.uuid}; export const ResourceUuid = connect( (state: RootState, props: { uuid: string }) => { @@ -169,19 +188,76 @@ export const ResourceEmail = connect( return resource || { email: '' }; })(renderEmail); -const renderIsActive = (props: { uuid: string, isActive: boolean, toggleIsActive: (uuid: string) => void }) => - props.toggleIsActive(props.uuid)} />; +const renderIsActive = (props: { uuid: string, kind: ResourceKind, isActive: boolean, toggleIsActive: (uuid: string) => void, disabled?: boolean }) => { + if (props.kind === ResourceKind.USER) { + return props.toggleIsActive(props.uuid)} />; + } else { + return ; + } +} export const ResourceIsActive = connect( - (state: RootState, props: { uuid: string }) => { + (state: RootState, props: { uuid: string, disabled?: boolean }) => { const resource = getResource(props.uuid)(state.resources); - return resource || { isActive: false }; + return resource ? {...resource, disabled: !!props.disabled} : { isActive: false, kind: ResourceKind.NONE }; }, { toggleIsActive } )(renderIsActive); +export const ResourceLinkTailIsActive = connect( + (state: RootState, props: { uuid: string, disabled?: boolean }) => { + const link = getResource(props.uuid)(state.resources); + const tailResource = getResource(link?.tailUuid || '')(state.resources); + + return tailResource ? {...tailResource, disabled: !!props.disabled} : { isActive: false, kind: ResourceKind.NONE }; + }, { toggleIsActive } +)(renderIsActive); + +const renderIsHidden = (props: { + memberLinkUuid: string, + permissionLinkUuid: string, + visible: boolean, + canManage: boolean, + setMemberIsHidden: (memberLinkUuid: string, permissionLinkUuid: string, hide: boolean) => void + }) => { + if (props.memberLinkUuid) { + return props.setMemberIsHidden(props.memberLinkUuid, props.permissionLinkUuid, !props.visible)} />; + } else { + return ; + } +} + +export const ResourceLinkTailIsVisible = connect( + (state: RootState, props: { uuid: string }) => { + const link = getResource(props.uuid)(state.resources); + const member = getResource(link?.tailUuid || '')(state.resources); + const group = getResource(link?.headUuid || '')(state.resources); + const permissions = filterResources((resource: LinkResource) => { + return resource.linkClass === LinkClass.PERMISSION + && resource.headUuid === link?.tailUuid + && resource.tailUuid === group?.uuid + && resource.name === PermissionLevel.CAN_READ; + })(state.resources); + + const permissionLinkUuid = permissions.length > 0 ? permissions[0].uuid : ''; + const isVisible = link && group && permissions.length > 0; + // Consider whether the current user canManage this resurce in addition when it's possible + const isBuiltin = isBuiltinGroup(link?.headUuid || ''); + + return member?.kind === ResourceKind.USER + ? { memberLinkUuid: link?.uuid, permissionLinkUuid, visible: isVisible, canManage: !isBuiltin } + : { memberLinkUuid: '', permissionLinkUuid: '', visible: false, canManage: false }; + }, { setMemberIsHidden } +)(renderIsHidden); + const renderIsAdmin = (props: { uuid: string, isAdmin: boolean, toggleIsAdmin: (uuid: string) => void }) => { - const currentLabel = resourceLabel(item.tailKind); - const isUnknow = currentLabel === "Unknown"; - return (
- {!isUnknow ? ( - renderLink(dispatch, item.tailUuid, currentLabel) - ) : ( - - {item.tailUuid} - - )} -
); -}; +const getResourceDisplayName = (resource: Resource): string => { + if ((resource as UserResource).kind === ResourceKind.USER + && typeof (resource as UserResource).firstName !== 'undefined') { + // We can be sure the resource is UserResource + return getUserDisplayName(resource as UserResource); + } else { + return (resource as GroupContentsResource).name; + } +} + +const renderResourceLink = (dispatch: Dispatch, item: Resource) => { + var displayName = getResourceDisplayName(item); -const renderLink = (dispatch: Dispatch, uuid: string, label: string) => - dispatch(navigateTo(uuid))}> - {label}: {uuid} + return dispatch(navigateTo(item.uuid))}> + {resourceLabel(item.kind, item && item.kind === ResourceKind.GROUP ? (item as GroupResource).groupClass || '' : '')}: {displayName || item.uuid} ; +}; export const ResourceLinkTail = connect( (state: RootState, props: { uuid: string }) => { const resource = getResource(props.uuid)(state.resources); + const tailResource = getResource(resource?.tailUuid || '')(state.resources); + return { - item: resource || { uuid: '', tailUuid: '', tailKind: ResourceKind.NONE } + item: tailResource || { uuid: resource?.tailUuid || '', kind: resource?.tailKind || ResourceKind.NONE } }; - })((props: { item: any } & DispatchProp) => - renderLinkTail(props.dispatch, props.item)); - -const renderLinkHead = (dispatch: Dispatch, item: { uuid: string, headUuid: string, headKind: ResourceKind }) => - renderLink(dispatch, item.headUuid, resourceLabel(item.headKind)); + })((props: { item: Resource } & DispatchProp) => + renderResourceLink(props.dispatch, props.item)); export const ResourceLinkHead = connect( (state: RootState, props: { uuid: string }) => { const resource = getResource(props.uuid)(state.resources); + const headResource = getResource(resource?.headUuid || '')(state.resources); + return { - item: resource || { uuid: '', headUuid: '', headKind: ResourceKind.NONE } + item: headResource || { uuid: resource?.headUuid || '', kind: resource?.headKind || ResourceKind.NONE } }; - })((props: { item: any } & DispatchProp) => - renderLinkHead(props.dispatch, props.item)); + })((props: { item: Resource } & DispatchProp) => + renderResourceLink(props.dispatch, props.item)); export const ResourceLinkUuid = connect( (state: RootState, props: { uuid: string }) => { @@ -322,6 +398,117 @@ export const ResourceLinkUuid = connect( return resource || { uuid: '' }; })(renderUuid); +export const ResourceLinkHeadUuid = connect( + (state: RootState, props: { uuid: string }) => { + const link = getResource(props.uuid)(state.resources); + const headResource = getResource(link?.headUuid || '')(state.resources); + + return headResource || { uuid: '' }; + })(renderUuid); + +export const ResourceLinkTailUuid = connect( + (state: RootState, props: { uuid: string }) => { + const link = getResource(props.uuid)(state.resources); + const tailResource = getResource(link?.tailUuid || '')(state.resources); + + return tailResource || { uuid: '' }; + })(renderUuid); + +const renderLinkDelete = (dispatch: Dispatch, item: LinkResource, canManage: boolean) => { + if (item.uuid) { + return canManage ? + + dispatch(openRemoveGroupMemberDialog(item.uuid))}> + + + : + + + + + ; + } else { + return ; + } +} + +export const ResourceLinkDelete = connect( + (state: RootState, props: { uuid: string }) => { + const link = getResource(props.uuid)(state.resources); + const isBuiltin = isBuiltinGroup(link?.headUuid || '') || isBuiltinGroup(link?.tailUuid || ''); + + return { + item: link || { uuid: '', kind: ResourceKind.NONE }, + canManage: link && getResourceLinkCanManage(state, link) && !isBuiltin, + }; + })((props: { item: LinkResource, canManage: boolean } & DispatchProp) => + renderLinkDelete(props.dispatch, props.item, props.canManage)); + +export const ResourceLinkTailEmail = connect( + (state: RootState, props: { uuid: string }) => { + const link = getResource(props.uuid)(state.resources); + const resource = getResource(link?.tailUuid || '')(state.resources); + + return resource || { email: '' }; + })(renderEmail); + +export const ResourceLinkTailUsername = connect( + (state: RootState, props: { uuid: string }) => { + const link = getResource(props.uuid)(state.resources); + const resource = getResource(link?.tailUuid || '')(state.resources); + + return resource || { username: '' }; + })(renderUsername); + +const renderPermissionLevel = (dispatch: Dispatch, link: LinkResource, canManage: boolean) => { + return + {formatPermissionLevel(link.name as PermissionLevel)} + {canManage ? + dispatch(openPermissionEditContextMenu(event, link))}> + + : + '' + } + ; +} + +export const ResourceLinkHeadPermissionLevel = connect( + (state: RootState, props: { uuid: string }) => { + const link = getResource(props.uuid)(state.resources); + const isBuiltin = isBuiltinGroup(link?.headUuid || '') || isBuiltinGroup(link?.tailUuid || ''); + + return { + link: link || { uuid: '', name: '', kind: ResourceKind.NONE }, + canManage: link && getResourceLinkCanManage(state, link) && !isBuiltin, + }; + })((props: { link: LinkResource, canManage: boolean } & DispatchProp) => + renderPermissionLevel(props.dispatch, props.link, props.canManage)); + +export const ResourceLinkTailPermissionLevel = connect( + (state: RootState, props: { uuid: string }) => { + const link = getResource(props.uuid)(state.resources); + const isBuiltin = isBuiltinGroup(link?.headUuid || '') || isBuiltinGroup(link?.tailUuid || ''); + + return { + link: link || { uuid: '', name: '', kind: ResourceKind.NONE }, + canManage: link && getResourceLinkCanManage(state, link) && !isBuiltin, + }; + })((props: { link: LinkResource, canManage: boolean } & DispatchProp) => + renderPermissionLevel(props.dispatch, props.link, props.canManage)); + +const getResourceLinkCanManage = (state: RootState, link: LinkResource) => { + const headResource = getResource(link.headUuid)(state.resources); + // const tailResource = getResource(link.tailUuid)(state.resources); + const userUuid = getUserUuid(state); + + if (headResource && headResource.kind === ResourceKind.GROUP) { + return userUuid ? (headResource as GroupResource).writableBy?.includes(userUuid) : false; + } else { + // true for now + return true; + } +} + // Process Resources const resourceRunProcess = (dispatch: Dispatch, uuid: string) => { return ( diff --git a/src/views-components/dialog-forms/add-group-member-dialog.tsx b/src/views-components/dialog-forms/add-group-member-dialog.tsx deleted file mode 100644 index 443191fe..00000000 --- a/src/views-components/dialog-forms/add-group-member-dialog.tsx +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import React from 'react'; -import { compose } from "redux"; -import { reduxForm, InjectedFormProps, WrappedFieldArrayProps, FieldArray } from 'redux-form'; -import { withDialog, WithDialogProps } from "store/dialog/with-dialog"; -import { FormDialog } from 'components/form-dialog/form-dialog'; -import { ParticipantSelect, Participant } from 'views-components/sharing-dialog/participant-select'; -import { ADD_GROUP_MEMBERS_DIALOG, ADD_GROUP_MEMBERS_FORM, AddGroupMembersFormData, ADD_GROUP_MEMBERS_USERS_FIELD_NAME, addGroupMembers } from 'store/group-details-panel/group-details-panel-actions'; -import { minLength } from 'validators/min-length'; - -export const AddGroupMembersDialog = compose( - withDialog(ADD_GROUP_MEMBERS_DIALOG), - reduxForm({ - form: ADD_GROUP_MEMBERS_FORM, - onSubmit: (data, dispatch) => { - dispatch(addGroupMembers(data)); - }, - }) -)( - (props: AddGroupMembersDialogProps) => - -); - -type AddGroupMembersDialogProps = WithDialogProps<{}> & InjectedFormProps; - -const UsersField = () => - ; - -const UsersFieldValidation = [minLength(1, () => 'Select at least one user')]; - -const UsersSelect = ({ fields }: WrappedFieldArrayProps) => - ; diff --git a/src/views-components/dialog-forms/create-group-dialog.tsx b/src/views-components/dialog-forms/create-group-dialog.tsx deleted file mode 100644 index fceea262..00000000 --- a/src/views-components/dialog-forms/create-group-dialog.tsx +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import React from 'react'; -import { compose } from "redux"; -import { reduxForm, InjectedFormProps, Field, WrappedFieldArrayProps, FieldArray } from 'redux-form'; -import { withDialog, WithDialogProps } from "store/dialog/with-dialog"; -import { FormDialog } from 'components/form-dialog/form-dialog'; -import { CREATE_GROUP_DIALOG, CREATE_GROUP_FORM, createGroup, CreateGroupFormData, CREATE_GROUP_NAME_FIELD_NAME, CREATE_GROUP_USERS_FIELD_NAME } from 'store/groups-panel/groups-panel-actions'; -import { TextField } from 'components/text-field/text-field'; -import { maxLength } from 'validators/max-length'; -import { require } from 'validators/require'; -import { ParticipantSelect, Participant } from 'views-components/sharing-dialog/participant-select'; - -export const CreateGroupDialog = compose( - withDialog(CREATE_GROUP_DIALOG), - reduxForm({ - form: CREATE_GROUP_FORM, - onSubmit: (data, dispatch) => { - dispatch(createGroup(data)); - } - }) -)( - (props: CreateGroupDialogComponentProps) => - -); - -type CreateGroupDialogComponentProps = WithDialogProps<{}> & InjectedFormProps; - -const CreateGroupFormFields = () => - <> - - - ; - -const GroupNameField = () => - ; - -const GROUP_NAME_VALIDATION = [require, maxLength(255)]; - -const UsersField = () => - ; - -const UsersSelect = ({ fields }: WrappedFieldArrayProps) => - ; diff --git a/src/views-components/dialog-forms/update-project-dialog.ts b/src/views-components/dialog-forms/update-project-dialog.ts index dca51b96..4ba03f2f 100644 --- a/src/views-components/dialog-forms/update-project-dialog.ts +++ b/src/views-components/dialog-forms/update-project-dialog.ts @@ -7,14 +7,29 @@ import { reduxForm } from 'redux-form'; import { withDialog } from "store/dialog/with-dialog"; import { DialogProjectUpdate } from 'views-components/dialog-update/dialog-project-update'; import { PROJECT_UPDATE_FORM_NAME, ProjectUpdateFormDialogData } from 'store/projects/project-update-actions'; -import { updateProject } from 'store/workbench/workbench-actions'; +import { updateProject, updateGroup } from 'store/workbench/workbench-actions'; +import { GroupClass } from "models/group"; +import { createGroup } from "store/groups-panel/groups-panel-actions"; export const UpdateProjectDialog = compose( withDialog(PROJECT_UPDATE_FORM_NAME), reduxForm({ form: PROJECT_UPDATE_FORM_NAME, - onSubmit: (data, dispatch) => { - dispatch(updateProject(data)); + onSubmit: (data, dispatch, props) => { + switch (props.data.sourcePanel) { + case GroupClass.PROJECT: + dispatch(updateProject(data)); + break; + case GroupClass.ROLE: + if (data.uuid) { + dispatch(updateGroup(data)); + } else { + dispatch(createGroup(data)); + } + break; + default: + break; + } } }) -)(DialogProjectUpdate); \ No newline at end of file +)(DialogProjectUpdate); diff --git a/src/views-components/dialog-update/dialog-project-update.tsx b/src/views-components/dialog-update/dialog-project-update.tsx index ac14e5dc..fda7c47d 100644 --- a/src/views-components/dialog-update/dialog-project-update.tsx +++ b/src/views-components/dialog-update/dialog-project-update.tsx @@ -7,19 +7,38 @@ import { InjectedFormProps } from 'redux-form'; import { WithDialogProps } from 'store/dialog/with-dialog'; import { ProjectUpdateFormDialogData } from 'store/projects/project-update-actions'; import { FormDialog } from 'components/form-dialog/form-dialog'; -import { ProjectNameField, ProjectDescriptionField } from 'views-components/form-fields/project-form-fields'; +import { ProjectNameField, ProjectDescriptionField, UsersField } from 'views-components/form-fields/project-form-fields'; +import { GroupClass } from 'models/group'; -type DialogProjectProps = WithDialogProps<{}> & InjectedFormProps; +type DialogProjectProps = WithDialogProps<{sourcePanel: GroupClass, create?: boolean}> & InjectedFormProps; -export const DialogProjectUpdate = (props: DialogProjectProps) => - { + let title = 'Edit Project'; + let fields = ProjectEditFields; + const sourcePanel = props.data.sourcePanel || ''; + const create = !!props.data.create; + + if (sourcePanel === GroupClass.ROLE) { + title = create ? 'Create Group' : 'Edit Group'; + fields = create ? GroupAddFields : ProjectEditFields; + } + + return ; +}; +// Also used as "Group Edit Fields" const ProjectEditFields = () => ; + +const GroupAddFields = () => + + + +; diff --git a/src/views-components/form-fields/project-form-fields.tsx b/src/views-components/form-fields/project-form-fields.tsx index 34d7cef7..6ef723d3 100644 --- a/src/views-components/form-fields/project-form-fields.tsx +++ b/src/views-components/form-fields/project-form-fields.tsx @@ -3,11 +3,12 @@ // SPDX-License-Identifier: AGPL-3.0 import React from "react"; -import { Field, Validator } from "redux-form"; +import { Field, FieldArray, Validator, WrappedFieldArrayProps } from "redux-form"; import { TextField, RichEditorTextField } from "components/text-field/text-field"; import { PROJECT_NAME_VALIDATION, PROJECT_NAME_VALIDATION_ALLOW_SLASH } from "validators/validators"; import { connect } from "react-redux"; import { RootState } from "store/store"; +import { Participant, ParticipantSelect } from "views-components/sharing-dialog/participant-select"; interface ProjectNameFieldProps { validate: Validator[]; @@ -42,3 +43,16 @@ export const ProjectDescriptionField = () => name='description' component={RichEditorTextField as any} label="Description - optional" />; + +export const UsersField = () => + ; + +export const UsersSelect = ({ fields }: WrappedFieldArrayProps) => + ; diff --git a/src/views-components/side-panel-tree/side-panel-tree.tsx b/src/views-components/side-panel-tree/side-panel-tree.tsx index 95efee8c..e8294834 100644 --- a/src/views-components/side-panel-tree/side-panel-tree.tsx +++ b/src/views-components/side-panel-tree/side-panel-tree.tsx @@ -9,7 +9,7 @@ import { TreePicker, TreePickerProps } from "../tree-picker/tree-picker"; import { TreeItem } from "components/tree/tree"; import { ProjectResource } from "models/project"; import { ListItemTextIcon } from "components/list-item-text-icon/list-item-text-icon"; -import { ProcessIcon, ProjectIcon, FilterGroupIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon, TrashIcon, PublicFavoriteIcon } from 'components/icon/icon'; +import { ProcessIcon, ProjectIcon, FilterGroupIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon, TrashIcon, PublicFavoriteIcon, GroupsIcon } from 'components/icon/icon'; import { WorkflowIcon } from 'components/icon/icon'; import { activateSidePanelTreeItem, toggleSidePanelTreeItemCollapse, SIDE_PANEL_TREE, SidePanelTreeCategory } from 'store/side-panel-tree/side-panel-tree-actions'; import { openSidePanelContextMenu } from 'store/context-menu/context-menu-actions'; @@ -82,6 +82,8 @@ const getSidePanelIcon = (category: string) => { return PublicFavoriteIcon; case SidePanelTreeCategory.ALL_PROCESSES: return ProcessIcon; + case SidePanelTreeCategory.GROUPS: + return GroupsIcon; default: return ProjectIcon; } diff --git a/src/views/group-details-panel/group-details-panel.tsx b/src/views/group-details-panel/group-details-panel.tsx index d0f79736..ce3f34c7 100644 --- a/src/views/group-details-panel/group-details-panel.tsx +++ b/src/views/group-details-panel/group-details-panel.tsx @@ -7,67 +7,129 @@ import { connect } from 'react-redux'; import { DataExplorer } from "views-components/data-explorer/data-explorer"; import { DataColumns } from 'components/data-table/data-table'; -import { ResourceUuid, ResourceFirstName, ResourceLastName, ResourceEmail, ResourceUsername } from 'views-components/data-explorer/renderers'; +import { ResourceLinkHeadUuid, ResourceLinkTailUsername, ResourceLinkHeadPermissionLevel, ResourceLinkTailPermissionLevel, ResourceLinkHead, ResourceLinkTail, ResourceLinkDelete, ResourceLinkTailIsActive, ResourceLinkTailIsVisible } from 'views-components/data-explorer/renderers'; import { createTree } from 'models/tree'; import { noop } from 'lodash/fp'; import { RootState } from 'store/store'; -import { GROUP_DETAILS_PANEL_ID, openAddGroupMembersDialog } from 'store/group-details-panel/group-details-panel-actions'; +import { GROUP_DETAILS_MEMBERS_PANEL_ID, GROUP_DETAILS_PERMISSIONS_PANEL_ID, openAddGroupMembersDialog, getCurrentGroupDetailsPanelUuid } from 'store/group-details-panel/group-details-panel-actions'; import { openContextMenu } from 'store/context-menu/context-menu-actions'; import { ResourcesState, getResource } from 'store/resources/resources'; -import { ContextMenuKind } from 'views-components/context-menu/context-menu'; -import { PermissionResource } from 'models/permission'; -import { Grid, Button } from '@material-ui/core'; +import { Grid, Button, Tabs, Tab, Paper, WithStyles, withStyles, StyleRulesCallback } from '@material-ui/core'; import { AddIcon } from 'components/icon/icon'; +import { getUserUuid } from 'common/getuser'; +import { GroupResource, isBuiltinGroup } from 'models/group'; +import { ArvadosTheme } from 'common/custom-theme'; -export enum GroupDetailsPanelColumnNames { - FIRST_NAME = "First name", - LAST_NAME = "Last name", - UUID = "UUID", - EMAIL = "Email", +type CssRules = "root"; + +const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ + root: { + width: '100%', + } +}); + +export enum GroupDetailsPanelMembersColumnNames { + FULL_NAME = "Name", USERNAME = "Username", + ACTIVE = "User Active", + VISIBLE = "Visible to other members", + PERMISSION = "Permission", + REMOVE = "Remove", +} + +export enum GroupDetailsPanelPermissionsColumnNames { + NAME = "Name", + PERMISSION = "Permission", + UUID = "UUID", + REMOVE = "Remove", } -export const groupDetailsPanelColumns: DataColumns = [ +export const groupDetailsMembersPanelColumns: DataColumns = [ { - name: GroupDetailsPanelColumnNames.FIRST_NAME, + name: GroupDetailsPanelMembersColumnNames.FULL_NAME, selected: true, configurable: true, filters: createTree(), - render: uuid => + render: uuid => }, { - name: GroupDetailsPanelColumnNames.LAST_NAME, + name: GroupDetailsPanelMembersColumnNames.USERNAME, selected: true, configurable: true, filters: createTree(), - render: uuid => + render: uuid => }, { - name: GroupDetailsPanelColumnNames.UUID, + name: GroupDetailsPanelMembersColumnNames.ACTIVE, selected: true, configurable: true, filters: createTree(), - render: uuid => + render: uuid => }, { - name: GroupDetailsPanelColumnNames.EMAIL, + name: GroupDetailsPanelMembersColumnNames.VISIBLE, selected: true, configurable: true, filters: createTree(), - render: uuid => + render: uuid => }, { - name: GroupDetailsPanelColumnNames.USERNAME, + name: GroupDetailsPanelMembersColumnNames.PERMISSION, selected: true, configurable: true, filters: createTree(), - render: uuid => + render: uuid => + }, + { + name: GroupDetailsPanelMembersColumnNames.REMOVE, + selected: true, + configurable: true, + filters: createTree(), + render: uuid => + }, +]; + +export const groupDetailsPermissionsPanelColumns: DataColumns = [ + { + name: GroupDetailsPanelPermissionsColumnNames.NAME, + selected: true, + configurable: true, + filters: createTree(), + render: uuid => + }, + { + name: GroupDetailsPanelPermissionsColumnNames.PERMISSION, + selected: true, + configurable: true, + filters: createTree(), + render: uuid => + }, + { + name: GroupDetailsPanelPermissionsColumnNames.UUID, + selected: true, + configurable: true, + filters: createTree(), + render: uuid => + }, + { + name: GroupDetailsPanelPermissionsColumnNames.REMOVE, + selected: true, + configurable: true, + filters: createTree(), + render: uuid => }, ]; const mapStateToProps = (state: RootState) => { + const groupUuid = getCurrentGroupDetailsPanelUuid(state.properties); + const group = getResource(groupUuid || '')(state.resources); + const userUuid = getUserUuid(state); + return { - resources: state.resources + resources: state.resources, + groupCanManage: userUuid && !isBuiltinGroup(group?.uuid || '') + ? group?.writableBy?.includes(userUuid) + : false, }; }; @@ -80,47 +142,74 @@ export interface GroupDetailsPanelProps { onContextMenu: (event: React.MouseEvent, item: any) => void; onAddUser: () => void; resources: ResourcesState; + groupCanManage: boolean; } -export const GroupDetailsPanel = connect( +export const GroupDetailsPanel = withStyles(styles)(connect( mapStateToProps, mapDispatchToProps )( - class GroupDetailsPanel extends React.Component { + class GroupDetailsPanel extends React.Component> { + state = { + value: 0, + }; + + componentDidMount() { + this.setState({ value: 0 }); + } render() { + const { value } = this.state; return ( - - -
- } /> + + + + + + {value === 0 && + + +
+ } + paperProps={{ + elevation: 0, + }} /> + } + {value === 1 && + + } +
); } - handleContextMenu = (event: React.MouseEvent, resourceUuid: string) => { - const resource = getResource(resourceUuid)(this.props.resources); - if (resource) { - this.props.onContextMenu(event, { - name: '', - uuid: resource.uuid, - ownerUuid: resource.ownerUuid, - kind: resource.kind, - menuKind: ContextMenuKind.GROUP_MEMBER - }); - } + handleChange = (event: React.MouseEvent, value: number) => { + this.setState({ value }); } - }); - + })); diff --git a/src/views/groups-panel/groups-panel.tsx b/src/views/groups-panel/groups-panel.tsx index faefab10..3251c729 100644 --- a/src/views/groups-panel/groups-panel.tsx +++ b/src/views/groups-panel/groups-panel.tsx @@ -8,7 +8,7 @@ import { Grid, Button, Typography, StyleRulesCallback, WithStyles, withStyles } import { DataExplorer } from "views-components/data-explorer/data-explorer"; import { DataColumns } from 'components/data-table/data-table'; import { SortDirection } from 'components/data-table/data-column'; -import { ResourceOwner } from 'views-components/data-explorer/renderers'; +import { ResourceUuid } from 'views-components/data-explorer/renderers'; import { AddIcon } from 'components/icon/icon'; import { ResourceName } from 'views-components/data-explorer/renderers'; import { createTree } from 'models/tree'; @@ -21,7 +21,6 @@ import { RootState } from 'store/store'; import { openContextMenu } from 'store/context-menu/context-menu-actions'; import { ResourceKind } from 'models/resource'; import { LinkClass, LinkResource } from 'models/link'; -import { navigateToGroupDetails } from 'store/navigation/navigation-action'; import { ArvadosTheme } from 'common/custom-theme'; type CssRules = "root"; @@ -34,7 +33,7 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ export enum GroupsPanelColumnNames { GROUP = "Name", - OWNER = "Owner", + UUID = "UUID", MEMBERS = "Members", } @@ -48,11 +47,11 @@ export const groupsPanelColumns: DataColumns = [ render: uuid => }, { - name: GroupsPanelColumnNames.OWNER, + name: GroupsPanelColumnNames.UUID, selected: true, configurable: true, filters: createTree(), - render: uuid => , + render: uuid => , }, { name: GroupsPanelColumnNames.MEMBERS, @@ -71,15 +70,12 @@ const mapStateToProps = (state: RootState) => { const mapDispatchToProps = { onContextMenu: openContextMenu, - onRowDoubleClick: (uuid: string) => - navigateToGroupDetails(uuid), onNewGroup: openCreateGroupDialog, }; export interface GroupsPanelProps { onNewGroup: () => void; onContextMenu: (event: React.MouseEvent, item: any) => void; - onRowDoubleClick: (item: string) => void; resources: ResourcesState; } @@ -92,14 +88,16 @@ export const GroupsPanel = withStyles(styles)(connect( return (