All about user login

Not runnable at the moment
This commit is contained in:
Tomas Bures 2017-07-08 15:48:34 +02:00
parent fbb8f5799e
commit d79bbad575
49 changed files with 1554 additions and 686 deletions

View file

@ -29,7 +29,8 @@
"react": "^15.5.4",
"react-dom": "^15.5.4",
"react-i18next": "^4.1.0",
"react-router-dom": "^4.1.1"
"react-router-dom": "^4.1.1",
"url-parse": "^1.1.9"
},
"devDependencies": {
"babel-cli": "^6.24.1",

254
client/src/account/API.js Normal file
View file

@ -0,0 +1,254 @@
'use strict';
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { withPageHelpers, Title } from '../lib/page'
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import URL from 'url-parse';
import axios from '../lib/axios';
import { Button } from '../lib/bootstrap-components';
@translate()
@withPageHelpers
@withErrorHandling
export default class API extends Component {
constructor(props) {
super(props);
this.state = {
accessToken: null
};
}
@withAsyncErrorHandler
async loadAccessToken() {
const response = await axios.get('/rest/access-token');
this.setState('accessToken', response.data);
}
componentDidMount() {
this.loadAccessToken();
}
async resetAccessToken() {
const response = await axios.post('/rest/access-token-reset');
this.setState('accessToken', response.data);
}
render() {
const t = this.props.t;
const thisUrl = new URL();
const serviceUrl = thisUrl.origin + '/';
const accessToken = this.state.accessToken || 'ACCESS_TOKEN';
return (
<div>
<Title>{t('Sign in')}</Title>
<div class="panel panel-default">
<div class="panel-body">
<div class="pull-right">
<Button label={this.state.accessToken ? t('Reset Access Token') : t('Generate Access Token')} icon="retweet" className="btn-info" onClickAsync={::this.resetAccessToken} />
</div>
{ this.state.accessToken ?
<div>{t('Personal access token:')} <code>{accessToken}</code></div>
:
<div>{t('Access token not yet generated')}</div>
}
</div>
</div>
<div class="well">
<h3>{t('Notes about the API')}</h3>
<ul>
<li>
{t('API response is a JSON structure with <code>error</code> and <code>data</code> properties. If the response <code>error</code> has a value set then the request failed.')}
</li>
<li>
{t('You need to define proper <code>Content-Type</code> when making a request. You can either use <code>application/x-www-form-urlencoded</code> for normal form data or <code>application/json</code> for a JSON payload. Using <code>multipart/form-data</code> is not supported.')}
</li>
</ul>
</div>
<h3>POST /api/subscribe/:listId {t('Add subscription')}</h3>
<p>
{t('This API call either inserts a new subscription or updates existing. Fields not included are left as is, so if you update only LAST_NAME value, then FIRST_NAME is kept untouched for an existing subscription.')}
</p>
<p>
<strong>GET</strong> {t('arguments')}
</p>
<ul>
<li><strong>access_token</strong> {t('your personal access token')}</li>
</ul>
<p>
<strong>POST</strong> {t('arguments')}
</p>
<ul>
<li><strong>EMAIL</strong> {t('subscriber\'s email address')} (<em>{t('required')}</em>)</li>
<li><strong>FIRST_NAME</strong> {t('subscriber\'s first name')}</li>
<li><strong>LAST_NAME</strong> {t('subscriber\'s last name')}</li>
<li><strong>TIMEZONE</strong> {t('subscriber\'s timezone (eg. "Europe/Tallinn", "PST" or "UTC"). If not set defaults to "UTC"')}</li>
<li><strong>MERGE_TAG_VALUE</strong> {t('custom field value. Use yes/no for option group values (checkboxes, radios, drop downs)')}</li>
</ul>
<p>
{t('Additional POST arguments')}:
</p>
<ul>
<li>
<strong>FORCE_SUBSCRIBE</strong> {t('set to "yes" if you want to make sure the email is marked as subscribed even if it was previously marked as unsubscribed. If the email was already unsubscribed/blocked then subscription status is not changed')}
by default.
</li>
<li>
<strong>REQUIRE_CONFIRMATION</strong> {t('set to "yes" if you want to send confirmation email to the subscriber before actually marking as subscribed')}
</li>
</ul>
<p>
<strong>{t('Example')}</strong>
</p>
<pre>curl -XPOST {serviceUrl}api/subscribe/B16uVTdW?access_token={accessToken} \
--data 'EMAIL=test@example.com&amp;MERGE_CHECKBOX=yes&amp;REQUIRE_CONFIRMATION=yes'</pre>
<h3>POST /api/unsubscribe/:listId {t('Remove subscription')}</h3>
<p>
{t('This API call marks a subscription as unsubscribed')}
</p>
<p>
<strong>GET</strong> {t('arguments')}
</p>
<ul>
<li><strong>access_token</strong> {t('your personal access token')}</li>
</ul>
<p>
<strong>POST</strong> {t('arguments')}
</p>
<ul>
<li><strong>EMAIL</strong> {t('subscriber\'s email address')} (<em>{t('required')}</em>)</li>
</ul>
<p>
<strong>{t('Example')}</strong>
</p>
<pre>curl -XPOST {serviceUrl}api/unsubscribe/B16uVTdW?access_token={accessToken} \
--data 'EMAIL=test@example.com'</pre>
<h3>POST /api/delete/:listId {t('Delete subscription')}</h3>
<p>
{t('This API call deletes a subscription')}
</p>
<p>
<strong>GET</strong> {t('arguments')}
</p>
<ul>
<li><strong>access_token</strong> {t('your personal access token')}</li>
</ul>
<p>
<strong>POST</strong> {t('arguments')}
</p>
<ul>
<li><strong>EMAIL</strong> {t('subscriber\'s email address')} (<em>{t('required')}</em>)</li>
</ul>
<p>
<strong>{t('Example')}</strong>
</p>
<pre>curl -XPOST {serviceUrl}api/delete/B16uVTdW?access_token={accessToken} \
--data 'EMAIL=test@example.com'</pre>
<h3>GET /api/blacklist/get {t('Get list of blacklisted emails')}</h3>
<p>
{t('This API call get list of blacklisted emails.')}
</p>
<p>
<strong>GET</strong> {t('arguments')}
</p>
<ul>
<li><strong>access_token</strong> {t('your personal access token')}
<li><strong>start</strong> {t('Start position')} (<em>{t('optional, default 0')}</em>)</li>
<li><strong>limit</strong> {t('limit emails count in response')} (<em>{t('optional, default 10000')}</em>)</li>
<li><strong>search</strong> {t('filter by part of email')} (<em>{t('optional, default ""')}</em>)</li>
</li>
</ul>
<p>
<strong>{t('Example')}</strong>
</p>
<pre>curl -XGET '{serviceUrl}api/blacklist/get?access_token={accessToken}&limit=10&start=10&search=gmail' </pre>
<h3>POST /api/blacklist/add {t('Add email to blacklist')}</h3>
<p>
{t('This API call either add emails to blacklist')}
</p>
<p>
<strong>GET</strong> {t('arguments')}
</p>
<ul>
<li><strong>access_token</strong> {t('your personal access token')}</li>
</ul>
<p>
<strong>POST</strong> {t('arguments')}
</p>
<ul>
<li><strong>EMAIL</strong> {t('email address')} (<em>{t('required')}</em>)</li>
</ul>
<p>
<strong>{t('Example')}</strong>
</p>
<pre>curl -XPOST '{serviceUrl}api/blacklist/add?access_token={accessToken}' \
--data 'EMAIL=test@example.com&amp;'</pre>
<h3>POST /api/blacklist/delete {t('Delete email from blacklist')}</h3>
<p>
{t('This API call either delete emails from blacklist')}
</p>
<p>
<strong>GET</strong> {t('arguments')}
</p>
<ul>
<li><strong>access_token</strong> {t('your personal access token')}</li>
</ul>
<p>
<strong>POST</strong> {t('arguments')}
</p>
<ul>
<li><strong>EMAIL</strong> {t('email address')} (<em>{t('required')}</em>)</li>
</ul>
<p>
<strong>{t('Example')}</strong>
</p>
<pre>curl -XPOST '{serviceUrl}api/blacklist/delete?access_token={accessToken}' \
--data 'EMAIL=test@example.com&amp;'</pre>
</div>
);
}
}

View file

@ -8,6 +8,7 @@ import {
} from '../lib/form';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import passwordValidator from '../../../shared/password-validator';
import mailtrainConfig from 'mailtrainConfig';
@translate()
@withForm
@ -23,15 +24,15 @@ export default class Account extends Component {
this.initForm({
serverValidation: {
url: '/account/rest/account-validate',
changed: ['email', 'password', 'currentPassword']
url: '/rest/account-validate',
changed: ['email', 'username', 'currentPassword']
}
});
}
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/account/rest/account`, data => {
await this.getFormValuesFromURL('/rest/account', data => {
data.password = '';
data.password2 = '';
data.currentPassword = '';
@ -102,53 +103,91 @@ export default class Account extends Component {
async submitHandler() {
const t = this.props.t;
this.disableForm();
this.setFormStatusMessage('info', t('Updating user profile ...'));
try {
this.disableForm();
this.setFormStatusMessage('info', t('Updating user profile ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, '/account/rest/account', data => {
delete data.password2;
});
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, '/rest/account', data => {
delete data.password2;
});
this.enableForm();
if (submitSuccessful) {
this.setFlashMessage('success', t('User profile updated'));
this.hideFormValidation();
this.updateFormValue('password', '');
this.updateFormValue('password2', '');
this.updateFormValue('currentPassword', '');
if (submitSuccessful) {
this.setFlashMessage('success', t('User profile updated'));
this.hideFormValidation();
this.updateFormValue('password', '');
this.updateFormValue('password2', '');
this.updateFormValue('currentPassword', '');
this.clearFormStatusMessage();
} else {
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
}
} catch (error) {
if (error instanceof interoperableErrors.IncorrectPasswordError) {
this.enableForm();
this.clearFormStatusMessage();
} else {
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('The password is incorrect (possibly just changed in another window / session). Enter correct password and try again.')}
</span>
);
this.scheduleFormRevalidate();
return;
}
if (error instanceof interoperableErrors.DuplicitEmailError) {
this.enableForm();
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('The email is already assigned to another user. Enter another email and try again.')}
</span>
);
this.scheduleFormRevalidate();
return;
}
throw error;
}
}
render() {
const t = this.props.t;
return (
if (mailtrainConfig.isAuthMethodLocal) {
return (
<div>
<Title>{t('Account')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<Fieldset label={t('General Settings')}>
<InputField id="name" label={t('Full Name')}/>
<InputField id="email" label={t('Email')} help={t('This address is used for account recovery in case you loose your password')}/>
</Fieldset>
<Fieldset label={t('Password Change')}>
<p>{t('You only need to fill out this form if you want to change your current password')}</p>
<InputField id="currentPassword" label={t('Current Password')} type="password" />
<InputField id="password" label={t('New Password')} type="password" />
<InputField id="password2" label={t('Confirm Password')} type="password" />
</Fieldset>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Update')}/>
</ButtonRow>
</Form>
</div>
);
} else {
<div>
<Title>{t('Account')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<Fieldset label={t('General Settings')}>
<InputField id="name" label={t('Full Name')}/>
<InputField id="email" label={t('Email')} help={t('This address is used for account recovery in case you loose your password')}/>
</Fieldset>
<Fieldset label={t('Password Change')}>
<p>{t('You only need to fill out this form if you want to change your current password')}</p>
<InputField id="currentPassword" label={t('Current Password')} type="password" />
<InputField id="password" label={t('New Password')} type="password" />
<InputField id="password2" label={t('Confirm Password')} type="password" />
</Fieldset>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Update')}/>
</ButtonRow>
</Form>
<p>Account management is not possible because Mailtrain is configured to use externally managed users.</p>
</div>
);
}
}
}

View file

@ -0,0 +1,78 @@
'use strict';
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { withPageHelpers, Title } from '../lib/page'
import {
withForm, Form, FormSendMethod, InputField, CheckBox, ButtonRow, Button, AlignedRow
} from '../lib/form';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
@translate()
@withForm
@withPageHelpers
@withErrorHandling
export default class Forget extends Component {
constructor(props) {
super(props);
this.state = {};
this.initForm();
}
componentDidMount() {
this.populateFormValues({
usernameOrEmail: this.props.match.params.username || ''
});
}
localValidateFormValues(state) {
const t = this.props.t;
const username = state.getIn(['usernameOrEmail', 'value']);
if (!username) {
state.setIn(['usernameOrEmail', 'error'], t('Username or email must not be empty'));
} else {
state.setIn(['usernameOrEmail', 'error'], null);
}
}
async submitHandler() {
const t = this.props.t;
this.disableForm();
this.setFormStatusMessage('info', t('Processing ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, '/rest/password-reset-send');
if (submitSuccessful) {
this.navigateToWithFlashMessage('/login', 'success', t('If the username / email exists in the system, password reset link will be sent to the registered email.'));
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('Please enter your username / email and try again.'));
}
}
render() {
const t = this.props.t;
return (
<div>
<Title>{t('Password Reset')}</Title>
<p>{t('Please provide the username or email address that is registered with your Mailtrain account.')}</p>
<p>{t('We will send you an email that will allow you to reset your password.')}</p>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="usernameOrEmail" label={t('Username or email')}/>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Send email')}/>
</ButtonRow>
</Form>
</div>
);
}
}

107
client/src/account/Login.js Normal file
View file

@ -0,0 +1,107 @@
'use strict';
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { withPageHelpers, Title } from '../lib/page'
import { Link } from 'react-router-dom'
import {
withForm, Form, FormSendMethod, InputField, CheckBox, ButtonRow, Button, AlignedRow
} from '../lib/form';
import { withErrorHandling } from '../lib/error-handling';
import URL from 'url-parse';
import mailtrainConfig from 'mailtrainConfig';
@translate()
@withForm
@withPageHelpers
@withErrorHandling
export default class Login extends Component {
constructor(props) {
super(props);
this.state = {};
this.initForm();
}
componentDidMount() {
this.populateFormValues({
username: '',
password: '',
remember: false
});
}
localValidateFormValues(state) {
const t = this.props.t;
const username = state.getIn(['username', 'value']);
if (!username) {
state.setIn(['username', 'error'], t('User name must not be empty'));
} else {
state.setIn(['username', 'error'], null);
}
const password = state.getIn(['password', 'value']);
if (!username) {
state.setIn(['password', 'error'], t('Password must not be empty'));
} else {
state.setIn(['password', 'error'], null);
}
}
async submitHandler() {
const t = this.props.t;
try {
this.disableForm();
this.setFormStatusMessage('info', t('Verifying credentials ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, '/rest/login');
if (submitSuccessful) {
const query = new URL(this.props.location.search, true).query;
/* FIXME, once we turn Mailtrain to single-page application, this should become navigateTo */
window.location = query.next;
} else {
this.setFormStatusMessage('warning', t('Please enter your credentials and try again.'));
}
} catch (error) {
if (error instanceof interoperableErrors.IncorrectPasswordError) {
this.enableForm();
this.setFormStatusMessage('danger',
<span>
<strong>{t('Invalid username or password.')}</strong>
</span>
);
return;
}
throw error;
}
}
render() {
const t = this.props.t;
return (
<div>
<Title>{t('Sign in')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="username" label={t('Username')}/>
<InputField id="password" label={t('Password')} type="password" />
<CheckBox id="remember" label={t('Remember me')}/>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Sign in')}/>
{mailtrainConfig.isAuthMethodLocal && <Link to={`/account/forgot/${this.getFormValue('username')}`}>{t('Forgot your password?')}</Link>}
</ButtonRow>
</Form>
</div>
);
}
}

137
client/src/account/Reset.js Normal file
View file

@ -0,0 +1,137 @@
'use strict';
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { withPageHelpers, Title } from '../lib/page'
import {
withForm, Form, Fieldset, FormSendMethod, InputField, ButtonRow, Button
} from '../lib/form';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import passwordValidator from '../../../shared/password-validator';
import axios from '../lib/axios';
const ResetTokenValidationState = {
PENDING: 0,
VALID: 1,
INVALID: 2
};
@translate()
@withForm
@withPageHelpers
@withErrorHandling
export default class Account extends Component {
constructor(props) {
super(props);
this.passwordValidator = passwordValidator(props.t);
this.state = {
resetTokenValidationState: ResetTokenValidationState.PENDING
};
this.initForm();
}
@withAsyncErrorHandler
async loadAccessToken() {
const params = this.props.match.params;
const response = await axios.post('/rest/password-reset-validate', {
username: params.username,
resetToken: params.resetToken
});
this.setState('resetTokenValidationState', response.data ? ResetTokenValidationState.VALID : ResetTokenValidationState.INVALID);
}
componentDidMount() {
const params = this.props.match.params;
this.populateFormValues({
username: params.username,
resetToken: params.resetToken,
password: '',
password2: ''
});
}
localValidateFormValues(state) {
const t = this.props.t;
const password = state.getIn(['password', 'value']) || '';
const password2 = state.getIn(['password2', 'value']) || '';
let passwordMsgs = [];
if (password || currentPassword) {
const passwordResults = this.passwordValidator.test(password);
passwordMsgs.push(...passwordResults.errors);
}
if (passwordMsgs.length > 1) {
passwordMsgs = passwordMsgs.map((msg, idx) => <div key={idx}>{msg}</div>)
}
state.setIn(['password', 'error'], passwordMsgs.length > 0 ? passwordMsgs : null);
state.setIn(['password2', 'error'], password !== password2 ? t('Passwords must match') : null);
}
async submitHandler() {
const t = this.props.t;
try {
this.disableForm();
this.setFormStatusMessage('info', t('Resetting password ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, '/rest/password-reset', data => {
delete data.password2;
});
if (submitSuccessful) {
this.navigateToWithFlashMessage('/account/login', 'success', t('Password reset'));
} else {
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
}
} catch (error) {
if (error instanceof interoperableErrors.InvalidToken) {
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your password cannot be reset.')}</strong>{' '}
{t('The reset token has expired.')}{' '}<Link to={`/account/forgot/${this.getFormValue('username')}`}>{t('Click here to request a new password reset link.')}</Link>
</span>
);
return;
}
throw error;
}
}
render() {
const t = this.props.t;
if (this.state.resetTokenValidationState === ResetTokenValidationState.PENDING) {
return (
<div>{t('Validating password reset token ...')}</div>
)
} else if (this.state.resetTokenValidationState === ResetTokenValidationState.INVALID) {
} else {
return (
<div>
<Title>{t('Set new password for') + ' ' + this.getFormValue('username')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="password" label={t('New Password')} type="password"/>
<InputField id="password2" label={t('Confirm Password')} type="password"/>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Reset password')}/>
</ButtonRow>
</Form>
</div>
);
}
}
}

View file

@ -7,20 +7,60 @@ import i18n from '../lib/i18n';
import { Section } from '../lib/page'
import Account from './Account'
import Login from './Login'
import Reset from './Forgot'
import ResetLink from './Reset'
import API from './API'
import mailtrainConfig from 'mailtrainConfig';
const getStructure = t => ({
'': {
title: t('Home'),
externalLink: '/',
children: {
'account': {
title: t('Account'),
link: '/account',
component: Account
const getStructure = t => {
const subPaths = {
'login': {
title: t('Sign in'),
link: '/account/login',
component: Login,
},
'api': {
title: t('API'),
link: '/account/api',
component: API
}
};
if (mailtrainConfig.isAuthMethodLocal) {
subPaths.forgot = {
title: t('Password reset'),
params: [':username?'],
link: '/account/forgot',
component: Reset
};
subPaths.reset = {
title: t('Password reset'),
params: [':username', ':resetToken'],
link: '/account/reset',
component: ResetLink
};
}
return {
'': {
title: t('Home'),
externalLink: '/',
children: {
account: {
title: t('Account'),
link: '/account',
component: Account,
children: subPaths
}
}
}
}
});
};
}
export default function() {
ReactDOM.render(

View file

@ -74,6 +74,32 @@ class Button extends Component {
}
@withErrorHandling
class ActionLink extends Component {
static propTypes = {
onClickAsync: PropTypes.func,
className: PropTypes.string
}
@withAsyncErrorHandler
async onClick(evt) {
if (this.props.onClickAsync) {
evt.preventDefault();
await this.props.onClickAsync(evt);
}
}
render() {
const props = this.props;
return (
<a href="" className={props.className} onClick={::this.onClick}>{props.children}</a>
);
}
}
@translate()
@withErrorHandling
class ModalDialog extends Component {
@ -184,6 +210,7 @@ class ModalDialog extends Component {
export {
Button,
ActionLink,
DismissibleAlert,
ModalDialog
};

View file

@ -128,6 +128,20 @@ function wrapInput(id, htmlId, owner, label, help, input) {
);
}
function wrapInputInline(id, htmlId, owner, containerClass, label, help, input) {
const helpBlock = help ? <div className="help-block col-sm-offset-2 col-sm-10" id={htmlId + '_help'}>{help}</div> : '';
return (
<div className={owner.addFormValidationClass('form-group', id)} >
<div className={"col-sm-10 col-sm-offset-2 " + containerClass }>
<label>{input} {label}</label>
</div>
{helpBlock}
<div className="help-block col-sm-offset-2 col-sm-10" id={htmlId + '_help_validation'}>{owner.getFormValidationMessage(id)}</div>
</div>
);
}
class InputField extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
@ -162,6 +176,29 @@ class InputField extends Component {
}
}
class CheckBox extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
help: PropTypes.string
}
static contextTypes = {
formStateOwner: PropTypes.object.isRequired
}
render() {
const props = this.props;
const owner = this.context.formStateOwner;
const id = this.props.id;
const htmlId = 'form_' + id;
return wrapInputInline(id, htmlId, owner, 'checkbox', props.label, props.help,
<input type="checkbox" checked={owner.getFormValue(id)} id={htmlId} aria-describedby={htmlId + '_help'} onChange={evt => {console.log(evt); /* FIXME owner.updateFormValue(id, evt.target.value)*/ }}/>
);
}
}
class TextArea extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
@ -186,11 +223,19 @@ class TextArea extends Component {
}
}
class ButtonRow extends Component {
class AlignedRow extends Component {
static propTypes = {
className: PropTypes.string
}
static defaultProps = {
className: ''
}
render() {
return (
<div className="form-group">
<div className="col-sm-10 col-sm-offset-2 mt-button-row">
<div className={"col-sm-10 col-sm-offset-2 " + this.props.className}>
{this.props.children}
</div>
</div>
@ -198,11 +243,19 @@ class ButtonRow extends Component {
}
}
class ButtonRow extends Component {
render() {
return (
<AlignedRow className="mt-button-row">{this.props.children}</AlignedRow>
);
}
}
@withErrorHandling
class Button extends Component {
static propTypes = {
onClickAsync: PropTypes.func,
onClick: PropTypes.func,
label: PropTypes.string,
icon: PropTypes.string,
className: PropTypes.string,
@ -215,12 +268,7 @@ class Button extends Component {
@withAsyncErrorHandler
async onClick(evt) {
if (this.props.onClick) {
evt.preventDefault();
onClick(evt);
} else if (this.props.onClickAsync) {
if (this.props.onClickAsync) {
evt.preventDefault();
this.context.formStateOwner.disableForm();
@ -255,6 +303,7 @@ class Button extends Component {
}
}
class TreeTableSelect extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
@ -297,6 +346,93 @@ function withForm(target) {
isServerValidationRunning: false
});
// formValidateResolve is called by "validateForm" once client receives validation response from server that does not
// trigger another server validation
let formValidateResolve = null;
function scheduleValidateForm(self) {
setTimeout(() => {
self.setState(previousState => ({
formState: previousState.formState.withMutations(mutState => {
validateFormState(self, mutState);
})
}));
}, 0);
}
function validateFormState(self, mutState) {
const settings = self.state.formSettings;
if (!mutState.get('isServerValidationRunning') && settings.serverValidation) {
const payload = {};
let payloadNotEmpty = false;
for (const attr of settings.serverValidation.extra || []) {
payload[attr] = mutState.getIn(['data', attr, 'value']);
}
for (const attr of settings.serverValidation.changed) {
const currValue = mutState.getIn(['data', attr, 'value']);
const serverValue = mutState.getIn(['data', attr, 'serverValue']);
// This really assumes that all form values are preinitialized (i.e. not undef)
if (currValue !== serverValue) {
mutState.setIn(['data', attr, 'serverValidated'], false);
payload[attr] = currValue;
payloadNotEmpty = true;
}
}
if (payloadNotEmpty) {
mutState.set('isServerValidationRunning', true);
axios.post(settings.serverValidation.url, payload)
.then(response => {
self.setState(previousState => ({
formState: previousState.formState.withMutations(mutState => {
mutState.set('isServerValidationRunning', false);
mutState.update('data', stateData => stateData.withMutations(mutStateData => {
for (const attr in payload) {
mutStateData.setIn([attr, 'serverValue'], payload[attr]);
if (payload[attr] === mutState.getIn(['data', attr, 'value'])) {
mutStateData.setIn([attr, 'serverValidated'], true);
mutStateData.setIn([attr, 'serverValidation'], response.data[attr] || true);
}
}
}));
})
}));
scheduleValidateForm(self);
})
.catch(error => {
console.log('Ignoring unhandled error in "validateFormState": ' + error);
self.setState(previousState => ({
formState: previousState.formState.set('isServerValidationRunning', false)
}));
scheduleValidateForm(self);
});
} else {
if (formValidateResolve) {
const resolve = formValidateResolve;
formValidateResolve = null;
resolve();
}
}
}
if (self.localValidateFormValues) {
mutState.update('data', stateData => stateData.withMutations(mutStateData => {
self.localValidateFormValues(mutStateData);
}));
}
}
inst.initForm = function(settings) {
const state = this.state || {};
state.formState = cleanFormState;
@ -345,12 +481,15 @@ function withForm(target) {
mutator(data);
}
let response;
if (method === FormSendMethod.PUT) {
await axios.put(url, data);
response = await axios.put(url, data);
} else if (method === FormSendMethod.POST) {
await axios.post(url, data);
response = await axios.post(url, data);
}
return true;
return response.data || true;
} else {
this.showFormValidation();
return false;
@ -371,110 +510,26 @@ function withForm(target) {
}
}));
this.validateForm(mutState);
validateFormState(this, mutState);
})
}));
};
// formValidateResolve is called by "validateForm" once client receives validation response from server that does not
// trigger another server validation
let formValidateResolve = null;
const scheduleValidateForm = (self) => {
setTimeout(() => {
self.setState(previousState => ({
formState: previousState.formState.withMutations(mutState => {
self.validateForm(mutState);
})
}));
}, 0);
};
inst.waitForFormServerValidated = async function() {
if (!this.isFormServerValidated()) {
await new Promise(resolve => { formValidateResolve = resolve; });
}
};
inst.validateForm = function(mutState) {
const settings = this.state.formSettings;
if (!mutState.get('isServerValidationRunning') && settings.serverValidation) {
const payload = {};
let payloadNotEmpty = false;
for (const attr of settings.serverValidation.extra || []) {
payload[attr] = mutState.getIn(['data', attr, 'value']);
}
for (const attr of settings.serverValidation.changed) {
const currValue = mutState.getIn(['data', attr, 'value']);
const serverValue = mutState.getIn(['data', attr, 'serverValue']);
// This really assumes that all form values are preinitialized (i.e. not undef)
if (currValue !== serverValue) {
mutState.setIn(['data', attr, 'serverValidated'], false);
payload[attr] = currValue;
payloadNotEmpty = true;
}
}
if (payloadNotEmpty) {
mutState.set('isServerValidationRunning', true);
axios.post(settings.serverValidation.url, payload)
.then(response => {
this.setState(previousState => ({
formState: previousState.formState.withMutations(mutState => {
mutState.set('isServerValidationRunning', false);
mutState.update('data', stateData => stateData.withMutations(mutStateData => {
for (const attr in payload) {
mutStateData.setIn([attr, 'serverValue'], payload[attr]);
if (payload[attr] === mutState.getIn(['data', attr, 'value'])) {
mutStateData.setIn([attr, 'serverValidated'], true);
mutStateData.setIn([attr, 'serverValidation'], response.data[attr] || true);
}
}
}));
})
}));
scheduleValidateForm(this);
})
.catch(error => {
console.log('Ignoring unhandled error in "validateForm": ' + error);
this.setState(previousState => ({
formState: previousState.formState.set('isServerValidationRunning', false)
}));
scheduleValidateForm(this);
});
} else {
if (formValidateResolve) {
const resolve = formValidateResolve;
formValidateResolve = null;
resolve();
}
}
}
if (this.localValidateFormValues) {
mutState.update('data', stateData => stateData.withMutations(mutStateData => {
this.localValidateFormValues(mutStateData);
}));
}
inst.scheduleFormRevalidate = function() {
scheduleValidateForm(this);
};
inst.updateFormValue = function(key, value) {
this.setState(previousState => ({
formState: previousState.formState.withMutations(mutState => {
mutState.setIn(['data', key, 'value'], value);
this.validateForm(mutState);
validateFormState(this, mutState);
})
}));
};
@ -590,7 +645,9 @@ export {
Form,
Fieldset,
InputField,
CheckBox,
TextArea,
AlignedRow,
ButtonRow,
Button,
TreeTableSelect,

View file

@ -202,7 +202,8 @@ class SectionContent extends Component {
errorHandler(error) {
if (error instanceof interoperableErrors.NotLoggedInError) {
window.location = '/users/login?next=' + encodeURIComponent(this.props.root);
/* FIXME, once we turn Mailtrain to single-page application, this should become navigateTo */
window.location = '/account/login?next=' + encodeURIComponent(this.props.root);
} else if (error.response && error.response.data && error.response.data.message) {
this.navigateToWithFlashMessage(this.props.root, 'danger', error.response.data.message);
} else {
@ -294,7 +295,6 @@ class NavButton extends Component {
}
}
function withPageHelpers(target) {
withErrorHandling(target);

View file

@ -57,7 +57,7 @@ export default class CUD extends Component {
@withAsyncErrorHandler
async loadTreeData() {
axios.get('/namespaces/rest/namespaces-tree')
axios.get('/rest/namespaces-tree')
.then(response => {
response.data.expanded = true;
@ -75,7 +75,7 @@ export default class CUD extends Component {
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/namespaces/rest/namespaces/${this.state.entityId}`, data => {
await this.getFormValuesFromURL(`/rest/namespaces/${this.state.entityId}`, data => {
if (data.parent) data.parent = data.parent.toString();
});
}
@ -121,10 +121,10 @@ export default class CUD extends Component {
let sendMethod, url;
if (edit) {
sendMethod = FormSendMethod.PUT;
url = `/namespaces/rest/namespaces/${this.state.entityId}`
url = `/rest/namespaces/${this.state.entityId}`
} else {
sendMethod = FormSendMethod.POST;
url = '/namespaces/rest/namespaces'
url = '/rest/namespaces'
}
try {
@ -175,7 +175,7 @@ export default class CUD extends Component {
this.disableForm();
this.setFormStatusMessage('info', t('Deleting namespace...'));
await axios.delete(`/namespaces/rest/namespaces/${this.state.entityId}`);
await axios.delete(`/rest/namespaces/${this.state.entityId}`);
this.navigateToWithFlashMessage('/namespaces', 'success', t('Namespace deleted'));

View file

@ -25,7 +25,7 @@ export default class List extends Component {
<Title>{t('Namespaces')}</Title>
<TreeTable withHeader dataUrl="/namespaces/rest/namespaces-tree" actionLinks={actionLinks} />
<TreeTable withHeader dataUrl="/rest/namespaces-tree" actionLinks={actionLinks} />
</div>
);
}

View file

@ -14,17 +14,17 @@ const getStructure = t => ({
title: t('Home'),
externalLink: '/',
children: {
'namespaces': {
namespaces: {
title: t('Namespaces'),
link: '/namespaces',
component: List,
children: {
'edit' : {
edit : {
title: t('Edit Namespace'),
params: [':id', ':action?'],
render: props => (<CUD edit {...props} />)
},
'create' : {
create : {
title: t('Create Namespace'),
render: props => (<CUD {...props} />)
}

View file

@ -29,7 +29,7 @@ export default class CUD extends Component {
this.initForm({
serverValidation: {
url: '/users/rest/users-validate',
url: '/rest/users-validate',
changed: ['username', 'email'],
extra: ['id']
}
@ -42,7 +42,7 @@ export default class CUD extends Component {
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/users/rest/users/${this.state.entityId}`, data => {
await this.getFormValuesFromURL(`/rest/users/${this.state.entityId}`, data => {
data.password = '';
data.password2 = '';
});
@ -66,7 +66,6 @@ export default class CUD extends Component {
const t = this.props.t;
const edit = this.props.edit;
const username = state.getIn(['username', 'value']);
const usernameServerValidation = state.getIn(['username', 'serverValidation']);
@ -88,8 +87,6 @@ export default class CUD extends Component {
state.setIn(['email', 'error'], t('Email must not be empty'));
} else if (!emailServerValidation || emailServerValidation.invalid) {
state.setIn(['email', 'error'], t('Invalid email address.'));
} else if (!emailServerValidation || emailServerValidation.exists) {
state.setIn(['email', 'error'], t('The email is already associated with another user in the system.'));
} else {
state.setIn(['email', 'error'], null);
}
@ -107,6 +104,8 @@ export default class CUD extends Component {
const password = state.getIn(['password', 'value']) || '';
const password2 = state.getIn(['password2', 'value']) || '';
const passwordResults = this.passwordValidator.test(password);
let passwordMsgs = [];
if (!edit && !password) {
@ -114,7 +113,6 @@ export default class CUD extends Component {
}
if (password) {
const passwordResults = this.passwordValidator.test(password);
passwordMsgs.push(...passwordResults.errors);
}
@ -133,10 +131,10 @@ export default class CUD extends Component {
let sendMethod, url;
if (edit) {
sendMethod = FormSendMethod.PUT;
url = `/users/rest/users/${this.state.entityId}`
url = `/rest/users/${this.state.entityId}`
} else {
sendMethod = FormSendMethod.POST;
url = '/users/rest/users'
url = '/rest/users'
}
try {
@ -194,7 +192,7 @@ export default class CUD extends Component {
this.disableForm();
this.setFormStatusMessage('info', t('Deleting user...'));
await axios.delete(`/users/rest/users/${this.state.entityId}`);
await axios.delete(`/rest/users/${this.state.entityId}`);
this.navigateToWithFlashMessage('/users', 'success', t('User deleted'));
}

View file

@ -4,6 +4,7 @@ import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { Title, Toolbar, NavButton } from '../lib/page';
import { Table } from '../lib/table';
import mailtrainConfig from 'mailtrainConfig';
@translate()
export default class List extends Component {
@ -19,10 +20,13 @@ export default class List extends Component {
const columns = [
{ data: 0, title: "#" },
{ data: 1, title: "Username" },
{ data: 2, title: "Full Name" }
{ data: 1, title: "Username" }
];
if (mailtrainConfig.isAuthMethodLocal) {
columns.push({ data: 2, title: "Full Name" });
}
return (
<div>
<Toolbar>
@ -31,7 +35,7 @@ export default class List extends Component {
<Title>{t('Users')}</Title>
<Table withHeader dataUrl="/users/rest/users-table" columns={columns} actionLinks={actionLinks} />
<Table withHeader dataUrl="/rest/users-table" columns={columns} actionLinks={actionLinks} />
</div>
);
}

View file

@ -8,31 +8,39 @@ import i18n from '../lib/i18n';
import { Section } from '../lib/page'
import CUD from './CUD'
import List from './List'
import mailtrainConfig from 'mailtrainConfig';
const getStructure = t => ({
'': {
title: t('Home'),
externalLink: '/',
children: {
'users': {
title: t('Users'),
link: '/users',
component: List,
children: {
'edit' : {
title: t('Edit User'),
params: [':id', ':action?'],
render: props => (<CUD edit {...props} />)
},
'create' : {
title: t('Create User'),
render: props => (<CUD {...props} />)
}
const getStructure = t => {
const subPaths = {};
if (mailtrainConfig.isAuthMethodLocal) {
subPaths.edit = {
title: t('Edit User'),
params: [':id', ':action?'],
render: props => (<CUD edit {...props} />)
};
subPahts.create = {
title: t('Create User'),
render: props => (<CUD {...props} />)
};
}
return {
'': {
title: t('Home'),
externalLink: '/',
children: {
users: {
title: t('Users'),
link: '/users',
component: List,
children: subPaths
}
}
}
}
});
};
export default function() {
ReactDOM.render(

View file

@ -21,7 +21,8 @@ module.exports = {
},
externals: {
jquery: 'jQuery',
csfrToken: 'csfrToken'
csfrToken: 'csfrToken',
mailtrainConfig: 'mailtrainConfig'
},
plugins: [
// new webpack.optimize.UglifyJsPlugin(),