[ARVADOS-WORKBENCH2] created: 1.4.1-330-g63ee9df0

Git user git at public.arvados.org
Wed Apr 29 20:28:53 UTC 2020


        at  63ee9df0903ee7378be295d9b0656f7ae0aa3ddc (commit)


commit 63ee9df0903ee7378be295d9b0656f7ae0aa3ddc
Author: Lucas Di Pentima <lucas at di-pentima.com.ar>
Date:   Tue Apr 28 19:06:02 2020 -0300

    16212: Adds tests for login via user/password form.
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas at di-pentima.com.ar>

diff --git a/cypress/integration/login.spec.js b/cypress/integration/login.spec.js
index 30fce0a6..c30124d8 100644
--- a/cypress/integration/login.spec.js
+++ b/cypress/integration/login.spec.js
@@ -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
diff --git a/cypress/support/commands.js b/cypress/support/commands.js
index ac4a5e0e..68ce6870 100644
--- a/cypress/support/commands.js
+++ b/cypress/support/commands.js
@@ -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};
+                })
             })
         })
-    })
-})
+    }
+)

commit 792b37dffef102105fa902888cb948da43b892b4
Author: Lucas Di Pentima <lucas at di-pentima.com.ar>
Date:   Mon Apr 27 19:06:29 2020 -0300

    16212: Refactors PeopleSelect component used on 'Share' dialog.
    
    * Now named ParticipantSelect as it also retrieves Groups.
    * Search for 'any' field on Users instead of just email.
    * Don't request groups if 'onlyPeople' prop passed.
    * Show users' display name, including email.
    * Fix chip rendering to show the same as what's listed.
    
    TBD: ParticipantSelect retrieves only 5 items per request when autocompleting.
    This may not be what users expect, but listing too many items require UI
    adjustments.
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas at di-pentima.com.ar>

