Merge branch '18834-uploading-a-file-into-a-subdirectory-of-a-collection-does-not...
authorDaniel Kutyła <daniel.kutyla@contractors.roche.com>
Wed, 6 Apr 2022 20:15:08 +0000 (22:15 +0200)
committerDaniel Kutyła <daniel.kutyla@contractors.roche.com>
Wed, 6 Apr 2022 20:15:08 +0000 (22:15 +0200)
closes #18834

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

71 files changed:
cypress.json
cypress/integration/page-not-found.spec.js
cypress/integration/process.spec.js [new file with mode: 0644]
cypress/integration/search.spec.js
cypress/integration/user-profile.spec.js [new file with mode: 0644]
cypress/integration/virtual-machine-admin.spec.js
cypress/support/commands.js
src/components/context-menu/context-menu.tsx
src/components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar.tsx [new file with mode: 0644]
src/components/icon/icon.tsx
src/components/multi-panel-view/multi-panel-view.tsx
src/components/select-field/select-field.tsx
src/models/collection.ts
src/models/group.ts
src/models/log.ts
src/routes/route-change-handlers.ts
src/routes/routes.ts
src/services/collection-service/collection-service.test.ts
src/services/collection-service/collection-service.ts
src/services/common-service/common-service.ts
src/services/user-service/user-service.ts
src/store/auth/auth-action.ts
src/store/breadcrumbs/breadcrumbs-actions.ts
src/store/context-menu/context-menu-actions.ts
src/store/context-menu/context-menu-filters.ts [new file with mode: 0644]
src/store/group-details-panel/group-details-panel-actions.ts
src/store/group-details-panel/group-details-panel-members-middleware-service.ts
src/store/group-details-panel/group-details-panel-permissions-middleware-service.ts
src/store/my-account/my-account-panel-actions.ts [deleted file]
src/store/navigation/navigation-action.ts
src/store/process-logs-panel/process-logs-panel-actions.ts
src/store/process-logs-panel/process-logs-panel.ts
src/store/process-panel/process-panel-actions.ts
src/store/process-panel/process-panel.ts
src/store/store.ts
src/store/user-profile/user-profile-actions.ts [new file with mode: 0644]
src/store/user-profile/user-profile-groups-middleware-service.ts [new file with mode: 0644]
src/store/users/user-panel-middleware-service.ts
src/store/users/users-actions.ts
src/store/virtual-machines/virtual-machines-actions.ts
src/store/workbench/workbench-actions.ts
src/validators/validators.tsx
src/views-components/context-menu/action-sets/process-resource-action-set.ts
src/views-components/context-menu/action-sets/user-action-set.ts
src/views-components/context-menu/context-menu.tsx
src/views-components/data-explorer/renderers.tsx
src/views-components/dialog-create/dialog-user-create.tsx
src/views-components/dialog-forms/setup-shell-account-dialog.tsx [deleted file]
src/views-components/form-fields/user-form-fields.tsx
src/views-components/user-dialog/activate-dialog.tsx [new file with mode: 0644]
src/views-components/user-dialog/deactivate-dialog.tsx [new file with mode: 0644]
src/views-components/user-dialog/manage-dialog.tsx [deleted file]
src/views-components/user-dialog/setup-dialog.tsx [new file with mode: 0644]
src/views/group-details-panel/group-details-panel.tsx
src/views/my-account-panel/my-account-panel-root.tsx [deleted file]
src/views/my-account-panel/my-account-panel.tsx [deleted file]
src/views/process-log-panel/process-log-code-snippet.tsx [deleted file]
src/views/process-log-panel/process-log-main-card.tsx [deleted file]
src/views/process-log-panel/process-log-panel-root.tsx [deleted file]
src/views/process-log-panel/process-log-panel.tsx [deleted file]
src/views/process-panel/process-details-card.tsx
src/views/process-panel/process-information-card.tsx
src/views/process-panel/process-log-card.tsx [new file with mode: 0644]
src/views/process-panel/process-log-code-snippet.tsx [new file with mode: 0644]
src/views/process-panel/process-log-form.tsx [moved from src/views/process-log-panel/process-log-form.tsx with 79% similarity]
src/views/process-panel/process-panel-root.tsx
src/views/process-panel/process-panel.tsx
src/views/user-panel/user-panel.tsx
src/views/user-profile-panel/user-profile-panel-root.tsx [new file with mode: 0644]
src/views/user-profile-panel/user-profile-panel.tsx [new file with mode: 0644]
src/views/workbench/workbench.tsx

index 47f77ede2f141113d1c6ad8f353151d29a8a5d54..ecafd8ccb5e14e01bed071941ba2fb27929bd981 100644 (file)
@@ -3,5 +3,6 @@
     "chromeWebSecurity": false,
     "viewportWidth": 1920,
     "viewportHeight": 1080,
-    "downloadsFolder": "cypress/downloads"
+    "downloadsFolder": "cypress/downloads",
+    "videoUploadOnPasses": false
 }
index 5d6a26b7d654ee0d2adc7c8f8fbdbccca9dc344b..4df4135c878ed63e4ef667eda50697985a31201d 100644 (file)
@@ -31,14 +31,22 @@ describe('Page not found tests', function() {
 
     it('shows not found popup', function() {
         // given
-        const notExistingUUID = 'zzzzz-tpzed-5o5tg0l9a57gxxx';
-
-        // when
-        cy.loginAs(adminUser);
-        cy.goToPath(`/projects/${notExistingUUID}`);
-
-        // then
-        cy.get('[data-cy=not-found-content]').should('exist');
-        cy.get('[data-cy=not-found-page]').should('not.exist');
+        [
+            '/projects/zzzzz-j7d0g-nonexistingproj',
+            '/projects/zzzzz-tpzed-nonexistinguser',
+            '/processes/zzzzz-xvhdp-nonexistingproc',
+            '/collections/zzzzz-4zz18-nonexistingcoll'
+        ].forEach(function(path) {
+            // Using de slower loginAs() method to avoid bumping into dialog
+            // dismissal issues that are not related to this test.
+            cy.loginAs(adminUser);
+
+            // when
+            cy.goToPath(path);
+
+            // then
+            cy.get('[data-cy=not-found-page]').should('not.exist');
+            cy.get('[data-cy=not-found-content]').should('exist');
+        });
     });
 })
