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 tools = require('./lib/tools');
const routes = require('./routes/index'); const routes = require('./routes/index');
const usersOld = require('./routes/users-legacy');
const lists = require('./routes/lists'); const lists = require('./routes/lists');
const settings = require('./routes/settings'); const settings = require('./routes/settings');
const settingsModel = require('./lib/models/settings'); const settingsModel = require('./lib/models/settings');
@ -43,9 +42,13 @@ const mosaico = require('./routes/mosaico');
const reports = require('./routes/reports'); const reports = require('./routes/reports');
const reportsTemplates = require('./routes/report-templates'); const reportsTemplates = require('./routes/report-templates');
const namespaces = require('./routes/namespaces'); const namespaces = require('./routes/rest/namespaces');
const users = require('./routes/users'); const users = require('./routes/rest/users');
const account = require('./routes/account'); 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'); const interoperableErrors = require('./shared/interoperable-errors');
@ -168,6 +171,7 @@ passport.setup(app);
app.use((req, res, next) => { app.use((req, res, next) => {
res.locals.flash = req.flash.bind(req); res.locals.flash = req.flash.bind(req);
res.locals.user = req.user; 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 = { res.locals.ldap = {
enabled: config.ldap.enabled, enabled: config.ldap.enabled,
passwordresetlink: config.ldap.passwordresetlink passwordresetlink: config.ldap.passwordresetlink
@ -209,7 +213,6 @@ app.use((req, res, next) => {
}); });
app.use('/', routes); app.use('/', routes);
app.use('/users', usersOld);
app.use('/lists', lists); app.use('/lists', lists);
app.use('/templates', templates); app.use('/templates', templates);
app.use('/campaigns', campaigns); app.use('/campaigns', campaigns);
@ -228,9 +231,24 @@ app.use('/editorapi', editorapi);
app.use('/grapejs', grapejs); app.use('/grapejs', grapejs);
app.use('/mosaico', mosaico); app.use('/mosaico', mosaico);
app.use('/namespaces', namespaces); /* FIXME - this should be removed once we bind the ReactJS client to / */
app.use('/users', users); app.use('/users', usersLegacyIntegration);
app.use('/account', account); 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) { if (config.reports && config.reports.enabled === true) {
app.use('/reports', reports); app.use('/reports', reports);

View file

@ -29,7 +29,8 @@
"react": "^15.5.4", "react": "^15.5.4",
"react-dom": "^15.5.4", "react-dom": "^15.5.4",
"react-i18next": "^4.1.0", "react-i18next": "^4.1.0",
"react-router-dom": "^4.1.1" "react-router-dom": "^4.1.1",
"url-parse": "^1.1.9"
}, },
"devDependencies": { "devDependencies": {
"babel-cli": "^6.24.1", "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'; } from '../lib/form';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'; import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import passwordValidator from '../../../shared/password-validator'; import passwordValidator from '../../../shared/password-validator';
import mailtrainConfig from 'mailtrainConfig';
@translate() @translate()
@withForm @withForm
@ -23,15 +24,15 @@ export default class Account extends Component {
this.initForm({ this.initForm({
serverValidation: { serverValidation: {
url: '/account/rest/account-validate', url: '/rest/account-validate',
changed: ['email', 'password', 'currentPassword'] changed: ['email', 'username', 'currentPassword']
} }
}); });
} }
@withAsyncErrorHandler @withAsyncErrorHandler
async loadFormValues() { async loadFormValues() {
await this.getFormValuesFromURL(`/account/rest/account`, data => { await this.getFormValuesFromURL('/rest/account', data => {
data.password = ''; data.password = '';
data.password2 = ''; data.password2 = '';
data.currentPassword = ''; data.currentPassword = '';
@ -102,53 +103,91 @@ export default class Account extends Component {
async submitHandler() { async submitHandler() {
const t = this.props.t; const t = this.props.t;
this.disableForm(); try {
this.setFormStatusMessage('info', t('Updating user profile ...')); 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; 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.clearFormStatusMessage();
this.setFlashMessage('success', t('User profile updated')); } else {
this.hideFormValidation(); this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
this.updateFormValue('password', ''); }
this.updateFormValue('password2', ''); } catch (error) {
this.updateFormValue('currentPassword', ''); if (error instanceof interoperableErrors.IncorrectPasswordError) {
this.enableForm();
this.clearFormStatusMessage(); this.setFormStatusMessage('danger',
} else { <span>
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.')); <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() { render() {
const t = this.props.t; 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> <div>
<Title>{t('Account')}</Title> <Title>{t('Account')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}> <p>Account management is not possible because Mailtrain is configured to use externally managed users.</p>
<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> </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 { Section } from '../lib/page'
import Account from './Account' 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 => {
title: t('Home'), const subPaths = {
externalLink: '/', 'login': {
children: { title: t('Sign in'),
'account': { link: '/account/login',
title: t('Account'), component: Login,
link: '/account', },
component: Account '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() { export default function() {
ReactDOM.render( 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() @translate()
@withErrorHandling @withErrorHandling
class ModalDialog extends Component { class ModalDialog extends Component {
@ -184,6 +210,7 @@ class ModalDialog extends Component {
export { export {
Button, Button,
ActionLink,
DismissibleAlert, DismissibleAlert,
ModalDialog 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 { class InputField extends Component {
static propTypes = { static propTypes = {
id: PropTypes.string.isRequired, 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 { class TextArea extends Component {
static propTypes = { static propTypes = {
id: PropTypes.string.isRequired, 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() { render() {
return ( return (
<div className="form-group"> <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} {this.props.children}
</div> </div>
</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 @withErrorHandling
class Button extends Component { class Button extends Component {
static propTypes = { static propTypes = {
onClickAsync: PropTypes.func, onClickAsync: PropTypes.func,
onClick: PropTypes.func,
label: PropTypes.string, label: PropTypes.string,
icon: PropTypes.string, icon: PropTypes.string,
className: PropTypes.string, className: PropTypes.string,
@ -215,12 +268,7 @@ class Button extends Component {
@withAsyncErrorHandler @withAsyncErrorHandler
async onClick(evt) { async onClick(evt) {
if (this.props.onClick) { if (this.props.onClickAsync) {
evt.preventDefault();
onClick(evt);
} else if (this.props.onClickAsync) {
evt.preventDefault(); evt.preventDefault();
this.context.formStateOwner.disableForm(); this.context.formStateOwner.disableForm();
@ -255,6 +303,7 @@ class Button extends Component {
} }
} }
class TreeTableSelect extends Component { class TreeTableSelect extends Component {
static propTypes = { static propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
@ -297,6 +346,93 @@ function withForm(target) {
isServerValidationRunning: false 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) { inst.initForm = function(settings) {
const state = this.state || {}; const state = this.state || {};
state.formState = cleanFormState; state.formState = cleanFormState;
@ -345,12 +481,15 @@ function withForm(target) {
mutator(data); mutator(data);
} }
let response;
if (method === FormSendMethod.PUT) { if (method === FormSendMethod.PUT) {
await axios.put(url, data); response = await axios.put(url, data);
} else if (method === FormSendMethod.POST) { } else if (method === FormSendMethod.POST) {
await axios.post(url, data); response = await axios.post(url, data);
} }
return true;
return response.data || true;
} else { } else {
this.showFormValidation(); this.showFormValidation();
return false; 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() { inst.waitForFormServerValidated = async function() {
if (!this.isFormServerValidated()) { if (!this.isFormServerValidated()) {
await new Promise(resolve => { formValidateResolve = resolve; }); await new Promise(resolve => { formValidateResolve = resolve; });
} }
}; };
inst.validateForm = function(mutState) { inst.scheduleFormRevalidate = function() {
const settings = this.state.formSettings; scheduleValidateForm(this);
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.updateFormValue = function(key, value) { inst.updateFormValue = function(key, value) {
this.setState(previousState => ({ this.setState(previousState => ({
formState: previousState.formState.withMutations(mutState => { formState: previousState.formState.withMutations(mutState => {
mutState.setIn(['data', key, 'value'], value); mutState.setIn(['data', key, 'value'], value);
this.validateForm(mutState); validateFormState(this, mutState);
}) })
})); }));
}; };
@ -590,7 +645,9 @@ export {
Form, Form,
Fieldset, Fieldset,
InputField, InputField,
CheckBox,
TextArea, TextArea,
AlignedRow,
ButtonRow, ButtonRow,
Button, Button,
TreeTableSelect, TreeTableSelect,

View file

@ -202,7 +202,8 @@ class SectionContent extends Component {
errorHandler(error) { errorHandler(error) {
if (error instanceof interoperableErrors.NotLoggedInError) { 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) { } else if (error.response && error.response.data && error.response.data.message) {
this.navigateToWithFlashMessage(this.props.root, 'danger', error.response.data.message); this.navigateToWithFlashMessage(this.props.root, 'danger', error.response.data.message);
} else { } else {
@ -294,7 +295,6 @@ class NavButton extends Component {
} }
} }
function withPageHelpers(target) { function withPageHelpers(target) {
withErrorHandling(target); withErrorHandling(target);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -119,6 +119,8 @@ baseDN="ou=users,dc=company"
filter="(|(username={{username}})(mail={{username}}))" filter="(|(username={{username}})(mail={{username}}))"
#Username field in LDAP (uid/cn/username) #Username field in LDAP (uid/cn/username)
uidTag="username" uidTag="username"
# nameTag identifies the attribute to be used for user's full name
nameTag="username"
passwordresetlink="" passwordresetlink=""
[postfixbounce] [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) => { module.exports.add = (username, password, email, callback) => {
db.getConnection((err, connection) => { db.getConnection((err, connection) => {
if (err) { 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 csrf = require('csurf');
let bodyParser = require('body-parser'); 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; let LdapStrategy;
try { try {
@ -30,28 +33,30 @@ module.exports.parseForm = bodyParser.urlencoded({
limit: config.www.postsize limit: config.www.postsize
}); });
module.exports.loggedIn = (req, res, next) => {
if (!req.user) {
next(new interoperableErrors.NotLoggedInError());
} else {
next();
}
};
module.exports.setup = app => { module.exports.setup = app => {
app.use(passport.initialize()); app.use(passport.initialize());
app.use(passport.session()); app.use(passport.session());
}; };
module.exports.logout = (req, res) => { module.exports.restLogout = (req, res) => {
if (req.user) { req.logout();
req.flash('info', util.format(_('%s logged out'), req.user.username)); res.json();
req.logout();
}
res.redirect('/');
}; };
module.exports.login = (req, res, next) => { module.exports.restLogin = (req, res, next) => {
passport.authenticate(config.ldap.enabled ? 'ldap' : 'local', (err, user, info) => { passport.authenticate(config.ldap.enabled ? 'ldap' : 'local', (err, user, info) => {
if (err) { return next(err);
req.flash('danger', err.message);
return next(err);
}
if (!user) { if (!user) {
req.flash('danger', info && info.message || _('Failed to authenticate user')); return next(new interoperableErrors.IncorrectPasswordError());
return res.redirect('/users/login' + (req.body.next ? '?next=' + encodeURIComponent(req.body.next) : ''));
} }
req.logIn(user, err => { req.logIn(user, err => {
if (err) { if (err) {
@ -66,14 +71,15 @@ module.exports.login = (req, res, next) => {
req.session.cookie.expires = false; req.session.cookie.expires = false;
} }
req.flash('success', util.format(_('Logged in as %s'), user.username)); return res.json();
return res.redirect(req.body.next || '/');
}); });
})(req, res, next); })(req, res, next);
}; };
if (config.ldap.enabled && LdapStrategy) { if (config.ldap.enabled && LdapStrategy) {
log.info('Using LDAP auth'); log.info('Using LDAP auth');
module.exports.authMethod = 'ldap';
module.exports.isAuthMethodLocal = false;
let opts = { let opts = {
server: { server: {
@ -82,62 +88,55 @@ if (config.ldap.enabled && LdapStrategy) {
base: config.ldap.baseDN, base: config.ldap.baseDN,
search: { search: {
filter: config.ldap.filter, filter: config.ldap.filter,
attributes: [config.ldap.uidTag, 'mail'], attributes: [config.ldap.uidTag, config.ldap.nameTag, 'mail'],
scope: 'sub' scope: 'sub'
}, },
uidTag: config.ldap.uidTag uidTag: config.ldap.uidTag
}; };
passport.use(new LdapStrategy(opts, (profile, done) => { passport.use(new LdapStrategy(opts, nodeifyFunction(async (profile) => {
users.findByUsername(profile[config.ldap.uidTag], (err, user) => { try {
if (err) { const user = await users.getByUsername(profile[config.ldap.uidTag]);
return done(err);
}
if (!user) { return {
// password is empty for ldap id: user.id,
users.add(profile[config.ldap.uidTag], '', profile.mail, (err, id) => { username: user.username,
if (err) { name: profile[config.ldap.nameTag],
return done(err); email: profile.mail
} };
return done(null, { } catch (err) {
id, if (err instanceof interoperableErrors.NotFoundError) {
username: profile[config.ldap.uidTag] 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 { } else {
return done(null, { throw err;
id: user.id,
username: user.username
});
} }
});
})); }
})));
passport.serializeUser((user, done) => { /* FIXME */ console.log(user); done(null, user); });
passport.deserializeUser((user, done) => done(null, user));
} else { } else {
log.info('Using local auth'); log.info('Using local auth');
module.exports.authMethod = 'local';
module.exports.isAuthMethodLocal = true;
passport.use(new LocalStrategy((username, password, done) => { passport.use(new LocalStrategy(nodeifyFunction(async (username, password) => {
users.authenticate(username, password, (err, user) => { return await users.getByUsernameIfPasswordMatch(username, password);
if (err) { })));
return done(err);
}
if (!user) { passport.serializeUser((user, done) => done(null, user.id));
return done(null, false, { passport.deserializeUser((id, done) => nodeifyPromise(users.getById(id), done));
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 lastHandler = handlers[handlers.length - 1];
const ret = handlers.slice(); 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; return ret;
} }
function create() { function create() {
const router = new express.Router(); const router = new express.Router();
router.allAsync = (path, ...handlers) => router.all(path, ...replaceLastBySafeHandler(handlers));
router.getAsync = (path, ...handlers) => router.get(path, ...replaceLastBySafeHandler(handlers)); router.getAsync = (path, ...handlers) => router.get(path, ...replaceLastBySafeHandler(handlers));
router.postAsync = (path, ...handlers) => router.post(path, ...replaceLastBySafeHandler(handlers)); router.postAsync = (path, ...handlers) => router.post(path, ...replaceLastBySafeHandler(handlers));
router.putAsync = (path, ...handlers) => router.put(path, ...replaceLastBySafeHandler(handlers)); router.putAsync = (path, ...handlers) => router.put(path, ...replaceLastBySafeHandler(handlers));

View file

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

View file

@ -21,8 +21,6 @@ async function getById(nsId) {
throw new interoperableErrors.NotFoundError(); throw new interoperableErrors.NotFoundError();
} }
ns.hash = hash(ns);
return 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 validators = require('../shared/validators');
const dtHelpers = require('../lib/dt-helpers'); const dtHelpers = require('../lib/dt-helpers');
const tools = require('../lib/tools-async'); 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 bcrypt = require('bcrypt-nodejs');
const bcryptHash = Promise.promisify(bcrypt.hash); const bcryptHash = bluebird.promisify(bcrypt.hash);
const bcryptCompare = Promise.promisify(bcrypt.compare); 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 allowedKeys = new Set(['username', 'name', 'email', 'password']);
const allowedKeysExternal = new Set(['username']);
const ownAccountAllowedKeys = new Set(['name', 'email', 'password']); const ownAccountAllowedKeys = new Set(['name', 'email', 'password']);
const passport = require('../../lib/passport');
function hash(user) { function hash(user) {
return hasher.hash(filterObject(user, allowedKeys)); return hasher.hash(filterObject(user, allowedKeys));
} }
async function getById(userId) { async function _getBy(key, value, extraColumns) {
const user = await knex('users').select(['id', 'username', 'name', 'email', 'password']).where('id', userId).first(); const columns = ['id', 'username', 'name', 'email'];
if (extraColumns) {
columns.push(...extraColumns);
}
const user = await knex('users').select(columns).where(key, value).first();
if (!user) { if (!user) {
throw new interoperableErrors.NotFoundError(); throw new interoperableErrors.NotFoundError();
} }
user.hash = hash(user);
delete(user.password);
return user; return user;
} }
async function getById(userId) {
return await _getBy('id', userId);
}
async function serverValidate(data, isOwnAccount) { async function serverValidate(data, isOwnAccount) {
const result = {}; const result = {};
@ -123,8 +141,9 @@ async function _validateAndPreprocess(tx, user, isCreate, isOwnAccount) {
} }
} }
async function create(user) { async function create(user) {
enforce(passport.isAuthMethodLocal, 'Local user management is required');
await knex.transaction(async tx => { await knex.transaction(async tx => {
await _validateAndPreprocess(tx, user, true); await _validateAndPreprocess(tx, user, true);
const userId = await tx('users').insert(filterObject(user, allowedKeys)); 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) { async function updateWithConsistencyCheck(user, isOwnAccount) {
enforce(passport.isAuthMethodLocal, 'Local user management is required');
await knex.transaction(async tx => { await knex.transaction(async tx => {
await _validateAndPreprocess(tx, user, false, isOwnAccount); await _validateAndPreprocess(tx, user, false, isOwnAccount);
@ -147,13 +175,8 @@ async function updateWithConsistencyCheck(user, isOwnAccount) {
} }
if (isOwnAccount && user.password) { if (isOwnAccount && user.password) {
console.log(user.currentPassword);
console.log(existingUser.password);
if (!await bcryptCompare(user.currentPassword, existingUser.password)) { if (!await bcryptCompare(user.currentPassword, existingUser.password)) {
// This is not an interoperable error because current password is verified in account-validate. throw new interoperableErrors.IncorrectPasswordError();
// A change of password between account-validate and submit would be signalled by ChangedError
throw new Error('Incorrect password');
} }
} }
@ -162,17 +185,139 @@ async function updateWithConsistencyCheck(user, isOwnAccount) {
} }
async function remove(userId) { async function remove(userId) {
enforce(passport.isAuthMethodLocal, 'Local user management is required');
// FIXME: enforce that userId is not the current user // FIXME: enforce that userId is not the current user
enforce(userId !== 1, 'Admin cannot be deleted'); enforce(userId !== 1, 'Admin cannot be deleted');
await knex('users').where('id', userId).del(); 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 = { module.exports = {
listDTAjax, listDTAjax,
remove, remove,
updateWithConsistencyCheck, updateWithConsistencyCheck,
create, create,
createExternal,
hash, hash,
getById, 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-gettext": "^2.0.0-rc.1",
"node-mocks-http": "^1.6.1", "node-mocks-http": "^1.6.1",
"node-object-hash": "^1.2.0", "node-object-hash": "^1.2.0",
"nodeify": "^1.0.1",
"nodemailer": "^4.0.1", "nodemailer": "^4.0.1",
"nodemailer-openpgp": "^1.0.2", "nodemailer-openpgp": "^1.0.2",
"npmlog": "^4.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'; 'use strict';
let users = require('../lib/models/users-legacy'); let users = require('../models/users');
let lists = require('../lib/models/lists'); let lists = require('../lib/models/lists');
let fields = require('../lib/models/fields'); let fields = require('../lib/models/fields');
let blacklist = require('../lib/models/blacklist'); let blacklist = require('../lib/models/blacklist');
let subscriptions = require('../lib/models/subscriptions'); let subscriptions = require('../lib/models/subscriptions');
let confirmations = require('../lib/models/confirmations'); let confirmations = require('../lib/models/confirmations');
let tools = require('../lib/tools'); let tools = require('../lib/tools');
let express = require('express');
let log = require('npmlog'); let log = require('npmlog');
let router = new express.Router(); const router = require('../lib/router-async').create();
let mailHelpers = require('../lib/subscription-mail-helpers'); 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) { if (!req.query.access_token) {
res.status(403); res.status(403);
return res.json({ return res.json({
@ -21,24 +21,24 @@ router.all('/*', (req, res, next) => {
}); });
} }
users.findByAccessToken(req.query.access_token, (err, user) => { try {
if (err) { 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); res.status(500);
return res.json({ return res.json({
error: err.message || err, error: err.message || err,
data: [] data: []
}); });
} }
if (!user) { }
res.status(403);
return res.json({
error: 'Invalid or expired access_token',
data: []
});
}
next();
});
}); });
router.post('/subscribe/:listId', (req, res) => { 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 passport = require('../lib/passport');
let express = require('express'); let express = require('express');
let router = new express.Router(); 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 fields = require('../lib/models/fields');
let settings = require('../lib/models/settings'); let settings = require('../lib/models/settings');
let _ = require('../lib/translate')._; 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; 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) { exports.up = function(knex, Promise) {
return knex.schema.table('users', table => { 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({ .then(() => knex('users').where('id', 1).update({
name: 'Administrator' 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 = { const errorTypes = {
InteroperableError, InteroperableError,
NotLoggedInError, NotLoggedInError,
@ -58,7 +70,9 @@ const errorTypes = {
LoopDetectedError, LoopDetectedError,
ChildDetectedError, ChildDetectedError,
DuplicitNameError, DuplicitNameError,
DuplicitEmailError DuplicitEmailError,
IncorrectPasswordError,
InvalidToken
}; };
function deserialize(errorObj) { function deserialize(errorObj) {

View file

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

View file

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