diff --git a/src/models/user.test.ts b/src/models/user.test.ts
index baa86da9..4fab59d5 100644
--- a/src/models/user.test.ts
+++ b/src/models/user.test.ts
@@ -8,6 +8,7 @@ describe('User', () => {
     it('gets the user display name', () => {
         type UserCase = {
             caseName: string;
+            withEmail?: boolean;
             user: User;
             expect: string;
         };
@@ -23,6 +24,18 @@ describe('User', () => {
                 },
                 expect: 'Some User'
             },
+            {
+                caseName: 'Full data available (with email)',
+                withEmail: true,
+                user: {
+                    email: 'someuser at 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 at example.com>>'
+            },
             {
                 caseName: 'Missing first name',
                 user: {
@@ -56,6 +69,18 @@ describe('User', () => {
                 },
                 expect: 'someuser at example.com'
             },
+            {
+                caseName: 'Missing first & last names (with email)',
+                withEmail: true,
+                user: {
+                    email: 'someuser at example.com', username: 'someuser',
+                    firstName: '', lastName: '',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'someuser at example.com'
+            },
             {
                 caseName: 'Missing first & last names, and email address',
                 user: {
@@ -67,6 +92,18 @@ describe('User', () => {
                 },
                 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: {
@@ -80,7 +117,7 @@ describe('User', () => {
             },
         ];
         testCases.forEach(c => {
-            const dispName = getUserDisplayName(c.user);
+            const dispName = getUserDisplayName(c.user, c.withEmail);
             expect(dispName).toEqual(c.expect);
         })
     });
diff --git a/src/models/user.ts b/src/models/user.ts
index 1e9a0260..3f0bcf47 100644
--- a/src/models/user.ts
+++ b/src/models/user.ts
@@ -32,8 +32,12 @@ export const getUserFullname = (user: User) => {
         : "";
 };
 
-export const getUserDisplayName = (user: User) => {
-    return getUserFullname(user) || user.email || user.username || user.uuid;
+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 {
diff --git a/src/store/group-details-panel/group-details-panel-actions.ts b/src/store/group-details-panel/group-details-panel-actions.ts
index 41fb690f..b2a72807 100644
--- a/src/store/group-details-panel/group-details-panel-actions.ts
+++ b/src/store/group-details-panel/group-details-panel-actions.ts
@@ -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';
@@ -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 = () =>
diff --git a/src/store/groups-panel/groups-panel-actions.ts b/src/store/groups-panel/groups-panel-actions.ts
index 35ec413c..b5ca3775 100644
--- a/src/store/groups-panel/groups-panel-actions.ts
+++ b/src/store/groups-panel/groups-panel-actions.ts
@@ -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) =>
diff --git a/src/views-components/dialog-forms/add-group-member-dialog.tsx b/src/views-components/dialog-forms/add-group-member-dialog.tsx
index 2bd2109e..53917f54 100644
--- a/src/views-components/dialog-forms/add-group-member-dialog.tsx
+++ b/src/views-components/dialog-forms/add-group-member-dialog.tsx
@@ -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 '
diff --git a/src/views-components/dialog-forms/create-group-dialog.tsx b/src/views-components/dialog-forms/create-group-dialog.tsx
index ff692fbd..7af5317d 100644
--- a/src/views-components/dialog-forms/create-group-dialog.tsx
+++ b/src/views-components/dialog-forms/create-group-dialog.tsx
@@ -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/sharing-dialog/people-select.tsx b/src/views-components/sharing-dialog/participant-select.tsx
similarity index 60%
rename from src/views-components/sharing-dialog/people-select.tsx
rename to src/views-components/sharing-dialog/participant-select.tsx
index 90235cd5..5d062da0 100644
--- a/src/views-components/sharing-dialog/people-select.tsx
+++ b/src/views-components/sharing-dialog/participant-select.tsx
@@ -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)
+            });
         }
     });
diff --git a/src/views-components/sharing-dialog/sharing-invitation-form-component.tsx b/src/views-components/sharing-dialog/sharing-invitation-form-component.tsx
index 5aec8feb..59456fb3 100644
--- a/src/views-components/sharing-dialog/sharing-invitation-form-component.tsx
+++ b/src/views-components/sharing-dialog/sharing-invitation-form-component.tsx
@@ -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} />;

commit 075dd4fd268311d5e28029ad3372cd5efb000c4d
Author: Lucas Di Pentima <lucas at di-pentima.com.ar>
Date:   Fri Apr 24 17:55:34 2020 -0300

    16212: Uses getUserDisplayName() wherever needed to show a user's name.
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas at di-pentima.com.ar>

diff --git a/src/models/user.test.ts b/src/models/user.test.ts
index d313ef4e..baa86da9 100644
--- a/src/models/user.test.ts
+++ b/src/models/user.test.ts
@@ -5,10 +5,6 @@
 import { User, getUserDisplayName } from './user';
 
 describe('User', () => {
-
-    beforeEach(() => {
-    });
-
     it('gets the user display name', () => {
         type UserCase = {
             caseName: string;
diff --git a/src/store/collections-content-address-panel/collections-content-address-middleware-service.ts b/src/store/collections-content-address-panel/collections-content-address-middleware-service.ts
index dc0f2c52..72da1d2e 100644
--- a/src/store/collections-content-address-panel/collections-content-address-middleware-service.ts
+++ b/src/store/collections-content-address-panel/collections-content-address-middleware-service.ts
@@ -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 }));
diff --git a/src/views-components/advanced-tab-dialog/metadataTab.tsx b/src/views-components/advanced-tab-dialog/metadataTab.tsx
index bcf277c0..467501a7 100644
--- a/src/views-components/advanced-tab-dialog/metadataTab.tsx
+++ b/src/views-components/advanced-tab-dialog/metadataTab.tsx
@@ -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>
diff --git a/src/views-components/user-dialog/manage-dialog.tsx b/src/views-components/user-dialog/manage-dialog.tsx
index 05e4a3fc..f1f0b6ce 100644
--- a/src/views-components/user-dialog/manage-dialog.tsx
+++ b/src/views-components/user-dialog/manage-dialog.tsx
@@ -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>}
 

commit ecfd85a34f5852c160a25ab61bc5c38059927c56
Author: Lucas Di Pentima <lucas at di-pentima.com.ar>
Date:   Fri Apr 24 12:06:29 2020 -0300

    16212: Displays user depending on available user data.
    
    Some times first/last names or email aren't available, so we want to
    show other than an empty string.
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas at di-pentima.com.ar>

diff --git a/src/models/user.test.ts b/src/models/user.test.ts
new file mode 100644
index 00000000..d313ef4e
--- /dev/null
+++ b/src/models/user.test.ts
@@ -0,0 +1,91 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { User, getUserDisplayName } from './user';
+
+describe('User', () => {
+
+    beforeEach(() => {
+    });
+
+    it('gets the user display name', () => {
+        type UserCase = {
+            caseName: string;
+            user: User;
+            expect: string;
+        };
+        const testCases: UserCase[] = [
+            {
+                caseName: 'Full data available',
+                user: {
+                    email: 'someuser at 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: 'Missing first name',
+                user: {
+                    email: 'someuser at example.com', username: 'someuser',
+                    firstName: '', lastName: 'User',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'someuser at example.com'
+            },
+            {
+                caseName: 'Missing last name',
+                user: {
+                    email: 'someuser at example.com', username: 'someuser',
+                    firstName: 'Some', lastName: '',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'someuser at example.com'
+            },
+            {
+                caseName: 'Missing first & last names',
+                user: {
+                    email: 'someuser at example.com', username: 'someuser',
+                    firstName: '', lastName: '',
+                    uuid: 'zzzzz-tpzed-someusersuuid',
+                    ownerUuid: 'zzzzz-tpzed-someusersowneruuid',
+                    prefs: {}, isAdmin: false, isActive: true
+                },
+                expect: 'someuser at 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 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);
+            expect(dispName).toEqual(c.expect);
+        })
+    });
+});
diff --git a/src/models/user.ts b/src/models/user.ts
index 87a97dfc..1e9a0260 100644
--- a/src/models/user.ts
+++ b/src/models/user.ts
@@ -26,8 +26,14 @@ 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) => {
+    return getUserFullname(user) || user.email || user.username || user.uuid;
 };
 
 export interface UserResource extends Resource, User {
diff --git a/src/models/vocabulary.test.ts b/src/models/vocabulary.test.ts
index 87a8dfb2..18e2f19f 100644
--- a/src/models/vocabulary.test.ts
+++ b/src/models/vocabulary.test.ts
@@ -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;
diff --git a/src/services/auth-service/auth-service.ts b/src/services/auth-service/auth-service.ts
index 690420e7..61db625c 100644
--- a/src/services/auth-service/auth-service.ts
+++ b/src/services/auth-service/auth-service.ts
@@ -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,
diff --git a/src/store/auth/auth-action-session.ts b/src/store/auth/auth-action-session.ts
index 52a1e23a..fc35ff88 100644
--- a/src/store/auth/auth-action-session.ts
+++ b/src/store/auth/auth-action-session.ts
@@ -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;
         };
@@ -242,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,
diff --git a/src/store/group-details-panel/group-details-panel-actions.ts b/src/store/group-details-panel/group-details-panel-actions.ts
index 55bfd5ae..41fb690f 100644
--- a/src/store/group-details-panel/group-details-panel-actions.ts
+++ b/src/store/group-details-panel/group-details-panel-actions.ts
@@ -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';
@@ -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,
diff --git a/src/views-components/main-app-bar/account-menu.tsx b/src/views-components/main-app-bar/account-menu.tsx
index 346a9ef0..37702536 100644
--- a/src/views-components/main-app-bar/account-menu.tsx
+++ b/src/views-components/main-app-bar/account-menu.tsx
@@ -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>

commit 88c2a5f798505cf19a79a8a400486ec63cdcc6f2
Author: Lucas Di Pentima <lucas at di-pentima.com.ar>
Date:   Tue Apr 7 18:52:10 2020 -0300

    16212: Assembles v2 token for authentication.
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas at di-pentima.com.ar>

diff --git a/src/views-components/login-form/login-form.tsx b/src/views-components/login-form/login-form.tsx
index 160a1bb8..b64ae0b0 100644
--- a/src/views-components/login-form/login-form.tsx
+++ b/src/views-components/login-form/login-form.tsx
@@ -85,8 +85,8 @@ export const LoginForm = withStyles(styles)(
             handleSubmit(username, password)
             .then((response) => {
                 setSubmitting(false);
-                const apiToken = response.data.api_token;
-                if (apiToken) {
+                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 {

commit 383d46d2b62a75fa4f7038dc6babf7d0b92656ca
Author: Lucas Di Pentima <lucas at di-pentima.com.ar>
Date:   Wed Apr 1 13:10:55 2020 -0300

    16212: Updates form POST to use new 'users/authenticate' endpoint.
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas at di-pentima.com.ar>

diff --git a/src/views-components/login-form/login-form.tsx b/src/views-components/login-form/login-form.tsx
index 8ec856f9..160a1bb8 100644
--- a/src/views-components/login-form/login-form.tsx
+++ b/src/views-components/login-form/login-form.tsx
@@ -85,7 +85,7 @@ export const LoginForm = withStyles(styles)(
             handleSubmit(username, password)
             .then((response) => {
                 setSubmitting(false);
-                const apiToken = response.data.token;
+                const apiToken = response.data.api_token;
                 if (apiToken) {
                     dispatch<any>(saveApiToken(apiToken)).finally(
                         () => dispatch(navigateToRootProject));
diff --git a/src/views/login-panel/login-panel.tsx b/src/views/login-panel/login-panel.tsx
index ce8adb8f..25fee7eb 100644
--- a/src/views/login-panel/login-panel.tsx
+++ b/src/views/login-panel/login-panel.tsx
@@ -50,11 +50,13 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 });
 
 const doPAMLogin = (url: string) => (username: string, password: string) => {
-    const formData = new FormData();
-    formData.append("username", username);
-    formData.append("password", password);
-    return Axios.post(`${url}/login`, formData, {
-        headers: { 'X-Http-Method-Override': 'GET' },
+    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'
+        },
     });
 };
 

commit 932b12b6ecd82cc429f9d6d9b20423cb43abbae7
Author: Lucas Di Pentima <lucas at di-pentima.com.ar>
Date:   Tue Mar 31 15:59:40 2020 -0300

    16212: Support LoginCluster feature.
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas at di-pentima.com.ar>

diff --git a/src/views/login-panel/login-panel.tsx b/src/views/login-panel/login-panel.tsx
index 57e7a0dc..ce8adb8f 100644
--- a/src/views/login-panel/login-panel.tsx
+++ b/src/views/login-panel/login-panel.tsx
@@ -74,8 +74,8 @@ export const LoginPanel = withStyles(styles)(
         localCluster: state.auth.localCluster,
         loginCluster: state.auth.loginCluster,
         welcomePage: state.auth.config.clusterConfig.Workbench.WelcomePageHTML,
-        pamLogin: state.auth.remoteHostsConfig[state.auth.homeCluster] &&
-            state.auth.remoteHostsConfig[state.auth.homeCluster].clusterConfig.Login.PAM || false,
+        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 : ''}`;
 
@@ -100,7 +100,7 @@ export const LoginPanel = withStyles(styles)(
                 ? <Typography component="div">
                     <LoginForm dispatch={dispatch}
                         loginLabel={loginBtnLabel}
-                        handleSubmit={doPAMLogin(`https://${remoteHosts[homeCluster]}`)}/>
+                        handleSubmit={doPAMLogin(`https://${remoteHosts[loginCluster || homeCluster]}`)}/>
                 </Typography>
                 : <Typography component="div" align="right">
                     <Button variant="contained" color="primary" style={{ margin: "1em" }}

commit fc73adaad544e07a970f720fd1fec3d30821d4b1
Author: Lucas Di Pentima <lucas at di-pentima.com.ar>
Date:   Tue Mar 31 15:35:29 2020 -0300

    16212: Loads remote clusters configs at app boot.
    
    When using a federation without LoginCluster, the user is given the option
    to log in using any cluster as the 'home cluster'. If any of those remote
    clusters has Login.PAM enabled, the login form is displayed.
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas at di-pentima.com.ar>

diff --git a/src/index.tsx b/src/index.tsx
index bf810fb7..d428b1c3 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -166,5 +166,3 @@ const initListener = (history: History, store: RootStore, services: ServiceRepos
         }
     };
 };
-
-// force build comment #1
diff --git a/src/store/auth/auth-action-session.ts b/src/store/auth/auth-action-session.ts
index a6387828..52a1e23a 100644
--- a/src/store/auth/auth-action-session.ts
+++ b/src/store/auth/auth-action-session.ts
@@ -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;
diff --git a/src/store/auth/auth-action.ts b/src/store/auth/auth-action.ts
index 923d3c9e..1060ec70 100644
--- a/src/store/auth/auth-action.ts
+++ b/src/store/auth/auth-action.ts
@@ -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") {
diff --git a/src/views-components/login-form/login-form.tsx b/src/views-components/login-form/login-form.tsx
index 2d4451c6..8ec856f9 100644
--- a/src/views-components/login-form/login-form.tsx
+++ b/src/views-components/login-form/login-form.tsx
@@ -46,10 +46,11 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
 
 type LoginFormProps = DispatchProp<any> & WithStyles<CssRules> & {
     handleSubmit: (username: string, password: string) => AxiosPromise;
+    loginLabel?: string,
 };
 
 export const LoginForm = withStyles(styles)(
-    ({ handleSubmit, dispatch, classes }: LoginFormProps) => {
+    ({ handleSubmit, loginLabel, dispatch, classes }: LoginFormProps) => {
         const userInput = useRef<HTMLInputElement>(null);
         const [username, setUsername] = useState('');
         const [password, setPassword] = useState('');
@@ -137,7 +138,7 @@ export const LoginForm = withStyles(styles)(
                         <Button variant="contained" size="large" color="primary"
                             className={classes.loginBtn} onClick={() => handleLogin()}
                             disabled={isSubmitting || isButtonDisabled}>
-                            Log in
+                            {loginLabel || 'Log in'}
                         </Button>
                     </CardActions>
                     { isSubmitting && <CircularProgress color='secondary' className={classes.progress} />}
diff --git a/src/views/login-panel/login-panel.tsx b/src/views/login-panel/login-panel.tsx
index 203a2f19..57e7a0dc 100644
--- a/src/views/login-panel/login-panel.tsx
+++ b/src/views/login-panel/login-panel.tsx
@@ -61,7 +61,7 @@ const doPAMLogin = (url: string) => (username: string, password: string) => {
 type LoginPanelProps = DispatchProp<any> & WithStyles<CssRules> & {
     remoteHosts: { [key: string]: string },
     homeCluster: string,
-    uuidPrefix: string,
+    localCluster: string,
     loginCluster: string,
     welcomePage: string,
     pamLogin: boolean,
@@ -71,12 +71,15 @@ 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,
-        pamLogin: state.auth.config.clusterConfig.Login.PAM,
-    }))(({ classes, dispatch, remoteHosts, homeCluster, uuidPrefix, loginCluster, welcomePage, pamLogin }: LoginPanelProps) =>
-        <Grid container justify="center" alignItems="center"
+        pamLogin: state.auth.remoteHostsConfig[state.auth.homeCluster] &&
+            state.auth.remoteHostsConfig[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}>
@@ -95,17 +98,17 @@ export const LoginPanel = withStyles(styles)(
 
                 {pamLogin
                 ? <Typography component="div">
-                    <LoginForm dispatch={dispatch} handleSubmit={
-                        doPAMLogin(`https://${remoteHosts[homeCluster]}`)}/>
+                    <LoginForm dispatch={dispatch}
+                        loginLabel={loginBtnLabel}
+                        handleSubmit={doPAMLogin(`https://${remoteHosts[homeCluster]}`)}/>
                 </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> to {uuidPrefix} with user from {homeCluster}</span>}
+                        onClick={() => dispatch(login(localCluster, homeCluster, loginCluster, remoteHosts))}>
+                        {loginBtnLabel}
                     </Button>
                 </Typography>}
             </Grid>
-        </Grid >
+        </Grid >);}
     ));

commit 70346cc28458475026a54c0d4ac1df2264080d55
Author: Lucas Di Pentima <lucas at di-pentima.com.ar>
Date:   Mon Mar 30 13:29:48 2020 -0300

    16212: Sets api token on login success, re-focus on login error.
    
    Also, shows an error message if successful login response doesn't have a token.
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas at di-pentima.com.ar>

diff --git a/src/views-components/login-form/login-form.tsx b/src/views-components/login-form/login-form.tsx
index 80d59995..2d4451c6 100644
--- a/src/views-components/login-form/login-form.tsx
+++ b/src/views-components/login-form/login-form.tsx
@@ -9,6 +9,9 @@ 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';
 
@@ -41,12 +44,12 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
     },
 });
 
-interface LoginFormProps {
+type LoginFormProps = DispatchProp<any> & WithStyles<CssRules> & {
     handleSubmit: (username: string, password: string) => AxiosPromise;
-}
+};
 
 export const LoginForm = withStyles(styles)(
-    ({ handleSubmit, classes }: LoginFormProps & WithStyles<CssRules>) => {
+    ({ handleSubmit, dispatch, classes }: LoginFormProps) => {
         const userInput = useRef<HTMLInputElement>(null);
         const [username, setUsername] = useState('');
         const [password, setPassword] = useState('');
@@ -65,24 +68,37 @@ export const LoginForm = withStyles(styles)(
             }
         }, [username, password]);
 
-        // This only run once after render.
+        // This only runs once after render.
         useEffect(() => {
-            userInput.current!.focus();
+            setFocus();
         }, []);
 
+        const setFocus = () => {
+            userInput.current!.focus();
+        };
+
         const handleLogin = () => {
+            setError(false);
+            setHelperText('');
             setSubmitting(true);
             handleSubmit(username, password)
             .then((response) => {
-                setError(false);
-                console.log("LOGIN SUCESSFUL: ", response);
                 setSubmitting(false);
+                const apiToken = response.data.token;
+                if (apiToken) {
+                    dispatch<any>(saveApiToken(apiToken)).finally(
+                        () => dispatch(navigateToRootProject));
+                } else {
+                    setError(true);
+                    setHelperText(response.data.message || 'Please try again');
+                    setFocus();
+                }
             })
             .catch((err) => {
                 setError(true);
-                console.log("ERROR: ", err.response);
-                setHelperText(`${err.response && err.response.data && err.response.data.errors[0] || 'Error logging in: '+err}`);
                 setSubmitting(false);
+                setHelperText(`${err.response && err.response.data && err.response.data.errors[0] || 'Error logging in: '+err}`);
+                setFocus();
             });
         };
 
@@ -96,40 +112,38 @@ export const LoginForm = withStyles(styles)(
 
         return (
             <React.Fragment>
-                <form className={classes.root} noValidate autoComplete="off">
-                    <Card className={classes.card}>
+            <form className={classes.root} noValidate autoComplete="off">
+                <Card className={classes.card}>
                     <div className={classes.wrapper}>
-                        <CardContent>
-                            <div>
-                                <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)}
-                                />
-                            </div>
-                        </CardContent>
-                        <CardActions>
-                            <Button variant="contained" size="large" color="primary"
-                                className={classes.loginBtn} onClick={() => handleLogin()}
-                                disabled={isSubmitting || isButtonDisabled}>
-                                Log in
-                            </Button>
-                        </CardActions>
-                        { isSubmitting && <CircularProgress color='secondary' className={classes.progress} />}
+                    <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}>
+                            Log in
+                        </Button>
+                    </CardActions>
+                    { isSubmitting && <CircularProgress color='secondary' className={classes.progress} />}
                     </div>
-                    </Card>
-                </form>
+                </Card>
+            </form>
             </React.Fragment>
         );
     });
diff --git a/src/views/login-panel/login-panel.tsx b/src/views/login-panel/login-panel.tsx
index e7eadbad..203a2f19 100644
--- a/src/views/login-panel/login-panel.tsx
+++ b/src/views/login-panel/login-panel.tsx
@@ -95,7 +95,7 @@ export const LoginPanel = withStyles(styles)(
 
                 {pamLogin
                 ? <Typography component="div">
-                    <LoginForm handleSubmit={
+                    <LoginForm dispatch={dispatch} handleSubmit={
                         doPAMLogin(`https://${remoteHosts[homeCluster]}`)}/>
                 </Typography>
                 : <Typography component="div" align="right">

commit 3072cd4bffd6c761c7c0ad760fc85efc7bc9ff6f
Author: Lucas Di Pentima <lucas at di-pentima.com.ar>
Date:   Mon Mar 30 11:43:43 2020 -0300

    16212: Set focus on username input element.
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas at di-pentima.com.ar>

diff --git a/src/views-components/login-form/login-form.tsx b/src/views-components/login-form/login-form.tsx
index 404c91ff..80d59995 100644
--- a/src/views-components/login-form/login-form.tsx
+++ b/src/views-components/login-form/login-form.tsx
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { useState, useEffect } 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';
@@ -47,6 +47,7 @@ interface LoginFormProps {
 
 export const LoginForm = withStyles(styles)(
     ({ handleSubmit, classes }: LoginFormProps & WithStyles<CssRules>) => {
+        const userInput = useRef<HTMLInputElement>(null);
         const [username, setUsername] = useState('');
         const [password, setPassword] = useState('');
         const [isButtonDisabled, setIsButtonDisabled] = useState(true);
@@ -64,6 +65,11 @@ export const LoginForm = withStyles(styles)(
             }
         }, [username, password]);
 
+        // This only run once after render.
+        useEffect(() => {
+            userInput.current!.focus();
+        }, []);
+
         const handleLogin = () => {
             setSubmitting(true);
             handleSubmit(username, password)
@@ -96,6 +102,7 @@ export const LoginForm = withStyles(styles)(
                         <CardContent>
                             <div>
                                 <TextField
+                                    inputRef={userInput}
                                     disabled={isSubmitting}
                                     error={error} fullWidth id="username" type="email"
                                     label="Username" margin="normal"

commit 340b84a6471ee5cfca114b1b348d53135b8a51f6
Author: Lucas Di Pentima <lucas at di-pentima.com.ar>
Date:   Sun Mar 29 11:50:01 2020 -0300

    16212: Adds login form when PAM Login is enabled. (WIP)
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas at di-pentima.com.ar>

diff --git a/src/views-components/login-form/login-form.tsx b/src/views-components/login-form/login-form.tsx
new file mode 100644
index 00000000..404c91ff
--- /dev/null
+++ b/src/views-components/login-form/login-form.tsx
@@ -0,0 +1,128 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { useState, useEffect } 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';
+
+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,
+    },
+});
+
+interface LoginFormProps {
+    handleSubmit: (username: string, password: string) => AxiosPromise;
+}
+
+export const LoginForm = withStyles(styles)(
+    ({ handleSubmit, classes }: LoginFormProps & WithStyles<CssRules>) => {
+        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]);
+
+        const handleLogin = () => {
+            setSubmitting(true);
+            handleSubmit(username, password)
+            .then((response) => {
+                setError(false);
+                console.log("LOGIN SUCESSFUL: ", response);
+                setSubmitting(false);
+            })
+            .catch((err) => {
+                setError(true);
+                console.log("ERROR: ", err.response);
+                setHelperText(`${err.response && err.response.data && err.response.data.errors[0] || 'Error logging in: '+err}`);
+                setSubmitting(false);
+            });
+        };
+
+        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>
+                            <div>
+                                <TextField
+                                    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)}
+                                />
+                            </div>
+                        </CardContent>
+                        <CardActions>
+                            <Button variant="contained" size="large" color="primary"
+                                className={classes.loginBtn} onClick={() => handleLogin()}
+                                disabled={isSubmitting || isButtonDisabled}>
+                                Log in
+                            </Button>
+                        </CardActions>
+                        { isSubmitting && <CircularProgress color='secondary' className={classes.progress} />}
+                    </div>
+                    </Card>
+                </form>
+            </React.Fragment>
+        );
+    });
diff --git a/src/views/login-panel/login-panel.tsx b/src/views/login-panel/login-panel.tsx
index 45f796fd..e7eadbad 100644
--- a/src/views/login-panel/login-panel.tsx
+++ b/src/views/login-panel/login-panel.tsx
@@ -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,12 +49,22 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     }
 });
 