\ No newline at end of file
diff --git a/cypress/integration/process.spec.js b/cypress/integration/process.spec.js
new file mode 100644 (file)
index 0000000..75c318d
--- /dev/null
@@ -0,0 +1,194 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Process 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('user', 'Active', 'User', false, true)
+            .as('activeUser').then(function() {
+                activeUser = this.activeUser;
+            }
+        );
+    });
+
+    beforeEach(function() {
+        cy.clearCookies();
+        cy.clearLocalStorage();
+    });
+
+    function setupDockerImage(image_name) {
+        // Create a collection that will be used as a docker image for the tests.
+        cy.createCollection(adminUser.token, {
+            name: 'docker_image',
+            manifest_text: ". d21353cfe035e3e384563ee55eadbb2f+67108864 5c77a43e329b9838cbec18ff42790e57+55605760 0:122714624:sha256:d8309758b8fe2c81034ffc8a10c36460b77db7bc5e7b448c4e5b684f9d95a678.tar\n"
+        }).as('dockerImage').then(function(dockerImage) {
+            // Give read permissions to the active user on the docker image.
+            cy.createLink(adminUser.token, {
+                link_class: 'permission',
+                name: 'can_read',
+                tail_uuid: activeUser.user.uuid,
+                head_uuid: dockerImage.uuid
+            }).as('dockerImagePermission').then(function() {
+                // Set-up docker image collection tags
+                cy.createLink(activeUser.token, {
+                    link_class: 'docker_image_repo+tag',
+                    name: image_name,
+                    head_uuid: dockerImage.uuid,
+                }).as('dockerImageRepoTag');
+                cy.createLink(activeUser.token, {
+                    link_class: 'docker_image_hash',
+                    name: 'sha256:d8309758b8fe2c81034ffc8a10c36460b77db7bc5e7b448c4e5b684f9d95a678',
+                    head_uuid: dockerImage.uuid,
+                }).as('dockerImageHash');
+            })
+        });
+        return cy.getAll('@dockerImage', '@dockerImageRepoTag', '@dockerImageHash',
+            '@dockerImagePermission').then(function([dockerImage]) {
+                return dockerImage;
+            });
+    }
+
+    function createContainerRequest(user, name, docker_image, command, reuse = false, state = 'Uncommitted') {
+        return setupDockerImage(docker_image).then(function(dockerImage) {
+            return cy.createContainerRequest(user.token, {
+                name: name,
+                command: command,
+                container_image: dockerImage.portable_data_hash, // for some reason, docker_image doesn't work here
+                output_path: 'stdout.txt',
+                priority: 1,
+                runtime_constraints: {
+                    vcpus: 1,
+                    ram: 1,
+                },
+                use_existing: reuse,
+                state: state,
+                mounts: {
+                    foo: {
+                        kind: 'tmp',
+                        path: '/tmp/foo',
+                    }
+                }
+            });
+        });
+    }
+
+    it('shows process logs', function() {
+        const crName = 'test_container_request';
+        createContainerRequest(
+            activeUser,
+            crName,
+            'arvados/jobs',
+            ['echo', 'hello world'],
+            false, 'Committed')
+        .then(function(containerRequest) {
+            cy.loginAs(activeUser);
+            cy.goToPath(`/processes/${containerRequest.uuid}`);
+            cy.get('[data-cy=process-info]').should('contain', crName);
+            cy.get('[data-cy=process-logs]')
+                .should('contain', 'No logs yet')
+                .and('not.contain', 'hello world');
+            cy.createLog(activeUser.token, {
+                object_uuid: containerRequest.container_uuid,
+                properties: {
+                    text: 'hello world'
+                },
+                event_type: 'stdout'
+            }).then(function(log) {
+                cy.get('[data-cy=process-logs]')
+                    .should('not.contain', 'No logs yet')
+                    .and('contain', 'hello world');
+            })
+        });
+    });
+
+    it('filters process logs by event type', function() {
+        const nodeInfoLogs = [
+            'Host Information',
+            'Linux compute-99cb150b26149780de44b929577e1aed-19rgca8vobuvc4p 5.4.0-1059-azure #62~18.04.1-Ubuntu SMP Tue Sep 14 17:53:18 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux',
+            'CPU Information',
+            'processor  : 0',
+            'vendor_id  : GenuineIntel',
+            'cpu family : 6',
+            'model      : 79',
+            'model name : Intel(R) Xeon(R) CPU E5-2673 v4 @ 2.30GHz'
+        ];
+        const crunchRunLogs = [
+            '2022-03-22T13:56:22.542417997Z using local keepstore process (pid 3733) at http://localhost:46837, writing logs to keepstore.txt in log collection',
+            '2022-03-22T13:56:26.237571754Z crunch-run 2.4.0~dev20220321141729 (go1.17.1) started',
+            '2022-03-22T13:56:26.244704134Z crunch-run process has uid=0(root) gid=0(root) groups=0(root)',
+            '2022-03-22T13:56:26.244862836Z Executing container \'zzzzz-dz642-1wokwvcct9s9du3\' using docker runtime',
+            '2022-03-22T13:56:26.245037738Z Executing on host \'compute-99cb150b26149780de44b929577e1aed-19rgca8vobuvc4p\'',
+        ];
+        const stdoutLogs = [
+            'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec dui nisi, hendrerit porta sapien a, pretium dignissim purus.',
+            'Integer viverra, mauris finibus aliquet ultricies, dui mauris cursus justo, ut venenatis nibh ex eget neque.',
+            'In hac habitasse platea dictumst.',
+            'Fusce fringilla turpis id accumsan faucibus. Donec congue congue ex non posuere. In semper mi quis tristique rhoncus.',
+            'Interdum et malesuada fames ac ante ipsum primis in faucibus.',
+            'Quisque fermentum tortor ex, ut suscipit velit feugiat faucibus.',
+            'Donec vitae porta risus, at luctus nulla. Mauris gravida iaculis ipsum, id sagittis tortor egestas ac.',
+            'Maecenas condimentum volutpat nulla. Integer lacinia maximus risus eu posuere.',
+            'Donec vitae leo id augue gravida bibendum.',
+            'Nam libero libero, pretium ac faucibus elementum, mattis nec ex.',
+            'Nullam id laoreet nibh. Vivamus tellus metus, pretium quis justo ut, bibendum varius metus. Pellentesque vitae accumsan lorem, quis tincidunt augue.',
+            'Aliquam viverra nisi nulla, et efficitur dolor mattis in.',
+            'Sed at enim sit amet nulla tincidunt mattis. Aenean eget aliquet ex, non ultrices ex. Nulla ex tortor, vestibulum aliquam tempor ac, aliquam vel est.',
+            'Fusce auctor faucibus libero id venenatis. Etiam sodales, odio eu cursus efficitur, quam sem blandit ex, quis porttitor enim dui quis lectus. In id tincidunt felis.',
+            'Phasellus non ex quis arcu tempus faucibus molestie in sapien.',
+            'Duis tristique semper dolor, vitae pulvinar risus.',
+            'Aliquam tortor elit, luctus nec tortor eget, porta tristique nulla.',
+            'Nulla eget mollis ipsum.',
+        ];
+
+        createContainerRequest(
+            activeUser,
+            'test_container_request',
+            'arvados/jobs',
+            ['echo', 'hello world'],
+            false, 'Committed')
+        .then(function(containerRequest) {
+            cy.logsForContainer(activeUser.token, containerRequest.container_uuid,
+                'node-info', nodeInfoLogs).as('nodeInfoLogs');
+            cy.logsForContainer(activeUser.token, containerRequest.container_uuid,
+                'crunch-run', crunchRunLogs).as('crunchRunLogs');
+            cy.logsForContainer(activeUser.token, containerRequest.container_uuid,
+                'stdout', stdoutLogs).as('stdoutLogs');
+            cy.getAll('@stdoutLogs', '@nodeInfoLogs', '@crunchRunLogs').then(function() {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                // Should should all logs
+                cy.get('[data-cy=process-logs-filter]').should('contain', 'All logs');
+                cy.get('[data-cy=process-logs]')
+                    .should('contain', stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
+                    .and('contain', nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
+                    .and('contain', crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
+                // Select 'node-info' logs
+                cy.get('[data-cy=process-logs-filter]').click();
+                cy.get('body').contains('li', 'node-info').click();
+                cy.get('[data-cy=process-logs]')
+                    .should('not.contain', stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
+                    .and('contain', nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
+                    .and('not.contain', crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
+                // Select 'stdout' logs
+                cy.get('[data-cy=process-logs-filter]').click();
+                cy.get('body').contains('li', 'stdout').click();
+                cy.get('[data-cy=process-logs]')
+                    .should('contain', stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
+                    .and('not.contain', nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
+                    .and('not.contain', crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
+            });
+        });
+    });
+});
\ No newline at end of file
index 403516b324f4b78084103fc94c7c46318375635d..491292bece641a79e977974faccafc0fea7b6379 100644 (file)
@@ -82,7 +82,7 @@ describe('Search tests', function() {
         });
     });
 
-    it.only('can display path of the selected item', function() {
+    it('can display path of the selected item', function() {
         const colName = `Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
 
         // Creates the collection using the admin token so we can set up
diff --git a/cypress/integration/user-profile.spec.js b/cypress/integration/user-profile.spec.js
new file mode 100644 (file)
index 0000000..7d21249
--- /dev/null
@@ -0,0 +1,449 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('User profile tests', function() {
+    let activeUser;
+    let adminUser;
+    const roleGroupName = `Test role group (${Math.floor(999999 * Math.random())})`;
+    const projectGroupName = `Test project group (${Math.floor(999999 * Math.random())})`;
+
+    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('user', 'Active', 'User', false, true)
+            .as('activeUser').then(function() {
+                activeUser = this.activeUser;
+            }
+        );
+    });
+
+    function assertProfileValues({
+        firstName,
+        lastName,
+        email,
+        username,
+        org,
+        org_email,
+        role,
+        website,
+    }) {
+        cy.get('[data-cy=profile-form] input[name="firstName"]').invoke('val').should('equal', firstName);
+        cy.get('[data-cy=profile-form] input[name="lastName"]').invoke('val').should('equal', lastName);
+        cy.get('[data-cy=profile-form] [data-cy=email] [data-cy=value]').contains(email);
+        cy.get('[data-cy=profile-form] [data-cy=username] [data-cy=value]').contains(username);
+
+        cy.get('[data-cy=profile-form] input[name="prefs.profile.organization"]').invoke('val').should('equal', org);
+        cy.get('[data-cy=profile-form] input[name="prefs.profile.organization_email"]').invoke('val').should('equal', org_email);
+        cy.get('[data-cy=profile-form] select[name="prefs.profile.role"]').invoke('val').should('equal', role);
+        cy.get('[data-cy=profile-form] input[name="prefs.profile.website_url"]').invoke('val').should('equal', website);
+    }
+
+    function enterProfileValues({
+        org,
+        org_email,
+        role,
+        website,
+    }) {
+        cy.get('[data-cy=profile-form] input[name="prefs.profile.organization"]').clear();
+        if (org) {
+            cy.get('[data-cy=profile-form] input[name="prefs.profile.organization"]').type(org);
+        }
+        cy.get('[data-cy=profile-form] input[name="prefs.profile.organization_email"]').clear();
+        if (org_email) {
+            cy.get('[data-cy=profile-form] input[name="prefs.profile.organization_email"]').type(org_email);
+        }
+        cy.get('[data-cy=profile-form] select[name="prefs.profile.role"]').select(role);
+        cy.get('[data-cy=profile-form] input[name="prefs.profile.website_url"]').clear();
+        if (website) {
+            cy.get('[data-cy=profile-form] input[name="prefs.profile.website_url"]').type(website);
+        }
+    }
+
+    function assertContextMenuItems({
+        account,
+        activate,
+        deactivate,
+        login,
+        setup
+    }) {
+        cy.get('[data-cy=user-profile-panel-options-btn]').click();
+        cy.get('[data-cy=context-menu]').within(() => {
+            cy.get('[role=button]').contains('Advanced');
+
+            cy.get('[role=button]').should(account ? 'contain' : 'not.contain', 'Account Settings');
+            cy.get('[role=button]').should(activate ? 'contain' : 'not.contain', 'Activate User');
+            cy.get('[role=button]').should(deactivate ? 'contain' : 'not.contain', 'Deactivate User');
+            cy.get('[role=button]').should(login ? 'contain' : 'not.contain', 'Login As User');
+            cy.get('[role=button]').should(setup ? 'contain' : 'not.contain', 'Setup User');
+        });
+        cy.get('div[role=presentation]').click();
+    }
+
+    beforeEach(function() {
+        cy.updateResource(adminUser.token, 'users', adminUser.user.uuid, {
+            prefs: {
+                profile: {
+                    organization: '',
+                    organization_email: '',
+                    role: '',
+                    website_url: '',
+                },
+            },
+        });
+        cy.updateResource(adminUser.token, 'users', activeUser.user.uuid, {
+            prefs: {
+                profile: {
+                    organization: '',
+                    organization_email: '',
+                    role: '',
+                    website_url: '',
+                },
+            },
+        });
+    });
+
+    it('non-admin can edit own profile', function() {
+        cy.loginAs(activeUser);
+
+        cy.get('header button[title="Account Management"]').click();
+        cy.get('#account-menu').contains('My account').click();
+
+        // Admin actions should be hidden, no account menu
+        assertContextMenuItems({
+            account: false,
+            activate: false,
+            deactivate: false,
+            login: false,
+            setup: false,
+        });
+
+        // Check initial values
+        assertProfileValues({
+            firstName: 'Active',
+            lastName: 'User',
+            email: 'user@example.local',
+            username: 'user',
+            org: '',
+            org_email: '',
+            role: '',
+            website: '',
+        });
+
+        // Change values
+        enterProfileValues({
+            org: 'Org name',
+            org_email: 'email@example.com',
+            role: 'Data Scientist',
+            website: 'example.com',
+        });
+
+        // Submit
+        cy.get('[data-cy=profile-form] button[type="submit"]').click();
+
+        // Check new values
+        assertProfileValues({
+            firstName: 'Active',
+            lastName: 'User',
+            email: 'user@example.local',
+            username: 'user',
+            org: 'Org name',
+            org_email: 'email@example.com',
+            role: 'Data Scientist',
+            website: 'example.com',
+        });
+    });
+
+    it('non-admin cannot edit other profile', function() {
+        cy.loginAs(activeUser);
+        cy.goToPath('/user/' + adminUser.user.uuid);
+
+        assertProfileValues({
+            firstName: 'Admin',
+            lastName: 'User',
+            email: 'admin@example.local',
+            username: 'admin',
+            org: '',
+            org_email: '',
+            role: '',
+            website: '',
+        });
+
+        // Inputs should be disabled
+        cy.get('[data-cy=profile-form] input[name="prefs.profile.organization"]').should('be.disabled');
+        cy.get('[data-cy=profile-form] input[name="prefs.profile.organization_email"]').should('be.disabled');
+        cy.get('[data-cy=profile-form] select[name="prefs.profile.role"]').should('be.disabled');
+        cy.get('[data-cy=profile-form] input[name="prefs.profile.website_url"]').should('be.disabled');
+
+        // Submit should be disabled
+        cy.get('[data-cy=profile-form] button[type="submit"]').should('be.disabled');
+
+        // Admin actions should be hidden, no account menu
+        assertContextMenuItems({
+            account: false,
+            activate: false,
+            deactivate: false,
+            login: false,
+            setup: false,
+        });
+    });
+
+    it('admin can edit own profile', function() {
+        cy.loginAs(adminUser);
+
+        cy.get('header button[title="Account Management"]').click();
+        cy.get('#account-menu').contains('My account').click();
+
+        // Admin actions should be visible, no account menu
+        assertContextMenuItems({
+            account: false,
+            activate: false,
+            deactivate: true,
+            login: false,
+            setup: false,
+        });
+
+        // Check initial values
+        assertProfileValues({
+            firstName: 'Admin',
+            lastName: 'User',
+            email: 'admin@example.local',
+            username: 'admin',
+            org: '',
+            org_email: '',
+            role: '',
+            website: '',
+        });
+
+        // Change values
+        enterProfileValues({
+            org: 'Admin org name',
+            org_email: 'admin@example.com',
+            role: 'Researcher',
+            website: 'admin.local',
+        });
+        cy.get('[data-cy=profile-form] button[type="submit"]').click();
+
+        // Check new values
+        assertProfileValues({
+            firstName: 'Admin',
+            lastName: 'User',
+            email: 'admin@example.local',
+            username: 'admin',
+            org: 'Admin org name',
+            org_email: 'admin@example.com',
+            role: 'Researcher',
+            website: 'admin.local',
+        });
+    });
+
+    it('admin can edit other profile', function() {
+        cy.loginAs(adminUser);
+        cy.goToPath('/user/' + activeUser.user.uuid);
+
+        // Check new values
+        assertProfileValues({
+            firstName: 'Active',
+            lastName: 'User',
+            email: 'user@example.local',
+            username: 'user',
+            org: '',
+            org_email: '',
+            role: '',
+            website: '',
+        });
+
+        enterProfileValues({
+            org: 'Changed org name',
+            org_email: 'changed@example.com',
+            role: 'Researcher',
+            website: 'changed.local',
+        });
+        cy.get('[data-cy=profile-form] button[type="submit"]').click();
+
+        // Check new values
+        assertProfileValues({
+            firstName: 'Active',
+            lastName: 'User',
+            email: 'user@example.local',
+            username: 'user',
+            org: 'Changed org name',
+            org_email: 'changed@example.com',
+            role: 'Researcher',
+            website: 'changed.local',
+        });
+
+        // Admin actions should be visible, no account menu
+        assertContextMenuItems({
+            account: false,
+            activate: false,
+            deactivate: true,
+            login: true,
+            setup: false,
+        });
+    });
+
+    it('displays role groups on user profile', function() {
+        cy.loginAs(adminUser);
+
+        cy.createGroup(adminUser.token, {
+            name: roleGroupName,
+            group_class: 'role',
+        }).as('roleGroup').then(function() {
+            cy.createLink(adminUser.token, {
+                name: 'can_write',
+                link_class: 'permission',
+                head_uuid: this.roleGroup.uuid,
+                tail_uuid: adminUser.user.uuid
+            });
+            cy.createLink(adminUser.token, {
+                name: 'can_write',
+                link_class: 'permission',
+                head_uuid: this.roleGroup.uuid,
+                tail_uuid: activeUser.user.uuid
+            });
+        });
+
+        cy.createGroup(adminUser.token, {
+            name: projectGroupName,
+            group_class: 'project',
+        }).as('projectGroup').then(function() {
+            cy.createLink(adminUser.token, {
+                name: 'can_write',
+                link_class: 'permission',
+                head_uuid: this.projectGroup.uuid,
+                tail_uuid: adminUser.user.uuid
+            });
+            cy.createLink(adminUser.token, {
+                name: 'can_write',
+                link_class: 'permission',
+                head_uuid: this.projectGroup.uuid,
+                tail_uuid: activeUser.user.uuid
+            });
+        });
+
+        cy.goToPath('/user/' + activeUser.user.uuid);
+        cy.get('div [role="tab"]').contains('GROUPS').click();
+        cy.get('[data-cy=user-profile-groups-data-explorer]').contains(roleGroupName);
+        cy.get('[data-cy=user-profile-groups-data-explorer]').should('not.contain', projectGroupName);
+
+        cy.goToPath('/user/' + adminUser.user.uuid);
+        cy.get('div [role="tab"]').contains('GROUPS').click();
+        cy.get('[data-cy=user-profile-groups-data-explorer]').contains(roleGroupName);
+        cy.get('[data-cy=user-profile-groups-data-explorer]').should('not.contain', projectGroupName);
+    });
+
+    it('allows performing admin functions', function() {
+        cy.loginAs(adminUser);
+        cy.goToPath('/user/' + activeUser.user.uuid);
+
+        // Check that user is active
+        cy.get('[data-cy=account-status]').contains('Active');
+        cy.get('div [role="tab"]').contains('GROUPS').click();
+        cy.get('[data-cy=user-profile-groups-data-explorer]').should('contain', 'All users');
+        cy.get('div [role="tab"]').contains('PROFILE').click();
+        assertContextMenuItems({
+            account: false,
+            activate: false,
+            deactivate: true,
+            login: true,
+            setup: false,
+        });
+
+        // Deactivate user
+        cy.get('[data-cy=user-profile-panel-options-btn]').click();
+        cy.get('[data-cy=context-menu]').contains('Deactivate User').click();
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+
+        // Check that user is deactivated
+        cy.get('[data-cy=account-status]').contains('Inactive');
+        cy.get('div [role="tab"]').contains('GROUPS').click();
+        cy.get('[data-cy=user-profile-groups-data-explorer]').should('not.contain', 'All users');
+        cy.get('div [role="tab"]').contains('PROFILE').click();
+        assertContextMenuItems({
+            account: false,
+            activate: true,
+            deactivate: false,
+            login: true,
+            setup: true,
+        });
+
+        // Setup user
+        cy.get('[data-cy=user-profile-panel-options-btn]').click();
+        cy.get('[data-cy=context-menu]').contains('Setup User').click();
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+
+        // Check that user is setup
+        cy.get('[data-cy=account-status]').contains('Setup');
+        cy.get('div [role="tab"]').contains('GROUPS').click();
+        cy.get('[data-cy=user-profile-groups-data-explorer]').should('contain', 'All users');
+        cy.get('div [role="tab"]').contains('PROFILE').click();
+        assertContextMenuItems({
+            account: false,
+            activate: true,
+            deactivate: true,
+            login: true,
+            setup: false,
+        });
+
+        // Activate user
+        cy.get('[data-cy=user-profile-panel-options-btn]').click();
+        cy.get('[data-cy=context-menu]').contains('Activate User').click();
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+
+        // Check that user is active
+        cy.get('[data-cy=account-status]').contains('Active');
+        cy.get('div [role="tab"]').contains('GROUPS').click();
+        cy.get('[data-cy=user-profile-groups-data-explorer]').should('contain', 'All users');
+        cy.get('div [role="tab"]').contains('PROFILE').click();
+        assertContextMenuItems({
+            account: false,
+            activate: false,
+            deactivate: true,
+            login: true,
+            setup: false,
+        });
+
+        // Deactivate and activate user skipping setup
+        cy.get('[data-cy=user-profile-panel-options-btn]').click();
+        cy.get('[data-cy=context-menu]').contains('Deactivate User').click();
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+        // Check
+        cy.get('[data-cy=account-status]').contains('Inactive');
+        cy.get('div [role="tab"]').contains('GROUPS').click();
+        cy.get('[data-cy=user-profile-groups-data-explorer]').should('not.contain', 'All users');
+        cy.get('div [role="tab"]').contains('PROFILE').click();
+        assertContextMenuItems({
+            account: false,
+            activate: true,
+            deactivate: false,
+            login: true,
+            setup: true,
+        });
+        // reactivate
+        cy.get('[data-cy=user-profile-panel-options-btn]').click();
+        cy.get('[data-cy=context-menu]').contains('Activate User').click();
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+
+        // Check that user is active
+        cy.get('[data-cy=account-status]').contains('Active');
+        cy.get('div [role="tab"]').contains('GROUPS').click();
+        cy.get('[data-cy=user-profile-groups-data-explorer]').should('contain', 'All users');
+        cy.get('div [role="tab"]').contains('PROFILE').click();
+        assertContextMenuItems({
+            account: false,
+            activate: false,
+            deactivate: true,
+            login: true,
+            setup: false,
+        });
+    });
+
+});
index 73804b2030da01a5d50f80d4a75da380b49425d7..f01a8911060ffd22e6545f8a3f0ffca137a4e190 100644 (file)
@@ -64,6 +64,7 @@ describe('Virtual machine login manage tests', function() {
         cy.get('[data-cy=form-dialog]').within(() => {
             cy.get('[data-cy=form-submit-btn]').click();
         });
+        cy.get('[data-cy=snackbar]').contains('Permission updated');
         cy.get('[data-cy=vm-admin-table]')
             .contains(vmHost)
             .parents('tr')
@@ -92,6 +93,7 @@ describe('Virtual machine login manage tests', function() {
         cy.get('[data-cy=form-dialog]').within(() => {
             cy.get('[data-cy=form-submit-btn]').click();
         });
+        cy.get('[data-cy=snackbar]').contains('Permission updated');
         cy.get('[data-cy=vm-admin-table]')
             .contains(vmHost)
             .parents('tr')
@@ -160,8 +162,17 @@ describe('Virtual machine login manage tests', function() {
             cy.get('[data-cy=form-submit-btn]').click();
         });
 
+        // Wait for page to finish loading
+        cy.get('[data-cy=snackbar]').contains('Permission updated');
         cy.get('[data-cy=vm-admin-table]')
-            .contains('user'); // Wait for page to finish
+            .contains(vmHost)
+            .parents('tr')
+            .within(() => {
+                cy.get('div[role=button]')
+                    .parent()
+                    .first()
+                    .contains('admin')
+            });
 
         cy.get('[data-cy=vm-admin-table]')
             .contains(vmHost)
@@ -183,6 +194,7 @@ describe('Virtual machine login manage tests', function() {
         cy.get('[data-cy=form-dialog]').within(() => {
             cy.get('[data-cy=form-submit-btn]').click();
         });
+        cy.get('[data-cy=snackbar]').contains('Permission updated');
 
         // Verify new login permissions
         // Check admin's vm page for login
index cfdfa9ecffb0e94c2262d0f580b8222f3d7b2c72..5a2428b2c5e7764f1f8c1c30cb9a221a7bb61ffc 100644 (file)
@@ -133,6 +133,16 @@ Cypress.Commands.add(
     }
 )
 
+Cypress.Commands.add(
+    "getCollection", (token, uuid) => {
+        return cy.doRequest('GET', `/arvados/v1/collections/${uuid}`, null, {}, token)
+            .its('body')
+            .then(function (theCollection) {
+                return theCollection;
+            })
+    }
+)
+
 Cypress.Commands.add(
     "createCollection", (token, data) => {
         return cy.createResource(token, 'collections', {
@@ -150,6 +160,49 @@ Cypress.Commands.add(
     }
 )
 
+Cypress.Commands.add(
+    'createContainerRequest', (token, data) => {
+        return cy.createResource(token, 'container_requests', {
+            container_request: JSON.stringify(data),
+            ensure_unique_name: true
+        })
+    }
+)
+
+Cypress.Commands.add(
+    "updateContainerRequest", (token, uuid, data) => {
+        return cy.updateResource(token, 'container_requests', uuid, {
+            container_request: JSON.stringify(data)
+        })
+    }
+)
+
+Cypress.Commands.add(
+    "createLog", (token, data) => {
+        return cy.createResource(token, 'logs', {
+            log: JSON.stringify(data)
+        })
+    }
+)
+
+Cypress.Commands.add(
+    "logsForContainer", (token, uuid, logType, logTextArray = []) => {
+        let logs = [];
+        for (const logText of logTextArray) {
+            logs.push(cy.createLog(token, {
+                object_uuid: uuid,
+                event_type: logType,
+                properties: {
+                    text: logText
+                }
+            }).as('lastLogRecord'))
+        }
+        cy.getAll('@lastLogRecord').then(function () {
+            return logs;
+        })
+    }
+)
+
 Cypress.Commands.add(
     "createVirtualMachine", (token, data) => {
         return cy.createResource(token, 'virtual_machines', {
index 36f0903df3784543401518f3f59c0b00eee02334..a44e8b7bd4af0aefee66a22523ee3f9676feb658 100644 (file)
@@ -5,11 +5,15 @@ import React from "react";
 import { Popover, List, ListItem, ListItemIcon, ListItemText, Divider } from "@material-ui/core";
 import { DefaultTransformOrigin } from "../popover/helpers";
 import { IconType } from "../icon/icon";
+import { RootState } from "store/store";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
 
 export interface ContextMenuItem {
     name?: string | React.ComponentType;
     icon?: IconType;
     component?: React.ComponentType<any>;
+    adminOnly?: boolean;
+    filters?: ((state: RootState, resource: ContextMenuResource) => boolean)[]
 }
 
 export type ContextMenuItemGroup = ContextMenuItem[];
diff --git a/src/components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar.tsx b/src/components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar.tsx
new file mode 100644 (file)
index 0000000..3b2ff68
--- /dev/null
@@ -0,0 +1,58 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { connect, DispatchProp } from "react-redux";
+import { StyleRulesCallback, Tooltip, WithStyles, withStyles } from "@material-ui/core";
+import { ArvadosTheme } from 'common/custom-theme';
+import CopyToClipboard from 'react-copy-to-clipboard';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { CopyIcon } from 'components/icon/icon';
+
+type CssRules = 'copyIcon';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+  copyIcon: {
+    marginLeft: theme.spacing.unit,
+    color: theme.palette.grey["500"],
+    cursor: 'pointer',
+    display: 'inline',
+    '& svg': {
+      fontSize: '1rem',
+      verticalAlign: 'middle',
+    }
+  }
+});
+
+interface CopyToClipboardDataProps {
+  children?: React.ReactNode;
+  value: string;
+}
+
+type CopyToClipboardProps = CopyToClipboardDataProps & WithStyles<CssRules> & DispatchProp;
+
+export const CopyToClipboardSnackbar = connect()(withStyles(styles)(
+  class CopyToClipboardSnackbar extends React.Component<CopyToClipboardProps> {
+    onCopy = () => {
+      this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+        message: 'Copied',
+        hideDuration: 2000,
+        kind: SnackbarKind.SUCCESS
+    }));
+    };
+
+    render() {
+      const { children, value, classes } = this.props;
+      return (
+        <Tooltip title="Copy to clipboard">
+          <span className={classes.copyIcon}>
+            <CopyToClipboard text={value} onCopy={this.onCopy}>
+              {children || <CopyIcon />}
+            </CopyToClipboard>
+          </span>
+        </Tooltip>
+      );
+    }
+  }
+));
index 54b7bee6cd827b044af7dd88197ae7c76e4a8d82..19b4beea1eb66274f45c2fc8ba1f1879a3194333 100644 (file)
@@ -65,11 +65,18 @@ import VpnKey from '@material-ui/icons/VpnKey';
 import LinkOutlined from '@material-ui/icons/LinkOutlined';
 import RemoveRedEye from '@material-ui/icons/RemoveRedEye';
 import Computer from '@material-ui/icons/Computer';
+import WrapText from '@material-ui/icons/WrapText';
+import TextIncrease from '@material-ui/icons/ZoomIn';
+import TextDecrease from '@material-ui/icons/ZoomOut';
+import CropFreeSharp from '@material-ui/icons/CropFreeSharp';
+import ExitToApp from '@material-ui/icons/ExitToApp';
+import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
+import RemoveCircleOutline from '@material-ui/icons/RemoveCircleOutline';
+import NotInterested from '@material-ui/icons/NotInterested';
 
 // Import FontAwesome icons
 import { library } from '@fortawesome/fontawesome-svg-core';
 import { faPencilAlt, faSlash, faUsers, faEllipsisH } from '@fortawesome/free-solid-svg-icons';
-import { CropFreeSharp } from '@material-ui/icons';
 library.add(
     faPencilAlt,
     faSlash,
@@ -173,3 +180,11 @@ export const CanReadIcon: IconType = (props) => <RemoveRedEye {...props} />;
 export const CanWriteIcon: IconType = (props) => <Edit {...props} />;
 export const CanManageIcon: IconType = (props) => <Computer {...props} />;
 export const AddUserIcon: IconType = (props) => <PersonAdd {...props} />;
+export const WordWrapIcon: IconType = (props) => <WrapText {...props} />;
+export const TextIncreaseIcon: IconType = (props) => <TextIncrease {...props} />;
+export const TextDecreaseIcon: IconType = (props) => <TextDecrease {...props} />;
+export const DeactivateUserIcon: IconType = (props) => <NotInterested {...props} />;
+export const LoginAsIcon: IconType = (props) => <ExitToApp {...props} />;
+export const ActiveIcon: IconType = (props) => <CheckCircleOutline {...props} />;
+export const SetupIcon: IconType = (props) => <RemoveCircleOutline {...props} />;
+export const InactiveIcon: IconType = (props) => <NotInterested {...props} />;
index 185c3b90ced0bf010c4ac7c61a57eb4c13824a22..de8249909d3af4ba16c53b49490ec0144bc6b4d0 100644 (file)
@@ -65,6 +65,7 @@ interface MPVPanelDataProps {
     panelIlluminated?: boolean;
     panelRef?: MutableRefObject<any>;
     forwardProps?: boolean;
+    maxHeight?: string;
 }
 
 interface MPVPanelActionProps {
@@ -78,14 +79,20 @@ export type MPVPanelProps = MPVPanelDataProps & MPVPanelActionProps;
 type MPVPanelContentProps = {children: ReactElement} & MPVPanelProps & GridProps;
 
 // Grid item compatible component for layout and MPV props passing
-export const MPVPanelContent = ({doHidePanel, doMaximizePanel, panelName, panelMaximized, panelIlluminated, panelRef, forwardProps, ...props}: MPVPanelContentProps) => {
+export const MPVPanelContent = ({doHidePanel, doMaximizePanel, panelName,
+    panelMaximized, panelIlluminated, panelRef, forwardProps, maxHeight,
+    ...props}: MPVPanelContentProps) => {
     useEffect(() => {
         if (panelRef && panelRef.current) {
             panelRef.current.scrollIntoView({behavior: 'smooth'});
         }
     }, [panelRef]);
 
-    return <Grid item {...props}>
+    const mh = panelMaximized
+        ? '100%'
+        : maxHeight;
+
+    return <Grid item style={{maxHeight: mh}} {...props}>
         <span ref={panelRef} /> {/* Element to scroll to when the panel is selected */}
         <Paper style={{height: '100%'}} elevation={panelIlluminated ? 8 : 0}>
             { forwardProps
@@ -112,7 +119,7 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
         children = [children];
     }
     const visibility = (children as ReactNodeArray).map((_, idx) =>
-        !!!panelStates || // if panelStates wasn't passed, default to all visible panels
+        !panelStates || // if panelStates wasn't passed, default to all visible panels
             (panelStates[idx] &&
                 (panelStates[idx].visible || panelStates[idx].visible === undefined)));
     const [panelVisibility, setPanelVisibility] = useState<boolean[]>(visibility);
@@ -159,13 +166,20 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
             const panelIsMaximized = panelVisibility[idx] &&
                 panelVisibility.filter(e => e).length === 1;
 
+            let brightenerTimer: NodeJS.Timer;
             toggles = [
                 ...toggles,
                 <Tooltip title={toggleTooltip} disableFocusListener>
                     <Button variant={toggleVariant} size="small" color="primary"
                         className={classNames(classes.button)}
-                        onMouseEnter={() => setBrightenedPanel(idx)}
-                        onMouseLeave={() => setBrightenedPanel(-1)}
+                        onMouseEnter={() => {
+                            brightenerTimer = setTimeout(
+                                () => setBrightenedPanel(idx), 100);
+                        }}
+                        onMouseLeave={() => {
+                            brightenerTimer && clearTimeout(brightenerTimer);
+                            setBrightenedPanel(-1);
+                        }}
                         onClick={showFn(idx)}>
                             {panelName}
                             {toggleIcon}
index e4dcad6c109fdd75cfb2cb14102d161272ceb649..6fa7ddea626396494cc6f3cc5a2e01887a15da7d 100644 (file)
@@ -35,14 +35,18 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     }
 });
 
+interface NativeSelectFieldProps {
+    disabled?: boolean;
+}
+
 export const NativeSelectField = withStyles(styles)
-    ((props: WrappedFieldProps & WithStyles<CssRules> & { items: any[] }) =>
+    ((props: WrappedFieldProps & NativeSelectFieldProps & WithStyles<CssRules> & { items: any[] }) =>
         <FormControl className={props.classes.formControl}>
             <Select className={props.classes.selectWrapper}
                 native
                 value={props.input.value}
                 onChange={props.input.onChange}
-                disabled={props.meta.submitting}
+                disabled={props.meta.submitting || props.disabled}
                 name={props.input.name}
                 inputProps={{
                     id: `id-${props.input.name}`,
@@ -81,4 +85,4 @@ export const SelectField = withStyles(selectFieldStyles)(
             </Select>
             <FormHelperText>{props.meta.error}</FormHelperText>
         </FormControl>
-);
\ No newline at end of file
+);
index 3effe6724847485185fb6d15c8043fe90baac69c..9a5d2ee2d86d6e34ef71c77af3fab222b3538d9c 100644 (file)
@@ -28,6 +28,40 @@ export interface CollectionResource extends TrashableResource, ResourceWithPrope
     fileSizeTotal: number;
 }
 
+// We exclude 'manifestText' and 'unsignedManifestText' from the default
+export const defaultCollectionSelectedFields = [
+    'name',
+    'description',
+    'portableDataHash',
+    'replicationDesired',
+    'replicationConfirmed',
+    'replicationConfirmedAt',
+    'storageClassesDesired',
+    'storageClassesConfirmed',
+    'storageClassesConfirmedAt',
+    'currentVersionUuid',
+    'version',
+    'preserveVersion',
+    'fileCount',
+    'fileSizeTotal',
+    // ResourceWithProperties field
+    'properties',
+    // TrashableResource fields
+    'trashAt',
+    'deleteAt',
+    'isTrashed',
+    // Resource fields
+    'uuid',
+    'ownerUuid',
+    'createdAt',
+    'modifiedByClientUuid',
+    'modifiedByUserUuid',
+    'modifiedAt',
+    'href',
+    'kind',
+    'etag',
+];
+
 export const getCollectionUrl = (uuid: string) => {
     return `/collections/${uuid}`;
 };
index 3f3656ccd5fba90bcfb6a037c73ce0420c4c343c..f6a72c538f9481392aabac0883077cbf2086a6b4 100644 (file)
@@ -25,14 +25,18 @@ export enum GroupClass {
     ROLE  = 'role',
 }
 
-export const BUILTIN_GROUP_IDS = [
-    'fffffffffffffff',
-    'anonymouspublic',
-    '000000000000000',
-]
+export enum BuiltinGroups {
+    ALL = 'fffffffffffffff',
+    ANON = 'anonymouspublic',
+    SYSTEM = '000000000000000',
+}
+
+export const getBuiltinGroupUuid = (cluster: string, groupName: BuiltinGroups): string => {
+    return cluster ? `${cluster}-${ResourceObjectType.GROUP}-${groupName}` : "";
+};
 
 export const isBuiltinGroup = (uuid: string) => {
     const match = RESOURCE_UUID_REGEX.exec(uuid);
     const parts = match ? match[0].split('-') : [];
-    return parts.length === 3 && parts[1] === ResourceObjectType.GROUP && BUILTIN_GROUP_IDS.includes(parts[2]);
+    return parts.length === 3 && parts[1] === ResourceObjectType.GROUP && Object.values<string>(BuiltinGroups).includes(parts[2]);
 };
index 3397993bae4c74f2b3e7d89c825618e27fa38a22..20060f88b640f4b2f1d720b0babcee290e65a5af 100644 (file)
@@ -16,6 +16,7 @@ export enum LogEventType {
     ARV_MOUNT = 'arv-mount',
     STDOUT = 'stdout',
     STDERR = 'stderr',
+    CONTAINER = 'container',
 }
 
 export interface LogResource extends Resource, ResourceWithProperties {
index 044a38bf20df04d3822a5325990256a89b9295ec..5b3ce6687ff0e4cb8b3e17a97d286898dda90946 100644 (file)
@@ -27,7 +27,6 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
     const publicFavoritesMatch = Routes.matchPublicFavoritesRoute(pathname);
     const trashMatch = Routes.matchTrashRoute(pathname);
     const processMatch = Routes.matchProcessRoute(pathname);
-    const processLogMatch = Routes.matchProcessLogRoute(pathname);
     const repositoryMatch = Routes.matchRepositoriesRoute(pathname);
     const searchResultsMatch = Routes.matchSearchResultsRoute(pathname);
     const sharedWithMeMatch = Routes.matchSharedWithMeRoute(pathname);
@@ -42,7 +41,8 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
     const apiClientAuthorizationsMatch = Routes.matchApiClientAuthorizationsRoute(pathname);
     const myAccountMatch = Routes.matchMyAccountRoute(pathname);
     const linkAccountMatch = Routes.matchLinkAccountRoute(pathname);
-    const userMatch = Routes.matchUsersRoute(pathname);
+    const usersMatch = Routes.matchUsersRoute(pathname);
+    const userProfileMatch = Routes.matchUserProfileRoute(pathname);
     const groupsMatch = Routes.matchGroupsRoute(pathname);
     const groupDetailsMatch = Routes.matchGroupDetailsRoute(pathname);
     const linksMatch = Routes.matchLinksRoute(pathname);
@@ -71,8 +71,6 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
         store.dispatch(WorkbenchActions.loadTrash());
     } else if (processMatch) {
         store.dispatch(WorkbenchActions.loadProcess(processMatch.params.id));
-    } else if (processLogMatch) {
-        store.dispatch(WorkbenchActions.loadProcessLog(processLogMatch.params.id));
     } else if (rootMatch) {
         store.dispatch(navigateToRootProject);
     } else if (sharedWithMeMatch) {
@@ -100,11 +98,13 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
     } else if (apiClientAuthorizationsMatch) {
         store.dispatch(WorkbenchActions.loadApiClientAuthorizations);
     } else if (myAccountMatch) {
-        store.dispatch(WorkbenchActions.loadMyAccount);
+        store.dispatch(WorkbenchActions.loadUserProfile());
     } else if (linkAccountMatch) {
         store.dispatch(WorkbenchActions.loadLinkAccount);
-    } else if (userMatch) {
+    } else if (usersMatch) {
         store.dispatch(WorkbenchActions.loadUsers);
+    } else if (userProfileMatch) {
+        store.dispatch(WorkbenchActions.loadUserProfile(userProfileMatch.params.id));
     } else if (groupsMatch) {
         store.dispatch(WorkbenchActions.loadGroupsPanel);
     } else if (groupDetailsMatch) {
index 41c71f7ca87994a32938d9c0138d1ba204a1ce6d..50689ec37c46ac1fba6d5ff985badeb233944031 100644 (file)
@@ -25,7 +25,6 @@ export const Routes = {
     PROCESSES: `/processes/:id(${RESOURCE_UUID_PATTERN})`,
     FAVORITES: '/favorites',
     TRASH: '/trash',
-    PROCESS_LOGS: `/process-logs/:id(${RESOURCE_UUID_PATTERN})`,
     REPOSITORIES: '/repositories',
     SHARED_WITH_ME: '/shared-with-me',
     RUN_PROCESS: '/run-process',
@@ -40,6 +39,7 @@ export const Routes = {
     LINK_ACCOUNT: '/link_account',
     KEEP_SERVICES: `/keep-services`,
     USERS: '/users',
+    USER_PROFILE: `/user/:id(${RESOURCE_UUID_PATTERN})`,
     API_CLIENT_AUTHORIZATIONS: `/api_client_authorizations`,
     GROUPS: '/groups',
     GROUP_DETAILS: `/group/:id(${RESOURCE_UUID_PATTERN})`,
@@ -95,10 +95,10 @@ export const getNavUrl = (uuid: string, config: FederationConfig) => {
 
 export const getProcessUrl = (uuid: string) => `/processes/${uuid}`;
 
-export const getProcessLogUrl = (uuid: string) => `/process-logs/${uuid}`;
-
 export const getGroupUrl = (uuid: string) => `/group/${uuid}`;
 
+export const getUserProfileUrl = (uuid: string) => `/user/${uuid}`;
+
 export interface ResourceRouteParams {
     id: string;
 }
@@ -124,9 +124,6 @@ export const matchCollectionRoute = (route: string) =>
 export const matchProcessRoute = (route: string) =>
     matchPath<ResourceRouteParams>(route, { path: Routes.PROCESSES });
 
-export const matchProcessLogRoute = (route: string) =>
-    matchPath<ResourceRouteParams>(route, { path: Routes.PROCESS_LOGS });
-
 export const matchSharedWithMeRoute = (route: string) =>
     matchPath(route, { path: Routes.SHARED_WITH_ME });
 
@@ -175,6 +172,9 @@ export const matchFedTokenRoute = (route: string) =>
 export const matchUsersRoute = (route: string) =>
     matchPath(route, { path: Routes.USERS });
 
+export const matchUserProfileRoute = (route: string) =>
+    matchPath<ResourceRouteParams>(route, { path: Routes.USER_PROFILE });
+
 export const matchApiClientAuthorizationsRoute = (route: string) =>
     matchPath(route, { path: Routes.API_CLIENT_AUTHORIZATIONS });
 
index 88c5f725950f7031f9df2f7cdae0309a8cc180b2..817fdd5453bac324948d1d8d8170c8bec2d7168f 100644 (file)
@@ -4,7 +4,8 @@
 
 import axios, { AxiosInstance } from 'axios';
 import MockAdapter from 'axios-mock-adapter';
-import { CollectionResource } from 'models/collection';
+import { snakeCase } from 'lodash';
+import { CollectionResource, defaultCollectionSelectedFields } from 'models/collection';
 import { AuthService } from '../auth-service/auth-service';
 import { CollectionService } from './collection-service';
 
@@ -32,17 +33,16 @@ describe('collection-service', () => {
     });
 
     describe('get', () => {
-        it('should make a list request with uuid filtering', async () => {
+        it('should make a request with default selected fields', async () => {
             serverApi.get = jest.fn(() => Promise.resolve(
                 { data: { items: [{}] } }
             ));
             const uuid = 'zzzzz-4zz18-0123456789abcde'
             await collectionService.get(uuid);
             expect(serverApi.get).toHaveBeenCalledWith(
-                '/collections', {
+                `/collections/${uuid}`, {
                     params: {
-                        filters: `[["uuid","=","zzzzz-4zz18-0123456789abcde"]]`,
-                        include_old_versions: true,
+                        select: JSON.stringify(defaultCollectionSelectedFields.map(snakeCase)),
                     },
                 }
             );
@@ -55,10 +55,8 @@ describe('collection-service', () => {
             const uuid = 'zzzzz-4zz18-0123456789abcde'
             await collectionService.get(uuid, undefined, ['manifestText']);
             expect(serverApi.get).toHaveBeenCalledWith(
-                '/collections', {
+                `/collections/${uuid}`, {
                     params: {
-                        filters: `[["uuid","=","zzzzz-4zz18-0123456789abcde"]]`,
-                        include_old_versions: true,
                         select: `["manifest_text"]`
                     },
                 }
index e5071d7e007af4ccea1f0df1ec894359ffc57618..92e4dfbae3e6d43f28f73b03dfce992da3f3fcfc 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { CollectionResource } from "models/collection";
+import { CollectionResource, defaultCollectionSelectedFields } from "models/collection";
 import { AxiosInstance } from "axios";
 import { CollectionFile, CollectionDirectory } from "models/collection-file";
 import { WebDAV } from "common/webdav";
@@ -11,8 +11,6 @@ import { extractFilesData } from "./collection-service-files-response";
 import { TrashableResourceService } from "services/common-service/trashable-resource-service";
 import { ApiActions } from "services/api/api-actions";
 import { customEncodeURI } from "common/url";
-import { FilterBuilder } from "services/api/filter-builder";
-import { ListArguments } from "services/common-service/common-service";
 import { Session } from "models/session";
 
 export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
@@ -33,19 +31,8 @@ export class CollectionService extends TrashableResourceService<CollectionResour
 
     async get(uuid: string, showErrors?: boolean, select?: string[], session?: Session) {
         super.validateUuid(uuid);
-        // We use a filtered list request to avoid getting the manifest text
-        const filters = new FilterBuilder().addEqual('uuid', uuid).getFilters();
-        const listArgs: ListArguments = {filters, includeOldVersions: true};
-        if (select) {
-            listArgs.select = select;
-        }
-
-        if (!session) {
-            const lst = await super.list(listArgs, showErrors);
-            return lst.items[0];
-        } else {
-            return super.get(uuid, showErrors, select, session);
-        }
+        const selectParam = select || defaultCollectionSelectedFields;
+        return super.get(uuid, showErrors, selectParam, session);
     }
 
     create(data?: Partial<CollectionResource>) {
index ddaf2ab02fa5ee4e18d544158e42f7606339983c..f16a2024ad2c3a1775d4a5fb7e83f784e08554e4 100644 (file)
@@ -117,7 +117,13 @@ export class CommonService<T> {
     get(uuid: string, showErrors?: boolean, select?: string[], session?: Session) {
         this.validateUuid(uuid);
 
-        const cfg: AxiosRequestConfig = {};
+        const cfg: AxiosRequestConfig = {
+            params: {
+                select: select
+                    ? `[${select.map(snakeCase).map(s => `"${s}"`).join(',')}]`
+                    : undefined
+            }
+        };
         if (session) {
             cfg.baseURL = session.baseUrl;
             cfg.headers = { 'Authorization': 'Bearer ' + session.token };
@@ -125,7 +131,7 @@ export class CommonService<T> {
 
         return CommonService.defaultResponse(
             this.serverApi
-                .get<T>(`/${this.resourceType}/${uuid}`, session ? cfg : undefined),
+                .get<T>(`/${this.resourceType}/${uuid}`, cfg),
             this.actions,
             true, // mapKeys
             showErrors
index dbbd5be8c2a7d4297f36c85c63772929d5c649a2..8581b26766715fe57ce1d50657c8e9131f90daa4 100644 (file)
@@ -6,22 +6,35 @@ import { AxiosInstance } from "axios";
 import { CommonResourceService } from "services/common-service/common-resource-service";
 import { UserResource } from "models/user";
 import { ApiActions } from "services/api/api-actions";
+import { ListResults } from "services/common-service/common-service";
 
 export class UserService extends CommonResourceService<UserResource> {
-    constructor(serverApi: AxiosInstance, actions: ApiActions) {
-        super(serverApi, "users", actions);
+    constructor(serverApi: AxiosInstance, actions: ApiActions, readOnlyFields: string[] = []) {
+        super(serverApi, "users", actions, readOnlyFields.concat([
+            'fullName',
+            'isInvited',
+            'writableBy',
+        ]));
     }
 
     activate(uuid: string) {
-        return CommonResourceService.defaultResponse(
+        return CommonResourceService.defaultResponse<UserResource>(
             this.serverApi
                 .post(this.resourceType + `/${uuid}/activate`),
             this.actions
         );
     }
 
+    setup(uuid: string) {
+        return CommonResourceService.defaultResponse<ListResults<any>>(
+            this.serverApi
+                .post(this.resourceType + `/setup`, {}, { params: { uuid } }),
+            this.actions
+        );
+    }
+
     unsetup(uuid: string) {
-        return CommonResourceService.defaultResponse(
+        return CommonResourceService.defaultResponse<UserResource>(
             this.serverApi
                 .post(this.resourceType + `/${uuid}/unsetup`),
             this.actions
index d58a8103d28c1309d422f5cd4556c92cfbea3840..7fc9df774358a73d3d9448e604f83bf8693242c0 100644 (file)
@@ -80,6 +80,10 @@ export const getConfig = (dispatch: Dispatch, getState: () => RootState, service
     return state.remoteHostsConfig[state.localCluster];
 };
 
+export const getLocalCluster = (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): string => {
+    return getState().auth.localCluster;
+};
+
 export const saveApiToken = (token: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
     let config: any;
     const tokenParts = token.split('/');
index 72e908aa83c91ffe5480532ad08113c36012005e..08e1a132fd12e23f722cb2bc4e88f6c0374caab0 100644 (file)
@@ -17,6 +17,7 @@ import { updateResources } from '../resources/resources-actions';
 import { ResourceKind } from 'models/resource';
 import { GroupResource } from 'models/group';
 import { extractUuidKind } from 'models/resource';
+import { UserResource } from 'models/user';
 
 export const BREADCRUMBS = 'breadcrumbs';
 
@@ -112,21 +113,52 @@ export const setProcessBreadcrumbs = (processUuid: string) =>
         }
     };
 
-export const GROUPS_PANEL_LABEL = 'Groups';
-
 export const setGroupsBreadcrumbs = () =>
-    setBreadcrumbs([{ label: GROUPS_PANEL_LABEL }]);
+    setBreadcrumbs([{ label: SidePanelTreeCategory.GROUPS }]);
 
 export const setGroupDetailsBreadcrumbs = (groupUuid: string) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
 
         const group = getResource<GroupResource>(groupUuid)(getState().resources);
 
         const breadcrumbs: ResourceBreadcrumb[] = [
-            { label: GROUPS_PANEL_LABEL, uuid: GROUPS_PANEL_LABEL },
-            { label: group ? group.name : groupUuid, uuid: groupUuid },
+            { label: SidePanelTreeCategory.GROUPS, uuid: SidePanelTreeCategory.GROUPS },
+            { label: group ? group.name : (await services.groupsService.get(groupUuid)).name, uuid: groupUuid },
         ];
 
         dispatch(setBreadcrumbs(breadcrumbs));
 
     };
+
+export const USERS_PANEL_LABEL = 'Users';
+
+export const setUsersBreadcrumbs = () =>
+    setBreadcrumbs([{ label: USERS_PANEL_LABEL, uuid: USERS_PANEL_LABEL }]);
+
+export const setUserProfileBreadcrumbs = (userUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            const user = getResource<UserResource>(userUuid)(getState().resources)
+                        || await services.userService.get(userUuid, false);
+            const breadcrumbs: ResourceBreadcrumb[] = [
+                { label: USERS_PANEL_LABEL, uuid: USERS_PANEL_LABEL },
+                { label: user ? user.username : userUuid, uuid: userUuid },
+            ];
+            dispatch(setBreadcrumbs(breadcrumbs));
+        } catch (e) {
+            const breadcrumbs: ResourceBreadcrumb[] = [
+                { label: USERS_PANEL_LABEL, uuid: USERS_PANEL_LABEL },
+                { label: userUuid, uuid: userUuid },
+            ];
+            dispatch(setBreadcrumbs(breadcrumbs));
+        }
+    };
+
+export const MY_ACCOUNT_PANEL_LABEL = 'My Account';
+
+export const setMyAccountBreadcrumbs = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(setBreadcrumbs([
+            { label: MY_ACCOUNT_PANEL_LABEL, uuid: MY_ACCOUNT_PANEL_LABEL },
+        ]));
+    };
index 336817ea4d0adc18c69a0c9b90347be33a7c33f5..1116949a6f31f769dfcf0fbb3ed56e147e7166c2 100644 (file)
@@ -208,6 +208,17 @@ export const openPermissionEditContextMenu = (event: React.MouseEvent<HTMLElemen
         }
     };
 
+export const openUserContextMenu = (event: React.MouseEvent<HTMLElement>, user: UserResource) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch<any>(openContextMenu(event, {
+            name: '',
+            uuid: user.uuid,
+            ownerUuid: user.ownerUuid,
+            kind: user.kind,
+            menuKind: ContextMenuKind.USER
+        }));
+    };
+
 export const resourceUuidToContextMenuKind = (uuid: string, readonly = false) =>
     (dispatch: Dispatch, getState: () => RootState) => {
         const { isAdmin: isAdminUser, uuid: userUuid } = getState().auth.user!;
diff --git a/src/store/context-menu/context-menu-filters.ts b/src/store/context-menu/context-menu-filters.ts
new file mode 100644 (file)
index 0000000..53993fa
--- /dev/null
@@ -0,0 +1,40 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from "store/store";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
+import { getUserAccountStatus, UserAccountStatus } from "store/users/users-actions";
+import { matchMyAccountRoute, matchUserProfileRoute } from "routes/routes";
+
+export const isAdmin = (state: RootState, resource: ContextMenuResource) => {
+  return state.auth.user!.isAdmin;
+}
+
+export const canActivateUser = (state: RootState, resource: ContextMenuResource) => {
+  const status = getUserAccountStatus(state, resource.uuid);
+  return status === UserAccountStatus.INACTIVE ||
+    status === UserAccountStatus.SETUP;
+};
+
+export const canDeactivateUser = (state: RootState, resource: ContextMenuResource) => {
+  const status = getUserAccountStatus(state, resource.uuid);
+  return status === UserAccountStatus.SETUP ||
+    status === UserAccountStatus.ACTIVE;
+};
+
+export const canSetupUser = (state: RootState, resource: ContextMenuResource) => {
+  const status = getUserAccountStatus(state, resource.uuid);
+  return status === UserAccountStatus.INACTIVE;
+};
+
+export const needsUserProfileLink = (state: RootState, resource: ContextMenuResource) => (
+  state.router.location ?
+    !(matchUserProfileRoute(state.router.location.pathname)
+      || matchMyAccountRoute(state.router.location.pathname)
+    ) : true
+);
+
+export const isOtherUser = (state: RootState, resource: ContextMenuResource) => {
+  return state.auth.user!.uuid !== resource.uuid;
+};
index e00ff77340514714053f20a9bb1ed74b227f79f5..2d34511957fe17b93b2511b96a40659dd3771f1a 100644 (file)
@@ -14,8 +14,9 @@ import { ServiceRepository } from 'services/services';
 import { PermissionResource, PermissionLevel } from 'models/permission';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 import { LinkResource } from 'models/link';
-import { deleteResources } from 'store/resources/resources-actions';
+import { deleteResources, updateResources } from 'store/resources/resources-actions';
 import { openSharingDialog } from 'store/sharing-dialog/sharing-dialog-actions';
+import { UserProfileGroupsActions } from 'store/user-profile/user-profile-actions';
 
 export const GROUP_DETAILS_MEMBERS_PANEL_ID = 'groupDetailsMembersPanel';
 export const GROUP_DETAILS_PERMISSIONS_PANEL_ID = 'groupDetailsPermissionsPanel';
@@ -48,9 +49,8 @@ export const openAddGroupMembersDialog = () =>
 export const editPermissionLevel = (uuid: string, level: PermissionLevel) =>
     async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
         try {
-            await permissionService.update(uuid, {name: level});
-            dispatch(GroupMembersPanelActions.REQUEST_ITEMS());
-            dispatch(GroupPermissionsPanelActions.REQUEST_ITEMS());
+            const permission = await permissionService.update(uuid, {name: level});
+            dispatch(updateResources([permission]));
             dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Permission level changed.', hideDuration: 2000 }));
         } catch (e) {
             dispatch(snackbarActions.OPEN_SNACKBAR({
@@ -83,25 +83,19 @@ export const openRemoveGroupMemberDialog = (uuid: string) =>
 export const removeGroupMember = (uuid: string) =>
 
     async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...', kind: SnackbarKind.INFO }));
+        await deleteGroupMember({
+            link: {
+                uuid,
+            },
+            permissionService,
+            dispatch,
+        });
+        dispatch<any>(deleteResources([uuid]));
+        dispatch(GroupMembersPanelActions.REQUEST_ITEMS());
+        dispatch(UserProfileGroupsActions.REQUEST_ITEMS());
 
-        const groupUuid = getCurrentGroupDetailsPanelUuid(getState().properties);
-
-        if (groupUuid) {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...', kind: SnackbarKind.INFO }));
-
-            await deleteGroupMember({
-                link: {
-                    uuid,
-                },
-                permissionService,
-                dispatch,
-            });
-
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
-            dispatch(GroupMembersPanelActions.REQUEST_ITEMS());
-
-        }
-
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
     };
 
 export const setMemberIsHidden = (memberLinkUuid: string, permissionLinkUuid: string, visible: boolean) =>
@@ -128,17 +122,18 @@ export const setMemberIsHidden = (memberLinkUuid: string, permissionLinkUuid: st
         } else if (visible && memberLink) {
             // Create read permission
             try {
-                await permissionService.create({
+                const permission = await permissionService.create({
                     headUuid: memberLink.tailUuid,
                     tailUuid: memberLink.headUuid,
                     name: PermissionLevel.CAN_READ,
                 });
+                dispatch(updateResources([permission]));
+                dispatch(GroupPermissionsPanelActions.REQUEST_ITEMS());
                 dispatch(snackbarActions.OPEN_SNACKBAR({
                     message: 'Created read permission.',
                     hideDuration: 2000,
                     kind: SnackbarKind.SUCCESS,
                 }));
-                dispatch(GroupPermissionsPanelActions.REQUEST_ITEMS());
             } catch(e) {
                 dispatch(snackbarActions.OPEN_SNACKBAR({
                     message: 'Failed to create permission',
index e6f18f7f98489a451cd0c19afdba912393184cf9..3a58927aac7cd3da6a3c41d13aef2fa3161dc82a 100644 (file)
@@ -24,7 +24,8 @@ export class GroupDetailsPanelMembersMiddlewareService extends DataExplorerMiddl
         const dataExplorer = getDataExplorer(api.getState().dataExplorer, this.getId());
         const groupUuid = getCurrentGroupDetailsPanelUuid(api.getState().properties);
         if (!dataExplorer || !groupUuid) {
-            api.dispatch(groupsDetailsPanelDataExplorerIsNotSet());
+            // Noop if data explorer refresh is triggered from another panel
+            return;
         } else {
             try {
                 const groupResource = await this.services.groupsService.get(groupUuid);
@@ -69,12 +70,6 @@ export class GroupDetailsPanelMembersMiddlewareService extends DataExplorerMiddl
     }
 }
 
-const groupsDetailsPanelDataExplorerIsNotSet = () =>
-    snackbarActions.OPEN_SNACKBAR({
-        message: 'Group members panel is not ready.',
-        kind: SnackbarKind.ERROR
-    });
-
 const couldNotFetchGroupDetailsContents = () =>
     snackbarActions.OPEN_SNACKBAR({
         message: 'Could not fetch group members.',
index 9e41409d8053bc3dd3cbbfb8cd14a9a13ba3436a..85beecd726b8ac980a537b5b9ab3db0915e19ae1 100644 (file)
@@ -24,7 +24,7 @@ export class GroupDetailsPanelPermissionsMiddlewareService extends DataExplorerM
         const dataExplorer = getDataExplorer(api.getState().dataExplorer, this.getId());
         const groupUuid = getCurrentGroupDetailsPanelUuid(api.getState().properties);
         if (!dataExplorer || !groupUuid) {
-            api.dispatch(groupsDetailsPanelDataExplorerIsNotSet());
+            // No-op if data explorer is not set since refresh may be triggered from elsewhere
         } else {
             try {
                 const permissionsOut = await this.services.permissionService.list({
@@ -76,12 +76,6 @@ export class GroupDetailsPanelPermissionsMiddlewareService extends DataExplorerM
     }
 }
 
-const groupsDetailsPanelDataExplorerIsNotSet = () =>
-    snackbarActions.OPEN_SNACKBAR({
-        message: 'Group permissions panel is not ready.',
-        kind: SnackbarKind.ERROR
-    });
-
 const couldNotFetchGroupDetailsContents = () =>
     snackbarActions.OPEN_SNACKBAR({
         message: 'Could not fetch group permissions.',
diff --git a/src/store/my-account/my-account-panel-actions.ts b/src/store/my-account/my-account-panel-actions.ts
deleted file mode 100644 (file)
index 9e974aa..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { Dispatch } from "redux";
-import { RootState } from "store/store";
-import { initialize } from "redux-form";
-import { ServiceRepository } from "services/services";
-import { setBreadcrumbs } from "store/breadcrumbs/breadcrumbs-actions";
-import { authActions } from "store/auth/auth-action";
-import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
-
-export const MY_ACCOUNT_FORM = 'myAccountForm';
-
-export const loadMyAccountPanel = () =>
-    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(setBreadcrumbs([{ label: 'User profile' }]));
-    };
-
-export const saveEditedUser = (resource: any) =>
-    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        try {
-            await services.userService.update(resource.uuid, resource);
-            dispatch(authActions.USER_DETAILS_SUCCESS(resource));
-            dispatch(initialize(MY_ACCOUNT_FORM, resource));
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Profile has been updated.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
-        } catch (e) {
-            return;
-        }
-    };
index 19cc36ae6d51752fb92150495a22115648ac2f72..1cdb6784bf42390c88ef60fa920a76c773ab71c8 100644 (file)
@@ -6,11 +6,12 @@ import { Dispatch, compose, AnyAction } from 'redux';
 import { push } from "react-router-redux";
 import { ResourceKind, extractUuidKind } from 'models/resource';
 import { SidePanelTreeCategory } from '../side-panel-tree/side-panel-tree-actions';
-import { Routes, getProcessLogUrl, getGroupUrl, getNavUrl } from 'routes/routes';
+import { Routes, getGroupUrl, getNavUrl, getUserProfileUrl } from 'routes/routes';
 import { RootState } from 'store/store';
 import { ServiceRepository } from 'services/services';
 import { pluginConfig } from 'plugins';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { USERS_PANEL_LABEL, MY_ACCOUNT_PANEL_LABEL } from 'store/breadcrumbs/breadcrumbs-actions';
 
 const navigationNotAvailable = (id: string) =>
     snackbarActions.OPEN_SNACKBAR({
@@ -69,6 +70,12 @@ export const navigateTo = (uuid: string) =>
             case SidePanelTreeCategory.ALL_PROCESSES:
                 dispatch(navigateToAllProcesses);
                 return;
+            case USERS_PANEL_LABEL:
+                dispatch(navigateToUsers);
+                return;
+            case MY_ACCOUNT_PANEL_LABEL:
+                dispatch(navigateToMyAccount);
+                return;
         }
 
         dispatch(navigationNotAvailable(uuid));
@@ -99,8 +106,6 @@ export const pushOrGoto = (url: string): AnyAction => {
 };
 
 
-export const navigateToProcessLogs = compose(push, getProcessLogUrl);
-
 export const navigateToRootProject = (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
     navigateTo(SidePanelTreeCategory.PROJECTS)(dispatch, getState);
 };
@@ -137,6 +142,8 @@ export const navigateToKeepServices = push(Routes.KEEP_SERVICES);
 
 export const navigateToUsers = push(Routes.USERS);
 
+export const navigateToUserProfile = compose(push, getUserProfileUrl);
+
 export const navigateToApiClientAuthorizations = push(Routes.API_CLIENT_AUTHORIZATIONS);
 
 export const navigateToGroups = push(Routes.GROUPS);
index ecbd030b1c27d62d505aab7d37d779d33b80c5e6..b0ddd7ee11f70dea0c122bb5afb2b4681976ee28 100644 (file)
@@ -9,7 +9,6 @@ import { RootState } from 'store/store';
 import { ServiceRepository } from 'services/services';
 import { Dispatch } from 'redux';
 import { groupBy } from 'lodash';
-import { loadProcess } from 'store/processes/processes-actions';
 import { LogResource } from 'models/log';
 import { LogService } from 'services/log-service/log-service';
 import { ResourceEventMessage } from 'websocket/resource-event-message';
@@ -34,8 +33,8 @@ export const setProcessLogsPanelFilter = (filter: string) =>
 export const initProcessLogsPanel = (processUuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, { logService }: ServiceRepository) => {
         dispatch(processLogsPanelActions.RESET_PROCESS_LOGS_PANEL());
-        const process = await dispatch<any>(loadProcess(processUuid));
-        if (process.container) {
+        const process = getProcess(processUuid)(getState().resources);
+        if (process && process.container) {
             const logResources = await loadContainerLogs(process.container.uuid, logService);
             const initialState = createInitialLogPanelState(logResources);
             dispatch(processLogsPanelActions.INIT_PROCESS_LOGS_PANEL(initialState));
@@ -45,7 +44,7 @@ export const initProcessLogsPanel = (processUuid: string) =>
 export const addProcessLogsPanelItem = (message: ResourceEventMessage<{ text: string }>) =>
     async (dispatch: Dispatch, getState: () => RootState, { logService }: ServiceRepository) => {
         if (PROCESS_PANEL_LOG_EVENT_TYPES.indexOf(message.eventType) > -1) {
-            const uuid = getProcessLogsPanelCurrentUuid(getState());
+            const uuid = getProcessLogsPanelCurrentUuid(getState().router);
             if (uuid) {
                 const process = getProcess(uuid)(getState().resources);
                 if (process) {
@@ -53,7 +52,7 @@ export const addProcessLogsPanelItem = (message: ResourceEventMessage<{ text: st
                     if (message.objectUuid === containerRequest.uuid
                         || (container && message.objectUuid === container.uuid)) {
                         dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM({
-                            logType: SUMMARIZED_FILTER_TYPE,
+                            logType: COMBINED_FILTER_TYPE,
                             log: message.properties.text
                         }));
                         dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM({
@@ -92,8 +91,8 @@ const createInitialLogPanelState = (logResources: LogResource[]) => {
             ...grouped,
             [key]: logsToLines(groupedLogResources[key])
         }), {});
-    const filters = [SUMMARIZED_FILTER_TYPE, ...Object.keys(groupedLogs)];
-    const logs = { [SUMMARIZED_FILTER_TYPE]: allLogs, ...groupedLogs };
+    const filters = [COMBINED_FILTER_TYPE, ...Object.keys(groupedLogs)];
+    const logs = { [COMBINED_FILTER_TYPE]: allLogs, ...groupedLogs };
     return { filters, logs };
 };
 
@@ -106,13 +105,13 @@ export const navigateToLogCollection = (uuid: string) =>
             await services.collectionService.get(uuid);
             dispatch<any>(navigateTo(uuid));
         } catch {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'This collection does not exists!', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not request collection', hideDuration: 2000, kind: SnackbarKind.ERROR }));
         }
     };
 
 const MAX_AMOUNT_OF_LOGS = 10000;
 
-const SUMMARIZED_FILTER_TYPE = 'Summarized';
+const COMBINED_FILTER_TYPE = 'All logs';
 
 const PROCESS_PANEL_LOG_EVENT_TYPES = [
     LogEventType.ARV_MOUNT,
@@ -123,4 +122,5 @@ const PROCESS_PANEL_LOG_EVENT_TYPES = [
     LogEventType.NODE_INFO,
     LogEventType.STDERR,
     LogEventType.STDOUT,
+    LogEventType.CONTAINER,
 ];
index 87b50bd2f97ff89f1a7d057e580aa3093a13bf80..0ca5d679c9caa14b0e2c2fe8847d56d57f61de29 100644 (file)
@@ -2,8 +2,8 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { RootState } from '../store';
-import { matchProcessLogRoute, matchProcessRoute } from 'routes/routes';
+import { matchProcessRoute } from 'routes/routes';
+import { RouterState } from 'react-router-redux';
 
 export interface ProcessLogsPanel {
     filters: string[];
@@ -19,8 +19,8 @@ export const getProcessPanelLogs = ({ selectedFilter, logs }: ProcessLogsPanel)
     return logs[selectedFilter];
 };
 
-export const getProcessLogsPanelCurrentUuid = ({ router }: RootState) => {
+export const getProcessLogsPanelCurrentUuid = (router: RouterState) => {
     const pathname = router.location ? router.location.pathname : '';
-    const match = matchProcessLogRoute(pathname) || matchProcessRoute(pathname);
+    const match = matchProcessRoute(pathname);
     return match ? match.params.id : undefined;
 };
index de114a3ec7467bdc195b8c8ed6c737e6b7f30442..962f5dfcf3b6a123aaaa3a7993ef7844bf77a39c 100644 (file)
@@ -13,6 +13,7 @@ import { snackbarActions } from 'store/snackbar/snackbar-actions';
 import { SnackbarKind } from '../snackbar/snackbar-actions';
 import { showWorkflowDetails } from 'store/workflow-panel/workflow-panel-actions';
 import { loadSubprocessPanel } from "../subprocess-panel/subprocess-panel-actions";
+import { initProcessLogsPanel, processLogsPanelActions } from "store/process-logs-panel/process-logs-panel-actions";
 
 export const processPanelActions = unionize({
     SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID: ofType<string>(),
@@ -25,10 +26,12 @@ export type ProcessPanelAction = UnionOf<typeof processPanelActions>;
 export const toggleProcessPanelFilter = processPanelActions.TOGGLE_PROCESS_PANEL_FILTER;
 
 export const loadProcessPanel = (uuid: string) =>
-    (dispatch: Dispatch) => {
+    async (dispatch: Dispatch) => {
+        dispatch(processLogsPanelActions.RESET_PROCESS_LOGS_PANEL());
         dispatch<ProcessPanelAction>(processPanelActions.SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID(uuid));
-        dispatch<any>(loadProcess(uuid));
+        await dispatch<any>(loadProcess(uuid));
         dispatch(initProcessPanelFilters);
+        dispatch<any>(initProcessLogsPanel(uuid));
         dispatch<any>(loadSubprocessPanel());
     };
 
index 935cfa58b4bf6929a1ed6c3e27428fd4b258a79d..49c2691d9d4c21c3fdd2ee48eea60a74f2db31c6 100644 (file)
@@ -2,7 +2,16 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+import { RouterState } from "react-router-redux";
+import { matchProcessRoute } from "routes/routes";
+
 export interface ProcessPanel {
     containerRequestUuid: string;
     filters: { [status: string]: boolean };
 }
+
+export const getProcessPanelCurrentUuid = (router: RouterState) => {
+    const pathname = router.location ? router.location.pathname : '';
+    const match = matchProcessRoute(pathname);
+    return match ? match.params.id : undefined;
+};
\ No newline at end of file
index 688c8a0564e414dd7eecbffd70ec556debf1ac13..94f110a09563ab17537b44445b964f650fef2ce5 100644 (file)
@@ -49,6 +49,8 @@ import { repositoriesReducer } from 'store/repositories/repositories-reducer';
 import { keepServicesReducer } from 'store/keep-services/keep-services-reducer';
 import { UserMiddlewareService } from 'store/users/user-panel-middleware-service';
 import { USERS_PANEL_ID } from 'store/users/users-actions';
+import { UserProfileGroupsMiddlewareService } from 'store/user-profile/user-profile-groups-middleware-service';
+import { USER_PROFILE_PANEL_ID } from 'store/user-profile/user-profile-actions'
 import { GroupsPanelMiddlewareService } from 'store/groups-panel/groups-panel-middleware-service';
 import { GROUPS_PANEL_ID } from 'store/groups-panel/groups-panel-actions';
 import { GroupDetailsPanelMembersMiddlewareService } from 'store/group-details-panel/group-details-panel-members-middleware-service';
@@ -114,6 +116,9 @@ export function configureStore(history: History, services: ServiceRepository, co
     const userPanelMiddleware = dataExplorerMiddleware(
         new UserMiddlewareService(services, USERS_PANEL_ID)
     );
+    const userProfileGroupsMiddleware = dataExplorerMiddleware(
+        new UserProfileGroupsMiddlewareService(services, USER_PROFILE_PANEL_ID)
+    );
     const groupsPanelMiddleware = dataExplorerMiddleware(
         new GroupsPanelMiddlewareService(services, GROUPS_PANEL_ID)
     );
@@ -160,6 +165,7 @@ export function configureStore(history: History, services: ServiceRepository, co
         sharedWithMePanelMiddleware,
         workflowPanelMiddleware,
         userPanelMiddleware,
+        userProfileGroupsMiddleware,
         groupsPanelMiddleware,
         groupDetailsPanelMembersMiddleware,
         groupDetailsPanelPermissionsMiddleware,
diff --git a/src/store/user-profile/user-profile-actions.ts b/src/store/user-profile/user-profile-actions.ts
new file mode 100644 (file)
index 0000000..9935518
--- /dev/null
@@ -0,0 +1,177 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+import { RootState } from "store/store";
+import { Dispatch } from 'redux';
+import { initialize, reset } from "redux-form";
+import { ServiceRepository } from "services/services";
+import { bindDataExplorerActions } from "store/data-explorer/data-explorer-action";
+import { propertiesActions } from 'store/properties/properties-actions';
+import { getProperty } from 'store/properties/properties';
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { deleteResources, updateResources } from "store/resources/resources-actions";
+import { dialogActions } from "store/dialog/dialog-actions";
+import { filterResources } from "store/resources/resources";
+import { ResourceKind } from "models/resource";
+import { LinkClass, LinkResource } from "models/link";
+import { BuiltinGroups, getBuiltinGroupUuid } from "models/group";
+
+export const USER_PROFILE_PANEL_ID = 'userProfilePanel';
+export const USER_PROFILE_FORM = 'userProfileForm';
+export const DEACTIVATE_DIALOG = 'deactivateDialog';
+export const SETUP_DIALOG = 'setupDialog';
+export const ACTIVATE_DIALOG = 'activateDialog';
+export const IS_PROFILE_INACCESSIBLE = 'isProfileInaccessible';
+
+export const UserProfileGroupsActions = bindDataExplorerActions(USER_PROFILE_PANEL_ID);
+
+export const getCurrentUserProfilePanelUuid = getProperty<string>(USER_PROFILE_PANEL_ID);
+export const getUserProfileIsInaccessible = getProperty<boolean>(IS_PROFILE_INACCESSIBLE);
+
+export const loadUserProfilePanel = (userUuid?: string) =>
+  async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    // Reset isInacessible to ensure error screen is hidden
+    dispatch(propertiesActions.SET_PROPERTY({ key: IS_PROFILE_INACCESSIBLE, value: false }));
+    // Get user uuid from route or use current user uuid
+    const uuid = userUuid || getState().auth.user?.uuid;
+    if (uuid) {
+      await dispatch(propertiesActions.SET_PROPERTY({ key: USER_PROFILE_PANEL_ID, value: uuid }));
+      try {
+        const user = await services.userService.get(uuid, false);
+        dispatch(initialize(USER_PROFILE_FORM, user));
+        dispatch(updateResources([user]));
+        dispatch(UserProfileGroupsActions.REQUEST_ITEMS());
+      } catch (e) {
+        if (e.status === 404) {
+          await dispatch(propertiesActions.SET_PROPERTY({ key: IS_PROFILE_INACCESSIBLE, value: true }));
+          dispatch(reset(USER_PROFILE_FORM));
+        } else {
+          dispatch(snackbarActions.OPEN_SNACKBAR({
+            message: 'Could not load user profile',
+            kind: SnackbarKind.ERROR
+          }));
+        }
+      }
+    }
+  }
+
+export const saveEditedUser = (resource: any) =>
+  async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+      try {
+          const user = await services.userService.update(resource.uuid, resource);
+          dispatch(updateResources([user]));
+          dispatch(initialize(USER_PROFILE_FORM, user));
+          dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Profile has been updated.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+      } catch (e) {
+          dispatch(snackbarActions.OPEN_SNACKBAR({
+              message: "Could not update profile",
+              kind: SnackbarKind.ERROR,
+          }));
+      }
+  };
+
+export const openSetupDialog = (uuid: string) =>
+  (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+    dispatch(dialogActions.OPEN_DIALOG({
+      id: SETUP_DIALOG,
+      data: {
+        title: 'Setup user',
+        text: 'Are you sure you want to setup this user?',
+        confirmButtonLabel: 'Confirm',
+        uuid
+      }
+    }));
+  };
+
+export const openActivateDialog = (uuid: string) =>
+  (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+    dispatch(dialogActions.OPEN_DIALOG({
+      id: ACTIVATE_DIALOG,
+      data: {
+        title: 'Activate user',
+        text: 'Are you sure you want to activate this user?',
+        confirmButtonLabel: 'Confirm',
+        uuid
+      }
+    }));
+  };
+
+export const openDeactivateDialog = (uuid: string) =>
+  (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+    dispatch(dialogActions.OPEN_DIALOG({
+      id: DEACTIVATE_DIALOG,
+      data: {
+        title: 'Deactivate user',
+        text: 'Are you sure you want to deactivate this user?',
+        confirmButtonLabel: 'Confirm',
+        uuid
+      }
+    }));
+  };
+
+export const setup = (uuid: string) =>
+  async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    try {
+      const resources = await services.userService.setup(uuid);
+      dispatch(updateResources(resources.items));
+
+      // Refresh data explorer
+      dispatch(UserProfileGroupsActions.REQUEST_ITEMS());
+
+      dispatch(snackbarActions.OPEN_SNACKBAR({ message: "User has been setup", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+    } catch (e) {
+      dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR }));
+    } finally {
+      dispatch(dialogActions.CLOSE_DIALOG({ id: SETUP_DIALOG }));
+    }
+  };
+
+export const activate = (uuid: string) =>
+  async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    try {
+      const user = await services.userService.activate(uuid);
+      dispatch(updateResources([user]));
+
+      // Refresh data explorer
+      dispatch(UserProfileGroupsActions.REQUEST_ITEMS());
+
+      dispatch(snackbarActions.OPEN_SNACKBAR({ message: "User has been activated", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+    } catch (e) {
+      dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR }));
+    }
+  };
+
+export const deactivate = (uuid: string) =>
+  async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    try {
+      const { resources, auth } = getState();
+      // Call unsetup
+      const user = await services.userService.unsetup(uuid);
+      dispatch(updateResources([user]));
+
+      // Find and remove all users membership
+      const allUsersGroupUuid = getBuiltinGroupUuid(auth.localCluster, BuiltinGroups.ALL);
+      const memberships = filterResources((resource: LinkResource) =>
+          resource.kind === ResourceKind.LINK &&
+          resource.linkClass === LinkClass.PERMISSION &&
+          resource.headUuid === allUsersGroupUuid &&
+          resource.tailUuid === uuid
+      )(resources);
+      // Remove all users membership locally
+      dispatch<any>(deleteResources(memberships.map(link => link.uuid)));
+
+      // Refresh data explorer
+      dispatch(UserProfileGroupsActions.REQUEST_ITEMS());
+
+      dispatch(snackbarActions.OPEN_SNACKBAR({
+        message: "User has been deactivated.",
+        hideDuration: 2000,
+        kind: SnackbarKind.SUCCESS
+      }));
+    } catch (e) {
+      dispatch(snackbarActions.OPEN_SNACKBAR({
+        message: "Could not deactivate user",
+        kind: SnackbarKind.ERROR,
+      }));
+    }
+  };
diff --git a/src/store/user-profile/user-profile-groups-middleware-service.ts b/src/store/user-profile/user-profile-groups-middleware-service.ts
new file mode 100644 (file)
index 0000000..a8a650a
--- /dev/null
@@ -0,0 +1,81 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ServiceRepository } from 'services/services';
+import { MiddlewareAPI, Dispatch } from 'redux';
+import { DataExplorerMiddlewareService, listResultsToDataExplorerItemsMeta } from 'store/data-explorer/data-explorer-middleware-service';
+import { RootState } from 'store/store';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { getCurrentUserProfilePanelUuid, UserProfileGroupsActions } from 'store/user-profile/user-profile-actions';
+import { updateResources } from 'store/resources/resources-actions';
+import { FilterBuilder } from 'services/api/filter-builder';
+import { LinkClass } from 'models/link';
+import { ResourceKind } from 'models/resource';
+import { GroupClass } from 'models/group';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+
+export class UserProfileGroupsMiddlewareService extends DataExplorerMiddlewareService {
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+        const state = api.getState();
+        const userUuid = getCurrentUserProfilePanelUuid(state.properties);
+        try {
+            api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
+
+            // Get user
+            const user = await this.services.userService.get(userUuid || '');
+            api.dispatch(updateResources([user]));
+
+            // Get user's group memberships
+            const groupMembershipLinks = await this.services.permissionService.list({
+                filters: new FilterBuilder()
+                    .addEqual('tail_uuid', userUuid)
+                    .addEqual('link_class', LinkClass.PERMISSION)
+                    .addEqual('head_kind', ResourceKind.GROUP)
+                    .getFilters()
+            });
+            // Update resources, includes "project" groups
+            api.dispatch(updateResources(groupMembershipLinks.items));
+
+            // Get user's groups details and filter to role groups
+            const groups = await this.services.groupsService.list({
+                filters: new FilterBuilder()
+                    .addIn('uuid', groupMembershipLinks.items
+                        .map(item => item.headUuid))
+                    .addEqual('group_class', GroupClass.ROLE)
+                    .getFilters(),
+                count: "none"
+            });
+            api.dispatch(updateResources(groups.items));
+
+            // Get permission links for only role groups
+            const roleGroupMembershipLinks = await this.services.permissionService.list({
+                filters: new FilterBuilder()
+                    .addIn('head_uuid', groups.items.map(item => item.uuid))
+                    .addEqual('tail_uuid', userUuid)
+                    .addEqual('link_class', LinkClass.PERMISSION)
+                    .addEqual('head_kind', ResourceKind.GROUP)
+                    .getFilters()
+            });
+
+            api.dispatch(UserProfileGroupsActions.SET_ITEMS({
+                ...listResultsToDataExplorerItemsMeta(roleGroupMembershipLinks),
+                items: roleGroupMembershipLinks.items.map(item => item.uuid),
+            }));
+        } catch {
+            api.dispatch(couldNotFetchGroups());
+        } finally {
+            api.dispatch(progressIndicatorActions.STOP_WORKING(this.getId()));
+        }
+    }
+}
+
+const couldNotFetchGroups = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Could not fetch groups.',
+        kind: SnackbarKind.ERROR
+    });
index 4496cbd2709e5d33894e02c84cd319c5791c5e7f..c0589a6056e0393745800e0559d2af5193a0065e 100644 (file)
@@ -17,6 +17,8 @@ import { userBindedActions } from 'store/users/users-actions';
 import { getSortColumn } from "store/data-explorer/data-explorer-reducer";
 import { UserResource } from 'models/user';
 import { UserPanelColumnNames } from 'views/user-panel/user-panel';
+import { BuiltinGroups, getBuiltinGroupUuid } from 'models/group';
+import { LinkClass } from 'models/link';
 
 export class UserMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
@@ -27,47 +29,33 @@ export class UserMiddlewareService extends DataExplorerMiddlewareService {
         const state = api.getState();
         const dataExplorer = getDataExplorer(state.dataExplorer, this.getId());
         try {
-            const responseFirstName = await this.services.userService.list(getParamsFirstName(dataExplorer));
-            if (responseFirstName.itemsAvailable) {
-                api.dispatch(updateResources(responseFirstName.items));
-                api.dispatch(setItems(responseFirstName));
-            } else {
-                const responseLastName = await this.services.userService.list(getParamsLastName(dataExplorer));
-                api.dispatch(updateResources(responseLastName.items));
-                api.dispatch(setItems(responseLastName));
-            }
+            const users = await this.services.userService.list(getParams(dataExplorer));
+            api.dispatch(updateResources(users.items));
+            api.dispatch(setItems(users));
+
+            // Get "all users" group memberships
+            const allUsersGroupUuid = getBuiltinGroupUuid(state.auth.localCluster, BuiltinGroups.ALL);
+            const allUserMemberships = await this.services.permissionService.list({
+                filters: new FilterBuilder()
+                    .addEqual('head_uuid', allUsersGroupUuid)
+                    .addEqual('link_class', LinkClass.PERMISSION)
+                    .getFilters()
+            });
+            api.dispatch(updateResources(allUserMemberships.items));
         } catch {
             api.dispatch(couldNotFetchUsers());
         }
     }
 }
 
-const getParamsFirstName = (dataExplorer: DataExplorer) => ({
-    ...dataExplorerToListParams(dataExplorer),
-    order: getOrder(dataExplorer),
-    filters: getFiltersFirstName(dataExplorer)
-});
-
-const getParamsLastName = (dataExplorer: DataExplorer) => ({
+const getParams = (dataExplorer: DataExplorer) => ({
     ...dataExplorerToListParams(dataExplorer),
     order: getOrder(dataExplorer),
-    filters: getFiltersLastName(dataExplorer)
+    filters: new FilterBuilder()
+        .addFullTextSearch(dataExplorer.searchValue)
+        .getFilters()
 });
 
-const getFiltersFirstName = (dataExplorer: DataExplorer) => {
-    const filters = new FilterBuilder()
-        .addILike("first_name", dataExplorer.searchValue)
-        .getFilters();
-    return filters;
-};
-
-const getFiltersLastName = (dataExplorer: DataExplorer) => {
-    const filters = new FilterBuilder()
-        .addILike("last_name", dataExplorer.searchValue)
-        .getFilters();
-    return filters;
-};
-
 export const getOrder = (dataExplorer: DataExplorer) => {
     const sortColumn = getSortColumn(dataExplorer);
     const order = new OrderBuilder<UserResource>();
@@ -75,13 +63,23 @@ export const getOrder = (dataExplorer: DataExplorer) => {
         const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC
             ? OrderDirection.ASC
             : OrderDirection.DESC;
-        const columnName = sortColumn && sortColumn.name === UserPanelColumnNames.LAST_NAME ? "lastName" : "firstName";
-        return order
-            .addOrder(sortDirection, columnName)
-            .getOrder();
-    } else {
-        return order.getOrder();
+        switch (sortColumn.name) {
+            case UserPanelColumnNames.NAME:
+                order.addOrder(sortDirection, "firstName")
+                    .addOrder(sortDirection, "lastName");
+                break;
+            case UserPanelColumnNames.UUID:
+                order.addOrder(sortDirection, "uuid");
+                break;
+            case UserPanelColumnNames.EMAIL:
+                order.addOrder(sortDirection, "email");
+                break;
+            case UserPanelColumnNames.USERNAME:
+                order.addOrder(sortDirection, "username");
+                break;
+        }
     }
+    return order.getOrder();
 };
 
 export const setItems = (listResults: ListResults<UserResource>) =>
@@ -94,4 +92,4 @@ const couldNotFetchUsers = () =>
     snackbarActions.OPEN_SNACKBAR({
         message: 'Could not fetch users.',
         kind: SnackbarKind.ERROR
-    });
\ No newline at end of file
+    });
index cd4d5c734988abc7e549589aad426e2cd6bf67e5..b553b324e5aea7298f26845485ab75f3fe1f0f32 100644 (file)
@@ -8,31 +8,31 @@ import { RootState } from 'store/store';
 import { getUserUuid } from "common/getuser";
 import { ServiceRepository } from "services/services";
 import { dialogActions } from 'store/dialog/dialog-actions';
-import { startSubmit, reset } from "redux-form";
+import { startSubmit, reset, stopSubmit } from "redux-form";
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 import { UserResource } from "models/user";
-import { getResource } from 'store/resources/resources';
+import { filterResources, 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";
+import { VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD, VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD } from "store/virtual-machines/virtual-machines-actions";
+import { PermissionLevel } from "models/permission";
+import { updateResources } from "store/resources/resources-actions";
+import { BuiltinGroups, getBuiltinGroupUuid } from "models/group";
+import { LinkClass, LinkResource } from "models/link";
+import { ResourceKind } from "models/resource";
 
 export const USERS_PANEL_ID = 'usersPanel';
 export const USER_ATTRIBUTES_DIALOG = 'userAttributesDialog';
 export const USER_CREATE_FORM_NAME = 'userCreateFormName';
-export const USER_MANAGEMENT_DIALOG = 'userManageDialog';
-export const SETUP_SHELL_ACCOUNT_DIALOG = 'setupShellAccountDialog';
 
 export interface UserCreateFormDialogData {
     email: string;
-    virtualMachineName: string;
-    groupVirtualMachine: string;
+    [VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]: string;
+    [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: string[];
 }
 
-export interface SetupShellAccountFormDialogData {
-    email: string;
-    virtualMachineName: string;
-    groupVirtualMachine: string;
-}
+export const userBindedActions = bindDataExplorerActions(USERS_PANEL_ID);
 
 export const openUserAttributes = (uuid: string) =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
@@ -41,31 +41,37 @@ export const openUserAttributes = (uuid: string) =>
         dispatch(dialogActions.OPEN_DIALOG({ id: USER_ATTRIBUTES_DIALOG, data }));
     };
 
-export const openUserManagement = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const { resources } = getState();
-        const data = getResource<UserResource>(uuid)(resources);
-        dispatch(dialogActions.OPEN_DIALOG({ id: USER_MANAGEMENT_DIALOG, data }));
-    };
-
-export const openSetupShellAccount = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const { resources } = getState();
-        const user = getResource<UserResource>(uuid)(resources);
-        const virtualMachines = await services.virtualMachineService.list();
-        dispatch(dialogActions.CLOSE_DIALOG({ id: USER_MANAGEMENT_DIALOG }));
-        dispatch(dialogActions.OPEN_DIALOG({ id: SETUP_SHELL_ACCOUNT_DIALOG, data: { user, ...virtualMachines } }));
-    };
-
 export const loginAs = (uuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const { resources } = getState();
-        const data = getResource<UserResource>(uuid)(resources);
-        const client = await services.apiClientAuthorizationService.create({ ownerUuid: uuid });
-        if (data) {
-            dispatch<any>(authActions.INIT_USER({ user: data, token: getTokenV2(client) }));
-            window.location.reload();
-            dispatch<any>(navigateToRootProject);
+        const userUuid = getUserUuid(getState());
+        if (userUuid === uuid) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: 'You are already logged in as this user',
+                kind: SnackbarKind.WARNING
+            }));
+        } else {
+            try {
+                const { resources } = getState();
+                const data = getResource<UserResource>(uuid)(resources);
+                const client = await services.apiClientAuthorizationService.create({ ownerUuid: uuid }, false);
+                if (data) {
+                    dispatch<any>(authActions.INIT_USER({ user: data, token: getTokenV2(client) }));
+                    window.location.reload();
+                    dispatch<any>(navigateToRootProject);
+                }
+            } catch (e) {
+                if (e.status === 403) {
+                    dispatch(snackbarActions.OPEN_SNACKBAR({
+                        message: 'You do not have permission to login as this user',
+                        kind: SnackbarKind.WARNING
+                    }));
+                } else {
+                    dispatch(snackbarActions.OPEN_SNACKBAR({
+                        message: 'Failed to login as this user',
+                        kind: SnackbarKind.ERROR
+                    }));
+                }
+            }
         }
     };
 
@@ -84,12 +90,28 @@ export const openUserProjects = (uuid: string) =>
         dispatch<any>(navigateTo(uuid));
     };
 
-
-export const createUser = (user: UserCreateFormDialogData) =>
+export const createUser = (data: UserCreateFormDialogData) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(startSubmit(USER_CREATE_FORM_NAME));
         try {
-            const newUser = await services.userService.create({ ...user });
+            const newUser = await services.userService.create({
+                email: data.email,
+            });
+            dispatch(updateResources([newUser]));
+
+            if (data[VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]) {
+                const permission = await services.permissionService.create({
+                    headUuid: data[VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD],
+                    tailUuid: newUser.uuid,
+                    name: PermissionLevel.CAN_LOGIN,
+                    properties: {
+                        username: newUser.username,
+                        groups: data.groups,
+                    }
+                });
+                dispatch(updateResources([permission]));
+            }
+
             dispatch(dialogActions.CLOSE_DIALOG({ id: USER_CREATE_FORM_NAME }));
             dispatch(reset(USER_CREATE_FORM_NAME));
             dispatch(snackbarActions.OPEN_SNACKBAR({ message: "User has been successfully created.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
@@ -98,23 +120,8 @@ export const createUser = (user: UserCreateFormDialogData) =>
             return newUser;
         } catch (e) {
             return;
-        }
-    };
-
-
-export const setupUserVM = (setupData: SetupShellAccountFormDialogData) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(startSubmit(USER_CREATE_FORM_NAME));
-        try {
-            // TODO: make correct API call
-            // const setupResult = await services.userService.setup({ ...setupData });
-            dispatch(dialogActions.CLOSE_DIALOG({ id: SETUP_SHELL_ACCOUNT_DIALOG }));
-            dispatch(reset(SETUP_SHELL_ACCOUNT_DIALOG));
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "User has been added to VM.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
-            dispatch<any>(loadUsersPanel());
-            dispatch(userBindedActions.REQUEST_ITEMS());
-        } catch (e) {
-            return;
+        } finally {
+            dispatch(stopSubmit(USER_CREATE_FORM_NAME));
         }
     };
 
@@ -129,21 +136,6 @@ export const openUserPanel = () =>
         }
     };
 
-export const toggleIsActive = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const { resources } = getState();
-        const data = getResource<UserResource>(uuid)(resources);
-        const isActive = data!.isActive;
-        let newActivity;
-        if (isActive) {
-            newActivity = await services.userService.unsetup(uuid);
-        } else {
-            newActivity = await services.userService.update(uuid, { isActive: true });
-        }
-        dispatch<any>(loadUsersPanel());
-        return newActivity;
-    };
-
 export const toggleIsAdmin = (uuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const { resources } = getState();
@@ -154,14 +146,31 @@ export const toggleIsAdmin = (uuid: string) =>
         return newActivity;
     };
 
-export const userBindedActions = bindDataExplorerActions(USERS_PANEL_ID);
-
-export const loadUsersData = () =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        await services.userService.list({ count: "none" });
-    };
-
 export const loadUsersPanel = () =>
     (dispatch: Dispatch) => {
         dispatch(userBindedActions.REQUEST_ITEMS());
     };
+
+export enum UserAccountStatus {
+        ACTIVE = 'Active',
+        INACTIVE = 'Inactive',
+        SETUP = 'Setup',
+    }
+
+export const getUserAccountStatus = (state: RootState, uuid: string) => {
+    const user = getResource<UserResource>(uuid)(state.resources);
+    // Get membership links for all users group
+    const allUsersGroupUuid = getBuiltinGroupUuid(state.auth.localCluster, BuiltinGroups.ALL);
+    const permissions = filterResources((resource: LinkResource) =>
+        resource.kind === ResourceKind.LINK &&
+        resource.linkClass === LinkClass.PERMISSION &&
+        resource.headUuid === allUsersGroupUuid &&
+        resource.tailUuid === uuid
+    )(state.resources);
+
+    return user && user.isActive
+        ? UserAccountStatus.ACTIVE
+        : permissions.length > 0
+            ? UserAccountStatus.SETUP
+            : UserAccountStatus.INACTIVE;
+}
index e2cf6fd4c0e33da5c3da8a901d153c3937a9c0aa..7034b4a570f1ad1c135f6fc00bfdc6868b5190b0 100644 (file)
@@ -157,7 +157,7 @@ export const addUpdateVirtualMachineLogin = ({uuid, vmUuid, user, groups}: AddLo
                 dispatch(updateResources([permission]));
             } else {
                 const permission = await services.permissionService.create({
-                headUuid: vmUuid,
+                    headUuid: vmUuid,
                     tailUuid: userResource.uuid,
                     name: PermissionLevel.CAN_LOGIN,
                     properties: {
index 98508f752ead17acf5fe461582a9f128c9af4462..ba405cb8f2938f9ce190b32eaed81ec8276aa76a 100644 (file)
@@ -31,7 +31,10 @@ import {
     setProcessBreadcrumbs,
     setSharedWithMeBreadcrumbs,
     setSidePanelBreadcrumbs,
-    setTrashBreadcrumbs
+    setTrashBreadcrumbs,
+    setUsersBreadcrumbs,
+    setMyAccountBreadcrumbs,
+    setUserProfileBreadcrumbs,
 } from 'store/breadcrumbs/breadcrumbs-actions';
 import { navigateTo, navigateToRootProject } from 'store/navigation/navigation-action';
 import { MoveToFormDialogData } from 'store/move-to-dialog/move-to-dialog';
@@ -49,7 +52,6 @@ import * as processUpdateActions from 'store/processes/process-update-actions';
 import * as processCopyActions from 'store/processes/process-copy-actions';
 import { trashPanelColumns } from "views/trash-panel/trash-panel";
 import { loadTrashPanel, trashPanelActions } from "store/trash-panel/trash-panel-action";
-import { initProcessLogsPanel } from 'store/process-logs-panel/process-logs-panel-actions';
 import { loadProcessPanel } from 'store/process-panel/process-panel-actions';
 import {
     loadSharedWithMePanel,
@@ -58,7 +60,6 @@ import {
 import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog';
 import { loadWorkflowPanel, workflowPanelActions } from 'store/workflow-panel/workflow-panel-actions';
 import { loadSshKeysPanel } from 'store/auth/auth-action-ssh';
-import { loadMyAccountPanel } from 'store/my-account/my-account-panel-actions';
 import { loadLinkAccountPanel, linkAccountPanelActions } from 'store/link-account-panel/link-account-panel-actions';
 import { loadSiteManagerPanel } from 'store/auth/auth-action-session';
 import { workflowPanelColumns } from 'views/workflow-panel/workflow-panel-view';
@@ -80,6 +81,7 @@ import { loadVirtualMachinesPanel } from 'store/virtual-machines/virtual-machine
 import { loadRepositoriesPanel } from 'store/repositories/repositories-actions';
 import { loadKeepServicesPanel } from 'store/keep-services/keep-services-actions';
 import { loadUsersPanel, userBindedActions } from 'store/users/users-actions';
+import * as userProfilePanelActions from 'store/user-profile/user-profile-actions';
 import { linkPanelActions, loadLinkPanel } from 'store/link-panel/link-panel-actions';
 import { linkPanelColumns } from 'views/link-panel/link-panel-root';
 import { userPanelColumns } from 'views/user-panel/user-panel';
@@ -101,6 +103,7 @@ import { allProcessesPanelColumns } from 'views/all-processes-panel/all-processe
 import { collectionPanelFilesAction } from '../collection-panel/collection-panel-files/collection-panel-files-actions';
 import { createTree } from 'models/tree';
 import { AdminMenuIcon } from 'components/icon/icon';
+import { userProfileGroupsColumns } from 'views/user-profile-panel/user-profile-panel-root';
 
 export const WORKBENCH_LOADING_SCREEN = 'workbenchLoadingScreen';
 
@@ -139,6 +142,7 @@ export const loadWorkbench = () =>
             dispatch(groupPanelActions.GroupsPanelActions.SET_COLUMNS({ columns: groupsPanelColumns }));
             dispatch(groupDetailsPanelActions.GroupMembersPanelActions.SET_COLUMNS({ columns: groupDetailsMembersPanelColumns }));
             dispatch(groupDetailsPanelActions.GroupPermissionsPanelActions.SET_COLUMNS({ columns: groupDetailsPermissionsPanelColumns }));
+            dispatch(userProfilePanelActions.UserProfileGroupsActions.SET_COLUMNS({ columns: userProfileGroupsColumns }));
             dispatch(linkPanelActions.SET_COLUMNS({ columns: linkPanelColumns }));
             dispatch(apiClientAuthorizationsActions.SET_COLUMNS({ columns: apiClientAuthorizationPanelColumns }));
             dispatch(collectionsContentAddressActions.SET_COLUMNS({ columns: collectionContentAddressPanelColumns }));
@@ -417,15 +421,6 @@ export const copyProcess = (data: CopyFormDialogData) =>
         }
     };
 
-export const loadProcessLog = (uuid: string) =>
-    handleFirstTimeLoad(
-        async (dispatch: Dispatch) => {
-            const process = await dispatch<any>(processesActions.loadProcess(uuid));
-            dispatch<any>(setProcessBreadcrumbs(uuid));
-            dispatch<any>(initProcessLogsPanel(uuid));
-            await dispatch<any>(activateSidePanelTreeItem(process.containerRequest.ownerUuid));
-        });
-
 export const resourceIsNotLoaded = (uuid: string) =>
     snackbarActions.OPEN_SNACKBAR({
         message: `Resource identified by ${uuid} is not loaded.`,
@@ -514,10 +509,18 @@ export const loadSiteManager = handleFirstTimeLoad(
         await dispatch(loadSiteManagerPanel());
     });
 
-export const loadMyAccount = handleFirstTimeLoad(
-    (dispatch: Dispatch<any>) => {
-        dispatch(loadMyAccountPanel());
-    });
+export const loadUserProfile = (userUuid?: string) =>
+    handleFirstTimeLoad(
+        (dispatch: Dispatch<any>) => {
+            if (userUuid) {
+                dispatch(setUserProfileBreadcrumbs(userUuid));
+                dispatch(userProfilePanelActions.loadUserProfilePanel(userUuid));
+            } else {
+                dispatch(setMyAccountBreadcrumbs());
+                dispatch(userProfilePanelActions.loadUserProfilePanel());
+            }
+        }
+    );
 
 export const loadLinkAccount = handleFirstTimeLoad(
     (dispatch: Dispatch<any>) => {
@@ -532,7 +535,7 @@ export const loadKeepServices = handleFirstTimeLoad(
 export const loadUsers = handleFirstTimeLoad(
     async (dispatch: Dispatch<any>) => {
         await dispatch(loadUsersPanel());
-        dispatch(setBreadcrumbs([{ label: 'Users' }]));
+        dispatch(setUsersBreadcrumbs());
     });
 
 export const loadApiClientAuthorizations = handleFirstTimeLoad(
index 81fed2ccccdb4220822a342a38eb3af10a38a735..6e72ef689829ef3aad0b6cc1c265d0467491ee21 100644 (file)
@@ -31,6 +31,8 @@ export const PROCESS_DESCRIPTION_VALIDATION = [maxLength(255)];
 export const REPOSITORY_NAME_VALIDATION = [require, maxLength(255)];
 
 export const USER_EMAIL_VALIDATION = [require, maxLength(255)];
+export const PROFILE_EMAIL_VALIDATION = [maxLength(255)];
+export const PROFILE_URL_VALIDATION = [maxLength(255)];
 export const USER_LENGTH_VALIDATION = [maxLength(255)];
 
 export const SSH_KEY_PUBLIC_VALIDATION = [require, isRsaKey, maxLength(1024)];
index be2f82fd9629b7daa961bd56190c384e1a235fbe..55b2d31fab2202ded4367af0dd61bc80182cc351 100644 (file)
@@ -8,7 +8,7 @@ import { toggleFavorite } from "store/favorites/favorites-actions";
 import {
     RenameIcon, ShareIcon, MoveToIcon, CopyIcon, DetailsIcon,
     RemoveIcon, ReRunProcessIcon, InputIcon, OutputIcon, CommandIcon,
-    LogIcon, AdvancedIcon
+    AdvancedIcon
 } from "components/icon/icon";
 import { favoritePanelActions } from "store/favorite-panel/favorite-panel-action";
 import { openMoveProcessDialog } from 'store/processes/process-move-actions';
@@ -21,7 +21,6 @@ import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
 import { openProcessInputDialog } from "store/processes/process-input-actions";
 import { navigateToOutput } from "store/process-panel/process-panel-actions";
 import { openProcessCommandDialog } from "store/processes/process-command-actions";
-import { navigateToProcessLogs } from "store/navigation/navigation-action";
 import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
 import { TogglePublicFavoriteAction } from "../actions/public-favorite-action";
 import { togglePublicFavorite } from "store/public-favorites/public-favorites-actions";
@@ -77,13 +76,6 @@ export const readOnlyProcessResourceActionSet: ContextMenuActionSet = [[
             dispatch<any>(openProcessCommandDialog(resource.uuid));
         }
     },
-    {
-        icon: LogIcon,
-        name: "Log",
-        execute: (dispatch, resource) => {
-            dispatch<any>(navigateToProcessLogs(resource.uuid));
-        }
-    },
     {
         icon: DetailsIcon,
         name: "View details",
index 2edb12d9fdb1e22a2d4b8255cc6205a5cceb9147..c298e1ab33ba1ac24564acbbee1f0d8bbb0f2040 100644 (file)
@@ -3,9 +3,21 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
-import { AdvancedIcon, ProjectIcon, AttributesIcon } from "components/icon/icon";
+import {
+    AdvancedIcon,
+    ProjectIcon,
+    AttributesIcon,
+    DeactivateUserIcon,
+    UserPanelIcon,
+    LoginAsIcon,
+    AdminMenuIcon,
+    ActiveIcon,
+} from "components/icon/icon";
 import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
-import { openUserAttributes, openUserProjects } from "store/users/users-actions";
+import { loginAs, openUserAttributes, openUserProjects } from "store/users/users-actions";
+import { openSetupDialog, openDeactivateDialog, openActivateDialog } from "store/user-profile/user-profile-actions";
+import { navigateToUserProfile } from "store/navigation/navigation-action";
+import { canActivateUser, canDeactivateUser, canSetupUser, isAdmin, needsUserProfileLink, isOtherUser } from "store/context-menu/context-menu-filters";
 
 export const userActionSet: ContextMenuActionSet = [[{
     name: "Attributes",
@@ -25,13 +37,51 @@ export const userActionSet: ContextMenuActionSet = [[{
     execute: (dispatch, { uuid }) => {
         dispatch<any>(openAdvancedTabDialog(uuid));
     }
-}, /*
-    // Neither of the buttons on this dialog work correctly (bugs #16114 and #16124) so hide it for now.
-    {
-    name: "Manage",
+}, {
+    name: "Account Settings",
     icon: UserPanelIcon,
     execute: (dispatch, { uuid }) => {
-        dispatch<any>(openUserManagement(uuid));
-    }
-} */
-]];
+        dispatch<any>(navigateToUserProfile(uuid));
+    },
+    filters: [needsUserProfileLink]
+}],[{
+    name: "Activate User",
+    icon: ActiveIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(openActivateDialog(uuid));
+    },
+    filters: [
+        isAdmin,
+        canActivateUser,
+    ],
+}, {
+    name: "Setup User",
+    icon: AdminMenuIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(openSetupDialog(uuid));
+    },
+    filters: [
+        isAdmin,
+        canSetupUser,
+    ],
+}, {
+    name: "Deactivate User",
+    icon: DeactivateUserIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(openDeactivateDialog(uuid));
+    },
+    filters: [
+        isAdmin,
+        canDeactivateUser,
+    ],
+}, {
+    name: "Login As User",
+    icon: LoginAsIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(loginAs(uuid));
+    },
+    filters: [
+        isAdmin,
+        isOtherUser,
+    ],
+}]];
index f2c43ced1f9e0ded2aa6277f72799e45d974863d..6f3a4389211363e9294bbfe3c53fdf530d32195a 100644 (file)
@@ -14,9 +14,19 @@ import { sortByProperty } from "common/array-utils";
 type DataProps = Pick<ContextMenuProps, "anchorEl" | "items" | "open"> & { resource?: ContextMenuResource };
 const mapStateToProps = (state: RootState): DataProps => {
     const { open, position, resource } = state.contextMenu;
+
+    const filteredItems = getMenuActionSet(resource).map((group) => (group.filter((item) => {
+        if (resource && item.filters) {
+            // Execute all filters on this item, every returns true IFF all filters return true
+            return item.filters.every((filter) => filter(state, resource));
+        } else {
+            return true;
+        }
+    })));
+
     return {
         anchorEl: resource ? createAnchorAt(position) : undefined,
-        items: getMenuActionSet(resource),
+        items: filteredItems,
         open,
         resource
     };
@@ -59,9 +69,9 @@ export const addMenuActionSet = (name: string, itemSet: ContextMenuActionSet) =>
 };
 
 const emptyActionSet: ContextMenuActionSet = [];
-const getMenuActionSet = (resource?: ContextMenuResource): ContextMenuActionSet => {
-    return resource ? menuActionSets.get(resource.menuKind) || emptyActionSet : emptyActionSet;
-};
+const getMenuActionSet = (resource?: ContextMenuResource): ContextMenuActionSet => (
+   resource ? menuActionSets.get(resource.menuKind) || emptyActionSet : emptyActionSet
+);
 
 export enum ContextMenuKind {
     API_CLIENT_AUTHORIZATION = "ApiClientAuthorization",
index a2acaca4525e6bceb6d65f869dae09f986412c21..e854da0ee6caeebfdfd41af58dc8a081b58ecf0e 100644 (file)
@@ -6,7 +6,21 @@ import React from 'react';
 import { Grid, Typography, withStyles, Tooltip, IconButton, Checkbox } from '@material-ui/core';
 import { FavoriteStar, PublicFavoriteStar } from '../favorite-star/favorite-star';
 import { Resource, ResourceKind, TrashableResource } from 'models/resource';
-import { ProjectIcon, FilterGroupIcon, CollectionIcon, ProcessIcon, DefaultIcon, ShareIcon, CollectionOldVersionIcon, WorkflowIcon, RemoveIcon, RenameIcon } from 'components/icon/icon';
+import {
+    ProjectIcon,
+    FilterGroupIcon,
+    CollectionIcon,
+    ProcessIcon,
+    DefaultIcon,
+    ShareIcon,
+    CollectionOldVersionIcon,
+    WorkflowIcon,
+    RemoveIcon,
+    RenameIcon,
+    ActiveIcon,
+    SetupIcon,
+    InactiveIcon,
+} from 'components/icon/icon';
 import { formatDate, formatFileSize, formatTime } from 'common/formatters';
 import { resourceLabel } from 'common/labels';
 import { connect, DispatchProp } from 'react-redux';
@@ -21,14 +35,14 @@ import { ResourceStatus as WorkflowStatus } from 'views/workflow-panel/workflow-
 import { getUuidPrefix, openRunProcess } from 'store/workflow-panel/workflow-panel-actions';
 import { openSharingDialog } from 'store/sharing-dialog/sharing-dialog-actions';
 import { getUserFullname, getUserDisplayName, User, UserResource } from 'models/user';
-import { toggleIsActive, toggleIsAdmin } from 'store/users/users-actions';
+import { toggleIsAdmin } from 'store/users/users-actions';
 import { LinkClass, LinkResource } from 'models/link';
-import { navigateTo, navigateToGroupDetails } from 'store/navigation/navigation-action';
+import { navigateTo, navigateToGroupDetails, navigateToUserProfile } from 'store/navigation/navigation-action';
 import { withResourceData } from 'views-components/data-explorer/with-resources';
 import { CollectionResource } from 'models/collection';
 import { IllegalNamingWarning } from 'components/warning/warning';
 import { loadResource } from 'store/resources/resources-actions';
-import { GroupClass, GroupResource, isBuiltinGroup } from 'models/group';
+import { BuiltinGroups, getBuiltinGroupUuid, GroupClass, GroupResource, isBuiltinGroup } from 'models/group';
 import { openRemoveGroupMemberDialog } from 'store/group-details-panel/group-details-panel-actions';
 import { setMemberIsHidden } from 'store/group-details-panel/group-details-panel-actions';
 import { formatPermissionLevel } from 'views-components/sharing-dialog/permission-select';
@@ -36,6 +50,7 @@ import { PermissionLevel } from 'models/permission';
 import { openPermissionEditContextMenu } from 'store/context-menu/context-menu-actions';
 import { getUserUuid } from 'common/getuser';
 import { VirtualMachinesResource } from 'models/virtual-machines';
+import { CopyToClipboardSnackbar } from 'components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar';
 
 const renderName = (dispatch: Dispatch, item: GroupContentsResource) => {
 
@@ -161,24 +176,32 @@ export const ResourceLastName = connect(
         return resource || { lastName: '' };
     })(renderLastName);
 
-const renderFullName = (item: { firstName: string, lastName: string }) =>
-    <Typography noWrap>{(item.firstName + " " + item.lastName).trim()}</Typography>;
+const renderFullName = (dispatch: Dispatch ,item: { uuid: string, firstName: string, lastName: string }, link?: boolean) => {
+    const displayName = (item.firstName + " " + item.lastName).trim() || item.uuid;
+    return link ? <Typography noWrap
+        color="primary"
+        style={{ 'cursor': 'pointer' }}
+        onClick={() => dispatch<any>(navigateToUserProfile(item.uuid))}>
+            {displayName}
+    </Typography> :
+    <Typography noWrap>{displayName}</Typography>;
+}
 
-export const ResourceFullName = connect(
-    (state: RootState, props: { uuid: string }) => {
+export const UserResourceFullName = connect(
+    (state: RootState, props: { uuid: string, link?: boolean }) => {
         const resource = getResource<UserResource>(props.uuid)(state.resources);
-        return resource || { firstName: '', lastName: '' };
-    })(renderFullName);
-
+        return {item: resource || { uuid: '', firstName: '', lastName: '' }, link: props.link};
+    })((props: {item: {uuid: string, firstName: string, lastName: string}, link?: boolean} & DispatchProp<any>) => renderFullName(props.dispatch, props.item, props.link));
 
 const renderUuid = (item: { uuid: string }) =>
-    <Typography data-cy="uuid" noWrap>{item.uuid}</Typography>;
+    <Typography data-cy="uuid" noWrap>
+        {item.uuid}
+        <CopyToClipboardSnackbar value={item.uuid} />
+    </Typography>;
 
-export const ResourceUuid = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<UserResource>(props.uuid)(state.resources);
-        return resource || { uuid: '' };
-    })(renderUuid);
+export const ResourceUuid = connect((state: RootState, props: { uuid: string }) => (
+        getResource<UserResource>(props.uuid)(state.resources) || { uuid: '' }
+    ))(renderUuid);
 
 const renderEmail = (item: { email: string }) =>
     <Typography noWrap>{item.email}</Typography>;
@@ -189,33 +212,61 @@ export const ResourceEmail = connect(
         return resource || { email: '' };
     })(renderEmail);
 
-const renderIsActive = (props: { uuid: string, kind: ResourceKind, isActive: boolean, toggleIsActive: (uuid: string) => void, disabled?: boolean }) => {
-    if (props.kind === ResourceKind.USER) {
-        return <Checkbox
-            color="primary"
-            checked={props.isActive}
-            disabled={!!props.disabled}
-            onClick={() => props.toggleIsActive(props.uuid)} />;
+enum UserAccountStatus {
+    ACTIVE = 'Active',
+    INACTIVE = 'Inactive',
+    SETUP = 'Setup',
+    UNKNOWN = ''
+}
+
+const renderAccountStatus = (props: {status: UserAccountStatus}) =>
+    <Grid container alignItems="center" wrap="nowrap" spacing={8} data-cy="account-status">
+        <Grid item>
+            {(() => {
+                switch(props.status) {
+                    case UserAccountStatus.ACTIVE:
+                        return <ActiveIcon style={{color: '#4caf50', verticalAlign: "middle"}} />;
+                    case UserAccountStatus.SETUP:
+                        return <SetupIcon style={{color: '#2196f3', verticalAlign: "middle"}} />;
+                    case UserAccountStatus.INACTIVE:
+                        return <InactiveIcon style={{color: '#9e9e9e', verticalAlign: "middle"}} />;
+                    default:
+                        return <></>;
+                }
+            })()}
+        </Grid>
+        <Grid item>
+            <Typography noWrap>
+                {props.status}
+            </Typography>
+        </Grid>
+    </Grid>;
+
+const getUserAccountStatus = (state: RootState, props: { uuid: string }) => {
+    const user = getResource<UserResource>(props.uuid)(state.resources);
+    // Get membership links for all users group
+    const allUsersGroupUuid = getBuiltinGroupUuid(state.auth.localCluster, BuiltinGroups.ALL);
+    const permissions = filterResources((resource: LinkResource) =>
+        resource.kind === ResourceKind.LINK &&
+        resource.linkClass === LinkClass.PERMISSION &&
+        resource.headUuid === allUsersGroupUuid &&
+        resource.tailUuid === props.uuid
+    )(state.resources);
+
+    if (user) {
+        return user.isActive ? {status: UserAccountStatus.ACTIVE} : permissions.length > 0 ? {status: UserAccountStatus.SETUP} : {status: UserAccountStatus.INACTIVE};
     } else {
-        return <Typography />;
+        return {status: UserAccountStatus.UNKNOWN};
     }
 }
 
-export const ResourceIsActive = connect(
-    (state: RootState, props: { uuid: string, disabled?: boolean }) => {
-        const resource = getResource<UserResource>(props.uuid)(state.resources);
-        return resource ? {...resource, disabled: !!props.disabled} : { isActive: false, kind: ResourceKind.NONE };
-    }, { toggleIsActive }
-)(renderIsActive);
-
-export const ResourceLinkTailIsActive = connect(
-    (state: RootState, props: { uuid: string, disabled?: boolean }) => {
+export const ResourceLinkTailAccountStatus = connect(
+    (state: RootState, props: { uuid: string }) => {
         const link = getResource<LinkResource>(props.uuid)(state.resources);
-        const tailResource = getResource<UserResource>(link?.tailUuid || '')(state.resources);
+        return link && link.tailKind === ResourceKind.USER ? getUserAccountStatus(state, {uuid: link.tailUuid}) : {status: UserAccountStatus.UNKNOWN};
+    })(renderAccountStatus);
 
-        return tailResource ? {...tailResource, disabled: !!props.disabled} : { isActive: false, kind: ResourceKind.NONE };
-    }, { toggleIsActive }
-)(renderIsActive);
+export const UserResourceAccountStatus = connect(getUserAccountStatus)(renderAccountStatus);
 
 const renderIsHidden = (props: {
                             memberLinkUuid: string,
@@ -230,7 +281,10 @@ const renderIsHidden = (props: {
                 color="primary"
                 checked={props.visible}
                 disabled={!props.canManage}
-                onClick={() => props.setMemberIsHidden(props.memberLinkUuid, props.permissionLinkUuid, !props.visible)} />;
+                onClick={(e) => {
+                    e.stopPropagation();
+                    props.setMemberIsHidden(props.memberLinkUuid, props.permissionLinkUuid, !props.visible);
+                }} />;
     } else {
         return <Typography />;
     }
@@ -263,7 +317,10 @@ const renderIsAdmin = (props: { uuid: string, isAdmin: boolean, toggleIsAdmin: (
     <Checkbox
         color="primary"
         checked={props.isAdmin}
-        onClick={() => props.toggleIsAdmin(props.uuid)} />;
+        onClick={(e) => {
+            e.stopPropagation();
+            props.toggleIsAdmin(props.uuid);
+        }} />;
 
 export const ResourceIsAdmin = connect(
     (state: RootState, props: { uuid: string }) => {
index d8d25da494c57ff587557039c62d1a828afb7822..6be7b28f0ebea3f5d370d7397cfdbd2e8ec228c6 100644 (file)
@@ -7,8 +7,16 @@ import { InjectedFormProps } from 'redux-form';
 import { WithDialogProps } from 'store/dialog/with-dialog';
 import { FormDialog } from 'components/form-dialog/form-dialog';
 import { UserEmailField, UserVirtualMachineField, UserGroupsVirtualMachineField } from 'views-components/form-fields/user-form-fields';
+import { UserCreateFormDialogData } from 'store/users/users-actions';
+import { UserResource } from 'models/user';
+import { VirtualMachinesResource } from 'models/virtual-machines';
 
-export type DialogUserProps = WithDialogProps<{}> & InjectedFormProps<any>;
+export type DialogUserProps = WithDialogProps<{}> & InjectedFormProps<UserCreateFormDialogData>;
+
+interface DataProps {
+    user: UserResource;
+    items: VirtualMachinesResource[];
+}
 
 export const UserRepositoryCreate = (props: DialogUserProps) =>
     <FormDialog
@@ -20,6 +28,6 @@ export const UserRepositoryCreate = (props: DialogUserProps) =>
 
 const UserAddFields = (props: DialogUserProps) => <span>
     <UserEmailField />
-    <UserVirtualMachineField data={props.data}/>
+    <UserVirtualMachineField data={props.data as DataProps}/>
     <UserGroupsVirtualMachineField />
 </span>;
diff --git a/src/views-components/dialog-forms/setup-shell-account-dialog.tsx b/src/views-components/dialog-forms/setup-shell-account-dialog.tsx
deleted file mode 100644 (file)
index 3bf700b..0000000
+++ /dev/null
@@ -1,86 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-import React from 'react';
-import { compose } from "redux";
-import { reduxForm, InjectedFormProps, Field } from 'redux-form';
-import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
-import { FormDialog } from 'components/form-dialog/form-dialog';
-import { TextField } from 'components/text-field/text-field';
-import { VirtualMachinesResource } from 'models/virtual-machines';
-import { USER_LENGTH_VALIDATION, CHOOSE_VM_VALIDATION } from 'validators/validators';
-import { InputLabel } from '@material-ui/core';
-import { NativeSelectField } from 'components/select-field/select-field';
-import { SetupShellAccountFormDialogData, SETUP_SHELL_ACCOUNT_DIALOG, setupUserVM } from 'store/users/users-actions';
-import { UserResource } from 'models/user';
-
-export const SetupShellAccountDialog = compose(
-    withDialog(SETUP_SHELL_ACCOUNT_DIALOG),
-    reduxForm<SetupShellAccountFormDialogData>({
-        form: SETUP_SHELL_ACCOUNT_DIALOG,
-        onSubmit: (data, dispatch) => {
-            dispatch(setupUserVM(data));
-        }
-    })
-)(
-    (props: SetupShellAccountDialogComponentProps) =>
-        <FormDialog
-            dialogTitle='Setup shell account'
-            formFields={SetupShellAccountFormFields}
-            submitLabel='Submit'
-            {...props}
-        />
-);
-
-interface UserProps {
-    data: {
-        user: UserResource;
-    };
-}
-
-interface VirtualMachinesProps {
-    data: {
-        items: VirtualMachinesResource[];
-    };
-}
-interface DataProps {
-    user: UserResource;
-    items: VirtualMachinesResource[];
-}
-
-const UserEmailField = ({ data }: UserProps) =>
-    <span>
-        <Field
-            name='email'
-            component={TextField as any}
-            disabled
-            label={data.user.email} /></span>;
-
-const UserVirtualMachineField = ({ data }: VirtualMachinesProps) =>
-    <div style={{ marginBottom: '21px' }}>
-        <InputLabel>Virtual Machine</InputLabel>
-        <Field
-            name='virtualMachine'
-            component={NativeSelectField as any}
-            validate={CHOOSE_VM_VALIDATION}
-            items={getVirtualMachinesList(data.items)} />
-    </div>;
-
-const UserGroupsVirtualMachineField = () =>
-    <Field
-        name='groups'
-        component={TextField as any}
-        validate={USER_LENGTH_VALIDATION}
-        label="Groups for virtual machine (comma separated list)" />;
-
-const getVirtualMachinesList = (virtualMachines: VirtualMachinesResource[]) =>
-    [{ key: "", value: "" }].concat(virtualMachines.map(it => ({ key: it.hostname, value: it.hostname })));
-
-type SetupShellAccountDialogComponentProps = WithDialogProps<{}> & InjectedFormProps<SetupShellAccountFormDialogData>;
-
-const SetupShellAccountFormFields = (props: SetupShellAccountDialogComponentProps) =>
-    <>
-        <UserEmailField data={props.data as DataProps} />
-        <UserVirtualMachineField data={props.data as DataProps} />
-        <UserGroupsVirtualMachineField />
-    </>;
index 393f29d325a6f28321b8f8effa90ca06d2d66c47..12fc91e2423553b62ceb71bc474c120d6aca04c0 100644 (file)
@@ -5,10 +5,18 @@
 import React from "react";
 import { Field } from "redux-form";
 import { TextField } from "components/text-field/text-field";
-import { USER_EMAIL_VALIDATION, USER_LENGTH_VALIDATION } from "validators/validators";
+import { USER_EMAIL_VALIDATION, CHOOSE_VM_VALIDATION } from "validators/validators";
 import { NativeSelectField } from "components/select-field/select-field";
 import { InputLabel } from "@material-ui/core";
 import { VirtualMachinesResource } from "models/virtual-machines";
+import { VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD, VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD } from "store/virtual-machines/virtual-machines-actions";
+import { GroupArrayInput } from "views-components/virtual-machines-dialog/group-array-input";
+
+interface VirtualMachinesProps {
+    data: {
+        items: VirtualMachinesResource[];
+    };
+}
 
 export const UserEmailField = () =>
     <Field
@@ -18,24 +26,31 @@ export const UserEmailField = () =>
         autoFocus={true}
         label="Email" />;
 
-export const UserVirtualMachineField = ({ data }: any) =>
+export const RequiredUserVirtualMachineField = ({ data }: VirtualMachinesProps) =>
+    <div style={{ marginBottom: '21px' }}>
+        <InputLabel>Virtual Machine</InputLabel>
+        <Field
+            name={VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD}
+            component={NativeSelectField as any}
+            validate={CHOOSE_VM_VALIDATION}
+            items={getVirtualMachinesList(data.items)} />
+    </div>;
+
+export const UserVirtualMachineField = ({ data }: VirtualMachinesProps) =>
     <div style={{ marginBottom: '21px' }}>
         <InputLabel>Virtual Machine</InputLabel>
         <Field
-            name='virtualMachine'
+            name={VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD}
             component={NativeSelectField as any}
-            validate={USER_LENGTH_VALIDATION}
             items={getVirtualMachinesList(data.items)} />
     </div>;
 
 export const UserGroupsVirtualMachineField = () =>
-    <Field
-        name='groups'
-        component={TextField as any}
-        validate={USER_LENGTH_VALIDATION}
-        label="Groups for virtual machine (comma separated list)" />;
+    <GroupArrayInput
+        name={VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD}
+        input={{id:"Add groups to VM login (eg: docker, sudo)", disabled:false}}
+        required={false}
+    />
 
-const getVirtualMachinesList = (virtualMachines: VirtualMachinesResource[]) => {
-    const mappedVirtualMachines = virtualMachines.map(it => ({ key: it.hostname, value: it.hostname }));
-    return mappedVirtualMachines;
-};
+const getVirtualMachinesList = (virtualMachines: VirtualMachinesResource[]) =>
+    [{ key: "", value: "" }].concat(virtualMachines.map(it => ({ key: it.uuid, value: it.hostname })));
diff --git a/src/views-components/user-dialog/activate-dialog.tsx b/src/views-components/user-dialog/activate-dialog.tsx
new file mode 100644 (file)
index 0000000..79e8330
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { activate, ACTIVATE_DIALOG } from 'store/user-profile/user-profile-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(activate(props.data.uuid));
+    }
+});
+
+export const ActivateDialog = compose(
+    withDialog(ACTIVATE_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
diff --git a/src/views-components/user-dialog/deactivate-dialog.tsx b/src/views-components/user-dialog/deactivate-dialog.tsx
new file mode 100644 (file)
index 0000000..8aefa92
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { deactivate, DEACTIVATE_DIALOG } from 'store/user-profile/user-profile-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(deactivate(props.data.uuid));
+    }
+});
+
+export const DeactivateDialog = compose(
+    withDialog(DEACTIVATE_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
diff --git a/src/views-components/user-dialog/manage-dialog.tsx b/src/views-components/user-dialog/manage-dialog.tsx
deleted file mode 100644 (file)
index b812f5c..0000000
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import React from "react";
-import { compose, Dispatch } from "redux";
-import { connect } from "react-redux";
-import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography } from "@material-ui/core";
-import { WithDialogProps } from "store/dialog/with-dialog";
-import { withDialog } from 'store/dialog/with-dialog';
-import { WithStyles, withStyles } from '@material-ui/core/styles';
-import { ArvadosTheme } from 'common/custom-theme';
-import { USER_MANAGEMENT_DIALOG, openSetupShellAccount, loginAs } from "store/users/users-actions";
-import { getUserDisplayName } from "models/user";
-
-type CssRules = 'spacing';
-
-const styles = withStyles<CssRules>((theme: ArvadosTheme) => ({
-    spacing: {
-        paddingBottom: theme.spacing.unit * 2,
-        paddingTop: theme.spacing.unit * 2,
-    }
-}));
-
-interface UserManageDataProps {
-    data: any;
-}
-
-interface UserManageActionProps {
-    openSetupShellAccount: (uuid: string) => void;
-    loginAs: (uuid: string) => void;
-}
-
-const mapDispatchToProps = (dispatch: Dispatch) => ({
-    openSetupShellAccount: (uuid: string) => dispatch<any>(openSetupShellAccount(uuid)),
-    loginAs: (uuid: string) => dispatch<any>(loginAs(uuid))
-});
-
-type UserManageProps = UserManageDataProps & UserManageActionProps & WithStyles<CssRules>;
-
-export const UserManageDialog = compose(
-    connect(null, mapDispatchToProps),
-    withDialog(USER_MANAGEMENT_DIALOG),
-    styles)(
-        (props: WithDialogProps<UserManageProps> & UserManageProps) =>
-            <Dialog open={props.open}
-                onClose={props.closeDialog}
-                fullWidth
-                maxWidth="md">
-                {props.data &&
-                    <span>
-                        <DialogTitle>{`Manage - ${getUserDisplayName(props.data)}`}</DialogTitle>
-                        <DialogContent>
-                            <Typography variant='body1' className={props.classes.spacing}>
-                                As an admin, you can log in as this user. When you’ve finished, you will need to log out and log in again with your own account.
-                    </Typography>
-                            <Button variant="contained" color="primary" onClick={() => props.loginAs(props.data.uuid)}>
-                                {`LOG IN AS ${getUserDisplayName(props.data)}`}
-                            </Button>
-                            <Typography variant='body1' className={props.classes.spacing}>
-                                As an admin, you can setup a shell account for this user. The login name is automatically generated from the user's e-mail address.
-                    </Typography>
-                            <Button variant="contained" color="primary" onClick={() => props.openSetupShellAccount(props.data.uuid)}>
-                                {`SETUP SHELL ACCOUNT FOR ${getUserDisplayName(props.data)}`}
-                            </Button>
-                        </DialogContent></span>}
-
-                <DialogActions>
-                    <Button
-                        variant='text'
-                        color='primary'
-                        onClick={props.closeDialog}>
-                        Close
-                </Button>
-                </DialogActions>
-            </Dialog>
-    );
diff --git a/src/views-components/user-dialog/setup-dialog.tsx b/src/views-components/user-dialog/setup-dialog.tsx
new file mode 100644 (file)
index 0000000..3a2fd35
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { setup, SETUP_DIALOG } from 'store/user-profile/user-profile-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(setup(props.data.uuid));
+    }
+});
+
+export const SetupDialog = compose(
+    withDialog(SETUP_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
index ce3f34c75348d39c4dbd1ee63df9b2e06a70d73c..9cee3cbc2fd2dc9d2a5f460b8244c17876aa7729 100644 (file)
@@ -7,7 +7,7 @@ import { connect } from 'react-redux';
 
 import { DataExplorer } from "views-components/data-explorer/data-explorer";
 import { DataColumns } from 'components/data-table/data-table';
-import { ResourceLinkHeadUuid, ResourceLinkTailUsername, ResourceLinkHeadPermissionLevel, ResourceLinkTailPermissionLevel, ResourceLinkHead, ResourceLinkTail, ResourceLinkDelete, ResourceLinkTailIsActive, ResourceLinkTailIsVisible } from 'views-components/data-explorer/renderers';
+import { ResourceLinkHeadUuid, ResourceLinkTailUsername, ResourceLinkHeadPermissionLevel, ResourceLinkTailPermissionLevel, ResourceLinkHead, ResourceLinkTail, ResourceLinkDelete, ResourceLinkTailAccountStatus, ResourceLinkTailIsVisible } from 'views-components/data-explorer/renderers';
 import { createTree } from 'models/tree';
 import { noop } from 'lodash/fp';
 import { RootState } from 'store/store';
@@ -31,7 +31,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 export enum GroupDetailsPanelMembersColumnNames {
     FULL_NAME = "Name",
     USERNAME = "Username",
-    ACTIVE = "User Active",
+    STATUS = "Account Status",
     VISIBLE = "Visible to other members",
     PERMISSION = "Permission",
     REMOVE = "Remove",
@@ -60,11 +60,11 @@ export const groupDetailsMembersPanelColumns: DataColumns<string> = [
         render: uuid => <ResourceLinkTailUsername uuid={uuid} />
     },
     {
-        name: GroupDetailsPanelMembersColumnNames.ACTIVE,
+        name: GroupDetailsPanelMembersColumnNames.STATUS,
         selected: true,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceLinkTailIsActive uuid={uuid} disabled={true} />
+        render: uuid => <ResourceLinkTailAccountStatus uuid={uuid} />
     },
     {
         name: GroupDetailsPanelMembersColumnNames.VISIBLE,
diff --git a/src/views/my-account-panel/my-account-panel-root.tsx b/src/views/my-account-panel/my-account-panel-root.tsx
deleted file mode 100644 (file)
index 283b9ac..0000000
+++ /dev/null
@@ -1,164 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import React from 'react';
-import { Field, InjectedFormProps, WrappedFieldProps } from "redux-form";
-import { TextField } from "components/text-field/text-field";
-import { NativeSelectField } from "components/select-field/select-field";
-import {
-    StyleRulesCallback,
-    WithStyles,
-    withStyles,
-    Card,
-    CardContent,
-    Button,
-    Typography,
-    Grid,
-    InputLabel
-} from '@material-ui/core';
-import { ArvadosTheme } from 'common/custom-theme';
-import { User } from "models/user";
-import { MY_ACCOUNT_VALIDATION } from "validators/validators";
-
-type CssRules = 'root' | 'gridItem' | 'label' | 'title' | 'actions';
-
-const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
-    root: {
-        width: '100%',
-        overflow: 'auto'
-    },
-    gridItem: {
-        height: 45,
-        marginBottom: 20
-    },
-    label: {
-        fontSize: '0.675rem'
-    },
-    title: {
-        marginBottom: theme.spacing.unit * 3,
-        color: theme.palette.grey["600"]
-    },
-    actions: {
-        display: 'flex',
-        justifyContent: 'flex-end'
-    }
-});
-
-export interface MyAccountPanelRootActionProps { }
-
-export interface MyAccountPanelRootDataProps {
-    isPristine: boolean;
-    isValid: boolean;
-    initialValues?: User;
-    localCluster: string;
-}
-
-const RoleTypes = [
-    { key: 'Bio-informatician', value: 'Bio-informatician' },
-    { key: 'Data Scientist', value: 'Data Scientist' },
-    { key: 'Analyst', value: 'Analyst' },
-    { key: 'Researcher', value: 'Researcher' },
-    { key: 'Software Developer', value: 'Software Developer' },
-    { key: 'System Administrator', value: 'System Administrator' },
-    { key: 'Other', value: 'Other' }
-];
-
-type MyAccountPanelRootProps = InjectedFormProps<MyAccountPanelRootActionProps> & MyAccountPanelRootDataProps & WithStyles<CssRules>;
-
-type LocalClusterProp = { localCluster: string };
-const renderField: React.ComponentType<WrappedFieldProps & LocalClusterProp> = ({ input, localCluster }) => (
-    <span>{localCluster === input.value.substring(0, 5) ? "" : "federated"} user {input.value}</span>
-);
-
-export const MyAccountPanelRoot = withStyles(styles)(
-    ({ classes, isValid, handleSubmit, reset, isPristine, invalid, submitting, localCluster }: MyAccountPanelRootProps) => {
-        return <Card className={classes.root}>
-            <CardContent>
-                <Typography variant="title" className={classes.title}>
-                    Logged in as <Field name="uuid" component={renderField} localCluster={localCluster} />
-                </Typography>
-                <form onSubmit={handleSubmit}>
-                    <Grid container spacing={24}>
-                        <Grid item className={classes.gridItem} sm={6} xs={12}>
-                            <Field
-                                label="First name"
-                                name="firstName"
-                                component={TextField as any}
-                                disabled
-                            />
-                        </Grid>
-                        <Grid item className={classes.gridItem} sm={6} xs={12}>
-                            <Field
-                                label="Last name"
-                                name="lastName"
-                                component={TextField as any}
-                                disabled
-                            />
-                        </Grid>
-                        <Grid item className={classes.gridItem} sm={6} xs={12}>
-                            <Field
-                                label="E-mail"
-                                name="email"
-                                component={TextField as any}
-                                disabled
-                            />
-                        </Grid>
-                        <Grid item className={classes.gridItem} sm={6} xs={12}>
-                            <Field
-                                label="Username"
-                                name="username"
-                                component={TextField as any}
-                                disabled
-                            />
-                        </Grid>
-                        <Grid item className={classes.gridItem} sm={6} xs={12}>
-                            <Field
-                                label="Organization"
-                                name="prefs.profile.organization"
-                                component={TextField as any}
-                                validate={MY_ACCOUNT_VALIDATION}
-                                required
-                            />
-                        </Grid>
-                        <Grid item className={classes.gridItem} sm={6} xs={12}>
-                            <Field
-                                label="E-mail at Organization"
-                                name="prefs.profile.organization_email"
-                                component={TextField as any}
-                                validate={MY_ACCOUNT_VALIDATION}
-                                required
-                            />
-                        </Grid>
-                        <Grid item className={classes.gridItem} sm={6} xs={12}>
-                            <InputLabel className={classes.label} htmlFor="prefs.profile.role">Role</InputLabel>
-                            <Field
-                                id="prefs.profile.role"
-                                name="prefs.profile.role"
-                                component={NativeSelectField as any}
-                                items={RoleTypes}
-                            />
-                        </Grid>
-                        <Grid item className={classes.gridItem} sm={6} xs={12}>
-                            <Field
-                                label="Website"
-                                name="prefs.profile.website_url"
-                                component={TextField as any}
-                            />
-                        </Grid>
-                        <Grid container direction="row" justify="flex-end" >
-                            <Button color="primary" onClick={reset} disabled={isPristine}>Discard changes</Button>
-                            <Button
-                                color="primary"
-                                variant="contained"
-                                type="submit"
-                                disabled={isPristine || invalid || submitting}>
-                                Save changes
-                            </Button>
-                        </Grid>
-                    </Grid>
-                </form >
-            </CardContent >
-        </Card >;
-    }
-);
diff --git a/src/views/my-account-panel/my-account-panel.tsx b/src/views/my-account-panel/my-account-panel.tsx
deleted file mode 100644 (file)
index 2421a28..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { RootState } from 'store/store';
-import { compose } from 'redux';
-import { reduxForm, isPristine, isValid } from 'redux-form';
-import { connect } from 'react-redux';
-import { saveEditedUser } from 'store/my-account/my-account-panel-actions';
-import { MyAccountPanelRoot, MyAccountPanelRootDataProps } from 'views/my-account-panel/my-account-panel-root';
-import { MY_ACCOUNT_FORM } from "store/my-account/my-account-panel-actions";
-
-const mapStateToProps = (state: RootState): MyAccountPanelRootDataProps => ({
-    isPristine: isPristine(MY_ACCOUNT_FORM)(state),
-    isValid: isValid(MY_ACCOUNT_FORM)(state),
-    initialValues: state.auth.user,
-    localCluster: state.auth.localCluster
-});
-
-export const MyAccountPanel = compose(
-    connect(mapStateToProps),
-    reduxForm({
-        form: MY_ACCOUNT_FORM,
-        onSubmit: (data, dispatch) => {
-            dispatch(saveEditedUser(data));
-        }
-    }))(MyAccountPanelRoot);
diff --git a/src/views/process-log-panel/process-log-code-snippet.tsx b/src/views/process-log-panel/process-log-code-snippet.tsx
deleted file mode 100644 (file)
index d1756c7..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import React from 'react';
-import { MuiThemeProvider, createMuiTheme, StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core/styles';
-import { CodeSnippet } from 'components/code-snippet/code-snippet';
-import grey from '@material-ui/core/colors/grey';
-
-type CssRules = 'codeSnippet';
-
-const styles: StyleRulesCallback<CssRules> = () => ({
-    codeSnippet: {
-        maxHeight: '550px',
-    }
-});
-
-const theme = createMuiTheme({
-    overrides: {
-        MuiTypography: {
-            body2: {
-                color: grey["200"]
-            },
-            root: {
-                backgroundColor: '#000'
-            }
-        }
-    },
-    typography: {
-        fontFamily: 'monospace',
-        useNextVariants: true,
-    }
-});
-
-interface ProcessLogCodeSnippetProps {
-    lines: string[];
-}
-
-export const ProcessLogCodeSnippet = withStyles(styles)(
-    (props: ProcessLogCodeSnippetProps & WithStyles<CssRules>) =>
-        <MuiThemeProvider theme={theme}>
-            <CodeSnippet lines={props.lines} className={props.classes.codeSnippet} />
-        </MuiThemeProvider>);
\ No newline at end of file
diff --git a/src/views/process-log-panel/process-log-main-card.tsx b/src/views/process-log-panel/process-log-main-card.tsx
deleted file mode 100644 (file)
index e6d4091..0000000
+++ /dev/null
@@ -1,120 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import React from 'react';
-import { Link } from 'react-router-dom';
-import {
-    StyleRulesCallback, WithStyles, withStyles, Card,
-    CardHeader, IconButton, CardContent, Grid, Typography, Tooltip
-} from '@material-ui/core';
-import { Process } from 'store/processes/process';
-import { ProcessLogCodeSnippet } from 'views/process-log-panel/process-log-code-snippet';
-import { ProcessLogForm, ProcessLogFormDataProps, ProcessLogFormActionProps } from 'views/process-log-panel/process-log-form';
-import { MoreOptionsIcon, ProcessIcon } from 'components/icon/icon';
-import { ArvadosTheme } from 'common/custom-theme';
-import { CodeSnippetDataProps } from 'components/code-snippet/code-snippet';
-import { BackIcon } from 'components/icon/icon';
-import { DefaultView } from 'components/default-view/default-view';
-
-type CssRules = 'backLink' | 'backIcon' | 'card' | 'title' | 'iconHeader' | 'link';
-
-const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
-    backLink: {
-        fontSize: '14px',
-        fontWeight: 600,
-        display: 'flex',
-        alignItems: 'center',
-        textDecoration: 'none',
-        padding: theme.spacing.unit,
-        color: theme.palette.grey["700"],
-    },
-    backIcon: {
-        marginRight: theme.spacing.unit
-    },
-    card: {
-        width: '100%'
-    },
-    title: {
-        color: theme.palette.grey["700"]
-    },
-    iconHeader: {
-        fontSize: '1.875rem',
-        color: theme.customs.colors.green700
-    },
-    link: {
-        fontSize: '0.875rem',
-        color: theme.palette.primary.main,
-        textAlign: 'right',
-        '&:hover': {
-            cursor: 'pointer'
-        }
-    }
-});
-
-
-interface ProcessLogMainCardDataProps {
-    process: Process;
-}
-
-export interface ProcessLogMainCardActionProps {
-    onContextMenu: (event: React.MouseEvent<any>, process: Process) => void;
-    navigateToLogCollection: (uuid: string) => void;
-}
-
-export type ProcessLogMainCardProps = ProcessLogMainCardDataProps
-    & ProcessLogMainCardActionProps
-    & CodeSnippetDataProps
-    & ProcessLogFormDataProps
-    & ProcessLogFormActionProps;
-
-export const ProcessLogMainCard = withStyles(styles)(
-    ({ classes, process, selectedFilter, filters, onChange, lines, onContextMenu, navigateToLogCollection }: ProcessLogMainCardProps & WithStyles<CssRules>) =>
-        <Grid item xs={12}>
-            <Link to={`/processes/${process.containerRequest.uuid}`} className={classes.backLink}>
-                <BackIcon className={classes.backIcon} /> BACK
-            </Link>
-            <Card className={classes.card}>
-                <CardHeader
-                    avatar={<ProcessIcon className={classes.iconHeader} />}
-                    action={
-                        <Tooltip title="More options" disableFocusListener>
-                            <IconButton onClick={event => onContextMenu(event, process)} aria-label="More options">
-                                <MoreOptionsIcon />
-                            </IconButton>
-                        </Tooltip>}
-                    title={
-                        <Tooltip title={process.containerRequest.name} placement="bottom-start">
-                            <Typography noWrap variant='h6' className={classes.title}>
-                                {process.containerRequest.name}
-                            </Typography>
-                        </Tooltip>}
-                    subheader={process.containerRequest.description} />
-                <CardContent>
-                    {lines.length > 0
-                        ? < Grid
-                            container
-                            spacing={24}
-                            direction='column'>
-                            <Grid container item>
-                                <Grid item xs={6}>
-                                    <ProcessLogForm selectedFilter={selectedFilter} filters={filters} onChange={onChange} />
-                                </Grid>
-                                <Grid item xs={6} className={classes.link}>
-                                    <span onClick={() => navigateToLogCollection(process.containerRequest.logUuid!)}>
-                                        Go to Log collection
-                                    </span>
-                                </Grid>
-                            </Grid>
-                            <Grid item xs>
-                                <ProcessLogCodeSnippet lines={lines} />
-                            </Grid>
-                        </Grid>
-                        : <DefaultView
-                            icon={ProcessIcon}
-                            messages={['No logs yet']} />
-                    }
-                </CardContent>
-            </Card>
-        </Grid >
-);
\ No newline at end of file
diff --git a/src/views/process-log-panel/process-log-panel-root.tsx b/src/views/process-log-panel/process-log-panel-root.tsx
deleted file mode 100644 (file)
index be04372..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import React from 'react';
-import { Grid } from '@material-ui/core';
-import { Process } from 'store/processes/process';
-import { ProcessLogMainCard } from 'views/process-log-panel/process-log-main-card';
-import { ProcessLogFormDataProps, ProcessLogFormActionProps } from 'views/process-log-panel/process-log-form';
-import { DefaultView } from 'components/default-view/default-view';
-import { ProcessIcon } from 'components/icon/icon';
-import { CodeSnippetDataProps } from 'components/code-snippet/code-snippet';
-import { ProcessLogMainCardActionProps } from './process-log-main-card';
-
-export type ProcessLogPanelRootDataProps = {
-    process?: Process;
-} & ProcessLogFormDataProps & CodeSnippetDataProps;
-
-export type ProcessLogPanelRootActionProps = ProcessLogMainCardActionProps & ProcessLogFormActionProps;
-
-export type ProcessLogPanelRootProps = ProcessLogPanelRootDataProps & ProcessLogPanelRootActionProps;
-
-export const ProcessLogPanelRoot = (props: ProcessLogPanelRootProps) =>
-    props.process
-        ? <Grid container spacing={16}>
-            <ProcessLogMainCard 
-                process={props.process} 
-                {...props} />
-        </Grid> 
-        : <Grid container
-            alignItems='center'
-            justify='center'>
-            <DefaultView
-                icon={ProcessIcon}
-                messages={['Process Log not found']} />
-        </Grid>;
diff --git a/src/views/process-log-panel/process-log-panel.tsx b/src/views/process-log-panel/process-log-panel.tsx
deleted file mode 100644 (file)
index 9f61f8b..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { RootState } from 'store/store';
-import { connect } from 'react-redux';
-import { getProcess } from 'store/processes/process';
-import { Dispatch } from 'redux';
-import { openProcessContextMenu } from 'store/context-menu/context-menu-actions';
-import { ProcessLogPanelRootDataProps, ProcessLogPanelRootActionProps, ProcessLogPanelRoot } from './process-log-panel-root';
-import { getProcessPanelLogs } from 'store/process-logs-panel/process-logs-panel';
-import { setProcessLogsPanelFilter, navigateToLogCollection } from 'store/process-logs-panel/process-logs-panel-actions';
-import { getProcessLogsPanelCurrentUuid } from 'store/process-logs-panel/process-logs-panel';
-
-export interface Log {
-    object_uuid: string;
-    event_at: string;
-    event_type: string;
-    summary: string;
-    properties: any;
-}
-
-export interface FilterOption {
-    label: string;
-    value: string;
-}
-
-const mapStateToProps = (state: RootState): ProcessLogPanelRootDataProps => {
-    const { resources, processLogsPanel } = state;
-    const uuid = getProcessLogsPanelCurrentUuid(state) || '';
-    return {
-        process: getProcess(uuid)(resources),
-        selectedFilter: { label: processLogsPanel.selectedFilter, value: processLogsPanel.selectedFilter },
-        filters: processLogsPanel.filters.map(filter => ({ label: filter, value: filter })),
-        lines: getProcessPanelLogs(processLogsPanel)
-    };
-};
-
-const mapDispatchToProps = (dispatch: Dispatch): ProcessLogPanelRootActionProps => ({
-    onContextMenu: (event, process) => {
-        dispatch<any>(openProcessContextMenu(event, process));
-    },
-    onChange: filter => {
-        dispatch(setProcessLogsPanelFilter(filter.value));
-    },
-    navigateToLogCollection: (uuid: string) => {
-        dispatch<any>(navigateToLogCollection(uuid));
-    }
-});
-
-export const ProcessLogPanel = connect(mapStateToProps, mapDispatchToProps)(ProcessLogPanelRoot);
index 18610781a018ae426ab45acae56bb75107e82f50..d3349c3ae8d79ea62d7dcb052ab6852bd8d43f4b 100644 (file)
@@ -19,12 +19,16 @@ import { Process } from 'store/processes/process';
 import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
 import { ProcessDetailsAttributes } from './process-details-attributes';
 
-type CssRules = 'card' | 'content' | 'title';
+type CssRules = 'card' | 'content' | 'title' | 'header';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     card: {
         height: '100%'
     },
+    header: {
+        paddingTop: theme.spacing.unit,
+        paddingBottom: theme.spacing.unit,
+    },
     content: {
         '&:last-child': {
             paddingBottom: theme.spacing.unit * 2,
@@ -46,6 +50,7 @@ export const ProcessDetailsCard = withStyles(styles)(
     ({ classes, process, doHidePanel, panelName }: ProcessDetailsCardProps) => {
         return <Card className={classes.card}>
             <CardHeader
+                className={classes.header}
                 classes={{
                     content: classes.title,
                 }}
index 4c93801707864341a4698b93bb4449827ecd32cf..fc34a31c2f2b0c7a761b88b85ca1b7e485091890 100644 (file)
@@ -17,12 +17,16 @@ import classNames from 'classnames';
 import { ContainerState } from 'models/container';
 import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
 
-type CssRules = 'card' | 'iconHeader' | 'label' | 'value' | 'chip' | 'link' | 'content' | 'title' | 'avatar' | 'cancelButton';
+type CssRules = 'card' | 'iconHeader' | 'label' | 'value' | 'chip' | 'link' | 'content' | 'title' | 'avatar' | 'cancelButton' | 'header';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     card: {
         height: '100%'
     },
+    header: {
+        paddingTop: theme.spacing.unit,
+        paddingBottom: theme.spacing.unit,
+    },
     iconHeader: {
         fontSize: '1.875rem',
         color: theme.customs.colors.green700,
@@ -93,6 +97,7 @@ export const ProcessInformationCard = withStyles(styles, { withTheme: true })(
         const finishedAt = container ? formatDate(container.finishedAt) : 'N/A';
         return <Card className={classes.card}>
             <CardHeader
+                className={classes.header}
                 classes={{
                     content: classes.title,
                     avatar: classes.avatar
diff --git a/src/views/process-panel/process-log-card.tsx b/src/views/process-panel/process-log-card.tsx
new file mode 100644 (file)
index 0000000..ac409ec
--- /dev/null
@@ -0,0 +1,180 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { useState } from 'react';
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    Card,
+    CardHeader,
+    IconButton,
+    CardContent,
+    Tooltip,
+    Grid,
+    Typography,
+} from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import {
+    CloseIcon,
+    CollectionIcon,
+    CopyIcon,
+    LogIcon,
+    MaximizeIcon,
+    TextDecreaseIcon,
+    TextIncreaseIcon,
+    WordWrapIcon
+} from 'components/icon/icon';
+import { Process } from 'store/processes/process';
+import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
+import {
+    FilterOption,
+    ProcessLogForm
+} from 'views/process-panel/process-log-form';
+import { ProcessLogCodeSnippet } from 'views/process-panel/process-log-code-snippet';
+import { DefaultView } from 'components/default-view/default-view';
+import { CodeSnippetDataProps } from 'components/code-snippet/code-snippet';
+import CopyToClipboard from 'react-copy-to-clipboard';
+
+type CssRules = 'card' | 'content' | 'title' | 'iconHeader' | 'header' | 'root' | 'logViewer' | 'logViewerContainer';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    card: {
+        height: '100%'
+    },
+    header: {
+        paddingTop: theme.spacing.unit,
+        paddingBottom: theme.spacing.unit,
+    },
+    content: {
+        padding: theme.spacing.unit * 0,
+        height: '100%',
+    },
+    logViewer: {
+        height: '100%',
+    },
+    logViewerContainer: {
+        height: '100%',
+    },
+    title: {
+        overflow: 'hidden',
+        paddingTop: theme.spacing.unit * 0.5
+    },
+    iconHeader: {
+        fontSize: '1.875rem',
+        color: theme.customs.colors.green700
+    },
+    root: {
+        height: '100%',
+    },
+});
+
+export interface ProcessLogsCardDataProps {
+    process: Process;
+    selectedFilter: FilterOption;
+    filters: FilterOption[];
+}
+
+export interface ProcessLogsCardActionProps {
+    onLogFilterChange: (filter: FilterOption) => void;
+    navigateToLog: (uuid: string) => void;
+    onCopy: (text: string) => void;
+}
+
+type ProcessLogsCardProps = ProcessLogsCardDataProps
+    & ProcessLogsCardActionProps
+    & CodeSnippetDataProps
+    & WithStyles<CssRules>
+    & MPVPanelProps;
+
+export const ProcessLogsCard = withStyles(styles)(
+    ({ classes, process, filters, selectedFilter, lines,
+        onLogFilterChange, navigateToLog, onCopy,
+        doHidePanel, doMaximizePanel, panelMaximized, panelName }: ProcessLogsCardProps) => {
+        const [wordWrapToggle, setWordWrapToggle] = useState<boolean>(true);
+        const [fontSize, setFontSize] = useState<number>(3);
+        const fontBaseSize = 10;
+        const fontStepSize = 1;
+
+        return <Grid item className={classes.root} xs={12}>
+            <Card className={classes.card}>
+                <CardHeader className={classes.header}
+                    avatar={<LogIcon className={classes.iconHeader} />}
+                    action={<Grid container direction='row' alignItems='center'>
+                        <Grid item>
+                            <ProcessLogForm selectedFilter={selectedFilter}
+                                filters={filters} onChange={onLogFilterChange} />
+                        </Grid>
+                        <Grid item>
+                            <Tooltip title="Decrease font size" disableFocusListener>
+                                <IconButton onClick={() => fontSize > 1 && setFontSize(fontSize-1)}>
+                                    <TextDecreaseIcon />
+                                </IconButton>
+                            </Tooltip>
+                        </Grid>
+                        <Grid item>
+                            <Tooltip title="Increase font size" disableFocusListener>
+                                <IconButton onClick={() => fontSize < 5 && setFontSize(fontSize+1)}>
+                                    <TextIncreaseIcon />
+                                </IconButton>
+                            </Tooltip>
+                        </Grid>
+                        <Grid item>
+                            <Tooltip title="Copy to clipboard" disableFocusListener>
+                                <IconButton>
+                                    <CopyToClipboard text={lines.join()} onCopy={() => onCopy("Log copied to clipboard")}>
+                                        <CopyIcon />
+                                    </CopyToClipboard>
+                                </IconButton>
+                            </Tooltip>
+                        </Grid>
+                        <Grid item>
+                            <Tooltip title="Toggle word wrapping" disableFocusListener>
+                                <IconButton onClick={() => setWordWrapToggle(!wordWrapToggle)}>
+                                    <WordWrapIcon />
+                                </IconButton>
+                            </Tooltip>
+                        </Grid>
+                        <Grid item>
+                            <Tooltip title="Go to Log collection" disableFocusListener>
+                                <IconButton onClick={() => navigateToLog(process.containerRequest.logUuid!)}>
+                                    <CollectionIcon />
+                                </IconButton>
+                            </Tooltip>
+                        </Grid>
+                        { doMaximizePanel && !panelMaximized &&
+                        <Tooltip title={`Maximize ${panelName || 'panel'}`} disableFocusListener>
+                            <IconButton onClick={doMaximizePanel}><MaximizeIcon /></IconButton>
+                        </Tooltip> }
+                        { doHidePanel && <Grid item>
+                            <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
+                                <IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
+                            </Tooltip>
+                        </Grid> }
+                    </Grid>}
+                    title={
+                        <Typography noWrap variant='h6' className={classes.title}>
+                            Logs
+                        </Typography>}
+                />
+                <CardContent className={classes.content}>
+                    {lines.length > 0
+                        ? < Grid
+                            className={classes.logViewerContainer}
+                            container
+                            spacing={24}
+                            direction='column'>
+                            <Grid className={classes.logViewer} item xs>
+                                <ProcessLogCodeSnippet fontSize={fontBaseSize+(fontStepSize*fontSize)} wordWrap={wordWrapToggle} lines={lines} />
+                            </Grid>
+                        </Grid>
+                        : <DefaultView
+                            icon={LogIcon}
+                            messages={['No logs yet']} />
+                    }
+                </CardContent>
+            </Card>
+        </Grid >
+});
+
diff --git a/src/views/process-panel/process-log-code-snippet.tsx b/src/views/process-panel/process-log-code-snippet.tsx
new file mode 100644 (file)
index 0000000..6ea628e
--- /dev/null
@@ -0,0 +1,109 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { useEffect, useRef, useState } from 'react';
+import {
+    MuiThemeProvider,
+    createMuiTheme,
+    StyleRulesCallback,
+    withStyles,
+    WithStyles
+} from '@material-ui/core/styles';
+import grey from '@material-ui/core/colors/grey';
+import { ArvadosTheme } from 'common/custom-theme';
+import { Link, Typography } from '@material-ui/core';
+import { navigateTo } from 'store/navigation/navigation-action';
+import { Dispatch } from 'redux';
+import { connect, DispatchProp } from 'react-redux';
+import classNames from 'classnames';
+
+type CssRules = 'root' | 'wordWrap' | 'logText';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        boxSizing: 'border-box',
+        overflow: 'auto',
+        backgroundColor: '#000',
+        height: `calc(100% - ${theme.spacing.unit * 4}px)`, // so that horizontal scollbar is visible
+    },
+    logText: {
+        padding: theme.spacing.unit * 0.5,
+    },
+    wordWrap: {
+        whiteSpace: 'pre-wrap',
+    },
+});
+
+const theme = createMuiTheme({
+    overrides: {
+        MuiTypography: {
+            body2: {
+                color: grey["200"]
+            }
+        }
+    },
+    typography: {
+        fontFamily: 'monospace',
+        useNextVariants: true,
+    }
+});
+
+interface ProcessLogCodeSnippetProps {
+    lines: string[];
+    fontSize: number;
+    wordWrap?: boolean;
+}
+
+const renderLinks = (fontSize: number, dispatch: Dispatch) => (text: string) => {
+    // Matches UUIDs & PDHs
+    const REGEX = /[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}|[0-9a-f]{32}\+\d+/g;
+    const links = text.match(REGEX);
+    if (!links) {
+        return <Typography style={{ fontSize: fontSize }}>{text}</Typography>;
+    }
+    return <Typography style={{ fontSize: fontSize }}>
+        {text.split(REGEX).map((part, index) =>
+        <React.Fragment key={index}>
+            {part}
+            {links[index] &&
+            <Link onClick={() => dispatch<any>(navigateTo(links[index]))}
+                style={ {cursor: 'pointer'} }>
+                {links[index]}
+            </Link>}
+        </React.Fragment>
+        )}
+    </Typography>;
+};
+
+export const ProcessLogCodeSnippet = withStyles(styles)(connect()(
+    ({classes, lines, fontSize, dispatch, wordWrap}: ProcessLogCodeSnippetProps & WithStyles<CssRules> & DispatchProp) => {
+        const [followMode, setFollowMode] = useState<boolean>(false);
+        const scrollRef = useRef<HTMLDivElement>(null);
+
+        useEffect(() => {
+            if (followMode && scrollRef.current && lines.length > 0) {
+                // Scroll to bottom
+                scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+            }
+        }, [followMode, lines, scrollRef]);
+
+        return <MuiThemeProvider theme={theme}>
+            <div ref={scrollRef} className={classes.root}
+                onScroll={(e) => {
+                    const elem = e.target as HTMLDivElement;
+                    if (elem.scrollTop + elem.clientHeight >= elem.scrollHeight) {
+                        setFollowMode(true);
+                    } else {
+                        setFollowMode(false);
+                    }
+                }}>
+                { lines.map((line: string, index: number) =>
+                <Typography key={index} component="pre"
+                    className={classNames(classes.logText, wordWrap ? classes.wordWrap : undefined)}>
+                    {renderLinks(fontSize, dispatch)(line)}
+                </Typography>
+                ) }
+            </div>
+        </MuiThemeProvider>
+    }));
\ No newline at end of file
similarity index 79%
rename from src/views/process-log-panel/process-log-form.tsx
rename to src/views/process-panel/process-log-form.tsx
index 946e575e3108537336b44809023cb259c99e14d9..1f63e28d2af99336d0a5277201f55d774b4cc419 100644 (file)
@@ -3,18 +3,30 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import React from 'react';
-import { withStyles, WithStyles, StyleRulesCallback, FormControl, InputLabel, Select, MenuItem, Input } from '@material-ui/core';
+import {
+    withStyles,
+    WithStyles,
+    StyleRulesCallback,
+    FormControl,
+    Select,
+    MenuItem,
+    Input
+} from '@material-ui/core';
 import { ArvadosTheme } from 'common/custom-theme';
-import { FilterOption } from './process-log-panel';
 
 type CssRules = 'formControl';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     formControl: {
-        minWidth: 200
+        minWidth: theme.spacing.unit * 15,
     }
 });
 
+export interface FilterOption {
+    label: string;
+    value: string;
+}
+
 export interface ProcessLogFormDataProps {
     selectedFilter: FilterOption;
     filters: FilterOption[];
@@ -28,11 +40,8 @@ type ProcessLogFormProps = ProcessLogFormDataProps & ProcessLogFormActionProps &
 
 export const ProcessLogForm = withStyles(styles)(
     ({ classes, selectedFilter, onChange, filters }: ProcessLogFormProps) =>
-        <form autoComplete="off">
+        <form autoComplete="off" data-cy="process-logs-filter">
             <FormControl className={classes.formControl}>
-                <InputLabel shrink htmlFor="log-label-placeholder">
-                    Event Type
-                </InputLabel>
                 <Select
                     value={selectedFilter.value}
                     onChange={({ target }) => onChange({ label: target.innerText, value: target.value })}
index 6fb9c09d16742d4b2b622974016da7cbc5558b89..156789a20dbe85c1427305ecbf4ab06d9a387ff5 100644 (file)
@@ -13,6 +13,9 @@ import { SubprocessFilterDataProps } from 'components/subprocess-filter/subproce
 import { MPVContainer, MPVPanelContent, MPVPanelState } from 'components/multi-panel-view/multi-panel-view';
 import { ArvadosTheme } from 'common/custom-theme';
 import { ProcessDetailsCard } from './process-details-card';
+import { getProcessPanelLogs, ProcessLogsPanel } from 'store/process-logs-panel/process-logs-panel';
+import { ProcessLogsCard } from './process-log-card';
+import { FilterOption } from 'views/process-panel/process-log-form';
 
 type CssRules = 'root';
 
@@ -26,6 +29,7 @@ export interface ProcessPanelRootDataProps {
     process?: Process;
     subprocesses: Array<Process>;
     filters: Array<SubprocessFilterDataProps>;
+    processLogsPanel: ProcessLogsPanel;
 }
 
 export interface ProcessPanelRootActionProps {
@@ -35,6 +39,9 @@ export interface ProcessPanelRootActionProps {
     navigateToOutput: (uuid: string) => void;
     navigateToWorkflow: (uuid: string) => void;
     cancelProcess: (uuid: string) => void;
+    onLogFilterChange: (filter: FilterOption) => void;
+    navigateToLog: (uuid: string) => void;
+    onLogCopyToClipboard: (uuid: string) => void;
 }
 
 export type ProcessPanelRootProps = ProcessPanelRootDataProps & ProcessPanelRootActionProps & WithStyles<CssRules>;
@@ -42,13 +49,15 @@ export type ProcessPanelRootProps = ProcessPanelRootDataProps & ProcessPanelRoot
 const panelsData: MPVPanelState[] = [
     {name: "Info"},
     {name: "Details", visible: false},
+    {name: "Logs", visible: true},
     {name: "Subprocesses"},
 ];
 
-export const ProcessPanelRoot = withStyles(styles)(({ process, ...props }: ProcessPanelRootProps) =>
+export const ProcessPanelRoot = withStyles(styles)(
+    ({ process, processLogsPanel, ...props }: ProcessPanelRootProps) =>
     process
         ? <MPVContainer className={props.classes.root} spacing={8} panelStates={panelsData}  justify-content="flex-start" direction="column" wrap="nowrap">
-            <MPVPanelContent forwardProps xs="auto">
+            <MPVPanelContent forwardProps xs="auto" data-cy="process-info">
                 <ProcessInformationCard
                     process={process}
                     onContextMenu={event => props.onContextMenu(event, process)}
@@ -58,10 +67,26 @@ export const ProcessPanelRoot = withStyles(styles)(({ process, ...props }: Proce
                     cancelProcess={props.cancelProcess}
                 />
             </MPVPanelContent>
-            <MPVPanelContent forwardProps xs="auto">
+            <MPVPanelContent forwardProps xs="auto" data-cy="process-details">
                 <ProcessDetailsCard process={process} />
             </MPVPanelContent>
-            <MPVPanelContent forwardProps xs>
+            <MPVPanelContent forwardProps xs maxHeight='50%' data-cy="process-logs">
+                <ProcessLogsCard
+                    onCopy={props.onLogCopyToClipboard}
+                    process={process}
+                    lines={getProcessPanelLogs(processLogsPanel)}
+                    selectedFilter={{
+                        label: processLogsPanel.selectedFilter,
+                        value: processLogsPanel.selectedFilter
+                    }}
+                    filters={processLogsPanel.filters.map(
+                        filter => ({ label: filter, value: filter })
+                    )}
+                    onLogFilterChange={props.onLogFilterChange}
+                    navigateToLog={props.navigateToLog}
+                />
+            </MPVPanelContent>
+            <MPVPanelContent forwardProps xs maxHeight='50%' data-cy="process-children">
                 <SubprocessPanel />
             </MPVPanelContent>
         </MPVContainer>
@@ -73,4 +98,3 @@ export const ProcessPanelRoot = withStyles(styles)(({ process, ...props }: Proce
                 icon={ProcessIcon}
                 messages={['Process not found']} />
         </Grid>);
-
index 3364a8d63043c78fcbb7ed130226ae524d5e2471..e04602925798fb9c624bdb30e5d750d028fb6375 100644 (file)
@@ -7,27 +7,45 @@ import { connect } from 'react-redux';
 import { getProcess, getSubprocesses, Process, getProcessStatus } from 'store/processes/process';
 import { Dispatch } from 'redux';
 import { openProcessContextMenu } from 'store/context-menu/context-menu-actions';
-import { matchProcessRoute } from 'routes/routes';
-import { ProcessPanelRootDataProps, ProcessPanelRootActionProps, ProcessPanelRoot } from './process-panel-root';
-import { ProcessPanel as ProcessPanelState} from 'store/process-panel/process-panel';
+import {
+    ProcessPanelRootDataProps,
+    ProcessPanelRootActionProps,
+    ProcessPanelRoot
+} from './process-panel-root';
+import {
+    getProcessPanelCurrentUuid,
+    ProcessPanel as ProcessPanelState
+} from 'store/process-panel/process-panel';
 import { groupBy } from 'lodash';
-import { toggleProcessPanelFilter, navigateToOutput, openWorkflow } from 'store/process-panel/process-panel-actions';
+import {
+    toggleProcessPanelFilter,
+    navigateToOutput,
+    openWorkflow
+} from 'store/process-panel/process-panel-actions';
 import { openProcessInputDialog } from 'store/processes/process-input-actions';
 import { cancelRunningWorkflow } from 'store/processes/processes-actions';
+import { navigateToLogCollection, setProcessLogsPanelFilter } from 'store/process-logs-panel/process-logs-panel-actions';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 
-const mapStateToProps = ({ router, resources, processPanel }: RootState): ProcessPanelRootDataProps => {
-    const pathname = router.location ? router.location.pathname : '';
-    const match = matchProcessRoute(pathname);
-    const uuid = match ? match.params.id : '';
+const mapStateToProps = ({ router, resources, processPanel, processLogsPanel }: RootState): ProcessPanelRootDataProps => {
+    const uuid = getProcessPanelCurrentUuid(router) || '';
     const subprocesses = getSubprocesses(uuid)(resources);
     return {
         process: getProcess(uuid)(resources),
         subprocesses: subprocesses.filter(subprocess => processPanel.filters[getProcessStatus(subprocess)]),
         filters: getFilters(processPanel, subprocesses),
+        processLogsPanel: processLogsPanel,
     };
 };
 
 const mapDispatchToProps = (dispatch: Dispatch): ProcessPanelRootActionProps => ({
+    onLogCopyToClipboard: (message: string) => {
+        dispatch<any>(snackbarActions.OPEN_SNACKBAR({
+            message,
+            hideDuration: 2000,
+            kind: SnackbarKind.SUCCESS,
+        }));
+    },
     onContextMenu: (event, process) => {
         dispatch<any>(openProcessContextMenu(event, process));
     },
@@ -37,12 +55,12 @@ const mapDispatchToProps = (dispatch: Dispatch): ProcessPanelRootActionProps =>
     openProcessInputDialog: (uuid) => dispatch<any>(openProcessInputDialog(uuid)),
     navigateToOutput: (uuid) => dispatch<any>(navigateToOutput(uuid)),
     navigateToWorkflow: (uuid) => dispatch<any>(openWorkflow(uuid)),
-    cancelProcess: (uuid) => dispatch<any>(cancelRunningWorkflow(uuid))
+    cancelProcess: (uuid) => dispatch<any>(cancelRunningWorkflow(uuid)),
+    onLogFilterChange: (filter) => dispatch(setProcessLogsPanelFilter(filter.value)),
+    navigateToLog: (uuid) => dispatch<any>(navigateToLogCollection(uuid)),
 });
 
-export const ProcessPanel = connect(mapStateToProps, mapDispatchToProps)(ProcessPanelRoot);
-
-export const getFilters = (processPanel: ProcessPanelState, processes: Process[]) => {
+const getFilters = (processPanel: ProcessPanelState, processes: Process[]) => {
     const grouppedProcesses = groupBy(processes, getProcessStatus);
     return Object
         .keys(processPanel.filters)
@@ -52,4 +70,6 @@ export const getFilters = (processPanel: ProcessPanelState, processes: Process[]
             checked: processPanel.filters[filter],
             key: filter,
         }));
-    };
\ No newline at end of file
+    };
+
+export const ProcessPanel = connect(mapStateToProps, mapDispatchToProps)(ProcessPanelRoot);
index 5fb979a2194c62af81a0de64b37cdc509096ead0..589353cd880ad91557f80969e6fe1233e818fc2f 100644 (file)
@@ -3,25 +3,23 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import React from 'react';
-import { WithStyles, withStyles, Tabs, Tab, Paper, Button, Grid } from '@material-ui/core';
+import { WithStyles, withStyles, Paper, Button, Grid } from '@material-ui/core';
 import { DataExplorer } from "views-components/data-explorer/data-explorer";
 import { connect, DispatchProp } from 'react-redux';
 import { DataColumns } from 'components/data-table/data-table';
 import { RootState } from 'store/store';
 import { SortDirection } from 'components/data-table/data-column';
-import { openContextMenu } from "store/context-menu/context-menu-actions";
+import { openUserContextMenu } from "store/context-menu/context-menu-actions";
 import { getResource, ResourcesState } from "store/resources/resources";
 import {
-    ResourceFirstName,
-    ResourceLastName,
+    UserResourceFullName,
     ResourceUuid,
     ResourceEmail,
-    ResourceIsActive,
     ResourceIsAdmin,
-    ResourceUsername
+    ResourceUsername,
+    UserResourceAccountStatus,
 } from "views-components/data-explorer/renderers";
-import { navigateTo } from "store/navigation/navigation-action";
-import { ContextMenuKind } from "views-components/context-menu/context-menu";
+import { navigateToUserProfile } from "store/navigation/navigation-action";
 import { DataTableDefaultView } from 'components/data-table-default-view/data-table-default-view';
 import { createTree } from 'models/tree';
 import { compose, Dispatch } from 'redux';
@@ -30,7 +28,7 @@ import { ShareMeIcon, AddIcon } from 'components/icon/icon';
 import { USERS_PANEL_ID, openUserCreateDialog } from 'store/users/users-actions';
 import { noop } from 'lodash';
 
-type UserPanelRules = "button" | 'root' | 'content';
+type UserPanelRules = "button" | 'root';
 
 const styles = withStyles<UserPanelRules>(theme => ({
     button: {
@@ -42,18 +40,13 @@ const styles = withStyles<UserPanelRules>(theme => ({
     root: {
         width: '100%',
     },
-    content: {
-        // reserve space for the tab bar
-        height: `calc(100% - ${theme.spacing.unit * 7}px)`,
-    }
 }));
 
 export enum UserPanelColumnNames {
-    FIRST_NAME = "First Name",
-    LAST_NAME = "Last Name",
+    NAME = "Name",
     UUID = "Uuid",
     EMAIL = "Email",
-    ACTIVE = "Active",
+    STATUS = "Account Status",
     ADMIN = "Admin",
     REDIRECT_TO_USER = "Redirect to user",
     USERNAME = "Username"
@@ -61,20 +54,12 @@ export enum UserPanelColumnNames {
 
 export const userPanelColumns: DataColumns<string> = [
     {
-        name: UserPanelColumnNames.FIRST_NAME,
-        selected: true,
-        configurable: true,
-        sortDirection: SortDirection.NONE,
-        filters: createTree(),
-        render: uuid => <ResourceFirstName uuid={uuid} />
-    },
-    {
-        name: UserPanelColumnNames.LAST_NAME,
+        name: UserPanelColumnNames.NAME,
         selected: true,
         configurable: true,
         sortDirection: SortDirection.NONE,
         filters: createTree(),
-        render: uuid => <ResourceLastName uuid={uuid} />
+        render: uuid => <UserResourceFullName uuid={uuid} link={true} />
     },
     {
         name: UserPanelColumnNames.UUID,
@@ -93,18 +78,16 @@ export const userPanelColumns: DataColumns<string> = [
         render: uuid => <ResourceEmail uuid={uuid} />
     },
     {
-        name: UserPanelColumnNames.ACTIVE,
+        name: UserPanelColumnNames.STATUS,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.NONE,
         filters: createTree(),
-        render: uuid => <ResourceIsActive uuid={uuid} />
+        render: uuid => <UserResourceAccountStatus uuid={uuid} />
     },
     {
         name: UserPanelColumnNames.ADMIN,
         selected: true,
         configurable: false,
-        sortDirection: SortDirection.NONE,
         filters: createTree(),
         render: uuid => <ResourceIsAdmin uuid={uuid} />
     },
@@ -124,8 +107,8 @@ interface UserPanelDataProps {
 
 interface UserPanelActionProps {
     openUserCreateDialog: () => void;
-    handleRowDoubleClick: (uuid: string) => void;
-    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: any) => void;
+    handleRowClick: (uuid: string) => void;
+    handleContextMenu: (event, resource: UserResource) => void;
 }
 
 const mapStateToProps = (state: RootState) => {
@@ -136,8 +119,8 @@ const mapStateToProps = (state: RootState) => {
 
 const mapDispatchToProps = (dispatch: Dispatch) => ({
     openUserCreateDialog: () => dispatch<any>(openUserCreateDialog()),
-    handleRowDoubleClick: (uuid: string) => dispatch<any>(navigateTo(uuid)),
-    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: any) => dispatch<any>(openContextMenu(event, item))
+    handleRowClick: (uuid: string) => dispatch<any>(navigateToUserProfile(uuid)),
+    handleContextMenu: (event, resource: UserResource) => dispatch<any>(openUserContextMenu(event, resource)),
 });
 
 type UserPanelProps = UserPanelDataProps & UserPanelActionProps & DispatchProp & WithStyles<UserPanelRules>;
@@ -146,63 +129,38 @@ export const UserPanel = compose(
     styles,
     connect(mapStateToProps, mapDispatchToProps))(
         class extends React.Component<UserPanelProps> {
-            state = {
-                value: 0,
-            };
-
-            componentDidMount() {
-                this.setState({ value: 0 });
-            }
-
             render() {
-                const { value } = this.state;
                 return <Paper className={this.props.classes.root}>
-                    <Tabs value={value} onChange={this.handleChange} fullWidth>
-                        <Tab label="USERS" />
-                        <Tab label="ACTIVITY" disabled />
-                    </Tabs>
-                    {value === 0 &&
-                        <div className={this.props.classes.content}>
-                            <DataExplorer
-                                id={USERS_PANEL_ID}
-                                onRowClick={noop}
-                                onRowDoubleClick={noop}
-                                onContextMenu={this.handleContextMenu}
-                                contextMenuColumn={true}
-                                hideColumnSelector
-                                actions={
-                                    <Grid container justify='flex-end'>
-                                        <Button variant="contained" color="primary" onClick={this.props.openUserCreateDialog}>
-                                            <AddIcon /> NEW USER
-                                        </Button>
-                                    </Grid>
-                                }
-                                paperProps={{
-                                    elevation: 0,
-                                }}
-                                dataTableDefaultView={
-                                    <DataTableDefaultView
-                                        icon={ShareMeIcon}
-                                        messages={['Your user list is empty.']} />
-                                } />
-                        </div>}
+                    <DataExplorer
+                        id={USERS_PANEL_ID}
+                        onRowClick={noop}
+                        onRowDoubleClick={noop}
+                        onContextMenu={this.handleContextMenu}
+                        contextMenuColumn={true}
+                        hideColumnSelector
+                        actions={
+                            <Grid container justify='flex-end'>
+                                <Button variant="contained" color="primary" onClick={this.props.openUserCreateDialog}>
+                                    <AddIcon /> NEW USER
+                                </Button>
+                            </Grid>
+                        }
+                        paperProps={{
+                            elevation: 0,
+                        }}
+                        dataTableDefaultView={
+                            <DataTableDefaultView
+                                icon={ShareMeIcon}
+                                messages={['Your user list is empty.']} />
+                        } />
                 </Paper>;
             }
 
-            handleChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
-                this.setState({ value });
-            }
-
             handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
+                event.stopPropagation();
                 const resource = getResource<UserResource>(resourceUuid)(this.props.resources);
                 if (resource) {
-                    this.props.onContextMenu(event, {
-                        name: '',
-                        uuid: resource.uuid,
-                        ownerUuid: resource.ownerUuid,
-                        kind: resource.kind,
-                        menuKind: ContextMenuKind.USER
-                    });
+                    this.props.handleContextMenu(event, resource);
                 }
             }
         }
diff --git a/src/views/user-profile-panel/user-profile-panel-root.tsx b/src/views/user-profile-panel/user-profile-panel-root.tsx
new file mode 100644 (file)
index 0000000..1c8b1da
--- /dev/null
@@ -0,0 +1,353 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Field, InjectedFormProps } from "redux-form";
+import { DispatchProp } from 'react-redux';
+import { UserResource } from 'models/user';
+import { TextField } from "components/text-field/text-field";
+import { DataExplorer } from "views-components/data-explorer/data-explorer";
+import { NativeSelectField } from "components/select-field/select-field";
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    CardContent,
+    Button,
+    Typography,
+    Grid,
+    InputLabel,
+    Tabs, Tab,
+    Paper,
+    Tooltip,
+    IconButton,
+} from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { DataTableDefaultView } from 'components/data-table-default-view/data-table-default-view';
+import { PROFILE_EMAIL_VALIDATION, PROFILE_URL_VALIDATION } from "validators/validators";
+import { USER_PROFILE_PANEL_ID } from 'store/user-profile/user-profile-actions';
+import { noop } from 'lodash';
+import { DetailsIcon, GroupsIcon, MoreOptionsIcon } from 'components/icon/icon';
+import { DataColumns } from 'components/data-table/data-table';
+import { ResourceLinkHeadUuid, ResourceLinkHeadPermissionLevel, ResourceLinkHead, ResourceLinkDelete, ResourceLinkTailIsVisible, UserResourceAccountStatus } from 'views-components/data-explorer/renderers';
+import { createTree } from 'models/tree';
+import { getResource, ResourcesState } from 'store/resources/resources';
+import { DefaultView } from 'components/default-view/default-view';
+import { CopyToClipboardSnackbar } from 'components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar';
+
+type CssRules = 'root' | 'emptyRoot' | 'gridItem' | 'label' | 'readOnlyValue' | 'title' | 'description' | 'actions' | 'content' | 'copyIcon';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+        overflow: 'auto'
+    },
+    emptyRoot: {
+        width: '100%',
+        overflow: 'auto',
+        padding: theme.spacing.unit * 4,
+    },
+    gridItem: {
+        height: 45,
+        marginBottom: 20
+    },
+    label: {
+        fontSize: '0.675rem',
+        color: theme.palette.grey['600']
+    },
+    readOnlyValue: {
+        fontSize: '0.875rem',
+    },
+    title: {
+        fontSize: '1.1rem',
+    },
+    description: {
+        color: theme.palette.grey["600"]
+    },
+    actions: {
+        display: 'flex',
+        justifyContent: 'flex-end'
+    },
+    content: {
+        // reserve space for the tab bar
+        height: `calc(100% - ${theme.spacing.unit * 7}px)`,
+    },
+    copyIcon: {
+        marginLeft: theme.spacing.unit,
+        color: theme.palette.grey["500"],
+        cursor: 'pointer',
+        display: 'inline',
+        '& svg': {
+            fontSize: '1rem'
+        }
+    }
+});
+
+export interface UserProfilePanelRootActionProps {
+    handleContextMenu: (event, resource: UserResource) => void;
+}
+
+export interface UserProfilePanelRootDataProps {
+    isAdmin: boolean;
+    isSelf: boolean;
+    isPristine: boolean;
+    isValid: boolean;
+    isInaccessible: boolean;
+    userUuid: string;
+    resources: ResourcesState;
+    localCluster: string;
+}
+
+const RoleTypes = [
+    { key: '', value: '' },
+    { key: 'Bio-informatician', value: 'Bio-informatician' },
+    { key: 'Data Scientist', value: 'Data Scientist' },
+    { key: 'Analyst', value: 'Analyst' },
+    { key: 'Researcher', value: 'Researcher' },
+    { key: 'Software Developer', value: 'Software Developer' },
+    { key: 'System Administrator', value: 'System Administrator' },
+    { key: 'Other', value: 'Other' }
+];
+
+type UserProfilePanelRootProps = InjectedFormProps<{}> & UserProfilePanelRootActionProps & UserProfilePanelRootDataProps & DispatchProp & WithStyles<CssRules>;
+
+export enum UserProfileGroupsColumnNames {
+    NAME = "Name",
+    PERMISSION = "Permission",
+    VISIBLE = "Visible to other members",
+    UUID = "UUID",
+    REMOVE = "Remove",
+}
+
+enum TABS {
+    PROFILE = "PROFILE",
+    GROUPS = "GROUPS",
+
+}
+
+export const userProfileGroupsColumns: DataColumns<string> = [
+    {
+        name: UserProfileGroupsColumnNames.NAME,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkHead uuid={uuid} />
+    },
+    {
+        name: UserProfileGroupsColumnNames.PERMISSION,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkHeadPermissionLevel uuid={uuid} />
+    },
+    {
+        name: UserProfileGroupsColumnNames.VISIBLE,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkTailIsVisible uuid={uuid} />
+    },
+    {
+        name: UserProfileGroupsColumnNames.UUID,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkHeadUuid uuid={uuid} />
+    },
+    {
+        name: UserProfileGroupsColumnNames.REMOVE,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkDelete uuid={uuid} />
+    },
+];
+
+const ReadOnlyField = withStyles(styles)(
+    (props: ({ label: string, input: {value: string} }) & WithStyles<CssRules> ) => (
+        <Grid item xs={12} data-cy="field">
+            <Typography className={props.classes.label}>
+                {props.label}
+            </Typography>
+            <Typography className={props.classes.readOnlyValue} data-cy="value">
+                {props.input.value}
+            </Typography>
+        </Grid>
+    )
+);
+
+export const UserProfilePanelRoot = withStyles(styles)(
+    class extends React.Component<UserProfilePanelRootProps> {
+        state = {
+            value: TABS.PROFILE,
+        };
+
+        componentDidMount() {
+            this.setState({ value: TABS.PROFILE});
+        }
+
+        render() {
+            if (this.props.isInaccessible) {
+                return (
+                    <Paper className={this.props.classes.emptyRoot}>
+                        <CardContent>
+                            <DefaultView icon={DetailsIcon} messages={['This user does not exist or your account does not have permission to view it']} />
+                        </CardContent>
+                    </Paper>
+                );
+            } else {
+                return <Paper className={this.props.classes.root}>
+                    <Tabs value={this.state.value} onChange={this.handleChange} variant={"fullWidth"}>
+                        <Tab label={TABS.PROFILE} value={TABS.PROFILE} />
+                        <Tab label={TABS.GROUPS} value={TABS.GROUPS} />
+                    </Tabs>
+                    {this.state.value === TABS.PROFILE &&
+                        <CardContent>
+                            <Grid container justify="space-between">
+                                <Grid item>
+                                    <Typography className={this.props.classes.title}>
+                                        {this.props.userUuid}
+                                        <CopyToClipboardSnackbar value={this.props.userUuid} />
+                                    </Typography>
+                                </Grid>
+                                <Grid item>
+                                    <Grid container alignItems="center">
+                                        <Grid item style={{marginRight: '10px'}}><UserResourceAccountStatus uuid={this.props.userUuid} /></Grid>
+                                        <Grid item>
+                                            <Tooltip title="Actions" disableFocusListener>
+                                                <IconButton
+                                                    data-cy='user-profile-panel-options-btn'
+                                                    aria-label="Actions"
+                                                    onClick={(event) => this.handleContextMenu(event, this.props.userUuid)}>
+                                                    <MoreOptionsIcon />
+                                                </IconButton>
+                                            </Tooltip>
+                                        </Grid>
+                                    </Grid>
+                                </Grid>
+                            </Grid>
+                            <form onSubmit={this.props.handleSubmit} data-cy="profile-form">
+                                <Grid container spacing={24}>
+                                    <Grid item className={this.props.classes.gridItem} sm={6} xs={12} data-cy="firstName">
+                                        <Field
+                                            label="First name"
+                                            name="firstName"
+                                            component={TextField as any}
+                                            disabled={!this.props.isAdmin && !this.props.isSelf}
+                                        />
+                                    </Grid>
+                                    <Grid item className={this.props.classes.gridItem} sm={6} xs={12} data-cy="lastName">
+                                        <Field
+                                            label="Last name"
+                                            name="lastName"
+                                            component={TextField as any}
+                                            disabled={!this.props.isAdmin && !this.props.isSelf}
+                                        />
+                                    </Grid>
+                                    <Grid item className={this.props.classes.gridItem} sm={6} xs={12} data-cy="email">
+                                        <Field
+                                            label="E-mail"
+                                            name="email"
+                                            component={ReadOnlyField as any}
+                                            disabled
+                                        />
+                                    </Grid>
+                                    <Grid item className={this.props.classes.gridItem} sm={6} xs={12} data-cy="username">
+                                        <Field
+                                            label="Username"
+                                            name="username"
+                                            component={ReadOnlyField as any}
+                                            disabled
+                                        />
+                                    </Grid>
+                                    <Grid item className={this.props.classes.gridItem} sm={6} xs={12}>
+                                        <Field
+                                            label="Organization"
+                                            name="prefs.profile.organization"
+                                            component={TextField as any}
+                                            disabled={!this.props.isAdmin && !this.props.isSelf}
+                                        />
+                                    </Grid>
+                                    <Grid item className={this.props.classes.gridItem} sm={6} xs={12}>
+                                        <Field
+                                            label="E-mail at Organization"
+                                            name="prefs.profile.organization_email"
+                                            component={TextField as any}
+                                            disabled={!this.props.isAdmin && !this.props.isSelf}
+                                            validate={PROFILE_EMAIL_VALIDATION}
+                                        />
+                                    </Grid>
+                                    <Grid item className={this.props.classes.gridItem} sm={6} xs={12}>
+                                        <InputLabel className={this.props.classes.label} htmlFor="prefs.profile.role">Role</InputLabel>
+                                        <Field
+                                            id="prefs.profile.role"
+                                            name="prefs.profile.role"
+                                            component={NativeSelectField as any}
+                                            items={RoleTypes}
+                                            disabled={!this.props.isAdmin && !this.props.isSelf}
+                                        />
+                                    </Grid>
+                                    <Grid item className={this.props.classes.gridItem} sm={6} xs={12}>
+                                        <Field
+                                            label="Website"
+                                            name="prefs.profile.website_url"
+                                            component={TextField as any}
+                                            disabled={!this.props.isAdmin && !this.props.isSelf}
+                                            validate={PROFILE_URL_VALIDATION}
+                                        />
+                                    </Grid>
+                                    <Grid item sm={12}>
+                                        <Grid container direction="row" justify="flex-end">
+                                            <Button color="primary" onClick={this.props.reset} disabled={this.props.isPristine}>Discard changes</Button>
+                                            <Button
+                                                color="primary"
+                                                variant="contained"
+                                                type="submit"
+                                                disabled={this.props.isPristine || this.props.invalid || this.props.submitting}>
+                                                Save changes
+                                            </Button>
+                                        </Grid>
+                                    </Grid>
+                                </Grid>
+                            </form >
+                        </CardContent>
+                    }
+                    {this.state.value === TABS.GROUPS &&
+                        <div className={this.props.classes.content}>
+                            <DataExplorer
+                                    id={USER_PROFILE_PANEL_ID}
+                                    data-cy="user-profile-groups-data-explorer"
+                                    onRowClick={noop}
+                                    onRowDoubleClick={noop}
+                                    onContextMenu={noop}
+                                    contextMenuColumn={false}
+                                    hideColumnSelector
+                                    hideSearchInput
+                                    paperProps={{
+                                        elevation: 0,
+                                    }}
+                                    dataTableDefaultView={
+                                        <DataTableDefaultView
+                                            icon={GroupsIcon}
+                                            messages={['Group list is empty.']} />
+                                    } />
+                        </div>}
+                </Paper >;
+            }
+        }
+
+        handleChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
+            this.setState({ value });
+        }
+
+        handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
+            event.stopPropagation();
+            const resource = getResource<UserResource>(resourceUuid)(this.props.resources);
+            if (resource) {
+                this.props.handleContextMenu(event, resource);
+            }
+        }
+
+    }
+);
diff --git a/src/views/user-profile-panel/user-profile-panel.tsx b/src/views/user-profile-panel/user-profile-panel.tsx
new file mode 100644 (file)
index 0000000..a90d44a
--- /dev/null
@@ -0,0 +1,43 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from 'store/store';
+import { compose, Dispatch } from 'redux';
+import { reduxForm, isPristine, isValid } from 'redux-form';
+import { connect } from 'react-redux';
+import { UserResource } from 'models/user';
+import { getUserProfileIsInaccessible, saveEditedUser } from 'store/user-profile/user-profile-actions';
+import { UserProfilePanelRoot, UserProfilePanelRootDataProps } from 'views/user-profile-panel/user-profile-panel-root';
+import { USER_PROFILE_FORM } from "store/user-profile/user-profile-actions";
+import { matchUserProfileRoute } from 'routes/routes';
+import { openUserContextMenu } from 'store/context-menu/context-menu-actions';
+
+const mapStateToProps = (state: RootState): UserProfilePanelRootDataProps => {
+  const pathname = state.router.location ? state.router.location.pathname : '';
+  const match = matchUserProfileRoute(pathname);
+  const uuid = match ? match.params.id : state.auth.user?.uuid || '';
+
+  return {
+    isAdmin: state.auth.user!.isAdmin,
+    isSelf: state.auth.user!.uuid === uuid,
+    isPristine: isPristine(USER_PROFILE_FORM)(state),
+    isValid: isValid(USER_PROFILE_FORM)(state),
+    isInaccessible: getUserProfileIsInaccessible(state.properties) || false,
+    localCluster: state.auth.localCluster,
+    userUuid: uuid,
+    resources: state.resources,
+}};
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    handleContextMenu: (event, resource: UserResource) => dispatch<any>(openUserContextMenu(event, resource)),
+});
+
+export const UserProfilePanel = compose(
+    connect(mapStateToProps, mapDispatchToProps),
+    reduxForm({
+        form: USER_PROFILE_FORM,
+        onSubmit: (data, dispatch) => {
+            dispatch(saveEditedUser(data));
+        }
+    }))(UserProfilePanelRoot);
index 49922202517c27f5dadb9a01364b6bb66377d441..28fae4cd6b1b5a4b7a668d798c55b3271ad4c9f5 100644 (file)
@@ -20,7 +20,6 @@ import { MultipleFilesRemoveDialog } from 'views-components/file-remove-dialog/m
 import { Routes } from 'routes/routes';
 import { SidePanel } from 'views-components/side-panel/side-panel';
 import { ProcessPanel } from 'views/process-panel/process-panel';
-import { ProcessLogPanel } from 'views/process-log-panel/process-log-panel';
 import { ChangeWorkflowDialog } from 'views-components/run-process-dialog/change-workflow-dialog';
 import { CreateProjectDialog } from 'views-components/dialog-forms/create-project-dialog';
 import { CreateCollectionDialog } from 'views-components/dialog-forms/create-collection-dialog';
@@ -47,7 +46,7 @@ import { SearchResultsPanel } from 'views/search-results-panel/search-results-pa
 import { SshKeyPanel } from 'views/ssh-key-panel/ssh-key-panel';
 import { SshKeyAdminPanel } from 'views/ssh-key-panel/ssh-key-admin-panel';
 import { SiteManagerPanel } from "views/site-manager-panel/site-manager-panel";
-import { MyAccountPanel } from 'views/my-account-panel/my-account-panel';
+import { UserProfilePanel } from 'views/user-profile-panel/user-profile-panel';
 import { SharingDialog } from 'views-components/sharing-dialog/sharing-dialog';
 import { NotFoundDialog } from 'views-components/not-found-dialog/not-found-dialog';
 import { AdvancedTabDialog } from 'views-components/advanced-tab-dialog/advanced-tab-dialog';
@@ -80,8 +79,9 @@ import { UserPanel } from 'views/user-panel/user-panel';
 import { UserAttributesDialog } from 'views-components/user-dialog/attributes-dialog';
 import { CreateUserDialog } from 'views-components/dialog-forms/create-user-dialog';
 import { HelpApiClientAuthorizationDialog } from 'views-components/api-client-authorizations-dialog/help-dialog';
-import { UserManageDialog } from 'views-components/user-dialog/manage-dialog';
-import { SetupShellAccountDialog } from 'views-components/dialog-forms/setup-shell-account-dialog';
+import { DeactivateDialog } from 'views-components/user-dialog/deactivate-dialog';
+import { ActivateDialog } from 'views-components/user-dialog/activate-dialog';
+import { SetupDialog } from 'views-components/user-dialog/setup-dialog';
 import { GroupsPanel } from 'views/groups-panel/groups-panel';
 import { RemoveGroupDialog } from 'views-components/groups-dialog/remove-dialog';
 import { GroupAttributesDialog } from 'views-components/groups-dialog/attributes-dialog';
@@ -158,7 +158,6 @@ let routes = <>
     <Route path={Routes.ALL_PROCESSES} component={AllProcessesPanel} />
     <Route path={Routes.PROCESSES} component={ProcessPanel} />
     <Route path={Routes.TRASH} component={TrashPanel} />
-    <Route path={Routes.PROCESS_LOGS} component={ProcessLogPanel} />
     <Route path={Routes.SHARED_WITH_ME} component={SharedWithMePanel} />
     <Route path={Routes.RUN_PROCESS} component={RunProcessPanel} />
     <Route path={Routes.WORKFLOWS} component={WorkflowPanel} />
@@ -172,7 +171,8 @@ let routes = <>
     <Route path={Routes.KEEP_SERVICES} component={KeepServicePanel} />
     <Route path={Routes.USERS} component={UserPanel} />
     <Route path={Routes.API_CLIENT_AUTHORIZATIONS} component={ApiClientAuthorizationPanel} />
-    <Route path={Routes.MY_ACCOUNT} component={MyAccountPanel} />
+    <Route path={Routes.MY_ACCOUNT} component={UserProfilePanel} />
+    <Route path={Routes.USER_PROFILE} component={UserProfilePanel} />
     <Route path={Routes.GROUPS} component={GroupsPanel} />
     <Route path={Routes.GROUP_DETAILS} component={GroupDetailsPanel} />
     <Route path={Routes.LINKS} component={LinkPanel} />
@@ -259,7 +259,6 @@ export const WorkbenchPanel =
             <RepositoryAttributesDialog />
             <RepositoriesSampleGitDialog />
             <RichTextEditorDialog />
-            <SetupShellAccountDialog />
             <SharingDialog />
             <NotFoundDialog />
             <Snackbar />
@@ -267,7 +266,9 @@ export const WorkbenchPanel =
             <UpdateProcessDialog />
             <UpdateProjectDialog />
             <UserAttributesDialog />
-            <UserManageDialog />
+            <DeactivateDialog />
+            <ActivateDialog />
+            <SetupDialog />
             <VirtualMachineAttributesDialog />
             <FedLogin />
             <WebDavS3InfoDialog />