// SPDX-License-Identifier: AGPL-3.0
import * as React from 'react';
-import { Input as MuiInput, Chip as MuiChip, Popper as MuiPopper, Paper, FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List, FormHelperText } from '@material-ui/core';
+import { Input as MuiInput, Chip as MuiChip, Popper as MuiPopper, Paper as MuiPaper, FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List, FormHelperText } from '@material-ui/core';
import { PopperProps } from '@material-ui/core/Popper';
import { WithStyles } from '@material-ui/core/styles';
import { noop } from 'lodash';
export interface AutocompleteState {
suggestionsOpen: boolean;
+ selectedSuggestionIndex: number;
}
export class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion>, AutocompleteState> {
state = {
suggestionsOpen: false,
+ selectedSuggestionIndex: 0,
};
containerRef = React.createRef<HTMLDivElement>();
onBlur={this.handleBlur}
onChange={this.props.onChange}
onKeyPress={this.handleKeyPress}
+ onKeyDown={this.handleNavigationKeyPress}
/>;
}
- renderHelperText(){
+ renderHelperText() {
return <FormHelperText>{this.props.helperText}</FormHelperText>;
}
const { suggestions = [] } = this.props;
return (
<Popper
- open={this.state.suggestionsOpen && suggestions.length > 0}
- anchorEl={this.inputRef.current}>
+ open={this.isSuggestionBoxOpen()}
+ anchorEl={this.inputRef.current}
+ key={suggestions.length}>
<Paper onMouseDown={this.preventBlur}>
<List dense style={{ width: this.getSuggestionsWidth() }}>
{suggestions.map(
(suggestion, index) =>
- <ListItem button key={index} onClick={this.handleSelect(suggestion)}>
+ <ListItem
+ button
+ key={index}
+ onClick={this.handleSelect(suggestion)}
+ selected={index === this.state.selectedSuggestionIndex}>
{this.renderSuggestion(suggestion)}
</ListItem>
)}
);
}
+ isSuggestionBoxOpen() {
+ const { suggestions = [] } = this.props;
+ return this.state.suggestionsOpen && suggestions.length > 0;
+ }
+
handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
const { onFocus = noop } = this.props;
this.setState({ suggestionsOpen: true });
});
}
- handleKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
- const { onCreate = noop } = this.props;
- if (key === 'Enter' && this.props.value.length > 0) {
- onCreate();
+ handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
+ const { onCreate = noop, onSelect = noop, suggestions = [] } = this.props;
+ const { selectedSuggestionIndex } = this.state;
+ if (event.key === 'Enter') {
+ if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) {
+ // prevent form submissions when selecting a suggestion
+ event.preventDefault();
+ onSelect(suggestions[selectedSuggestionIndex]);
+ } else if (this.props.value.length > 0) {
+ onCreate();
+ }
+ }
+ }
+
+ handleNavigationKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
+ if (key === 'ArrowUp') {
+ this.updateSelectedSuggestionIndex(-1);
+ } else if (key === 'ArrowDown') {
+ this.updateSelectedSuggestionIndex(1);
}
}
+ updateSelectedSuggestionIndex(value: -1 | 1) {
+ const { suggestions = [] } = this.props;
+ this.setState(({ selectedSuggestionIndex }) => ({
+ selectedSuggestionIndex: (selectedSuggestionIndex + value) % suggestions.length
+ }));
+ }
+
renderChips() {
const { items, onDelete } = this.props;
return items.map(
});
const Input = withStyles(inputStyles)(MuiInput);
+
+const Paper = withStyles({
+ root: {
+ maxHeight: '80vh',
+ overflowY: 'auto',
+ }
+})(MuiPaper);
type TextFieldProps = WrappedFieldProps & WithStyles<CssRules>;
export const TextField = withStyles(styles)((props: TextFieldProps & {
- label?: string, autoFocus?: boolean, required?: boolean, select?: boolean, children: React.ReactNode
+ label?: string, autoFocus?: boolean, required?: boolean, select?: boolean, disabled?: boolean, children: React.ReactNode
}) =>
<MaterialTextField
helperText={props.meta.touched && props.meta.error}
className={props.classes.textField}
label={props.label}
- disabled={props.meta.submitting}
+ disabled={props.disabled || props.meta.submitting}
error={props.meta.touched && !!props.meta.error}
autoComplete='off'
autoFocus={props.autoFocus}
import { Resource, ResourceKind } from '~/models/resource';
+export type UserPrefs = {
+ profile?: {
+ organization?: string,
+ organization_email?: string,
+ lab?: string,
+ website_url?: string,
+ role?: string
+ }
+};
+
export interface User {
email: string;
firstName: string;
lastName: string;
uuid: string;
ownerUuid: string;
+ identityUrl: string;
+ prefs: UserPrefs;
isAdmin: boolean;
}
const keepServicesMatch = Routes.matchKeepServicesRoute(pathname);
const computeNodesMatch = Routes.matchComputeNodesRoute(pathname);
const apiClientAuthorizationsMatch = Routes.matchApiClientAuthorizationsRoute(pathname);
+ const myAccountMatch = Routes.matchMyAccountRoute(pathname);
if (projectMatch) {
store.dispatch(WorkbenchActions.loadProject(projectMatch.params.id));
store.dispatch(WorkbenchActions.loadComputeNodes);
} else if (apiClientAuthorizationsMatch) {
store.dispatch(WorkbenchActions.loadApiClientAuthorizations);
+ } else if (myAccountMatch) {
+ store.dispatch(WorkbenchActions.loadMyAccount);
}
};
WORKFLOWS: '/workflows',
SEARCH_RESULTS: '/search-results',
SSH_KEYS: `/ssh-keys`,
+ MY_ACCOUNT: '/my-account',
KEEP_SERVICES: `/keep-services`,
COMPUTE_NODES: `/nodes`,
API_CLIENT_AUTHORIZATIONS: `/api_client_authorizations`
export const matchSshKeysRoute = (route: string) =>
matchPath(route, { path: Routes.SSH_KEYS });
+export const matchMyAccountRoute = (route: string) =>
+ matchPath(route, { path: Routes.MY_ACCOUNT });
+
export const matchKeepServicesRoute = (route: string) =>
matchPath(route, { path: Routes.KEEP_SERVICES });
//
// SPDX-License-Identifier: AGPL-3.0
-import { User } from "~/models/user";
+import { User, UserPrefs } from "~/models/user";
import { AxiosInstance } from "axios";
import { ApiActions } from "~/services/api/api-actions";
import * as uuid from "uuid/v4";
export const USER_UUID_KEY = 'userUuid';
export const USER_OWNER_UUID_KEY = 'userOwnerUuid';
export const USER_IS_ADMIN = 'isAdmin';
+export const USER_IDENTITY_URL = 'identityUrl';
+export const USER_PREFS = 'prefs';
export interface UserDetailsResponse {
email: string;
uuid: string;
owner_uuid: string;
is_admin: boolean;
+ identity_url: string;
+ prefs: UserPrefs;
}
export class AuthService {
const lastName = localStorage.getItem(USER_LAST_NAME_KEY);
const uuid = this.getUuid();
const ownerUuid = this.getOwnerUuid();
- const isAdmin = this.getIsAdmin();
+ const isAdmin = this.getIsAdmin();
+ const identityUrl = localStorage.getItem(USER_IDENTITY_URL);
+ const prefs = JSON.parse(localStorage.getItem(USER_PREFS) || '{"profile": {}}');
- return email && firstName && lastName && uuid && ownerUuid
- ? { email, firstName, lastName, uuid, ownerUuid, isAdmin }
+ return email && firstName && lastName && uuid && ownerUuid && identityUrl && prefs
+ ? { email, firstName, lastName, uuid, ownerUuid, isAdmin, identityUrl, prefs }
: undefined;
}
localStorage.setItem(USER_UUID_KEY, user.uuid);
localStorage.setItem(USER_OWNER_UUID_KEY, user.ownerUuid);
localStorage.setItem(USER_IS_ADMIN, JSON.stringify(user.isAdmin));
+ localStorage.setItem(USER_IDENTITY_URL, user.identityUrl);
+ localStorage.setItem(USER_PREFS, JSON.stringify(user.prefs));
}
public removeUser() {
localStorage.removeItem(USER_UUID_KEY);
localStorage.removeItem(USER_OWNER_UUID_KEY);
localStorage.removeItem(USER_IS_ADMIN);
+ localStorage.removeItem(USER_IDENTITY_URL);
+ localStorage.removeItem(USER_PREFS);
}
public login() {
.get<UserDetailsResponse>('/users/current')
.then(resp => {
this.actions.progressFn(reqId, false);
+ const prefs = resp.data.prefs.profile ? resp.data.prefs : { profile: {}};
return {
email: resp.data.email,
firstName: resp.data.first_name,
lastName: resp.data.last_name,
uuid: resp.data.uuid,
ownerUuid: resp.data.owner_uuid,
- isAdmin: resp.data.is_admin
+ isAdmin: resp.data.is_admin,
+ identityUrl: resp.data.identity_url,
+ prefs
};
})
.catch(e => {
}
};
-
export type AuthAction = UnionOf<typeof authActions>;
lastName: "Doe",
uuid: "uuid",
ownerUuid: "ownerUuid",
+ identityUrl: "identityUrl",
+ prefs: {},
isAdmin: false
};
const state = reducer(initialState, authActions.INIT({ user, token: "token" }));
lastName: "Doe",
uuid: "uuid",
ownerUuid: "ownerUuid",
+ identityUrl: "identityUrl",
+ prefs: {},
isAdmin: false
};
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { RootState } from "~/store/store";
+import { initialize } from "redux-form";
+import { ServiceRepository } from "~/services/services";
+import { setBreadcrumbs } from "~/store/breadcrumbs/breadcrumbs-actions";
+import { authActions } from "~/store/auth/auth-action";
+import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions";
+
+export const MY_ACCOUNT_FORM = 'myAccountForm';
+
+export const loadMyAccountPanel = () =>
+ (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+ dispatch(setBreadcrumbs([{ label: 'User profile'}]));
+ };
+
+export const saveEditedUser = (resource: any) =>
+ async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+ try {
+ await services.userService.update(resource.uuid, resource);
+ services.authService.saveUser(resource);
+ dispatch(authActions.USER_DETAILS_SUCCESS(resource));
+ dispatch(initialize(MY_ACCOUNT_FORM, resource));
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Profile has been updated.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+ } catch(e) {
+ return;
+ }
+ };
export const navigateToSshKeys= push(Routes.SSH_KEYS);
+export const navigateToMyAccount = push(Routes.MY_ACCOUNT);
+
export const navigateToKeepServices = push(Routes.KEEP_SERVICES);
export const navigateToComputeNodes = push(Routes.COMPUTE_NODES);
import { CopyFormDialogData } from '~/store/copy-dialog/copy-dialog';
import { loadWorkflowPanel, workflowPanelActions } from '~/store/workflow-panel/workflow-panel-actions';
import { loadSshKeysPanel } from '~/store/auth/auth-action';
+import { loadMyAccountPanel } from '~/store/my-account/my-account-panel-actions';
import { workflowPanelColumns } from '~/views/workflow-panel/workflow-panel-view';
import { progressIndicatorActions } from '~/store/progress-indicator/progress-indicator-actions';
import { getProgressIndicator } from '~/store/progress-indicator/progress-indicator-reducer';
await dispatch(loadSshKeysPanel());
});
+export const loadMyAccount = handleFirstTimeLoad(
+ (dispatch: Dispatch<any>) => {
+ dispatch(loadMyAccountPanel());
+ });
+
export const loadKeepServices = handleFirstTimeLoad(
async (dispatch: Dispatch<any>) => {
await dispatch(loadKeepServicesPanel());
export const SSH_KEY_PUBLIC_VALIDATION = [require, isRsaKey, maxLength(1024)];
export const SSH_KEY_NAME_VALIDATION = [require, maxLength(255)];
+
+export const MY_ACCOUNT_VALIDATION = [require];
import { openRepositoriesPanel } from "~/store/repositories/repositories-actions";
import {
navigateToSshKeys, navigateToKeepServices, navigateToComputeNodes,
- navigateToApiClientAuthorizations
+ navigateToApiClientAuthorizations, navigateToMyAccount
} from '~/store/navigation/navigation-action';
import { openVirtualMachines } from "~/store/virtual-machines/virtual-machines-actions";
{ user.isAdmin && <MenuItem onClick={() => dispatch(navigateToApiClientAuthorizations)}>Api Tokens</MenuItem> }
{ user.isAdmin && <MenuItem onClick={() => dispatch(navigateToKeepServices)}>Keep Services</MenuItem> }
{ user.isAdmin && <MenuItem onClick={() => dispatch(navigateToComputeNodes)}>Compute Nodes</MenuItem> }
- <MenuItem>My account</MenuItem>
+ <MenuItem onClick={() => dispatch(navigateToMyAccount)}>My account</MenuItem>
<MenuItem onClick={() => dispatch(logout())}>Logout</MenuItem>
</DropdownMenu>
: null);
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Field, InjectedFormProps } from "redux-form";
+import { TextField } from "~/components/text-field/text-field";
+import { NativeSelectField } from "~/components/select-field/select-field";
+import {
+ StyleRulesCallback,
+ WithStyles,
+ withStyles,
+ Card,
+ CardContent,
+ Button,
+ Typography,
+ Grid,
+ InputLabel
+} from '@material-ui/core';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { User } from "~/models/user";
+import { MY_ACCOUNT_VALIDATION} from "~/validators/validators";
+
+type CssRules = 'root' | 'gridItem' | 'label' | 'title' | 'actions';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+ root: {
+ width: '100%',
+ overflow: 'auto'
+ },
+ gridItem: {
+ height: 45,
+ marginBottom: 20
+ },
+ label: {
+ fontSize: '0.675rem'
+ },
+ title: {
+ marginBottom: theme.spacing.unit * 3,
+ color: theme.palette.grey["600"]
+ },
+ actions: {
+ display: 'flex',
+ justifyContent: 'flex-end'
+ }
+});
+
+export interface MyAccountPanelRootActionProps {}
+
+export interface MyAccountPanelRootDataProps {
+ isPristine: boolean;
+ isValid: boolean;
+ initialValues?: User;
+}
+
+const RoleTypes = [
+ {key: 'Bio-informatician', value: 'Bio-informatician'},
+ {key: 'Data Scientist', value: 'Data Scientist'},
+ {key: 'Analyst', value: 'Analyst'},
+ {key: 'Researcher', value: 'Researcher'},
+ {key: 'Software Developer', value: 'Software Developer'},
+ {key: 'System Administrator', value: 'System Administrator'},
+ {key: 'Other', value: 'Other'}
+];
+
+type MyAccountPanelRootProps = InjectedFormProps<MyAccountPanelRootActionProps> & MyAccountPanelRootDataProps & WithStyles<CssRules>;
+
+export const MyAccountPanelRoot = withStyles(styles)(
+ ({ classes, isValid, handleSubmit, reset, isPristine, invalid, submitting }: MyAccountPanelRootProps) => {
+ return <Card className={classes.root}>
+ <CardContent>
+ <Typography variant="title" className={classes.title}>User profile</Typography>
+ <form onSubmit={handleSubmit}>
+ <Grid container direction="row" spacing={24}>
+ <Grid item xs={6}>
+ <Grid item className={classes.gridItem}>
+ <Field
+ label="E-mail"
+ name="email"
+ component={TextField}
+ disabled
+ />
+ </Grid>
+ <Grid item className={classes.gridItem}>
+ <Field
+ label="First name"
+ name="firstName"
+ component={TextField}
+ disabled
+ />
+ </Grid>
+ <Grid item className={classes.gridItem}>
+ <Field
+ label="Identity URL"
+ name="identityUrl"
+ component={TextField}
+ disabled
+ />
+ </Grid>
+ <Grid item className={classes.gridItem}>
+ <Field
+ label="Organization"
+ name="prefs.profile.organization"
+ component={TextField}
+ validate={MY_ACCOUNT_VALIDATION}
+ required
+ />
+ </Grid>
+ <Grid item className={classes.gridItem}>
+ <Field
+ label="Website"
+ name="prefs.profile.website_url"
+ component={TextField}
+ />
+ </Grid>
+ <Grid item className={classes.gridItem}>
+ <InputLabel className={classes.label} htmlFor="prefs.profile.role">Organization</InputLabel>
+ <Field
+ id="prefs.profile.role"
+ name="prefs.profile.role"
+ component={NativeSelectField}
+ items={RoleTypes}
+ />
+ </Grid>
+ </Grid>
+ <Grid item xs={6}>
+ <Grid item className={classes.gridItem} />
+ <Grid item className={classes.gridItem}>
+ <Field
+ label="Last name"
+ name="lastName"
+ component={TextField}
+ disabled
+ />
+ </Grid>
+ <Grid item className={classes.gridItem} />
+ <Grid item className={classes.gridItem}>
+ <Field
+ label="E-mail at Organization"
+ name="prefs.profile.organization_email"
+ component={TextField}
+ validate={MY_ACCOUNT_VALIDATION}
+ required
+ />
+ </Grid>
+ </Grid>
+ <Grid item xs={12} className={classes.actions}>
+ <Button color="primary" onClick={reset} disabled={isPristine}>Discard changes</Button>
+ <Button
+ color="primary"
+ variant="contained"
+ type="submit"
+ disabled={isPristine || invalid || submitting}>
+ Save changes
+ </Button>
+ </Grid>
+ </Grid>
+ </form>
+ </CardContent>
+ </Card>;}
+);
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from '~/store/store';
+import { compose } from 'redux';
+import { reduxForm, isPristine, isValid } from 'redux-form';
+import { connect } from 'react-redux';
+import { saveEditedUser } from '~/store/my-account/my-account-panel-actions';
+import { MyAccountPanelRoot, MyAccountPanelRootDataProps } from '~/views/my-account-panel/my-account-panel-root';
+import { MY_ACCOUNT_FORM } from "~/store/my-account/my-account-panel-actions";
+
+const mapStateToProps = (state: RootState): MyAccountPanelRootDataProps => ({
+ isPristine: isPristine(MY_ACCOUNT_FORM)(state),
+ isValid: isValid(MY_ACCOUNT_FORM)(state),
+ initialValues: state.auth.user
+});
+
+export const MyAccountPanel = compose(
+ connect(mapStateToProps),
+ reduxForm({
+ form: MY_ACCOUNT_FORM,
+ onSubmit: (data, dispatch) => {
+ dispatch(saveEditedUser(data));
+ }
+}))(MyAccountPanelRoot);
\ No newline at end of file
import { WorkflowPanel } from '~/views/workflow-panel/workflow-panel';
import { SearchResultsPanel } from '~/views/search-results-panel/search-results-panel';
import { SshKeyPanel } from '~/views/ssh-key-panel/ssh-key-panel';
+import { MyAccountPanel } from '~/views/my-account-panel/my-account-panel';
import { SharingDialog } from '~/views-components/sharing-dialog/sharing-dialog';
import { AdvancedTabDialog } from '~/views-components/advanced-tab-dialog/advanced-tab-dialog';
import { ProcessInputDialog } from '~/views-components/process-input-dialog/process-input-dialog';
<Route path={Routes.KEEP_SERVICES} component={KeepServicePanel} />
<Route path={Routes.COMPUTE_NODES} component={ComputeNodePanel} />
<Route path={Routes.API_CLIENT_AUTHORIZATIONS} component={ApiClientAuthorizationPanel} />
+ <Route path={Routes.MY_ACCOUNT} component={MyAccountPanel} />
</Switch>
</Grid>
</Grid>