let activeUser;
let inactiveUser;
let adminUser;
+ let randomUser = {};
before(function() {
// Only set up common users once. These aren't set up as aliases because
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() {
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(
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]')
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
}
)
-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};
+ })
})
})
- })
-})
+ }
+)
"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",
};
Login: {
LoginCluster: string;
+ PAM: boolean;
};
Collections: {
ForwardSlashNameSubstitution: string;
},
Login: {
LoginCluster: "",
+ PAM: false,
},
Collections: {
ForwardSlashNameSubstitution: "",
}
};
};
-
-// force build comment #1
--- /dev/null
+// 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);
+ })
+ });
+});
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 {
// SPDX-License-Identifier: AGPL-3.0
import * as Vocabulary from './vocabulary';
-import { pipe } from 'lodash/fp';
describe('Vocabulary', () => {
let vocabulary: Vocabulary.Vocabulary;
//
// 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";
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,
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,
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;
};
}
};
+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;
status: SessionStatus.VALIDATED,
active: false,
email: user.email,
- name: getUserFullname(user),
+ name: getUserDisplayName(user),
uuid: user.uuid,
baseUrl: config.baseUrl,
clusterId: config.uuidPrefix,
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: {},
// 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") {
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) {
.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 }));
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';
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';
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 = () =>
await deleteGroupMember({
user: {
uuid,
- name: user ? getUserFullname(user) : uuid,
+ name: user ? getUserDisplayName(user) : uuid,
},
group: {
uuid: groupUuid,
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';
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) =>
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';
<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>
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';
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 '
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),
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() || []}
--- /dev/null
+// 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>
+ );
+ });
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';
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>
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: []
};
);
}
- 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);
}
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,
});
}
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)
+ });
}
});
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}>
component={InvitedPeopleFieldComponent} />;
-const InvitedPeopleFieldComponent = ({ fields }: WrappedFieldArrayProps<Person>) =>
- <PeopleSelect
+const InvitedPeopleFieldComponent = ({ fields }: WrappedFieldArrayProps<Participant>) =>
+ <ParticipantSelect
items={fields.getAll() || []}
onSelect={fields.push}
onDelete={fields.remove} />;
// 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';
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>}
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';
}
});
+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}>
</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> 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 >);}
));
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"
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"
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: