diff --git a/app.js b/app.js
index 4666a0a4..3e257cb3 100644
--- a/app.js
+++ b/app.js
@@ -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);
diff --git a/client/package.json b/client/package.json
index eafdbc4f..977455d2 100644
--- a/client/package.json
+++ b/client/package.json
@@ -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",
diff --git a/client/src/account/API.js b/client/src/account/API.js
new file mode 100644
index 00000000..f18347a4
--- /dev/null
+++ b/client/src/account/API.js
@@ -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 (
+
+
{t('Sign in')}
+
+
+
+
+
+
+
+ { this.state.accessToken ?
+
{t('Personal access token:')} {accessToken}
+ :
+
{t('Access token not yet generated')}
+ }
+
+
+
+
+
{t('Notes about the API')}
+
+
+
+ {t('API response is a JSON structure with error
and data
properties. If the response error
has a value set then the request failed.')}
+
+
+ {t('You need to define proper Content-Type
when making a request. You can either use application/x-www-form-urlencoded
for normal form data or application/json
for a JSON payload. Using multipart/form-data
is not supported.')}
+
+
+
+
+
POST /api/subscribe/:listId – {t('Add subscription')}
+
+
+ {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.')}
+
+
+
+ GET {t('arguments')}
+
+
+ access_token – {t('your personal access token')}
+
+
+
+ POST {t('arguments')}
+
+
+ EMAIL – {t('subscriber\'s email address')} ({t('required')} )
+ FIRST_NAME – {t('subscriber\'s first name')}
+ LAST_NAME – {t('subscriber\'s last name')}
+ TIMEZONE – {t('subscriber\'s timezone (eg. "Europe/Tallinn", "PST" or "UTC"). If not set defaults to "UTC"')}
+ MERGE_TAG_VALUE – {t('custom field value. Use yes/no for option group values (checkboxes, radios, drop downs)')}
+
+
+
+ {t('Additional POST arguments')}:
+
+
+
+
+ FORCE_SUBSCRIBE – {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.
+
+
+ REQUIRE_CONFIRMATION – {t('set to "yes" if you want to send confirmation email to the subscriber before actually marking as subscribed')}
+
+
+
+
+ {t('Example')}
+
+
+
curl -XPOST {serviceUrl}api/subscribe/B16uVTdW?access_token={accessToken} \
+--data 'EMAIL=test@example.com&MERGE_CHECKBOX=yes&REQUIRE_CONFIRMATION=yes'
+
+
POST /api/unsubscribe/:listId – {t('Remove subscription')}
+
+
+ {t('This API call marks a subscription as unsubscribed')}
+
+
+
+ GET {t('arguments')}
+
+
+ access_token – {t('your personal access token')}
+
+
+
+ POST {t('arguments')}
+
+
+ EMAIL – {t('subscriber\'s email address')} ({t('required')} )
+
+
+
+ {t('Example')}
+
+
+
curl -XPOST {serviceUrl}api/unsubscribe/B16uVTdW?access_token={accessToken} \
+--data 'EMAIL=test@example.com'
+
+
POST /api/delete/:listId – {t('Delete subscription')}
+
+
+ {t('This API call deletes a subscription')}
+
+
+
+ GET {t('arguments')}
+
+
+ access_token – {t('your personal access token')}
+
+
+
+ POST {t('arguments')}
+
+
+ EMAIL – {t('subscriber\'s email address')} ({t('required')} )
+
+
+
+ {t('Example')}
+
+
+
curl -XPOST {serviceUrl}api/delete/B16uVTdW?access_token={accessToken} \
+--data 'EMAIL=test@example.com'
+
+
GET /api/blacklist/get – {t('Get list of blacklisted emails')}
+
+
+ {t('This API call get list of blacklisted emails.')}
+
+
+
+ GET {t('arguments')}
+
+
+ access_token – {t('your personal access token')}
+ start – {t('Start position')} ({t('optional, default 0')} )
+ limit – {t('limit emails count in response')} ({t('optional, default 10000')} )
+ search – {t('filter by part of email')} ({t('optional, default ""')} )
+
+
+
+
+ {t('Example')}
+
+
+
curl -XGET '{serviceUrl}api/blacklist/get?access_token={accessToken}&limit=10&start=10&search=gmail'
+
+
POST /api/blacklist/add – {t('Add email to blacklist')}
+
+
+ {t('This API call either add emails to blacklist')}
+
+
+
+ GET {t('arguments')}
+
+
+ access_token – {t('your personal access token')}
+
+
+
+ POST {t('arguments')}
+
+
+ EMAIL – {t('email address')} ({t('required')} )
+
+
+
+ {t('Example')}
+
+
+
curl -XPOST '{serviceUrl}api/blacklist/add?access_token={accessToken}' \
+--data 'EMAIL=test@example.com&'
+
+
POST /api/blacklist/delete – {t('Delete email from blacklist')}
+
+
+ {t('This API call either delete emails from blacklist')}
+
+
+
+ GET {t('arguments')}
+
+
+ access_token – {t('your personal access token')}
+
+
+
+ POST {t('arguments')}
+
+
+ EMAIL – {t('email address')} ({t('required')} )
+
+
+
+ {t('Example')}
+
+
+
curl -XPOST '{serviceUrl}api/blacklist/delete?access_token={accessToken}' \
+--data 'EMAIL=test@example.com&'
+
+ );
+ }
+}
diff --git a/client/src/account/Account.js b/client/src/account/Account.js
index 92019126..5c7c05da 100644
--- a/client/src/account/Account.js
+++ b/client/src/account/Account.js
@@ -8,6 +8,7 @@ import {
} from '../lib/form';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import passwordValidator from '../../../shared/password-validator';
+import mailtrainConfig from 'mailtrainConfig';
@translate()
@withForm
@@ -23,15 +24,15 @@ export default class Account extends Component {
this.initForm({
serverValidation: {
- url: '/account/rest/account-validate',
- changed: ['email', 'password', 'currentPassword']
+ url: '/rest/account-validate',
+ changed: ['email', 'username', 'currentPassword']
}
});
}
@withAsyncErrorHandler
async loadFormValues() {
- await this.getFormValuesFromURL(`/account/rest/account`, data => {
+ await this.getFormValuesFromURL('/rest/account', data => {
data.password = '';
data.password2 = '';
data.currentPassword = '';
@@ -102,53 +103,91 @@ export default class Account extends Component {
async submitHandler() {
const t = this.props.t;
- this.disableForm();
- this.setFormStatusMessage('info', t('Updating user profile ...'));
+ try {
+ this.disableForm();
+ this.setFormStatusMessage('info', t('Updating user profile ...'));
- const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, '/account/rest/account', data => {
- delete data.password2;
- });
+ const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, '/rest/account', data => {
+ delete data.password2;
+ });
- this.enableForm();
+ if (submitSuccessful) {
+ this.setFlashMessage('success', t('User profile updated'));
+ this.hideFormValidation();
+ this.updateFormValue('password', '');
+ this.updateFormValue('password2', '');
+ this.updateFormValue('currentPassword', '');
- if (submitSuccessful) {
- this.setFlashMessage('success', t('User profile updated'));
- this.hideFormValidation();
- this.updateFormValue('password', '');
- this.updateFormValue('password2', '');
- this.updateFormValue('currentPassword', '');
+ this.clearFormStatusMessage();
+ } else {
+ this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
+ }
+ } catch (error) {
+ if (error instanceof interoperableErrors.IncorrectPasswordError) {
+ this.enableForm();
- this.clearFormStatusMessage();
- } else {
- this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
+ this.setFormStatusMessage('danger',
+
+ {t('Your updates cannot be saved.')} {' '}
+ {t('The password is incorrect (possibly just changed in another window / session). Enter correct password and try again.')}
+
+ );
+
+ this.scheduleFormRevalidate();
+ return;
+ }
+
+ if (error instanceof interoperableErrors.DuplicitEmailError) {
+ this.enableForm();
+
+ this.setFormStatusMessage('danger',
+
+ {t('Your updates cannot be saved.')} {' '}
+ {t('The email is already assigned to another user. Enter another email and try again.')}
+
+ );
+
+ this.scheduleFormRevalidate();
+ return;
+ }
+
+ throw error;
}
}
render() {
const t = this.props.t;
- return (
+ if (mailtrainConfig.isAuthMethodLocal) {
+ return (
+
+ );
+ } else {
{t('Account')}
-
+
Account management is not possible because Mailtrain is configured to use externally managed users.
- );
+ }
}
}
diff --git a/client/src/account/Forgot.js b/client/src/account/Forgot.js
new file mode 100644
index 00000000..efa84ef1
--- /dev/null
+++ b/client/src/account/Forgot.js
@@ -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 (
+
+
{t('Password Reset')}
+
+
{t('Please provide the username or email address that is registered with your Mailtrain account.')}
+
+
{t('We will send you an email that will allow you to reset your password.')}
+
+
+
+ );
+ }
+}
diff --git a/client/src/account/Login.js b/client/src/account/Login.js
new file mode 100644
index 00000000..24d7f937
--- /dev/null
+++ b/client/src/account/Login.js
@@ -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',
+
+ {t('Invalid username or password.')}
+
+ );
+
+ return;
+ }
+
+ throw error;
+ }
+ }
+
+ render() {
+ const t = this.props.t;
+
+ return (
+
+
{t('Sign in')}
+
+
+
+ );
+ }
+}
diff --git a/client/src/account/Reset.js b/client/src/account/Reset.js
new file mode 100644
index 00000000..aad088da
--- /dev/null
+++ b/client/src/account/Reset.js
@@ -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) => {msg}
)
+ }
+
+ 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',
+
+ {t('Your password cannot be reset.')} {' '}
+ {t('The reset token has expired.')}{' '} {t('Click here to request a new password reset link.')}
+
+ );
+ return;
+ }
+
+ throw error;
+ }
+ }
+
+ render() {
+ const t = this.props.t;
+
+ if (this.state.resetTokenValidationState === ResetTokenValidationState.PENDING) {
+ return (
+ {t('Validating password reset token ...')}
+ )
+ } else if (this.state.resetTokenValidationState === ResetTokenValidationState.INVALID) {
+
+ } else {
+ return (
+
+
{t('Set new password for') + ' ' + this.getFormValue('username')}
+
+
+
+ );
+ }
+ }
+}
diff --git a/client/src/account/root.js b/client/src/account/root.js
index e5e57767..7507d5b6 100644
--- a/client/src/account/root.js
+++ b/client/src/account/root.js
@@ -7,20 +7,60 @@ import i18n from '../lib/i18n';
import { Section } from '../lib/page'
import Account from './Account'
+import Login from './Login'
+import Reset from './Forgot'
+import ResetLink from './Reset'
+import API from './API'
+import mailtrainConfig from 'mailtrainConfig';
-const getStructure = t => ({
- '': {
- title: t('Home'),
- externalLink: '/',
- children: {
- 'account': {
- title: t('Account'),
- link: '/account',
- component: Account
+
+const getStructure = t => {
+ const subPaths = {
+ 'login': {
+ title: t('Sign in'),
+ link: '/account/login',
+ component: Login,
+ },
+ 'api': {
+ title: t('API'),
+ link: '/account/api',
+ component: API
+ }
+ };
+
+ if (mailtrainConfig.isAuthMethodLocal) {
+ subPaths.forgot = {
+ title: t('Password reset'),
+ params: [':username?'],
+ link: '/account/forgot',
+ component: Reset
+ };
+
+ subPaths.reset = {
+ title: t('Password reset'),
+ params: [':username', ':resetToken'],
+ link: '/account/reset',
+ component: ResetLink
+ };
+ }
+
+ return {
+ '': {
+ title: t('Home'),
+ externalLink: '/',
+ children: {
+ account: {
+ title: t('Account'),
+ link: '/account',
+ component: Account,
+
+ children: subPaths
+
+ }
}
}
- }
-});
+ };
+}
export default function() {
ReactDOM.render(
diff --git a/client/src/lib/bootstrap-components.js b/client/src/lib/bootstrap-components.js
index 7b609580..92586d24 100644
--- a/client/src/lib/bootstrap-components.js
+++ b/client/src/lib/bootstrap-components.js
@@ -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 (
+ {props.children}
+ );
+ }
+}
+
+
@translate()
@withErrorHandling
class ModalDialog extends Component {
@@ -184,6 +210,7 @@ class ModalDialog extends Component {
export {
Button,
+ ActionLink,
DismissibleAlert,
ModalDialog
};
\ No newline at end of file
diff --git a/client/src/lib/form.js b/client/src/lib/form.js
index 9e56d754..ae28e0bb 100644
--- a/client/src/lib/form.js
+++ b/client/src/lib/form.js
@@ -128,6 +128,20 @@ function wrapInput(id, htmlId, owner, label, help, input) {
);
}
+function wrapInputInline(id, htmlId, owner, containerClass, label, help, input) {
+ const helpBlock = help ? {help}
: '';
+
+ return (
+
+
+ {input} {label}
+
+ {helpBlock}
+
{owner.getFormValidationMessage(id)}
+
+ );
+}
+
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,
+ {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 (
-
@@ -198,11 +243,19 @@ class ButtonRow extends Component {
}
}
+
+class ButtonRow extends Component {
+ render() {
+ return (
+
{this.props.children}
+ );
+ }
+}
+
@withErrorHandling
class Button extends Component {
static propTypes = {
onClickAsync: PropTypes.func,
- onClick: PropTypes.func,
label: PropTypes.string,
icon: PropTypes.string,
className: PropTypes.string,
@@ -215,12 +268,7 @@ class Button extends Component {
@withAsyncErrorHandler
async onClick(evt) {
- if (this.props.onClick) {
- evt.preventDefault();
-
- onClick(evt);
-
- } else if (this.props.onClickAsync) {
+ if (this.props.onClickAsync) {
evt.preventDefault();
this.context.formStateOwner.disableForm();
@@ -255,6 +303,7 @@ class Button extends Component {
}
}
+
class TreeTableSelect extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
@@ -297,6 +346,93 @@ function withForm(target) {
isServerValidationRunning: false
});
+ // formValidateResolve is called by "validateForm" once client receives validation response from server that does not
+ // trigger another server validation
+ let formValidateResolve = null;
+
+ function scheduleValidateForm(self) {
+ setTimeout(() => {
+ self.setState(previousState => ({
+ formState: previousState.formState.withMutations(mutState => {
+ validateFormState(self, mutState);
+ })
+ }));
+ }, 0);
+ }
+
+ function validateFormState(self, mutState) {
+ const settings = self.state.formSettings;
+
+ if (!mutState.get('isServerValidationRunning') && settings.serverValidation) {
+ const payload = {};
+ let payloadNotEmpty = false;
+
+ for (const attr of settings.serverValidation.extra || []) {
+ payload[attr] = mutState.getIn(['data', attr, 'value']);
+ }
+
+ for (const attr of settings.serverValidation.changed) {
+ const currValue = mutState.getIn(['data', attr, 'value']);
+ const serverValue = mutState.getIn(['data', attr, 'serverValue']);
+
+ // This really assumes that all form values are preinitialized (i.e. not undef)
+ if (currValue !== serverValue) {
+ mutState.setIn(['data', attr, 'serverValidated'], false);
+ payload[attr] = currValue;
+ payloadNotEmpty = true;
+ }
+ }
+
+ if (payloadNotEmpty) {
+ mutState.set('isServerValidationRunning', true);
+
+ axios.post(settings.serverValidation.url, payload)
+ .then(response => {
+
+ self.setState(previousState => ({
+ formState: previousState.formState.withMutations(mutState => {
+ mutState.set('isServerValidationRunning', false);
+
+ mutState.update('data', stateData => stateData.withMutations(mutStateData => {
+ for (const attr in payload) {
+ mutStateData.setIn([attr, 'serverValue'], payload[attr]);
+
+ if (payload[attr] === mutState.getIn(['data', attr, 'value'])) {
+ mutStateData.setIn([attr, 'serverValidated'], true);
+ mutStateData.setIn([attr, 'serverValidation'], response.data[attr] || true);
+ }
+ }
+ }));
+ })
+ }));
+
+ scheduleValidateForm(self);
+ })
+ .catch(error => {
+ console.log('Ignoring unhandled error in "validateFormState": ' + error);
+
+ self.setState(previousState => ({
+ formState: previousState.formState.set('isServerValidationRunning', false)
+ }));
+
+ scheduleValidateForm(self);
+ });
+ } else {
+ if (formValidateResolve) {
+ const resolve = formValidateResolve;
+ formValidateResolve = null;
+ resolve();
+ }
+ }
+ }
+
+ if (self.localValidateFormValues) {
+ mutState.update('data', stateData => stateData.withMutations(mutStateData => {
+ self.localValidateFormValues(mutStateData);
+ }));
+ }
+ }
+
inst.initForm = function(settings) {
const state = this.state || {};
state.formState = cleanFormState;
@@ -345,12 +481,15 @@ function withForm(target) {
mutator(data);
}
+ let response;
if (method === FormSendMethod.PUT) {
- await axios.put(url, data);
+ response = await axios.put(url, data);
} else if (method === FormSendMethod.POST) {
- await axios.post(url, data);
+ response = await axios.post(url, data);
}
- return true;
+
+ return response.data || true;
+
} else {
this.showFormValidation();
return false;
@@ -371,110 +510,26 @@ function withForm(target) {
}
}));
- this.validateForm(mutState);
+ validateFormState(this, mutState);
})
}));
};
-
- // formValidateResolve is called by "validateForm" once client receives validation response from server that does not
- // trigger another server validation
- let formValidateResolve = null;
-
- const scheduleValidateForm = (self) => {
- setTimeout(() => {
- self.setState(previousState => ({
- formState: previousState.formState.withMutations(mutState => {
- self.validateForm(mutState);
- })
- }));
- }, 0);
- };
-
inst.waitForFormServerValidated = async function() {
if (!this.isFormServerValidated()) {
await new Promise(resolve => { formValidateResolve = resolve; });
}
};
- inst.validateForm = function(mutState) {
- const settings = this.state.formSettings;
-
- if (!mutState.get('isServerValidationRunning') && settings.serverValidation) {
- const payload = {};
- let payloadNotEmpty = false;
-
- for (const attr of settings.serverValidation.extra || []) {
- payload[attr] = mutState.getIn(['data', attr, 'value']);
- }
-
- for (const attr of settings.serverValidation.changed) {
- const currValue = mutState.getIn(['data', attr, 'value']);
- const serverValue = mutState.getIn(['data', attr, 'serverValue']);
-
- // This really assumes that all form values are preinitialized (i.e. not undef)
- if (currValue !== serverValue) {
- mutState.setIn(['data', attr, 'serverValidated'], false);
- payload[attr] = currValue;
- payloadNotEmpty = true;
- }
- }
-
- if (payloadNotEmpty) {
- mutState.set('isServerValidationRunning', true);
-
- axios.post(settings.serverValidation.url, payload)
- .then(response => {
-
- this.setState(previousState => ({
- formState: previousState.formState.withMutations(mutState => {
- mutState.set('isServerValidationRunning', false);
-
- mutState.update('data', stateData => stateData.withMutations(mutStateData => {
- for (const attr in payload) {
- mutStateData.setIn([attr, 'serverValue'], payload[attr]);
-
- if (payload[attr] === mutState.getIn(['data', attr, 'value'])) {
- mutStateData.setIn([attr, 'serverValidated'], true);
- mutStateData.setIn([attr, 'serverValidation'], response.data[attr] || true);
- }
- }
- }));
- })
- }));
-
- scheduleValidateForm(this);
- })
- .catch(error => {
- console.log('Ignoring unhandled error in "validateForm": ' + error);
-
- this.setState(previousState => ({
- formState: previousState.formState.set('isServerValidationRunning', false)
- }));
-
- scheduleValidateForm(this);
- });
- } else {
- if (formValidateResolve) {
- const resolve = formValidateResolve;
- formValidateResolve = null;
- resolve();
- }
- }
- }
-
- if (this.localValidateFormValues) {
- mutState.update('data', stateData => stateData.withMutations(mutStateData => {
- this.localValidateFormValues(mutStateData);
- }));
- }
+ inst.scheduleFormRevalidate = function() {
+ scheduleValidateForm(this);
};
inst.updateFormValue = function(key, value) {
this.setState(previousState => ({
formState: previousState.formState.withMutations(mutState => {
mutState.setIn(['data', key, 'value'], value);
- this.validateForm(mutState);
+ validateFormState(this, mutState);
})
}));
};
@@ -590,7 +645,9 @@ export {
Form,
Fieldset,
InputField,
+ CheckBox,
TextArea,
+ AlignedRow,
ButtonRow,
Button,
TreeTableSelect,
diff --git a/client/src/lib/page.js b/client/src/lib/page.js
index 7b42489a..1a3d39f0 100644
--- a/client/src/lib/page.js
+++ b/client/src/lib/page.js
@@ -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);
diff --git a/client/src/namespaces/CUD.js b/client/src/namespaces/CUD.js
index f5e3c427..28a13886 100644
--- a/client/src/namespaces/CUD.js
+++ b/client/src/namespaces/CUD.js
@@ -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'));
diff --git a/client/src/namespaces/List.js b/client/src/namespaces/List.js
index 9917dba4..a16ab205 100644
--- a/client/src/namespaces/List.js
+++ b/client/src/namespaces/List.js
@@ -25,7 +25,7 @@ export default class List extends Component {
{t('Namespaces')}
-
+
);
}
diff --git a/client/src/namespaces/root.js b/client/src/namespaces/root.js
index 60c3e002..a41fcb90 100644
--- a/client/src/namespaces/root.js
+++ b/client/src/namespaces/root.js
@@ -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 => ( )
},
- 'create' : {
+ create : {
title: t('Create Namespace'),
render: props => ( )
}
diff --git a/client/src/users/CUD.js b/client/src/users/CUD.js
index a725b088..aae2fd66 100644
--- a/client/src/users/CUD.js
+++ b/client/src/users/CUD.js
@@ -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'));
}
diff --git a/client/src/users/List.js b/client/src/users/List.js
index 84369fe1..0bdb07ff 100644
--- a/client/src/users/List.js
+++ b/client/src/users/List.js
@@ -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 (
@@ -31,7 +35,7 @@ export default class List extends Component {
{t('Users')}
-
+
);
}
diff --git a/client/src/users/root.js b/client/src/users/root.js
index 6eac50c6..0d61f82b 100644
--- a/client/src/users/root.js
+++ b/client/src/users/root.js
@@ -8,31 +8,39 @@ import i18n from '../lib/i18n';
import { Section } from '../lib/page'
import CUD from './CUD'
import List from './List'
+import mailtrainConfig from 'mailtrainConfig';
-const getStructure = t => ({
- '': {
- title: t('Home'),
- externalLink: '/',
- children: {
- 'users': {
- title: t('Users'),
- link: '/users',
- component: List,
- children: {
- 'edit' : {
- title: t('Edit User'),
- params: [':id', ':action?'],
- render: props => ( )
- },
- 'create' : {
- title: t('Create User'),
- render: props => ( )
- }
+const getStructure = t => {
+ const subPaths = {};
+
+ if (mailtrainConfig.isAuthMethodLocal) {
+ subPaths.edit = {
+ title: t('Edit User'),
+ params: [':id', ':action?'],
+ render: props => ( )
+ };
+
+ subPahts.create = {
+ title: t('Create User'),
+ render: props => ( )
+ };
+ }
+
+ return {
+ '': {
+ title: t('Home'),
+ externalLink: '/',
+ children: {
+ users: {
+ title: t('Users'),
+ link: '/users',
+ component: List,
+ children: subPaths
}
}
}
}
-});
+};
export default function() {
ReactDOM.render(
diff --git a/client/webpack.config.js b/client/webpack.config.js
index 2326b4c0..2222131d 100644
--- a/client/webpack.config.js
+++ b/client/webpack.config.js
@@ -21,7 +21,8 @@ module.exports = {
},
externals: {
jquery: 'jQuery',
- csfrToken: 'csfrToken'
+ csfrToken: 'csfrToken',
+ mailtrainConfig: 'mailtrainConfig'
},
plugins: [
// new webpack.optimize.UglifyJsPlugin(),
diff --git a/config/default.toml b/config/default.toml
index d21ce445..c1adc60a 100644
--- a/config/default.toml
+++ b/config/default.toml
@@ -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]
diff --git a/lib/client-helpers.js b/lib/client-helpers.js
new file mode 100644
index 00000000..b9c6d379
--- /dev/null
+++ b/lib/client-helpers.js
@@ -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
+};
+
diff --git a/lib/models/users-legacy.js b/lib/models/users-legacy-REMOVE.js
similarity index 89%
rename from lib/models/users-legacy.js
rename to lib/models/users-legacy-REMOVE.js
index b16106c9..7c42a0c9 100644
--- a/lib/models/users-legacy.js
+++ b/lib/models/users-legacy-REMOVE.js
@@ -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) {
diff --git a/lib/nodeify.js b/lib/nodeify.js
new file mode 100644
index 00000000..d4b94d31
--- /dev/null
+++ b/lib/nodeify.js
@@ -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);
+ };
+};
diff --git a/lib/passport.js b/lib/passport.js
index f92aae5f..a7afaa38 100644
--- a/lib/passport.js
+++ b/lib/passport.js
@@ -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));
- req.logout();
- }
- res.redirect('/');
+module.exports.restLogout = (req, res) => {
+ req.logout();
+ 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);
- }
+ 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 {
+ id: user.id,
+ username: user.username,
+ name: profile[config.ldap.nameTag],
+ email: profile.mail
+ };
- return done(null, {
- id,
- username: profile[config.ldap.uidTag]
- });
+ } 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 {
- return done(null, {
- id: user.id,
- username: user.username
- });
+ 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);
+ })));
- 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) => nodeifyPromise(users.getById(id), done));
}
-passport.serializeUser((user, done) => {
- done(null, user.id);
-});
-
-passport.deserializeUser((id, done) => {
- users.get(id, done);
-});
diff --git a/lib/router-async.js b/lib/router-async.js
index c9482474..a24419bd 100644
--- a/lib/router-async.js
+++ b/lib/router-async.js
@@ -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));
diff --git a/lib/tools-async.js b/lib/tools-async.js
index 7ed49163..df93c7d6 100644
--- a/lib/tools-async.js
+++ b/lib/tools-async.js
@@ -2,7 +2,6 @@
const _ = require('./translate')._;
const util = require('util');
-const Promise = require('bluebird');
const isemail = require('isemail')
module.exports = {
diff --git a/models/namespaces.js b/models/namespaces.js
index 3e30e967..68b43732 100644
--- a/models/namespaces.js
+++ b/models/namespaces.js
@@ -21,8 +21,6 @@ async function getById(nsId) {
throw new interoperableErrors.NotFoundError();
}
- ns.hash = hash(ns);
-
return ns;
}
diff --git a/models/settings.js b/models/settings.js
new file mode 100644
index 00000000..c6031376
--- /dev/null
+++ b/models/settings.js
@@ -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
+};
+
diff --git a/models/users.js b/models/users.js
index f9dcd253..774ab0fd 100644
--- a/models/users.js
+++ b/models/users.js
@@ -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
};
\ No newline at end of file
diff --git a/package.json b/package.json
index b571ea5b..c7724df8 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/routes/account-legacy-integration.js b/routes/account-legacy-integration.js
new file mode 100644
index 00000000..03856058
--- /dev/null
+++ b/routes/account-legacy-integration.js
@@ -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;
\ No newline at end of file
diff --git a/routes/account.js b/routes/account.js
deleted file mode 100644
index a0fe6db3..00000000
--- a/routes/account.js
+++ /dev/null
@@ -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;
diff --git a/routes/api.js b/routes/api.js
index 77da50af..f0cd0c15 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -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) => {
diff --git a/routes/namespaces-legacy-integration.js b/routes/namespaces-legacy-integration.js
new file mode 100644
index 00000000..8892268b
--- /dev/null
+++ b/routes/namespaces-legacy-integration.js
@@ -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;
\ No newline at end of file
diff --git a/routes/namespaces.js b/routes/namespaces.js
deleted file mode 100644
index c9725861..00000000
--- a/routes/namespaces.js
+++ /dev/null
@@ -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;
diff --git a/routes/rest/account.js b/routes/rest/account.js
new file mode 100644
index 00000000..a354ccad
--- /dev/null
+++ b/routes/rest/account.js
@@ -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;
diff --git a/routes/rest/namespaces.js b/routes/rest/namespaces.js
new file mode 100644
index 00000000..09eb8266
--- /dev/null
+++ b/routes/rest/namespaces.js
@@ -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;
diff --git a/routes/rest/users.js b/routes/rest/users.js
new file mode 100644
index 00000000..3f10bf8f
--- /dev/null
+++ b/routes/rest/users.js
@@ -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;
\ No newline at end of file
diff --git a/routes/users-legacy.js b/routes/users-legacy-REMOVE.js
similarity index 79%
rename from routes/users-legacy.js
rename to routes/users-legacy-REMOVE.js
index b4ce01fe..f57a566d 100644
--- a/routes/users-legacy.js
+++ b/routes/users-legacy-REMOVE.js
@@ -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;
diff --git a/routes/users-legacy-integration.js b/routes/users-legacy-integration.js
new file mode 100644
index 00000000..80d55def
--- /dev/null
+++ b/routes/users-legacy-integration.js
@@ -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;
\ No newline at end of file
diff --git a/routes/users.js b/routes/users.js
deleted file mode 100644
index 263525be..00000000
--- a/routes/users.js
+++ /dev/null
@@ -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;
diff --git a/setup/knex/migrations/20170617123450_create_user_name.js b/setup/knex/migrations/20170617123450_create_user_name.js
index 00aa9b65..40e91250 100644
--- a/setup/knex/migrations/20170617123450_create_user_name.js
+++ b/setup/knex/migrations/20170617123450_create_user_name.js
@@ -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'
diff --git a/shared/interoperable-errors.js b/shared/interoperable-errors.js
index 1a9c81ce..ba7bd9b5 100644
--- a/shared/interoperable-errors.js
+++ b/shared/interoperable-errors.js
@@ -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) {
diff --git a/test/e2e/lib/worker-counter.js b/test/e2e/lib/worker-counter.js
index 4debeda6..9c97a876 100644
--- a/test/e2e/lib/worker-counter.js
+++ b/test/e2e/lib/worker-counter.js
@@ -1,7 +1,5 @@
'use strict';
-const Promise = require('bluebird');
-
class WorkerCounter {
constructor() {
this.counter = 0;
diff --git a/views/layout.hbs b/views/layout.hbs
index f9efabd5..27c6923e 100644
--- a/views/layout.hbs
+++ b/views/layout.hbs
@@ -24,6 +24,7 @@
@@ -78,19 +79,12 @@
{{#translate}}Blog{{/translate}}
- {{#if user }}
-
+ {{#if admin }}
+
+ {{/if}}
+
+
+ {{#if user }}
+