From d79bbad575b3157c15de477e5c5639e5f320cb32 Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Sat, 8 Jul 2017 15:48:34 +0200 Subject: [PATCH] All about user login Not runnable at the moment --- app.js | 34 ++- client/package.json | 3 +- client/src/account/API.js | 254 +++++++++++++++++ client/src/account/Account.js | 113 +++++--- client/src/account/Forgot.js | 78 ++++++ client/src/account/Login.js | 107 ++++++++ client/src/account/Reset.js | 137 ++++++++++ client/src/account/root.js | 62 ++++- client/src/lib/bootstrap-components.js | 27 ++ client/src/lib/form.js | 257 +++++++++++------- client/src/lib/page.js | 4 +- client/src/namespaces/CUD.js | 10 +- client/src/namespaces/List.js | 2 +- client/src/namespaces/root.js | 6 +- client/src/users/CUD.js | 16 +- client/src/users/List.js | 10 +- client/src/users/root.js | 48 ++-- client/webpack.config.js | 3 +- config/default.toml | 2 + lib/client-helpers.js | 26 ++ ...users-legacy.js => users-legacy-REMOVE.js} | 46 ---- lib/nodeify.js | 15 + lib/passport.js | 117 ++++---- lib/router-async.js | 3 +- lib/tools-async.js | 1 - models/namespaces.js | 2 - models/settings.js | 44 +++ models/users.js | 179 ++++++++++-- package.json | 1 + routes/account-legacy-integration.js | 15 + routes/account.js | 59 ---- routes/api.js | 32 +-- routes/namespaces-legacy-integration.js | 10 + routes/namespaces.js | 95 ------- routes/rest/account.js | 61 +++++ routes/rest/namespaces.js | 74 +++++ routes/rest/users.js | 44 +++ ...users-legacy.js => users-legacy-REMOVE.js} | 31 +-- routes/users-legacy-integration.js | 10 + routes/users.js | 70 ----- .../20170617123450_create_user_name.js | 4 +- shared/interoperable-errors.js | 16 +- test/e2e/lib/worker-counter.js | 2 - views/layout.hbs | 38 ++- views/{users => users-REMOVE}/api.hbs | 0 views/{users => users-REMOVE}/forgot.hbs | 0 views/{users => users-REMOVE}/login.hbs | 0 views/{users => users-REMOVE}/reset.hbs | 0 views/users/account.hbs | 72 ----- 49 files changed, 1554 insertions(+), 686 deletions(-) create mode 100644 client/src/account/API.js create mode 100644 client/src/account/Forgot.js create mode 100644 client/src/account/Login.js create mode 100644 client/src/account/Reset.js create mode 100644 lib/client-helpers.js rename lib/models/{users-legacy.js => users-legacy-REMOVE.js} (89%) create mode 100644 lib/nodeify.js create mode 100644 models/settings.js create mode 100644 routes/account-legacy-integration.js delete mode 100644 routes/account.js create mode 100644 routes/namespaces-legacy-integration.js delete mode 100644 routes/namespaces.js create mode 100644 routes/rest/account.js create mode 100644 routes/rest/namespaces.js create mode 100644 routes/rest/users.js rename routes/{users-legacy.js => users-legacy-REMOVE.js} (79%) create mode 100644 routes/users-legacy-integration.js delete mode 100644 routes/users.js rename views/{users => users-REMOVE}/api.hbs (100%) rename views/{users => users-REMOVE}/forgot.hbs (100%) rename views/{users => users-REMOVE}/login.hbs (100%) rename views/{users => users-REMOVE}/reset.hbs (100%) delete mode 100644 views/users/account.hbs 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')} +

+ + +

+ POST {t('arguments')} +

+ + +

+ {t('Additional POST arguments')}: +

+ + + +

+ {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')} +

+ + +

+ POST {t('arguments')} +

+ + +

+ {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')} +

+ + +

+ POST {t('arguments')} +

+ + +

+ {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')} +

+ + +

+ {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')} +

+ + +

+ POST {t('arguments')} +

+ + +

+ {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')} +

+ + +

+ POST {t('arguments')} +

+ + +

+ {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 ( +
+ {t('Account')} + +
+
+ + +
+ +
+

{t('You only need to fill out this form if you want to change your current password')}

+ + + +
+ + +
+ ); + } else {
{t('Account')} -
-
- - -
- -
-

{t('You only need to fill out this form if you want to change your current password')}

- - - -
- - -
- ); + } } } 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 ( +
+
+ +
+ {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 (
-
+
{this.props.children}
@@ -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 }} -