17119: Merge branch 'master' into 17119-make-arvados-path-configurable-in-tests 17119-make-arvados-path-configurable-in-tests
authorWard Vandewege <ward@curii.com>
Fri, 12 Mar 2021 22:45:18 +0000 (17:45 -0500)
committerWard Vandewege <ward@curii.com>
Fri, 12 Mar 2021 22:45:29 +0000 (17:45 -0500)
Arvados-DCO-1.1-Signed-off-by: Ward Vandewege <ward@curii.com>

25 files changed:
cypress/integration/favorites.spec.js
cypress/integration/sharing.spec.js [new file with mode: 0644]
cypress/support/commands.js
src/components/code-snippet/code-snippet.tsx
src/components/details-attribute/details-attribute.tsx
src/index.tsx
src/models/api-client-authorization.ts
src/services/common-service/common-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/current-token-dialog/current-token-dialog-actions.tsx [deleted file]
src/store/token-dialog/token-dialog-actions.tsx [new file with mode: 0644]
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/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/main-app-bar/account-menu.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/run-process-panel/inputs/file-input.tsx
src/views/run-process-panel/run-process-inputs-form.tsx
src/views/workbench/workbench.tsx

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 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 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..2cecc8ce4bfe69cc5bb7e25a2eb39be2b5ef227e 100644 (file)
@@ -91,7 +91,7 @@ export const DetailsAttribute = connect(mapStateToProps)(withStyles(styles)(
             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>;
index 98281b67d9ff5cfb4bca863ea7b5b5f2dd8ce27d..b32066a46c69c37a9185bece05294319b6453fbc 100644 (file)
@@ -36,7 +36,7 @@ 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 { setTokenDialogApiHost } from '~/store/token-dialog/token-dialog-actions';
 import { processResourceActionSet } 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';
@@ -130,7 +130,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 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 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,
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: {} });
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 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
     });
 
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 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>
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..928001a
--- /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()).not.toContain('Expires at:');
+
+      const someDate = '2140-01-01T00:00:00.000Z'
+      props.tokenExpiration = new Date(someDate);
+      wrapper = mount(<TokenDialogComponent {...props} />);
+      expect(wrapper.html()).toContain('Expires at:');
+    });
+
+    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..2a77ea3
--- /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 { DetailsAttribute } 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>
+                <Typography  paragraph={true}>
+                    <DetailsAttribute label='API Host' value={data.apiHost} copyValue={data.apiHost} />
+                    <DetailsAttribute label='API Token' value={data.token} copyValue={data.token} />
+                    <DetailsAttribute 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>
+                <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 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 />