Merge branch '18284-vm-listing' into main. Closes #18284
authorStephen Smith <stephen@curii.com>
Mon, 14 Feb 2022 15:31:50 +0000 (10:31 -0500)
committerStephen Smith <stephen@curii.com>
Mon, 14 Feb 2022 15:31:50 +0000 (10:31 -0500)
Arvados-DCO-1.1-Signed-off-by: Stephen Smith <stephen@curii.com>

16 files changed:
cypress/integration/virtual-machine-admin.spec.js [new file with mode: 0644]
cypress/support/commands.js
src/components/breadcrumbs/breadcrumbs.tsx
src/components/chips-input/chips-input.tsx
src/components/icon/icon.tsx
src/models/permission.ts
src/routes/route-change-handlers.ts
src/store/virtual-machines/virtual-machines-actions.ts
src/store/workbench/workbench-actions.ts
src/views-components/data-explorer/renderers.tsx
src/views-components/virtual-machines-dialog/add-login-dialog.tsx [new file with mode: 0644]
src/views-components/virtual-machines-dialog/group-array-input.tsx [new file with mode: 0644]
src/views-components/virtual-machines-dialog/remove-login-dialog.tsx [new file with mode: 0644]
src/views/virtual-machine-panel/virtual-machine-admin-panel.tsx
src/views/virtual-machine-panel/virtual-machine-user-panel.tsx
src/views/workbench/workbench.tsx

diff --git a/cypress/integration/virtual-machine-admin.spec.js b/cypress/integration/virtual-machine-admin.spec.js
new file mode 100644 (file)
index 0000000..73804b2
--- /dev/null
@@ -0,0 +1,274 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Virtual machine login manage tests', function() {
+    let activeUser;
+    let adminUser;
+
+    const vmHost = `vm-${Math.floor(999999 * Math.random())}.host`;
+
+    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', 'VMAdmin', 'User', true, true)
+            .as('adminUser').then(function() {
+                adminUser = this.adminUser;
+            }
+        );
+        cy.getUser('user', 'VMActive', 'User', false, true)
+            .as('activeUser').then(function() {
+                activeUser = this.activeUser;
+            }
+        );
+    });
+
+    it('adds and removes vm logins', function() {
+        cy.loginAs(adminUser);
+        cy.createVirtualMachine(adminUser.token, {hostname: vmHost});
+
+        // Navigate to VM admin
+        cy.get('header button[title="Admin Panel"]').click();
+        cy.get('#admin-menu').contains('Virtual Machines').click();
+
+        // Add login permission to admin
+        cy.get('[data-cy=vm-admin-table]')
+            .contains(vmHost)
+            .parents('tr')
+            .within(() => {
+                cy.get('button[title="Add Login Permission"]').click();
+            });
+        cy.get('[data-cy=form-dialog]')
+            .should('contain', 'Add login permission')
+            .within(() => {
+                cy.get('label')
+                  .contains('Search for user')
+                  .parent()
+                  .within(() => {
+                    cy.get('input').type('VMAdmin');
+                  })
+            });
+        cy.get('[role=tooltip]').click();
+        cy.get('[data-cy=form-dialog]')
+            .should('contain', 'Add login permission')
+            .within(() => {
+                cy.get('label')
+                  .contains('Add groups')
+                  .parent()
+                  .within(() => {
+                    cy.get('input').type('docker sudo{enter}');
+                  })
+            });
+        cy.get('[data-cy=form-dialog]').within(() => {
+            cy.get('[data-cy=form-submit-btn]').click();
+        });
+        cy.get('[data-cy=vm-admin-table]')
+            .contains(vmHost)
+            .parents('tr')
+            .within(() => {
+                cy.get('td').contains('admin');
+        });
+
+        // Add login permission to activeUser
+        cy.get('[data-cy=vm-admin-table]')
+            .contains(vmHost)
+            .parents('tr')
+            .within(() => {
+                cy.get('button[title="Add Login Permission"]').click();
+            });
+        cy.get('[data-cy=form-dialog]')
+            .should('contain', 'Add login permission')
+            .within(() => {
+                cy.get('label')
+                  .contains('Search for user')
+                  .parent()
+                  .within(() => {
+                    cy.get('input').type('VMActive user');
+                  })
+            });
+        cy.get('[role=tooltip]').click();
+        cy.get('[data-cy=form-dialog]').within(() => {
+            cy.get('[data-cy=form-submit-btn]').click();
+        });
+        cy.get('[data-cy=vm-admin-table]')
+            .contains(vmHost)
+            .parents('tr')
+            .within(() => {
+                cy.get('td').contains('user');
+        });
+
+        // Check admin's vm page for login
+        cy.get('header button[title="Account Management"]').click();
+        cy.get('#account-menu').contains('Virtual Machines').click();
+
+        cy.get('[data-cy=vm-user-table]')
+            .contains(vmHost)
+            .parents('tr')
+            .within(() => {
+                cy.get('td').contains('admin');
+                cy.get('td').contains('docker');
+                cy.get('td').contains('sudo');
+                cy.get('td').contains('ssh admin@' + vmHost);
+        });
+
+        // Check activeUser's vm page for login
+        cy.loginAs(activeUser);
+        cy.get('header button[title="Account Management"]').click();
+        cy.get('#account-menu').contains('Virtual Machines').click();
+
+        cy.get('[data-cy=vm-user-table]')
+            .contains(vmHost)
+            .parents('tr')
+            .within(() => {
+                cy.get('td').contains('user');
+                cy.get('td').should('not.contain', 'docker');
+                cy.get('td').should('not.contain', 'sudo');
+                cy.get('td').contains('ssh user@' + vmHost);
+        });
+
+        // Edit login permissions
+        cy.loginAs(adminUser);
+        cy.get('header button[title="Admin Panel"]').click();
+        cy.get('#admin-menu').contains('Virtual Machines').click();
+
+        cy.get('[data-cy=vm-admin-table]')
+            .contains('admin'); // Wait for page to finish
+
+        cy.get('[data-cy=vm-admin-table]')
+            .contains(vmHost)
+            .parents('tr')
+            .contains('admin')
+            .click();
+
+        cy.get('[data-cy=form-dialog]')
+            .should('contain', 'Update login permission')
+            .within(() => {
+                cy.get('label')
+                    .contains('Add groups')
+                    .parent()
+                    .as('groupInput');
+            });
+
+        cy.get('@groupInput').within(() => {
+            cy.get('div[role=button]').contains('sudo').parent().find('svg').click();
+            cy.get('div[role=button]').contains('docker').parent().find('svg').click();
+        });
+
+        cy.get('[data-cy=form-dialog]').within(() => {
+            cy.get('[data-cy=form-submit-btn]').click();
+        });
+
+        cy.get('[data-cy=vm-admin-table]')
+            .contains('user'); // Wait for page to finish
+
+        cy.get('[data-cy=vm-admin-table]')
+            .contains(vmHost)
+            .parents('tr')
+            .contains('user')
+            .click();
+
+        cy.get('[data-cy=form-dialog]')
+            .should('contain', 'Update login permission')
+            .within(() => {
+                cy.get('label')
+                    .contains('Add groups')
+                    .parent()
+                    .within(() => {
+                        cy.get('input').type('docker{enter}');
+                    })
+            });
+
+        cy.get('[data-cy=form-dialog]').within(() => {
+            cy.get('[data-cy=form-submit-btn]').click();
+        });
+
+        // Verify new login permissions
+        // Check admin's vm page for login
+        cy.get('header button[title="Account Management"]').click();
+        cy.get('#account-menu').contains('Virtual Machines').click();
+
+        cy.get('[data-cy=vm-user-table]')
+            .contains(vmHost)
+            .parents('tr')
+            .within(() => {
+                cy.get('td').contains('admin');
+                cy.get('td').should('not.contain', 'docker');
+                cy.get('td').should('not.contain', 'sudo');
+                cy.get('td').contains('ssh admin@' + vmHost);
+        });
+
+        // Verify new login permissions
+        // Check activeUser's vm page for login
+        cy.loginAs(activeUser);
+        cy.get('header button[title="Account Management"]').click();
+        cy.get('#account-menu').contains('Virtual Machines').click();
+
+        cy.get('[data-cy=vm-user-table]')
+            .contains(vmHost)
+            .parents('tr')
+            .within(() => {
+                cy.get('td').contains('user');
+                cy.get('td').contains('docker');
+                cy.get('td').should('not.contain', 'sudo');
+                cy.get('td').contains('ssh user@' + vmHost);
+        });
+
+        // Remove login permissions
+        cy.loginAs(adminUser);
+        cy.get('header button[title="Admin Panel"]').click();
+        cy.get('#admin-menu').contains('Virtual Machines').click();
+
+        cy.get('[data-cy=vm-admin-table]')
+            .contains('user'); // Wait for page to finish
+
+        cy.get('[data-cy=vm-admin-table]')
+            .contains(vmHost)
+            .parents('tr')
+            .as('vmRow')
+            .contains('user')
+            .parents('[role=button]')
+            .find('svg')
+            .as('removeButton');
+        cy.get('@removeButton').click();
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+
+        cy.get('@vmRow')
+            .within(() => {
+                cy.get('div[role=button]').should('not.contain', 'user');
+                cy.get('div[role=button]').should('have.length', 1)
+            });
+
+        cy.get('@vmRow')
+            .find('div[role=button]')
+            .contains('admin')
+            .parents('[role=button]')
+            .find('svg')
+            .as('removeButton');
+        cy.get('@removeButton').click();
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+
+        cy.get('[data-cy=vm-admin-table]')
+            .contains(vmHost)
+            .parents('tr')
+            .within(() => {
+                cy.get('div[role=button]').should('not.contain', 'admin');
+            });
+
+        // Check admin's vm page for login
+        cy.get('header button[title="Account Management"]').click();
+        cy.get('#account-menu').contains('Virtual Machines').click();
+
+        cy.get('[data-cy=vm-user-panel]')
+            .should('not.contain', vmHost);
+
+        // Check activeUser's vm page for login
+        cy.loginAs(activeUser);
+        cy.get('header button[title="Account Management"]').click();
+        cy.get('#account-menu').contains('Virtual Machines').click();
+
+        cy.get('[data-cy=vm-user-panel]')
+            .should('not.contain', vmHost);
+    });
+});
index 07290e550aa3b6beceba61559a477216ef220435..cfdfa9ecffb0e94c2262d0f580b8222f3d7b2c72 100644 (file)
@@ -150,6 +150,15 @@ Cypress.Commands.add(
     }
 )
 
