Merge branch '16212-login-form'
authorLucas Di Pentima <lucas@di-pentima.com.ar>
Thu, 30 Apr 2020 20:45:42 +0000 (17:45 -0300)
committerLucas Di Pentima <lucas@di-pentima.com.ar>
Thu, 30 Apr 2020 20:45:42 +0000 (17:45 -0300)
Refs #16212

Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas@di-pentima.com.ar>

24 files changed:
cypress/integration/login.spec.js
cypress/support/commands.js
package.json
src/common/config.ts
src/index.tsx
src/models/user.test.ts [new file with mode: 0644]
src/models/user.ts
src/models/vocabulary.test.ts
src/services/auth-service/auth-service.ts
src/store/auth/auth-action-session.ts
src/store/auth/auth-action.ts
src/store/collections-content-address-panel/collections-content-address-middleware-service.ts
src/store/group-details-panel/group-details-panel-actions.ts
src/store/groups-panel/groups-panel-actions.ts
src/views-components/advanced-tab-dialog/metadataTab.tsx
src/views-components/dialog-forms/add-group-member-dialog.tsx
src/views-components/dialog-forms/create-group-dialog.tsx
src/views-components/login-form/login-form.tsx [new file with mode: 0644]
src/views-components/main-app-bar/account-menu.tsx
src/views-components/sharing-dialog/participant-select.tsx [moved from src/views-components/sharing-dialog/people-select.tsx with 60% similarity]
src/views-components/sharing-dialog/sharing-invitation-form-component.tsx
src/views-components/user-dialog/manage-dialog.tsx
src/views/login-panel/login-panel.tsx
yarn.lock

