Commit 8c3d8530 authored by Matija Obreza's avatar Matija Obreza
Browse files

Merge branch '234-password-change' into 'main'

Password change

Closes #234

See merge request grin-global/grin-global-ui!219
parent e0a7b45f
......@@ -8,7 +8,8 @@
"maxCharacters": "Must be {{count, number}} characters or less",
"invalidEmail": "Invalid email address",
"minValue": "Should be greater or equal to {{count, min}}",
"invalidDoi": "Invalid DOI. Must start with 10.[digits]/[something]"
"invalidDoi": "Invalid DOI. Must start with 10.[digits]/[something]",
"invalidNewPassword": "New passwords doesn`t match"
},
"model": {
"_": {
......
import { call, put, takeEvery } from 'redux-saga/effects';
// constants
export const CHANGE_PASSWORD_FAIL = 'CHANGE_PASSWORD_FAIL';
export const CHANGE_PASSWORD_SAGA = 'saga/login/CHANGE_PASSWORD';
export const CLEAR_CHANGE_PASSWORD_FAIL_SAGA = 'saga/login/CLEAR_CHANGE_PASSWORD_FAIL_SAGA';
// service
import { ProfileService } from '@gringlobal-ce/client/service';
export const profileSagas = [
takeEvery(CHANGE_PASSWORD_SAGA, changePasswordSaga),
takeEvery(CLEAR_CHANGE_PASSWORD_FAIL_SAGA, clearChangePasswordErrorSaga)
];
export const changePasswordAction = (data) => ({
type: CHANGE_PASSWORD_SAGA,
payload: {
data
},
});
function* changePasswordSaga(action) {
const { currentPassword, newPassword, closeDialog } = action.payload.data;
try {
yield call(ProfileService.changePassword, newPassword, currentPassword);
closeDialog?.();
} catch (e) {
yield put({
type: CHANGE_PASSWORD_FAIL,
error: e.data?.error,
});
}
}
export const clearChangePasswordErrorAction = () => ({
type: CLEAR_CHANGE_PASSWORD_FAIL_SAGA,
});
function* clearChangePasswordErrorSaga(action) {
yield put({
type: CHANGE_PASSWORD_FAIL,
error: null,
});
}
import { takeEvery } from 'redux-saga/effects';
import { loginSagas } from './login';
import { applicationConfigSagas } from './applicationConfig';
import { profileSagas } from './profile';
export const coreSagas = [
process.env.NODE_ENV === 'development' ? takeEvery((action) => /^api\//.test(action.type), logApi) : {},
...loginSagas,
...applicationConfigSagas,
...profileSagas,
];
function* logApi(action) {
......
......@@ -7,6 +7,7 @@ import historyReducer from '@gringlobal-ce/client/reducer/history';
import login from '@gringlobal-ce/client/reducer/login';
import pageTitle from '@gringlobal-ce/client/reducer/pageTitle';
import snackbar from '@gringlobal-ce/client/reducer/snackbar';
import profile from '@gringlobal-ce/client/reducer/profile';
const coreReducers = (history?) => ({
// lib reducers
......@@ -19,6 +20,7 @@ const coreReducers = (history?) => ({
login,
pageTitle,
snackbar,
profile,
// model reducers
});
......
// constants
import { CHANGE_PASSWORD_FAIL } from '@gringlobal-ce/client/action/profile';
const INITIAL_STATE = {
error: null,
};
const profile = (state = INITIAL_STATE, action) => {
const { type, ...rest } = action;
switch (type) {
case CHANGE_PASSWORD_FAIL: {
return { ...state, ...rest };
}
default:
return state;
}
};
export default profile;
import * as QueryString from 'query-string';
import { AxiosInstance, AxiosRequestConfig } from 'axios';
import OAuthClient from '@gringlobal-ce/client/model/gringlobal/OAuthClient';
import SysUser from '@gringlobal-ce/client/model/gringlobal/SysUser';
const URL_CHANGE_PASSWORD = `/api/v1/me/password`;
const URL_GET_PROFILE = `/api/v1/me/user`;
const URL_GET_CLIENT = `/api/v1/me/client`;
/**
* Profile service
*
* GRIN-Global CE API
*/
class ProfileService {
private _axios: AxiosInstance;
public constructor(axios: AxiosInstance) {
this._axios = axios;
}
/**
* changePassword at /api/v1/me/password
*
* @param newPassword string
* @param oldPassword string
* @param xhrConfig additional xhr config
*/
public changePassword = (newPassword: string, oldPassword: string, xhrConfig?: AxiosRequestConfig): Promise<string> => {
const qs = QueryString.stringify({
new: newPassword || undefined,
old: oldPassword || undefined,
}, {});
const apiUrl = URL_CHANGE_PASSWORD + (qs ? `?${qs}` : '');
// console.log(`Fetching from ${apiUrl}`);
const content = { /* No content in request body */ };
return this._axios.request({
...xhrConfig,
url: apiUrl,
method: 'POST',
...content,
}).then(({ data }) => data as string);
}
/**
* getProfile at /api/v1/me/user
*
* @param xhrConfig additional xhr config
*/
public getProfile = (xhrConfig?: AxiosRequestConfig): Promise<SysUser> => {
const apiUrl = URL_GET_PROFILE;
// console.log(`Fetching from ${apiUrl}`);
const content = { /* No content in request body */ };
return this._axios.request({
...xhrConfig,
url: apiUrl,
method: 'GET',
...content,
}).then(({ data }) => data as SysUser);
}
/**
* getClient at /api/v1/me/client
*
* @param xhrConfig additional xhr config
*/
public getClient = (xhrConfig?: AxiosRequestConfig): Promise<OAuthClient> => {
const apiUrl = URL_GET_CLIENT;
// console.log(`Fetching from ${apiUrl}`);
const content = { /* No content in request body */ };
return this._axios.request({
...xhrConfig,
url: apiUrl,
method: 'GET',
...content,
}).then(({ data }) => data as OAuthClient);
}
}
export default ProfileService;
......@@ -13,6 +13,7 @@ const URL_UPDATE_USER = '/api/v1/admin/user';
const URL_CREATE_USER = '/api/v1/admin/user';
const URL_ENABLE_USER = UrlTemplate.parse('/api/v1/admin/user/{id}/enable');
const URL_DISABLE_USER = UrlTemplate.parse('/api/v1/admin/user/{id}/disable');
const URL_SET_PASSWORD = UrlTemplate.parse(`/api/v1/admin/user/{id}/password`);
const URL_GET_GROUP = UrlTemplate.parse('/api/v1/admin/group/{id}');
const URL_REMOVE_GROUP = UrlTemplate.parse('/api/v1/admin/group/{id}');
......@@ -174,6 +175,30 @@ export default class UserService {
}).then(({ data }) => data as SysUser);
};
/**
* setPassword at /api/v1/admin/user/{id}/password
*
* @param id
* @param pass
* @param xhrConfig additional xhr config
*/
public setPassword = (id: number, pass: string, xhrConfig?: AxiosRequestConfig): Promise<boolean> => {
const qs = QueryString.stringify({
pass: pass || undefined,
}, {});
const apiUrl = URL_SET_PASSWORD.expand({ id }) + (qs ? `?${qs}` : '');
// console.log(`Fetching from ${apiUrl}`);
const content = { /* No content in request body */ };
return this._axios.request({
...xhrConfig,
url: apiUrl,
method: 'POST',
...content,
}).then(({ data }) => data as boolean);
}
/**
* get at /api/v1/admin/group/{id}
*
......
import CooperatorService from '@gringlobal-ce/client/service/CooperatorService';
import UserService from '@gringlobal-ce/client/service/UserService';
import LoginService from '@gringlobal-ce/client/service/LoginService';
import ProfileService from '@gringlobal-ce/client/service/ProfileService';
import TaxonomyService from '@gringlobal-ce/client/service/TaxonomyService';
import AccessionService from '@gringlobal-ce/client/service/AccessionService';
import InventoryService from '@gringlobal-ce/client/service/InventoryService';
......@@ -102,6 +103,7 @@ const ConfiguredTaxonomyService = new TaxonomyService(serviceAxios);
const ConfiguredCooperatorService = new CooperatorService(serviceAxios);
const ConfiguredUserService = new UserService(serviceAxios);
const ConfiguredLoginService = new LoginService(serviceAxios);
const ConfiguredProfileService = new ProfileService(serviceAxios);
const ConfiguredAccessionService = new AccessionService(serviceAxios);
const ConfiguredInventoryService = new InventoryService(serviceAxios);
......@@ -132,6 +134,7 @@ export {
ConfiguredCooperatorService as CooperatorService,
ConfiguredUserService as UserService,
ConfiguredLoginService as LoginService,
ConfiguredProfileService as ProfileService,
ConfiguredTaxonomyService as TaxonomyService,
ConfiguredAccessionService as AccessionService,
ConfiguredInventoryService as InventoryService,
......
......@@ -8,6 +8,7 @@
"maxCharacters": "Must be {{count, number}} characters or less",
"invalidEmail": "Invalid email address",
"minValue": "Should be greater or equal to {{count, min}}",
"invalidDoi": "Invalid DOI. Must start with 10.[digits]/[something]"
"invalidDoi": "Invalid DOI. Must start with 10.[digits]/[something]",
"invalidNewPassword": "New passwords doesn`t match"
}
}
export const composeValidators = (...validators) => (value) =>
validators.reduce((error, validator) => error || validator(value), undefined);
export const composeValidators = (...validators) => (value, allValues) =>
validators.reduce((error, validator) => error || validator(value, allValues), undefined);
export const required = (value, allValues, meta) => !value ? 'client:validations.required' : undefined;
......@@ -16,3 +16,5 @@ export const emailAddress = (value) => !value || value.match(/^(?:[A-Za-z0-9!#$%
export const minValue = (min) => (value) => (value === undefined || isNaN(value) || value >= min) ? undefined : [ 'client:validations.minValue', { count: min } ]; // `Should be greater than ${min}`;
export const validDoi = (value) => !value || value.match(/^10\.\d+\/.+/gi) ? undefined : 'client:validations.invalidDoi';
export const newPasswordsMatch = (value, allValues) => value && allValues?.newPassword && value !== allValues.newPassword ? 'client:validations.invalidNewPassword' : undefined;
......@@ -8,6 +8,7 @@
"users": "Users",
"login": "Login",
"logout": "Logout",
"changePassword": "Change password",
"accessions": "Accessions",
"inventory": "Inventory",
"inventorygroup": "Groups",
......@@ -873,6 +874,20 @@
"placeholder": "Your password"
}
}
},
"c": {
"changePassword": {
"title": "Change password",
"currentPassword": {
"placeholder": "Your current password"
},
"newPassword": {
"placeholder": "Enter new password"
},
"newPasswordRepeat": {
"placeholder": "Repeat new password"
}
}
}
},
"admin": {
......@@ -886,7 +901,9 @@
"profile": "{{username}} profile information",
"enable": "Enable",
"disable": "Disable",
"assign": "Assign groups"
"setPassword": "Set password",
"assign": "Assign groups",
"passwordUpdated": "Password successfully updated!"
},
"edit": {
"title": "Update user profile",
......@@ -921,6 +938,9 @@
},
"common": {
"password": "Password",
"currentPassword": "Current password",
"newPassword": "New password",
"newPasswordRepeat": "Repeat new password",
"oAuth": {
"authorities": {
"CLIENT": "Client",
......
......@@ -9,10 +9,21 @@ import { UserRole } from '@gringlobal-ce/client/model/gringlobal/SysUser';
import { bindActionCreators, compose } from 'redux';
import { logoutUserAction } from '@gringlobal-ce/client/action/login';
import { UISecurity as P } from '@gringlobal-ce/client/service';
import ChangePasswordForm from 'user/ui/c/form/ChangePasswordForm';
import { changePasswordAction, clearChangePasswordErrorAction } from '@gringlobal-ce/client/action/profile';
function PublicMenu(props: { login: any, logoutUserAction: () => void; }): JSX.Element {
function PublicMenu(props: { login: any, profile: any, logoutUserAction: () => void, changePasswordAction: (data) => void, clearChangePasswordErrorAction: () => void }): JSX.Element {
const { t } = useTranslation();
const [ changePasswordDialogIsOpen, setChangePasswordDialogIsOpen ] = React.useState(false);
const openChangePasswordDialog = () => {
setChangePasswordDialogIsOpen(true)
}
const closeChangePasswordDialog = () => {
setChangePasswordDialogIsOpen(false)
}
return (
<List>
......@@ -124,22 +135,43 @@ function PublicMenu(props: { login: any, logoutUserAction: () => void; }): JSX.E
</ListItem>
</Link>
:
<Link to="/">
<ListItem button onClick={ props.logoutUserAction }>
<ListItemText primary={ t('navigation.logout') } />
<>
<Link to="/">
<ListItem button onClick={ props.logoutUserAction }>
<ListItemText primary={ t('navigation.logout') } />
</ListItem>
</Link>
<ListItem button onClick={ openChangePasswordDialog }>
<ListItemText primary={ t('navigation.changePassword') } />
</ListItem>
</Link>
</>
}
<Authorize roles={ [ UserRole.USER ] }>
<ChangePasswordForm
isOpen={ changePasswordDialogIsOpen }
onClose={ closeChangePasswordDialog }
onSubmit={ (data) => props.changePasswordAction({...data, closeDialog: closeChangePasswordDialog }) }
formId='change-password-form'
title={ t('user.public.c.changePassword.title') }
size='sm'
error={ props.profile?.error }
resetError={ props.clearChangePasswordErrorAction }
/>
</Authorize>
</List>
);
}
const mapStateToProps = (state) => ({
login: state.login,
profile: state.profile
});
const mapDispatchToProps = (dispatch) => bindActionCreators({
logoutUserAction,
changePasswordAction,
clearChangePasswordErrorAction,
}, dispatch);
const composedPublicLayout = compose(connect(mapStateToProps, mapDispatchToProps))(PublicMenu)
......
......@@ -8,6 +8,7 @@
"users": "Users",
"login": "Login",
"logout": "Logout",
"changePassword": "Change password",
"accessions": "Accessions",
"inventory": "Inventory",
"inventorygroup": "Groups",
......
......@@ -12,6 +12,20 @@
"placeholder": "Your password"
}
}
},
"c": {
"changePassword": {
"title": "Change password",
"currentPassword": {
"placeholder": "Your current password"
},
"newPassword": {
"placeholder": "Enter new password"
},
"newPasswordRepeat": {
"placeholder": "Repeat new password"
}
}
}
},
"admin": {
......@@ -25,7 +39,9 @@
"profile": "{{username}} profile information",
"enable": "Enable",
"disable": "Disable",
"assign": "Assign groups"
"setPassword": "Set password",
"assign": "Assign groups",
"passwordUpdated": "Password successfully updated!"
},
"edit": {
"title": "Update user profile",
......@@ -60,6 +76,9 @@
},
"common": {
"password": "Password",
"currentPassword": "Current password",
"newPassword": "New password",
"newPasswordRepeat": "Repeat new password",
"oAuth": {
"authorities": {
"CLIENT": "Client",
......
......@@ -50,10 +50,10 @@ class UserBrowsePage extends React.Component<IBrowsePageProps> {
console.log(`Column ${toggledColumn} was toggled. Have ${selectedColumns}`);
};
private handleSubmit = (userData) => {
private handleSubmit = ({ cooperator, username, password }) => {
const { receiveUser, navigateTo } = this.props;
this.setState({ error: null });
UserService.update(userData)
UserService.create(username, password, cooperator?.id)
.then((user: User) => {
this.closeUserDialog();
receiveUser(user);
......
......@@ -38,6 +38,7 @@ import AssignSysGroupsDialog from './c/AssignSysGroupsDialog';
import ContentHeader from '@gringlobal-ce/client/ui/common/heading/ContentHeader';
import { GridContainer, GridItem } from '@gringlobal-ce/client/ui/common/grid';
import UserForm from 'user/ui/admin/c/UserForm';
import ChangePasswordForm from 'user/ui/c/form/ChangePasswordForm';
const styles = (theme) => createStyles({
action: {
......@@ -77,6 +78,7 @@ class UserDetailsPage extends React.Component<IUserDetailsPage> {
groupsDialogIsOpen: false,
userDialogIsOpen: false,
submitError: null,
changePasswordDialogIsOpen: false,
};
public componentDidMount() {
......@@ -133,6 +135,19 @@ class UserDetailsPage extends React.Component<IUserDetailsPage> {
});
};
private handleSetPassword = ({ newPassword }) => {
const { userCall, showSnackbar, t } = this.props;
this.resetError();
UserService.setPassword(userCall.data.id, newPassword)
.then(() => {
this.closeChangePasswordDialog();
showSnackbar(t('user.admin.p.details.passwordUpdated'))
})
.catch((e) => {
this.setState({ submitError: e.data && e.data.error || e.toString() });
});
};
private resetError = () => {
return this.setState({ submitError: null });
}
......@@ -145,9 +160,13 @@ class UserDetailsPage extends React.Component<IUserDetailsPage> {
private closeGroupsDialog = () => this.setState({ groupsDialogIsOpen: false });
private openChangePasswordDialog = () => this.setState({ changePasswordDialogIsOpen: true });
private closeChangePasswordDialog = () => this.setState({ changePasswordDialogIsOpen: false });
public render() {
const { userCall, t, classes } = this.props;
const { groupsDialogIsOpen, userDialogIsOpen, submitError } = this.state;
const { groupsDialogIsOpen, userDialogIsOpen, submitError, changePasswordDialogIsOpen } = this.state;
if (!userCall) {
return null;
......@@ -211,6 +230,11 @@ class UserDetailsPage extends React.Component<IUserDetailsPage> {
<CardActions>
<ButtonBar>
{ YesNoToBoolean(user.isEnabled) ? [
<ActionButton
key="setPassword"
title={ t('user.admin.p.details.setPassword') }
action={ this.openChangePasswordDialog }
/>,
<ActionButton
key="disable"
title={ t('user.admin.p.details.disable') }
......@@ -284,6 +308,17 @@ class UserDetailsPage extends React.Component<IUserDetailsPage> {
error={ submitError }
resetError={ this.resetError }
/>
<ChangePasswordForm
isOpen={ changePasswordDialogIsOpen }
onClose={ this.closeChangePasswordDialog }
onSubmit={ this.handleSetPassword }
formId='change-password-form'
title={ t('user.public.c.changePassword.title') }
size='sm'
error={ submitError }
resetError={ this.resetError }
setPassword
/>
</div>
</>
);
......
import * as React from 'react';
import { Form, Field, FormProps, FormRenderProps } from 'react-final-form';
import { withTranslation, WithTranslation } from 'react-i18next';
// UI
import { TextField } from '@gringlobal-ce/client/ui/common/form/TextField';
import { Grid } from '@material-ui/core';
import { withStyles, WithStyles } from '@material-ui/core/styles';
import withDialog from 'ui/common/withDialog';
// Utils
import { composeValidators, newPasswordsMatch, required } from '@gringlobal-ce/client/utilities/validators';
const styles = (theme) => ({
textField: {
},
});
const ChangePasswordForm = ({ t, onSubmit, initialValues, classes, setPassword, error }: FormProps & WithTranslation & WithStyles) =>
<Form
initialValues={ initialValues }
onSubmit={ onSubmit }
>
{ (props: FormRenderProps & WithStyles) => (
<form onSubmit={ props.handleSubmit } id="change-password-form">
<Grid container spacing={ 4 }>
{ !setPassword &&
<Grid item xs={ 12 }>
<Field
label={ t('user.common.currentPassword') }
helperText={ t('user.public.c.changePassword.currentPassword.placeholder') }
name="currentPassword"
type="password"
component={ TextField }
required
validate={ required }
/>
</Grid>
}
<Grid item xs={ 12 }>
<Field
label={ t('user.common.newPassword') }
helperText={ t('user.public.c.changePassword.newPassword.placeholder') }
name="newPassword"
type="password"