+Cypress.Commands.add(
+    "createVirtualMachine", (token, data) => {
+        return cy.createResource(token, 'virtual_machines', {
+            virtual_machine: JSON.stringify(data),
+            ensure_unique_name: true
+        })
+    }
+)
+
 Cypress.Commands.add(
     "createResource", (token, suffix, data) => {
         return cy.doRequest('POST', '/arvados/v1/' + suffix, data, null, token, true)
@@ -318,4 +327,4 @@ function b64toBlob(b64Data, contentType = '', sliceSize = 512) {
 
     const blob = new Blob(byteArrays, { type: contentType });
     return blob
-}
\ No newline at end of file
+}
index 21211460b7df265be2a5b47cffe4384612700002..3d668856ecd5d1c20e4faebc3b51d54466045277 100644 (file)
@@ -7,12 +7,15 @@ import { Button, Grid, StyleRulesCallback, WithStyles, Typography, Tooltip } fro
 import ChevronRightIcon from '@material-ui/icons/ChevronRight';
 import { withStyles } from '@material-ui/core';
 import { IllegalNamingWarning } from '../warning/warning';
+import { IconType } from 'components/icon/icon';
+import grey from '@material-ui/core/colors/grey';
 
 export interface Breadcrumb {
     label: string;
+    icon?: IconType;
 }
 
-type CssRules = "item" | "currentItem" | "label";
+type CssRules = "item" | "currentItem" | "label" | "icon";
 
 const styles: StyleRulesCallback<CssRules> = theme => ({
     item: {
@@ -23,7 +26,11 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
     },
     label: {
         textTransform: "none"
-    }
+    },
+    icon: {
+        fontSize: 20,
+        color: grey["600"]
+    },
 });
 
 export interface BreadcrumbsProps {
@@ -39,6 +46,7 @@ export const Breadcrumbs = withStyles(styles)(
         items.map((item, index) => {
             const isLastItem = index === items.length - 1;
             const isFirstItem = index === 0;
+            const Icon = item.icon || (() => (null));
             return (
                 <React.Fragment key={index}>
                     {isFirstItem ? null : <IllegalNamingWarning name={item.label} />}
@@ -54,6 +62,7 @@ export const Breadcrumbs = withStyles(styles)(
                             className={isLastItem ? classes.currentItem : classes.item}
                             onClick={() => onClick(item)}
                             onContextMenu={event => onContextMenu(event, item)}>
+                            <Icon className={classes.icon} />
                             <Typography
                                 noWrap
                                 color="inherit"
index 32077fbedf3a5560f1fdf4de1549bf64203ad1a5..cbb1fb1283b31246152f2c32b6a961cb5f256248 100644 (file)
@@ -12,12 +12,16 @@ interface ChipsInputProps<Value> {
     values: Value[];
     getLabel?: (value: Value) => string;
     onChange: (value: Value[]) => void;
+    handleFocus?: (e: any) => void;
+    handleBlur?: (e: any) => void;
+    chipsClassName?: string;
     createNewValue: (value: string) => Value;
     inputComponent?: React.ComponentType<InputProps>;
     inputProps?: InputProps;
     deletable?: boolean;
     orderable?: boolean;
     disabled?: boolean;
+    pattern?: RegExp;
 }
 
 type CssRules = 'chips' | 'input' | 'inputContainer';
@@ -49,22 +53,45 @@ export const ChipsInput = withStyles(styles)(
         timeout = -1;
 
         setText = (event: React.ChangeEvent<HTMLInputElement>) => {
-            this.setState({ text: event.target.value });
+            this.setState({ text: event.target.value }, () => {
+                // If pattern is provided, check for delimiter
+                if (this.props.pattern) {
+                    const matches = this.state.text.match(this.props.pattern);
+                    // Only create values if 1 match and the last character is a delimiter
+                    //   (user pressed an invalid character at the end of a token)
+                    //   or if multiple matches (user pasted text)
+                    if (matches &&
+                            (
+                                matches.length > 1 ||
+                                (matches.length === 1 && !this.state.text.endsWith(matches[0]))
+                            )) {
+                        this.createNewValue(matches.map((i) => i));
+                    }
+                }
+            });
         }
 
-        handleKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
-            if (key === 'Enter') {
+        handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
+            // Handle special keypresses
+            if (e.key === 'Enter') {
                 this.createNewValue();
-            } else if (key === 'Backspace') {
+                e.preventDefault();
+            } else if (e.key === 'Backspace') {
                 this.deleteLastValue();
             }
         }
 
-        createNewValue = () => {
+        createNewValue = (matches?: string[]) => {
             if (this.state.text) {
-                const newValue = this.props.createNewValue(this.state.text);
-                this.setState({ text: '' });
-                this.props.onChange([...this.props.values, newValue]);
+                if (matches && matches.length > 0) {
+                    const newValues = matches.map((v) => this.props.createNewValue(v));
+                    this.setState({ text: '' });
+                    this.props.onChange([...this.props.values, ...newValues]);
+                } else {
+                    const newValue = this.props.createNewValue(this.state.text);
+                    this.setState({ text: '' });
+                    this.props.onChange([...this.props.values, newValue]);
+                }
             }
         }
 
@@ -104,7 +131,7 @@ export const ChipsInput = withStyles(styles)(
 
         renderChips() {
             const { classes, ...props } = this.props;
-            return <div className={classes.chips}>
+            return <div className={[classes.chips, this.props.chipsClassName].join(' ')}>
                 <Chips
                     {...props}
                     clickable={!props.disabled}
@@ -121,6 +148,8 @@ export const ChipsInput = withStyles(styles)(
                 onChange={this.setText}
                 disabled={this.props.disabled}
                 onKeyDown={this.handleKeyPress}
+                onFocus={this.props.handleFocus}
+                onBlur={this.props.handleBlur}
                 inputProps={{
                     ...(InputProps && InputProps.inputProps),
                     className: classes.input,
index 4f7305f740e524b87745c1c0b82ef84e4f28305a..54b7bee6cd827b044af7dd88197ae7c76e4a8d82 100644 (file)
@@ -172,3 +172,4 @@ 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} />;
+export const AddUserIcon: IconType = (props) => <PersonAdd {...props} />;
index f340c5023adad5365421231c794452d2ec58182f..1d6038010b77093336687e7b0ce60cb279fef0a9 100644 (file)
@@ -13,4 +13,5 @@ export enum PermissionLevel {
     CAN_READ = 'can_read',
     CAN_WRITE = 'can_write',
     CAN_MANAGE = 'can_manage',
+    CAN_LOGIN = 'can_login',
 }
index 70f65cb46a8e72dd4d2962236a915b06392f63c9..044a38bf20df04d3822a5325990256a89b9295ec 100644 (file)
@@ -86,7 +86,7 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
     } else if (virtualMachineUserMatch) {
         store.dispatch(WorkbenchActions.loadVirtualMachines);
     } else if (virtualMachineAdminMatch) {
-        store.dispatch(WorkbenchActions.loadVirtualMachines);
+        store.dispatch(WorkbenchActions.loadVirtualMachinesAdmin);
     } else if (repositoryMatch) {
         store.dispatch(WorkbenchActions.loadRepositories);
     } else if (sshKeysUserMatch) {
index e3b69d8dab4df73f797a2e44895a9780343d08ff..e2cf6fd4c0e33da5c3da8a901d153c3937a9c0aa 100644 (file)
@@ -14,6 +14,11 @@ import { FilterBuilder } from "services/api/filter-builder";
 import { ListResults } from "services/common-service/common-service";
 import { dialogActions } from 'store/dialog/dialog-actions';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { PermissionLevel } from "models/permission";
+import { deleteResources, updateResources } from 'store/resources/resources-actions';
+import { Participant } from "views-components/sharing-dialog/participant-select";
+import { initialize, reset } from "redux-form";
+import { getUserDisplayName } from "models/user";
 
 export const virtualMachinesActions = unionize({
     SET_REQUESTED_DATE: ofType<string>(),
@@ -27,6 +32,14 @@ export type VirtualMachineActions = UnionOf<typeof virtualMachinesActions>;
 export const VIRTUAL_MACHINES_PANEL = 'virtualMachinesPanel';
 export const VIRTUAL_MACHINE_ATTRIBUTES_DIALOG = 'virtualMachineAttributesDialog';
 export const VIRTUAL_MACHINE_REMOVE_DIALOG = 'virtualMachineRemoveDialog';
+export const VIRTUAL_MACHINE_ADD_LOGIN_DIALOG = 'virtualMachineAddLoginDialog';
+export const VIRTUAL_MACHINE_ADD_LOGIN_FORM = 'virtualMachineAddLoginForm';
+export const VIRTUAL_MACHINE_REMOVE_LOGIN_DIALOG = 'virtualMachineRemoveLoginDialog';
+
+export const VIRTUAL_MACHINE_UPDATE_LOGIN_UUID_FIELD = 'uuid';
+export const VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD = 'vmUuid';
+export const VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD = 'user';
+export const VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD = 'groups';
 
 export const openUserVirtualMachines = () =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
@@ -59,8 +72,29 @@ const loadRequestedDate = () =>
 export const loadVirtualMachinesAdminData = () =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch<any>(loadRequestedDate());
+
         const virtualMachines = await services.virtualMachineService.list();
+        dispatch(updateResources(virtualMachines.items));
         dispatch(virtualMachinesActions.SET_VIRTUAL_MACHINES(virtualMachines));
+
+
+        const logins = await services.permissionService.list({
+            filters: new FilterBuilder()
+            .addIn('head_uuid', virtualMachines.items.map(item => item.uuid))
+            .addEqual('name', PermissionLevel.CAN_LOGIN)
+            .getFilters()
+        });
+        dispatch(updateResources(logins.items));
+        dispatch(virtualMachinesActions.SET_LINKS(logins));
+
+        const users = await services.userService.list({
+            filters: new FilterBuilder()
+            .addIn('uuid', logins.items.map(item => item.tailUuid))
+            .getFilters(),
+            count: "none"
+        });
+        dispatch(updateResources(users.items));
+
         const getAllLogins = await services.virtualMachineService.getAllLogins();
         dispatch(virtualMachinesActions.SET_LOGINS(getAllLogins));
     };
@@ -79,6 +113,104 @@ export const loadVirtualMachinesUserData = () =>
         dispatch(virtualMachinesActions.SET_LINKS(links));
     };
 
+export const openAddVirtualMachineLoginDialog = (vmUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(initialize(VIRTUAL_MACHINE_ADD_LOGIN_FORM, {[VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]: vmUuid, [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: []}));
+        dispatch(dialogActions.OPEN_DIALOG( {id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: {}} ));
+    }
+
+export const openEditVirtualMachineLoginDialog = (permissionUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const login = await services.permissionService.get(permissionUuid);
+        const user = await services.userService.get(login.tailUuid);
+        dispatch(initialize(VIRTUAL_MACHINE_ADD_LOGIN_FORM, {
+                [VIRTUAL_MACHINE_UPDATE_LOGIN_UUID_FIELD]: permissionUuid,
+                [VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD]: {name: getUserDisplayName(user, true), uuid: login.tailUuid},
+                [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: login.properties.groups,
+            }));
+        dispatch(dialogActions.OPEN_DIALOG( {id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: {updating: true}} ));
+    }
+
+export interface AddLoginFormData {
+    [VIRTUAL_MACHINE_UPDATE_LOGIN_UUID_FIELD]: string;
+    [VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]: string;
+    [VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD]: Participant;
+    [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: string[];
+}
+
+
+export const addUpdateVirtualMachineLogin = ({uuid, vmUuid, user, groups}: AddLoginFormData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            // Get user
+            const userResource = await services.userService.get(user.uuid);
+
+            if (uuid) {
+                const permission = await services.permissionService.update(uuid, {
+                    tailUuid: userResource.uuid,
+                    name: PermissionLevel.CAN_LOGIN,
+                    properties: {
+                        username: userResource.username,
+                        groups,
+                    }
+                });
+                dispatch(updateResources([permission]));
+            } else {
+                const permission = await services.permissionService.create({
+                headUuid: vmUuid,
+                    tailUuid: userResource.uuid,
+                    name: PermissionLevel.CAN_LOGIN,
+                    properties: {
+                        username: userResource.username,
+                        groups,
+                    }
+                });
+                dispatch(updateResources([permission]));
+            }
+
+            dispatch(reset(VIRTUAL_MACHINE_ADD_LOGIN_FORM));
+            dispatch(dialogActions.CLOSE_DIALOG({ id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG }));
+            dispatch<any>(loadVirtualMachinesAdminData());
+
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: `Permission updated`,
+                kind: SnackbarKind.SUCCESS
+            }));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        }
+    };
+
+export const openRemoveVirtualMachineLoginDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: VIRTUAL_MACHINE_REMOVE_LOGIN_DIALOG,
+            data: {
+                title: 'Remove login permission',
+                text: 'Are you sure you want to remove this permission?',
+                confirmButtonLabel: 'Remove',
+                uuid
+            }
+        }));
+    };
+
+export const removeVirtualMachineLogin = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            await services.permissionService.delete(uuid);
+            dispatch<any>(deleteResources([uuid]));
+
+            dispatch<any>(loadVirtualMachinesAdminData());
+
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: `Login permission removed`,
+                kind: SnackbarKind.SUCCESS
+            }));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        }
+    };
+
 export const saveRequestedDate = () =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const date = formatDate((new Date()).toISOString());
index 03d65f62817e120567e3d27152f5f418caddd15b..98508f752ead17acf5fe461582a9f128c9af4462 100644 (file)
@@ -100,6 +100,7 @@ import { loadAllProcessesPanel, allProcessesPanelActions } from '../all-processe
 import { allProcessesPanelColumns } from 'views/all-processes-panel/all-processes-panel';
 import { collectionPanelFilesAction } from '../collection-panel/collection-panel-files/collection-panel-files-actions';
 import { createTree } from 'models/tree';
+import { AdminMenuIcon } from 'components/icon/icon';
 
 export const WORKBENCH_LOADING_SCREEN = 'workbenchLoadingScreen';
 
@@ -491,6 +492,12 @@ export const loadVirtualMachines = handleFirstTimeLoad(
         dispatch(setBreadcrumbs([{ label: 'Virtual Machines' }]));
     });
 
+export const loadVirtualMachinesAdmin = handleFirstTimeLoad(
+    async (dispatch: Dispatch<any>) => {
+        await dispatch(loadVirtualMachinesPanel());
+        dispatch(setBreadcrumbs([{ label: 'Virtual Machines Admin', icon: AdminMenuIcon }]));
+    });
+
 export const loadRepositories = handleFirstTimeLoad(
     async (dispatch: Dispatch<any>) => {
         await dispatch(loadRepositoriesPanel());
index 47c1eaa9fb73e7a85212b5e1bdc180350a111688..a2acaca4525e6bceb6d65f869dae09f986412c21 100644 (file)
@@ -35,6 +35,7 @@ import { formatPermissionLevel } from 'views-components/sharing-dialog/permissio
 import { PermissionLevel } from 'models/permission';
 import { openPermissionEditContextMenu } from 'store/context-menu/context-menu-actions';
 import { getUserUuid } from 'common/getuser';
+import { VirtualMachinesResource } from 'models/virtual-machines';
 
 const renderName = (dispatch: Dispatch, item: GroupContentsResource) => {
 
@@ -271,15 +272,37 @@ export const ResourceIsAdmin = connect(
     }, { toggleIsAdmin }
 )(renderIsAdmin);
 
-const renderUsername = (item: { username: string }) =>
-    <Typography noWrap>{item.username}</Typography>;
+const renderUsername = (item: { username: string, uuid: string }) =>
+    <Typography noWrap>{item.username || item.uuid}</Typography>;
 
 export const ResourceUsername = connect(
     (state: RootState, props: { uuid: string }) => {
         const resource = getResource<UserResource>(props.uuid)(state.resources);
-        return resource || { username: '' };
+        return resource || { username: '', uuid: props.uuid };
     })(renderUsername);
 
+// Virtual machine resource
+
+const renderHostname = (item: { hostname: string }) =>
+    <Typography noWrap>{item.hostname}</Typography>;
+
+export const VirtualMachineHostname = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const resource = getResource<VirtualMachinesResource>(props.uuid)(state.resources);
+        return resource || { hostname: '' };
+    })(renderHostname);
+
+const renderVirtualMachineLogin = (login: {user: string}) =>
+    <Typography noWrap>{login.user}</Typography>
+
+export const VirtualMachineLogin = connect(
+    (state: RootState, props: { linkUuid: string }) => {
+        const permission = getResource<LinkResource>(props.linkUuid)(state.resources);
+        const user = getResource<UserResource>(permission?.tailUuid || '')(state.resources);
+
+        return {user: user?.username || permission?.tailUuid || ''};
+    })(renderVirtualMachineLogin);
+
 // Common methods
 const renderCommonData = (data: string) =>
     <Typography noWrap>{data}</Typography>;
diff --git a/src/views-components/virtual-machines-dialog/add-login-dialog.tsx b/src/views-components/virtual-machines-dialog/add-login-dialog.tsx
new file mode 100644 (file)
index 0000000..bfc0471
--- /dev/null
@@ -0,0 +1,56 @@
+// 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, WrappedFieldProps, Field } from 'redux-form';
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, VIRTUAL_MACHINE_ADD_LOGIN_FORM, addUpdateVirtualMachineLogin, AddLoginFormData, VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD, VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD } from 'store/virtual-machines/virtual-machines-actions';
+import { ParticipantSelect } from 'views-components/sharing-dialog/participant-select';
+import { GroupArrayInput } from 'views-components/virtual-machines-dialog/group-array-input';
+
+export const VirtualMachineAddLoginDialog = compose(
+    withDialog(VIRTUAL_MACHINE_ADD_LOGIN_DIALOG),
+    reduxForm<AddLoginFormData>({
+        form: VIRTUAL_MACHINE_ADD_LOGIN_FORM,
+        onSubmit: (data, dispatch) => {
+            dispatch(addUpdateVirtualMachineLogin(data));
+        }
+    })
+)(
+    (props: CreateGroupDialogComponentProps) =>
+        <FormDialog
+            dialogTitle={props.data.updating ? "Update login permission" : "Add login permission"}
+            formFields={AddLoginFormFields}
+            submitLabel={props.data.updating ? "Update" : "Add"}
+            {...props}
+        />
+);
+
+type CreateGroupDialogComponentProps = WithDialogProps<{updating: boolean}> & InjectedFormProps<AddLoginFormData>;
+
+const AddLoginFormFields = () =>
+    <>
+        <UserField />
+        <GroupArrayInput
+            name={VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD}
+            input={{id:"Add groups to VM login (eg: docker, sudo)", disabled:false}}
+            required={false}
+        />
+    </>;
+
+const UserField = () =>
+    <Field
+        name={VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD}
+        component={UserSelect}
+        />;
+
+const UserSelect = ({ input, meta }: WrappedFieldProps) =>
+    <ParticipantSelect
+        onlyPeople
+        label='Search for user to grant login permission'
+        items={input.value ? [input.value] : []}
+        onSelect={input.onChange}
+        onDelete={() => (input.onChange(''))} />;
diff --git a/src/views-components/virtual-machines-dialog/group-array-input.tsx b/src/views-components/virtual-machines-dialog/group-array-input.tsx
new file mode 100644 (file)
index 0000000..3ea5e77
--- /dev/null
@@ -0,0 +1,73 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { StringArrayCommandInputParameter } from 'models/workflow';
+import { Field } from 'redux-form';
+import { GenericInputProps } from 'views/run-process-panel/inputs/generic-input';
+import { ChipsInput } from 'components/chips-input/chips-input';
+import { identity } from 'lodash';
+import { withStyles, WithStyles, FormGroup, Input, InputLabel, FormControl } from '@material-ui/core';
+
+export interface StringArrayInputProps {
+  name: string;
+  input: StringArrayCommandInputParameter;
+  required: boolean;
+}
+
+type CssRules = 'chips';
+
+const styles = {
+    chips: {
+        marginTop: "16px",
+    },
+};
+
+export const GroupArrayInput = ({name, input}: StringArrayInputProps) =>
+    <Field
+        name={name}
+        commandInput={input}
+        component={StringArrayInputComponent as any}
+        />;
+
+const StringArrayInputComponent = (props: GenericInputProps) => {
+  return <FormGroup>
+        <FormControl fullWidth error={props.meta.error}>
+          <InputLabel shrink={props.meta.active || props.input.value.length > 0}>{props.commandInput.id}</InputLabel>
+          <StyledInputComponent {...props} />
+        </FormControl>
+    </FormGroup>;
+    };
+
+const StyledInputComponent = withStyles(styles)(
+  class InputComponent extends React.PureComponent<GenericInputProps & WithStyles<CssRules>>{
+      render() {
+          const { classes } = this.props;
+          const { commandInput, input, meta } = this.props;
+          return <ChipsInput
+              deletable={!commandInput.disabled}
+              orderable={!commandInput.disabled}
+              disabled={commandInput.disabled}
+              values={input.value}
+              onChange={this.handleChange}
+              handleFocus={input.onFocus}
+              createNewValue={identity}
+              inputComponent={Input}
+              chipsClassName={classes.chips}
+              pattern={/[_a-z][-0-9_a-z]*/ig}
+              inputProps={{
+                  error: meta.error,
+              }} />;
+      }
+
+      handleChange = (values: {}[]) => {
+        const { input, meta } = this.props;
+          if (!meta.touched) {
+              input.onBlur(values);
+          }
+          input.onChange(values);
+      }
+
+  }
+);
diff --git a/src/views-components/virtual-machines-dialog/remove-login-dialog.tsx b/src/views-components/virtual-machines-dialog/remove-login-dialog.tsx
new file mode 100644 (file)
index 0000000..60a485f
--- /dev/null
@@ -0,0 +1,22 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { VIRTUAL_MACHINE_REMOVE_LOGIN_DIALOG, removeVirtualMachineLogin, loadVirtualMachinesAdminData } from 'store/virtual-machines/virtual-machines-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(removeVirtualMachineLogin(props.data.uuid));
+        dispatch<any>(loadVirtualMachinesAdminData());
+    }
+});
+
+export const RemoveVirtualMachineLoginDialog = compose(
+    withDialog(VIRTUAL_MACHINE_REMOVE_LOGIN_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
index a6ad24a7dbbc6c7206dcbe48d390efac7a24d6b9..468ef35a984da4f7a1198d886c576e4d9a9e33ba 100644 (file)
@@ -4,18 +4,19 @@
 
 import React from 'react';
 import { connect } from 'react-redux';
-import { Grid, Card, CardContent, TableBody, TableCell, TableHead, TableRow, Table, Tooltip, IconButton } from '@material-ui/core';
+import { Grid, Card, Chip, CardContent, TableBody, TableCell, TableHead, TableRow, Table, Tooltip, IconButton } from '@material-ui/core';
 import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
 import { ArvadosTheme } from 'common/custom-theme';
 import { compose, Dispatch } from 'redux';
-import { loadVirtualMachinesAdminData } from 'store/virtual-machines/virtual-machines-actions';
+import { loadVirtualMachinesAdminData, openAddVirtualMachineLoginDialog, openRemoveVirtualMachineLoginDialog, openEditVirtualMachineLoginDialog } from 'store/virtual-machines/virtual-machines-actions';
 import { RootState } from 'store/store';
 import { ListResults } from 'services/common-service/common-service';
-import { MoreOptionsIcon } from 'components/icon/icon';
+import { MoreOptionsIcon, AddUserIcon } from 'components/icon/icon';
 import { VirtualMachineLogins, VirtualMachinesResource } from 'models/virtual-machines';
 import { openVirtualMachinesContextMenu } from 'store/context-menu/context-menu-actions';
+import { ResourceUuid, VirtualMachineHostname, VirtualMachineLogin } from 'views-components/data-explorer/renderers';
 
-type CssRules = 'moreOptionsButton' | 'moreOptions';
+type CssRules = 'moreOptionsButton' | 'moreOptions' | 'chipsRoot';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     moreOptionsButton: {
@@ -27,6 +28,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
             paddingRight: 0
         }
     },
+    chipsRoot: {
+        margin: `0px -${theme.spacing.unit / 2}px`,
+    },
 });
 
 const mapStateToProps = (state: RootState) => {
@@ -36,22 +40,35 @@ const mapStateToProps = (state: RootState) => {
     };
 };
 