+const doPAMLogin = (url: string) => (username: string, password: string) => {
+    const formData = new FormData();
+    formData.append("username", username);
+    formData.append("password", password);
+    return Axios.post(`${url}/login`, formData, {
+        headers: { 'X-Http-Method-Override': 'GET' },
+    });
+};
+
 type LoginPanelProps = DispatchProp<any> & WithStyles<CssRules> & {
     remoteHosts: { [key: string]: string },
     homeCluster: string,
     uuidPrefix: string,
     loginCluster: string,
-    welcomePage: string
+    welcomePage: string,
+    pamLogin: boolean,
 };
 
 export const LoginPanel = withStyles(styles)(
@@ -61,8 +73,9 @@ export const LoginPanel = withStyles(styles)(
         homeCluster: state.auth.homeCluster,
         uuidPrefix: state.auth.localCluster,
         loginCluster: state.auth.loginCluster,
-        welcomePage: state.auth.config.clusterConfig.Workbench.WelcomePageHTML
-    }))(({ classes, dispatch, remoteHosts, homeCluster, uuidPrefix, loginCluster, welcomePage }: LoginPanelProps) =>
+        welcomePage: state.auth.config.clusterConfig.Workbench.WelcomePageHTML,
+        pamLogin: state.auth.config.clusterConfig.Login.PAM,
+    }))(({ classes, dispatch, remoteHosts, homeCluster, uuidPrefix, loginCluster, welcomePage, pamLogin }: LoginPanelProps) =>
         <Grid container justify="center" alignItems="center"
             className={classes.root}
             style={{ marginTop: 56, overflowY: "auto", height: "100%" }}>
@@ -80,14 +93,19 @@ export const LoginPanel = withStyles(styles)(
                         </Select>
                     </Typography>}
 
-                <Typography component="div" align="right">
-                    <Button variant="contained" color="primary" style={{ margin: "1em" }} className={classes.button}
+                {pamLogin
+                ? <Typography component="div">
+                    <LoginForm handleSubmit={
+                        doPAMLogin(`https://${remoteHosts[homeCluster]}`)}/>
+                </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 &&
+                        Log in {uuidPrefix !== homeCluster && loginCluster !== homeCluster &&
                             <span> to {uuidPrefix} with user from {homeCluster}</span>}
                     </Button>
-                </Typography>
+                </Typography>}
             </Grid>
         </Grid >
     ));

