Merge branch 'master'
authorMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Sun, 16 Dec 2018 21:44:03 +0000 (22:44 +0100)
committerMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Sun, 16 Dec 2018 21:44:03 +0000 (22:44 +0100)
Feature #14505

Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski@contractors.roche.com>

1  2 
src/index.tsx
src/models/link.ts
src/routes/route-change-handlers.ts
src/routes/routes.ts
src/store/navigation/navigation-action.ts
src/store/store.ts
src/store/workbench/workbench-actions.ts
src/views-components/context-menu/context-menu.tsx
src/views-components/main-app-bar/account-menu.tsx
src/views/workbench/workbench.tsx

diff --combined src/index.tsx
index f64e076a892cbf6ef18a1b4a8cf087df11502ba8,e73f08c46ba8b48d2fab54bd5cbe0de89fb3b753..508fa7c3dad5cb140246352cb821b56c98d49d7a
@@@ -56,8 -56,7 +56,9 @@@ import { virtualMachineActionSet } fro
  import { userActionSet } from '~/views-components/context-menu/action-sets/user-action-set';
  import { computeNodeActionSet } from '~/views-components/context-menu/action-sets/compute-node-action-set';
  import { apiClientAuthorizationActionSet } from '~/views-components/context-menu/action-sets/api-client-authorization-action-set';
 +import { groupActionSet } from '~/views-components/context-menu/action-sets/group-action-set';
 +import { groupMemberActionSet } from '~/views-components/context-menu/action-sets/group-member-action-set';
+ import { linkActionSet } from '~/views-components/context-menu/action-sets/link-action-set';
  
  console.log(`Starting arvados [${getBuildInfo()}]`);
  