index 30fce0a65d29661014e99d9c1768d3b7428a0111..c30124d8f2f9dfae5f63114953d82ac2640a383c 100644 (file)
@@ -6,6 +6,7 @@ describe('Login tests', function() {
     let activeUser;
     let inactiveUser;
     let adminUser;
+    let randomUser = {};
 
     before(function() {
         // Only set up common users once. These aren't set up as aliases because
@@ -27,6 +28,16 @@ describe('Login tests', function() {
                 inactiveUser = this.inactiveUser;
             }
         );
+        randomUser.username = `randomuser${Math.floor(Math.random() * Math.floor(999999))}`;
+        randomUser.password = {
+            crypt: 'zpAReoZzPnwmQ',
+            clear: 'topsecret',
+        };
+        cy.exec(`useradd ${randomUser.username} -p ${randomUser.password.crypt}`);
+    })
+
+    after(function() {
+        cy.exec(`userdel ${randomUser.username}`);
     })
 
     beforeEach(function() {
@@ -60,6 +71,7 @@ describe('Login tests', function() {
     it('logs in successfully with valid user token', function() {
         cy.visit(`/token/?api_token=${activeUser.token}`);
         cy.url().should('contain', '/projects/');
+        cy.get('div#root').should('contain', 'Arvados Workbench (zzzzz)');
         cy.get('div#root').should('not.contain', 'Your account is inactive');
         cy.get('button[title="Account Management"]').click();
         cy.get('ul[role=menu] > li[role=menuitem]').contains(
@@ -69,6 +81,7 @@ describe('Login tests', function() {
     it('logs in successfully with valid admin token', function() {
         cy.visit(`/token/?api_token=${adminUser.token}`);
         cy.url().should('contain', '/projects/');
+        cy.get('div#root').should('contain', 'Arvados Workbench (zzzzz)');
         cy.get('div#root').should('not.contain', 'Your account is inactive');
         cy.get('button[title="Admin Panel"]').click();
         cy.get('ul[role=menu] > li[role=menuitem]')
@@ -78,4 +91,25 @@ describe('Login tests', function() {
         cy.get('ul[role=menu] > li[role=menuitem]').contains(
             `${adminUser.user.first_name} ${adminUser.user.last_name}`);
     })
+
+    it('fails to authenticate using the login form with wrong password', function() {
+        cy.visit('/');
+        cy.get('#username').type(randomUser.username);
+        cy.get('#password').type('wrong password');
+        cy.get("button span:contains('Log in')").click();
+        cy.get('p#password-helper-text').should('contain', 'PAM: Authentication failure');
+        cy.url().should('not.contain', '/projects/');
+    })
+
+    it('successfully authenticates using the login form', function() {
+        cy.visit('/');
+        cy.get('#username').type(randomUser.username);
+        cy.get('#password').type(randomUser.password.clear);
+        cy.get("button span:contains('Log in')").click();
+        cy.url().should('contain', '/projects/');
+        cy.get('div#root').should('contain', 'Arvados Workbench (zzzzz)');
+        cy.get('div#root').should('contain', 'Your account is inactive');
+        cy.get('button[title="Account Management"]').click();
+        cy.get('ul[role=menu] > li[role=menuitem]').contains(randomUser.username);
+    })
 })
\ No newline at end of file
index ac4a5e0e0b70d4dd1fc53cf22cafa728445b7edf..68ce687059d6feb3553d4a3b671deb589de889ab 100644 (file)
@@ -53,40 +53,42 @@ Cypress.Commands.add(
     }
 )
 
-Cypress.Commands.add("getUser", (username, first_name='', last_name='', is_admin=false, is_active=true) => {
-    // Create user if not already created
-    return cy.do_request('POST', '/auth/controller/callback', {
-        auth_info: JSON.stringify({
-            email: `${username}@example.local`,
-            username: username,
-            first_name: first_name,
-            last_name: last_name,
-            alternate_emails: []
-        }),
-        return_to: ',https://example.local'
-    }, null, systemToken, true, false) // Don't follow redirects so we can catch the token
-    .its('headers.location').as('location')
-    // Get its token and set the account up as admin and/or active
-    .then(function() {
-        this.userToken = this.location.split("=")[1]
-        assert.isString(this.userToken)
-        return cy.do_request('GET', '/arvados/v1/users', null, {
-            filters: `[["username", "=", "${username}"]]`
-        })
-        .its('body.items.0')
-        .as('aUser')
+Cypress.Commands.add(
+    "getUser", (username, first_name='', last_name='', is_admin=false, is_active=true) => {
+        // Create user if not already created
+        return cy.do_request('POST', '/auth/controller/callback', {
+            auth_info: JSON.stringify({
+                email: `${username}@example.local`,
+                username: username,
+                first_name: first_name,
+                last_name: last_name,
+                alternate_emails: []
+            }),
+            return_to: ',https://example.local'
+        }, null, systemToken, true, false) // Don't follow redirects so we can catch the token
+        .its('headers.location').as('location')
+        // Get its token and set the account up as admin and/or active
         .then(function() {
-            cy.do_request('PUT', `/arvados/v1/users/${this.aUser.uuid}`, {
-                user: {
-                    is_admin: is_admin,
-                    is_active: is_active
-                }
+            this.userToken = this.location.split("=")[1]
+            assert.isString(this.userToken)
+            return cy.do_request('GET', '/arvados/v1/users', null, {
+                filters: `[["username", "=", "${username}"]]`
             })
-            .its('body')
-            .as('theUser')
+            .its('body.items.0')
+            .as('aUser')
             .then(function() {
-                return {user: this.theUser, token: this.userToken};
+                cy.do_request('PUT', `/arvados/v1/users/${this.aUser.uuid}`, {
+                    user: {
+                        is_admin: is_admin,
+                        is_active: is_active
+                    }
+                })
+                .its('body')
+                .as('theUser')
+                .then(function() {
+                    return {user: this.theUser, token: this.userToken};
+                })
             })
         })
-    })
-})
+    }
+)
index db7c410d3246e1836e2f95c0459126c861f647fe..16ad657bc165ba9e235d2a922dbad9de53ae23d3 100644 (file)
     "mem": "4.0.0",
     "prop-types": "15.7.2",
     "query-string": "6.9.0",
-    "react": "16.5.2",
+    "react": "16.8.6",
     "react-copy-to-clipboard": "5.0.1",
     "react-dnd": "5.0.0",
     "react-dnd-html5-backend": "5.0.1",
-    "react-dom": "16.5.2",
+    "react-dom": "16.8.6",
     "react-dropzone": "5.1.1",
     "react-highlight-words": "0.14.0",
     "react-redux": "5.0.7",
index 0a13f4e11664b7fe9fc945ac30e236682eb43572..f9fb9f6a0727d708a4cbd641d40d05563630dd97 100644 (file)
@@ -58,6 +58,7 @@ export interface ClusterConfigJSON {
     };
     Login: {
         LoginCluster: string;
+        PAM: boolean;
     };
     Collections: {
         ForwardSlashNameSubstitution: string;
@@ -187,6 +188,7 @@ export const mockClusterConfigJSON = (config: Partial<ClusterConfigJSON>): Clust
     },
     Login: {
         LoginCluster: "",
+        PAM: false,
     },
     Collections: {
         ForwardSlashNameSubstitution: "",
index bf810fb7583695bce32279269a11a3ce388615fc..d428b1c361143928feddfcd3529dd497d4643fa7 100644 (file)
@@ -166,5 +166,3 @@ const initListener = (history: History, store: RootStore, services: ServiceRepos
         }
     };
 };
-
-// force build comment #1
diff --git a/src/models/user.test.ts b/src/models/user.test.ts
new file mode 100644 (file)
index 0000000..4fab59d
--- /dev/null
@@ -0,0 +1,124 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { User, getUserDisplayName } from './user';
+
+describe('User', () => {
+    it('gets the user display name', () => {
+        type UserCase = {
+            caseName: string;
+            withEmail?: boolean;
+            user: User;
+            expect: string;
+        };
+        const testCases: UserCase[] = [
+            {
+                caseName: 'Full data available',
+                user: {
+                    email: 'someuser@example.com', username: 'someuser',
+                    firstName: 'Some', lastName: 'User',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'Some User'
+            },
+            {
+                caseName: 'Full data available (with email)',
+                withEmail: true,
+                user: {
+                    email: 'someuser@example.com', username: 'someuser',
+                    firstName: 'Some', lastName: 'User',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'Some User <<someuser@example.com>>'
+            },
+            {
+                caseName: 'Missing first name',
+                user: {
+                    email: 'someuser@example.com', username: 'someuser',
+                    firstName: '', lastName: 'User',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'someuser@example.com'
+            },
+            {
+                caseName: 'Missing last name',
+                user: {
+                    email: 'someuser@example.com', username: 'someuser',
+                    firstName: 'Some', lastName: '',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'someuser@example.com'
+            },
+            {
+                caseName: 'Missing first & last names',
+                user: {
+                    email: 'someuser@example.com', username: 'someuser',
+                    firstName: '', lastName: '',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'someuser@example.com'
+            },
+            {
+                caseName: 'Missing first & last names (with email)',
+                withEmail: true,
+                user: {
+                    email: 'someuser@example.com', username: 'someuser',
+                    firstName: '', lastName: '',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'someuser@example.com'
+            },
+            {
+                caseName: 'Missing first & last names, and email address',
+                user: {
+                    email: '', username: 'someuser',
+                    firstName: '', lastName: '',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'someuser'
+            },
+            {
+                caseName: 'Missing first & last names, and email address (with email)',
+                withEmail: true,
+                user: {
+                    email: '', username: 'someuser',
+                    firstName: '', lastName: '',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'someuser'
+            },
+            {
+                caseName: 'Missing all data (should not happen)',
+                user: {
+                    email: '', username: '',
+                    firstName: '', lastName: '',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'zzzzz-tpzed-someusersuuid'
+            },
+        ];
+        testCases.forEach(c => {
+            const dispName = getUserDisplayName(c.user, c.withEmail);
+            expect(dispName).toEqual(c.expect);
+        })
+    });
+});
index 87a97dfcd1934b057369cee39bcfb82f389c05e0..3f0bcf47fc082c89c65b04a4dad2420e4afb73d1 100644 (file)
@@ -26,8 +26,18 @@ export interface User {
     isActive: boolean;
 }
 
-export const getUserFullname = (user?: User) => {
-    return user ? `${user.firstName} ${user.lastName}` : "";
+export const getUserFullname = (user: User) => {
+    return user.firstName && user.lastName
+        ? `${user.firstName} ${user.lastName}`
+        : "";
+};
+
+export const getUserDisplayName = (user: User, withEmail = false) => {
+    const displayName = getUserFullname(user) || user.email || user.username || user.uuid;
+    if (withEmail && user.email && displayName !== user.email) {
+        return `${displayName} <<${user.email}>>`;
+    }
+    return displayName;
 };
 
 export interface UserResource extends Resource, User {
index 87a8dfb26f92688aa357318f6447203a45004cdc..18e2f19f8fe4be2e74e32af07ec5e7b4484e6308 100644 (file)
@@ -3,7 +3,6 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as Vocabulary from './vocabulary';
-import { pipe } from 'lodash/fp';
 
 describe('Vocabulary', () => {
     let vocabulary: Vocabulary.Vocabulary;
index 690420e78449ff35815cd63e0b1644a65012793a..61db625c62306862b6b304bfc70c8f5c9d5e4caf 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { getUserFullname, User, UserPrefs } from '~/models/user';
+import { User, UserPrefs, getUserDisplayName } from '~/models/user';
 import { AxiosInstance } from "axios";
 import { ApiActions } from "~/services/api/api-actions";
 import * as uuid from "uuid/v4";
@@ -129,7 +129,7 @@ export class AuthService {
             clusterId: cfg.uuidPrefix,
             remoteHost: cfg.rootUrl,
             baseUrl: cfg.baseUrl,
-            name: getUserFullname(user),
+            name: user ? getUserDisplayName(user): '',
             email: user ? user.email : '',
             token: this.getApiToken(),
             loggedIn: true,
index a63878286a94dc407d1060c98a8e8c7c5e5cceda..fc35ff88056db1feac4ea319993b7c606a4a3631 100644 (file)
@@ -7,7 +7,7 @@ import { setBreadcrumbs } from "~/store/breadcrumbs/breadcrumbs-actions";
 import { RootState } from "~/store/store";
 import { ServiceRepository, createServices, setAuthorizationHeader } from "~/services/services";
 import Axios from "axios";
-import { getUserFullname, User } from "~/models/user";
+import { User, getUserDisplayName } from "~/models/user";
 import { authActions } from "~/store/auth/auth-action";
 import {
     Config, ClusterConfigJSON, CLUSTER_CONFIG_PATH, DISCOVERY_DOC_PATH,
@@ -131,7 +131,7 @@ export const validateSession = (session: Session, activeSession: Session) =>
             session.token = token;
             session.email = user.email;
             session.uuid = user.uuid;
-            session.name = getUserFullname(user);
+            session.name = getUserDisplayName(user);
             session.loggedIn = true;
             session.apiRevision = apiRevision;
         };
@@ -200,6 +200,19 @@ export const validateSessions = () =>
         }
     };
 
+export const addRemoteConfig = (remoteHost: string) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const config = await getRemoteHostConfig(remoteHost);
+        if (!config) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: `Could not get config for ${remoteHost}`,
+                kind: SnackbarKind.ERROR
+            }));
+            return;
+        }
+        dispatch(authActions.REMOTE_CLUSTER_CONFIG({ config }));
+    };
+
 export const addSession = (remoteHost: string, token?: string, sendToLogin?: boolean) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         const sessions = getState().auth.sessions;
@@ -229,7 +242,7 @@ export const addSession = (remoteHost: string, token?: string, sendToLogin?: boo
                     status: SessionStatus.VALIDATED,
                     active: false,
                     email: user.email,
-                    name: getUserFullname(user),
+                    name: getUserDisplayName(user),
                     uuid: user.uuid,
                     baseUrl: config.baseUrl,
                     clusterId: config.uuidPrefix,
index 923d3c9ea693427c344fda6f5344758af153c83b..1060ec708dcaab8cd9a0e39235bc769e4c1ed841 100644 (file)
@@ -15,6 +15,7 @@ import { createServices, setAuthorizationHeader } from "~/services/services";
 import { cancelLinking } from '~/store/link-account-panel/link-account-panel-actions';
 import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
 import { WORKBENCH_LOADING_SCREEN } from '~/store/workbench/workbench-actions';
+import { addRemoteConfig } from './auth-action-session';
 
 export const authActions = unionize({
     LOGIN: {},
@@ -38,23 +39,30 @@ export const initAuth = (config: Config) => (dispatch: Dispatch, getState: () =>
     // Cancel any link account ops in progress unless the user has
     // just logged in or there has been a successful link operation
     const data = services.linkAccountService.getLinkOpStatus();
-    if (!matchTokenRoute(location.pathname) && (!matchFedTokenRoute(location.pathname)) && data === undefined) {
+    if (!matchTokenRoute(location.pathname) &&
+        (!matchFedTokenRoute(location.pathname)) && data === undefined) {
         dispatch<any>(cancelLinking()).then(() => {
             dispatch<any>(init(config));
         });
-    }
-    else {
+    } else {
         dispatch<any>(init(config));
     }
 };
 
 const init = (config: Config) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    const remoteHosts = () => getState().auth.remoteHosts;
     const token = services.authService.getApiToken();
     let homeCluster = services.authService.getHomeCluster();
     if (homeCluster && !config.remoteHosts[homeCluster]) {
         homeCluster = undefined;
     }
     dispatch(authActions.SET_CONFIG({ config }));
+    Object.keys(remoteHosts()).forEach((remoteUuid: string) => {
+        const remoteHost = remoteHosts()[remoteUuid];
+        if (remoteUuid !== config.uuidPrefix) {
+            dispatch<any>(addRemoteConfig(remoteHost));
+        }
+    });
     dispatch(authActions.SET_HOME_CLUSTER(config.loginCluster || homeCluster || config.uuidPrefix));
 
     if (token && token !== "undefined") {
index dc0f2c52c3cb10ef1db17f9d6885fec5683477b8..72da1d2e0144927395175ad4739f227b9cb02071 100644 (file)
@@ -24,6 +24,7 @@ import { updatePublicFavorites } from '~/store/public-favorites/public-favorites
 import { setBreadcrumbs } from '../breadcrumbs/breadcrumbs-actions';
 import { ResourceKind, extractUuidKind } from '~/models/resource';
 import { ownerNameActions } from '~/store/owner-name/owner-name-actions';
+import { getUserDisplayName } from '~/models/user';
 
 export class CollectionsWithSameContentAddressMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
@@ -90,7 +91,11 @@ export class CollectionsWithSameContentAddressMiddlewareService extends DataExpl
                         .getFilters()
                 });
                 responseUsers.items.map(it => {
-                    api.dispatch<any>(ownerNameActions.SET_OWNER_NAME({ name: it.uuid === userUuid ? 'User: Me' : `User: ${it.firstName} ${it.lastName}`, uuid: it.uuid }));
+                    api.dispatch<any>(ownerNameActions.SET_OWNER_NAME({
+                        name: it.uuid === userUuid
+                            ? 'User: Me'
+                            : `User: ${getUserDisplayName(it)}`,
+                        uuid: it.uuid }));
                 });
                 responseGroups.items.map(it => {
                     api.dispatch<any>(ownerNameActions.SET_OWNER_NAME({ name: `Project: ${it.name}`, uuid: it.uuid }));
index 55bfd5ae8d1cef4e22079923e6e156649c370675..b2a7280720941aa3e320c6eaefa9030e79a07ea0 100644 (file)
@@ -6,7 +6,7 @@ import { bindDataExplorerActions } from '~/store/data-explorer/data-explorer-act
 import { Dispatch } from 'redux';
 import { propertiesActions } from '~/store/properties/properties-actions';
 import { getProperty } from '~/store/properties/properties';
-import { Person } from '~/views-components/sharing-dialog/people-select';
+import { Participant } from '~/views-components/sharing-dialog/participant-select';
 import { dialogActions } from '~/store/dialog/dialog-actions';
 import { reset, startSubmit } from 'redux-form';
 import { addGroupMember, deleteGroupMember } from '~/store/groups-panel/groups-panel-actions';
@@ -16,7 +16,7 @@ import { RootState } from '~/store/store';
 import { ServiceRepository } from '~/services/services';
 import { PermissionResource } from '~/models/permission';
 import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
-import { UserResource, getUserFullname } from '~/models/user';
+import { UserResource, getUserDisplayName } from '~/models/user';
 
 export const GROUP_DETAILS_PANEL_ID = 'groupDetailsPanel';
 export const ADD_GROUP_MEMBERS_DIALOG = 'addGrupMembers';
@@ -36,7 +36,7 @@ export const loadGroupDetailsPanel = (groupUuid: string) =>
 export const getCurrentGroupDetailsPanelUuid = getProperty<string>(GROUP_DETAILS_PANEL_ID);
 
 export interface AddGroupMembersFormData {
-    [ADD_GROUP_MEMBERS_USERS_FIELD_NAME]: Person[];
+    [ADD_GROUP_MEMBERS_USERS_FIELD_NAME]: Participant[];
 }
 
 export const openAddGroupMembersDialog = () =>
@@ -113,7 +113,7 @@ export const removeGroupMember = (uuid: string) =>
             await deleteGroupMember({
                 user: {
                     uuid,
-                    name: user ? getUserFullname(user) : uuid,
+                    name: user ? getUserDisplayName(user) : uuid,
                 },
                 group: {
                     uuid: groupUuid,
index 35ec413c0ed1378b4671929baaed62d29319a9ba..b5ca37759b56e29642f6fea54d21b507e198d6e1 100644 (file)
@@ -6,7 +6,7 @@ import { Dispatch } from 'redux';
 import { reset, startSubmit, stopSubmit, FormErrors } from 'redux-form';
 import { bindDataExplorerActions } from "~/store/data-explorer/data-explorer-action";
 import { dialogActions } from '~/store/dialog/dialog-actions';
-import { Person } from '~/views-components/sharing-dialog/people-select';
+import { Participant } from '~/views-components/sharing-dialog/participant-select';
 import { RootState } from '~/store/store';
 import { ServiceRepository } from '~/services/services';
 import { getResource } from '~/store/resources/resources';
@@ -65,7 +65,7 @@ export const openRemoveGroupDialog = (uuid: string) =>
 
 export interface CreateGroupFormData {
     [CREATE_GROUP_NAME_FIELD_NAME]: string;
-    [CREATE_GROUP_USERS_FIELD_NAME]?: Person[];
+    [CREATE_GROUP_USERS_FIELD_NAME]?: Participant[];
 }
 
 export const createGroup = ({ name, users = [] }: CreateGroupFormData) =>
index bcf277c0b1d963d238e63e6fc2a14085ce676397..467501a77f874c8fa66c1fde15a7f00a190c33ae 100644 (file)
@@ -4,7 +4,7 @@
 
 import * as React from "react";
 import { Table, TableHead, TableCell, TableRow, TableBody, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
-import { UserResource } from "~/models/user";
+import { UserResource, getUserDisplayName } from "~/models/user";
 
 type CssRules = 'cell';
 
@@ -47,7 +47,7 @@ export const MetadataTab = withStyles(styles)((props: MetadataProps & WithStyles
                     <TableCell className={props.classes.cell}>{it.uuid}</TableCell>
                     <TableCell className={props.classes.cell}>{it.linkClass}</TableCell>
                     <TableCell className={props.classes.cell}>{it.name}</TableCell>
-                    <TableCell className={props.classes.cell}>{props.user && `User: ${props.user.firstName} ${props.user.lastName}`}</TableCell>
+                    <TableCell className={props.classes.cell}>{props.user && `User: ${getUserDisplayName(props.user)}`}</TableCell>
                     <TableCell className={props.classes.cell}>{it.headUuid === props.uuid ? 'this' : it.headUuid}</TableCell>
                     <TableCell className={props.classes.cell}>{JSON.stringify(it.properties)}</TableCell>
                 </TableRow>
index 2bd2109e8e0a595636998fd47da13725be4512e6..53917f54b2ede6471b3c685ea2a18a70f58fc620 100644 (file)
@@ -7,7 +7,7 @@ import { compose } from "redux";
 import { reduxForm, InjectedFormProps, WrappedFieldArrayProps, FieldArray } from 'redux-form';
 import { withDialog, WithDialogProps } from "~/store/dialog/with-dialog";
 import { FormDialog } from '~/components/form-dialog/form-dialog';
-import { PeopleSelect, Person } from '~/views-components/sharing-dialog/people-select';
+import { ParticipantSelect, Participant } from '~/views-components/sharing-dialog/participant-select';
 import { ADD_GROUP_MEMBERS_DIALOG, ADD_GROUP_MEMBERS_FORM, AddGroupMembersFormData, ADD_GROUP_MEMBERS_USERS_FIELD_NAME, addGroupMembers } from '~/store/group-details-panel/group-details-panel-actions';
 import { minLength } from '~/validators/min-length';
 
@@ -39,8 +39,8 @@ const UsersField = () =>
 
 const UsersFieldValidation = [minLength(1, () => 'Select at least one user')];
 
-const UsersSelect = ({ fields }: WrappedFieldArrayProps<Person>) =>
-    <PeopleSelect
+const UsersSelect = ({ fields }: WrappedFieldArrayProps<Participant>) =>
+    <ParticipantSelect
         onlyPeople
         autofocus
         label='Enter email adresses '
index ff692fbd7243e4307affc4326297ab5e8cc4d1ae..7af5317d534c03470d76e151eecbd5a74c9110c9 100644 (file)
@@ -11,7 +11,7 @@ import { CREATE_GROUP_DIALOG, CREATE_GROUP_FORM, createGroup, CreateGroupFormDat
 import { TextField } from '~/components/text-field/text-field';
 import { maxLength } from '~/validators/max-length';
 import { require } from '~/validators/require';
-import { PeopleSelect, Person } from '~/views-components/sharing-dialog/people-select';
+import { ParticipantSelect, Participant } from '~/views-components/sharing-dialog/participant-select';
 
 export const CreateGroupDialog = compose(
     withDialog(CREATE_GROUP_DIALOG),
@@ -54,8 +54,8 @@ const UsersField = () =>
         name={CREATE_GROUP_USERS_FIELD_NAME}
         component={UsersSelect} />;
 
-const UsersSelect = ({ fields }: WrappedFieldArrayProps<Person>) =>
-    <PeopleSelect
+const UsersSelect = ({ fields }: WrappedFieldArrayProps<Participant>) =>
+    <ParticipantSelect
         onlyPeople
         label='Enter email adresses '
         items={fields.getAll() || []}
diff --git a/src/views-components/login-form/login-form.tsx b/src/views-components/login-form/login-form.tsx
new file mode 100644 (file)
index 0000000..b64ae0b
--- /dev/null
@@ -0,0 +1,150 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { useState, useEffect, useRef } from 'react';
+import { withStyles, WithStyles, StyleRulesCallback } from '@material-ui/core/styles';
+import CircularProgress from '@material-ui/core/CircularProgress';
+import { Button, Card, CardContent, TextField, CardActions } from '@material-ui/core';
+import { green } from '@material-ui/core/colors';
+import { AxiosPromise } from 'axios';
+import { DispatchProp } from 'react-redux';
+import { saveApiToken } from '~/store/auth/auth-action';
+import { navigateToRootProject } from '~/store/navigation/navigation-action';
+
+type CssRules = 'root' | 'loginBtn' | 'card' | 'wrapper' | 'progress';
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    root: {
+        display: 'flex',
+        flexWrap: 'wrap',
+        width: '100%',
+        margin: `${theme.spacing.unit} auto`
+    },
+    loginBtn: {
+        marginTop: theme.spacing.unit,
+        flexGrow: 1
+    },
+    card: {
+        marginTop: theme.spacing.unit,
+        width: '100%'
+    },
+    wrapper: {
+        margin: theme.spacing.unit,
+        position: 'relative',
+    },
+    progress: {
+        color: green[500],
+        position: 'absolute',
+        top: '50%',
+        left: '50%',
+        marginTop: -12,
+        marginLeft: -12,
+    },
+});
+
+type LoginFormProps = DispatchProp<any> & WithStyles<CssRules> & {
+    handleSubmit: (username: string, password: string) => AxiosPromise;
+    loginLabel?: string,
+};
+
+export const LoginForm = withStyles(styles)(
+    ({ handleSubmit, loginLabel, dispatch, classes }: LoginFormProps) => {
+        const userInput = useRef<HTMLInputElement>(null);
+        const [username, setUsername] = useState('');
+        const [password, setPassword] = useState('');
+        const [isButtonDisabled, setIsButtonDisabled] = useState(true);
+        const [isSubmitting, setSubmitting] = useState(false);
+        const [helperText, setHelperText] = useState('');
+        const [error, setError] = useState(false);
+
+        useEffect(() => {
+            setError(false);
+            setHelperText('');
+            if (username.trim() && password.trim()) {
+                setIsButtonDisabled(false);
+            } else {
+                setIsButtonDisabled(true);
+            }
+        }, [username, password]);
+
+        // This only runs once after render.
+        useEffect(() => {
+            setFocus();
+        }, []);
+
+        const setFocus = () => {
+            userInput.current!.focus();
+        };
+
+        const handleLogin = () => {
+            setError(false);
+            setHelperText('');
+            setSubmitting(true);
+            handleSubmit(username, password)
+            .then((response) => {
+                setSubmitting(false);
+                if (response.data.uuid && response.data.api_token) {
+                    const apiToken = `v2/${response.data.uuid}/${response.data.api_token}`;
+                    dispatch<any>(saveApiToken(apiToken)).finally(
+                        () => dispatch(navigateToRootProject));
+                } else {
+                    setError(true);
+                    setHelperText(response.data.message || 'Please try again');
+                    setFocus();
+                }
+            })
+            .catch((err) => {
+                setError(true);
+                setSubmitting(false);
+                setHelperText(`${err.response && err.response.data && err.response.data.errors[0] || 'Error logging in: '+err}`);
+                setFocus();
+            });
+        };
+
+        const handleKeyPress = (e: any) => {
+            if (e.keyCode === 13 || e.which === 13) {
+                if (!isButtonDisabled) {
+                    handleLogin();
+                }
+            }
+        };
+
+        return (
+            <React.Fragment>
+            <form className={classes.root} noValidate autoComplete="off">
+                <Card className={classes.card}>
+                    <div className={classes.wrapper}>
+                    <CardContent>
+                        <TextField
+                            inputRef={userInput}
+                            disabled={isSubmitting}
+                            error={error} fullWidth id="username" type="email"
+                            label="Username" margin="normal"
+                            onChange={(e) => setUsername(e.target.value)}
+                            onKeyPress={(e) => handleKeyPress(e)}
+                        />
+                        <TextField
+                            disabled={isSubmitting}
+                            error={error} fullWidth id="password" type="password"
+                            label="Password" margin="normal"
+                            helperText={helperText}
+                            onChange={(e) => setPassword(e.target.value)}
+                            onKeyPress={(e) => handleKeyPress(e)}
+                        />
+                    </CardContent>
+                    <CardActions>
+                        <Button variant="contained" size="large" color="primary"
+                            className={classes.loginBtn} onClick={() => handleLogin()}
+                            disabled={isSubmitting || isButtonDisabled}>
+                            {loginLabel || 'Log in'}
+                        </Button>
+                    </CardActions>
+                    { isSubmitting && <CircularProgress color='secondary' className={classes.progress} />}
+                    </div>
+                </Card>
+            </form>
+            </React.Fragment>
+        );
+    });
index 346a9ef02ea529db2fa33d4e5664ed7a567ddf40..37702536a67cc49d8a997c06f8adc41906034da8 100644 (file)
@@ -5,7 +5,7 @@
 import * as React from "react";
 import { MenuItem, Divider } from "@material-ui/core";
 import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
-import { User, getUserFullname } from "~/models/user";
+import { User, getUserDisplayName } from "~/models/user";
 import { DropdownMenu } from "~/components/dropdown-menu/dropdown-menu";
 import { UserPanelIcon } from "~/components/icon/icon";
 import { DispatchProp, connect } from 'react-redux';
@@ -66,7 +66,7 @@ export const AccountMenu = withStyles(styles)(
                     title="Account Management"
                     key={currentRoute}>
                     <MenuItem disabled>
-                        {getUserFullname(user)} {user.uuid.substr(0, 5) !== localCluster && `(${user.uuid.substr(0, 5)})`}
+                        {getUserDisplayName(user)} {user.uuid.substr(0, 5) !== localCluster && `(${user.uuid.substr(0, 5)})`}
                     </MenuItem>
                     {user.isActive ? <>
                         <MenuItem onClick={() => dispatch(openUserVirtualMachines())}>Virtual Machines</MenuItem>
similarity index 60%
rename from src/views-components/sharing-dialog/people-select.tsx
rename to src/views-components/sharing-dialog/participant-select.tsx
index 90235cd5831507314d238a7704faf1efa21cb7d4..5d062da0ef7ae612179c2e9edfc14e5622eec739 100644 (file)
@@ -10,38 +10,50 @@ import { FilterBuilder } from '../../services/api/filter-builder';
 import { debounce } from 'debounce';
 import { ListItemText, Typography } from '@material-ui/core';
 import { noop } from 'lodash/fp';
-import { GroupClass } from '~/models/group';
+import { GroupClass, GroupResource } from '~/models/group';
+import { getUserDisplayName, UserResource } from '~/models/user';
+import { ResourceKind } from '~/models/resource';
+import { ListResults } from '~/services/common-service/common-service';
 
-export interface Person {
+export interface Participant {
     name: string;
-    email: string;
     uuid: string;
 }
 
-export interface PeopleSelectProps {
+type ParticipantResource = GroupResource & UserResource;
 
-    items: Person[];
+interface ParticipantSelectProps {
+    items: Participant[];
     label?: string;
     autofocus?: boolean;
     onlyPeople?: boolean;
 
     onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
     onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
-    onCreate?: (person: Person) => void;
+    onCreate?: (person: Participant) => void;
     onDelete?: (index: number) => void;
-    onSelect?: (person: Person) => void;
-
+    onSelect?: (person: Participant) => void;
 }
 
-export interface PeopleSelectState {
+interface ParticipantSelectState {
     value: string;
-    suggestions: any[];
+    suggestions: ParticipantResource[];
 }
 
-export const PeopleSelect = connect()(
-    class PeopleSelect extends React.Component<PeopleSelectProps & DispatchProp, PeopleSelectState> {
-
-        state: PeopleSelectState = {
+const getDisplayName = (item: GroupResource & UserResource) => {
+    switch(item.kind) {
+        case ResourceKind.USER:
+            return getUserDisplayName(item, true);
+        case ResourceKind.GROUP:
+            return item.name;
+        default:
+            return item.uuid;
+    }
+};
+
+export const ParticipantSelect = connect()(
+    class ParticipantSelect extends React.Component<ParticipantSelectProps & DispatchProp, ParticipantSelectState> {
+        state: ParticipantSelectState = {
             value: '',
             suggestions: []
         };
@@ -67,21 +79,20 @@ export const PeopleSelect = connect()(
             );
         }
 
-        renderChipValue({ name, uuid }: Person) {
-            return name ? name : uuid;
+        renderChipValue(chipValue: Participant) {
+            const { name, uuid } = chipValue;
+            return name || uuid;
         }
 
-        renderSuggestion({ firstName, lastName, email, name }: any) {
+        renderSuggestion(item: ParticipantResource) {
             return (
                 <ListItemText>
-                    {name ?
-                        <Typography noWrap>{name}</Typography> :
-                        <Typography noWrap>{`${firstName} ${lastName} <<${email}>>`}</Typography>}
+                    <Typography noWrap>{getDisplayName(item)}</Typography>
                 </ListItemText>
             );
         }
 
-        handleDelete = (_: Person, index: number) => {
+        handleDelete = (_: Participant, index: number) => {
             const { onDelete = noop } = this.props;
             onDelete(index);
         }
@@ -91,19 +102,18 @@ export const PeopleSelect = connect()(
             if (onCreate) {
                 this.setState({ value: '', suggestions: [] });
                 onCreate({
-                    email: '',
                     name: '',
                     uuid: this.state.value,
                 });
             }
         }
 
-        handleSelect = ({ email, firstName, lastName, uuid, name }: any) => {
+        handleSelect = (selection: ParticipantResource) => {
+            const { uuid } = selection;
             const { onSelect = noop } = this.props;
             this.setState({ value: '', suggestions: [] });
             onSelect({
-                email,
-                name: `${name ? name : `${firstName} ${lastName}`}`,
+                name: getDisplayName(selection),
                 uuid,
             });
         }
@@ -116,16 +126,23 @@ export const PeopleSelect = connect()(
 
         requestSuggestions = async (_: void, __: void, { userService, groupsService }: ServiceRepository) => {
             const { value } = this.state;
+            const limit = 5; // FIXME: Does this provide a good UX?
+
+            const filterUsers = new FilterBuilder()
+                .addILike('any', value)
+                .getFilters();
+            const userItems: ListResults<any> = await userService.list({ filters: filterUsers, limit });
+
             const filterGroups = new FilterBuilder()
                 .addNotIn('group_class', [GroupClass.PROJECT])
                 .addILike('name', value)
                 .getFilters();
-            const groupItems = await groupsService.list({ filters: filterGroups, limit: 5 });
-            const filterUsers = new FilterBuilder()
-                .addILike('email', value)
-                .getFilters();
-            const userItems: any = await userService.list({ filters: filterUsers, limit: 5 });
-            const items = groupItems.items.concat(userItems.items);
-            this.setState({ suggestions: this.props.onlyPeople ? userItems.items : items });
+
+            const groupItems: ListResults<any> = await groupsService.list({ filters: filterGroups, limit });
+            this.setState({
+                suggestions: this.props.onlyPeople
+                    ? userItems.items
+                    : userItems.items.concat(groupItems.items)
+            });
         }
     });
index 5aec8febed3252b9e4bf3768466ac04fb20ef4a3..59456fb3f046119b22d8e0d3b00a668e1690b21f 100644 (file)
@@ -6,7 +6,7 @@ import * as React from 'react';
 import { Field, WrappedFieldProps, FieldArray, WrappedFieldArrayProps } from 'redux-form';
 import { Grid, FormControl, InputLabel } from '@material-ui/core';
 import { PermissionSelect, parsePermissionLevel, formatPermissionLevel } from './permission-select';
-import { PeopleSelect, Person } from './people-select';
+import { ParticipantSelect, Participant } from './participant-select';
 
 export default () =>
     <Grid container spacing={8}>
@@ -24,8 +24,8 @@ const InvitedPeopleField = () =>
         component={InvitedPeopleFieldComponent} />;
 
 
-const InvitedPeopleFieldComponent = ({ fields }: WrappedFieldArrayProps<Person>) =>
-    <PeopleSelect
+const InvitedPeopleFieldComponent = ({ fields }: WrappedFieldArrayProps<Participant>) =>
+    <ParticipantSelect
         items={fields.getAll() || []}
         onSelect={fields.push}
         onDelete={fields.remove} />;
index 05e4a3fc091b5d71943f42adaa6da3e9110f05e9..f1f0b6ce8580162582d2e12dd1faf9a92cc33c96 100644 (file)
@@ -3,14 +3,15 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as 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 { compose, Dispatch } from "redux";
 import { USER_MANAGEMENT_DIALOG, openSetupShellAccount, loginAs } from "~/store/users/users-actions";
-import { connect } from "react-redux";
+import { getUserDisplayName } from "~/models/user";
 
 type CssRules = 'spacing';
 
@@ -48,19 +49,19 @@ export const UserManageDialog = compose(
                 maxWidth="md">
                 {props.data &&
                     <span>
-                        <DialogTitle>{`Manage - ${props.data.firstName} ${props.data.lastName}`}</DialogTitle>
+                        <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 ${props.data.firstName} ${props.data.lastName}`}
+                                {`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 ${props.data.firstName} ${props.data.lastName}`}
+                                {`SETUP SHELL ACCOUNT FOR ${getUserDisplayName(props.data)}`}
                             </Button>
                         </DialogContent></span>}
 
index 45f796fd8c5847e28a7ecd41e38f94166ff42b63..25fee7eb3ccc99110cada5174aab1363973b52b6 100644 (file)
@@ -9,6 +9,8 @@ import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/st
 import { login, authActions } from '~/store/auth/auth-action';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { RootState } from '~/store/store';
+import { LoginForm } from '~/views-components/login-form/login-form';
+import Axios from 'axios';
 
 type CssRules = 'root' | 'container' | 'title' | 'content' | 'content__bolder' | 'button';
 
@@ -47,23 +49,39 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     }
 });
 
+const doPAMLogin = (url: string) => (username: string, password: string) => {
+    const formData = [];
+    formData.push('username='+encodeURIComponent(username));
+    formData.push('password='+encodeURIComponent(password));
+    return Axios.post(`${url}/arvados/v1/users/authenticate`, formData.join('&'), {
+        headers: {
+            'Content-Type': 'application/x-www-form-urlencoded'
+        },
+    });
+};
+
 type LoginPanelProps = DispatchProp<any> & WithStyles<CssRules> & {
     remoteHosts: { [key: string]: string },
     homeCluster: string,
-    uuidPrefix: string,
+    localCluster: string,
     loginCluster: string,
-    welcomePage: string
+    welcomePage: string,
+    pamLogin: boolean,
 };
 
 export const LoginPanel = withStyles(styles)(
     connect((state: RootState) => ({
         remoteHosts: state.auth.remoteHosts,
         homeCluster: state.auth.homeCluster,
-        uuidPrefix: state.auth.localCluster,
+        localCluster: state.auth.localCluster,
         loginCluster: state.auth.loginCluster,
-        welcomePage: state.auth.config.clusterConfig.Workbench.WelcomePageHTML
-    }))(({ classes, dispatch, remoteHosts, homeCluster, uuidPrefix, loginCluster, welcomePage }: LoginPanelProps) =>
-        <Grid container justify="center" alignItems="center"
+        welcomePage: state.auth.config.clusterConfig.Workbench.WelcomePageHTML,
+        pamLogin: state.auth.remoteHostsConfig[state.auth.loginCluster || state.auth.homeCluster] &&
+            state.auth.remoteHostsConfig[state.auth.loginCluster || state.auth.homeCluster].clusterConfig.Login.PAM || false,
+    }))(({ classes, dispatch, remoteHosts, homeCluster, localCluster, loginCluster, welcomePage, pamLogin }: LoginPanelProps) => {
+        const loginBtnLabel = `Log in${(localCluster !== homeCluster && loginCluster !== homeCluster) ? " to "+localCluster+" with user from "+homeCluster : ''}`;
+
+        return (<Grid container justify="center" alignItems="center"
             className={classes.root}
             style={{ marginTop: 56, overflowY: "auto", height: "100%" }}>
             <Grid item className={classes.container}>
@@ -80,14 +98,19 @@ export const LoginPanel = withStyles(styles)(
                         </Select>
                     </Typography>}
 
-                <Typography component="div" align="right">
-                    <Button variant="contained" color="primary" style={{ margin: "1em" }} className={classes.button}
-                        onClick={() => dispatch(login(uuidPrefix, homeCluster, loginCluster, remoteHosts))}>
-                        Log in
-                       {uuidPrefix !== homeCluster && loginCluster !== homeCluster &&
-                            <span>&nbsp;to {uuidPrefix} with user from {homeCluster}</span>}
-                    </Button>
+                {pamLogin
+                ? <Typography component="div">
+                    <LoginForm dispatch={dispatch}
+                        loginLabel={loginBtnLabel}
+                        handleSubmit={doPAMLogin(`https://${remoteHosts[loginCluster || homeCluster]}`)}/>
                 </Typography>
+                : <Typography component="div" align="right">
+                    <Button variant="contained" color="primary" style={{ margin: "1em" }}
+                        className={classes.button}
+                        onClick={() => dispatch(login(localCluster, homeCluster, loginCluster, remoteHosts))}>
+                        {loginBtnLabel}
+                    </Button>
+                </Typography>}
             </Grid>
-        </Grid >
+        </Grid >);}
     ));
index e1a62b88ea156c1b16847a351e7ea3c68cceb3fb..da2629f5188958e9269e24bbb6720dc45956fee4 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
@@ -8862,15 +8862,15 @@ react-dnd@5.0.0:
     recompose "^0.27.1"
     shallowequal "^1.0.2"
 
-react-dom@16.5.2:
-  version "16.5.2"
-  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.5.2.tgz#b69ee47aa20bab5327b2b9d7c1fe2a30f2cfa9d7"
-  integrity sha512-RC8LDw8feuZOHVgzEf7f+cxBr/DnKdqp56VU0lAs1f4UfKc4cU8wU4fTq/mgnvynLQo8OtlPC19NUFh/zjZPuA==
+react-dom@16.8.6:
+  version "16.8.6"
+  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.6.tgz#71d6303f631e8b0097f56165ef608f051ff6e10f"
+  integrity sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA==
   dependencies:
     loose-envify "^1.1.0"
     object-assign "^4.1.1"
     prop-types "^15.6.2"
-    schedule "^0.5.0"
+    scheduler "^0.13.6"
 
 react-dropzone@5.1.1:
   version "5.1.1"
@@ -9055,15 +9055,15 @@ react-transition-group@^2.2.1:
     prop-types "^15.6.2"
     react-lifecycles-compat "^3.0.4"
 
-react@16.5.2:
-  version "16.5.2"
-  resolved "https://registry.yarnpkg.com/react/-/react-16.5.2.tgz#19f6b444ed139baa45609eee6dc3d318b3895d42"
-  integrity sha512-FDCSVd3DjVTmbEAjUNX6FgfAmQ+ypJfHUsqUJOYNCBUp1h8lqmtC+0mXJ+JjsWx4KAVTkk1vKd1hLQPvEviSuw==
+react@16.8.6:
+  version "16.8.6"
+  resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe"
+  integrity sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==
   dependencies:
     loose-envify "^1.1.0"
     object-assign "^4.1.1"
     prop-types "^15.6.2"
-    schedule "^0.5.0"
+    scheduler "^0.13.6"
 
 read-pkg-up@^1.0.1:
   version "1.0.1"
@@ -9715,11 +9715,12 @@ sax@^1.2.1, sax@^1.2.4, sax@~1.2.1:
   resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
   integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
 
-schedule@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/schedule/-/schedule-0.5.0.tgz#c128fffa0b402488b08b55ae74bb9df55cc29cc8"
-  integrity sha512-HUcJicG5Ou8xfR//c2rPT0lPIRR09vVvN81T9fqfVgBmhERUbDEQoYKjpBxbueJnCPpSu2ujXzOnRQt6x9o/jw==
+scheduler@^0.13.6:
+  version "0.13.6"
+  resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.6.tgz#466a4ec332467b31a91b9bf74e5347072e4cd889"
+  integrity sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==
   dependencies:
+    loose-envify "^1.1.0"
     object-assign "^4.1.1"
 
 scheduler@^0.17.0: