"chromeWebSecurity": false,
"viewportWidth": 1920,
"viewportHeight": 1080,
- "downloadsFolder": "cypress/downloads"
+ "downloadsFolder": "cypress/downloads",
+ "videoUploadOnPasses": false
}
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
--- /dev/null
+// 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
});
});
- 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
--- /dev/null
+// 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,
+ });
+ });
+
+});
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')
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')
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)
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
}
)
+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', {
}
)
+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', {
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[];
--- /dev/null
+// 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>
+ );
+ }
+ }
+));
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,
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} />;
panelIlluminated?: boolean;
panelRef?: MutableRefObject<any>;
forwardProps?: boolean;
+ maxHeight?: string;
}
interface 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
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);
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}
}
});
+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}`,
</Select>
<FormHelperText>{props.meta.error}</FormHelperText>
</FormControl>
-);
\ No newline at end of file
+);
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}`;
};
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]);
};
ARV_MOUNT = 'arv-mount',
STDOUT = 'stdout',
STDERR = 'stderr',
+ CONTAINER = 'container',
}
export interface LogResource extends Resource, ResourceWithProperties {
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);
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);
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) {
} 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) {
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',
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})`,
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;
}
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 });
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 });
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';
});
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)),
},
}
);
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"]`
},
}
//
// 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";
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;
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>) {
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 };
return CommonService.defaultResponse(
this.serverApi
- .get<T>(`/${this.resourceType}/${uuid}`, session ? cfg : undefined),
+ .get<T>(`/${this.resourceType}/${uuid}`, cfg),
this.actions,
true, // mapKeys
showErrors
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
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('/');
import { ResourceKind } from 'models/resource';
import { GroupResource } from 'models/group';
import { extractUuidKind } from 'models/resource';
+import { UserResource } from 'models/user';
export const BREADCRUMBS = 'breadcrumbs';
}
};
-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 },
+ ]));
+ };
}
};
+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!;
--- /dev/null
+// 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;
+};
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';
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({
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) =>
} 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',
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);
}
}
-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.',
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({
}
}
-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.',
+++ /dev/null
-// 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;
- }
- };
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({
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));
};
-export const navigateToProcessLogs = compose(push, getProcessLogUrl);
-
export const navigateToRootProject = (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
navigateTo(SidePanelTreeCategory.PROJECTS)(dispatch, getState);
};
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);
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';
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));
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) {
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({
...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 };
};
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,
LogEventType.NODE_INFO,
LogEventType.STDERR,
LogEventType.STDOUT,
+ LogEventType.CONTAINER,
];
//
// 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[];
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;
};
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>(),
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());
};
//
// 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
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';
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)
);
sharedWithMePanelMiddleware,
workflowPanelMiddleware,
userPanelMiddleware,
+ userProfileGroupsMiddleware,
groupsPanelMiddleware,
groupDetailsPanelMembersMiddleware,
groupDetailsPanelPermissionsMiddleware,
--- /dev/null
+// 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,
+ }));
+ }
+ };
--- /dev/null
+// 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
+ });
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) {
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>();
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>) =>
snackbarActions.OPEN_SNACKBAR({
message: 'Could not fetch users.',
kind: SnackbarKind.ERROR
- });
\ No newline at end of file
+ });
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) => {
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
+ }));
+ }
+ }
}
};
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 }));
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));
}
};
}
};
-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();
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;
+}
dispatch(updateResources([permission]));
} else {
const permission = await services.permissionService.create({
- headUuid: vmUuid,
+ headUuid: vmUuid,
tailUuid: userResource.uuid,
name: PermissionLevel.CAN_LOGIN,
properties: {
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';
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,
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';
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';
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';
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 }));
}
};
-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.`,
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>) => {
export const loadUsers = handleFirstTimeLoad(
async (dispatch: Dispatch<any>) => {
await dispatch(loadUsersPanel());
- dispatch(setBreadcrumbs([{ label: 'Users' }]));
+ dispatch(setUsersBreadcrumbs());
});
export const loadApiClientAuthorizations = handleFirstTimeLoad(
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)];
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';
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";
dispatch<any>(openProcessCommandDialog(resource.uuid));
}
},
- {
- icon: LogIcon,
- name: "Log",
- execute: (dispatch, resource) => {
- dispatch<any>(navigateToProcessLogs(resource.uuid));
- }
- },
{
icon: DetailsIcon,
name: "View details",
// 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",
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,
+ ],
+}]];
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
};
};
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",
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';
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';
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) => {
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>;
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,
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 />;
}
<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 }) => {
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
const UserAddFields = (props: DialogUserProps) => <span>
<UserEmailField />
- <UserVirtualMachineField data={props.data}/>
+ <UserVirtualMachineField data={props.data as DataProps}/>
<UserGroupsVirtualMachineField />
</span>;
+++ /dev/null
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-import React from 'react';
-import { compose } from "redux";
-import { reduxForm, InjectedFormProps, 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 />
- </>;
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
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 })));
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { 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);
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { 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);
+++ /dev/null
-// 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>
- );
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
+import { 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);
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';
export enum GroupDetailsPanelMembersColumnNames {
FULL_NAME = "Name",
USERNAME = "Username",
- ACTIVE = "User Active",
+ STATUS = "Account Status",
VISIBLE = "Visible to other members",
PERMISSION = "Permission",
REMOVE = "Remove",
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,
+++ /dev/null
-// 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 >;
- }
-);
+++ /dev/null
-// 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);
+++ /dev/null
-// 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
+++ /dev/null
-// 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
+++ /dev/null
-// 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>;
+++ /dev/null
-// 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);
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,
({ classes, process, doHidePanel, panelName }: ProcessDetailsCardProps) => {
return <Card className={classes.card}>
<CardHeader
+ className={classes.header}
classes={{
content: classes.title,
}}
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,
const finishedAt = container ? formatDate(container.finishedAt) : 'N/A';
return <Card className={classes.card}>
<CardHeader
+ className={classes.header}
classes={{
content: classes.title,
avatar: classes.avatar
--- /dev/null
+// 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 >
+});
+
--- /dev/null
+// 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
// 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[];
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 })}
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';
process?: Process;
subprocesses: Array<Process>;
filters: Array<SubprocessFilterDataProps>;
+ processLogsPanel: ProcessLogsPanel;
}
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>;
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)}
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>
icon={ProcessIcon}
messages={['Process not found']} />
</Grid>);
-
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));
},
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)
checked: processPanel.filters[filter],
key: filter,
}));
- };
\ No newline at end of file
+ };
+
+export const ProcessPanel = connect(mapStateToProps, mapDispatchToProps)(ProcessPanelRoot);
// 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';
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: {
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"
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,
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} />
},
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) => {
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>;
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);
}
}
}
--- /dev/null
+// 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);
+ }
+ }
+
+ }
+);
--- /dev/null
+// 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);
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';
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';
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';
<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} />
<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} />
<RepositoryAttributesDialog />
<RepositoriesSampleGitDialog />
<RichTextEditorDialog />
- <SetupShellAccountDialog />
<SharingDialog />
<NotFoundDialog />
<Snackbar />
<UpdateProcessDialog />
<UpdateProjectDialog />
<UserAttributesDialog />
- <UserManageDialog />
+ <DeactivateDialog />
+ <ActivateDialog />
+ <SetupDialog />
<VirtualMachineAttributesDialog />
<FedLogin />
<WebDavS3InfoDialog />