Merge branch '16115-sharing-links'. Closes #16115
authorLucas Di Pentima <lucas.dipentima@curii.com>
Tue, 24 May 2022 15:26:37 +0000 (12:26 -0300)
committerLucas Di Pentima <lucas.dipentima@curii.com>
Tue, 24 May 2022 15:26:37 +0000 (12:26 -0300)
Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas.dipentima@curii.com>

15 files changed:
cypress/integration/collection.spec.js
cypress/support/commands.js
public/webshell/index.html
public/webshell/shell_in_a_box.js
src/components/autocomplete/autocomplete.tsx
src/models/collection.ts
src/models/user.test.ts
src/models/user.ts
src/store/resource-type-filters/resource-type-filters.test.ts
src/store/resource-type-filters/resource-type-filters.ts
src/store/run-process-panel/run-process-panel-actions.test.ts
src/store/run-process-panel/run-process-panel-actions.ts
src/store/virtual-machines/virtual-machines-actions.ts
src/views-components/sharing-dialog/participant-select.tsx
src/views-components/virtual-machines-dialog/add-login-dialog.tsx

index 568121f119f8c3b5aa500cab491aa4c2f55930d3..b62a34414fb58b57c4e6ac0d418f14dbce78553f 100644 (file)
@@ -251,7 +251,7 @@ describe('Collection panel tests', function () {
                             .should('contain', 'someKey: someValue')
                             .and('not.contain', 'anotherKey: anotherValue');
                         // Check that the file listing show both read & write operations
-                        cy.get('[data-cy=collection-files-panel]').within(() => {
+                        cy.waitForDom().get('[data-cy=collection-files-panel]').within(() => {
                             cy.get('[data-cy=collection-files-right-panel]', { timeout: 5000 })
                                 .should('contain', fileName);
                             if (isWritable) {
@@ -842,7 +842,7 @@ describe('Collection panel tests', function () {
 
                 cy.get('[data-cy=form-submit-btn]').click();
 
-                cy.get('.layout-pane-primary', { timeout: 12000 }).contains('Projects').click();
+                cy.waitForDom().get('.layout-pane-primary', { timeout: 12000 }).contains('Projects').click();
 
                 cy.get('main').contains(`Files extracted from: ${this.collection.name}`).should('exist');
             });
index 74f44f7a5ae83fc6ef7d5c2ec640f5cda72d4fc0..e98000fc71403462a2f67ffbb0008c804da5c4b0 100644 (file)
@@ -423,7 +423,10 @@ function b64toBlob(b64Data, contentType = '', sliceSize = 512) {
 // From https://github.com/cypress-io/cypress/issues/7306#issuecomment-1076451070=
 // This command requires the async package (https://www.npmjs.com/package/async)
 Cypress.Commands.add('waitForDom', () => {
-    cy.window().then(win => {
+    cy.window().then({
+        // Don't timeout before waitForDom finishes
+        timeout: 10000
+    }, win => {
       let timeElapsed = 0;
 
       cy.log("Waiting for DOM mutations to complete");
index 028664c488f74b7f5dd8256d63e9a81390bdeab8..aae70a97afab13a30a553eb2a6196d2a074f9484 100644 (file)
@@ -60,7 +60,7 @@
         if (currentTime - lastTime > idleTimeoutMs) {
           //logout
           sh.reset();
-          sh.sessionClosed();
+          sh.sessionClosed("Session timed out after " + timeout + " seconds.");
           document.body.onmousemove = undefined;
           document.body.onkeydown = undefined;
         } else {
              sh.keysPressed(token + "\n");
              sh.vt100('(sent authentication token)\n');
              token = null;
-             updateIdleTimer();
-             document.body.onmousemove = updateIdleTimer;
-             document.body.onkeydown = updateIdleTimer;
-             setTimeout(checkIdleTimer, 1000);
+             if (timeout > 0) {
+               updateIdleTimer();
+               document.body.onmousemove = updateIdleTimer;
+               document.body.onkeydown = updateIdleTimer;
+               setTimeout(checkIdleTimer, 1000);
+             }
           } else {
             setTimeout(trySendToken, 200);
           }
index c258b5d7316e40eda679006c9310c97a478144da..6b0a5b69b5d8b9fff6653eb62ba06ae5cf7b3113 100644 (file)
@@ -128,7 +128,7 @@ function ShellInABox(url, container) {
 };
 extend(ShellInABox, VT100);
 
-ShellInABox.prototype.sessionClosed = function() {
+ShellInABox.prototype.sessionClosed = function(msg) {
   try {
     this.connected    = false;
     if (this.session) {
@@ -136,7 +136,7 @@ ShellInABox.prototype.sessionClosed = function() {
       if (this.cursorX > 0) {
         this.vt100('\r\n');
       }
-      this.vt100('Session closed.');
+      this.vt100(msg || 'Session closed.');
       this.currentRequest.abort();
     }
     // Revealing the "reconnect" button is commented out until we hook
index cc1843367eb0b55fb64f36ea6051a398ae7e6f53..0044807b8a9ddb3e2abeb43a546dad5a2ada4d83 100644 (file)
@@ -175,7 +175,7 @@ export class Autocomplete<Value, Suggestion> extends React.Component<Autocomplet
                 <Chip
                     label={this.renderChipValue(item)}
                     key={index}
-                    onDelete={() => onDelete ? onDelete(item, index) : undefined} />
+                    onDelete={onDelete && !this.props.disabled ? (() =>  onDelete(item, index)) : undefined} />
         );
     }
 
index 9a5d2ee2d86d6e34ef71c77af3fab222b3538d9c..defaca769a2544de5b6108a493ff745aee5a76ce 100644 (file)
@@ -70,4 +70,5 @@ export enum CollectionType {
     GENERAL = 'nil',
     OUTPUT = 'output',
     LOG = 'log',
+    INTERMEDIATE = 'intermediate',
 }
index 4fab59d5db8db13008069d96b4252001f31b599c..30f82cf1821bad21bb5d144126edd2765b85b011 100644 (file)
@@ -34,7 +34,7 @@ describe('User', () => {
                     ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
                     prefs: {}, isAdmin: false, isActive: true
                 },
-                expect: 'Some User <<someuser@example.com>>'
+                expect: 'Some User <someuser@example.com>'
             },
             {
                 caseName: 'Missing first name',
index 2857bce6bf14d2f71ad5d5f21bc0dacb9af9e2ad..9b3d97d8486337befae509d35761d93cf1edf6be 100644 (file)
@@ -32,12 +32,16 @@ export const getUserFullname = (user: User) => {
         : "";
 };
 
-export const getUserDisplayName = (user: User, withEmail = false) => {
+export const getUserDisplayName = (user: User, withEmail = false, withUuid = false) => {
     const displayName = getUserFullname(user) || user.email || user.username || user.uuid;
+    let parts: string[] = [displayName];
     if (withEmail && user.email && displayName !== user.email) {
-        return `${displayName} <<${user.email}>>`;
+        parts.push(`<${user.email}>`);
     }
-    return displayName;
+    if (withUuid) {
+        parts.push(`(${user.uuid})`);
+    }
+    return parts.join(' ');
 };
 
 export interface UserResource extends Resource, User {
index 698515bde55f8fc945baa5ce3d9e40bddd22f623..a3684507b3bbd3a8db79dcd02fe97e7b9756bbb2 100644 (file)
@@ -47,6 +47,7 @@ describe("serializeResourceTypeFilters", () => {
             deselectNode(ObjectTypeFilter.PROCESS),
             deselectNode(CollectionTypeFilter.GENERAL_COLLECTION),
             deselectNode(CollectionTypeFilter.LOG_COLLECTION),
+            deselectNode(CollectionTypeFilter.INTERMEDIATE_COLLECTION),
         )();
 
         const serializedFilters = serializeResourceTypeFilters(filters);
@@ -54,6 +55,20 @@ describe("serializeResourceTypeFilters", () => {
             .toEqual(`["uuid","is_a",["${ResourceKind.PROJECT}","${ResourceKind.COLLECTION}"]],["collections.properties.type","in",["output"]]`);
     });
 
+    it("should serialize intermediate collections and projects", () => {
+        const filters = pipe(
+            () => getInitialResourceTypeFilters(),
+            deselectNode(ObjectTypeFilter.PROCESS),
+            deselectNode(CollectionTypeFilter.GENERAL_COLLECTION),
+            deselectNode(CollectionTypeFilter.LOG_COLLECTION),
+            deselectNode(CollectionTypeFilter.OUTPUT_COLLECTION),
+        )();
+
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.PROJECT}","${ResourceKind.COLLECTION}"]],["collections.properties.type","in",["intermediate"]]`);
+    });
+
     it("should serialize general and log collections", () => {
         const filters = pipe(
             () => getInitialResourceTypeFilters(),
index a39807d58238196a44a9c93e347927778ad4bf36..0539cefecc93dc74df0912b8450d81613c9e566b 100644 (file)
@@ -38,6 +38,7 @@ export enum CollectionTypeFilter {
     GENERAL_COLLECTION = 'General',
     OUTPUT_COLLECTION = 'Output',
     LOG_COLLECTION = 'Log',
+    INTERMEDIATE_COLLECTION = 'Intermediate',
 }
 
 export enum ProcessTypeFilter {
@@ -82,6 +83,7 @@ export const getInitialResourceTypeFilters = pipe(
         initFilter(ObjectTypeFilter.COLLECTION),
         initFilter(CollectionTypeFilter.GENERAL_COLLECTION, ObjectTypeFilter.COLLECTION),
         initFilter(CollectionTypeFilter.OUTPUT_COLLECTION, ObjectTypeFilter.COLLECTION),
+        initFilter(CollectionTypeFilter.INTERMEDIATE_COLLECTION, ObjectTypeFilter.COLLECTION),
         initFilter(CollectionTypeFilter.LOG_COLLECTION, ObjectTypeFilter.COLLECTION),
     ),
 );
@@ -111,6 +113,7 @@ export const getTrashPanelTypeFilters = pipe(
     initFilter(ObjectTypeFilter.COLLECTION),
     initFilter(CollectionTypeFilter.GENERAL_COLLECTION, ObjectTypeFilter.COLLECTION),
     initFilter(CollectionTypeFilter.OUTPUT_COLLECTION, ObjectTypeFilter.COLLECTION),
+    initFilter(CollectionTypeFilter.INTERMEDIATE_COLLECTION, ObjectTypeFilter.COLLECTION),
     initFilter(CollectionTypeFilter.LOG_COLLECTION, ObjectTypeFilter.COLLECTION),
 );
 
@@ -167,6 +170,10 @@ const collectionTypeToPropertyValue = (type: CollectionTypeFilter) => {
             return CollectionType.OUTPUT;
         case CollectionTypeFilter.LOG_COLLECTION:
             return CollectionType.LOG;
+        case CollectionTypeFilter.INTERMEDIATE_COLLECTION:
+            return CollectionType.INTERMEDIATE;
+        default:
+            return CollectionType.GENERAL;
     }
 };
 
@@ -273,7 +280,7 @@ export const serializeSimpleObjectTypeFilters = (filters: Tree<DataTableFilterIt
         .map(objectTypeToResourceKind);
 };
 
-export const buildProcessStatusFilters = ( fb: FilterBuilder, activeStatusFilter: string, resourcePrefix?: string ): FilterBuilder => {
+export const buildProcessStatusFilters = (fb: FilterBuilder, activeStatusFilter: string, resourcePrefix?: string): FilterBuilder => {
     switch (activeStatusFilter) {
         case ProcessStatusFilter.ONHOLD: {
             fb.addDistinct('state', ContainerRequestState.FINAL, resourcePrefix);
index 7edc4cffd49b5a5cf806ddf54d8ea9a4662e6430..745be88fb5f66a1c6d0c9d1a3f13f047b841b50e 100644 (file)
@@ -131,6 +131,7 @@ describe("run-process-panel-actions", () => {
                 },
                 schedulingParameters: { max_run_time: undefined },
                 state: "Committed",
+                useExisting: false
             });
 
             // and
index b421f0723a0f34a67a6c835e5764ed0f34d0882a..adb5ade7c2bb87eb0dce96ae5769eacb10632af5 100644 (file)
@@ -177,7 +177,8 @@ export const runProcess = async (dispatch: Dispatch<any>, getState: () => RootSt
             properties: {
                 workflowUuid: selectedWorkflow.uuid,
                 workflowName: selectedWorkflow.name
-            }
+            },
+            useExisting: false
         };
         const newProcess = await services.containerRequestService.create(newProcessData);
         dispatch(navigateTo(newProcess.uuid));
index 7034b4a570f1ad1c135f6fc00bfdc6868b5190b0..e4b17ea0b807d4bfbeb544e9f1a811aedbec290b 100644 (file)
@@ -18,7 +18,7 @@ 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";
+import { getUserDisplayName, UserResource } from "models/user";
 
 export const virtualMachinesActions = unionize({
     SET_REQUESTED_DATE: ofType<string>(),
@@ -40,6 +40,7 @@ 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 VIRTUAL_MACHINE_ADD_LOGIN_EXCLUDE = 'excludedPerticipants';
 
 export const openUserVirtualMachines = () =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
@@ -115,8 +116,22 @@ export const loadVirtualMachinesUserData = () =>
 
 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: {}} ));
+        // Get login permissions of vm
+        const virtualMachines = await services.virtualMachineService.list();
+        dispatch(updateResources(virtualMachines.items));
+        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(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: {excludedParticipants: logins.items.map(it => it.tailUuid)}} ));
     }
 
 export const openEditVirtualMachineLoginDialog = (permissionUuid: string) =>
@@ -125,7 +140,7 @@ export const openEditVirtualMachineLoginDialog = (permissionUuid: string) =>
         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_USER_FIELD]: {name: getUserDisplayName(user, true, 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}} ));
@@ -141,10 +156,15 @@ export interface AddLoginFormData {
 
 export const addUpdateVirtualMachineLogin = ({uuid, vmUuid, user, groups}: AddLoginFormData) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        let userResource: UserResource | undefined = undefined;
         try {
             // Get user
-            const userResource = await services.userService.get(user.uuid);
-
+            userResource = await services.userService.get(user.uuid, false);
+        } catch (e) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Failed to get user details.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                return;
+        }
+        try {
             if (uuid) {
                 const permission = await services.permissionService.update(uuid, {
                     tailUuid: userResource.uuid,
index 402faa7f9414d935c64ccc3ec677c98f5db8a00f..a826fcd59aaa9f6be62f0e5979861c679031474d 100644 (file)
@@ -24,9 +24,12 @@ type ParticipantResource = GroupResource | UserResource;
 
 interface ParticipantSelectProps {
     items: Participant[];
+    excludedParticipants?: string[];
     label?: string;
     autofocus?: boolean;
     onlyPeople?: boolean;
+    onlyActive?: boolean;
+    disabled?: boolean;
 
     onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
     onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
@@ -43,9 +46,9 @@ interface ParticipantSelectState {
 const getDisplayName = (item: GroupResource | UserResource) => {
     switch (item.kind) {
         case ResourceKind.USER:
-            return getUserDisplayName(item, true);
+            return getUserDisplayName(item, true, true);
         case ResourceKind.GROUP:
-            return item.name;
+            return item.name + `(${`(${(item as Resource).uuid})`})`;
         default:
             return (item as Resource).uuid;
     }
@@ -71,11 +74,12 @@ export const ParticipantSelect = connect()(
                     onChange={this.handleChange}
                     onCreate={this.handleCreate}
                     onSelect={this.handleSelect}
-                    onDelete={this.handleDelete}
+                    onDelete={this.props.onDelete && !this.props.disabled ? this.handleDelete : undefined}
                     onFocus={this.props.onFocus}
                     onBlur={this.props.onBlur}
                     renderChipValue={this.renderChipValue}
-                    renderSuggestion={this.renderSuggestion} />
+                    renderSuggestion={this.renderSuggestion}
+                    disabled={this.props.disabled}/>
             );
         }
 
@@ -130,11 +134,14 @@ export const ParticipantSelect = connect()(
 
             const filterUsers = new FilterBuilder()
                 .addILike('any', value)
+                .addEqual('is_active', this.props.onlyActive || undefined)
+                .addNotIn('uuid', this.props.excludedParticipants)
                 .getFilters();
             const userItems: ListResults<any> = await userService.list({ filters: filterUsers, limit, count: "none" });
 
             const filterGroups = new FilterBuilder()
                 .addNotIn('group_class', [GroupClass.PROJECT, GroupClass.FILTER])
+                .addNotIn('uuid', this.props.excludedParticipants)
                 .addILike('name', value)
                 .getFilters();
 
index bfc047164a4bc8d00bdb15f82c8db19b422c6521..1654452bf493b656b031c648fa1faf96a1275e1a 100644 (file)
@@ -4,7 +4,7 @@
 
 import React from 'react';
 import { compose } from "redux";
-import { reduxForm, InjectedFormProps, WrappedFieldProps, Field } from 'redux-form';
+import { reduxForm, InjectedFormProps, Field, GenericField } 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';
@@ -31,26 +31,41 @@ export const VirtualMachineAddLoginDialog = compose(
 
 type CreateGroupDialogComponentProps = WithDialogProps<{updating: boolean}> & InjectedFormProps<AddLoginFormData>;
 
-const AddLoginFormFields = () =>
-    <>
-        <UserField />
+const AddLoginFormFields = (props) => {
+    return <>
+        <ParticipantField
+            name={VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD}
+            component={props.data.updating ? ReadOnlyUserSelect : UserSelect}
+            excludedParticipants={props.data.excludedParticipants}
+        />
         <GroupArrayInput
             name={VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD}
             input={{id:"Add groups to VM login (eg: docker, sudo)", disabled:false}}
             required={false}
         />
     </>;
+}
+
+interface UserFieldProps {
+    excludedParticipants: string[];
+}
 
-const UserField = () =>
-    <Field
-        name={VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD}
-        component={UserSelect}
-        />;
+const ParticipantField = Field as new () => GenericField<UserFieldProps>;
 
-const UserSelect = ({ input, meta }: WrappedFieldProps) =>
+const UserSelect = (props) =>
     <ParticipantSelect
         onlyPeople
+        onlyActive
         label='Search for user to grant login permission'
-        items={input.value ? [input.value] : []}
-        onSelect={input.onChange}
-        onDelete={() => (input.onChange(''))} />;
+        items={props.input.value ? [props.input.value] : []}
+        excludedParticipants={props.excludedParticipants}
+        onSelect={props.input.onChange}
+        onDelete={() => (props.input.onChange(''))} />;
+
+const ReadOnlyUserSelect = (props) =>
+        <ParticipantSelect
+            onlyPeople
+            onlyActive
+            label='User'
+            items={props.input.value ? [props.input.value] : []}
+            disabled={true} />;