commit 77a1e393189bcb4d56643f253460a93d231b7c92
Author: Lucas Di Pentima <lucas at di-pentima.com.ar>
Date:   Fri Mar 27 19:11:17 2020 -0300

    16212: Upgrades react & react-dom to support hooks.
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas at di-pentima.com.ar>

diff --git a/package.json b/package.json
index db7c410d..16ad657b 100644
--- a/package.json
+++ b/package.json
@@ -35,11 +35,11 @@
     "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",
diff --git a/yarn.lock b/yarn.lock
index e1a62b88..da2629f5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8862,15 +8862,15 @@ react-dnd at 5.0.0:
     recompose "^0.27.1"
     shallowequal "^1.0.2"
 
-react-dom at 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 at 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 at 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 at 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 at 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:

commit 8fdba020a6f5373f9f359423b682197e8a51c8ee
Author: Lucas Di Pentima <lucas at di-pentima.com.ar>
Date:   Fri Mar 27 18:50:04 2020 -0300

    16212: Loads PAM login feature from exported config.
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas at di-pentima.com.ar>

diff --git a/src/common/config.ts b/src/common/config.ts
index 0a13f4e1..f9fb9f6a 100644
--- a/src/common/config.ts
+++ b/src/common/config.ts
@@ -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: "",

-----------------------------------------------------------------------


hooks/post-receive
-- 




More information about the arvados-commits mailing list