--- /dev/null
+// 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);
+ });
+});
}
)
+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)
const blob = new Blob(byteArrays, { type: contentType });
return blob
-}
\ No newline at end of file
+}
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: {
},
label: {
textTransform: "none"
- }
+ },
+ icon: {
+ fontSize: 20,
+ color: grey["600"]
+ },
});
export interface BreadcrumbsProps {
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} />}
className={isLastItem ? classes.currentItem : classes.item}
onClick={() => onClick(item)}
onContextMenu={event => onContextMenu(event, item)}>
+ <Icon className={classes.icon} />
<Typography
noWrap
color="inherit"
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';
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]);
+ }
}
}
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}
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,
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} />;
CAN_READ = 'can_read',
CAN_WRITE = 'can_write',
CAN_MANAGE = 'can_manage',
+ CAN_LOGIN = 'can_login',
}
} 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) {
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>(),
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) => {
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));
};
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());
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';
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());
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) => {
}, { 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>;
--- /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, 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(''))} />;
--- /dev/null
+// 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);
+ }
+
+ }
+);
--- /dev/null
+// 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);
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: {
paddingRight: 0
}
},
+ chipsRoot: {
+ margin: `0px -${theme.spacing.unit / 2}px`,
+ },
});
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>;
</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>
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';
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";
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) => {
};
};
-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 {
interface VirtualMachinesPanelActionProps {
saveRequestedDate: () => void;
loadVirtualMachinesData: () => string;
+ onCopy: (message: string) => void;
}
type VirtualMachineProps = VirtualMachinesPanelActionProps & VirtualMachinesPanelDataProps & WithStyles<CssRules>;
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} />}
</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>
<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>;
}
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';
<RemoveRepositoryDialog />
<RemoveSshKeyDialog />
<RemoveVirtualMachineDialog />
+ <RemoveVirtualMachineLoginDialog />
+ <VirtualMachineAddLoginDialog />
<RenameFileDialog />
<RepositoryAttributesDialog />
<RepositoriesSampleGitDialog />