--- /dev/null
+// 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');
+ });
+ });
+ });
+
+});
case ResourceKind.PROJECT:
if (subtype === "filter") {
return "Filter group";
+ } else if (subtype === "role") {
+ return "Group";
}
return "Project";
case ResourceKind.PROCESS:
doHidePanel, doMaximizePanel, panelName, panelMaximized
} = this.props;
- return <Paper className={classes.root} {...paperProps} key={paperKey}>
+ const dataCy = this.props["data-cy"];
+ return <Paper className={classes.root} {...paperProps} key={paperKey} data-cy={dataCy}>
<Grid container direction="column" wrap="nowrap" className={classes.container}>
{title && <Grid item xs className={classes.title}>{title}</Grid>}
- {(!hideColumnSelector || !hideSearchInput) && <Grid item xs><Toolbar className={title ? classes.toolbarUnderTitle : classes.toolbar}>
+ {(!hideColumnSelector || !hideSearchInput || !!actions) && <Grid item xs><Toolbar className={title ? classes.toolbarUnderTitle : classes.toolbar}>
<Grid container justify="space-between" wrap="nowrap" alignItems="center">
- <div className={classes.searchBox}>
+ {!hideSearchInput && <div className={classes.searchBox}>
{!hideSearchInput && <SearchInput
label={searchLabel}
value={searchValue}
selfClearProp={currentItemUuid}
onSearch={onSearch} />}
- </div>
+ </div>}
{actions}
{!hideColumnSelector && <ColumnSelector
columns={columns}
import VisibilityOff from '@material-ui/icons/VisibilityOff';
import VpnKey from '@material-ui/icons/VpnKey';
import LinkOutlined from '@material-ui/icons/LinkOutlined';
+import RemoveRedEye from '@material-ui/icons/RemoveRedEye';
+import Computer from '@material-ui/icons/Computer';
// Import FontAwesome icons
import { library } from '@fortawesome/fontawesome-svg-core';
-import { faPencilAlt, faSlash } from '@fortawesome/free-solid-svg-icons';
+import { faPencilAlt, faSlash, faUsers } from '@fortawesome/free-solid-svg-icons';
import { CropFreeSharp } from '@material-ui/icons';
library.add(
faPencilAlt,
faSlash,
+ faUsers,
);
export const ReadOnlyIcon = (props: any) =>
</div>
</span>;
+export const GroupsIcon = (props: any) =>
+ <span {...props}>
+ <span className="fas fa-users" />
+ </span>;
+
export const CollectionOldVersionIcon = (props: any) =>
<Tooltip title='Old version'>
<Badge badgeContent={<History fontSize='small' />}>
export const WarningIcon: IconType = (props) => <Warning style={{ color: '#fbc02d', height: '30px', width: '30px' }} {...props} />;
export const Link: IconType = (props) => <LinkOutlined {...props} />;
export const FolderSharedIcon: IconType = (props) => <FolderShared {...props} />;
+export const CanReadIcon: IconType = (props) => <RemoveRedEye {...props} />;
+export const CanWriteIcon: IconType = (props) => <Edit {...props} />;
+export const CanManageIcon: IconType = (props) => <Computer {...props} />;
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';
addMenuActionSet(ContextMenuKind.PROCESS_ADMIN, processResourceAdminActionSet);
addMenuActionSet(ContextMenuKind.PROJECT_ADMIN, projectAdminActionSet);
addMenuActionSet(ContextMenuKind.FILTER_GROUP_ADMIN, filterGroupAdminActionSet);
+addMenuActionSet(ContextMenuKind.PERMISSION_EDIT, permissionEditActionSet);
storeRedirects();
//
// 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;
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]);
+};
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) => {
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 }>(),
}
};
+export const openPermissionEditContextMenu = (event: React.MouseEvent<HTMLElement>, link: LinkResource) =>
+ (dispatch: Dispatch, getState: () => RootState) => {
+ if (link) {
+ dispatch<any>(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!;
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<string>(GROUP_DETAILS_PANEL_ID);
-
-export interface AddGroupMembersFormData {
- [ADD_GROUP_MEMBERS_USERS_FIELD_NAME]: Participant[];
-}
+export const getCurrentGroupDetailsPanelUuid = getProperty<string>(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<any>(openSharingDialog(groupUuid, () => {
+ dispatch(GroupMembersPanelActions.REQUEST_ITEMS());
+ }));
+ }
+ };
- dispatch(startSubmit(ADD_GROUP_MEMBERS_FORM));
-
- const group = getResource<GroupResource>(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,
+ }));
}
};
const groupUuid = getCurrentGroupDetailsPanelUuid(getState().properties);
if (groupUuid) {
-
- const group = getResource<GroupResource>(groupUuid)(getState().resources);
- const user = getResource<UserResource>(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<LinkResource>(memberLinkUuid)(getState().resources);
+
+ if (!visible && permissionLinkUuid) {
+ // Remove read permission
+ try {
+ await permissionService.delete(permissionLinkUuid);
+ dispatch<any>(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,
+ }));
+ }
+ }
+ };
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);
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());
}
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
});
--- /dev/null
+// 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<Dispatch, RootState>) {
+ 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
+ });
// 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';
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) =>
}));
};
-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,
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`,
} 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;
}
*/
export const addGroupMember = async ({ user, group, ...args }: AddGroupMemberArgs) => {
await createPermission({
- head: { ...user },
- tail: { ...group },
+ head: { ...group },
+ tail: { ...user },
permissionLevel: PermissionLevel.CAN_READ,
...args,
});
};
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;
}
} 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
+};
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
}));
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));
message: 'Could not fetch groups.',
kind: SnackbarKind.ERROR
});
-
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';
case SidePanelTreeCategory.TRASH:
dispatch(navigateToTrash);
return;
- case GROUPS_PANEL_LABEL:
+ case SidePanelTreeCategory.GROUPS:
dispatch(navigateToGroups);
return;
case SidePanelTreeCategory.ALL_PROCESSES:
// 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;
}
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) =>
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) {
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 {
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<any>(loadSharingDialog);
};
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<any>(savePublicPermissionChanges);
await dispatch<any>(saveManagementChanges);
await dispatch<any>(sendInvitations);
dispatch(reset(SHARING_INVITATION_FORM_NAME));
await dispatch<any>(loadSharingDialog);
+
+ const dialog = getDialog<SharingDialogData>(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<any>(sendInvitations);
dispatch(closeSharingDialog());
kind: SnackbarKind.SUCCESS,
}));
dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
+
+ const dialog = getDialog<SharingDialogData>(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<string>(getState().dialog, SHARING_DIALOG_NAME);
+ const dialog = getDialog<SharingDialogData>(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<any>(initializePublicAccessForm(items));
await dispatch<any>(initializeManagementForm(items));
dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
const savePublicPermissionChanges = async (_: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
const state = getState();
const { user } = state.auth;
- const dialog = getDialog<string>(state.dialog, SHARING_DIALOG_NAME);
+ const dialog = getDialog<SharingDialogData>(state.dialog, SHARING_DIALOG_NAME);
if (dialog && user) {
const { permissionUuid, visibility } = getSharingPublicAccessFormData(state);
await permissionService.create({
ownerUuid: user.uuid,
- headUuid: dialog.data,
+ headUuid: dialog.data.resourceUuid,
tailUuid: getPublicGroupUuid(state),
name: PermissionLevel.CAN_READ,
});
const sendInvitations = async (_: Dispatch, getState: () => RootState, { permissionService, userService }: ServiceRepository) => {
const state = getState();
const { user } = state.auth;
- const dialog = getDialog<string>(state.dialog, SHARING_DIALOG_NAME);
+ const dialog = getDialog<SharingDialogData>(state.dialog, SHARING_DIALOG_NAME);
if (dialog && user) {
const invitations = getFormValues(SHARING_INVITATION_FORM_NAME)(state) as SharingInvitationFormData;
const invitationDataUsers = getUsersFromForm
.map(person => ({
ownerUuid: user.uuid,
- headUuid: dialog.data,
+ headUuid: dialog.data.resourceUuid,
tailUuid: person.uuid,
name: invitations.permissions
}));
const invitationsDataGroups = getGroupsFromForm.map(
group => ({
ownerUuid: user.uuid,
- headUuid: dialog.data,
+ headUuid: dialog.data.resourceUuid,
tailUuid: group.uuid,
name: invitations.permissions
})
WORKFLOWS = 'Workflows',
FAVORITES = 'My Favorites',
TRASH = 'Trash',
- ALL_PROCESSES = 'All Processes'
+ ALL_PROCESSES = 'All Processes',
+ GROUPS = 'Groups',
}
export const SIDE_PANEL_TREE = 'sidePanelTree';
SidePanelTreeCategory.PUBLIC_FAVORITES,
SidePanelTreeCategory.FAVORITES,
SidePanelTreeCategory.WORKFLOWS,
+ SidePanelTreeCategory.GROUPS,
SidePanelTreeCategory.ALL_PROCESSES,
SidePanelTreeCategory.TRASH
];
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';
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)
workflowPanelMiddleware,
userPanelMiddleware,
groupsPanelMiddleware,
- groupDetailsPanelMiddleware,
+ groupDetailsPanelMembersMiddleware,
+ groupDetailsPanelPermissionsMiddleware,
linkPanelMiddleware,
apiClientAuthorizationMiddlewareService,
publicFavoritesMiddleware,
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';
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 }));
}
};
+export const updateGroup = (data: projectUpdateActions.ProjectUpdateFormDialogData) =>
+ async (dispatch: Dispatch) => {
+ const updatedGroup = await dispatch<any>(groupPanelActions.updateGroup(data));
+ if (updatedGroup) {
+ dispatch(snackbarActions.OPEN_SNACKBAR({
+ message: "Group has been successfully updated.",
+ hideDuration: 2000,
+ kind: SnackbarKind.SUCCESS
+ }));
+ await dispatch<any>(loadSidePanelTreeProjects(updatedGroup.ownerUuid));
+ dispatch<any>(reloadProjectMatchingUuid([updatedGroup.ownerUuid, updatedGroup.uuid]));
+ }
+ };
+
export const loadCollection = (uuid: string) =>
handleFirstTimeLoad(
async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
// 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<any>(openGroupUpdateDialog(resource));
+ }
+}, {
name: "Attributes",
icon: AttributesIcon,
execute: (dispatch, { uuid }) => {
execute: (dispatch, { uuid }) => {
dispatch<any>(openRemoveGroupDialog(uuid));
}
-}]];
\ No newline at end of file
+}]];
--- /dev/null
+// 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<any>(editPermissionLevel(uuid, PermissionLevel.CAN_READ));
+ }
+}, {
+ name: "Write",
+ icon: CanWriteIcon,
+ execute: (dispatch, { uuid }) => {
+ dispatch<any>(editPermissionLevel(uuid, PermissionLevel.CAN_WRITE));
+ }
+}, {
+ name: "Manage",
+ icon: CanManageIcon,
+ execute: (dispatch, { uuid }) => {
+ dispatch<any>(editPermissionLevel(uuid, PermissionLevel.CAN_MANAGE));
+ }
+}]];
USER = "User",
GROUPS = "Group",
GROUP_MEMBER = "GroupMember",
+ PERMISSION_EDIT = "PermissionEdit",
LINK = "Link",
}
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';
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) =>
- <Grid container alignItems="center" wrap="nowrap" spacing={16}>
+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 <Grid container alignItems="center" wrap="nowrap" spacing={16}>
<Grid item>
{renderIcon(item)}
</Grid>
<Grid item>
- <Typography color="primary" style={{ width: 'auto', cursor: 'pointer' }} onClick={() => dispatch<any>(navigateTo(item.uuid))}>
+ <Typography color="primary" style={{ width: 'auto', cursor: 'pointer' }} onClick={() => dispatch<any>(navFunc(item.uuid))}>
{item.kind === ResourceKind.PROJECT || item.kind === ResourceKind.COLLECTION
? <IllegalNamingWarning name={item.name} />
: null}
</Typography>
</Grid>
</Grid>;
+};
export const ResourceName = connect(
(state: RootState, props: { uuid: string }) => {
})((props: { ownerUuid?: string, uuidPrefix: string, uuid?: string } & DispatchProp<any>) =>
resourceShare(props.dispatch, props.uuidPrefix, props.ownerUuid, props.uuid));
+// User Resources
const renderFirstName = (item: { firstName: string }) => {
return <Typography noWrap>{item.firstName}</Typography>;
};
-// User Resources
export const ResourceFirstName = connect(
(state: RootState, props: { uuid: string }) => {
const resource = getResource<UserResource>(props.uuid)(state.resources);
return resource || { lastName: '' };
})(renderLastName);
+const renderFullName = (item: { firstName: string, lastName: string }) =>
+ <Typography noWrap>{(item.firstName + " " + item.lastName).trim()}</Typography>;
+
+export const ResourceFullName = connect(
+ (state: RootState, props: { uuid: string }) => {
+ const resource = getResource<UserResource>(props.uuid)(state.resources);
+ return resource || { firstName: '', lastName: '' };
+ })(renderFullName);
+
+
const renderUuid = (item: { uuid: string }) =>
- <Typography noWrap>{item.uuid}</Typography>;
+ <Typography data-cy="uuid" noWrap>{item.uuid}</Typography>;
export const ResourceUuid = connect(
(state: RootState, props: { uuid: string }) => {
return resource || { email: '' };
})(renderEmail);
-const renderIsActive = (props: { uuid: string, isActive: boolean, toggleIsActive: (uuid: string) => void }) =>
- <Checkbox
- color="primary"
- checked={props.isActive}
- onClick={() => 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 <Checkbox
+ color="primary"
+ checked={props.isActive}
+ disabled={!!props.disabled}
+ onClick={() => props.toggleIsActive(props.uuid)} />;
+ } else {
+ return <Typography />;
+ }
+}
export const ResourceIsActive = connect(
- (state: RootState, props: { uuid: string }) => {
+ (state: RootState, props: { uuid: string, disabled?: boolean }) => {
const resource = getResource<UserResource>(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<LinkResource>(props.uuid)(state.resources);
+ const tailResource = getResource<UserResource>(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 <Checkbox
+ data-cy="user-visible-checkbox"
+ color="primary"
+ checked={props.visible}
+ disabled={!props.canManage}
+ onClick={() => props.setMemberIsHidden(props.memberLinkUuid, props.permissionLinkUuid, !props.visible)} />;
+ } else {
+ return <Typography />;
+ }
+}
+
+export const ResourceLinkTailIsVisible = connect(
+ (state: RootState, props: { uuid: string }) => {
+ const link = getResource<LinkResource>(props.uuid)(state.resources);
+ const member = getResource<Resource>(link?.tailUuid || '')(state.resources);
+ const group = getResource<GroupResource>(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 }) =>
<Checkbox
color="primary"
return resource || { linkClass: '' };
})(renderLinkClass);
-const renderLinkTail = (dispatch: Dispatch, item: { uuid: string, tailUuid: string, tailKind: string }) => {
- const currentLabel = resourceLabel(item.tailKind);
- const isUnknow = currentLabel === "Unknown";
- return (<div>
- {!isUnknow ? (
- renderLink(dispatch, item.tailUuid, currentLabel)
- ) : (
- <Typography noWrap color="default">
- {item.tailUuid}
- </Typography>
- )}
- </div>);
-};
+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) =>
- <Typography noWrap color="primary" style={{ 'cursor': 'pointer' }} onClick={() => dispatch<any>(navigateTo(uuid))}>
- {label}: {uuid}
+ return <Typography noWrap color="primary" style={{ 'cursor': 'pointer' }} onClick={() => dispatch<any>(navigateTo(item.uuid))}>
+ {resourceLabel(item.kind, item && item.kind === ResourceKind.GROUP ? (item as GroupResource).groupClass || '' : '')}: {displayName || item.uuid}
</Typography>;
+};
export const ResourceLinkTail = connect(
(state: RootState, props: { uuid: string }) => {
const resource = getResource<LinkResource>(props.uuid)(state.resources);
+ const tailResource = getResource<Resource>(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<any>) =>
- 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<any>) =>
+ renderResourceLink(props.dispatch, props.item));
export const ResourceLinkHead = connect(
(state: RootState, props: { uuid: string }) => {
const resource = getResource<LinkResource>(props.uuid)(state.resources);
+ const headResource = getResource<Resource>(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<any>) =>
- renderLinkHead(props.dispatch, props.item));
+ })((props: { item: Resource } & DispatchProp<any>) =>
+ renderResourceLink(props.dispatch, props.item));
export const ResourceLinkUuid = connect(
(state: RootState, props: { uuid: string }) => {
return resource || { uuid: '' };
})(renderUuid);
+export const ResourceLinkHeadUuid = connect(
+ (state: RootState, props: { uuid: string }) => {
+ const link = getResource<LinkResource>(props.uuid)(state.resources);
+ const headResource = getResource<Resource>(link?.headUuid || '')(state.resources);
+
+ return headResource || { uuid: '' };
+ })(renderUuid);
+
+export const ResourceLinkTailUuid = connect(
+ (state: RootState, props: { uuid: string }) => {
+ const link = getResource<LinkResource>(props.uuid)(state.resources);
+ const tailResource = getResource<Resource>(link?.tailUuid || '')(state.resources);
+
+ return tailResource || { uuid: '' };
+ })(renderUuid);
+
+const renderLinkDelete = (dispatch: Dispatch, item: LinkResource, canManage: boolean) => {
+ if (item.uuid) {
+ return canManage ?
+ <Typography noWrap>
+ <IconButton data-cy="resource-delete-button" onClick={() => dispatch<any>(openRemoveGroupMemberDialog(item.uuid))}>
+ <RemoveIcon />
+ </IconButton>
+ </Typography> :
+ <Typography noWrap>
+ <IconButton disabled data-cy="resource-delete-button">
+ <RemoveIcon />
+ </IconButton>
+ </Typography>;
+ } else {
+ return <Typography noWrap></Typography>;
+ }
+}
+
+export const ResourceLinkDelete = connect(
+ (state: RootState, props: { uuid: string }) => {
+ const link = getResource<LinkResource>(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<any>) =>
+ renderLinkDelete(props.dispatch, props.item, props.canManage));
+
+export const ResourceLinkTailEmail = connect(
+ (state: RootState, props: { uuid: string }) => {
+ const link = getResource<LinkResource>(props.uuid)(state.resources);
+ const resource = getResource<UserResource>(link?.tailUuid || '')(state.resources);
+
+ return resource || { email: '' };
+ })(renderEmail);
+
+export const ResourceLinkTailUsername = connect(
+ (state: RootState, props: { uuid: string }) => {
+ const link = getResource<LinkResource>(props.uuid)(state.resources);
+ const resource = getResource<UserResource>(link?.tailUuid || '')(state.resources);
+
+ return resource || { username: '' };
+ })(renderUsername);
+
+const renderPermissionLevel = (dispatch: Dispatch, link: LinkResource, canManage: boolean) => {
+ return <Typography noWrap>
+ {formatPermissionLevel(link.name as PermissionLevel)}
+ {canManage ?
+ <IconButton data-cy="edit-permission-button" onClick={(event) => dispatch<any>(openPermissionEditContextMenu(event, link))}>
+ <RenameIcon />
+ </IconButton> :
+ ''
+ }
+ </Typography>;
+}
+
+export const ResourceLinkHeadPermissionLevel = connect(
+ (state: RootState, props: { uuid: string }) => {
+ const link = getResource<LinkResource>(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<any>) =>
+ renderPermissionLevel(props.dispatch, props.link, props.canManage));
+
+export const ResourceLinkTailPermissionLevel = connect(
+ (state: RootState, props: { uuid: string }) => {
+ const link = getResource<LinkResource>(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<any>) =>
+ renderPermissionLevel(props.dispatch, props.link, props.canManage));
+
+const getResourceLinkCanManage = (state: RootState, link: LinkResource) => {
+ const headResource = getResource<Resource>(link.headUuid)(state.resources);
+ // const tailResource = getResource<Resource>(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 (
+++ /dev/null
-// 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<AddGroupMembersFormData>({
- form: ADD_GROUP_MEMBERS_FORM,
- onSubmit: (data, dispatch) => {
- dispatch(addGroupMembers(data));
- },
- })
-)(
- (props: AddGroupMembersDialogProps) =>
- <FormDialog
- dialogTitle='Add users'
- formFields={UsersField}
- submitLabel='Add'
- {...props}
- />
-);
-
-type AddGroupMembersDialogProps = WithDialogProps<{}> & InjectedFormProps<AddGroupMembersFormData>;
-
-const UsersField = () =>
- <FieldArray
- name={ADD_GROUP_MEMBERS_USERS_FIELD_NAME}
- component={UsersSelect as any}
- validate={UsersFieldValidation} />;
-
-const UsersFieldValidation = [minLength(1, () => 'Select at least one user')];
-
-const UsersSelect = ({ fields }: WrappedFieldArrayProps<Participant>) =>
- <ParticipantSelect
- onlyPeople
- autofocus
- label='Enter email adresses '
- items={fields.getAll() || []}
- onSelect={fields.push}
- onDelete={fields.remove} />;
+++ /dev/null
-// 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<CreateGroupFormData>({
- form: CREATE_GROUP_FORM,
- onSubmit: (data, dispatch) => {
- dispatch(createGroup(data));
- }
- })
-)(
- (props: CreateGroupDialogComponentProps) =>
- <FormDialog
- dialogTitle='Create a group'
- formFields={CreateGroupFormFields}
- submitLabel='Create'
- {...props}
- />
-);
-
-type CreateGroupDialogComponentProps = WithDialogProps<{}> & InjectedFormProps<CreateGroupFormData>;
-
-const CreateGroupFormFields = () =>
- <>
- <GroupNameField />
- <UsersField />
- </>;
-
-const GroupNameField = () =>
- <Field
- name={CREATE_GROUP_NAME_FIELD_NAME}
- component={TextField as any}
- validate={GROUP_NAME_VALIDATION}
- label="Name"
- autoFocus={true} />;
-
-const GROUP_NAME_VALIDATION = [require, maxLength(255)];
-
-const UsersField = () =>
- <FieldArray
- name={CREATE_GROUP_USERS_FIELD_NAME}
- component={UsersSelect as any} />;
-
-const UsersSelect = ({ fields }: WrappedFieldArrayProps<Participant>) =>
- <ParticipantSelect
- onlyPeople
- label='Enter email adresses '
- items={fields.getAll() || []}
- onSelect={fields.push}
- onDelete={fields.remove} />;
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<ProjectUpdateFormDialogData>({
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);
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<ProjectUpdateFormDialogData>;
+type DialogProjectProps = WithDialogProps<{sourcePanel: GroupClass, create?: boolean}> & InjectedFormProps<ProjectUpdateFormDialogData>;
-export const DialogProjectUpdate = (props: DialogProjectProps) =>
- <FormDialog
- dialogTitle='Edit Project'
- formFields={ProjectEditFields}
+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 <FormDialog
+ dialogTitle={title}
+ formFields={fields}
submitLabel='Save'
{...props}
/>;
+};
+// Also used as "Group Edit Fields"
const ProjectEditFields = () => <span>
<ProjectNameField />
<ProjectDescriptionField />
</span>;
+
+const GroupAddFields = () => <span>
+ <ProjectNameField />
+ <UsersField />
+ <ProjectDescriptionField />
+</span>;
// 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[];
name='description'
component={RichEditorTextField as any}
label="Description - optional" />;
+
+export const UsersField = () =>
+ <span data-cy='users-field'><FieldArray
+ name="users"
+ component={UsersSelect as any} /></span>;
+
+export const UsersSelect = ({ fields }: WrappedFieldArrayProps<Participant>) =>
+ <ParticipantSelect
+ onlyPeople
+ label='Search for users to add to the group'
+ items={fields.getAll() || []}
+ onSelect={fields.push}
+ onDelete={fields.remove} />;
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';
return PublicFavoriteIcon;
case SidePanelTreeCategory.ALL_PROCESSES:
return ProcessIcon;
+ case SidePanelTreeCategory.GROUPS:
+ return GroupsIcon;
default:
return ProjectIcon;
}
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<CssRules> = (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<string> = [
+export const groupDetailsMembersPanelColumns: DataColumns<string> = [
{
- name: GroupDetailsPanelColumnNames.FIRST_NAME,
+ name: GroupDetailsPanelMembersColumnNames.FULL_NAME,
selected: true,
configurable: true,
filters: createTree(),
- render: uuid => <ResourceFirstName uuid={uuid} />
+ render: uuid => <ResourceLinkTail uuid={uuid} />
},
{
- name: GroupDetailsPanelColumnNames.LAST_NAME,
+ name: GroupDetailsPanelMembersColumnNames.USERNAME,
selected: true,
configurable: true,
filters: createTree(),
- render: uuid => <ResourceLastName uuid={uuid} />
+ render: uuid => <ResourceLinkTailUsername uuid={uuid} />
},
{
- name: GroupDetailsPanelColumnNames.UUID,
+ name: GroupDetailsPanelMembersColumnNames.ACTIVE,
selected: true,
configurable: true,
filters: createTree(),
- render: uuid => <ResourceUuid uuid={uuid} />
+ render: uuid => <ResourceLinkTailIsActive uuid={uuid} disabled={true} />
},
{
- name: GroupDetailsPanelColumnNames.EMAIL,
+ name: GroupDetailsPanelMembersColumnNames.VISIBLE,
selected: true,
configurable: true,
filters: createTree(),
- render: uuid => <ResourceEmail uuid={uuid} />
+ render: uuid => <ResourceLinkTailIsVisible uuid={uuid} />
},
{
- name: GroupDetailsPanelColumnNames.USERNAME,
+ name: GroupDetailsPanelMembersColumnNames.PERMISSION,
selected: true,
configurable: true,
filters: createTree(),
- render: uuid => <ResourceUsername uuid={uuid} />
+ render: uuid => <ResourceLinkTailPermissionLevel uuid={uuid} />
+ },
+ {
+ name: GroupDetailsPanelMembersColumnNames.REMOVE,
+ selected: true,
+ configurable: true,
+ filters: createTree(),
+ render: uuid => <ResourceLinkDelete uuid={uuid} />
+ },
+];
+
+export const groupDetailsPermissionsPanelColumns: DataColumns<string> = [
+ {
+ name: GroupDetailsPanelPermissionsColumnNames.NAME,
+ selected: true,
+ configurable: true,
+ filters: createTree(),
+ render: uuid => <ResourceLinkHead uuid={uuid} />
+ },
+ {
+ name: GroupDetailsPanelPermissionsColumnNames.PERMISSION,
+ selected: true,
+ configurable: true,
+ filters: createTree(),
+ render: uuid => <ResourceLinkHeadPermissionLevel uuid={uuid} />
+ },
+ {
+ name: GroupDetailsPanelPermissionsColumnNames.UUID,
+ selected: true,
+ configurable: true,
+ filters: createTree(),
+ render: uuid => <ResourceLinkHeadUuid uuid={uuid} />
+ },
+ {
+ name: GroupDetailsPanelPermissionsColumnNames.REMOVE,
+ selected: true,
+ configurable: true,
+ filters: createTree(),
+ render: uuid => <ResourceLinkDelete uuid={uuid} />
},
];
const mapStateToProps = (state: RootState) => {
+ const groupUuid = getCurrentGroupDetailsPanelUuid(state.properties);
+ const group = getResource<GroupResource>(groupUuid || '')(state.resources);
+ const userUuid = getUserUuid(state);
+
return {
- resources: state.resources
+ resources: state.resources,
+ groupCanManage: userUuid && !isBuiltinGroup(group?.uuid || '')
+ ? group?.writableBy?.includes(userUuid)
+ : false,
};
};
onContextMenu: (event: React.MouseEvent<HTMLElement>, 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<GroupDetailsPanelProps> {
+ class GroupDetailsPanel extends React.Component<GroupDetailsPanelProps & WithStyles<CssRules>> {
+ state = {
+ value: 0,
+ };
+
+ componentDidMount() {
+ this.setState({ value: 0 });
+ }
render() {
+ const { value } = this.state;
return (
- <DataExplorer
- id={GROUP_DETAILS_PANEL_ID}
- onRowClick={noop}
- onRowDoubleClick={noop}
- onContextMenu={this.handleContextMenu}
- contextMenuColumn={true}
- hideColumnSelector
- hideSearchInput
- actions={
- <Grid container justify='flex-end'>
- <Button
- variant="contained"
- color="primary"
- onClick={this.props.onAddUser}>
- <AddIcon /> Add user
- </Button>
- </Grid>
- } />
+ <Paper className={this.props.classes.root}>
+ <Tabs value={value} onChange={this.handleChange} variant="fullWidth">
+ <Tab data-cy="group-details-members-tab" label="MEMBERS" />
+ <Tab data-cy="group-details-permissions-tab" label="PERMISSIONS" />
+ </Tabs>
+ {value === 0 &&
+ <DataExplorer
+ id={GROUP_DETAILS_MEMBERS_PANEL_ID}
+ data-cy="group-members-data-explorer"
+ onRowClick={noop}
+ onRowDoubleClick={noop}
+ onContextMenu={noop}
+ contextMenuColumn={false}
+ hideColumnSelector
+ hideSearchInput
+ actions={
+ this.props.groupCanManage &&
+ <Grid container justify='flex-end'>
+ <Button
+ data-cy="group-member-add"
+ variant="contained"
+ color="primary"
+ onClick={this.props.onAddUser}>
+ <AddIcon /> Add user
+ </Button>
+ </Grid>
+ }
+ paperProps={{
+ elevation: 0,
+ }} />
+ }
+ {value === 1 &&
+ <DataExplorer
+ id={GROUP_DETAILS_PERMISSIONS_PANEL_ID}
+ data-cy="group-permissions-data-explorer"
+ onRowClick={noop}
+ onRowDoubleClick={noop}
+ onContextMenu={noop}
+ contextMenuColumn={false}
+ hideColumnSelector
+ hideSearchInput
+ paperProps={{
+ elevation: 0,
+ }} />
+ }
+ </Paper>
);
}
- handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
- const resource = getResource<PermissionResource>(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<HTMLElement>, value: number) => {
+ this.setState({ value });
}
- });
-
+ }));
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';
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";
export enum GroupsPanelColumnNames {
GROUP = "Name",
- OWNER = "Owner",
+ UUID = "UUID",
MEMBERS = "Members",
}
render: uuid => <ResourceName uuid={uuid} />
},
{
- name: GroupsPanelColumnNames.OWNER,
+ name: GroupsPanelColumnNames.UUID,
selected: true,
configurable: true,
filters: createTree(),
- render: uuid => <ResourceOwner uuid={uuid} />,
+ render: uuid => <ResourceUuid uuid={uuid} />,
},
{
name: GroupsPanelColumnNames.MEMBERS,
const mapDispatchToProps = {
onContextMenu: openContextMenu,
- onRowDoubleClick: (uuid: string) =>
- navigateToGroupDetails(uuid),
onNewGroup: openCreateGroupDialog,
};
export interface GroupsPanelProps {
onNewGroup: () => void;
onContextMenu: (event: React.MouseEvent<HTMLElement>, item: any) => void;
- onRowDoubleClick: (item: string) => void;
resources: ResourcesState;
}
return (
<div className={this.props.classes.root}><DataExplorer
id={GROUPS_PANEL_ID}
+ data-cy="groups-panel-data-explorer"
onRowClick={noop}
- onRowDoubleClick={this.props.onRowDoubleClick}
+ onRowDoubleClick={noop}
onContextMenu={this.handleContextMenu}
contextMenuColumn={true}
hideColumnSelector
actions={
<Grid container justify='flex-end'>
<Button
+ data-cy="groups-panel-new-group"
variant="contained"
color="primary"
onClick={this.props.onNewGroup}>
const resource = getResource<GroupResource>(resourceUuid)(this.props.resources);
if (resource) {
this.props.onContextMenu(event, {
- name: '',
+ name: resource.name,
uuid: resource.uuid,
+ description: resource.description,
ownerUuid: resource.ownerUuid,
kind: resource.kind,
menuKind: ContextMenuKind.GROUPS
const permissions = filterResources((resource: LinkResource) =>
resource.kind === ResourceKind.LINK &&
resource.linkClass === LinkClass.PERMISSION &&
- resource.tailUuid === props.uuid
+ resource.headUuid === props.uuid
)(state.resources);
return {
};
}
-)(Typography);
+)((props: {children: number}) => (<Typography children={props.children} />));
import { UserManageDialog } from 'views-components/user-dialog/manage-dialog';
import { SetupShellAccountDialog } from 'views-components/dialog-forms/setup-shell-account-dialog';
import { GroupsPanel } from 'views/groups-panel/groups-panel';
-import { CreateGroupDialog } from 'views-components/dialog-forms/create-group-dialog';
import { RemoveGroupDialog } from 'views-components/groups-dialog/remove-dialog';
import { GroupAttributesDialog } from 'views-components/groups-dialog/attributes-dialog';
import { GroupDetailsPanel } from 'views/group-details-panel/group-details-panel';
import { RemoveGroupMemberDialog } from 'views-components/groups-dialog/member-remove-dialog';
import { GroupMemberAttributesDialog } from 'views-components/groups-dialog/member-attributes-dialog';
-import { AddGroupMembersDialog } from 'views-components/dialog-forms/add-group-member-dialog';
import { PartialCopyToCollectionDialog } from 'views-components/dialog-forms/partial-copy-to-collection-dialog';
import { PublicFavoritePanel } from 'views/public-favorites-panel/public-favorites-panel';
import { LinkAccountPanel } from 'views/link-account-panel/link-account-panel';
<Grid item>
<DetailsPanel />
</Grid>
- <AddGroupMembersDialog />
<AdvancedTabDialog />
<AttributesApiClientAuthorizationDialog />
<AttributesKeepServiceDialog />
<CopyCollectionDialog />
<CopyProcessDialog />
<CreateCollectionDialog />
- <CreateGroupDialog />
<CreateProjectDialog />
<CreateRepositoryDialog />
<CreateSshKeyDialog />