-const mapDispatchToProps = (dispatch: Dispatch): Pick<VirtualMachinesPanelActionProps, 'loadVirtualMachinesData' | 'onOptionsMenuOpen'> => ({
+const mapDispatchToProps = (dispatch: Dispatch): Pick<VirtualMachinesPanelActionProps, 'loadVirtualMachinesData' | 'onOptionsMenuOpen' | 'onAddLogin' | 'onDeleteLogin' | 'onLoginEdit'> => ({
     loadVirtualMachinesData: () => dispatch<any>(loadVirtualMachinesAdminData()),
     onOptionsMenuOpen: (event, virtualMachine) => {
         dispatch<any>(openVirtualMachinesContextMenu(event, virtualMachine));
     },
+    onAddLogin: (uuid: string) => {
+        dispatch<any>(openAddVirtualMachineLoginDialog(uuid));
+    },
+    onDeleteLogin: (uuid: string) => {
+        dispatch<any>(openRemoveVirtualMachineLoginDialog(uuid));
+    },
+    onLoginEdit: (uuid: string) => {
+        dispatch<any>(openEditVirtualMachineLoginDialog(uuid));
+    },
 });
 
 interface VirtualMachinesPanelDataProps {
     virtualMachines: ListResults<any>;
     logins: VirtualMachineLogins;
+    links: ListResults<any>;
     userUuid: string;
 }
 
 interface VirtualMachinesPanelActionProps {
     loadVirtualMachinesData: () => string;
     onOptionsMenuOpen: (event: React.MouseEvent<HTMLElement>, virtualMachine: VirtualMachinesResource) => void;
+    onAddLogin: (uuid: string) => void;
+    onDeleteLogin: (uuid: string) => void;
+    onLoginEdit: (uuid: string) => void;
 }
 
 type VirtualMachineProps = VirtualMachinesPanelActionProps & VirtualMachinesPanelDataProps & WithStyles<CssRules>;
@@ -85,24 +102,40 @@ const CardContentWithVirtualMachines = (props: VirtualMachineProps) =>
     </Grid>;
 
 const virtualMachinesTable = (props: VirtualMachineProps) =>
-    <Table>
+    <Table data-cy="vm-admin-table">
         <TableHead>
             <TableRow>
                 <TableCell>Uuid</TableCell>
                 <TableCell>Host name</TableCell>
                 <TableCell>Logins</TableCell>
                 <TableCell />
+                <TableCell />
             </TableRow>
         </TableHead>
         <TableBody>
-            {props.logins.items.length > 0 && props.virtualMachines.items.map((it, index) =>
+            {props.virtualMachines.items.map((machine, index) =>
                 <TableRow key={index}>
-                    <TableCell>{it.uuid}</TableCell>
-                    <TableCell>{it.hostname}</TableCell>
-                    <TableCell>["{props.logins.items.map(it => it.userUuid === props.userUuid ? it.username : '')}"]</TableCell>
+                    <TableCell><ResourceUuid uuid={machine.uuid} /></TableCell>
+                    <TableCell><VirtualMachineHostname uuid={machine.uuid} /></TableCell>
+                    <TableCell>
+                        <Grid container spacing={8} className={props.classes.chipsRoot}>
+                            {props.links.items.filter((link) => (link.headUuid === machine.uuid)).map((permission, i) => (
+                                <Grid item key={i}>
+                                    <Chip label={<VirtualMachineLogin linkUuid={permission.uuid} />} onDelete={event => props.onDeleteLogin(permission.uuid)} onClick={event => props.onLoginEdit(permission.uuid)} />
+                                </Grid>
+                            ))}
+                        </Grid>
+                    </TableCell>
+                    <TableCell>
+                        <Tooltip title="Add Login Permission" disableFocusListener>
+                            <IconButton onClick={event => props.onAddLogin(machine.uuid)} className={props.classes.moreOptionsButton}>
+                                <AddUserIcon />
+                            </IconButton>
+                        </Tooltip>
+                    </TableCell>
                     <TableCell className={props.classes.moreOptions}>
                         <Tooltip title="More options" disableFocusListener>
-                            <IconButton onClick={event => props.onOptionsMenuOpen(event, it)} className={props.classes.moreOptionsButton}>
+                            <IconButton onClick={event => props.onOptionsMenuOpen(event, machine)} className={props.classes.moreOptionsButton}>
                                 <MoreOptionsIcon />
                             </IconButton>
                         </Tooltip>
index d8725461efb1df2d92a2a52956da7c0524278af5..70f97daf029cff6def9f86089030eea0edf7fb82 100644 (file)
@@ -4,7 +4,7 @@
 
 import React from 'react';
 import { connect } from 'react-redux';
-import { Grid, Typography, Button, Card, CardContent, TableBody, TableCell, TableHead, TableRow, Table, Tooltip } from '@material-ui/core';
+import { Grid, Typography, Button, Card, CardContent, TableBody, TableCell, TableHead, TableRow, Table, Tooltip, Chip } from '@material-ui/core';
 import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
 import { ArvadosTheme } from 'common/custom-theme';
 import { compose, Dispatch } from 'redux';
@@ -15,8 +15,11 @@ import { HelpIcon } from 'components/icon/icon';
 import { SESSION_STORAGE } from "services/auth-service/auth-service";
 // import * as CopyToClipboard from 'react-copy-to-clipboard';
 import parse from "parse-duration";
+import { CopyIcon } from 'components/icon/icon';
+import CopyToClipboard from 'react-copy-to-clipboard';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 
-type CssRules = 'button' | 'codeSnippet' | 'link' | 'linkIcon' | 'rightAlign' | 'cardWithoutMachines' | 'icon';
+type CssRules = 'button' | 'codeSnippet' | 'link' | 'linkIcon' | 'rightAlign' | 'cardWithoutMachines' | 'icon' | 'chipsRoot' | 'copyIcon' | 'webshellButton';
 
 const EXTRA_TOKEN = "exraToken";
 
@@ -56,7 +59,22 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     icon: {
         textAlign: "right",
         marginTop: theme.spacing.unit
-    }
+    },
+    chipsRoot: {
+        margin: `0px -${theme.spacing.unit / 2}px`,
+    },
+    copyIcon: {
+        marginLeft: theme.spacing.unit,
+        color: theme.palette.grey["500"],
+        cursor: 'pointer',
+        display: 'inline',
+        '& svg': {
+            fontSize: '1rem'
+        }
+    },
+    webshellButton: {
+        textTransform: "initial",
+    },
 });
 
 const mapStateToProps = (state: RootState) => {
@@ -73,9 +91,16 @@ const mapStateToProps = (state: RootState) => {
     };
 };
 
-const mapDispatchToProps = (dispatch: Dispatch): Pick<VirtualMachinesPanelActionProps, 'loadVirtualMachinesData' | 'saveRequestedDate'> => ({
+const mapDispatchToProps = (dispatch: Dispatch): Pick<VirtualMachinesPanelActionProps, 'loadVirtualMachinesData' | 'saveRequestedDate' | 'onCopy'> => ({
     saveRequestedDate: () => dispatch<any>(saveRequestedDate()),
     loadVirtualMachinesData: () => dispatch<any>(loadVirtualMachinesUserData()),
+    onCopy: (message: string) => {
+        dispatch(snackbarActions.OPEN_SNACKBAR({
+            message,
+            hideDuration: 2000,
+            kind: SnackbarKind.SUCCESS
+        }));
+    },
 });
 
 interface VirtualMachinesPanelDataProps {
@@ -94,6 +119,7 @@ interface VirtualMachinesPanelDataProps {
 interface VirtualMachinesPanelActionProps {
     saveRequestedDate: () => void;
     loadVirtualMachinesData: () => string;
+    onCopy: (message: string) => void;
 }
 
 type VirtualMachineProps = VirtualMachinesPanelActionProps & VirtualMachinesPanelDataProps & WithStyles<CssRules>;
@@ -109,7 +135,7 @@ export const VirtualMachineUserPanel = compose(
             render() {
                 const { virtualMachines, links } = this.props;
                 return (
-                    <Grid container spacing={16}>
+                    <Grid container spacing={16} data-cy="vm-user-panel">
                         {virtualMachines.itemsAvailable === 0 && <CardContentWithoutVirtualMachines {...this.props} />}
                         {virtualMachines.itemsAvailable > 0 && links.itemsAvailable > 0 && <CardContentWithVirtualMachines {...this.props} />}
                         {<CardSSHSection {...this.props} />}
@@ -169,11 +195,12 @@ const virtualMachineSendRequest = (props: VirtualMachineProps) =>
     </span>;
 
 const virtualMachinesTable = (props: VirtualMachineProps) =>
-    <Table>
+    <Table data-cy="vm-user-table">
         <TableHead>
             <TableRow>
                 <TableCell>Host name</TableCell>
                 <TableCell>Login name</TableCell>
+                <TableCell>Groups</TableCell>
                 <TableCell>Command line</TableCell>
                 <TableCell>Web shell</TableCell>
             </TableRow>
@@ -181,23 +208,48 @@ const virtualMachinesTable = (props: VirtualMachineProps) =>
         <TableBody>
             {props.virtualMachines.items.map(it =>
                 props.links.items.map(lk => {
-                    if (lk.tailUuid === props.userUuid) {
+                    if (lk.tailUuid === props.userUuid && lk.headUuid === it.uuid) {
                         const username = lk.properties.username;
                         const command = `ssh ${username}@${it.hostname}${props.hostSuffix}`;
                         let tokenParam = "";
                         if (props.tokenLocation === SESSION_STORAGE || props.tokenLocation === EXTRA_TOKEN) {
                           tokenParam = `&token=${encodeURIComponent(props.token)}`;
                         }
+                        const loginHref = `/webshell/?host=${encodeURIComponent(props.webshellUrl + '/' + it.hostname)}&timeout=${props.idleTimeout}&login=${encodeURIComponent(username)}${tokenParam}`;
                         return <TableRow key={lk.uuid}>
                             <TableCell>{it.hostname}</TableCell>
                             <TableCell>{username}</TableCell>
+                            <TableCell>
+                                <Grid container spacing={8} className={props.classes.chipsRoot}>
+                                    {
+                                    (lk.properties.groups || []).map((group, i) => (
+                                        <Grid item key={i}>
+                                            <Chip label={group} />
+                                        </Grid>
+                                    ))
+                                    }
+                                </Grid>
+                            </TableCell>
                             <TableCell>
                                 {command}
+                                <Tooltip title="Copy to clipboard">
+                                    <span className={props.classes.copyIcon}>
+                                        <CopyToClipboard text={command || ""} onCopy={() => props.onCopy!("Copied")}>
+                                            <CopyIcon />
+                                        </CopyToClipboard>
+                                    </span>
+                                </Tooltip>
                             </TableCell>
                             <TableCell>
-                                <a href={`/webshell/?host=${encodeURIComponent(props.webshellUrl + '/' + it.hostname)}&timeout=${props.idleTimeout}&login=${encodeURIComponent(username)}${tokenParam}`} target="_blank" rel="noopener noreferrer" className={props.classes.link}>
-                                    Log in as {username}
-                                </a>
+                                <Button
+                                    className={props.classes.webshellButton}
+                                    variant="contained"
+                                    size="small"
+                                    href={loginHref}
+                                    target="_blank"
+                                    rel="noopener noreferrer">
+                                        Log in as {username}
+                                </Button>
                             </TableCell>
                         </TableRow>;
                     }
index e7bb048fb0edcb6a2f632c8b69e377d8e42a6d6e..49922202517c27f5dadb9a01364b6bb66377d441 100644 (file)
@@ -68,12 +68,14 @@ import { RemoveApiClientAuthorizationDialog } from 'views-components/api-client-
 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 { VirtualMachineAttributesDialog } from 'views-components/virtual-machines-dialog/attributes-dialog';
 import { RemoveVirtualMachineDialog } from 'views-components/virtual-machines-dialog/remove-dialog';
+import { RemoveVirtualMachineLoginDialog } from 'views-components/virtual-machines-dialog/remove-login-dialog';
+import { VirtualMachineAddLoginDialog } from 'views-components/virtual-machines-dialog/add-login-dialog';
 import { AttributesApiClientAuthorizationDialog } from 'views-components/api-client-authorizations-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';
@@ -251,6 +253,8 @@ export const WorkbenchPanel =
             <RemoveRepositoryDialog />
             <RemoveSshKeyDialog />
             <RemoveVirtualMachineDialog />
+            <RemoveVirtualMachineLoginDialog />
+            <VirtualMachineAddLoginDialog />
             <RenameFileDialog />
             <RepositoryAttributesDialog />
             <RepositoriesSampleGitDialog />