Merge branch '17337-files-not-visible-in-arvados'
authorDaniel Kutyła <daniel.kutyla@contractors.roche.com>
Thu, 1 Apr 2021 20:49:33 +0000 (22:49 +0200)
committerDaniel Kutyła <daniel.kutyla@contractors.roche.com>
Thu, 1 Apr 2021 20:49:51 +0000 (22:49 +0200)
closes #17337

Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła <daniel.kutyla@contractors.roche.com>

54 files changed:
Makefile
cypress/integration/favorites.spec.js
cypress/integration/sharing.spec.js [new file with mode: 0644]
cypress/integration/side-panel.spec.js
cypress/support/commands.js
src/common/labels.ts
src/components/code-snippet/code-snippet.tsx
src/components/details-attribute/details-attribute.tsx
src/components/icon/icon.tsx
src/components/tree/tree.tsx
src/components/tree/virtual-tree.tsx
src/index.tsx
src/models/api-client-authorization.ts
src/models/group.ts
src/models/project.ts
src/services/common-service/common-service.ts
src/services/project-service/project-service.test.ts
src/services/project-service/project-service.ts
src/store/auth/auth-action.test.ts
src/store/auth/auth-action.ts
src/store/auth/auth-reducer.ts
src/store/collections/collection-info-actions.ts
src/store/context-menu/context-menu-actions.test.ts
src/store/context-menu/context-menu-actions.ts
src/store/current-token-dialog/current-token-dialog-actions.tsx [deleted file]
src/store/groups-panel/groups-panel-middleware-service.ts
src/store/resource-type-filters/resource-type-filters.test.ts
src/store/resource-type-filters/resource-type-filters.ts
src/store/side-panel-tree/side-panel-tree-actions.ts
src/store/token-dialog/token-dialog-actions.tsx [new file with mode: 0644]
src/store/tree-picker/tree-picker-actions.ts
src/store/users/users-actions.ts
src/views-components/auto-logout/auto-logout.test.tsx
src/views-components/auto-logout/auto-logout.tsx
src/views-components/context-menu/action-sets/process-resource-action-set.ts
src/views-components/context-menu/action-sets/project-action-set.test.ts
src/views-components/context-menu/action-sets/project-action-set.ts
src/views-components/context-menu/action-sets/project-admin-action-set.ts
src/views-components/context-menu/context-menu.tsx
src/views-components/current-token-dialog/current-token-dialog.test.tsx [deleted file]
src/views-components/current-token-dialog/current-token-dialog.tsx [deleted file]
src/views-components/data-explorer/renderers.tsx
src/views-components/details-panel/project-details.tsx
src/views-components/main-app-bar/account-menu.tsx
src/views-components/sharing-dialog/participant-select.tsx
src/views-components/side-panel-button/side-panel-button.tsx
src/views-components/side-panel-tree/side-panel-tree.tsx
src/views-components/token-dialog/token-dialog.test.tsx [new file with mode: 0644]
src/views-components/token-dialog/token-dialog.tsx [new file with mode: 0644]
src/views/project-panel/project-panel.tsx
src/views/run-process-panel/inputs/file-input.tsx
src/views/run-process-panel/run-process-inputs-form.tsx
src/views/workbench/workbench.tsx
tools/run-integration-tests.sh

index 6da3ed1062b038ece0a901794944f93cb053a6ba..de88cd3548cbc04be68900fa0b5ce8fecbb00c3c 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -19,6 +19,8 @@ ITERATION?=1
 
 TARGETS?="centos7 debian8 debian10 ubuntu1404 ubuntu1604 ubuntu1804 ubuntu2004"
 
+ARVADOS_DIRECTORY?=unset
+
 DESCRIPTION=Arvados Workbench2 - Arvados is a free and open source platform for big data science.
 MAINTAINER=Arvados Package Maintainers <packaging@arvados.org>
 
@@ -65,7 +67,7 @@ unit-tests: yarn-install
 
 integration-tests: yarn-install
        yarn run cypress install
-       $(WORKSPACE)/tools/run-integration-tests.sh
+       $(WORKSPACE)/tools/run-integration-tests.sh -a $(ARVADOS_DIRECTORY)
 
 integration-tests-in-docker: workbench2-build-image
        docker run -ti -v$(PWD):$(PWD) -w$(PWD) workbench2-build make integration-tests