@@@ -79,10 -78,9 +80,11 @@@ addMenuActionSet(ContextMenuKind.SSH_KE
  addMenuActionSet(ContextMenuKind.VIRTUAL_MACHINE, virtualMachineActionSet);
  addMenuActionSet(ContextMenuKind.KEEP_SERVICE, keepServiceActionSet);
  addMenuActionSet(ContextMenuKind.USER, userActionSet);
+ addMenuActionSet(ContextMenuKind.LINK, linkActionSet);
  addMenuActionSet(ContextMenuKind.NODE, computeNodeActionSet);
  addMenuActionSet(ContextMenuKind.API_CLIENT_AUTHORIZATION, apiClientAuthorizationActionSet);
 +addMenuActionSet(ContextMenuKind.GROUPS, groupActionSet);
 +addMenuActionSet(ContextMenuKind.GROUP_MEMBER, groupMemberActionSet);
  
  fetchConfig()
      .then(({ config, apiHost }) => {
diff --combined src/models/link.ts
index 1798e4477634418248b4de38b399b121561df595,d931f7f21898b394b2b2efb73349838e533532a8..785d531cf7d609fec3af696d16c3fbb9028753a4
@@@ -2,20 -2,23 +2,23 @@@
  //
  // SPDX-License-Identifier: AGPL-3.0
  
- import { Resource, ResourceKind } from "./resource";
 -import { Resource } from "./resource";
  import { TagProperty } from "~/models/tag";
 -import { ResourceKind } from '~/models/resource';
++import { Resource, ResourceKind } from '~/models/resource';
  
  export interface LinkResource extends Resource {
      headUuid: string;
+     headKind: ResourceKind;
      tailUuid: string;
+     tailKind: string;
      linkClass: string;
      name: string;
      properties: TagProperty;
 +    kind: ResourceKind.LINK;
  }
  
  export enum LinkClass {
      STAR = 'star',
      TAG = 'tag',
      PERMISSION = 'permission',
+     PRESET = 'preset',
  }
index cbfbfd381b55beae88e84c87e9f9621b39798ff5,655c806f3a3b0337cc1a89eccae6b7a29ddaa832..7b37509f90b94be0cd053e3052e52ef438bac8fc
@@@ -7,6 -7,7 +7,7 @@@ import { RootStore } from '~/store/stor
  import * as Routes from '~/routes/routes';
  import * as WorkbenchActions from '~/store/workbench/workbench-actions';
  import { navigateToRootProject } from '~/store/navigation/navigation-action';
+ import { dialogActions } from '~/store/dialog/dialog-actions';
  
  export const addRouteChangeHandlers = (history: History, store: RootStore) => {
      const handler = handleLocationChange(store);
@@@ -26,16 -27,19 +27,21 @@@ const handleLocationChange = (store: Ro
      const searchResultsMatch = Routes.matchSearchResultsRoute(pathname);
      const sharedWithMeMatch = Routes.matchSharedWithMeRoute(pathname);
      const runProcessMatch = Routes.matchRunProcessRoute(pathname);
-     const virtualMachineMatch = Routes.matchVirtualMachineRoute(pathname);
+     const virtualMachineUserMatch = Routes.matchUserVirtualMachineRoute(pathname);
+     const virtualMachineAdminMatch = Routes.matchAdminVirtualMachineRoute(pathname);
      const workflowMatch = Routes.matchWorkflowRoute(pathname);
-     const sshKeysMatch = Routes.matchSshKeysRoute(pathname);
+     const sshKeysUserMatch = Routes.matchSshKeysUserRoute(pathname);
+     const sshKeysAdminMatch = Routes.matchSshKeysAdminRoute(pathname);
      const keepServicesMatch = Routes.matchKeepServicesRoute(pathname);
      const computeNodesMatch = Routes.matchComputeNodesRoute(pathname);
      const apiClientAuthorizationsMatch = Routes.matchApiClientAuthorizationsRoute(pathname);
      const myAccountMatch = Routes.matchMyAccountRoute(pathname);
      const userMatch = Routes.matchUsersRoute(pathname);
 +    const groupsMatch = Routes.matchGroupsRoute(pathname);
 +    const groupDetailsMatch = Routes.matchGroupDetailsRoute(pathname);
+     const linksMatch = Routes.matchLinksRoute(pathname);
+     store.dispatch(dialogActions.CLOSE_ALL_DIALOGS());
  
      if (projectMatch) {
          store.dispatch(WorkbenchActions.loadProject(projectMatch.params.id));
          store.dispatch(WorkbenchActions.loadWorkflow);
      } else if (searchResultsMatch) {
          store.dispatch(WorkbenchActions.loadSearchResults);
-     } else if (virtualMachineMatch) {
+     } else if (virtualMachineUserMatch) {
+         store.dispatch(WorkbenchActions.loadVirtualMachines);
+     } else if (virtualMachineAdminMatch) {
          store.dispatch(WorkbenchActions.loadVirtualMachines);
-     } else if(repositoryMatch) {
+     } else if (repositoryMatch) {
          store.dispatch(WorkbenchActions.loadRepositories);
-     } else if (sshKeysMatch) {
+     } else if (sshKeysUserMatch) {
+         store.dispatch(WorkbenchActions.loadSshKeys);
+     } else if (sshKeysAdminMatch) {
          store.dispatch(WorkbenchActions.loadSshKeys);
      } else if (keepServicesMatch) {
          store.dispatch(WorkbenchActions.loadKeepServices);
          store.dispatch(WorkbenchActions.loadApiClientAuthorizations);
      } else if (myAccountMatch) {
          store.dispatch(WorkbenchActions.loadMyAccount);
-     }else if (userMatch) {
+     } else if (userMatch) {
          store.dispatch(WorkbenchActions.loadUsers);
 +    } else if (groupsMatch) {
 +        store.dispatch(WorkbenchActions.loadGroupsPanel);
 +    } else if (groupDetailsMatch) {
 +        store.dispatch(WorkbenchActions.loadGroupDetailsPanel(groupDetailsMatch.params.id));
+     } else if (linksMatch) {
+         store.dispatch(WorkbenchActions.loadLinks);
      }
  };
diff --combined src/routes/routes.ts
index 6d44725ce1bc0406d4cc446905f5745d1650a890,05f6663fe3c2bbb8021423249e5e045263ab3be7..661a065eb35848bbfaef168d58af2781960f92ad
@@@ -19,17 -19,18 +19,20 @@@ export const Routes = 
      REPOSITORIES: '/repositories',
      SHARED_WITH_ME: '/shared-with-me',
      RUN_PROCESS: '/run-process',
-     VIRTUAL_MACHINES: '/virtual-machines',
+     VIRTUAL_MACHINES_ADMIN: '/virtual-machines-admin',
+     VIRTUAL_MACHINES_USER: '/virtual-machines-user',
      WORKFLOWS: '/workflows',
      SEARCH_RESULTS: '/search-results',
-     SSH_KEYS: `/ssh-keys`,
+     SSH_KEYS_ADMIN: `/ssh-keys-admin`,
+     SSH_KEYS_USER: `/ssh-keys-user`,
      MY_ACCOUNT: '/my-account',
      KEEP_SERVICES: `/keep-services`,
      COMPUTE_NODES: `/nodes`,
      USERS: '/users',
      API_CLIENT_AUTHORIZATIONS: `/api_client_authorizations`,
 +    GROUPS: '/groups',
 +    GROUP_DETAILS: `/group/:id(${RESOURCE_UUID_PATTERN})`,
+     LINKS: '/links'
  };
  
  export const getResourceUrl = (uuid: string) => {
@@@ -50,8 -51,6 +53,8 @@@ export const getProcessUrl = (uuid: str
  
  export const getProcessLogUrl = (uuid: string) => `/process-logs/${uuid}`;
  
 +export const getGroupUrl = (uuid: string) => `/group/${uuid}`;
 +
  export interface ResourceRouteParams {
      id: string;
  }
@@@ -89,14 -88,20 +92,20 @@@ export const matchWorkflowRoute = (rout
  export const matchSearchResultsRoute = (route: string) =>
      matchPath<ResourceRouteParams>(route, { path: Routes.SEARCH_RESULTS });
  
- export const matchVirtualMachineRoute = (route: string) =>
-     matchPath<ResourceRouteParams>(route, { path: Routes.VIRTUAL_MACHINES });
+ export const matchUserVirtualMachineRoute = (route: string) =>
+     matchPath<ResourceRouteParams>(route, { path: Routes.VIRTUAL_MACHINES_USER });
+ export const matchAdminVirtualMachineRoute = (route: string) =>
+     matchPath<ResourceRouteParams>(route, { path: Routes.VIRTUAL_MACHINES_ADMIN });
  
  export const matchRepositoriesRoute = (route: string) =>
      matchPath<ResourceRouteParams>(route, { path: Routes.REPOSITORIES });
  
- export const matchSshKeysRoute = (route: string) =>
-     matchPath(route, { path: Routes.SSH_KEYS });
+ export const matchSshKeysUserRoute = (route: string) =>
+     matchPath(route, { path: Routes.SSH_KEYS_USER });
+ export const matchSshKeysAdminRoute = (route: string) =>
+     matchPath(route, { path: Routes.SSH_KEYS_ADMIN });
  
  export const matchMyAccountRoute = (route: string) =>
      matchPath(route, { path: Routes.MY_ACCOUNT });
@@@ -113,8 -118,5 +122,11 @@@ export const matchComputeNodesRoute = (
  export const matchApiClientAuthorizationsRoute = (route: string) =>
      matchPath(route, { path: Routes.API_CLIENT_AUTHORIZATIONS });
  
 -    matchPath(route, { path: Routes.LINKS });
 +export const matchGroupsRoute = (route: string) =>
 +    matchPath(route, { path: Routes.GROUPS });
 +
 +export const matchGroupDetailsRoute = (route: string) =>
 +    matchPath<ResourceRouteParams>(route, { path: Routes.GROUP_DETAILS });
++    
+ export const matchLinksRoute = (route: string) =>
++    matchPath(route, { path: Routes.LINKS });
index 9aa4a32c7a07537d435c695186aaf5d15e6129a1,92443c02dc1cf90800a50418d167c72f253d92b5..c53c55e89287212f91715481a8998fe429fddaf2
@@@ -8,10 -8,9 +8,10 @@@ import { ResourceKind, extractUuidKind 
  import { getCollectionUrl } from "~/models/collection";
  import { getProjectUrl } from "~/models/project";
  import { SidePanelTreeCategory } from '../side-panel-tree/side-panel-tree-actions';
 -import { Routes, getProcessUrl, getProcessLogUrl } from '~/routes/routes';
 +import { Routes, getProcessUrl, getProcessLogUrl, getGroupUrl } from '~/routes/routes';
  import { RootState } from '~/store/store';
  import { ServiceRepository } from '~/services/services';
 +import { GROUPS_PANEL_LABEL } from '~/store/breadcrumbs/breadcrumbs-actions';
  
  export const navigateTo = (uuid: string) =>
      async (dispatch: Dispatch) => {
@@@ -22,6 -21,8 +22,8 @@@
              dispatch<any>(navigateToCollection(uuid));
          } else if (kind === ResourceKind.CONTAINER_REQUEST) {
              dispatch<any>(navigateToProcess(uuid));
+         } else if (kind === ResourceKind.VIRTUAL_MACHINE) {
+             dispatch<any>(navigateToAdminVirtualMachines);
          }
          if (uuid === SidePanelTreeCategory.FAVORITES) {
              dispatch<any>(navigateToFavorites);
@@@ -31,8 -32,6 +33,8 @@@
              dispatch(navigateToWorkflows);
          } else if (uuid === SidePanelTreeCategory.TRASH) {
              dispatch(navigateToTrash);
 +        } else if (uuid === GROUPS_PANEL_LABEL) {
 +            dispatch(navigateToGroups);
          }
      };
  
@@@ -65,11 -64,15 +67,15 @@@ export const navigateToRunProcess = pus
  
  export const navigateToSearchResults = push(Routes.SEARCH_RESULTS);
  
- export const navigateToVirtualMachines = push(Routes.VIRTUAL_MACHINES);
+ export const navigateToUserVirtualMachines = push(Routes.VIRTUAL_MACHINES_USER);
+ export const navigateToAdminVirtualMachines = push(Routes.VIRTUAL_MACHINES_ADMIN);
  
  export const navigateToRepositories = push(Routes.REPOSITORIES);
  
- export const navigateToSshKeys= push(Routes.SSH_KEYS);
+ export const navigateToSshKeysAdmin= push(Routes.SSH_KEYS_ADMIN);
+ export const navigateToSshKeysUser= push(Routes.SSH_KEYS_USER);
  
  export const navigateToMyAccount = push(Routes.MY_ACCOUNT);
  
@@@ -81,6 -84,4 +87,8 @@@ export const navigateToUsers = push(Rou
  
  export const navigateToApiClientAuthorizations = push(Routes.API_CLIENT_AUTHORIZATIONS);
  
 -export const navigateToLinks = push(Routes.LINKS);
 +export const navigateToGroups = push(Routes.GROUPS);
 +
 +export const navigateToGroupDetails = compose(push, getGroupUrl);
++
++export const navigateToLinks = push(Routes.LINKS);
diff --combined src/store/store.ts
index ad70868e0c62a830367f8e289337abd637915de8,792224d240f7434595f409db117a85744fbf528a..3aef8f500013fb301d6e6089296b9a05909a561e
@@@ -50,10 -50,8 +50,12 @@@ import { UserMiddlewareService } from '
  import { USERS_PANEL_ID } from '~/store/users/users-actions';
  import { computeNodesReducer } from '~/store/compute-nodes/compute-nodes-reducer';
  import { apiClientAuthorizationsReducer } from '~/store/api-client-authorizations/api-client-authorizations-reducer';
 +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 { LINK_PANEL_ID } from '~/store/link-panel/link-panel-actions';
+ import { LinkMiddlewareService } from '~/store/link-panel/link-panel-middleware-service';
  
  const composeEnhancers =
      (process.env.NODE_ENV === 'development' &&
@@@ -88,13 -86,9 +90,16 @@@ export function configureStore(history
      const userPanelMiddleware = dataExplorerMiddleware(
          new UserMiddlewareService(services, USERS_PANEL_ID)
      );
 +    const groupsPanelMiddleware = dataExplorerMiddleware(
 +        new GroupsPanelMiddlewareService(services, GROUPS_PANEL_ID)
 +    );
 +    const groupDetailsPanelMiddleware = dataExplorerMiddleware(
 +        new GroupDetailsPanelMiddlewareService(services, GROUP_DETAILS_PANEL_ID)
 +    );
 +
+     const linkPanelMiddleware = dataExplorerMiddleware(
+         new LinkMiddlewareService(services, LINK_PANEL_ID)
+     );
      const middlewares: Middleware[] = [
          routerMiddleware(history),
          thunkMiddleware.withExtraArgument(services),
          sharedWithMePanelMiddleware,
          workflowPanelMiddleware,
          userPanelMiddleware,
 +        groupsPanelMiddleware,
 +        groupDetailsPanelMiddleware,
+         linkPanelMiddleware
      ];
      const enhancer = composeEnhancers(applyMiddleware(...middlewares));
      return createStore(rootReducer, enhancer);
index e185e8cfd471d48ddc6697aee6f69ba8bd9351a6,85540f0b434ac90826b93aba3561f726d3325578..af2afab29f53ba33b2a06bed69e6174ddf085e9b
@@@ -14,7 -14,7 +14,7 @@@ import { favoritePanelActions } from '~
  import { projectPanelColumns } from '~/views/project-panel/project-panel';
  import { favoritePanelColumns } from '~/views/favorite-panel/favorite-panel';
  import { matchRootRoute } from '~/routes/routes';
 -import { setSidePanelBreadcrumbs, setProcessBreadcrumbs, setSharedWithMeBreadcrumbs, setTrashBreadcrumbs, setBreadcrumbs } from '~/store/breadcrumbs/breadcrumbs-actions';
 +import { setSidePanelBreadcrumbs, setProcessBreadcrumbs, setSharedWithMeBreadcrumbs, setTrashBreadcrumbs, setBreadcrumbs, setGroupDetailsBreadcrumbs, setGroupsBreadcrumbs } from '~/store/breadcrumbs/breadcrumbs-actions';
  import { navigateToProject } from '~/store/navigation/navigation-action';
  import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
  import { ServiceRepository } from '~/services/services';
@@@ -59,13 -59,11 +59,15 @@@ import { loadVirtualMachinesPanel } fro
  import { loadRepositoriesPanel } from '~/store/repositories/repositories-actions';
  import { loadKeepServicesPanel } from '~/store/keep-services/keep-services-actions';
  import { loadUsersPanel, userBindedActions } from '~/store/users/users-actions';
+ import { loadLinkPanel, linkPanelActions } from '~/store/link-panel/link-panel-actions';
+ import { linkPanelColumns } from '~/views/link-panel/link-panel-root';
  import { userPanelColumns } from '~/views/user-panel/user-panel';
  import { loadComputeNodesPanel } from '~/store/compute-nodes/compute-nodes-actions';
  import { loadApiClientAuthorizationsPanel } from '~/store/api-client-authorizations/api-client-authorizations-actions';
 +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';
  
  export const WORKBENCH_LOADING_SCREEN = 'workbenchLoadingScreen';
  
@@@ -100,8 -98,7 +102,9 @@@ export const loadWorkbench = () =
                  dispatch(workflowPanelActions.SET_COLUMNS({ columns: workflowPanelColumns }));
                  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(linkPanelActions.SET_COLUMNS({ columns: linkPanelColumns }));
                  dispatch<any>(initSidePanelTree());
                  if (router.location) {
                      const match = matchRootRoute(router.location.pathname);
@@@ -408,6 -405,11 +411,11 @@@ export const loadSearchResults = handle
          await dispatch(loadSearchResultsPanel());
      });
  
+ export const loadLinks = handleFirstTimeLoad(
+     async (dispatch: Dispatch<any>) => {
+         await dispatch(loadLinkPanel());
+     });
  export const loadVirtualMachines = handleFirstTimeLoad(
      async (dispatch: Dispatch<any>) => {
          await dispatch(loadVirtualMachinesPanel());
@@@ -451,20 -453,6 +459,20 @@@ export const loadApiClientAuthorization
          await dispatch(loadApiClientAuthorizationsPanel());
      });
  
 +export const loadGroupsPanel = handleFirstTimeLoad(
 +    (dispatch: Dispatch<any>) => {
 +        dispatch(setGroupsBreadcrumbs());
 +        dispatch(groupPanelActions.loadGroupsPanel());
 +    });
 +
 +
 +export const loadGroupDetailsPanel = (groupUuid: string) =>
 +    handleFirstTimeLoad(
 +        (dispatch: Dispatch<any>) => {
 +            dispatch(setGroupDetailsBreadcrumbs(groupUuid));
 +            dispatch(groupDetailsPanelActions.loadGroupDetailsPanel(groupUuid));
 +        });
 +
  const finishLoadingProject = (project: GroupContentsResource | string) =>
      async (dispatch: Dispatch<any>) => {
          const uuid = typeof project === 'string' ? project : project.uuid;
index e148a78a8c2c13086845eefa6caf9c8a3ed25c03,a9200ebb363df15541e6da5abe641c8200d98309..4ce2f5214d5f337d2488f0acb232d6d1ea5f1b0d
@@@ -75,7 -75,6 +75,8 @@@ export enum ContextMenuKind 
      VIRTUAL_MACHINE = "VirtualMachine",
      KEEP_SERVICE = "KeepService",
      USER = "User",
-     GROUP_MEMBER = "GroupMember"
 +    NODE = "Node",
 +    GROUPS = "Group",
 -    NODE = "Node"
++    GROUP_MEMBER = "GroupMember",
+     LINK = "Link",
  }
index cb8fd5665c692f62aa2afec07c7c6a7d3c07db44,1609aafa0849a277432f343dbe39e788de788bf7..a1116540226a5e2d543fd48aba1718468481e55a
@@@ -12,12 -12,8 +12,13 @@@ import { logout } from '~/store/auth/au
  import { RootState } from "~/store/store";
  import { openCurrentTokenDialog } from '~/store/current-token-dialog/current-token-dialog-actions';
  import { openRepositoriesPanel } from "~/store/repositories/repositories-actions";
- import { 
-     navigateToSshKeys, navigateToKeepServices, navigateToComputeNodes,
-     navigateToApiClientAuthorizations, navigateToMyAccount, navigateToGroups
++import {
++    navigateToKeepServices, navigateToComputeNodes,
++    navigateToApiClientAuthorizations, navigateToGroups
 +} from '~/store/navigation/navigation-action';
- import { openVirtualMachines } from "~/store/virtual-machines/virtual-machines-actions";
 +import { navigateToUsers } from '~/store/navigation/navigation-action';
+ import { navigateToSshKeysUser, navigateToMyAccount } from '~/store/navigation/navigation-action';
+ import { openUserVirtualMachines } from "~/store/virtual-machines/virtual-machines-actions";
  
  interface AccountMenuProps {
      user?: User;
@@@ -37,15 -33,10 +38,15 @@@ export const AccountMenu = connect(mapS
                  <MenuItem>
                      {getUserFullname(user)}
                  </MenuItem>
-                 <MenuItem onClick={() => dispatch(openVirtualMachines())}>Virtual Machines</MenuItem>
-                 <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>
+                 <MenuItem onClick={() => dispatch(openUserVirtualMachines())}>Virtual Machines</MenuItem>
+                 {!user.isAdmin && <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>}
                  <MenuItem onClick={() => dispatch(openCurrentTokenDialog)}>Current token</MenuItem>
-                 <MenuItem onClick={() => dispatch(navigateToSshKeys)}>Ssh Keys</MenuItem>
 +                <MenuItem onClick={() => dispatch(navigateToUsers)}>Users</MenuItem>
-                 { user.isAdmin && <MenuItem onClick={() => dispatch(navigateToGroups)}>Groups</MenuItem> }
-                 { user.isAdmin && <MenuItem onClick={() => dispatch(navigateToApiClientAuthorizations)}>Api Tokens</MenuItem> }
-                 { user.isAdmin && <MenuItem onClick={() => dispatch(navigateToKeepServices)}>Keep Services</MenuItem> }
-                 { user.isAdmin && <MenuItem onClick={() => dispatch(navigateToComputeNodes)}>Compute Nodes</MenuItem> }
++                {user.isAdmin && <MenuItem onClick={() => dispatch(navigateToGroups)}>Groups</MenuItem>}
++                {user.isAdmin && <MenuItem onClick={() => dispatch(navigateToApiClientAuthorizations)}>Api Tokens</MenuItem>}
++                {user.isAdmin && <MenuItem onClick={() => dispatch(navigateToKeepServices)}>Keep Services</MenuItem>}
++                {user.isAdmin && <MenuItem onClick={() => dispatch(navigateToComputeNodes)}>Compute Nodes</MenuItem>}
+                 <MenuItem onClick={() => dispatch(navigateToSshKeysUser)}>Ssh Keys</MenuItem>
                  <MenuItem onClick={() => dispatch(navigateToMyAccount)}>My account</MenuItem>
                  <MenuItem onClick={() => dispatch(logout())}>Logout</MenuItem>
              </DropdownMenu>
index 695834a69bf1aecb8ebcfaa5196ebc373a81e744,025540e22ed89c6bab5ecd1a9dee6ddc11281a94..bff328e8c8c6ff448bc271d36068925a8b0cc81d
@@@ -49,12 -49,14 +49,14 @@@ import { MyAccountPanel } from '~/views
  import { SharingDialog } from '~/views-components/sharing-dialog/sharing-dialog';
  import { AdvancedTabDialog } from '~/views-components/advanced-tab-dialog/advanced-tab-dialog';
  import { ProcessInputDialog } from '~/views-components/process-input-dialog/process-input-dialog';
- import { VirtualMachinePanel } from '~/views/virtual-machine-panel/virtual-machine-panel';
+ import { VirtualMachineUserPanel } from '~/views/virtual-machine-panel/virtual-machine-user-panel';
+ import { VirtualMachineAdminPanel } from '~/views/virtual-machine-panel/virtual-machine-admin-panel';
  import { ProjectPropertiesDialog } from '~/views-components/project-properties-dialog/project-properties-dialog';
  import { RepositoriesPanel } from '~/views/repositories-panel/repositories-panel';
  import { KeepServicePanel } from '~/views/keep-service-panel/keep-service-panel';
  import { ComputeNodePanel } from '~/views/compute-node-panel/compute-node-panel';
  import { ApiClientAuthorizationPanel } from '~/views/api-client-authorization-panel/api-client-authorization-panel';
+ import { LinkPanel } from '~/views/link-panel/link-panel';
  import { RepositoriesSampleGitDialog } from '~/views-components/repositories-sample-git-dialog/repositories-sample-git-dialog';
  import { RepositoryAttributesDialog } from '~/views-components/repository-attributes-dialog/repository-attributes-dialog';
  import { CreateRepositoryDialog } from '~/views-components/dialog-forms/create-repository-dialog';
@@@ -64,25 -66,19 +66,27 @@@ import { PublicKeyDialog } from '~/view
  import { RemoveApiClientAuthorizationDialog } from '~/views-components/api-client-authorizations-dialog/remove-dialog';
  import { RemoveComputeNodeDialog } from '~/views-components/compute-nodes-dialog/remove-dialog';
  import { RemoveKeepServiceDialog } from '~/views-components/keep-services-dialog/remove-dialog';
+ import { RemoveLinkDialog } from '~/views-components/links-dialog/remove-dialog';
  import { RemoveSshKeyDialog } from '~/views-components/ssh-keys-dialog/remove-dialog';
  import { RemoveVirtualMachineDialog } from '~/views-components/virtual-machines-dialog/remove-dialog';
  import { AttributesApiClientAuthorizationDialog } from '~/views-components/api-client-authorizations-dialog/attributes-dialog';
  import { AttributesComputeNodeDialog } from '~/views-components/compute-nodes-dialog/attributes-dialog';
  import { AttributesKeepServiceDialog } from '~/views-components/keep-services-dialog/attributes-dialog';
+ import { AttributesLinkDialog } from '~/views-components/links-dialog/attributes-dialog';
  import { AttributesSshKeyDialog } from '~/views-components/ssh-keys-dialog/attributes-dialog';
  import { VirtualMachineAttributesDialog } from '~/views-components/virtual-machines-dialog/attributes-dialog';
  import { UserPanel } from '~/views/user-panel/user-panel';
  import { UserAttributesDialog } from '~/views-components/user-dialog/attributes-dialog';
  import { CreateUserDialog } from '~/views-components/dialog-forms/create-user-dialog';
  import { HelpApiClientAuthorizationDialog } from '~/views-components/api-client-authorizations-dialog/help-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';
  
  type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content';
  
@@@ -152,16 -148,17 +156,19 @@@ export const WorkbenchPanel 
                                  <Route path={Routes.RUN_PROCESS} component={RunProcessPanel} />
                                  <Route path={Routes.WORKFLOWS} component={WorkflowPanel} />
                                  <Route path={Routes.SEARCH_RESULTS} component={SearchResultsPanel} />
-                                 <Route path={Routes.VIRTUAL_MACHINES} component={VirtualMachinePanel} />
+                                 <Route path={Routes.VIRTUAL_MACHINES_USER} component={VirtualMachineUserPanel} />
+                                 <Route path={Routes.VIRTUAL_MACHINES_ADMIN} component={VirtualMachineAdminPanel} />
                                  <Route path={Routes.REPOSITORIES} component={RepositoriesPanel} />
-                                 <Route path={Routes.SSH_KEYS} component={SshKeyPanel} />
+                                 <Route path={Routes.SSH_KEYS_USER} component={SshKeyPanel} />
+                                 <Route path={Routes.SSH_KEYS_ADMIN} component={SshKeyPanel} />
                                  <Route path={Routes.KEEP_SERVICES} component={KeepServicePanel} />
                                  <Route path={Routes.USERS} component={UserPanel} />
                                  <Route path={Routes.COMPUTE_NODES} component={ComputeNodePanel} />
                                  <Route path={Routes.API_CLIENT_AUTHORIZATIONS} component={ApiClientAuthorizationPanel} />
                                  <Route path={Routes.MY_ACCOUNT} component={MyAccountPanel} />
 +                                <Route path={Routes.GROUPS} component={GroupsPanel} />
 +                                <Route path={Routes.GROUP_DETAILS} component={GroupDetailsPanel} />
+                                 <Route path={Routes.LINKS} component={LinkPanel} />
                              </Switch>
                          </Grid>
                      </Grid>
              <Grid item>
                  <DetailsPanel />
              </Grid>
 +            <AddGroupMembersDialog />
              <AdvancedTabDialog />
              <AttributesApiClientAuthorizationDialog />
              <AttributesComputeNodeDialog />
              <AttributesKeepServiceDialog />
+             <AttributesLinkDialog />
              <AttributesSshKeyDialog />
              <ChangeWorkflowDialog />
              <ContextMenu />
              <CopyCollectionDialog />
              <CopyProcessDialog />
              <CreateCollectionDialog />
 +            <CreateGroupDialog />
              <CreateProjectDialog />
              <CreateRepositoryDialog />
              <CreateSshKeyDialog />
              <CurrentTokenDialog />
              <FileRemoveDialog />
              <FilesUploadCollectionDialog />
 +            <GroupAttributesDialog />
 +            <GroupMemberAttributesDialog />
              <HelpApiClientAuthorizationDialog />
              <MoveCollectionDialog />
              <MoveProcessDialog />
              <ProjectPropertiesDialog />
              <RemoveApiClientAuthorizationDialog />
              <RemoveComputeNodeDialog />
 +            <RemoveGroupDialog />
 +            <RemoveGroupMemberDialog />
              <RemoveKeepServiceDialog />
+             <RemoveLinkDialog />
              <RemoveProcessDialog />
              <RemoveRepositoryDialog />
              <RemoveSshKeyDialog />