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

34
app.js
View file

@ -21,7 +21,6 @@ const passport = require('./lib/passport');
const tools = require('./lib/tools');
const routes = require('./routes/index');
const usersOld = require('./routes/users-legacy');
const lists = require('./routes/lists');
const settings = require('./routes/settings');
const settingsModel = require('./lib/models/settings');
@ -43,9 +42,13 @@ const mosaico = require('./routes/mosaico');
const reports = require('./routes/reports');
const reportsTemplates = require('./routes/report-templates');
const namespaces = require('./routes/namespaces');
const users = require('./routes/users');
const account = require('./routes/account');
const namespaces = require('./routes/rest/namespaces');
const users = require('./routes/rest/users');
const account = require('./routes/rest/account');
const namespacesLegacyIntegration = require('./routes/namespaces-legacy-integration');
const usersLegacyIntegration = require('./routes/users-legacy-integration');
const accountLegacyIntegration = require('./routes/account-legacy-integration');
const interoperableErrors = require('./shared/interoperable-errors');
@ -168,6 +171,7 @@ passport.setup(app);
app.use((req, res, next) => {
res.locals.flash = req.flash.bind(req);
res.locals.user = req.user;
res.locals.admin = req.user && req.user.id == 1; // FIXME, this should verify the admin privileges and set this accordingly
res.locals.ldap = {
enabled: config.ldap.enabled,
passwordresetlink: config.ldap.passwordresetlink
@ -209,7 +213,6 @@ app.use((req, res, next) => {
});
app.use('/', routes);
app.use('/users', usersOld);
app.use('/lists', lists);
app.use('/templates', templates);
app.use('/campaigns', campaigns);
@ -228,9 +231,24 @@ app.use('/editorapi', editorapi);
app.use('/grapejs', grapejs);
app.use('/mosaico', mosaico);
app.use('/namespaces', namespaces);
app.use('/users', users);
app.use('/account', account);
/* FIXME - this should be removed once we bind the ReactJS client to / */
app.use('/users', usersLegacyIntegration);
app.use('/namespaces', namespacesLegacyIntegration);
app.use('/account', accountLegacyIntegration);
/* ------------------------------------------------------------------- */
app.all('/rest/*', (req, res, next) => {
console.log('njr');
req.needsJSONResponse = true;
next();
});
app.use('/rest', namespaces);
app.use('/rest', users);
app.use('/rest', account);
if (config.reports && config.reports.enabled === true) {
app.use('/reports', reports);

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,15 +103,14 @@ export default class Account extends Component {
async submitHandler() {
const t = this.props.t;
try {
this.disableForm();
this.setFormStatusMessage('info', t('Updating user profile ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, '/account/rest/account', data => {
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();
@ -122,11 +122,43 @@ export default class Account extends Component {
} 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.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;
if (mailtrainConfig.isAuthMethodLocal) {
return (
<div>
<Title>{t('Account')}</Title>
@ -150,5 +182,12 @@ export default class Account extends Component {
</Form>
</div>
);
} else {
<div>
<Title>{t('Account')}</Title>
<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 => ({
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': {
account: {
title: t('Account'),
link: '/account',
component: 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);
}
}
}));
})
}));
inst.scheduleFormRevalidate = function() {
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.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 => ({
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': {
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} />)
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(),

View file

@ -119,6 +119,8 @@ baseDN="ou=users,dc=company"
filter="(|(username={{username}})(mail={{username}}))"
#Username field in LDAP (uid/cn/username)
uidTag="username"
# nameTag identifies the attribute to be used for user's full name
nameTag="username"
passwordresetlink=""
[postfixbounce]

26
lib/client-helpers.js Normal file
View file

@ -0,0 +1,26 @@
'use strict';
const passport = require('./passport');
function _getConfig() {
return {
authMethod: passport.authMethod,
isAuthMethodLocal: passport.isAuthMethodLocal
}
}
function registerRootRoute(router, title, entryPoint) {
router.get('/*', passport.csrfProtection, (req, res) => {
res.render('react-root', {
title,
reactEntryPoint: entryPoint,
reactCsrfToken: req.csrfToken(),
mailtrainConfig: JSON.stringify(_getConfig())
});
});
}
module.exports = {
registerRootRoute
};

View file

@ -39,52 +39,6 @@ module.exports.get = (id, callback) => {
});
};
module.exports.findByAccessToken = (accessToken, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT `id`, `username`, `email`, `access_token` FROM `users` WHERE `access_token`=? LIMIT 1', [accessToken], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (!rows.length) {
return callback(null, false);
}
let user = tools.convertKeys(rows[0]);
return callback(null, user);
});
});
};
module.exports.findByUsername = (username, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT `id`, `username`, `email`, `access_token` FROM `users` WHERE `username`=? LIMIT 1', [username], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (!rows.length) {
return callback(null, false);
}
let user = tools.convertKeys(rows[0]);
return callback(null, user);
});
});
};
module.exports.add = (username, password, email, callback) => {
db.getConnection((err, connection) => {
if (err) {

15
lib/nodeify.js Normal file
View file

@ -0,0 +1,15 @@
'use strict';
const nodeify = require('nodeify');
module.exports.nodeifyPromise = nodeify;
module.exports.nodeifyFunction = (asyncFun) => {
return (...args) => {
const callback = args.pop();
const promise = asyncFun(...args);
return module.exports.nodeifyPromise(promise, callback);
};
};

View file

@ -10,7 +10,10 @@ let LocalStrategy = require('passport-local').Strategy;
let csrf = require('csurf');
let bodyParser = require('body-parser');
let users = require('./models/users-legacy');
const users = require('../models/users');
const { nodeifyFunction, nodeifyPromise } = require('./nodeify');
const interoperableErrors = require('../shared/interoperable-errors');
let LdapStrategy;
try {
@ -30,28 +33,30 @@ module.exports.parseForm = bodyParser.urlencoded({
limit: config.www.postsize
});
module.exports.loggedIn = (req, res, next) => {
if (!req.user) {
next(new interoperableErrors.NotLoggedInError());
} else {
next();
}
};
module.exports.setup = app => {
app.use(passport.initialize());
app.use(passport.session());
};
module.exports.logout = (req, res) => {
if (req.user) {
req.flash('info', util.format(_('%s logged out'), req.user.username));
module.exports.restLogout = (req, res) => {
req.logout();
}
res.redirect('/');
res.json();
};
module.exports.login = (req, res, next) => {
module.exports.restLogin = (req, res, next) => {
passport.authenticate(config.ldap.enabled ? 'ldap' : 'local', (err, user, info) => {
if (err) {
req.flash('danger', err.message);
return next(err);
}
if (!user) {
req.flash('danger', info && info.message || _('Failed to authenticate user'));
return res.redirect('/users/login' + (req.body.next ? '?next=' + encodeURIComponent(req.body.next) : ''));
return next(new interoperableErrors.IncorrectPasswordError());
}
req.logIn(user, err => {
if (err) {
@ -66,14 +71,15 @@ module.exports.login = (req, res, next) => {
req.session.cookie.expires = false;
}
req.flash('success', util.format(_('Logged in as %s'), user.username));
return res.redirect(req.body.next || '/');
return res.json();
});
})(req, res, next);
};
if (config.ldap.enabled && LdapStrategy) {
log.info('Using LDAP auth');
module.exports.authMethod = 'ldap';
module.exports.isAuthMethodLocal = false;
let opts = {
server: {
@ -82,62 +88,55 @@ if (config.ldap.enabled && LdapStrategy) {
base: config.ldap.baseDN,
search: {
filter: config.ldap.filter,
attributes: [config.ldap.uidTag, 'mail'],
attributes: [config.ldap.uidTag, config.ldap.nameTag, 'mail'],
scope: 'sub'
},
uidTag: config.ldap.uidTag
};
passport.use(new LdapStrategy(opts, (profile, done) => {
users.findByUsername(profile[config.ldap.uidTag], (err, user) => {
if (err) {
return done(err);
}
passport.use(new LdapStrategy(opts, nodeifyFunction(async (profile) => {
try {
const user = await users.getByUsername(profile[config.ldap.uidTag]);
if (!user) {
// password is empty for ldap
users.add(profile[config.ldap.uidTag], '', profile.mail, (err, id) => {
if (err) {
return done(err);
}
return done(null, {
id,
username: profile[config.ldap.uidTag]
});
});
} else {
return done(null, {
return {
id: user.id,
username: user.username
username: user.username,
name: profile[config.ldap.nameTag],
email: profile.mail
};
} catch (err) {
if (err instanceof interoperableErrors.NotFoundError) {
const userId = await users.createExternal({
username: profile[config.ldap.uidTag],
});
return {
id: userId,
username: profile[config.ldap.uidTag],
name: profile[config.ldap.nameTag],
email: profile.mail
};
} else {
throw err;
}
});
}));
}
})));
passport.serializeUser((user, done) => { /* FIXME */ console.log(user); done(null, user); });
passport.deserializeUser((user, done) => done(null, user));
} else {
log.info('Using local auth');
module.exports.authMethod = 'local';
module.exports.isAuthMethodLocal = true;
passport.use(new LocalStrategy((username, password, done) => {
users.authenticate(username, password, (err, user) => {
if (err) {
return done(err);
passport.use(new LocalStrategy(nodeifyFunction(async (username, password) => {
return await users.getByUsernameIfPasswordMatch(username, password);
})));
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => nodeifyPromise(users.getById(id), done));
}
if (!user) {
return done(null, false, {
message: _('Incorrect username or password')
});
}
return done(null, user);
});
}));
}
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser((id, done) => {
users.get(id, done);
});

View file

@ -9,13 +9,14 @@ function replaceLastBySafeHandler(handlers) {
const lastHandler = handlers[handlers.length - 1];
const ret = handlers.slice();
ret[handlers.length - 1] = (req, res, next) => lastHandler(req, res).catch(error => next(error));
ret[handlers.length - 1] = (req, res, next) => lastHandler(req, res, next).catch(error => next(error));
return ret;
}
function create() {
const router = new express.Router();
router.allAsync = (path, ...handlers) => router.all(path, ...replaceLastBySafeHandler(handlers));
router.getAsync = (path, ...handlers) => router.get(path, ...replaceLastBySafeHandler(handlers));
router.postAsync = (path, ...handlers) => router.post(path, ...replaceLastBySafeHandler(handlers));
router.putAsync = (path, ...handlers) => router.put(path, ...replaceLastBySafeHandler(handlers));

View file

@ -2,7 +2,6 @@
const _ = require('./translate')._;
const util = require('util');
const Promise = require('bluebird');
const isemail = require('isemail')
module.exports = {

View file

@ -21,8 +21,6 @@ async function getById(nsId) {
throw new interoperableErrors.NotFoundError();
}
ns.hash = hash(ns);
return ns;
}

44
models/settings.js Normal file
View file

@ -0,0 +1,44 @@
'use strict';
'use strict';
const knex = require('../lib/knex');
const tools = require('../lib/tools');
async function get(keyOrKeys) {
let keys;
if (!Array.isArray(keyOrKeys)) {
keys = [ keys ];
} else {
keys = keyOrKeys;
}
keys = keys.map(key => tools.toDbKey(key));
const result = await knex('settings').whereIn('key', keys);
const settings = {};
for (const key of keys) {
settings[tools.fromDbKey(key)] = result[key];
}
if (!Array.isArray(keyOrKeys)) {
return settings[keyOrKeys];
} else {
return settings;
}
}
async function set(key, value) {
try {
await knex('settings').insert({key, value});
} catch (err) {
await knex('settings').where('key', key).update('value', value);
}
}
module.exports = {
get,
set
};

View file

@ -8,31 +8,49 @@ const passwordValidator = require('../shared/password-validator')();
const validators = require('../shared/validators');
const dtHelpers = require('../lib/dt-helpers');
const tools = require('../lib/tools-async');
const Promise = require('bluebird');
let crypto = require('crypto');
const settings = require('./settings');
const bluebird = require('bluebird');
const bcrypt = require('bcrypt-nodejs');
const bcryptHash = Promise.promisify(bcrypt.hash);
const bcryptCompare = Promise.promisify(bcrypt.compare);
const bcryptHash = bluebird.promisify(bcrypt.hash);
const bcryptCompare = bluebird.promisify(bcrypt.compare);
const mailer = require('../lib/mailer');
const mailerSendMail = bluebird.promisify(mailer.sendMail);
const allowedKeys = new Set(['username', 'name', 'email', 'password']);
const allowedKeysExternal = new Set(['username']);
const ownAccountAllowedKeys = new Set(['name', 'email', 'password']);
const passport = require('../../lib/passport');
function hash(user) {
return hasher.hash(filterObject(user, allowedKeys));
}
async function getById(userId) {
const user = await knex('users').select(['id', 'username', 'name', 'email', 'password']).where('id', userId).first();
async function _getBy(key, value, extraColumns) {
const columns = ['id', 'username', 'name', 'email'];
if (extraColumns) {
columns.push(...extraColumns);
}
const user = await knex('users').select(columns).where(key, value).first();
if (!user) {
throw new interoperableErrors.NotFoundError();
}
user.hash = hash(user);
delete(user.password);
return user;
}
async function getById(userId) {
return await _getBy('id', userId);
}
async function serverValidate(data, isOwnAccount) {
const result = {};
@ -123,8 +141,9 @@ async function _validateAndPreprocess(tx, user, isCreate, isOwnAccount) {
}
}
async function create(user) {
enforce(passport.isAuthMethodLocal, 'Local user management is required');
await knex.transaction(async tx => {
await _validateAndPreprocess(tx, user, true);
const userId = await tx('users').insert(filterObject(user, allowedKeys));
@ -132,7 +151,16 @@ async function create(user) {
});
}
async function createExternal(user) {
enforce(!passport.isAuthMethodLocal, 'External user management is required');
const userId = await knex('users').insert(filterObject(user, allowedKeysExternal));
return userId;
}
async function updateWithConsistencyCheck(user, isOwnAccount) {
enforce(passport.isAuthMethodLocal, 'Local user management is required');
await knex.transaction(async tx => {
await _validateAndPreprocess(tx, user, false, isOwnAccount);
@ -147,13 +175,8 @@ async function updateWithConsistencyCheck(user, isOwnAccount) {
}
if (isOwnAccount && user.password) {
console.log(user.currentPassword);
console.log(existingUser.password);
if (!await bcryptCompare(user.currentPassword, existingUser.password)) {
// This is not an interoperable error because current password is verified in account-validate.
// A change of password between account-validate and submit would be signalled by ChangedError
throw new Error('Incorrect password');
throw new interoperableErrors.IncorrectPasswordError();
}
}
@ -162,17 +185,139 @@ async function updateWithConsistencyCheck(user, isOwnAccount) {
}
async function remove(userId) {
enforce(passport.isAuthMethodLocal, 'Local user management is required');
// FIXME: enforce that userId is not the current user
enforce(userId !== 1, 'Admin cannot be deleted');
await knex('users').where('id', userId).del();
}
async function getByAccessToken(accessToken) {
return await _getBy('access_token', accessToken);
}
async function getByUsername(username) {
return await _getBy('username', username);
}
async function getByUsernameIfPasswordMatch(username, password) {
const user = await _getBy('username', username, ['password']);
if (!await bcryptCompare(password, user.password)) {
throw new interoperableErrors.IncorrectPasswordError();
}
return user;
}
async function getAccessToken(userId) {
const user = await _getBy('id', userId, ['access_token']);
return user.access_token;
}
async function resetAccessToken(userId) {
const token = crypto.randomBytes(20).toString('hex').toLowerCase();
const affectedRows = await knex('users').where({id: userId}).update({access_token: token});
if (!affectedRows) {
throw new interoperableErrors.NotFoundError();
}
return token;
}
async function sendPasswordReset(usernameOrEmail) {
enforce(passport.isAuthMethodLocal, 'Local user management is required');
await knex.transaction(async tx => {
const user = await tx('users').where('username', usernameOrEmail).orWhere('email', usernameOrEmail).select(['id', 'username', 'email', 'name']).first();
if (user) {
const resetToken = crypto.randomBytes(16).toString('base64').replace(/[^a-z0-9]/gi, '');
await tx('users').where('id', user.id).update({
reset_token: resetToken,
reset_expire: new Date(Date.now() + 60 * 60 * 1000)
});
const { serviceUrl, adminEmail } = await settings.get(['serviceUrl', 'adminEmail']);
await mailer.sendMail({
from: {
address: adminEmail
},
to: {
address: user.email
},
subject: _('Mailer password change request')
}, {
html: 'emails/password-reset-html.hbs',
text: 'emails/password-reset-text.hbs',
data: {
title: 'Mailtrain',
username: user.username,
name: user.name,
confirmUrl: urllib.resolve(serviceUrl, `/account/reset-link/${encodeURIComponent(user.username)}/${encodeURIComponent(resetToken)}`)
}
});
}
// We intentionally silently ignore the situation when user is not found. This is not to reveal if a user exists in the system.
});
}
async function isPasswordResetTokenValid(username, resetToken) {
enforce(passport.isAuthMethodLocal, 'Local user management is required');
const user = await knex('users').select(['id']).where({username, reset_token: resetToken}).andWhere('reset_expire', '>', new Date()).first();
return !!user;
}
async function resetPassword(username, resetToken, password) {R
enforce(passport.isAuthMethodLocal, 'Local user management is required');
await knex.transaction(async tx => {
const user = await tx('users').select(['id']).where({
username,
reset_token: resetToken
}).andWhere('reset_expire', '>', new Date()).first();
if (user) {
const passwordValidatorResults = passwordValidator.test(password);
if (passwordValidatorResults.errors.length > 0) {
// This is not an interoperable error because this is not supposed to happen unless the client is tampered with.
throw new Error('Invalid password');
}
password = await bcryptHash(password, null, null);
await tx('users').where({username}).update({
password,
reset_token: null,
reset_expire: null
});
} else {
throw new interoperableErrors.InvalidToken();
}
});
}
module.exports = {
listDTAjax,
remove,
updateWithConsistencyCheck,
create,
createExternal,
hash,
getById,
serverValidate
serverValidate,
getByAccessToken,
getByUsername,
getByUsernameIfPasswordMatch,
getAccessToken,
resetAccessToken,
sendPasswordReset,
isPasswordResetTokenValid,
resetPassword
};

View file

@ -97,6 +97,7 @@
"node-gettext": "^2.0.0-rc.1",
"node-mocks-http": "^1.6.1",
"node-object-hash": "^1.2.0",
"nodeify": "^1.0.1",
"nodemailer": "^4.0.1",
"nodemailer-openpgp": "^1.0.2",
"npmlog": "^4.0.2",

View file

@ -0,0 +1,15 @@
'use strict';
const _ = require('../lib/translate')._;
const clientHelpers = require('../lib/client-helpers');
const router = require('../lib/router-async').create();
router.get('/logout', (req, res) => {
req.logout();
res.redirect('/');
});
clientHelpers.registerRootRoute(router, _('Account'), 'account');
module.exports = router;

View file

@ -1,59 +0,0 @@
'use strict';
const passport = require('../lib/passport');
const router = require('../lib/router-async').create();
const _ = require('../lib/translate')._;
const users = require('../models/users');
const interoperableErrors = require('../shared/interoperable-errors');
router.all('/rest/*', (req, res, next) => {
req.needsJSONResponse = true;
if (!req.user) {
throw new interoperableErrors.NotLoggedInError();
}
next();
});
router.getAsync('/rest/account', async (req, res) => {
const user = await users.getById(req.user.id);
return res.json(user);
});
router.postAsync('/rest/account', passport.csrfProtection, async (req, res) => {
const data = req.body;
data.id = req.user.id;
await users.updateWithConsistencyCheck(req.body, true);
return res.json();
});
router.postAsync('/rest/account-validate', async (req, res) => {
const data = req.body;
data.id = req.user.id;
return res.json(await users.serverValidate(data, true));
});
router.all('/*', (req, res, next) => {
if (!req.user) {
req.flash('danger', _('Need to be logged in to access restricted content'));
return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl));
}
// res.setSelectedMenu('users'); FIXME
next();
});
router.getAsync('/*', passport.csrfProtection, async (req, res) => {
res.render('react-root', {
title: _('Account'),
reactEntryPoint: 'account',
reactCsrfToken: req.csrfToken()
});
});
module.exports = router;

View file

@ -1,18 +1,18 @@
'use strict';
let users = require('../lib/models/users-legacy');
let users = require('../models/users');
let lists = require('../lib/models/lists');
let fields = require('../lib/models/fields');
let blacklist = require('../lib/models/blacklist');
let subscriptions = require('../lib/models/subscriptions');
let confirmations = require('../lib/models/confirmations');
let tools = require('../lib/tools');
let express = require('express');
let log = require('npmlog');
let router = new express.Router();
const router = require('../lib/router-async').create();
let mailHelpers = require('../lib/subscription-mail-helpers');
const interoperableErrors = require('../shared/interoperable-errors');
router.all('/*', (req, res, next) => {
router.allAsync('/*', async (req, res, next) => {
if (!req.query.access_token) {
res.status(403);
return res.json({
@ -21,24 +21,24 @@ router.all('/*', (req, res, next) => {
});
}
users.findByAccessToken(req.query.access_token, (err, user) => {
if (err) {
try {
await users.getByAccessToken(req.query.access_token);
next();
} catch (err) {
if (err instanceof interoperableErrors.NotFoundError) {
res.status(403);
return res.json({
error: 'Invalid or expired access_token',
data: []
});
} else {
res.status(500);
return res.json({
error: err.message || err,
data: []
});
}
if (!user) {
res.status(403);
return res.json({
error: 'Invalid or expired access_token',
data: []
});
}
next();
});
});
router.post('/subscribe/:listId', (req, res) => {

View file

@ -0,0 +1,10 @@
'use strict';
const _ = require('../lib/translate')._;
const clientHelpers = require('../lib/client-helpers');
const router = require('../lib/router-async').create();
clientHelpers.registerRootRoute(router, _('Namespaces'), 'namespaces');
module.exports = router;

View file

@ -1,95 +0,0 @@
'use strict';
const passport = require('../lib/passport');
const router = require('../lib/router-async').create();
const _ = require('../lib/translate')._;
const namespaces = require('../models/namespaces');
const interoperableErrors = require('../shared/interoperable-errors');
router.all('/rest/*', (req, res, next) => {
req.needsJSONResponse = true;
if (!req.user) {
throw new interoperableErrors.NotLoggedInError();
}
next();
});
router.getAsync('/rest/namespaces/:nsId', async (req, res) => {
const ns = await namespaces.getById(req.params.nsId);
return res.json(ns);
});
router.postAsync('/rest/namespaces', passport.csrfProtection, async (req, res) => {
await namespaces.create(req.body);
return res.json();
});
router.putAsync('/rest/namespaces/:nsId', passport.csrfProtection, async (req, res) => {
const ns = req.body;
ns.id = parseInt(req.params.nsId);
await namespaces.updateWithConsistencyCheck(ns);
return res.json();
});
router.deleteAsync('/rest/namespaces/:nsId', passport.csrfProtection, async (req, res) => {
await namespaces.remove(req.params.nsId);
return res.json();
});
router.getAsync('/rest/namespaces-tree', async (req, res) => {
const entries = {};
let root; // Only the Root namespace is without a parent
const rows = await namespaces.list();
for (let row of rows) {
let entry;
if (!entries[row.id]) {
entry = {
children: []
};
entries[row.id] = entry;
} else {
entry = entries[row.id];
}
if (row.parent) {
if (!entries[row.parent]) {
entries[row.parent] = {
children: []
};
}
entries[row.parent].children.push(entry);
} else {
root = entry;
}
entry.title = row.name;
entry.key = row.id;
}
return res.json(root);
});
router.all('/*', (req, res, next) => {
if (!req.user) {
req.flash('danger', _('Need to be logged in to access restricted content'));
return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl));
}
// res.setSelectedMenu('namespaces'); FIXME
next();
});
router.getAsync('/*', passport.csrfProtection, async (req, res) => {
res.render('react-root', {
title: _('Namespaces'),
reactEntryPoint: 'namespaces',
reactCsrfToken: req.csrfToken()
});
});
module.exports = router;

61
routes/rest/account.js Normal file
View file

@ -0,0 +1,61 @@
'use strict';
const passport = require('../../lib/passport');
const _ = require('../../lib/translate')._;
const users = require('../../models/users');
const router = require('../../lib/router-async').create();
router.getAsync('/account', passport.loggedIn, async (req, res) => {
const user = await users.getById(req.user.id);
return res.json(user);
});
router.postAsync('/account', passport.loggedIn, passport.csrfProtection, async (req, res) => {
const data = req.body;
data.id = req.user.id;
await users.updateWithConsistencyCheck(req.body, true);
return res.json();
});
router.postAsync('/account-validate', passport.loggedIn, async (req, res) => {
const data = req.body;
data.id = req.user.id;
return res.json(await users.serverValidate(data, true));
});
router.getAsync('/access-token', passport.loggedIn, async (req, res) => {
const accessToken = await users.getAccessToken(req.user.id);
return res.json(accessToken);
});
router.postAsync('/access-token-reset', passport.loggedIn, passport.csrfProtection, async (req, res) => {
const accessToken = await users.resetAccessToken(req.user.id);
return res.json(accessToken);
});
router.post('/login', passport.restLogin);
router.post('/logout', passport.restLogout); // TODO - this endpoint is currently not in use. It will become relevant once we switch to SPA
router.postAsync('/password-reset-send', async (req, res) => {
await users.sendPasswordReset(req.body.username);
return res.json();
});
router.getAsync('/password-reset-validate', async (req, res) => {
const isValid = await users.isPasswordResetTokenValid(req.body.username, req.body.resetToken);
return res.json(isValid);
})
router.getAsync('/password-reset', async (req, res) => {
await users.resetPassword(req.body.username, req.body.resetToken, req.body.password);
return res.json();
})
module.exports = router;

74
routes/rest/namespaces.js Normal file
View file

@ -0,0 +1,74 @@
'use strict';
const passport = require('../../lib/passport');
const _ = require('../../lib/translate')._;
const namespaces = require('../../models/namespaces');
const interoperableErrors = require('../../shared/interoperable-errors');
const router = require('../../lib/router-async').create();
router.getAsync('/namespaces/:nsId', passport.loggedIn, async (req, res) => {
const ns = await namespaces.getById(req.params.nsId);
ns.hash = namespaces.hash(ns);
return res.json(ns);
});
router.postAsync('/namespaces', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await namespaces.create(req.body);
return res.json();
});
router.putAsync('/namespaces/:nsId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
const ns = req.body;
ns.id = parseInt(req.params.nsId);
await namespaces.updateWithConsistencyCheck(ns);
return res.json();
});
router.deleteAsync('/namespaces/:nsId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await namespaces.remove(req.params.nsId);
return res.json();
});
router.getAsync('/namespaces-tree', passport.loggedIn, async (req, res) => {
const entries = {};
let root; // Only the Root namespace is without a parent
const rows = await namespaces.list();
for (let row of rows) {
let entry;
if (!entries[row.id]) {
entry = {
children: []
};
entries[row.id] = entry;
} else {
entry = entries[row.id];
}
if (row.parent) {
if (!entries[row.parent]) {
entries[row.parent] = {
children: []
};
}
entries[row.parent].children.push(entry);
} else {
root = entry;
}
entry.title = row.name;
entry.key = row.id;
}
return res.json(root);
});
module.exports = router;

44
routes/rest/users.js Normal file
View file

@ -0,0 +1,44 @@
'use strict';
const passport = require('../../lib/passport');
const _ = require('../../lib/translate')._;
const users = require('../../models/users');
const interoperableErrors = require('../../shared/interoperable-errors');
const router = require('../../lib/router-async').create();
router.getAsync('/users/:userId', passport.loggedIn, async (req, res) => {
const user = await users.getById(req.params.userId);
user.hash = users.hash(user);
return res.json(user);
});
router.postAsync('/users', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await users.create(req.body);
return res.json();
});
router.putAsync('/users/:userId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
const user = req.body;
user.id = parseInt(req.params.userId);
await users.updateWithConsistencyCheck(user);
return res.json();
});
router.deleteAsync('/users/:userId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await users.remove(req.params.userId);
return res.json();
});
router.postAsync('/users-validate', passport.loggedIn, async (req, res) => {
return res.json(await users.serverValidate(req.body));
});
router.postAsync('/users-table', passport.loggedIn, async (req, res) => {
return res.json(await users.listDTAjax(req.body));
});
module.exports = router;

View file

@ -3,7 +3,7 @@
let passport = require('../lib/passport');
let express = require('express');
let router = new express.Router();
let users = require('../lib/models/users-legacy');
let users = require('../lib/models/users-legacy-REMOVE');
let fields = require('../lib/models/fields');
let settings = require('../lib/models/settings');
let _ = require('../lib/translate')._;
@ -115,33 +115,4 @@ router.post('/api/reset-token', passport.parseForm, passport.csrfProtection, (re
});
});
router.all('/account', (req, res, next) => {
if (!req.user) {
req.flash('danger', _('Need to be logged in to access restricted content'));
return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl));
}
next();
});
router.get('/account', passport.csrfProtection, (req, res) => {
let data = {
csrfToken: req.csrfToken(),
email: req.user.email
};
res.render('users/account', data);
});
router.post('/account', passport.parseForm, passport.csrfProtection, (req, res) => {
users.update(Number(req.user.id), req.body, (err, success) => {
if (err) {
req.flash('danger', err.message || err);
} else if (success) {
req.flash('success', _('Account information updated'));
} else {
req.flash('info', _('Account information not updated'));
}
return res.redirect('/users/account');
});
});
module.exports = router;

View file

@ -0,0 +1,10 @@
'use strict';
const _ = require('../lib/translate')._;
const clientHelpers = require('../lib/client-helpers');
const router = require('../lib/router-async').create();
clientHelpers.registerRootRoute(router, _('Users'), 'users');
module.exports = router;

View file

@ -1,70 +0,0 @@
'use strict';
const passport = require('../lib/passport');
const router = require('../lib/router-async').create();
const _ = require('../lib/translate')._;
const users = require('../models/users');
const interoperableErrors = require('../shared/interoperable-errors');
router.all('/rest/*', (req, res, next) => {
req.needsJSONResponse = true;
if (!req.user) {
throw new interoperableErrors.NotLoggedInError();
}
next();
});
router.getAsync('/rest/users/:userId', async (req, res) => {
const user = await users.getById(req.params.userId);
return res.json(user);
});
router.postAsync('/rest/users', passport.csrfProtection, async (req, res) => {
await users.create(req.body);
return res.json();
});
router.putAsync('/rest/users/:userId', passport.csrfProtection, async (req, res) => {
const user = req.body;
user.id = parseInt(req.params.userId);
await users.updateWithConsistencyCheck(user);
return res.json();
});
router.deleteAsync('/rest/users/:userId', passport.csrfProtection, async (req, res) => {
await users.remove(req.params.userId);
return res.json();
});
router.postAsync('/rest/users-validate', async (req, res) => {
return res.json(await users.serverValidate(req.body));
});
router.postAsync('/rest/users-table', async (req, res) => {
return res.json(await users.listDTAjax(req.body));
});
router.all('/*', (req, res, next) => {
if (!req.user) {
req.flash('danger', _('Need to be logged in to access restricted content'));
return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl));
}
// res.setSelectedMenu('users'); FIXME
next();
});
router.getAsync('/*', passport.csrfProtection, async (req, res) => {
res.render('react-root', {
title: _('Users'),
reactEntryPoint: 'users',
reactCsrfToken: req.csrfToken()
});
});
module.exports = router;

View file

@ -1,6 +1,8 @@
exports.up = function(knex, Promise) {
return knex.schema.table('users', table => {
table.string('name').notNullable().default('');
// name and password can be null in case of LDAP login
table.string('name');
table.string('password').alter();
})
.then(() => knex('users').where('id', 1).update({
name: 'Administrator'

View file

@ -50,6 +50,18 @@ class DuplicitEmailError extends InteroperableError {
}
}
class IncorrectPasswordError extends InteroperableError {
constructor(msg, data) {
super('IncorrectPasswordError', msg, data);
}
}
class InvalidToken extends InteroperableError {
constructor(msg, data) {
super('InvalidToken', msg, data);
}
}
const errorTypes = {
InteroperableError,
NotLoggedInError,
@ -58,7 +70,9 @@ const errorTypes = {
LoopDetectedError,
ChildDetectedError,
DuplicitNameError,
DuplicitEmailError
DuplicitEmailError,
IncorrectPasswordError,
InvalidToken
};
function deserialize(errorObj) {

View file

@ -1,7 +1,5 @@
'use strict';
const Promise = require('bluebird');
class WorkerCounter {
constructor() {
this.counter = 0;

View file

@ -24,6 +24,7 @@
<script>
window.csfrToken = '{{reactCsrfToken}}';
window.mailtrainConfig = {{mailtrainConfig}};
</script>
<script src="/mailtrain/{{reactEntryPoint}}.js"></script>
@ -78,19 +79,12 @@
<li><a href="https://mailtrain.wordpress.com/"><span class="glyphicon glyphicon-share-alt" aria-hidden="true"></span> {{#translate}}Blog{{/translate}}</a></li>
</ul>
{{#if user }}
<ul class="nav navbar-nav navbar-right">
{{#if admin }}
<ul class="nav navbar-nav">
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<span class="glyphicon glyphicon-user" aria-hidden="true"></span> {{user.username}} <span class="caret"></span>
</a>
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Administration<span class="caret"></span></a>
<ul class="dropdown-menu">
<li>
<a href="/users/account">
<span class="glyphicon glyphicon-user" aria-hidden="true"></span> {{#translate}}Account{{/translate}}
</a>
</li>
<li>
<a href="/settings">
<span class="glyphicon glyphicon-cog" aria-hidden="true"></span> {{#translate}}Settings{{/translate}}
@ -102,12 +96,30 @@
</a>
</li>
<li>
<a href="/users/api">
<a href="/account/api">
<span class="glyphicon glyphicon-retweet" aria-hidden="true"></span> {{#translate}}API{{/translate}}
</a>
</li>
</ul>
</li>
</ul>
{{/if}}
{{#if user }}
<ul class="nav navbar-nav navbar-right">
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<span class="glyphicon glyphicon-user" aria-hidden="true"></span> {{user.username}} <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li>
<a href="/users/logout">
<a href="/account">
<span class="glyphicon glyphicon-user" aria-hidden="true"></span> {{#translate}}Account{{/translate}}
</a>
</li>
<li>
<a href="/account/logout">
<span class="glyphicon glyphicon-log-out" aria-hidden="true"></span> {{#translate}}Log out{{/translate}}
</a>
</li>
@ -119,7 +131,7 @@
<ul class="nav navbar-nav navbar-right">
<li>
<a href="/users/login" role="button" aria-haspopup="true" aria-expanded="false">
<a href="/account/login" role="button" aria-haspopup="true" aria-expanded="false">
<span class="glyphicon glyphicon-log-in" aria-hidden="true"></span> {{#translate}}Sign in{{/translate}}
</a>
</li>

View file

@ -1,72 +0,0 @@
<ol class="breadcrumb">
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
<li class="active">{{#translate}}Account{{/translate}}</li>
</ol>
<h2>{{#translate}}Account{{/translate}}</h2>
<hr>
{{#if ldap.enabled}}
<p>
{{#translate}}This account is managed through LDAP.{{/translate}}
<br/>
<br/> {{#translate}}Associated Email Address{{/translate}}: <a href="mailto:{{email}}">{{email}}</a>
</p>
{{else}}
<form class="form-horizontal" method="post" action="/users/account">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<fieldset>
<legend>
{{#translate}}General Settings{{/translate}}
</legend>
<div class="form-group">
<label for="email" class="col-sm-2 control-label">{{#translate}}Email Address{{/translate}}</label>
<div class="col-sm-10">
<input type="email" class="form-control" name="email" id="email" value="{{email}}" placeholder="{{#translate}}Your e-mail address{{/translate}}" required>
<span class="help-block">{{#translate}}This address is used for account recovery in case you loose your password{{/translate}}</span>
</div>
</div>
</fieldset>
<fieldset>
<legend>
{{#translate}}Password change{{/translate}}
</legend>
<p>
{{#translate}}You only need to fill out this form if you want to change your current password{{/translate}}
</p>
<div class="form-group">
<label for="currentPassword" class="col-sm-2 control-label">{{#translate}}Current Password{{/translate}}</label>
<div class="col-sm-10">
<input type="password" class="form-control" name="currentPassword" id="currentPassword" placeholder="{{#translate}}Current Password{{/translate}}">
</div>
</div>
<div class="form-group">
<label for="password" class="col-sm-2 control-label">{{#translate}}New Password{{/translate}}</label>
<div class="col-sm-10">
<input type="password" class="form-control" name="password" id="password" placeholder="{{#translate}}New Password{{/translate}}">
</div>
</div>
<div class="form-group">
<label for="password2" class="col-sm-2 control-label">{{#translate}}Confirm Password{{/translate}}</label>
<div class="col-sm-10">
<input type="password" class="form-control" name="password2" id="password2" placeholder="{{#translate}}Confirm New Password{{/translate}}">
</div>
</div>
</fieldset>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-ok"></i> {{#translate}}Update{{/translate}}</button>
</div>
</div>
</form>
{{/if}}