index eca35bc4e0a0be6b148225b5f02301e761a9cd15..2afdf92e5c4c11229a16009206e01027fd5e1490 100644 (file)
@@ -26,16 +26,11 @@ describe('Favorites tests', function () {
     beforeEach(function () {
         cy.clearCookies()
         cy.clearLocalStorage()
-    })
-
-    it('checks that Public favorites does not appear under shared with me', function () {
-        cy.loginAs(adminUser);
-        cy.contains('Shared with me').click();
-        cy.get('main').contains('Public favorites').should('not.exist');
     });
 
     it('creates and removes a public favorite', function () {
         cy.loginAs(adminUser);
+
         cy.createGroup(adminUser.token, {
             name: `my-favorite-project`,
             group_class: 'project',
@@ -51,46 +46,79 @@ describe('Favorites tests', function () {
         });
     });
 
-    it('can copy collection to favorites', () => {
+    it('can copy selected into the collection', () => {
         cy.loginAs(adminUser);
 
-        cy.createGroup(adminUser.token, {
-            name: `my-shared-writable-project ${Math.floor(Math.random() * 999999)}`,
-            group_class: 'project',
-        }).as('mySharedWritableProject').then(function (mySharedWritableProject) {
-            cy.contains('Refresh').click();
-            cy.get('main').contains(mySharedWritableProject.name).rightclick();
-            cy.get('[data-cy=context-menu]').within(() => {
-                cy.contains('Share').click();
+        cy.createCollection(adminUser.token, {
+            name: `Test source collection ${Math.floor(Math.random() * 999999)}`,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+        })
+            .as('testSourceCollection').then(function (testSourceCollection) {
+                cy.shareWith(adminUser.token, activeUser.user.uuid, testSourceCollection.uuid, 'can_read');
             });
-            cy.get('[id="select-permissions"]').as('selectPermissions');
-            cy.get('@selectPermissions').click();
-            cy.contains('Write').click();
-            cy.get('.sharing-dialog').as('sharingDialog');
-            cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
-            cy.get('[role=tooltip]').click();
-            cy.get('@sharingDialog').contains('Save').click();
-        });
 
-        cy.createGroup(adminUser.token, {
-            name: `my-shared-readonly-project ${Math.floor(Math.random() * 999999)}`,
-            group_class: 'project',
-        }).as('mySharedReadonlyProject').then(function (mySharedReadonlyProject) {
-            cy.contains('Refresh').click();
-            cy.get('main').contains(mySharedReadonlyProject.name).rightclick();
-            cy.get('[data-cy=context-menu]').within(() => {
-                cy.contains('Share').click();
+        cy.createCollection(adminUser.token, {
+            name: `Test target collection ${Math.floor(Math.random() * 999999)}`,
+        })
+            .as('testTargetCollection').then(function (testTargetCollection) {
+                cy.shareWith(adminUser.token, activeUser.user.uuid, testTargetCollection.uuid, 'can_write');
             });
-            cy.get('.sharing-dialog').as('sharingDialog');
-            cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
-            cy.get('[role=tooltip]').click();
-            cy.get('@sharingDialog').contains('Save').click();
-        });
 
-        cy.createGroup(activeUser.token, {
-            name: `my-project ${Math.floor(Math.random() * 999999)}`,
-            group_class: 'project',
-        }).as('myProject1');
+        cy.getAll('@testSourceCollection', '@testTargetCollection')
+            .then(function ([testSourceCollection, testTargetCollection]) {
+                cy.loginAs(activeUser);
+
+                cy.get('.layout-pane-primary')
+                    .contains('Projects').click();
+
+                cy.addToFavorites(activeUser.token, activeUser.user.uuid, testTargetCollection.uuid);
+
+                cy.get('main').contains(testSourceCollection.name).click();
+                cy.get('[data-cy=collection-files-panel]').contains('bar');
+                cy.get('[data-cy=collection-files-panel]').find('input[type=checkbox]').click({ force: true });
+                cy.get('[data-cy=collection-files-panel-options-btn]').click();
+                cy.get('[data-cy=context-menu]')
+                    .contains('Copy selected into the collection').click();
+
+                cy.get('[data-cy=projects-tree-favourites-tree-picker]')
+                    .find('i')
+                    .click();
+
+                cy.get('[data-cy=projects-tree-favourites-tree-picker]')
+                    .contains(testTargetCollection.name)
+                    .click();
+
+                cy.get('[data-cy=form-submit-btn]').click();
+
+                cy.get('.layout-pane-primary')
+                    .contains('Projects').click();
+
+                cy.get('main').contains(testTargetCollection.name).click();
+
+                cy.get('[data-cy=collection-files-panel]').contains('bar');
+            });
+    });
+
+    it('can copy collection to favorites', () => {
+        cy.createProject({
+            owningUser: adminUser,
+            targetUser: activeUser,
+            projectName: 'mySharedWritableProject',
+            canWrite: true,
+            addToFavorites: true
+        });
+        cy.createProject({
+            owningUser: adminUser,
+            targetUser: activeUser,
+            projectName: 'mySharedReadonlyProject',
+            canWrite: false,
+            addToFavorites: true
+        });
+        cy.createProject({
+            owningUser: activeUser,
+            projectName: 'myProject1',
+            addToFavorites: true
+        });
 
         cy.createCollection(adminUser.token, {
             name: `Test collection ${Math.floor(Math.random() * 999999)}`,
@@ -103,25 +131,8 @@ describe('Favorites tests', function () {
             .then(function ([mySharedWritableProject, mySharedReadonlyProject, myProject1, testCollection]) {
                 cy.loginAs(activeUser);
 
-                cy.contains('Shared with me').click();
-
-                cy.get('main').contains(mySharedWritableProject.name).rightclick();
-                cy.get('[data-cy=context-menu]').within(() => {
-                    cy.contains('Add to favorites').click();
-                });
-
-                cy.get('main').contains(mySharedReadonlyProject.name).rightclick();
-                cy.get('[data-cy=context-menu]').within(() => {
-                    cy.contains('Add to favorites').click();
-                });
-
                 cy.doSearch(`${activeUser.user.uuid}`);
 
-                cy.get('main').contains(myProject1.name).rightclick();
-                cy.get('[data-cy=context-menu]').within(() => {
-                    cy.contains('Add to favorites').click();
-                });
-
                 cy.contains(testCollection.name).rightclick();
                 cy.get('[data-cy=context-menu]').within(() => {
                     cy.contains('Move to').click();
@@ -142,81 +153,58 @@ describe('Favorites tests', function () {
             });
     });
 
-    it('can copy selected into the collection', () => {
-        cy.loginAs(adminUser);
-
-        cy.createCollection(adminUser.token, {
-            name: `Test source collection ${Math.floor(Math.random() * 999999)}`,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
-        })
-            .as('testSourceCollection').then(function (testSourceCollection) {
-                cy.contains('Refresh').click();
-                cy.get('main').contains(testSourceCollection.name).rightclick();
-                cy.get('[data-cy=context-menu]').within(() => {
-                    cy.contains('Share').click();
-                });
-                cy.get('[id="select-permissions"]').as('selectPermissions');
-                cy.get('@selectPermissions').click();
-                cy.contains('Write').click();
-                cy.get('.sharing-dialog').as('sharingDialog');
-                cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
-                cy.get('[role=tooltip]').click();
-                cy.get('@sharingDialog').contains('Save').click();
-            });
-
-        cy.createCollection(adminUser.token, {
-            name: `Test target collection ${Math.floor(Math.random() * 999999)}`,
-        })
-            .as('testTargetCollection').then(function (testTargetCollection) {
-                cy.contains('Refresh').click();
-                cy.get('main').contains(testTargetCollection.name).rightclick();
-                cy.get('[data-cy=context-menu]').within(() => {
-                    cy.contains('Share').click();
-                });
-                cy.get('[id="select-permissions"]').as('selectPermissions');
-                cy.get('@selectPermissions').click();
-                cy.contains('Write').click();
-                cy.get('.sharing-dialog').as('sharingDialog');
-                cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
-                cy.get('[role=tooltip]').click();
-                cy.get('@sharingDialog').contains('Save').click();
-            });
+    it('can view favourites in workflow', () => {
+        cy.createProject({
+            owningUser: adminUser,
+            targetUser: activeUser,
+            projectName: 'mySharedWritableProject',
+            canWrite: true,
+            addToFavorites: true
+        });
+        cy.createProject({
+            owningUser: adminUser,
+            targetUser: activeUser,
+            projectName: 'mySharedReadonlyProject',
+            canWrite: false,
+            addToFavorites: true
+        });
+        cy.createProject({
+            owningUser: activeUser,
+            projectName: 'myProject1',
+            addToFavorites: true
+        });
 
-        cy.getAll('@testSourceCollection', '@testTargetCollection')
-            .then(function ([testSourceCollection, testTargetCollection]) {
+        cy.getAll('@mySharedWritableProject', '@mySharedReadonlyProject', '@myProject1')
+            .then(function ([mySharedWritableProject, mySharedReadonlyProject, myProject1]) {
                 cy.loginAs(activeUser);
 
-                cy.get('.layout-pane-primary')
-                    .contains('Projects').click();
-
-                cy.get('main').contains(testTargetCollection.name).rightclick();
-                cy.get('[data-cy=context-menu]').within(() => {
-                    cy.contains('Add to favorites').click();
-                });
-
-                cy.get('main').contains(testSourceCollection.name).click();
-                cy.get('[data-cy=collection-files-panel]').contains('bar');
-                cy.get('[data-cy=collection-files-panel]').find('input[type=checkbox]').click({ force: true });
-                cy.get('[data-cy=collection-files-panel-options-btn]').click();
-                cy.get('[data-cy=context-menu]')
-                    .contains('Copy selected into the collection').click();
+                cy.createWorkflow(adminUser.token, {
+                    name: `TestWorkflow${Math.floor(Math.random() * 999999)}.cwl`,
+                    definition: "{\n    \"$graph\": [\n        {\n            \"class\": \"Workflow\",\n            \"doc\": \"Reverse the lines in a document, then sort those lines.\",\n            \"hints\": [\n                {\n                    \"acrContainerImage\": \"99b0201f4cade456b4c9d343769a3b70+261\",\n                    \"class\": \"http://arvados.org/cwl#WorkflowRunnerResources\"\n                }\n            ],\n            \"id\": \"#main\",\n            \"inputs\": [\n                {\n                    \"default\": null,\n                    \"doc\": \"The input file to be processed.\",\n                    \"id\": \"#main/input\",\n                    \"type\": \"File\"\n                },\n                {\n                    \"default\": true,\n                    \"doc\": \"If true, reverse (decending) sort\",\n                    \"id\": \"#main/reverse_sort\",\n                    \"type\": \"boolean\"\n                }\n            ],\n            \"outputs\": [\n                {\n                    \"doc\": \"The output with the lines reversed and sorted.\",\n                    \"id\": \"#main/output\",\n                    \"outputSource\": \"#main/sorted/output\",\n                    \"type\": \"File\"\n                }\n            ],\n            \"steps\": [\n                {\n                    \"id\": \"#main/rev\",\n                    \"in\": [\n                        {\n                            \"id\": \"#main/rev/input\",\n                            \"source\": \"#main/input\"\n                        }\n                    ],\n                    \"out\": [\n                        \"#main/rev/output\"\n                    ],\n                    \"run\": \"#revtool.cwl\"\n                },\n                {\n                    \"id\": \"#main/sorted\",\n                    \"in\": [\n                        {\n                            \"id\": \"#main/sorted/input\",\n                            \"source\": \"#main/rev/output\"\n                        },\n                        {\n                            \"id\": \"#main/sorted/reverse\",\n                            \"source\": \"#main/reverse_sort\"\n                        }\n                    ],\n                    \"out\": [\n                        \"#main/sorted/output\"\n                    ],\n                    \"run\": \"#sorttool.cwl\"\n                }\n            ]\n        },\n        {\n            \"baseCommand\": \"rev\",\n            \"class\": \"CommandLineTool\",\n            \"doc\": \"Reverse each line using the `rev` command\",\n            \"hints\": [\n                {\n                    \"class\": \"ResourceRequirement\",\n                    \"ramMin\": 8\n                }\n            ],\n            \"id\": \"#revtool.cwl\",\n            \"inputs\": [\n                {\n                    \"id\": \"#revtool.cwl/input\",\n                    \"inputBinding\": {},\n                    \"type\": \"File\"\n                }\n            ],\n            \"outputs\": [\n                {\n                    \"id\": \"#revtool.cwl/output\",\n                    \"outputBinding\": {\n                        \"glob\": \"output.txt\"\n                    },\n                    \"type\": \"File\"\n                }\n            ],\n            \"stdout\": \"output.txt\"\n        },\n        {\n            \"baseCommand\": \"sort\",\n            \"class\": \"CommandLineTool\",\n            \"doc\": \"Sort lines using the `sort` command\",\n            \"hints\": [\n                {\n                    \"class\": \"ResourceRequirement\",\n                    \"ramMin\": 8\n                }\n            ],\n            \"id\": \"#sorttool.cwl\",\n            \"inputs\": [\n                {\n                    \"id\": \"#sorttool.cwl/reverse\",\n                    \"inputBinding\": {\n                        \"position\": 1,\n                        \"prefix\": \"-r\"\n                    },\n                    \"type\": \"boolean\"\n                },\n                {\n                    \"id\": \"#sorttool.cwl/input\",\n                    \"inputBinding\": {\n                        \"position\": 2\n                    },\n                    \"type\": \"File\"\n                }\n            ],\n            \"outputs\": [\n                {\n                    \"id\": \"#sorttool.cwl/output\",\n                    \"outputBinding\": {\n                        \"glob\": \"output.txt\"\n                    },\n                    \"type\": \"File\"\n                }\n            ],\n            \"stdout\": \"output.txt\"\n        }\n    ],\n    \"cwlVersion\": \"v1.0\"\n}",
+                    owner_uuid: myProject1.uuid,
+                })
+                    .as('testWorkflow');
 
-                cy.get('[data-cy=projects-tree-favourites-tree-picker]')
-                    .find('i')
-                    .click();
+                cy.contains('Shared with me').click();
 
-                cy.get('[data-cy=projects-tree-favourites-tree-picker]')
-                    .contains(testTargetCollection.name)
-                    .click();
+                cy.doSearch(`${activeUser.user.uuid}`);
 
-                cy.get('[data-cy=form-submit-btn]').click();
+                cy.get('main').contains(myProject1.name).click();
 
-                cy.get('.layout-pane-primary')
-                    .contains('Projects').click();
+                cy.get('[data-cy=side-panel-button]').click();
 
-                cy.get('main').contains(testTargetCollection.name).click();
+                cy.get('#aside-menu-list').contains('Run a process').click();
 
-                cy.get('[data-cy=collection-files-panel]').contains('bar');
+                cy.get('@testWorkflow')
+                    .then((testWorkflow) => {
+                        cy.get('main').contains(testWorkflow.name).click();
+                        cy.get('[data-cy=run-process-next-button]').click();
+                        cy.get('[readonly]').click();
+                        cy.get('[data-cy=choose-a-file-dialog]').as('chooseFileDialog');
+                        cy.get('[data-cy=projects-tree-favourites-tree-picker]').contains('Favorites').closest('ul').find('i').click();
+                        cy.get('@chooseFileDialog').find(`[data-id=${mySharedWritableProject.uuid}]`);
+                        cy.get('@chooseFileDialog').find(`[data-id=${mySharedReadonlyProject.uuid}]`);
+                    });
             });
     });
 });
\ No newline at end of file
diff --git a/cypress/integration/sharing.spec.js b/cypress/integration/sharing.spec.js
new file mode 100644 (file)
index 0000000..5786c41
--- /dev/null
@@ -0,0 +1,81 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Sharing tests', function () {
+    let activeUser;
+    let adminUser;
+
+    before(function () {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser('admin', 'Admin', 'User', true, true)
+            .as('adminUser').then(function () {
+                adminUser = this.adminUser;
+            }
+            );
+        cy.getUser('collectionuser1', 'Collection', 'User', false, true)
+            .as('activeUser').then(function () {
+                activeUser = this.activeUser;
+            }
+            );
+    })
+
+    beforeEach(function () {
+        cy.clearCookies()
+        cy.clearLocalStorage()
+    });
+
+    it('can share projects to other users', () => {
+        cy.loginAs(adminUser);
+
+        cy.createGroup(adminUser.token, {
+            name: `my-shared-writable-project ${Math.floor(Math.random() * 999999)}`,
+            group_class: 'project',
+        }).as('mySharedWritableProject').then(function (mySharedWritableProject) {
+            cy.contains('Refresh').click();
+            cy.get('main').contains(mySharedWritableProject.name).rightclick();
+            cy.get('[data-cy=context-menu]').within(() => {
+                cy.contains('Share').click();
+            });
+            cy.get('[id="select-permissions"]').as('selectPermissions');
+            cy.get('@selectPermissions').click();
+            cy.contains('Write').click();
+            cy.get('.sharing-dialog').as('sharingDialog');
+            cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
+            cy.get('[role=tooltip]').click();
+            cy.get('@sharingDialog').contains('Save').click();
+        });
+
+        cy.createGroup(adminUser.token, {
+            name: `my-shared-readonly-project ${Math.floor(Math.random() * 999999)}`,
+            group_class: 'project',
+        }).as('mySharedReadonlyProject').then(function (mySharedReadonlyProject) {
+            cy.contains('Refresh').click();
+            cy.get('main').contains(mySharedReadonlyProject.name).rightclick();
+            cy.get('[data-cy=context-menu]').within(() => {
+                cy.contains('Share').click();
+            });
+            cy.get('.sharing-dialog').as('sharingDialog');
+            cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
+            cy.get('[role=tooltip]').click();
+            cy.get('@sharingDialog').contains('Save').click();
+        });
+
+        cy.getAll('@mySharedWritableProject', '@mySharedReadonlyProject')
+            .then(function ([mySharedWritableProject, mySharedReadonlyProject]) {
+                cy.loginAs(activeUser);
+
+                cy.contains('Shared with me').click();
+
+                cy.get('main').contains(mySharedWritableProject.name).rightclick();
+                cy.get('[data-cy=context-menu]').should('contain', 'Move to trash');
+                cy.get('[data-cy=context-menu]').contains('Move to trash').click();
+
+                cy.get('main').contains(mySharedReadonlyProject.name).rightclick();
+                cy.get('[data-cy=context-menu]').should('not.contain', 'Move to trash');
+            });
+    });
+});
\ No newline at end of file
index 309037ec58a9536112f62133cb2b46fa84936f69..8100a0e48135be15563a2a9e5f6d2eef6b26633b 100644 (file)
@@ -36,7 +36,7 @@ describe('Side panel tests', function() {
             .and('not.be.disabled');
     })
 
-    it('disables or enables the +NEW side panel button on depending on project permissions', function() {
+    it('disables or enables the +NEW side panel button depending on project permissions', function() {
         cy.loginAs(activeUser);
         [true, false].map(function(isWritable) {
             cy.createGroup(adminUser.token, {
@@ -75,4 +75,22 @@ describe('Side panel tests', function() {
                 .and('be.disabled');
         })
     })
-})
\ No newline at end of file
+
+    it('disables the +NEW side panel button when viewing filter group', function() {
+        cy.loginAs(adminUser);
+        cy.createGroup(adminUser.token, {
+            name: `my-favorite-filter-group`,
+            group_class: 'filter',
+            properties: {filters: []},
+        }).as('myFavoriteFilterGroup').then(function (myFavoriteFilterGroup) {
+            cy.contains('Refresh').click();
+            cy.doSearch(`${myFavoriteFilterGroup.uuid}`);
+            cy.get('[data-cy=breadcrumb-last]').should('contain', 'my-favorite-filter-group');
+
+            cy.get('[data-cy=side-panel-button]')
+                    .should('exist')
+                    .and(`be.disabled`);
+        })
+    })
+
+})
index 24e4b73a3265962f6dec09cdf68025d28d63b03c..8dc003fee1288f8b10cd76ec8bee7d27f1a17fc8 100644 (file)
@@ -196,4 +196,46 @@ Cypress.Commands.add('getAll', (...elements) => {
     }
 
     return promise
-})
\ No newline at end of file
+})
+
+Cypress.Commands.add('shareWith', (srcUserToken, targetUserUUID, itemUUID, permission = 'can_write') => {
+    cy.createLink(srcUserToken, {
+        name: permission,
+        link_class: 'permission',
+        head_uuid: itemUUID,
+        tail_uuid: targetUserUUID
+    });
+})
+
+Cypress.Commands.add('addToFavorites', (activeUserToken, activeUserUUID, itemUUID) => {
+    cy.createLink(activeUserToken, {
+        head_uuid: itemUUID,
+        link_class: 'star',
+        name: '',
+        owner_uuid: activeUserUUID,
+        tail_uuid: activeUserUUID,
+    });
+})
+
+Cypress.Commands.add('createProject', ({
+    owningUser,
+    targetUser,
+    projectName,
+    canWrite,
+    addToFavorites
+}) => {
+    const writePermission = canWrite ? 'can_write' : 'can_read';
+
+    cy.createGroup(owningUser.token, {
+        name: `${projectName} ${Math.floor(Math.random() * 999999)}`,
+        group_class: 'project',
+    }).as(`${projectName}`).then((project) => {
+        if (targetUser && targetUser !== owningUser) {
+            cy.shareWith(owningUser.token, targetUser.user.uuid, project.uuid, writePermission);
+        }
+        if (addToFavorites) {
+            const user = targetUser ? targetUser : owningUser;
+            cy.addToFavorites(user.token, user.user.uuid, project.uuid);
+        }
+    });
+});
\ No newline at end of file
index c3c4fcd02733ac465bb2a4b27f63503a14bac9ee..cfc2c52c799ce96b2ef03647c8e0ba3d41d20613 100644 (file)
@@ -4,11 +4,14 @@
 
 import { ResourceKind } from "~/models/resource";
 
-export const resourceLabel = (type: string) => {
+export const resourceLabel = (type: string, subtype = '') => {
     switch (type) {
         case ResourceKind.COLLECTION:
             return "Data collection";
         case ResourceKind.PROJECT:
+            if (subtype === "filter") {
+                return "Filter group";
+            }
             return "Project";
         case ResourceKind.PROCESS:
             return "Process";
index 72d7d92b11224ff537539dbadaf06c32e2de9503..f54d4a11bca797a32fd7ea06f2d4cee5ef8292ac 100644 (file)
@@ -35,7 +35,7 @@ export const CodeSnippet = withStyles(styles)(
         className={classNames(classes.root, className)}>
             {
                 lines.map((line: string, index: number) => {
-                    return <Typography key={index} className={apiResponse ? classes.space : ''} component="pre">{line}</Typography>;
+                    return <Typography key={index} className={apiResponse ? classes.space : className} component="pre">{line}</Typography>;
                 })
             }
         </Typography>
index 7633b71a45685137c92ec69a32f972d4cb0c3109..026157f404ee38a508f6998ba87756a1fa610e62 100644 (file)
@@ -85,13 +85,12 @@ export const DetailsAttribute = connect(mapStateToProps)(withStyles(styles)(
         }
 
         render() {
-            const { uuidEnhancer, label, link, value, children, classes, classLabel,
-                classValue, lowercaseValue, onValueClick, linkToUuid,
-                localCluster, remoteHostsConfig, sessions, copyValue } = this.props;
+            const { uuidEnhancer, link, value, classes, linkToUuid,
+                localCluster, remoteHostsConfig, sessions } = this.props;
             let valueNode: React.ReactNode;
 
             if (linkToUuid) {
-            const uuid = uuidEnhancer ? uuidEnhancer(linkToUuid) : linkToUuid;
+                const uuid = uuidEnhancer ? uuidEnhancer(linkToUuid) : linkToUuid;
                 const linkUrl = getNavUrl(linkToUuid || "", { localCluster, remoteHostsConfig, sessions });
                 if (linkUrl[0] === '/') {
                     valueNode = <Link to={linkUrl} className={classes.link}>{uuid}</Link>;
@@ -104,23 +103,33 @@ export const DetailsAttribute = connect(mapStateToProps)(withStyles(styles)(
                 valueNode = value;
             }
 
-            return <Typography component="div" className={classes.attribute}>
-                <Typography component="div" className={classnames([classes.label, classLabel])}>{label}</Typography>
-                <Typography
-                    onClick={onValueClick}
-                    component="div"
-                    className={classnames([classes.value, classValue, { [classes.lowercaseValue]: lowercaseValue }])}>
-                    {valueNode}
-                    {children}
-                    {(linkToUuid || copyValue) && <Tooltip title="Copy to clipboard">
-                        <span className={classes.copyIcon}>
-                            <CopyToClipboard text={linkToUuid || copyValue || ""} onCopy={() => this.onCopy("Copied")}>
-                                <CopyIcon />
-                            </CopyToClipboard>
-                        </span>
-                    </Tooltip>}
-                </Typography>
-            </Typography>;
+            return <DetailsAttributeComponent {...this.props} value={valueNode} onCopy={this.onCopy} />;
         }
     }
 ));
+
+interface DetailsAttributeComponentProps {
+    value: React.ReactNode;
+    onCopy?: (msg: string) => void;
+}
+
+export const DetailsAttributeComponent = withStyles(styles)(
+    (props: DetailsAttributeDataProps & WithStyles<CssRules> & DetailsAttributeComponentProps) =>
+        <Typography component="div" className={props.classes.attribute}>
+            <Typography component="div" className={classnames([props.classes.label, props.classLabel])}>{props.label}</Typography>
+            <Typography
+                onClick={props.onValueClick}
+                component="div"
+                className={classnames([props.classes.value, props.classValue, { [props.classes.lowercaseValue]: props.lowercaseValue }])}>
+                {props.value}
+                {props.children}
+                {(props.linkToUuid || props.copyValue) && props.onCopy && <Tooltip title="Copy to clipboard">
+                    <span className={props.classes.copyIcon}>
+                        <CopyToClipboard text={props.linkToUuid || props.copyValue || ""} onCopy={() => props.onCopy!("Copied")}>
+                            <CopyIcon />
+                        </CopyToClipboard>
+                    </span>
+                </Tooltip>}
+            </Typography>
+        </Typography>);
+
index 9eb60332e7261e0ba75f40bc62334432d988c4ff..6bbacaf4bebf6f368778e968ad275a550e960b19 100644 (file)
@@ -28,6 +28,7 @@ import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
 import FlipToFront from '@material-ui/icons/FlipToFront';
 import Folder from '@material-ui/icons/Folder';
 import FolderShared from '@material-ui/icons/FolderShared';
+import Pageview from '@material-ui/icons/Pageview';
 import GetApp from '@material-ui/icons/GetApp';
 import Help from '@material-ui/icons/Help';
 import HelpOutline from '@material-ui/icons/HelpOutline';
@@ -126,6 +127,7 @@ export const PaginationLeftArrowIcon: IconType = (props) => <ChevronLeft {...pro
 export const PaginationRightArrowIcon: IconType = (props) => <ChevronRight {...props} />;
 export const ProcessIcon: IconType = (props) => <BubbleChart {...props} />;
 export const ProjectIcon: IconType = (props) => <Folder {...props} />;
+export const FilterGroupIcon: IconType = (props) => <Pageview {...props} />;
 export const ProjectsIcon: IconType = (props) => <Inbox {...props} />;
 export const ProvenanceGraphIcon: IconType = (props) => <DeviceHub {...props} />;
 export const RemoveIcon: IconType = (props) => <Delete {...props} />;
index 908ee28ca8b24a139c21ef76358a6cbeaa723c5b..cf4d708daaabab31eddd615baac1b82a298b88a7 100644 (file)
@@ -5,7 +5,7 @@
 import * as React from 'react';
 import { List, ListItem, ListItemIcon, Checkbox, Radio, Collapse } from "@material-ui/core";
 import { StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core/styles';
-import { CollectionIcon, DefaultIcon, DirectoryIcon, FileIcon, ProjectIcon } from '~/components/icon/icon';
+import { CollectionIcon, DefaultIcon, DirectoryIcon, FileIcon, ProjectIcon, FilterGroupIcon } from '~/components/icon/icon';
 import { ReactElement } from "react";
 import CircularProgress from '@material-ui/core/CircularProgress';
 import classnames from "classnames";
@@ -13,6 +13,7 @@ import classnames from "classnames";
 import { ArvadosTheme } from '~/common/custom-theme';
 import { SidePanelRightArrowIcon } from '../icon/icon';
 import { ResourceKind } from '~/models/resource';
+import { GroupClass } from '~/models/group';
 
 type CssRules = 'list'
     | 'listItem'
@@ -162,31 +163,35 @@ const FLAT_TREE_ACTIONS = {
     toggleActive: 'TOGGLE_ACTIVE',
 };
 
-const ItemIcon = React.memo(({type, kind, active, classes}: any) => {
+const ItemIcon = React.memo(({type, kind, active, groupClass, classes}: any) => {
     let Icon = ProjectIcon;
 
-        if (type) {
-            switch (type) {
-                case 'directory':
-                    Icon = DirectoryIcon;
-                    break;
-                case 'file':
-                    Icon = FileIcon;
-                    break;
-                default:
-                    Icon = DefaultIcon;
-            }
+    if (groupClass === GroupClass.FILTER) {
+        Icon = FilterGroupIcon;
+    }
+
+    if (type) {
+        switch (type) {
+            case 'directory':
+                Icon = DirectoryIcon;
+                break;
+            case 'file':
+                Icon = FileIcon;
+                break;
+            default:
+                Icon = DefaultIcon;
         }
+    }
 
-        if (kind) {
-            switch(kind) {
-                case ResourceKind.COLLECTION:
-                    Icon = CollectionIcon;
-                    break;
-                default:
-                    break;
-            }
+    if (kind) {
+        switch(kind) {
+            case ResourceKind.COLLECTION:
+                Icon = CollectionIcon;
+                break;
+            default:
+                break;
         }
+    }
 
     return <Icon className={classnames({ [classes.active]: active }, classes.childItemIcon)} />;
 });
@@ -228,7 +233,7 @@ const FlatTree = (props: FlatTreeProps) =>
                     </i>
                     <div data-action={FLAT_TREE_ACTIONS.toggleActive} className={props.classes.renderContainer}>
                         <span style={{ display: 'flex', alignItems: 'center' }}>
-                            <ItemIcon type={item.data.type} active={item.active} kind={item.data.kind} classes={props.classes} />
+                            <ItemIcon type={item.data.type} active={item.active} kind={item.data.kind} groupClass={item.data.kind === ResourceKind.GROUP ? item.data.groupClass : ''} classes={props.classes} />
                             <span style={{ fontSize: '0.875rem' }}>
                                 {item.data.name}
                             </span>
index 4e4500b7289f5502cad1142e9506ae98a9d6a82d..5eb23ba194dd6bbb27feac0a356bcd9a29641ac5 100644 (file)
@@ -24,12 +24,18 @@ type CssRules = 'list'
     | 'iconOpen'
     | 'toggableIcon'
     | 'checkbox'
+    | 'virtualFileTree'
     | 'virtualizedList';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     list: {
         padding: '3px 0px',
     },
+    virtualFileTree: {
+        "&:last-child": {
+            paddingBottom: 20
+          }
+    },
     virtualizedList: {
         height: '200px',
     },
@@ -84,7 +90,7 @@ export const Row =  <T, _>(itemList: VirtualTreeItem<T>[], render: any, treeProp
         const it = itemList[index];
         const level = it.level || 0;
         const { toggleItemActive, disableRipple, currentItemUuid, useRadioButtons } = treeProps;
-        const { listItem, loader, toggableIconContainer, renderContainer } = classes;
+        const { listItem, loader, toggableIconContainer, renderContainer, virtualFileTree } = classes;
         const { levelIndentation = 20, itemRightPadding = 20 } = treeProps;
 
         const showSelection = typeof treeProps.showSelection === 'function'
@@ -130,7 +136,7 @@ export const Row =  <T, _>(itemList: VirtualTreeItem<T>[], render: any, treeProp
                 : undefined;
         };
 
-        return <div data-cy='virtual-file-tree' style={style}>
+        return <div className={virtualFileTree} data-cy='virtual-file-tree' style={style}>
             <ListItem button className={listItem}
                 style={{
                     paddingLeft: (level + 1) * levelIndentation,
index 98281b67d9ff5cfb4bca863ea7b5b5f2dd8ce27d..522d8dc1ee55676b777f09eaa3902ebecf53124d 100644 (file)
@@ -21,7 +21,7 @@ import { CustomTheme } from '~/common/custom-theme';
 import { fetchConfig } from '~/common/config';
 import { addMenuActionSet, ContextMenuKind } from '~/views-components/context-menu/context-menu';
 import { rootProjectActionSet } from "~/views-components/context-menu/action-sets/root-project-action-set";
-import { projectActionSet, readOnlyProjectActionSet } from "~/views-components/context-menu/action-sets/project-action-set";
+import { filterGroupActionSet, projectActionSet, readOnlyProjectActionSet } from "~/views-components/context-menu/action-sets/project-action-set";
 import { resourceActionSet } from '~/views-components/context-menu/action-sets/resource-action-set';
 import { favoriteActionSet } from "~/views-components/context-menu/action-sets/favorite-action-set";
 import { collectionFilesActionSet, readOnlyCollectionFilesActionSet } from '~/views-components/context-menu/action-sets/collection-files-action-set';
@@ -36,8 +36,8 @@ import { ServiceRepository } from '~/services/services';
 import { initWebSocket } from '~/websocket/websocket';
 import { Config } from '~/common/config';
 import { addRouteChangeHandlers } from './routes/route-change-handlers';
-import { setCurrentTokenDialogApiHost } from '~/store/current-token-dialog/current-token-dialog-actions';
-import { processResourceActionSet } from '~/views-components/context-menu/action-sets/process-resource-action-set';
+import { setTokenDialogApiHost } from '~/store/token-dialog/token-dialog-actions';
+import { processResourceActionSet, readOnlyProcessResourceActionSet } from '~/views-components/context-menu/action-sets/process-resource-action-set';
 import { progressIndicatorActions } from '~/store/progress-indicator/progress-indicator-actions';
 import { trashedCollectionActionSet } from '~/views-components/context-menu/action-sets/trashed-collection-action-set';
 import { setBuildInfo } from '~/store/app-info/app-info-actions';
@@ -58,7 +58,7 @@ import { groupMemberActionSet } from '~/views-components/context-menu/action-set
 import { linkActionSet } from '~/views-components/context-menu/action-sets/link-action-set';
 import { loadFileViewersConfig } from '~/store/file-viewers/file-viewers-actions';
 import { processResourceAdminActionSet } from '~/views-components/context-menu/action-sets/process-resource-admin-action-set';
-import { projectAdminActionSet } from '~/views-components/context-menu/action-sets/project-admin-action-set';
+import { filterGroupAdminActionSet, projectAdminActionSet } from '~/views-components/context-menu/action-sets/project-admin-action-set';
 import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions";
 import { openNotFoundDialog } from './store/not-found-panel/not-found-panel-action';
 import { storeRedirects } from './common/redirect-to';
@@ -68,6 +68,7 @@ console.log(`Starting arvados [${getBuildInfo()}]`);
 addMenuActionSet(ContextMenuKind.ROOT_PROJECT, rootProjectActionSet);
 addMenuActionSet(ContextMenuKind.PROJECT, projectActionSet);
 addMenuActionSet(ContextMenuKind.READONLY_PROJECT, readOnlyProjectActionSet);
+addMenuActionSet(ContextMenuKind.FILTER_GROUP, filterGroupActionSet);
 addMenuActionSet(ContextMenuKind.RESOURCE, resourceActionSet);
 addMenuActionSet(ContextMenuKind.FAVORITE, favoriteActionSet);
 addMenuActionSet(ContextMenuKind.COLLECTION_FILES, collectionFilesActionSet);
@@ -81,6 +82,7 @@ addMenuActionSet(ContextMenuKind.OLD_VERSION_COLLECTION, oldCollectionVersionAct
 addMenuActionSet(ContextMenuKind.TRASHED_COLLECTION, trashedCollectionActionSet);
 addMenuActionSet(ContextMenuKind.PROCESS, processActionSet);
 addMenuActionSet(ContextMenuKind.PROCESS_RESOURCE, processResourceActionSet);
+addMenuActionSet(ContextMenuKind.READONLY_PROCESS_RESOURCE, readOnlyProcessResourceActionSet);
 addMenuActionSet(ContextMenuKind.TRASH, trashActionSet);
 addMenuActionSet(ContextMenuKind.REPOSITORY, repositoryActionSet);
 addMenuActionSet(ContextMenuKind.SSH_KEY, sshKeyActionSet);
@@ -95,6 +97,7 @@ addMenuActionSet(ContextMenuKind.GROUP_MEMBER, groupMemberActionSet);
 addMenuActionSet(ContextMenuKind.COLLECTION_ADMIN, collectionAdminActionSet);
 addMenuActionSet(ContextMenuKind.PROCESS_ADMIN, processResourceAdminActionSet);
 addMenuActionSet(ContextMenuKind.PROJECT_ADMIN, projectAdminActionSet);
+addMenuActionSet(ContextMenuKind.FILTER_GROUP_ADMIN, filterGroupAdminActionSet);
 
 storeRedirects();
 
@@ -130,7 +133,7 @@ fetchConfig()
         store.subscribe(initListener(history, store, services, config));
         store.dispatch(initAuth(config));
         store.dispatch(setBuildInfo());
-        store.dispatch(setCurrentTokenDialogApiHost(apiHost));
+        store.dispatch(setTokenDialogApiHost(apiHost));
         store.dispatch(loadVocabulary);
         store.dispatch(loadFileViewersConfig);
 
index 01a92017d54d9ae9b8323d101f549359208323d5..739485c5682aba2209aaf16547e47651a47db2e1 100644 (file)
@@ -18,4 +18,7 @@ export interface ApiClientAuthorization extends Resource {
     ownerUuid: string;
     defaultOwnerUuid: string;
     scopes: string[];
-}
\ No newline at end of file
+}
+
+export const getTokenV2 = (aca: ApiClientAuthorization): string =>
+    `v2/${aca.uuid}/${aca.apiToken}`;
\ No newline at end of file
index e18c8ecbb96c6b67652ee51f2245ba022eaddd17..365e9ccebb9fc22341da3589f2dc993287d304c7 100644 (file)
@@ -15,5 +15,6 @@ export interface GroupResource extends TrashableResource {
 }
 
 export enum GroupClass {
-    PROJECT = "project"
+    PROJECT = 'project',
+    FILTER  = 'filter',
 }
index 8e101ce29ffeaac99cb7c2073aae96b8b79ac9e8..86ac04f6dd58222d869bd29980ed03715f0adba7 100644 (file)
@@ -5,7 +5,7 @@
 import { GroupClass, GroupResource } from "./group";
 
 export interface ProjectResource extends GroupResource {
-    groupClass: GroupClass.PROJECT;
+    groupClass: GroupClass.PROJECT | GroupClass.FILTER;
 }
 
 export const getProjectUrl = (uuid: string) => {
index 54c0edf6bf2c684346e0309103ad085c4f38a541..e43f9f8f136a7404af40b9ffb0626cffd650d9ad 100644 (file)
@@ -94,11 +94,13 @@ export class CommonService<T> {
             });
     }
 
-    create(data?: Partial<T>) {
+    create(data?: Partial<T>, showErrors?: boolean) {
         return CommonService.defaultResponse(
             this.serverApi
                 .post<T>(this.resourceType, data && CommonService.mapKeys(_.snakeCase)(data)),
-            this.actions
+            this.actions,
+            true, // mapKeys
+            showErrors
         );
     }
 
index 12eae0fec00be34f3399fc8280db8ea4a7d95681..3634b8cba60a3fc84621b4f12ef87c56ad9b53b6 100644 (file)
@@ -31,7 +31,7 @@ describe("CommonResourceService", () => {
         expect(axiosInstance.get).toHaveBeenCalledWith("/groups", {
             params: {
                 filters: "[" + new FilterBuilder()
-                    .addEqual("group_class", "project")
+                    .addIn("group_class", ["project", "filter"])
                     .getFilters() + "]",
                 order: undefined
             }
index 4ae91d4d088fe1c113e75d4191142719e39be618..515571e7d2a04113199530543eda3312d0dcd16b 100644 (file)
@@ -20,7 +20,7 @@ export class ProjectService extends GroupsService<ProjectResource> {
             filters: joinFilters(
                 args.filters || '',
                 new FilterBuilder()
-                    .addEqual("group_class", GroupClass.PROJECT)
+                    .addIn('group_class', [GroupClass.PROJECT, GroupClass.FILTER])
                     .getFilters()
             )
         });
index 13575d44d5ce1ab65196fd0dab4bbf43ecb464a2..abc2a5a1a438f2237b88e166a884f957ce74dfee 100644 (file)
@@ -2,20 +2,21 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { initAuth } from "./auth-action";
+import { getNewExtraToken, initAuth } from "./auth-action";
 import { API_TOKEN_KEY } from "~/services/auth-service/auth-service";
 
 import 'jest-localstorage-mock';
 import { ServiceRepository, createServices } from "~/services/services";
 import { configureStore, RootStore } from "../store";
 import { createBrowserHistory } from "history";
-import { mockConfig, DISCOVERY_DOC_PATH, } from '~/common/config';
+import { Config, mockConfig } from '~/common/config';
 import { ApiActions } from "~/services/api/api-actions";
 import { ACCOUNT_LINK_STATUS_KEY } from '~/services/link-account-service/link-account-service';
 import Axios from "axios";
 import MockAdapter from "axios-mock-adapter";
 import { ImportMock } from 'ts-mock-imports';
 import * as servicesModule from "~/services/services";
+import { SessionStatus } from "~/models/session";
 
 describe('auth-actions', () => {
     const axiosInst = Axios.create({ headers: {} });
@@ -42,8 +43,7 @@ describe('auth-actions', () => {
         importMocks.map(m => m.restore());
     });
 
-    it('should initialise state with user and api token from local storage', (done) => {
-
+    it('creates an extra token', async () => {
         axiosMock
             .onGet("/users/current")
             .reply(200, {
@@ -56,9 +56,76 @@ describe('auth-actions', () => {
                 is_active: true,
                 username: "jdoe",
                 prefs: {}
+            })
+            .onGet("/api_client_authorizations/current")
+            .reply(200, {
+                expires_at: "2140-01-01T00:00:00.000Z",
+                api_token: 'extra token',
+            })
+            .onPost("/api_client_authorizations")
+            .replyOnce(200, {
+                uuid: 'zzzzz-gj3su-xxxxxxxxxx',
+                apiToken: 'extra token',
+            })
+            .onPost("/api_client_authorizations")
+            .reply(200, {
+                uuid: 'zzzzz-gj3su-xxxxxxxxxx',
+                apiToken: 'extra additional token',
             });
 
+        importMocks.push(ImportMock.mockFunction(servicesModule, 'createServices', services));
+        sessionStorage.setItem(ACCOUNT_LINK_STATUS_KEY, "0");
+        localStorage.setItem(API_TOKEN_KEY, "token");
+
+        const config: any = {
+            rootUrl: "https://zzzzz.arvadosapi.com",
+            uuidPrefix: "zzzzz",
+            remoteHosts: { },
+            apiRevision: 12345678,
+            clusterConfig: {
+                Login: { LoginCluster: "" },
+            },
+        };
+
+        // Set up auth, confirm that no extra token was requested
+        await store.dispatch(initAuth(config))
+        expect(store.getState().auth.apiToken).toBeDefined();
+        expect(store.getState().auth.extraApiToken).toBeUndefined();
+
+        // Ask for an extra token
+        await store.dispatch(getNewExtraToken());
+        expect(store.getState().auth.apiToken).toBeDefined();
+        expect(store.getState().auth.extraApiToken).toBeDefined();
+        const extraToken = store.getState().auth.extraApiToken;
+
+        // Ask for a cached extra token
+        await store.dispatch(getNewExtraToken(true));
+        expect(store.getState().auth.extraApiToken).toBe(extraToken);
+
+        // Ask for a new extra token, should make a second api request
+        await store.dispatch(getNewExtraToken(false));
+        expect(store.getState().auth.extraApiToken).toBeDefined();
+        expect(store.getState().auth.extraApiToken).not.toBe(extraToken);
+    });
+
+    it('should initialise state with user and api token from local storage', (done) => {
         axiosMock
+            .onGet("/users/current")
+            .reply(200, {
+                email: "test@test.com",
+                first_name: "John",
+                last_name: "Doe",
+                uuid: "zzzzz-tpzed-abcefg",
+                owner_uuid: "ownerUuid",
+                is_admin: false,
+                is_active: true,
+                username: "jdoe",
+                prefs: {}
+            })
+            .onGet("/api_client_authorizations/current")
+            .reply(200, {
+                expires_at: "2140-01-01T00:00:00.000Z"
+            })
             .onGet("https://xc59z.arvadosapi.com/discovery/v1/apis/arvados/v1/rest")
             .reply(200, {
                 baseUrl: "https://xc59z.arvadosapi.com/arvados/v1",
@@ -84,6 +151,9 @@ describe('auth-actions', () => {
             uuidPrefix: "zzzzz",
             remoteHosts: { xc59z: "xc59z.arvadosapi.com" },
             apiRevision: 12345678,
+            clusterConfig: {
+                Login: { LoginCluster: "" },
+            },
         };
 
         store.dispatch(initAuth(config));
@@ -92,14 +162,20 @@ describe('auth-actions', () => {
             const auth = store.getState().auth;
             if (auth.apiToken === "token" &&
                 auth.sessions.length === 2 &&
-                auth.sessions[0].status === 2 &&
-                auth.sessions[1].status === 2
+                auth.sessions[0].status === SessionStatus.VALIDATED &&
+                auth.sessions[1].status === SessionStatus.VALIDATED
             ) {
                 try {
                     expect(auth).toEqual({
                         apiToken: "token",
+                        apiTokenExpiration: new Date("2140-01-01T00:00:00.000Z"),
                         config: {
                             apiRevision: 12345678,
+                            clusterConfig: {
+                                Login: {
+                                    LoginCluster: "",
+                                },
+                            },
                             remoteHosts: {
                                 "xc59z": "xc59z.arvadosapi.com",
                             },
@@ -107,12 +183,19 @@ describe('auth-actions', () => {
                             uuidPrefix: "zzzzz",
                         },
                         sshKeys: [],
+                        extraApiToken: undefined,
+                        extraApiTokenExpiration: undefined,
                         homeCluster: "zzzzz",
                         localCluster: "zzzzz",
                         loginCluster: undefined,
                         remoteHostsConfig: {
                             "zzzzz": {
                                 "apiRevision": 12345678,
+                                "clusterConfig": {
+                                    "Login": {
+                                        "LoginCluster": "",
+                                    },
+                                },
                                 "remoteHosts": {
                                     "xc59z": "xc59z.arvadosapi.com",
                                 },
@@ -170,7 +253,7 @@ describe('auth-actions', () => {
                     });
                     done();
                 } catch (e) {
-                    console.log(e);
+                    fail(e);
                 }
             }
         });
index 15fe3d4d591da9e00ef356ac591726060a8e76a3..8c44aec448754ad9115a25c0730a911664a9b23a 100644 (file)
@@ -16,12 +16,15 @@ import { cancelLinking } from '~/store/link-account-panel/link-account-panel-act
 import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
 import { WORKBENCH_LOADING_SCREEN } from '~/store/workbench/workbench-actions';
 import { addRemoteConfig } from './auth-action-session';
+import { getTokenV2 } from '~/models/api-client-authorization';
 
 export const authActions = unionize({
     LOGIN: {},
     LOGOUT: ofType<{ deleteLinkData: boolean }>(),
     SET_CONFIG: ofType<{ config: Config }>(),
-    INIT_USER: ofType<{ user: User, token: string }>(),
+    SET_EXTRA_TOKEN: ofType<{ extraApiToken: string, extraApiTokenExpiration?: Date }>(),
+    RESET_EXTRA_TOKEN: {},
+    INIT_USER: ofType<{ user: User, token: string, tokenExpiration?: Date }>(),
     USER_DETAILS_REQUEST: {},
     USER_DETAILS_SUCCESS: ofType<User>(),
     SET_SSH_KEYS: ofType<SshKeyResource[]>(),
@@ -35,21 +38,18 @@ export const authActions = unionize({
     REMOTE_CLUSTER_CONFIG: ofType<{ config: Config }>(),
 });
 
-export const initAuth = (config: Config) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const initAuth = (config: Config) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
     // Cancel any link account ops in progress unless the user has
     // just logged in or there has been a successful link operation
     const data = services.linkAccountService.getLinkOpStatus();
     if (!matchTokenRoute(location.pathname) &&
         (!matchFedTokenRoute(location.pathname)) && data === undefined) {
-        dispatch<any>(cancelLinking()).then(() => {
-            dispatch<any>(init(config));
-        });
-    } else {
-        dispatch<any>(init(config));
+        await dispatch<any>(cancelLinking());
     }
+    return dispatch<any>(init(config));
 };
 
-const init = (config: Config) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+const init = (config: Config) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
     const remoteHosts = () => getState().auth.remoteHosts;
     const token = services.authService.getApiToken();
     let homeCluster = services.authService.getHomeCluster();
@@ -67,11 +67,12 @@ const init = (config: Config) => (dispatch: Dispatch, getState: () => RootState,
 
     if (token && token !== "undefined") {
         dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
-        dispatch<any>(saveApiToken(token)).then(() => {
+        try {
+            await dispatch<any>(saveApiToken(token)); // .then(() => {
+            await dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
+        } catch (e) {
             dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
-        }).catch(() => {
-            dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
-        });
+        }
     }
 };
 
@@ -80,17 +81,60 @@ export const getConfig = (dispatch: Dispatch, getState: () => RootState, service
     return state.remoteHostsConfig[state.localCluster];
 };
 
-export const saveApiToken = (token: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
+export const saveApiToken = (token: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
     const config = dispatch<any>(getConfig);
     const svc = createServices(config, { progressFn: () => { }, errorFn: () => { } });
     setAuthorizationHeader(svc, token);
-    return svc.authService.getUserDetails().then((user: User) => {
-        dispatch(authActions.INIT_USER({ user, token }));
-    }).catch(() => {
+    try {
+        const user = await svc.authService.getUserDetails();
+        const client = await svc.apiClientAuthorizationService.get('current');
+        const tokenExpiration = client.expiresAt ? new Date(client.expiresAt) : undefined;
+        dispatch(authActions.INIT_USER({ user, token, tokenExpiration }));
+    } catch (e) {
         dispatch(authActions.LOGOUT({ deleteLinkData: false }));
-    });
+    }
 };
 
+export const getNewExtraToken = (reuseStored: boolean = false) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const extraToken = getState().auth.extraApiToken;
+        if (reuseStored && extraToken !== undefined) {
+            const config = dispatch<any>(getConfig);
+            const svc = createServices(config, { progressFn: () => { }, errorFn: () => { } });
+            setAuthorizationHeader(svc, extraToken);
+            try {
+                // Check the extra token's validity before using it. Refresh its
+                // expiration date just in case it changed.
+                const client = await svc.apiClientAuthorizationService.get('current');
+                dispatch(authActions.SET_EXTRA_TOKEN({
+                    extraApiToken: extraToken,
+                    extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt): undefined,
+                }));
+                return extraToken;
+            } catch (e) {
+                dispatch(authActions.RESET_EXTRA_TOKEN());
+            }
+        }
+        const user = getState().auth.user;
+        const loginCluster = getState().auth.config.clusterConfig.Login.LoginCluster;
+        if (user === undefined) { return; }
+        if (loginCluster !== "" && getState().auth.homeCluster !== loginCluster) { return; }
+        try {
+            // Do not show errors on the create call, cluster security configuration may not
+            // allow token creation and there's no way to know that from workbench2 side in advance.
+            const client = await services.apiClientAuthorizationService.create(undefined, false);
+            const newExtraToken = getTokenV2(client);
+            dispatch(authActions.SET_EXTRA_TOKEN({
+                extraApiToken: newExtraToken,
+                extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt): undefined,
+            }));
+            return newExtraToken;
+        } catch {
+            console.warn("Cannot create new tokens with the current token, probably because of cluster's security settings.");
+            return;
+        }
+    };
+
 export const login = (uuidPrefix: string, homeCluster: string, loginCluster: string,
     remoteHosts: { [key: string]: string }) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         services.authService.login(uuidPrefix, homeCluster, loginCluster, remoteHosts);
index 946407fe24172610fbc3aaf9cff7b95052a43af8..b29cf3411a5650cc330ef0735a7fb768a95ade22 100644 (file)
@@ -12,6 +12,9 @@ import { Config, mockConfig } from '~/common/config';
 export interface AuthState {
     user?: User;
     apiToken?: string;
+    apiTokenExpiration?: Date;
+    extraApiToken?: string;
+    extraApiTokenExpiration?: Date;
     sshKeys: SshKeyResource[];
     sessions: Session[];
     localCluster: string;
@@ -25,6 +28,9 @@ export interface AuthState {
 const initialState: AuthState = {
     user: undefined,
     apiToken: undefined,
+    apiTokenExpiration: undefined,
+    extraApiToken: undefined,
+    extraApiTokenExpiration: undefined,
     sshKeys: [],
     sessions: [],
     localCluster: "",
@@ -37,69 +43,68 @@ const initialState: AuthState = {
 
 export const authReducer = (services: ServiceRepository) => (state = initialState, action: AuthAction) => {
     return authActions.match(action, {
-        SET_CONFIG: ({ config }) => {
-            return {
+        SET_CONFIG: ({ config }) =>
+            ({
                 ...state,
                 config,
                 localCluster: config.uuidPrefix,
-                remoteHosts: { ...config.remoteHosts, [config.uuidPrefix]: new URL(config.rootUrl).host },
+                remoteHosts: {
+                    ...config.remoteHosts,
+                    [config.uuidPrefix]: new URL(config.rootUrl).host
+                },
                 homeCluster: config.loginCluster || config.uuidPrefix,
                 loginCluster: config.loginCluster,
-                remoteHostsConfig: { ...state.remoteHostsConfig, [config.uuidPrefix]: config }
-            };
-        },
-        REMOTE_CLUSTER_CONFIG: ({ config }) => {
-            return {
+                remoteHostsConfig: {
+                    ...state.remoteHostsConfig,
+                    [config.uuidPrefix]: config
+                }
+            }),
+        REMOTE_CLUSTER_CONFIG: ({ config }) =>
+            ({
                 ...state,
-                remoteHostsConfig: { ...state.remoteHostsConfig, [config.uuidPrefix]: config },
-            };
-        },
-        INIT_USER: ({ user, token }) => {
-            return { ...state, user, apiToken: token, homeCluster: user.uuid.substr(0, 5) };
-        },
-        LOGIN: () => {
-            return state;
-        },
-        LOGOUT: () => {
-            return { ...state, apiToken: undefined };
-        },
-        USER_DETAILS_SUCCESS: (user: User) => {
-            return { ...state, user, homeCluster: user.uuid.substr(0, 5) };
-        },
-        SET_SSH_KEYS: (sshKeys: SshKeyResource[]) => {
-            return { ...state, sshKeys };
-        },
-        ADD_SSH_KEY: (sshKey: SshKeyResource) => {
-            return { ...state, sshKeys: state.sshKeys.concat(sshKey) };
-        },
-        REMOVE_SSH_KEY: (uuid: string) => {
-            return { ...state, sshKeys: state.sshKeys.filter((sshKey) => sshKey.uuid !== uuid) };
-        },
-        SET_HOME_CLUSTER: (homeCluster: string) => {
-            return { ...state, homeCluster };
-        },
-        SET_SESSIONS: (sessions: Session[]) => {
-            return { ...state, sessions };
-        },
-        ADD_SESSION: (session: Session) => {
-            return { ...state, sessions: state.sessions.concat(session) };
-        },
-        REMOVE_SESSION: (clusterId: string) => {
-            return {
+                remoteHostsConfig: {
+                    ...state.remoteHostsConfig,
+                    [config.uuidPrefix]: config
+                },
+            }),
+        SET_EXTRA_TOKEN: ({ extraApiToken, extraApiTokenExpiration }) =>
+            ({ ...state, extraApiToken, extraApiTokenExpiration }),
+        RESET_EXTRA_TOKEN: () =>
+            ({ ...state, extraApiToken: undefined, extraApiTokenExpiration: undefined }),
+        INIT_USER: ({ user, token, tokenExpiration }) =>
+            ({ ...state,
+                user,
+                apiToken: token,
+                apiTokenExpiration: tokenExpiration,
+                homeCluster: user.uuid.substr(0, 5)
+            }),
+        LOGIN: () => state,
+        LOGOUT: () => ({ ...state, apiToken: undefined }),
+        USER_DETAILS_SUCCESS: (user: User) =>
+            ({ ...state, user, homeCluster: user.uuid.substr(0, 5) }),
+        SET_SSH_KEYS: (sshKeys: SshKeyResource[]) => ({ ...state, sshKeys }),
+        ADD_SSH_KEY: (sshKey: SshKeyResource) =>
+            ({ ...state, sshKeys: state.sshKeys.concat(sshKey) }),
+        REMOVE_SSH_KEY: (uuid: string) =>
+            ({ ...state, sshKeys: state.sshKeys.filter((sshKey) => sshKey.uuid !== uuid) }),
+        SET_HOME_CLUSTER: (homeCluster: string) => ({ ...state, homeCluster }),
+        SET_SESSIONS: (sessions: Session[]) => ({ ...state, sessions }),
+        ADD_SESSION: (session: Session) =>
+            ({ ...state, sessions: state.sessions.concat(session) }),
+        REMOVE_SESSION: (clusterId: string) =>
+            ({
                 ...state,
                 sessions: state.sessions.filter(
                     session => session.clusterId !== clusterId
                 )
-            };
-        },
-        UPDATE_SESSION: (session: Session) => {
-            return {
+            }),
+        UPDATE_SESSION: (session: Session) =>
+            ({
                 ...state,
                 sessions: state.sessions.map(
                     s => s.clusterId === session.clusterId ? session : s
                 )
-            };
-        },
+            }),
         default: () => state
     });
 };
index 49fe54f67ea6ccacee79135178ea23b912f1607d..29dc6b879eb744693f7fcfd0bfa3087f647cbb87 100644 (file)
@@ -6,6 +6,7 @@ import { Dispatch } from "redux";
 import { RootState } from "~/store/store";
 import { ServiceRepository } from "~/services/services";
 import { dialogActions } from '~/store/dialog/dialog-actions';
+import { getNewExtraToken } from "../auth/auth-action";
 
 export const COLLECTION_WEBDAV_S3_DIALOG_NAME = 'collectionWebdavS3Dialog';
 
@@ -21,12 +22,13 @@ export interface WebDavS3InfoDialogData {
 }
 
 export const openWebDavS3InfoDialog = (uuid: string, activeTab?: number) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        await dispatch<any>(getNewExtraToken(true));
         dispatch(dialogActions.OPEN_DIALOG({
             id: COLLECTION_WEBDAV_S3_DIALOG_NAME,
             data: {
                 title: 'Access Collection using WebDAV or S3',
-                token: getState().auth.apiToken,
+                token: getState().auth.extraApiToken || getState().auth.apiToken,
                 downloadUrl: getState().auth.config.keepWebServiceUrl,
                 collectionsUrl: getState().auth.config.keepWebInlineServiceUrl,
                 localCluster: getState().auth.localCluster,
index 2778568e7681d1073a7f627e70bf9444b8aaee21..36976336a8f7d1409f18db7ea3a228fbbaa8920e 100644 (file)
@@ -6,6 +6,8 @@ import { ContextMenuKind } from '~/views-components/context-menu/context-menu';
 import { resourceUuidToContextMenuKind } from './context-menu-actions';
 import configureStore from 'redux-mock-store';
 import thunk from 'redux-thunk';
+import { PROJECT_PANEL_CURRENT_UUID } from '../project-panel/project-panel-action';
+import { GroupClass } from '~/models/group';
 
 describe('context-menu-actions', () => {
     describe('resourceUuidToContextMenuKind', () => {
@@ -16,70 +18,80 @@ describe('context-menu-actions', () => {
         const headCollectionUuid = 'zzzzz-4zz18-aaaaaaaaaaaaaaa';
         const oldCollectionUuid = 'zzzzz-4zz18-aaaaaaaaaaaaaab';
         const projectUuid = 'zzzzz-j7d0g-ccccccccccccccc';
+        const filterGroupUuid = 'zzzzz-j7d0g-ccccccccccccccd';
         const linkUuid = 'zzzzz-o0j2j-0123456789abcde';
         const containerRequestUuid = 'zzzzz-xvhdp-0123456789abcde';
 
         it('should return the correct menu kind', () => {
             const cases = [
-                // resourceUuid, isAdminUser, isEditable, isTrashed, expected
-                [headCollectionUuid, false, true, true, ContextMenuKind.TRASHED_COLLECTION],
-                [headCollectionUuid, false, true, false, ContextMenuKind.COLLECTION],
-                [headCollectionUuid, false, false, true, ContextMenuKind.READONLY_COLLECTION],
-                [headCollectionUuid, false, false, false, ContextMenuKind.READONLY_COLLECTION],
-                [headCollectionUuid, true, true, true, ContextMenuKind.TRASHED_COLLECTION],
-                [headCollectionUuid, true, true, false, ContextMenuKind.COLLECTION_ADMIN],
-                [headCollectionUuid, true, false, true, ContextMenuKind.TRASHED_COLLECTION],
-                [headCollectionUuid, true, false, false, ContextMenuKind.COLLECTION_ADMIN],
+                // resourceUuid, isAdminUser, isEditable, isTrashed, forceReadonly, expected
+                [headCollectionUuid, false, true, true, false, ContextMenuKind.TRASHED_COLLECTION],
+                [headCollectionUuid, false, true, false, false, ContextMenuKind.COLLECTION],
+                [headCollectionUuid, false, true, false, true, ContextMenuKind.READONLY_COLLECTION],
+                [headCollectionUuid, false, false, true, false, ContextMenuKind.READONLY_COLLECTION],
+                [headCollectionUuid, false, false, false, false, ContextMenuKind.READONLY_COLLECTION],
+                [headCollectionUuid, true, true, true, false, ContextMenuKind.TRASHED_COLLECTION],
+                [headCollectionUuid, true, true, false, false, ContextMenuKind.COLLECTION_ADMIN],
+                [headCollectionUuid, true, false, true, false, ContextMenuKind.TRASHED_COLLECTION],
+                [headCollectionUuid, true, false, false, false, ContextMenuKind.COLLECTION_ADMIN],
+                [headCollectionUuid, true, false, false, true, ContextMenuKind.READONLY_COLLECTION],
 
-                [oldCollectionUuid, false, true, true, ContextMenuKind.OLD_VERSION_COLLECTION],
-                [oldCollectionUuid, false, true, false, ContextMenuKind.OLD_VERSION_COLLECTION],
-                [oldCollectionUuid, false, false, true, ContextMenuKind.OLD_VERSION_COLLECTION],
-                [oldCollectionUuid, false, false, false, ContextMenuKind.OLD_VERSION_COLLECTION],
-                [oldCollectionUuid, true, true, true, ContextMenuKind.OLD_VERSION_COLLECTION],
-                [oldCollectionUuid, true, true, false, ContextMenuKind.OLD_VERSION_COLLECTION],
-                [oldCollectionUuid, true, false, true, ContextMenuKind.OLD_VERSION_COLLECTION],
-                [oldCollectionUuid, true, false, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, false, true, true, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, false, true, false, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, false, false, true, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, false, false, false, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, true, true, true, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, true, true, false, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, true, false, true, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, true, false, false, false, ContextMenuKind.OLD_VERSION_COLLECTION],
 
                 // FIXME: WB2 doesn't currently have context menu for trashed projects
-                // [projectUuid, false, true, true, ContextMenuKind.TRASHED_PROJECT],
-                [projectUuid, false, true, false, ContextMenuKind.PROJECT],
-                [projectUuid, false, false, true, ContextMenuKind.READONLY_PROJECT],
-                [projectUuid, false, false, false, ContextMenuKind.READONLY_PROJECT],
-                // [projectUuid, true, true, true, ContextMenuKind.TRASHED_PROJECT],
-                [projectUuid, true, true, false, ContextMenuKind.PROJECT_ADMIN],
-                // [projectUuid, true, false, true, ContextMenuKind.TRASHED_PROJECT],
-                [projectUuid, true, false, false, ContextMenuKind.PROJECT_ADMIN],
+                // [projectUuid, false, true, true, false, ContextMenuKind.TRASHED_PROJECT],
+                [projectUuid, false, true, false, false, ContextMenuKind.PROJECT],
+                [projectUuid, false, true, false, true, ContextMenuKind.READONLY_PROJECT],
+                [projectUuid, false, false, true, false, ContextMenuKind.READONLY_PROJECT],
+                [projectUuid, false, false, false, false, ContextMenuKind.READONLY_PROJECT],
+                // [projectUuid, true, true, true, false, ContextMenuKind.TRASHED_PROJECT],
+                [projectUuid, true, true, false, false, ContextMenuKind.PROJECT_ADMIN],
+                // [projectUuid, true, false, true, false, ContextMenuKind.TRASHED_PROJECT],
+                [projectUuid, true, false, false, false, ContextMenuKind.PROJECT_ADMIN],
+                [projectUuid, true, false, false, true, ContextMenuKind.READONLY_PROJECT],
 
-                [linkUuid, false, true, true, ContextMenuKind.LINK],
-                [linkUuid, false, true, false, ContextMenuKind.LINK],
-                [linkUuid, false, false, true, ContextMenuKind.LINK],
-                [linkUuid, false, false, false, ContextMenuKind.LINK],
-                [linkUuid, true, true, true, ContextMenuKind.LINK],
-                [linkUuid, true, true, false, ContextMenuKind.LINK],
-                [linkUuid, true, false, true, ContextMenuKind.LINK],
-                [linkUuid, true, false, false, ContextMenuKind.LINK],
+                [linkUuid, false, true, true, false, ContextMenuKind.LINK],
+                [linkUuid, false, true, false, false, ContextMenuKind.LINK],
+                [linkUuid, false, false, true, false, ContextMenuKind.LINK],
+                [linkUuid, false, false, false, false, ContextMenuKind.LINK],
+                [linkUuid, true, true, true, false, ContextMenuKind.LINK],
+                [linkUuid, true, true, false, false, ContextMenuKind.LINK],
+                [linkUuid, true, false, true, false, ContextMenuKind.LINK],
+                [linkUuid, true, false, false, false, ContextMenuKind.LINK],
 
-                [userUuid, false, true, true, ContextMenuKind.ROOT_PROJECT],
-                [userUuid, false, true, false, ContextMenuKind.ROOT_PROJECT],
-                [userUuid, false, false, true, ContextMenuKind.ROOT_PROJECT],
-                [userUuid, false, false, false, ContextMenuKind.ROOT_PROJECT],
-                [userUuid, true, true, true, ContextMenuKind.ROOT_PROJECT],
-                [userUuid, true, true, false, ContextMenuKind.ROOT_PROJECT],
-                [userUuid, true, false, true, ContextMenuKind.ROOT_PROJECT],
-                [userUuid, true, false, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, false, true, true, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, false, true, false, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, false, false, true, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, false, false, false, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, true, true, true, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, true, true, false, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, true, false, true, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, true, false, false, false, ContextMenuKind.ROOT_PROJECT],
 
-                [containerRequestUuid, false, true, true, ContextMenuKind.PROCESS_RESOURCE],
-                [containerRequestUuid, false, true, false, ContextMenuKind.PROCESS_RESOURCE],
-                [containerRequestUuid, false, false, true, ContextMenuKind.PROCESS_RESOURCE],
-                [containerRequestUuid, false, false, false, ContextMenuKind.PROCESS_RESOURCE],
-                [containerRequestUuid, true, true, true, ContextMenuKind.PROCESS_ADMIN],
-                [containerRequestUuid, true, true, false, ContextMenuKind.PROCESS_ADMIN],
-                [containerRequestUuid, true, false, true, ContextMenuKind.PROCESS_ADMIN],
-                [containerRequestUuid, true, false, false, ContextMenuKind.PROCESS_ADMIN],
+                [containerRequestUuid, false, true, true, false, ContextMenuKind.PROCESS_RESOURCE],
+                [containerRequestUuid, false, true, false, false, ContextMenuKind.PROCESS_RESOURCE],
+                [containerRequestUuid, false, false, true, false, ContextMenuKind.PROCESS_RESOURCE],
+                [containerRequestUuid, false, false, false, false, ContextMenuKind.PROCESS_RESOURCE],
+                [containerRequestUuid, false, false, false, true, ContextMenuKind.READONLY_PROCESS_RESOURCE],
+                [containerRequestUuid, true, true, true, false, ContextMenuKind.PROCESS_ADMIN],
+                [containerRequestUuid, true, true, false, false, ContextMenuKind.PROCESS_ADMIN],
+                [containerRequestUuid, true, false, true, false, ContextMenuKind.PROCESS_ADMIN],
+                [containerRequestUuid, true, false, false, false, ContextMenuKind.PROCESS_ADMIN],
+                [containerRequestUuid, true, false, false, true, ContextMenuKind.READONLY_PROCESS_RESOURCE],
             ]
 
-            cases.forEach(([resourceUuid, isAdminUser, isEditable, isTrashed, expected]) => {
+            cases.forEach(([resourceUuid, isAdminUser, isEditable, isTrashed, forceReadonly, expected]) => {
                 const initialState = {
+                    properties: {
+                        [PROJECT_PANEL_CURRENT_UUID]: projectUuid,
+                    },
                     resources: {
                         [headCollectionUuid]: {
                             uuid: headCollectionUuid,
@@ -91,12 +103,18 @@ describe('context-menu-actions', () => {
                             uuid: oldCollectionUuid,
                             currentVersionUuid: headCollectionUuid,
                             isTrashed: isTrashed,
-
                         },
                         [projectUuid]: {
                             uuid: projectUuid,
                             ownerUuid: isEditable ? userUuid : otherUserUuid,
                             writableBy: isEditable ? [userUuid] : [otherUserUuid],
+                            groupClass: GroupClass.PROJECT,
+                        },
+                        [filterGroupUuid]: {
+                            uuid: filterGroupUuid,
+                            ownerUuid: isEditable ? userUuid : otherUserUuid,
+                            writableBy: isEditable ? [userUuid] : [otherUserUuid],
+                            groupClass: GroupClass.FILTER,
                         },
                         [linkUuid]: {
                             uuid: linkUuid,
@@ -118,13 +136,14 @@ describe('context-menu-actions', () => {
                 };
                 const store = mockStore(initialState);
 
-                const menuKind = store.dispatch<any>(resourceUuidToContextMenuKind(resourceUuid as string))
+                let menuKind: any;
                 try {
+                    menuKind = store.dispatch<any>(resourceUuidToContextMenuKind(resourceUuid as string, forceReadonly as boolean))
                     expect(menuKind).toBe(expected);
                 } catch (err) {
-                    throw new Error(`menuKind for resource ${JSON.stringify(initialState.resources[resourceUuid as string])} expected to be ${expected} but got ${menuKind}.`);
+                    throw new Error(`menuKind for resource ${JSON.stringify(initialState.resources[resourceUuid as string])} forceReadonly: ${forceReadonly} expected to be ${expected} but got ${menuKind}.`);
                 }
             });
         });
     });
-});
\ No newline at end of file
+});
index 225538859a743a690e2da15143fe600d8e786fe6..83335f83c5aa938d2716f05705695ed7eabce358 100644 (file)
@@ -18,7 +18,7 @@ import { VirtualMachinesResource } from '~/models/virtual-machines';
 import { KeepServiceResource } from '~/models/keep-services';
 import { ProcessResource } from '~/models/process';
 import { CollectionResource } from '~/models/collection';
-import { GroupResource } from '~/models/group';
+import { GroupClass, GroupResource } from '~/models/group';
 import { GroupContentsResource } from '~/services/groups-service/groups-service';
 
 export const contextMenuActions = unionize({
@@ -201,19 +201,24 @@ export const openProcessContextMenu = (event: React.MouseEvent<HTMLElement>, pro
         }
     };
 
-export const resourceUuidToContextMenuKind = (uuid: string) =>
+export const resourceUuidToContextMenuKind = (uuid: string, readonly = false) =>
     (dispatch: Dispatch, getState: () => RootState) => {
         const { isAdmin: isAdminUser, uuid: userUuid } = getState().auth.user!;
         const kind = extractUuidKind(uuid);
         const resource = getResourceWithEditableStatus<GroupResource & EditableResource>(uuid, userUuid)(getState().resources);
-        const isEditable = isAdminUser || (resource || {} as EditableResource).isEditable;
+
+        const isEditable = (isAdminUser || (resource || {} as EditableResource).isEditable) && !readonly;
         switch (kind) {
             case ResourceKind.PROJECT:
-                return !isAdminUser
-                    ? isEditable
-                        ? ContextMenuKind.PROJECT
-                        : ContextMenuKind.READONLY_PROJECT
-                    : ContextMenuKind.PROJECT_ADMIN;
+                return (isAdminUser && !readonly)
+                    ? (resource && resource.groupClass !== GroupClass.FILTER)
+                        ? ContextMenuKind.PROJECT_ADMIN
+                        : ContextMenuKind.FILTER_GROUP_ADMIN
+                    : isEditable
+                        ? (resource && resource.groupClass !== GroupClass.FILTER)
+                            ? ContextMenuKind.PROJECT
+                            : ContextMenuKind.FILTER_GROUP
+                        : ContextMenuKind.READONLY_PROJECT;
             case ResourceKind.COLLECTION:
                 const c = getResource<CollectionResource>(uuid)(getState().resources);
                 if (c === undefined) { return; }
@@ -223,15 +228,17 @@ export const resourceUuidToContextMenuKind = (uuid: string) =>
                     ? ContextMenuKind.OLD_VERSION_COLLECTION
                     : (isTrashed && isEditable)
                         ? ContextMenuKind.TRASHED_COLLECTION
-                        : isAdminUser
+                        : (isAdminUser && !readonly)
                             ? ContextMenuKind.COLLECTION_ADMIN
                             : isEditable
                                 ? ContextMenuKind.COLLECTION
                                 : ContextMenuKind.READONLY_COLLECTION;
             case ResourceKind.PROCESS:
-                return !isAdminUser
-                    ? ContextMenuKind.PROCESS_RESOURCE
-                    : ContextMenuKind.PROCESS_ADMIN;
+                return (isAdminUser && !readonly)
+                    ? ContextMenuKind.PROCESS_ADMIN
+                    : readonly
+                        ? ContextMenuKind.READONLY_PROCESS_RESOURCE
+                        : ContextMenuKind.PROCESS_RESOURCE;
             case ResourceKind.USER:
                 return ContextMenuKind.ROOT_PROJECT;
             case ResourceKind.LINK:
diff --git a/src/store/current-token-dialog/current-token-dialog-actions.tsx b/src/store/current-token-dialog/current-token-dialog-actions.tsx
deleted file mode 100644 (file)
index fe8186b..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { dialogActions } from "~/store/dialog/dialog-actions";
-import { getProperty } from '~/store/properties/properties';
-import { propertiesActions } from '~/store/properties/properties-actions';
-import { RootState } from '~/store/store';
-
-export const CURRENT_TOKEN_DIALOG_NAME = 'currentTokenDialog';
-const API_HOST_PROPERTY_NAME = 'apiHost';
-
-export interface CurrentTokenDialogData {
-    currentToken: string;
-    apiHost: string;
-}
-
-export const setCurrentTokenDialogApiHost = (apiHost: string) =>
-    propertiesActions.SET_PROPERTY({ key: API_HOST_PROPERTY_NAME, value: apiHost });
-
-export const getCurrentTokenDialogData = (state: RootState): CurrentTokenDialogData => ({
-    apiHost: getProperty<string>(API_HOST_PROPERTY_NAME)(state.properties) || '',
-    currentToken: state.auth.apiToken || '',
-});
-
-export const openCurrentTokenDialog = dialogActions.OPEN_DIALOG({ id: CURRENT_TOKEN_DIALOG_NAME, data: {} });
index f1576a23bdffdf26c43538ba151b6f3b1b278619..8589c7687efe496142f97ea16334651777ab6d83 100644 (file)
@@ -36,7 +36,7 @@ export class GroupsPanelMiddlewareService extends DataExplorerMiddlewareService
                     order.addOrder(direction, 'name');
                 }
                 const filters = new FilterBuilder()
-                    .addNotIn('group_class', [GroupClass.PROJECT])
+                    .addNotIn('group_class', [GroupClass.PROJECT, GroupClass.FILTER])
                     .addILike('name', dataExplorer.searchValue)
                     .getFilters();
                 const response = await this.services.groupsService
index 2f4d3cad524fd3c04f99d147b28ffc4be31d7070..95d0349f11c3a37987972f23a295004f8a676e38 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { getInitialResourceTypeFilters, serializeResourceTypeFilters, ObjectTypeFilter, CollectionTypeFilter, ProcessTypeFilter } from './resource-type-filters';
+import { getInitialResourceTypeFilters, serializeResourceTypeFilters, ObjectTypeFilter, CollectionTypeFilter, ProcessTypeFilter, GroupTypeFilter } from './resource-type-filters';
 import { ResourceKind } from '~/models/resource';
 import { deselectNode } from '~/models/tree';
 import { pipe } from 'lodash/fp';
@@ -73,4 +73,43 @@ describe("serializeResourceTypeFilters", () => {
         expect(serializedFilters)
             .toEqual(`["uuid","is_a",["${ResourceKind.PROCESS}"]],["container_requests.requesting_container_uuid","!=",null]`);
     });
+
+    it("should serialize all project types", () => {
+        const filters = pipe(
+            () => getInitialResourceTypeFilters(),
+            deselectNode(ObjectTypeFilter.PROCESS),
+            deselectNode(ObjectTypeFilter.COLLECTION),
+        )();
+
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.GROUP}"]]`);
+    });
+
+    it("should serialize filter groups", () => {
+        const filters = pipe(
+            () => getInitialResourceTypeFilters(),
+            deselectNode(GroupTypeFilter.PROJECT)
+            deselectNode(ObjectTypeFilter.PROCESS),
+            deselectNode(ObjectTypeFilter.COLLECTION),
+        )();
+
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.GROUP}"]],["groups.group_class","=","filter"]`);
+    });
+
+    it("should serialize projects (normal)", () => {
+        const filters = pipe(
+            () => getInitialResourceTypeFilters(),
+            deselectNode(GroupTypeFilter.FILTER_GROUP)
+            deselectNode(ObjectTypeFilter.PROCESS),
+            deselectNode(ObjectTypeFilter.COLLECTION),
+        )();
+
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.GROUP}"]],["groups.group_class","=","project"]`);
+    });
+
 });
index ef1198bc65d862554b4b655fa617a902ca9e6584..26db4e9e1c9ed1027adec8c03eb9735feecb42ad 100644 (file)
@@ -25,7 +25,12 @@ export enum ProcessStatusFilter {
 export enum ObjectTypeFilter {
     PROJECT = 'Project',
     PROCESS = 'Process',
-    COLLECTION = 'Data Collection',
+    COLLECTION = 'Data collection',
+}
+
+export enum GroupTypeFilter {
+    PROJECT = 'Project (normal)',
+    FILTER_GROUP = 'Filter group',
 }
 
 export enum CollectionTypeFilter {
@@ -62,7 +67,11 @@ export const getSimpleObjectTypeFilters = pipe(
 // causing compile issues.
 export const getInitialResourceTypeFilters = pipe(
     (): DataTableFilters => createTree<DataTableFilterItem>(),
-    initFilter(ObjectTypeFilter.PROJECT),
+    pipe(
+        initFilter(ObjectTypeFilter.PROJECT),
+        initFilter(GroupTypeFilter.PROJECT, ObjectTypeFilter.PROJECT),
+        initFilter(GroupTypeFilter.FILTER_GROUP, ObjectTypeFilter.PROJECT),
+    ),
     pipe(
         initFilter(ObjectTypeFilter.PROCESS),
         initFilter(ProcessTypeFilter.MAIN_PROCESS, ObjectTypeFilter.PROCESS),
@@ -124,10 +133,14 @@ const objectTypeToResourceKind = (type: ObjectTypeFilter) => {
 };
 
 const serializeObjectTypeFilters = ({ fb, selectedFilters }: ReturnType<typeof createFiltersBuilder>) => {
+    const groupFilters = getMatchingFilters(values(GroupTypeFilter), selectedFilters);
     const collectionFilters = getMatchingFilters(values(CollectionTypeFilter), selectedFilters);
     const processFilters = getMatchingFilters(values(ProcessTypeFilter), selectedFilters);
     const typeFilters = pipe(
         () => new Set(getMatchingFilters(values(ObjectTypeFilter), selectedFilters)),
+        set => groupFilters.length > 0
+            ? set.add(ObjectTypeFilter.PROJECT)
+            : set,
         set => collectionFilters.length > 0
             ? set.add(ObjectTypeFilter.COLLECTION)
             : set,
@@ -182,6 +195,30 @@ const buildCollectionTypeFilters = ({ fb, filters }: { fb: FilterBuilder, filter
     }
 };
 
+const serializeGroupTypeFilters = ({ fb, selectedFilters }: ReturnType<typeof createFiltersBuilder>) => pipe(
+    () => getMatchingFilters(values(GroupTypeFilter), selectedFilters),
+    filters => filters,
+    mappedFilters => ({
+        fb: buildGroupTypeFilters({ fb, filters: mappedFilters, use_prefix: true }),
+        selectedFilters
+    })
+)();
+
+const GROUP_TYPES = values(GroupTypeFilter);
+
+const buildGroupTypeFilters = ({ fb, filters, use_prefix }: { fb: FilterBuilder, filters: string[], use_prefix: boolean }) => {
+    switch (true) {
+        case filters.length === 0 || filters.length === GROUP_TYPES.length:
+            return fb;
+        case includes(GroupTypeFilter.PROJECT, filters):
+            return fb.addEqual('groups.group_class', 'project');
+        case includes(GroupTypeFilter.FILTER_GROUP, filters):
+            return fb.addEqual('groups.group_class', 'filter');
+        default:
+            return fb;
+    }
+};
+
 const serializeProcessTypeFilters = ({ fb, selectedFilters }: ReturnType<typeof createFiltersBuilder>) => pipe(
     () => getMatchingFilters(values(ProcessTypeFilter), selectedFilters),
     filters => filters,
@@ -210,6 +247,7 @@ const buildProcessTypeFilters = ({ fb, filters, use_prefix }: { fb: FilterBuilde
 export const serializeResourceTypeFilters = pipe(
     createFiltersBuilder,
     serializeObjectTypeFilters,
+    serializeGroupTypeFilters,
     serializeCollectionTypeFilters,
     serializeProcessTypeFilters,
     ({ fb }) => fb.getFilters(),
@@ -260,4 +298,4 @@ export const buildProcessStatusFilters = ( fb:FilterBuilder, activeStatusFilter:
         }
     }
     return fb;
-};
\ No newline at end of file
+};
index ff506103db6ce3ecf23e4e3d0fadbd26d8d5385b..05d619270fd2a6a5f1b732172ab4cb00ef5f2f44 100644 (file)
@@ -112,7 +112,7 @@ const loadSharedRoot = async (dispatch: Dispatch, getState: () => RootState, ser
     const params = {
         filters: `[${new FilterBuilder()
             .addIsA('uuid', ResourceKind.PROJECT)
-            .addEqual('group_class', GroupClass.PROJECT)
+            .addIn('group_class', [GroupClass.PROJECT, GroupClass.FILTER])
             .addDistinct('uuid', getState().auth.config.uuidPrefix + '-j7d0g-publicfavorites')
             .getFilters()}]`,
         order: new OrderBuilder<ProjectResource>()
diff --git a/src/store/token-dialog/token-dialog-actions.tsx b/src/store/token-dialog/token-dialog-actions.tsx
new file mode 100644 (file)
index 0000000..c6eb014
--- /dev/null
@@ -0,0 +1,37 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { dialogActions } from "~/store/dialog/dialog-actions";
+import { getProperty } from '~/store/properties/properties';
+import { propertiesActions } from '~/store/properties/properties-actions';
+import { RootState } from '~/store/store';
+
+export const TOKEN_DIALOG_NAME = 'tokenDialog';
+const API_HOST_PROPERTY_NAME = 'apiHost';
+
+export interface TokenDialogData {
+    token: string;
+    tokenExpiration?: Date;
+    apiHost: string;
+    canCreateNewTokens: boolean;
+}
+
+export const setTokenDialogApiHost = (apiHost: string) =>
+    propertiesActions.SET_PROPERTY({ key: API_HOST_PROPERTY_NAME, value: apiHost });
+
+export const getTokenDialogData = (state: RootState): TokenDialogData => {
+    const loginCluster = state.auth.config.clusterConfig.Login.LoginCluster;
+    const canCreateNewTokens = !(loginCluster !== "" && state.auth.homeCluster !== loginCluster);
+
+    return {
+        apiHost: getProperty<string>(API_HOST_PROPERTY_NAME)(state.properties) || '',
+        token: state.auth.extraApiToken || state.auth.apiToken || '',
+        tokenExpiration: state.auth.extraApiToken
+            ? state.auth.extraApiTokenExpiration
+            : state.auth.apiTokenExpiration,
+        canCreateNewTokens,
+    };
+};
+
+export const openTokenDialog = dialogActions.OPEN_DIALOG({ id: TOKEN_DIALOG_NAME, data: {} });
index d11f7527b4e0d7d18a1e6e9d1a0ecbddfae67e35..5d12b419ebe898e2666131e9d5bb85b4adcb98b0 100644 (file)
@@ -21,7 +21,7 @@ import { mapTree } from '../../models/tree';
 import { LinkResource, LinkClass } from "~/models/link";
 import { mapTreeValues } from "~/models/tree";
 import { sortFilesTree } from "~/services/collection-service/collection-service-files-response";
-import { GroupResource } from "~/models/group";
+import { GroupClass, GroupResource } from "~/models/group";
 
 export const treePickerActions = unionize({
     LOAD_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
@@ -101,11 +101,12 @@ interface LoadProjectParams {
     pickerId: string;
     includeCollections?: boolean;
     includeFiles?: boolean;
+    includeFilterGroups?: boolean;
     loadShared?: boolean;
 }
 export const loadProject = (params: LoadProjectParams) =>
     async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => {
-        const { id, pickerId, includeCollections = false, includeFiles = false, loadShared = false } = params;
+        const { id, pickerId, includeCollections = false, includeFiles = false, includeFilterGroups = false, loadShared = false } = params;
 
         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
 
@@ -121,7 +122,12 @@ export const loadProject = (params: LoadProjectParams) =>
         dispatch<any>(receiveTreePickerData<GroupContentsResource>({
             id,
             pickerId,
-            data: items,
+            data: items.filter((item) => {
+                    if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
+                        return false;
+                    }
+                    return true;
+                }),
             extractNodeData: item => ({
                 id: item.uuid,
                 value: item,
index 8f696fa29ad6e0d9e7122b5cbdc82d04a1d93a3f..26b8810c8485a87ec928e24cc63be320dc8d1c55 100644 (file)
@@ -14,6 +14,7 @@ import { UserResource } from "~/models/user";
 import { getResource } from '~/store/resources/resources';
 import { navigateTo, navigateToUsers, navigateToRootProject } from "~/store/navigation/navigation-action";
 import { authActions } from '~/store/auth/auth-action';
+import { getTokenV2 } from "~/models/api-client-authorization";
 
 export const USERS_PANEL_ID = 'usersPanel';
 export const USER_ATTRIBUTES_DIALOG = 'userAttributesDialog';
@@ -62,7 +63,7 @@ export const loginAs = (uuid: string) =>
         const data = getResource<UserResource>(uuid)(resources);
         const client = await services.apiClientAuthorizationService.create({ ownerUuid: uuid });
         if (data) {
-            dispatch<any>(authActions.INIT_USER({ user: data, token: `v2/${client.uuid}/${client.apiToken}` }));
+            dispatch<any>(authActions.INIT_USER({ user: data, token: getTokenV2(client) }));
             location.reload();
             dispatch<any>(navigateToRootProject);
         }
index f8daa764f86d6068a73c782ce26514c1cd8f9025..4949672437b9530cb36ca6e9cd23058812721b97 100644 (file)
@@ -5,7 +5,7 @@
 import * as React from 'react';
 import { configure, mount } from "enzyme";
 import * as Adapter from 'enzyme-adapter-react-16';
-import { AutoLogoutComponent, AutoLogoutProps } from './auto-logout';
+import { AutoLogoutComponent, AutoLogoutProps, LAST_ACTIVE_TIMESTAMP } from './auto-logout';
 
 configure({ adapter: new Adapter() });
 
@@ -13,8 +13,15 @@ describe('<AutoLogoutComponent />', () => {
     let props: AutoLogoutProps;
     const sessionIdleTimeout = 300;
     const lastWarningDuration = 60;
+    const eventListeners = {};
     jest.useFakeTimers();
 
+    beforeAll(() => {
+        window.addEventListener = jest.fn((event, cb) => {
+            eventListeners[event] = cb;
+        });
+    });
+
     beforeEach(() => {
         props = {
             sessionIdleTimeout: sessionIdleTimeout,
@@ -39,4 +46,17 @@ describe('<AutoLogoutComponent />', () => {
         jest.runTimersToTime(1*1000);
         expect(props.doWarn).toBeCalled();
     });
+
+    it('should reset the idle timer when activity event is received', () => {
+        jest.runTimersToTime((sessionIdleTimeout-lastWarningDuration-1)*1000);
+        expect(props.doWarn).not.toBeCalled();
+        // Simulate activity from other window/tab
+        eventListeners.storage({
+            key: LAST_ACTIVE_TIMESTAMP,
+            newValue: '42' // value currently doesn't matter
+        })
+        jest.runTimersToTime(1*1000);
+        // Warning should not appear because idle timer was reset
+        expect(props.doWarn).not.toBeCalled();
+    });
 });
\ No newline at end of file
index f1464ce18497f3476017ec2075f430afb3bf339d..f7e6f4b838d0082feff5c2bc8d9ff0f4d14c61cb 100644 (file)
@@ -24,12 +24,10 @@ interface AutoLogoutActionProps {
     doCloseWarn: () => void;
 }
 
-const mapStateToProps = (state: RootState, ownProps: any): AutoLogoutDataProps => {
-    return {
-        sessionIdleTimeout: parse(state.auth.config.clusterConfig.Workbench.IdleTimeout, 's') || 0,
-        lastWarningDuration: ownProps.lastWarningDuration || 60,
-    };
-};
+const mapStateToProps = (state: RootState, ownProps: any): AutoLogoutDataProps => ({
+    sessionIdleTimeout: parse(state.auth.config.clusterConfig.Workbench.IdleTimeout, 's') || 0,
+    lastWarningDuration: ownProps.lastWarningDuration || 60,
+});
 
 const mapDispatchToProps = (dispatch: Dispatch): AutoLogoutActionProps => ({
     doLogout: () => dispatch<any>(logout(true)),
@@ -41,9 +39,41 @@ const mapDispatchToProps = (dispatch: Dispatch): AutoLogoutActionProps => ({
 
 export type AutoLogoutProps = AutoLogoutDataProps & AutoLogoutActionProps;
 
+const debounce = (delay: number | undefined, fn: Function) => {
+    let timerId: number | null;
+    return (...args: any[]) => {
+        if (timerId) { clearTimeout(timerId); }
+        timerId = setTimeout(() => {
+            fn(...args);
+            timerId = null;
+        }, delay);
+    };
+};
+
+export const LAST_ACTIVE_TIMESTAMP = 'lastActiveTimestamp';
+
 export const AutoLogoutComponent = (props: AutoLogoutProps) => {
     let logoutTimer: NodeJS.Timer;
-    const lastWarningDuration = min([props.lastWarningDuration, props.sessionIdleTimeout])! * 1000 ;
+    const lastWarningDuration = min([props.lastWarningDuration, props.sessionIdleTimeout])! * 1000;
+
+    // Runs once after render
+    React.useEffect(() => {
+        window.addEventListener('storage', handleStorageEvents);
+        // Component cleanup
+        return () => {
+            window.removeEventListener('storage', handleStorageEvents);
+        };
+    }, []);
+
+    const handleStorageEvents = (e: StorageEvent) => {
+        if (e.key === LAST_ACTIVE_TIMESTAMP) {
+            // Other tab activity detected by a localStorage change event.
+            debounce(500, () => {
+                handleOnActive();
+                reset();
+            })();
+        }
+    };
 
     const handleOnIdle = () => {
         logoutTimer = setTimeout(
@@ -54,16 +84,23 @@ export const AutoLogoutComponent = (props: AutoLogoutProps) => {
     };
 
     const handleOnActive = () => {
-        clearTimeout(logoutTimer);
+        if (logoutTimer) { clearTimeout(logoutTimer); }
         props.doCloseWarn();
     };
 
-    useIdleTimer({
+    const handleOnAction = () => {
+        // Notify the other tabs there was some activity.
+        const now = (new Date).getTime();
+        localStorage.setItem(LAST_ACTIVE_TIMESTAMP, now.toString());
+    };
+
+    const { reset } = useIdleTimer({
         timeout: (props.lastWarningDuration < props.sessionIdleTimeout)
             ? 1000 * (props.sessionIdleTimeout - props.lastWarningDuration)
             : 1,
         onIdle: handleOnIdle,
         onActive: handleOnActive,
+        onAction: handleOnAction,
         debounce: 500
     });
 
index 8cab9bfd5171b39f1171def4376cfa2e9dd15df5..73a65a2d417f6006050f9e82939c73418367a27c 100644 (file)
@@ -14,21 +14,7 @@ import { openSharingDialog } from "~/store/sharing-dialog/sharing-dialog-actions
 import { openRemoveProcessDialog } from "~/store/processes/processes-actions";
 import { toggleDetailsPanel } from '~/store/details-panel/details-panel-action';
 
-export const processResourceActionSet: ContextMenuActionSet = [[
-    {
-        icon: RenameIcon,
-        name: "Edit process",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openProcessUpdateDialog(resource));
-        }
-    },
-    {
-        icon: ShareIcon,
-        name: "Share",
-        execute: (dispatch, { uuid }) => {
-            dispatch<any>(openSharingDialog(uuid));
-        }
-    },
+export const readOnlyProcessResourceActionSet: ContextMenuActionSet = [[
     {
         component: ToggleFavoriteAction,
         execute: (dispatch, resource) => {
@@ -37,13 +23,6 @@ export const processResourceActionSet: ContextMenuActionSet = [[
             });
         }
     },
-    {
-        icon: MoveToIcon,
-        name: "Move to",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openMoveProcessDialog(resource));
-        }
-    },
     {
         icon: CopyIcon,
         name: "Copy to project",
@@ -58,6 +37,31 @@ export const processResourceActionSet: ContextMenuActionSet = [[
             dispatch<any>(toggleDetailsPanel());
         }
     },
+]];
+
+export const processResourceActionSet: ContextMenuActionSet = [[
+    ...readOnlyProcessResourceActionSet.reduce((prev, next) => prev.concat(next), []),
+    {
+        icon: RenameIcon,
+        name: "Edit process",
+        execute: (dispatch, resource) => {
+            dispatch<any>(openProcessUpdateDialog(resource));
+        }
+    },
+    {
+        icon: ShareIcon,
+        name: "Share",
+        execute: (dispatch, { uuid }) => {
+            dispatch<any>(openSharingDialog(uuid));
+        }
+    },
+    {
+        icon: MoveToIcon,
+        name: "Move to",
+        execute: (dispatch, resource) => {
+            dispatch<any>(openMoveProcessDialog(resource));
+        }
+    },
     {
         name: "Remove",
         icon: RemoveIcon,
index fd328221a8a9ffca94e818b26f70cbdc8dc6d56a..1932194ca4b0c694a7dff83b964d76753ef7ae55 100644 (file)
@@ -2,11 +2,12 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { projectActionSet, readOnlyProjectActionSet } from "./project-action-set";
+import { filterGroupActionSet, projectActionSet, readOnlyProjectActionSet } from "./project-action-set";
 
 describe('project-action-set', () => {
     const flattProjectActionSet = projectActionSet.reduce((prev, next) => prev.concat(next), []);
     const flattReadOnlyProjectActionSet = readOnlyProjectActionSet.reduce((prev, next) => prev.concat(next), []);
+    const flattFilterGroupActionSet = filterGroupActionSet.reduce((prev, next) => prev.concat(next), []);
 
     describe('projectActionSet', () => {
         it('should not be empty', () => {
@@ -33,4 +34,17 @@ describe('project-action-set', () => {
                 .not.toEqual(expect.arrayContaining(flattProjectActionSet));
         })
     });
-});
\ No newline at end of file
+
+    describe('filterGroupActionSet', () => {
+        it('should not be empty', () => {
+            // then
+            expect(flattFilterGroupActionSet.length).toBeGreaterThan(0);
+        });
+
+        it('should not contain projectActionSet items', () => {
+            // then
+            expect(flattFilterGroupActionSet)
+                .not.toEqual(expect.arrayContaining(flattProjectActionSet));
+        })
+    });
+});
index 57ba0ea3f1fcbcf98c9a763878ff6655a8a77a57..800f57d9f5ff13874d07b918c491a1ece4250c38 100644 (file)
@@ -66,16 +66,9 @@ export const readOnlyProjectActionSet: ContextMenuActionSet = [[
     },
 ]];
 
-export const projectActionSet: ContextMenuActionSet = [
+export const filterGroupActionSet: ContextMenuActionSet = [
     [
         ...readOnlyProjectActionSet.reduce((prev, next) => prev.concat(next), []),
-        {
-            icon: NewProjectIcon,
-            name: "New project",
-            execute: (dispatch, resource) => {
-                dispatch<any>(openProjectCreateDialog(resource.uuid));
-            }
-        },
         {
             icon: RenameIcon,
             name: "Edit project",
@@ -106,3 +99,16 @@ export const projectActionSet: ContextMenuActionSet = [
         },
     ]
 ];
+
+export const projectActionSet: ContextMenuActionSet = [
+    [
+        ...filterGroupActionSet.reduce((prev, next) => prev.concat(next), []),
+        {
+            icon: NewProjectIcon,
+            name: "New project",
+            execute: (dispatch, resource) => {
+                dispatch<any>(openProjectCreateDialog(resource.uuid));
+            }
+        },
+    ]
+];
index a3a8ce79e9ffd897c91dcb3751660abff71d4d54..982a78832740f942e89b377a879558b28ff096ac 100644 (file)
@@ -7,7 +7,7 @@ import { TogglePublicFavoriteAction } from "~/views-components/context-menu/acti
 import { togglePublicFavorite } from "~/store/public-favorites/public-favorites-actions";
 import { publicFavoritePanelActions } from "~/store/public-favorites-panel/public-favorites-action";
 
-import { projectActionSet } from "~/views-components/context-menu/action-sets/project-action-set";
+import { projectActionSet, filterGroupActionSet } from "~/views-components/context-menu/action-sets/project-action-set";
 
 export const projectAdminActionSet: ContextMenuActionSet = [[
     ...projectActionSet.reduce((prev, next) => prev.concat(next), []),
@@ -21,3 +21,16 @@ export const projectAdminActionSet: ContextMenuActionSet = [[
         }
     }
 ]];
+
+export const filterGroupAdminActionSet: ContextMenuActionSet = [[
+    ...filterGroupActionSet.reduce((prev, next) => prev.concat(next), []),
+    {
+        component: TogglePublicFavoriteAction,
+        name: 'TogglePublicFavoriteAction',
+        execute: (dispatch, resource) => {
+            dispatch<any>(togglePublicFavorite(resource)).then(() => {
+                dispatch<any>(publicFavoritePanelActions.REQUEST_ITEMS());
+            });
+        }
+    }
+]];
index 219913cdd13ce4a2549779159a8ab8f62a4be9c7..ee87d71a37d84da65a8f0594cf1cfe3130a97fc3 100644 (file)
@@ -67,8 +67,10 @@ export enum ContextMenuKind {
     API_CLIENT_AUTHORIZATION = "ApiClientAuthorization",
     ROOT_PROJECT = "RootProject",
     PROJECT = "Project",
+    FILTER_GROUP = "FilterGroup",
     READONLY_PROJECT = 'ReadOnlyProject',
     PROJECT_ADMIN = "ProjectAdmin",
+    FILTER_GROUP_ADMIN = "FilterGroupAdmin",
     RESOURCE = "Resource",
     FAVORITE = "Favorite",
     TRASH = "Trash",
@@ -85,6 +87,7 @@ export enum ContextMenuKind {
     PROCESS = "Process",
     PROCESS_ADMIN = 'ProcessAdmin',
     PROCESS_RESOURCE = 'ProcessResource',
+    READONLY_PROCESS_RESOURCE = 'ReadOnlyProcessResource',
     PROCESS_LOGS = "ProcessLogs",
     REPOSITORY = "Repository",
     SSH_KEY = "SshKey",
diff --git a/src/views-components/current-token-dialog/current-token-dialog.test.tsx b/src/views-components/current-token-dialog/current-token-dialog.test.tsx
deleted file mode 100644 (file)
index eb405e9..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import * as React from 'react';
-import { Button } from '@material-ui/core';
-import { mount, configure } from 'enzyme';
-import * as Adapter from 'enzyme-adapter-react-16';
-import * as CopyToClipboard from 'react-copy-to-clipboard';
-import { CurrentTokenDialogComponent } from './current-token-dialog';
-
-configure({ adapter: new Adapter() });
-
-jest.mock('toggle-selection', () => () => () => null);
-
-describe('<CurrentTokenDialog />', () => {
-  let props;
-  let wrapper;
-
-  beforeEach(() => {
-    props = {
-      classes: {},
-      data: {
-        currentToken: '123123123123',
-      },
-      open: true,
-      dispatch: jest.fn(),
-    };
-  });
-
-  describe('copy to clipboard', () => {
-    beforeEach(() => {
-      wrapper = mount(<CurrentTokenDialogComponent {...props} />);
-    });
-
-    it('should copy API TOKEN to the clipboard', () => {
-      // when
-      wrapper.find(CopyToClipboard).find(Button).simulate('click');
-
-      // and
-      expect(props.dispatch).toHaveBeenCalledWith({
-        payload: {
-          hideDuration: 2000,
-          kind: 1,
-          message: 'Token copied to clipboard',
-        },
-        type: 'OPEN_SNACKBAR',
-      });
-    });
-  });
-});
diff --git a/src/views-components/current-token-dialog/current-token-dialog.tsx b/src/views-components/current-token-dialog/current-token-dialog.tsx
deleted file mode 100644 (file)
index 9cb08f8..0000000
+++ /dev/null
@@ -1,108 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import * as React from 'react';
-import { Dialog, DialogActions, DialogTitle, DialogContent, WithStyles, withStyles, StyleRulesCallback, Button, Typography } from '@material-ui/core';
-import * as CopyToClipboard from 'react-copy-to-clipboard';
-import { ArvadosTheme } from '~/common/custom-theme';
-import { withDialog } from '~/store/dialog/with-dialog';
-import { WithDialogProps } from '~/store/dialog/with-dialog';
-import { connect, DispatchProp } from 'react-redux';
-import { CurrentTokenDialogData, getCurrentTokenDialogData, CURRENT_TOKEN_DIALOG_NAME } from '~/store/current-token-dialog/current-token-dialog-actions';
-import { DefaultCodeSnippet } from '~/components/default-code-snippet/default-code-snippet';
-import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
-
-type CssRules = 'link' | 'paper' | 'button' | 'copyButton';
-
-const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
-    link: {
-        color: theme.palette.primary.main,
-        textDecoration: 'none',
-        margin: '0px 4px'
-    },
-    paper: {
-        padding: theme.spacing.unit,
-        marginBottom: theme.spacing.unit * 2,
-        backgroundColor: theme.palette.grey["200"],
-        border: `1px solid ${theme.palette.grey["300"]}`
-    },
-    button: {
-        fontSize: '0.8125rem',
-        fontWeight: 600
-    },
-    copyButton: {
-        boxShadow: 'none',
-        marginTop: theme.spacing.unit * 2,
-        marginBottom: theme.spacing.unit * 2,
-    }
-});
-
-type CurrentTokenProps = CurrentTokenDialogData & WithDialogProps<{}> & WithStyles<CssRules> & DispatchProp;
-
-export class CurrentTokenDialogComponent extends React.Component<CurrentTokenProps> {
-    onCopy = (message: string) => {
-        this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
-            message,
-            hideDuration: 2000,
-            kind: SnackbarKind.SUCCESS
-        }));
-    }
-
-    getSnippet = ({ apiHost, currentToken }: CurrentTokenDialogData) =>
-        `HISTIGNORE=$HISTIGNORE:'export ARVADOS_API_TOKEN=*'
-export ARVADOS_API_TOKEN=${currentToken}
-export ARVADOS_API_HOST=${apiHost}
-unset ARVADOS_API_HOST_INSECURE`
-
-    render() {
-        const { classes, open, closeDialog, ...data } = this.props;
-
-        return <Dialog
-            open={open}
-            onClose={closeDialog}
-            fullWidth={true}
-            maxWidth='md'>
-            <DialogTitle>Current Token</DialogTitle>
-            <DialogContent>
-                <Typography paragraph={true}>
-                    The Arvados API token is a secret key that enables the Arvados SDKs to access Arvados with the proper permissions.
-                        <Typography component='span'>
-                            For more information see
-                            <a href='http://doc.arvados.org/user/reference/api-tokens.html' target='blank' className={classes.link}>
-                                Getting an API token.
-                            </a>
-                        </Typography>
-                </Typography>
-                <Typography paragraph={true}>
-                    Paste the following lines at a shell prompt to set up the necessary environment for Arvados SDKs to authenticate to your account.
-                </Typography>
-                <DefaultCodeSnippet lines={[this.getSnippet(data)]} />
-                <CopyToClipboard text={this.getSnippet(data)} onCopy={() => this.onCopy('Token copied to clipboard')}>
-                    <Button
-                        color="primary"
-                        size="small"
-                        variant="contained"
-                        className={classes.copyButton}
-                    >
-                        COPY TO CLIPBOARD
-                    </Button>
-                </CopyToClipboard>
-                <Typography >
-                    Arvados
-                            <a href='http://doc.arvados.org/user/reference/api-tokens.html' target='blank' className={classes.link}>virtual machines</a>
-                    do this for you automatically. This setup is needed only when you use the API remotely (e.g., from your own workstation).
-                </Typography>
-            </DialogContent>
-            <DialogActions>
-                <Button onClick={closeDialog} className={classes.button} color="primary">CLOSE</Button>
-            </DialogActions>
-        </Dialog>;
-    }
-}
-
-export const CurrentTokenDialog =
-    withStyles(styles)(
-        connect(getCurrentTokenDialogData)(
-            withDialog(CURRENT_TOKEN_DIALOG_NAME)(CurrentTokenDialogComponent)));
-
index 6cf29faecf444683540de031966273298fc19314..93abb15e237ddc73424271f26762cbab3d6471bb 100644 (file)
@@ -6,7 +6,7 @@ import * as React from 'react';
 import { Grid, Typography, withStyles, Tooltip, IconButton, Checkbox } from '@material-ui/core';
 import { FavoriteStar, PublicFavoriteStar } from '../favorite-star/favorite-star';
 import { ResourceKind, TrashableResource } from '~/models/resource';
-import { ProjectIcon, CollectionIcon, ProcessIcon, DefaultIcon, ShareIcon, CollectionOldVersionIcon, WorkflowIcon } from '~/components/icon/icon';
+import { ProjectIcon, FilterGroupIcon, CollectionIcon, ProcessIcon, DefaultIcon, ShareIcon, CollectionOldVersionIcon, WorkflowIcon } from '~/components/icon/icon';
 import { formatDate, formatFileSize, formatTime } from '~/common/formatters';
 import { resourceLabel } from '~/common/labels';
 import { connect, DispatchProp } from 'react-redux';
@@ -28,6 +28,7 @@ import { withResourceData } from '~/views-components/data-explorer/with-resource
 import { CollectionResource } from '~/models/collection';
 import { IllegalNamingWarning } from '~/components/warning/warning';
 import { loadResource } from '~/store/resources/resources-actions';
+import { GroupClass } from '~/models/group';
 
 const renderName = (dispatch: Dispatch, item: GroupContentsResource) =>
     <Grid container alignItems="center" wrap="nowrap" spacing={16}>
@@ -59,6 +60,9 @@ export const ResourceName = connect(
 const renderIcon = (item: GroupContentsResource) => {
     switch (item.kind) {
         case ResourceKind.PROJECT:
+            if (item.groupClass === GroupClass.FILTER) {
+                return <FilterGroupIcon />;
+            }
             return <ProjectIcon />;
         case ResourceKind.COLLECTION:
             if (item.uuid === item.currentVersionUuid) {
@@ -464,16 +468,16 @@ export const ResourceOwnerWithName =
             </Typography>;
         });
 
-const renderType = (type: string) =>
+const renderType = (type: string, subtype: string) =>
     <Typography noWrap>
-        {resourceLabel(type)}
+        {resourceLabel(type, subtype)}
     </Typography>;
 
 export const ResourceType = connect(
     (state: RootState, props: { uuid: string }) => {
         const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
-        return { type: resource ? resource.kind : '' };
-    })((props: { type: string }) => renderType(props.type));
+        return { type: resource ? resource.kind : '', subtype: resource && resource.kind === ResourceKind.GROUP ? resource.groupClass : '' };
+    })((props: { type: string, subtype: string }) => renderType(props.type, props.subtype));
 
 export const ResourceStatus = connect((state: RootState, props: { uuid: string }) => {
     return { resource: getResource<GroupContentsResource>(props.uuid)(state.resources) };
index 61797373b69a51ddb8dcd8039cf5180610ed5749..3feb4a7eb6e35d0a78d0e417301d2775e68d1672 100644 (file)
@@ -5,7 +5,7 @@
 import * as React from 'react';
 import { connect } from 'react-redux';
 import { openProjectPropertiesDialog } from '~/store/details-panel/details-panel-action';
-import { ProjectIcon, RenameIcon } from '~/components/icon/icon';
+import { ProjectIcon, RenameIcon, FilterGroupIcon } from '~/components/icon/icon';
 import { ProjectResource } from '~/models/project';
 import { formatDate } from '~/common/formatters';
 import { ResourceKind } from '~/models/resource';
@@ -18,9 +18,13 @@ import { ArvadosTheme } from '~/common/custom-theme';
 import { Dispatch } from 'redux';
 import { getPropertyChip } from '../resource-properties-form/property-chip';
 import { ResourceOwnerWithName } from '../data-explorer/renderers';
+import { GroupClass } from "~/models/group";
 
 export class ProjectDetails extends DetailsData<ProjectResource> {
     getIcon(className?: string) {
+        if (this.item.groupClass === GroupClass.FILTER) {
+            return <FilterGroupIcon className={className} />;
+        }
         return <ProjectIcon className={className} />;
     }
 
@@ -59,12 +63,12 @@ type ProjectDetailsComponentProps = ProjectDetailsComponentDataProps & ProjectDe
 const ProjectDetailsComponent = connect(null, mapDispatchToProps)(
     withStyles(styles)(
         ({ classes, project, onClick }: ProjectDetailsComponentProps) => <div>
-            <DetailsAttribute label='Type' value={resourceLabel(ResourceKind.PROJECT)} />
+            <DetailsAttribute label='Type' value={project.groupClass === GroupClass.FILTER ? 'Filter group' : resourceLabel(ResourceKind.PROJECT)} />
             <DetailsAttribute label='Owner' linkToUuid={project.ownerUuid}
                 uuidEnhancer={(uuid: string) => <ResourceOwnerWithName uuid={uuid} />} />
             <DetailsAttribute label='Last modified' value={formatDate(project.modifiedAt)} />
             <DetailsAttribute label='Created at' value={formatDate(project.createdAt)} />
-            <DetailsAttribute label='Project UUID' linkToUuid={project.uuid} value={project.uuid} />
+            <DetailsAttribute label='UUID' linkToUuid={project.uuid} value={project.uuid} />
             <DetailsAttribute label='Description'>
                 {project.description ?
                     <RichTextEditorLink
@@ -75,9 +79,12 @@ const ProjectDetailsComponent = connect(null, mapDispatchToProps)(
                 }
             </DetailsAttribute>
             <DetailsAttribute label='Properties'>
-                <div onClick={onClick}>
-                    <RenameIcon className={classes.editIcon} />
-                </div>
+                {project.groupClass !== GroupClass.FILTER ?
+                    <div onClick={onClick}>
+                        <RenameIcon className={classes.editIcon} />
+                    </div>
+                    : ''
+                }
             </DetailsAttribute>
             {
                 Object.keys(project.properties).map(k =>
index 6e844cc8e2337001deaa0495eee9d15800de9082..ea3a2dd932409efce0b9ea3849180705dbcd4026 100644 (file)
@@ -9,9 +9,9 @@ import { User, getUserDisplayName } from "~/models/user";
 import { DropdownMenu } from "~/components/dropdown-menu/dropdown-menu";
 import { UserPanelIcon } from "~/components/icon/icon";
 import { DispatchProp, connect } from 'react-redux';
-import { authActions } from '~/store/auth/auth-action';
+import { authActions, getNewExtraToken } from '~/store/auth/auth-action';
 import { RootState } from "~/store/store";
-import { openCurrentTokenDialog } from '~/store/current-token-dialog/current-token-dialog-actions';
+import { openTokenDialog } from '~/store/token-dialog/token-dialog-actions';
 import { openRepositoriesPanel } from "~/store/repositories/repositories-actions";
 import {
     navigateToSiteManager,
@@ -70,7 +70,10 @@ export const AccountMenuComponent =
             {user.isActive ? <>
                 <MenuItem onClick={() => dispatch(openUserVirtualMachines())}>Virtual Machines</MenuItem>
                 {!user.isAdmin && <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>}
-                <MenuItem onClick={() => dispatch(openCurrentTokenDialog)}>Current token</MenuItem>
+                <MenuItem onClick={() => {
+                    dispatch<any>(getNewExtraToken(true));
+                    dispatch(openTokenDialog);
+                }}>Get API token</MenuItem>
                 <MenuItem onClick={() => dispatch(navigateToSshKeysUser)}>Ssh Keys</MenuItem>
                 <MenuItem onClick={() => dispatch(navigateToSiteManager)}>Site Manager</MenuItem>
                 <MenuItem onClick={() => dispatch(navigateToMyAccount)}>My account</MenuItem>
index ea3775e93ad652a8463b95b3e28656e74a8f03de..0a61926e21ddc581fba0ed662f237fc2d3fd971a 100644 (file)
@@ -134,7 +134,7 @@ export const ParticipantSelect = connect()(
             const userItems: ListResults<any> = await userService.list({ filters: filterUsers, limit, count: "none" });
 
             const filterGroups = new FilterBuilder()
-                .addNotIn('group_class', [GroupClass.PROJECT])
+                .addNotIn('group_class', [GroupClass.PROJECT, GroupClass.FILTER])
                 .addILike('name', value)
                 .getFilters();
 
index 3ca2f0d66e95d4cc552c54a70ca27f4644063d96..bf03bf6cb0e9f2b7bcf7be2872debd42fba5363e 100644 (file)
@@ -15,7 +15,7 @@ import { navigateToRunProcess } from '~/store/navigation/navigation-action';
 import { runProcessPanelActions } from '~/store/run-process-panel/run-process-panel-actions';
 import { getUserUuid } from '~/common/getuser';
 import { matchProjectRoute } from '~/routes/routes';
-import { GroupResource } from '~/models/group';
+import { GroupClass, GroupResource } from '~/models/group';
 import { ResourcesState, getResource } from '~/store/resources/resources';
 import { extractUuidKind, ResourceKind } from '~/models/resource';
 
@@ -87,7 +87,8 @@ export const SidePanelButton = withStyles(styles)(
                     const currentProject = getResource<GroupResource>(currentItemId)(resources);
                     if (currentProject &&
                         currentProject.writableBy.indexOf(currentUserUUID || '') >= 0 &&
-                        !isProjectTrashed(currentProject, resources)) {
+                        !isProjectTrashed(currentProject, resources) &&
+                        currentProject.groupClass !== GroupClass.FILTER) {
                         enabled = true;
                     }
                 }
@@ -150,4 +151,4 @@ export const SidePanelButton = withStyles(styles)(
             }
         }
     )
-);
\ No newline at end of file
+);
index e0d9777da006aa317f9684eba4492ffd5934ff48..4c6f01a146c910885de60244924529aaed575f59 100644 (file)
@@ -9,13 +9,15 @@ import { TreePicker, TreePickerProps } from "../tree-picker/tree-picker";
 import { TreeItem } from "~/components/tree/tree";
 import { ProjectResource } from "~/models/project";
 import { ListItemTextIcon } from "~/components/list-item-text-icon/list-item-text-icon";
-import { ProcessIcon, ProjectIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon, TrashIcon, PublicFavoriteIcon } from '~/components/icon/icon';
+import { ProcessIcon, ProjectIcon, FilterGroupIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon, TrashIcon, PublicFavoriteIcon } from '~/components/icon/icon';
 import { WorkflowIcon } from '~/components/icon/icon';
 import { activateSidePanelTreeItem, toggleSidePanelTreeItemCollapse, SIDE_PANEL_TREE, SidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions';
 import { openSidePanelContextMenu } from '~/store/context-menu/context-menu-actions';
 import { noop } from 'lodash';
 import { ResourceKind } from "~/models/resource";
 import { IllegalNamingWarning } from "~/components/warning/warning";
+import { GroupClass } from "~/models/group";
+
 export interface SidePanelTreeProps {
     onItemActivation: (id: string) => void;
     sidePanelProgress?: boolean;
@@ -58,7 +60,9 @@ const renderSidePanelItem = (item: TreeItem<ProjectResource>) => {
 const getProjectPickerIcon = (item: TreeItem<ProjectResource | string>) =>
     typeof item.data === 'string'
         ? getSidePanelIcon(item.data)
-        : ProjectIcon;
+        : (item.data && item.data.groupClass === GroupClass.FILTER)
+            ? FilterGroupIcon
+            : ProjectIcon;
 
 const getSidePanelIcon = (category: string) => {
     switch (category) {
diff --git a/src/views-components/token-dialog/token-dialog.test.tsx b/src/views-components/token-dialog/token-dialog.test.tsx
new file mode 100644 (file)
index 0000000..d954861
--- /dev/null
@@ -0,0 +1,80 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Button } from '@material-ui/core';
+import { mount, configure } from 'enzyme';
+import * as Adapter from 'enzyme-adapter-react-16';
+import * as CopyToClipboard from 'react-copy-to-clipboard';
+import { TokenDialogComponent } from './token-dialog';
+
+configure({ adapter: new Adapter() });
+
+jest.mock('toggle-selection', () => () => () => null);
+
+describe('<CurrentTokenDialog />', () => {
+  let props;
+  let wrapper;
+
+  beforeEach(() => {
+    props = {
+      classes: {},
+      token: 'xxxtokenxxx',
+      apiHost: 'example.com',
+      open: true,
+      dispatch: jest.fn(),
+    };
+  });
+
+  describe('Get API Token dialog', () => {
+    beforeEach(() => {
+      wrapper = mount(<TokenDialogComponent {...props} />);
+    });
+
+    it('should include API host and token', () => {
+      expect(wrapper.html()).toContain('export ARVADOS_API_HOST=example.com');
+      expect(wrapper.html()).toContain('export ARVADOS_API_TOKEN=xxxtokenxxx');
+    });
+
+    it('should show the token expiration if present', () => {
+      expect(props.tokenExpiration).toBeUndefined();
+      expect(wrapper.html()).toContain('This token does not have an expiration date');
+
+      const someDate = '2140-01-01T00:00:00.000Z'
+      props.tokenExpiration = new Date(someDate);
+      wrapper = mount(<TokenDialogComponent {...props} />);
+      expect(wrapper.html()).toContain(props.tokenExpiration.toLocaleString());
+    });
+
+    it('should show a create new token button when allowed', () => {
+      expect(props.canCreateNewTokens).toBeFalsy();
+      expect(wrapper.html()).not.toContain('GET NEW TOKEN');
+
+      props.canCreateNewTokens = true;
+      wrapper = mount(<TokenDialogComponent {...props} />);
+      expect(wrapper.html()).toContain('GET NEW TOKEN');
+    });
+  });
+
+  describe('copy to clipboard button', () => {
+    beforeEach(() => {
+      wrapper = mount(<TokenDialogComponent {...props} />);
+    });
+
+    it('should copy API TOKEN to the clipboard', () => {
+      // when
+      wrapper.find(CopyToClipboard).find(Button).simulate('click');
+
+      // and
+      expect(props.dispatch).toHaveBeenCalledWith({
+        payload: {
+          hideDuration: 2000,
+          kind: 1,
+          message: 'Shell code block copied',
+        },
+        type: 'OPEN_SNACKBAR',
+      });
+    });
+  });
+});
diff --git a/src/views-components/token-dialog/token-dialog.tsx b/src/views-components/token-dialog/token-dialog.tsx
new file mode 100644 (file)
index 0000000..1aa3dd0
--- /dev/null
@@ -0,0 +1,162 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import {
+    Dialog,
+    DialogActions,
+    DialogTitle,
+    DialogContent,
+    WithStyles,
+    withStyles,
+    StyleRulesCallback,
+    Button,
+    Typography
+} from '@material-ui/core';
+import * as CopyToClipboard from 'react-copy-to-clipboard';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { withDialog } from '~/store/dialog/with-dialog';
+import { WithDialogProps } from '~/store/dialog/with-dialog';
+import { connect, DispatchProp } from 'react-redux';
+import {
+    TokenDialogData,
+    getTokenDialogData,
+    TOKEN_DIALOG_NAME,
+} from '~/store/token-dialog/token-dialog-actions';
+import { DefaultCodeSnippet } from '~/components/default-code-snippet/default-code-snippet';
+import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
+import { getNewExtraToken } from '~/store/auth/auth-action';
+import { DetailsAttributeComponent } from '~/components/details-attribute/details-attribute';
+
+type CssRules = 'link' | 'paper' | 'button' | 'actionButton' | 'codeBlock';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    link: {
+        color: theme.palette.primary.main,
+        textDecoration: 'none',
+        margin: '0px 4px'
+    },
+    paper: {
+        padding: theme.spacing.unit,
+        marginBottom: theme.spacing.unit * 2,
+        backgroundColor: theme.palette.grey["200"],
+        border: `1px solid ${theme.palette.grey["300"]}`
+    },
+    button: {
+        fontSize: '0.8125rem',
+        fontWeight: 600
+    },
+    actionButton: {
+        boxShadow: 'none',
+        marginTop: theme.spacing.unit * 2,
+        marginBottom: theme.spacing.unit * 2,
+        marginRight: theme.spacing.unit * 2,
+    },
+    codeBlock: {
+        fontSize: '0.8125rem',
+    },
+});
+
+type TokenDialogProps = TokenDialogData & WithDialogProps<{}> & WithStyles<CssRules> & DispatchProp;
+
+export class TokenDialogComponent extends React.Component<TokenDialogProps> {
+    onCopy = (message: string) => {
+        this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+            message,
+            hideDuration: 2000,
+            kind: SnackbarKind.SUCCESS
+        }));
+    }
+
+    onGetNewToken = async () => {
+        const newToken = await this.props.dispatch<any>(getNewExtraToken());
+        if (newToken) {
+            this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: 'New token retrieved',
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS
+            }));
+        } else {
+            this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: 'Creating new tokens is not allowed',
+                hideDuration: 2000,
+                kind: SnackbarKind.WARNING
+            }));
+        }
+    }
+
+    getSnippet = ({ apiHost, token }: TokenDialogData) =>
+        `HISTIGNORE=$HISTIGNORE:'export ARVADOS_API_TOKEN=*'
+export ARVADOS_API_TOKEN=${token}
+export ARVADOS_API_HOST=${apiHost}
+unset ARVADOS_API_HOST_INSECURE`
+
+    render() {
+        const { classes, open, closeDialog, ...data } = this.props;
+        const tokenExpiration = data.tokenExpiration
+            ? data.tokenExpiration.toLocaleString()
+            : `This token does not have an expiration date`;
+
+        return <Dialog
+            open={open}
+            onClose={closeDialog}
+            fullWidth={true}
+            maxWidth='md'>
+            <DialogTitle>Get API Token</DialogTitle>
+            <DialogContent>
+                <Typography paragraph={true}>
+                    The Arvados API token is a secret key that enables the Arvados SDKs to access Arvados with the proper permissions.
+                    <Typography component='span'>
+                        For more information see
+                        <a href='http://doc.arvados.org/user/reference/api-tokens.html' target='blank' className={classes.link}>
+                            Getting an API token.
+                        </a>
+                    </Typography>
+                </Typography>
+
+                <DetailsAttributeComponent label='API Host' value={data.apiHost} copyValue={data.apiHost} onCopy={this.onCopy} />
+                <DetailsAttributeComponent label='API Token' value={data.token} copyValue={data.token} onCopy={this.onCopy} />
+                <DetailsAttributeComponent label='Token expiration' value={tokenExpiration} />
+                { this.props.canCreateNewTokens && <Button
+                    onClick={() => this.onGetNewToken()}
+                    color="primary"
+                    size="small"
+                    variant="contained"
+                    className={classes.actionButton}
+                >
+                    GET NEW TOKEN
+                </Button> }
+
+                <Typography paragraph={true}>
+                    Paste the following lines at a shell prompt to set up the necessary environment for Arvados SDKs to authenticate to your account.
+                </Typography>
+                <DefaultCodeSnippet className={classes.codeBlock} lines={[this.getSnippet(data)]} />
+                <CopyToClipboard text={this.getSnippet(data)} onCopy={() => this.onCopy('Shell code block copied')}>
+                    <Button
+                        color="primary"
+                        size="small"
+                        variant="contained"
+                        className={classes.actionButton}
+                    >
+                        COPY TO CLIPBOARD
+                    </Button>
+                </CopyToClipboard>
+                <Typography>
+                    Arvados
+                            <a href='http://doc.arvados.org/user/reference/api-tokens.html' target='blank' className={classes.link}>virtual machines</a>
+                    do this for you automatically. This setup is needed only when you use the API remotely (e.g., from your own workstation).
+                </Typography>
+            </DialogContent>
+            <DialogActions>
+                <Button onClick={closeDialog} className={classes.button} color="primary">CLOSE</Button>
+            </DialogActions>
+        </Dialog>;
+    }
+}
+
+export const TokenDialog =
+    withStyles(styles)(
+        connect(getTokenDialogData)(
+            withDialog(TOKEN_DIALOG_NAME)(TokenDialogComponent)));
+
index 47dbd9b062b665f0c92f08a2d0079bb443833a0f..35a7f9c16068e87296db695cf0b63f0e0fbae722 100644 (file)
@@ -44,6 +44,7 @@ import {
     getInitialProcessStatusFilters
 } from '~/store/resource-type-filters/resource-type-filters';
 import { GroupContentsResource } from '~/services/groups-service/groups-service';
+import { GroupClass, GroupResource } from '~/models/group';
 
 type CssRules = 'root' | "button";
 
@@ -167,7 +168,14 @@ export const ProjectPanel = withStyles(styles)(
             handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
                 const { resources } = this.props;
                 const resource = getResource<GroupContentsResource>(resourceUuid)(resources);
-                const menuKind = this.props.dispatch<any>(resourceUuidToContextMenuKind(resourceUuid));
+                // When viewing the contents of a filter group, all contents should be treated as read only.
+                let readonly = false;
+                const project = getResource<GroupResource>(this.props.currentItemId)(resources);
+                if (project && project.groupClass === GroupClass.FILTER) {
+                    readonly = true;
+                }
+
+                const menuKind = this.props.dispatch<any>(resourceUuidToContextMenuKind(resourceUuid, readonly));
                 if (menuKind && resource) {
                     this.props.dispatch<any>(openContextMenu(event, {
                         name: resource.name,
index 66070f77bfc05adafbe9dea37428942d747dabe4..0b80050d56533780152009e240783034b4430ba9 100644 (file)
@@ -23,14 +23,18 @@ import { CollectionFile, CollectionFileType } from '~/models/collection-file';
 
 export interface FileInputProps {
     input: FileCommandInputParameter;
+    options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
 }
-export const FileInput = ({ input }: FileInputProps) =>
+export const FileInput = ({ input, options }: FileInputProps) =>
     <Field
         name={input.id}
         commandInput={input}
         component={FileInputComponent}
         format={format}
         parse={parse}
+        {...{
+            options
+        }}
         validate={getValidation(input)} />;
 
 const format = (value?: File) => value ? value.basename : '';
@@ -54,7 +58,9 @@ interface FileInputComponentState {
 }
 
 const FileInputComponent = connect()(
-    class FileInputComponent extends React.Component<GenericInputProps & DispatchProp, FileInputComponentState> {
+    class FileInputComponent extends React.Component<GenericInputProps & DispatchProp & {
+        options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
+    }, FileInputComponentState> {
         state: FileInputComponentState = {
             open: false,
         };
@@ -119,6 +125,7 @@ const FileInputComponent = connect()(
                         pickerId={this.props.commandInput.id}
                         includeCollections
                         includeFiles
+                        options={this.props.options}
                         toggleItemActive={this.setFile} />
                 </DialogContent>
                 <DialogActions>
index 45b971179384671f6c3175e2544c19c60cec1fd9..3a0afd34868c91fc7ce86727b84f3e7112e7f9b8 100644 (file)
@@ -89,7 +89,7 @@ const getInputComponent = (input: CommandInputParameter) => {
             return <StringInput input={input as StringCommandInputParameter} />;
 
         case isPrimitiveOfType(input, CWLType.FILE):
-            return <FileInput input={input as FileCommandInputParameter} />;
+            return <FileInput options={{ showOnlyOwned: false, showOnlyWritable: false }} input={input as FileCommandInputParameter} />;
 
         case isPrimitiveOfType(input, CWLType.DIRECTORY):
             return <DirectoryInput input={input as DirectoryCommandInputParameter} />;
index f5cfda89828e8164c758061ec6c41b2a99c94ea6..9c2a7df8ffd547c7ab8f15f2dc62c875cb91087f 100644 (file)
@@ -10,7 +10,7 @@ import { DetailsPanel } from '~/views-components/details-panel/details-panel';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { ContextMenu } from "~/views-components/context-menu/context-menu";
 import { FavoritePanel } from "../favorite-panel/favorite-panel";
-import { CurrentTokenDialog } from '~/views-components/current-token-dialog/current-token-dialog';
+import { TokenDialog } from '~/views-components/token-dialog/token-dialog';
 import { RichTextEditorDialog } from '~/views-components/rich-text-editor-dialog/rich-text-editor-dialog';
 import { Snackbar } from '~/views-components/snackbar/snackbar';
 import { CollectionPanel } from '../collection-panel/collection-panel';
@@ -221,7 +221,7 @@ export const WorkbenchPanel =
             <CreateRepositoryDialog />
             <CreateSshKeyDialog />
             <CreateUserDialog />
-            <CurrentTokenDialog />
+            <TokenDialog />
             <FileRemoveDialog />
             <FilesUploadCollectionDialog />
             <GroupAttributesDialog />
index 140de3c8ad2fcbcf9c7bce5ba0e7d7c91bfe8c77..fbc456bd5e0bdf567d1a1f3de66488a8f139aa91 100755 (executable)
@@ -10,7 +10,7 @@ cleanup() {
     set +e +o pipefail
     kill ${arvboot_PID} ${consume_stdout_PID} ${wb2_PID} ${consume_wb2_stdout_PID}
     wait ${arvboot_PID} ${consume_stdout_PID} ${wb2_PID} ${consume_wb2_stdout_PID} || true
-    if [ "${CLEANUP_ARVADOS_DIR}" -eq "1" ]; then
+    if [ ${CLEANUP_ARVADOS_DIR} -eq 1 ]; then
         rm -rf ${ARVADOS_DIR}
     fi
     echo >&2 "done"
@@ -36,9 +36,9 @@ usage() {
 # Allow self-signed certs on 'wait-on'
 export NODE_TLS_REJECT_UNAUTHORIZED=0
 
+ARVADOS_DIR="unset"
 CLEANUP_ARVADOS_DIR=0
 CYPRESS_MODE="run"
-ARVADOS_DIR=`mktemp -d`
 WB2_DIR=`pwd`
 
 while getopts "ia:w:" o; do
@@ -61,6 +61,13 @@ while getopts "ia:w:" o; do
 done
 shift $((OPTIND-1))
 
+if [ "${ARVADOS_DIR}" = "unset" ]; then
+  echo "ARVADOS_DIR is unset, creating a temporary directory for new checkout"
+  ARVADOS_DIR=`mktemp -d`
+fi
+
+echo "ARVADOS_DIR is ${ARVADOS_DIR}"
+
 ARVADOS_LOG=${ARVADOS_DIR}/arvados.log
 ARVADOS_CONF=${WB2_DIR}/tools/arvados_config.yml