IGNITE-10839 Web Console: Added optional email confirmation.
authorAndrey Novikov <anovikov@apache.org>
Fri, 28 Dec 2018 06:52:50 +0000 (13:52 +0700)
committerAlexey Kuznetsov <akuznetsov@apache.org>
Fri, 28 Dec 2018 06:52:50 +0000 (13:52 +0700)
Co-authored-by: Andrey Novikov <anovikov@apache.org>
Co-authored-by: Ilya Borisov <klaster1@gmail.com>
29 files changed:
modules/web-console/assembly/README.txt
modules/web-console/backend/app/mongo.js
modules/web-console/backend/app/schemas.js
modules/web-console/backend/app/settings.js
modules/web-console/backend/config/settings.json.sample
modules/web-console/backend/errors/MissingConfirmRegistrationException.js [new file with mode: 0644]
modules/web-console/backend/errors/index.js
modules/web-console/backend/middlewares/api.js
modules/web-console/backend/routes/public.js
modules/web-console/backend/services/auth.js
modules/web-console/backend/services/mails.js
modules/web-console/backend/services/users.js
modules/web-console/frontend/app/app.js
modules/web-console/frontend/app/components/list-of-registered-users/categories.js
modules/web-console/frontend/app/components/list-of-registered-users/column-defs.js
modules/web-console/frontend/app/components/page-signin/component.ts
modules/web-console/frontend/app/components/page-signin/controller.ts
modules/web-console/frontend/app/components/page-signin/run.ts
modules/web-console/frontend/app/components/page-signin/template.pug
modules/web-console/frontend/app/components/page-signup-confirmation/component.ts [new file with mode: 0644]
modules/web-console/frontend/app/components/page-signup-confirmation/controller.ts [new file with mode: 0644]
modules/web-console/frontend/app/components/page-signup-confirmation/index.ts [new file with mode: 0644]
modules/web-console/frontend/app/components/page-signup-confirmation/state.ts [new file with mode: 0644]
modules/web-console/frontend/app/components/page-signup-confirmation/style.scss [new file with mode: 0644]
modules/web-console/frontend/app/components/page-signup-confirmation/template.tpl.pug [new file with mode: 0644]
modules/web-console/frontend/app/components/page-signup/controller.ts
modules/web-console/frontend/app/modules/user/Auth.service.ts
modules/web-console/frontend/app/modules/user/emailConfirmationInterceptor.ts [new file with mode: 0644]
modules/web-console/frontend/app/modules/user/user.module.js

index 2656aca..e88e345 100644 (file)
@@ -41,21 +41,24 @@ Technical details
     On Windows: `ignite-web-console-win.exe --server:port 3000`
 
 All available parameters with defaults:
-    Web Console host:           --server:host 0.0.0.0
-    Web Console port:           --server:port 80
+    Web Console host:                              --server:host 0.0.0.0
+    Web Console port:                              --server:port 80
 
-    Enable HTTPS:               --server:ssl false
+    Enable HTTPS:                                  --server:ssl false
+    Disable self registration:                     --server:disable:signup false
 
-    Disable self registration:  --server:disable:signup false
+    MongoDB URL:                                   --mongodb:url mongodb://localhost/console
 
-    MongoDB URL:                --mongodb:url mongodb://localhost/console
+    Enable account activation:                     --activation:enabled false
+    Activation timeout(milliseconds):              --activation:timeout 1800000
+    Activation send email throttle (milliseconds): --activation:sendTimeout 180000
 
-    Mail service:               --mail:service "gmail"
-    Signature text:             --mail:sign "Kind regards, Apache Ignite Team"
-    Greeting text:              --mail:greeting "Apache Ignite Web Console"
-    Mail FROM:                  --mail:from "Apache Ignite Web Console <someusername@somecompany.somedomain>"
-    User to send e-mail:        --mail:auth:user "someusername@somecompany.somedomain"
-    E-mail service password:    --mail:auth:pass ""
+    Mail service:                                  --mail:service "gmail"
+    Signature text:                                --mail:sign "Kind regards, Apache Ignite Team"
+    Greeting text:                                 --mail:greeting "Apache Ignite Web Console"
+    Mail FROM:                                     --mail:from "Apache Ignite Web Console <someusername@somecompany.somedomain>"
+    User to send e-mail:                           --mail:auth:user "someusername@somecompany.somedomain"
+    E-mail service password:                       --mail:auth:pass ""
 
 SSL options has no default values:
     --server:key "path to file with server.key"
index 6a843db..57171ac 100644 (file)
@@ -128,7 +128,8 @@ module.exports.factory = function(settings, mongoose, schemas) {
                                             admin: true,
                                             token: 'ruQvlWff09zqoVYyh6WJ',
                                             attempts: 0,
-                                            resetPasswordToken: 'O2GWgOkKkhqpDcxjYnSP'
+                                            resetPasswordToken: 'O2GWgOkKkhqpDcxjYnSP',
+                                            activated: true
                                         }),
                                         mongo.Space.create({
                                             _id: '59fc0c26e145c32be0f83b34',
@@ -146,5 +147,22 @@ module.exports.factory = function(settings, mongoose, schemas) {
 
                     return mongo;
                 });
+        })
+        .then((mongo) => {
+            if (settings.activation.enabled) {
+                return mongo.Account.find({
+                    $or: [{activated: false}, {activated: {$exists: false}}],
+                    activationToken: {$exists: false}
+                }, '_id').lean().exec()
+                    .then((accounts) => {
+                        const conditions = _.map(accounts, (account) => ({session: {$regex: `"${account._id}"`}}));
+
+                        return mongoose.connection.db.collection('sessions').deleteMany({$or: conditions});
+                    })
+                    .then(() => mongo)
+                    .catch(() => mongo);
+            }
+
+            return mongo;
         });
 };
index 14ce111..2f6498f 100644 (file)
@@ -46,7 +46,10 @@ module.exports.factory = function(mongoose) {
         lastActivity: Date,
         admin: Boolean,
         token: String,
-        resetPasswordToken: String
+        resetPasswordToken: String,
+        activated: {type: Boolean, default: false},
+        activationSentAt: Date,
+        activationToken: String
     });
 
     // Install passport plugin.
index 9c4d3c3..0079788 100644 (file)
@@ -18,6 +18,7 @@
 'use strict';
 
 const fs = require('fs');
+const _ = require('lodash');
 
 // Fire me up!
 
@@ -61,6 +62,14 @@ module.exports = {
             return v === 'true' || v === true;
         };
 
+        let activationEnabled = _isTrue('activation:enabled');
+
+        if (activationEnabled && _.isEmpty(mail)) {
+            activationEnabled = false;
+
+            console.warn('Mail server settings are required for account confirmation!');
+        }
+
         const settings = {
             agent: {
                 dists: nconf.get('agent:dists') || dfltAgentDists
@@ -72,6 +81,11 @@ module.exports = {
                 disableSignup: _isTrue('server:disable:signup')
             },
             mail,
+            activation: {
+                enabled: activationEnabled,
+                timeout: nconf.get('activation:timeout') || 1800000,
+                sendTimeout: nconf.get('activation:sendTimeout') || 180000
+            },
             mongoUrl: nconf.get('mongodb:url') || 'mongodb://127.0.0.1/console',
             cookieTTL: 3600000 * 24 * 30,
             sessionSecret: nconf.get('server:sessionSecret') || 'keyboard cat',
index c16ba26..02bc327 100644 (file)
   "mongodb": {
     "url": "mongodb://localhost/console"
   },
+  "activation": {
+      "enabled": false,
+      "timeout": 1800000,
+      "sendTimeout": 180000
+  },
   "mail": {
     "service": "gmail",
     "from": "Some Company Web Console <some_username@some_company.com>",
diff --git a/modules/web-console/backend/errors/MissingConfirmRegistrationException.js b/modules/web-console/backend/errors/MissingConfirmRegistrationException.js
new file mode 100644 (file)
index 0000000..a094a67
--- /dev/null
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+'use strict';
+
+const IllegalAccessError = require('./IllegalAccessError');
+
+class MissingConfirmRegistrationException extends IllegalAccessError {
+    constructor(email) {
+        super('User account email not activated');
+
+        this.data = {
+            errorCode: 10104,
+            message: this.message,
+            email
+        };
+    }
+}
+
+module.exports = MissingConfirmRegistrationException;
index 2fadc12..cb8f043 100644 (file)
@@ -24,6 +24,7 @@ const IllegalArgumentException = require('./IllegalArgumentException');
 const IllegalAccessError = require('./IllegalAccessError');
 const DuplicateKeyException = require('./DuplicateKeyException');
 const ServerErrorException = require('./ServerErrorException');
+const MissingConfirmRegistrationException = require('./MissingConfirmRegistrationException');
 const MissingResourceException = require('./MissingResourceException');
 const AuthFailedException = require('./AuthFailedException');
 
@@ -35,6 +36,7 @@ module.exports = {
         IllegalArgumentException,
         DuplicateKeyException,
         ServerErrorException,
+        MissingConfirmRegistrationException,
         MissingResourceException,
         AuthFailedException
     })
index 27e130d..d4d832b 100644 (file)
@@ -37,6 +37,9 @@ module.exports.factory = () => {
                 if (_.includes(['MongoError', 'MongooseError'], err.name))
                     return res.status(500).send(err.message);
 
+                if (_.isObject(err.data))
+                    return res.status(err.httpCode || err.code || 500).json(err.data);
+
                 res.status(err.httpCode || err.code || 500).send(err.message);
             },
 
index 8aa3b43..d290b53 100644 (file)
@@ -25,17 +25,18 @@ const passport = require('passport');
 
 module.exports = {
     implements: 'routes/public',
-    inject: ['mongo', 'services/mails', 'services/users', 'services/auth']
+    inject: ['mongo', 'settings', 'services/users', 'services/auth', 'errors']
 };
 
-/**
+/**                             
  * @param mongo
- * @param mailsService
+ * @param settings
  * @param {UsersService} usersService
  * @param {AuthService} authService
+ * @param errors
  * @returns {Promise}
  */
-module.exports.factory = function(mongo, mailsService, usersService, authService) {
+module.exports.factory = function(mongo, settings, usersService, authService, errors) {
     return new Promise((factoryResolve) => {
         const router = new express.Router();
 
@@ -76,14 +77,34 @@ module.exports.factory = function(mongo, mailsService, usersService, authService
         router.post('/signin', (req, res, next) => {
             passport.authenticate('local', (errAuth, user) => {
                 if (errAuth)
-                    return res.status(401).send(errAuth.message);
+                    return res.api.error(new errors.AuthFailedException(errAuth.message));
 
                 if (!user)
-                    return res.status(401).send('Invalid email or password');
+                    return res.api.error(new errors.AuthFailedException('Invalid email or password'));
 
-                req.logIn(user, {}, (errLogIn) => {
+                if (settings.activation.enabled) {
+                    const activationToken = req.body.activationToken;
+
+                    const errToken = authService.validateActivationToken(user, activationToken);
+
+                    if (errToken)
+                        return res.api.error(errToken);
+
+                    if (authService.isActivationTokenExpired(user, activationToken)) {
+                        authService.resetActivationToken(req.origin(), user.email)
+                            .catch((ignored) => {
+                                // No-op.
+                            });
+
+                        return res.api.error(new errors.AuthFailedException('This activation link was expired. We resend a new one. Please open the most recent email and click on the activation link.'));
+                    }
+
+                    user.activated = true;
+                }
+
+                return req.logIn(user, {}, (errLogIn) => {
                     if (errLogIn)
-                        return res.status(401).send(errLogIn.message);
+                        return res.api.error(new errors.AuthFailedException(errLogIn.message));
 
                     return res.sendStatus(200);
                 });
@@ -103,10 +124,8 @@ module.exports.factory = function(mongo, mailsService, usersService, authService
          * Send e-mail to user with reset token.
          */
         router.post('/password/forgot', (req, res) => {
-            authService.resetPasswordToken(req.body.email)
-                .then((user) => mailsService.emailUserResetLink(req.origin(), user))
-                .then(() => 'An email has been sent with further instructions.')
-                .then(res.api.ok)
+            authService.resetPasswordToken(req.origin(), req.body.email)
+                .then(() => res.api.ok('An email has been sent with further instructions.'))
                 .catch(res.api.error);
         });
 
@@ -116,8 +135,7 @@ module.exports.factory = function(mongo, mailsService, usersService, authService
         router.post('/password/reset', (req, res) => {
             const {token, password} = req.body;
 
-            authService.resetPasswordByToken(token, password)
-                .then((user) => mailsService.emailPasswordChanged(req.origin(), user))
+            authService.resetPasswordByToken(req.origin(), token, password)
                 .then(res.api.ok)
                 .catch(res.api.error);
         });
@@ -131,6 +149,13 @@ module.exports.factory = function(mongo, mailsService, usersService, authService
                 .catch(res.api.error);
         });
 
+        /* Send e-mail to user with account confirmation token. */
+        router.post('/activation/resend', (req, res) => {
+            authService.resetActivationToken(req.origin(), req.body.email)
+                .then(() => res.api.ok('An email has been sent with further instructions.'))
+                .catch(res.api.error);
+        });
+
         factoryResolve(router);
     });
 };
index 986ed95..4679957 100644 (file)
 
 // Fire me up!
 
+const _ = require('lodash');
+
 module.exports = {
     implements: 'services/auth',
-    inject: ['mongo', 'settings', 'errors', 'services/utils']
+    inject: ['mongo', 'settings', 'errors', 'services/utils', 'services/mails']
 };
 
 /**
@@ -29,37 +31,44 @@ module.exports = {
  * @param settings
  * @param errors
  * @param {UtilsService} utilsService
+ * @param {MailsService} mailsService
  * @returns {AuthService}
  */
 
-module.exports.factory = (mongo, settings, errors, utilsService) => {
+module.exports.factory = (mongo, settings, errors, utilsService, mailsService) => {
     class AuthService {
         /**
          * Reset password reset token for user.
          *
+         * @param host Web Console host.
          * @param email - user email
          * @returns {Promise.<mongo.Account>} - that resolves account found by email with new reset password token.
          */
-        static resetPasswordToken(email) {
+        static resetPasswordToken(host, email) {
             return mongo.Account.findOne({email}).exec()
                 .then((user) => {
                     if (!user)
                         throw new errors.MissingResourceException('Account with that email address does not exists!');
 
+                    if (settings.activation.enabled && !user.activated)
+                        throw new errors.MissingConfirmRegistrationException(user.email);
+
                     user.resetPasswordToken = utilsService.randomString(settings.tokenLength);
 
                     return user.save();
-                });
+                })
+                .then((user) => mailsService.emailUserResetLink(host, user));
         }
 
         /**
          * Reset password by reset token.
          *
+         * @param host Web Console host.
          * @param {string} token - reset token
          * @param {string} newPassword - new password
          * @returns {Promise.<mongo.Account>} - that resolves account with new password
          */
-        static resetPasswordByToken(token, newPassword) {
+        static resetPasswordByToken(host, token, newPassword) {
             return mongo.Account.findOne({resetPasswordToken: token}).exec()
                 .then((user) => {
                     if (!user)
@@ -75,7 +84,8 @@ module.exports.factory = (mongo, settings, errors, utilsService) => {
                             resolve(_user.save());
                         });
                     });
-                });
+                })
+                .then((user) => mailsService.emailPasswordChanged(host, user));
         }
 
         /**
@@ -93,6 +103,67 @@ module.exports.factory = (mongo, settings, errors, utilsService) => {
                     return {token, email: user.email};
                 });
         }
+
+        /**
+         * Validate activationToken token.
+         *
+         * @param {mongo.Account} user - User object.
+         * @param {string} activationToken - activate account token
+         * @return {Error} If token is invalid.
+         */
+        static validateActivationToken(user, activationToken) {
+            if (user.activated) {
+                if (user.activationToken !== activationToken)
+                    return new errors.AuthFailedException('Invalid email or password!');
+            }
+            else {
+                if (_.isEmpty(activationToken))
+                    return new errors.MissingConfirmRegistrationException(user.email);
+
+                if (user.activationToken !== activationToken)
+                    return new errors.AuthFailedException('This activation token isn\'t valid.');
+            }
+        }
+
+        /**
+         * Check if activation token expired.
+         *
+         * @param {mongo.Account} user - User object.
+         * @param {string} activationToken - activate account token
+         * @return {boolean} If token was already expired.
+         */
+        static isActivationTokenExpired(user, activationToken) {
+            return !user.activated &&
+                new Date().getTime() - user.activationSentAt.getTime() >= settings.activation.timeout;
+        }
+
+        /**
+         * Reset password reset token for user.
+         *
+         * @param host Web Console host.
+         * @param email - user email.
+         * @returns {Promise}.
+         */
+        static resetActivationToken(host, email) {
+            return mongo.Account.findOne({email}).exec()
+                .then((user) => {
+                    if (!user)
+                        throw new errors.MissingResourceException('Account with that email address does not exists!');
+
+                    if (!settings.activation.enabled)
+                        throw new errors.IllegalAccessError('Activation was not enabled!');
+
+                    if (user.activationSentAt &&
+                        new Date().getTime() - user.activationSentAt.getTime() < settings.activation.sendTimeout)
+                        throw new errors.IllegalAccessError('Too Many Activation Attempts!');
+
+                    user.activationToken = utilsService.randomString(settings.tokenLength);
+                    user.activationSentAt = new Date();
+
+                    return user.save();
+                })
+                .then((user) =>  mailsService.emailUserActivation(host, user));
+        }
     }
 
     return AuthService;
index 75da128..183fbe1 100644 (file)
@@ -108,6 +108,24 @@ module.exports.factory = (settings) => {
 
         /**
          * Send email to user for password reset.
+         *
+         * @param host
+         * @param user
+         */
+        emailUserActivation(host, user) {
+            const activationLink = `${host}/signin?activationToken=${user.activationToken}`;
+
+            return this.send(user, `Confirm your account on ${settings.mail.greeting}`,
+                `Hello ${user.firstName} ${user.lastName}!<br><br>` +
+                `You are receiving this email because you have signed up to use <a href="${host}">${settings.mail.greeting}</a>.<br><br>` +
+                'Please click on the following link, or paste this into your browser to activate your account:<br><br>' +
+                `<a href="${activationLink}">${activationLink}</a>`,
+                'Failed to send email with confirm account link!');
+        }
+
+        /**
+         * Send email to user for password reset.
+         *
          * @param host
          * @param user
          */
index 79578c5..ed844db 100644 (file)
@@ -53,6 +53,8 @@ module.exports.factory = (errors, settings, mongo, spacesService, mailsService,
                     user.admin = cnt === 0;
                     user.registered = new Date();
                     user.token = utilsService.randomString(settings.tokenLength);
+                    user.resetPasswordToken = utilsService.randomString(settings.tokenLength);
+                    user.activated = false;
 
                     if (settings.server.disableSignup && !user.admin && !createdByAdmin)
                         throw new errors.ServerErrorException('Sign-up is not allowed. Ask your Web Console administrator to create account for you.');
@@ -73,15 +75,27 @@ module.exports.factory = (errors, settings, mongo, spacesService, mailsService,
                     });
                 })
                 .then((registered) => {
-                    registered.resetPasswordToken = utilsService.randomString(settings.tokenLength);
+                    return mongo.Space.create({name: 'Personal space', owner: registered._id})
+                        .then(() => registered)
+                })
+                .then((registered) => {
+                    if (settings.activation.enabled) {
+                        registered.activationToken = utilsService.randomString(settings.tokenLength);
+                        registered.activationSentAt = new Date();
 
-                    return registered.save()
-                        .then(() => mongo.Space.create({name: 'Personal space', owner: registered._id}))
-                        .then(() => {
-                            mailsService.emailUserSignUp(host, registered, createdByAdmin);
+                        if (!createdByAdmin) {
+                            return registered.save()
+                                .then(() => {
+                                    mailsService.emailUserActivation(host, registered);
 
-                            return registered;
-                        });
+                                    throw new errors.MissingConfirmRegistrationException(registered.email);
+                                });
+                        }
+                    }
+
+                    mailsService.emailUserSignUp(host, registered, createdByAdmin);
+
+                    return registered;
                 });
         }
 
@@ -93,6 +107,9 @@ module.exports.factory = (errors, settings, mongo, spacesService, mailsService,
          */
         static save(changed) {
             delete changed.admin;
+            delete changed.activated;
+            delete changed.activationSentAt;
+            delete changed.activationToken;
 
             return mongo.Account.findById(changed._id).exec()
                 .then((user) => {
@@ -157,6 +174,7 @@ module.exports.factory = (errors, settings, mongo, spacesService, mailsService,
                             country: 1,
                             lastLogin: 1,
                             lastActivity: 1,
+                            activated: 1,
                             spaces: {
                                 $filter: {
                                     input: '$spaces',
index fd75ade..94f9704 100644 (file)
@@ -161,6 +161,7 @@ import pageForgotPassword from './components/page-forgot-password';
 import formSignup from './components/form-signup';
 import sidebar from './components/web-console-sidebar';
 import permanentNotifications from './components/permanent-notifications';
+import signupConfirmation from './components/page-signup-confirmation';
 
 import igniteServices from './services';
 
@@ -268,7 +269,9 @@ export default angular.module('ignite-console', [
     formSignup.name,
     timedRedirection.name,
     sidebar.name,
-    permanentNotifications.name
+    permanentNotifications.name,
+    timedRedirection.name,
+    signupConfirmation.name
 ])
 .service('$exceptionHandler', $exceptionHandler)
 // Directives.
index e73dd17..aa70863 100644 (file)
@@ -19,6 +19,7 @@ export default [
     {name: 'Actions', visible: false, enableHiding: false},
     {name: 'User', visible: true, enableHiding: false},
     {name: 'Email', visible: true, enableHiding: true},
+    {name: 'Activated', visible: false, enableHiding: true},
     {name: 'Company', visible: true, enableHiding: true},
     {name: 'Country', visible: true, enableHiding: true},
     {name: 'Last login', visible: false, enableHiding: true},
index 2094f0c..b1c71f7 100644 (file)
@@ -32,6 +32,7 @@ const VALUE_WITH_TITLE = '<div class="ui-grid-cell-contents"><label bs-tooltip d
 export default [
     {name: 'user', enableHiding: false, displayName: 'User', categoryDisplayName: 'User', field: 'userName', cellTemplate: USER_TEMPLATE, minWidth: 160, enableFiltering: true, pinnedLeft: true, filter: { placeholder: 'Filter by name...' }},
     {name: 'email', displayName: 'Email', categoryDisplayName: 'Email', field: 'email', cellTemplate: EMAIL_TEMPLATE, minWidth: 160, width: 220, enableFiltering: true, filter: { placeholder: 'Filter by email...' }},
+    {name: 'activated', displayName: 'Activated', categoryDisplayName: 'Activated', field: 'activated', width: 220, enableFiltering: true, filter: { placeholder: 'Filter by activation...' }, visible: false},
     {name: 'company', displayName: 'Company', categoryDisplayName: 'Company', field: 'company', cellTemplate: VALUE_WITH_TITLE, minWidth: 180, enableFiltering: true, filter: { placeholder: 'Filter by company...' }},
     {name: 'country', displayName: 'Country', categoryDisplayName: 'Country', field: 'countryCode', cellTemplate: VALUE_WITH_TITLE, minWidth: 160, enableFiltering: true, filter: { placeholder: 'Filter by country...' }},
     {name: 'lastlogin', displayName: 'Last login', categoryDisplayName: 'Last login', field: 'lastLogin', cellTemplate: DATE_WITH_TITLE, minWidth: 135, width: 135, enableFiltering: false, visible: false},
index 968ff39..6c5f461 100644 (file)
@@ -22,5 +22,8 @@ import './style.scss';
 /** @type {ng.IComponentOptions} */
 export default {
     controller,
-    template
+    template,
+    bindings: {
+        activationToken: '@?'
+    }
 };
index 18fee0b..71caa0e 100644 (file)
@@ -17,6 +17,8 @@
 
 import AuthService from 'app/modules/user/Auth.service';
 
+import {PageSigninStateParams} from './run';
+
 interface ISiginData {
     email: string,
     password: string
@@ -27,7 +29,9 @@ interface ISigninFormController extends ng.IFormController {
     password: ng.INgModelController
 }
 
-export default class implements ng.IPostLink {
+export default class PageSignIn implements ng.IPostLink {
+    activationToken?: PageSigninStateParams['activationToken'];
+
     data: ISiginData = {
         email: null,
         password: null
@@ -65,8 +69,8 @@ export default class implements ng.IPostLink {
         if (!this.canSubmitForm(this.form))
             return;
 
-        return this.Auth.signin(this.data.email, this.data.password).catch((res) => {
-            this.IgniteMessages.showError(null, res.data);
+        return this.Auth.signin(this.data.email, this.data.password, this.activationToken).catch((res) => {
+            this.IgniteMessages.showError(null, res.data.errorMessage ? res.data.errorMessage : res.data);
 
             this.setServerError(res.data);
 
index 4c0a1e6..1d13397 100644 (file)
  */
 
 import publicTemplate from '../../../views/public.pug';
-import {UIRouter} from '@uirouter/angularjs';
+import {UIRouter, StateParams} from '@uirouter/angularjs';
 import {IIgniteNg1StateDeclaration} from 'app/types';
 
+export type PageSigninStateParams = StateParams & {activationToken?: string};
+
 export function registerState($uiRouter: UIRouter) {
     const state: IIgniteNg1StateDeclaration = {
-        url: '/signin',
+        url: '/signin?{activationToken:string}',
         name: 'signin',
         views: {
             '': {
@@ -54,6 +56,11 @@ export function registerState($uiRouter: UIRouter) {
         },
         tfMetaTags: {
             title: 'Sign In'
+        },
+        resolve: {
+            activationToken() {
+                return $uiRouter.stateService.transition.params<PageSigninStateParams>().activationToken;
+            }
         }
     };
 
index 2a128a0..c85976f 100644 (file)
@@ -17,6 +17,8 @@
 include /app/helpers/jade/mixins
 
 h3.public-page__title Sign In
+p(ng-if='$ctrl.activationToken')
+    | Please sign in to confirm your registration
 form(name='$ctrl.form' novalidate ng-submit='$ctrl.signin()')
     +form-field__email({
         label: 'Email:',
@@ -45,6 +47,6 @@ form(name='$ctrl.form' novalidate ng-submit='$ctrl.signin()')
         a(ui-sref='forgotPassword({email: $ctrl.data.email})') Forgot password?
         button.btn-ignite.btn-ignite--primary(
             type='submit'
-        ) Sign In
+        ) {{ ::$ctrl.activationToken ? "Activate" : "Sign In" }}
 footer.page-signin__no-account-message
     | Don't have an account? #[a(ui-sref='signup') Get started]
diff --git a/modules/web-console/frontend/app/components/page-signup-confirmation/component.ts b/modules/web-console/frontend/app/components/page-signup-confirmation/component.ts
new file mode 100644 (file)
index 0000000..3a1cc81
--- /dev/null
@@ -0,0 +1,28 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import controller from './controller';
+import templateUrl from './template.tpl.pug';
+import './style.scss';
+
+export const component: ng.IComponentOptions = {
+    controller,
+    templateUrl,
+    bindings: {
+        email: '@'
+    }
+};
diff --git a/modules/web-console/frontend/app/components/page-signup-confirmation/controller.ts b/modules/web-console/frontend/app/components/page-signup-confirmation/controller.ts
new file mode 100644 (file)
index 0000000..e5f5c89
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {default as Auth} from '../../modules/user/Auth.service';
+import {default as MessagesFactory} from '../../services/Messages.service';
+
+export default class PageSignupConfirmation {
+    email: string;
+
+    static $inject = ['Auth', 'IgniteMessages', '$element'];
+
+    constructor(private auth: Auth, private messages: ReturnType<typeof MessagesFactory>, private el: JQLite) {
+    }
+
+    $postLink() {
+        this.el.addClass('public-page');
+    }
+
+    async resendConfirmation() {
+        try {
+            await this.auth.resendSignupConfirmation(this.email);
+            this.messages.showInfo('Signup confirmation sent, check your email');
+        }
+        catch (e) {
+            this.messages.showError(e);
+        }
+    }
+}
diff --git a/modules/web-console/frontend/app/components/page-signup-confirmation/index.ts b/modules/web-console/frontend/app/components/page-signup-confirmation/index.ts
new file mode 100644 (file)
index 0000000..8df7532
--- /dev/null
@@ -0,0 +1,23 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {component} from './component';
+import {state} from './state';
+
+export default angular.module('ignite-console.page-signup-confirmation', [])
+    .run(state)
+    .component('pageSignupConfirmation', component);
diff --git a/modules/web-console/frontend/app/components/page-signup-confirmation/state.ts b/modules/web-console/frontend/app/components/page-signup-confirmation/state.ts
new file mode 100644 (file)
index 0000000..fb1b6a4
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {UIRouter, StateParams} from '@uirouter/angularjs';
+import {IIgniteNg1StateDeclaration} from '../../types';
+import publicTemplate from '../../../views/public.pug';
+
+export type PageSignupConfirmationStateParams = StateParams & {email: string};
+
+state.$inject = ['$uiRouter'];
+
+export function state(router: UIRouter) {
+    router.stateRegistry.register({
+        name: 'signup-confirmation',
+        url: '/signup-confirmation?{email:string}',
+        views: {
+            '': {
+                template: publicTemplate
+            },
+            'page@signup-confirmation': {
+                component: 'pageSignupConfirmation'
+            }
+        },
+        unsaved: true,
+        tfMetaTags: {
+            title: 'Sign Up Confirmation'
+        },
+        resolve: {
+            email() {
+                return router.stateService.transition.params<PageSignupConfirmationStateParams>().email;
+            }
+        }
+    } as IIgniteNg1StateDeclaration);
+}
diff --git a/modules/web-console/frontend/app/components/page-signup-confirmation/style.scss b/modules/web-console/frontend/app/components/page-signup-confirmation/style.scss
new file mode 100644 (file)
index 0000000..d3c062b
--- /dev/null
@@ -0,0 +1,22 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+page-signup-confirmation {
+    display: flex;
+    flex-direction: column;
+    flex: 1 0 auto;
+}
diff --git a/modules/web-console/frontend/app/components/page-signup-confirmation/template.tpl.pug b/modules/web-console/frontend/app/components/page-signup-confirmation/template.tpl.pug
new file mode 100644 (file)
index 0000000..a1d183c
--- /dev/null
@@ -0,0 +1,24 @@
+//-
+    Licensed to the Apache Software Foundation (ASF) under one or more
+    contributor license agreements.  See the NOTICE file distributed with
+    this work for additional information regarding copyright ownership.
+    The ASF licenses this file to You under the Apache License, Version 2.0
+    (the "License"); you may not use this file except in compliance with
+    the License.  You may obtain a copy of the License at
+
+         http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+
+
+h3.public-page__title Confirm your email
+p
+    | Thanks For Signing Up!
+    br
+    | Please check your email and click link in the message we just sent to <b>{{::$ctrl.email}}</b>.
+    br
+    | If you don’t receive email try to <a ng-click='$ctrl.resendConfirmation()'>resend confirmation</a> once more.
index 93d9987..748bf7f 100644 (file)
@@ -19,6 +19,10 @@ import Auth from '../../modules/user/Auth.service';
 import MessagesFactory from '../../services/Messages.service';
 import FormUtilsFactoryFactory from '../../services/FormUtils.service';
 import {ISignupData} from '../form-signup';
+import {get, eq, pipe} from 'lodash/fp';
+
+const EMAIL_NOT_CONFIRMED_ERROR_CODE = 10104;
+const isEmailConfirmationError = pipe(get('data.errorCode'), eq(EMAIL_NOT_CONFIRMED_ERROR_CODE));
 
 export default class PageSignup implements ng.IPostLink {
     form: ng.IFormController;
@@ -64,6 +68,9 @@ export default class PageSignup implements ng.IPostLink {
             return;
 
         return this.Auth.signup(this.data).catch((res) => {
+            if (isEmailConfirmationError(res))
+                return;
+
             this.IgniteMessages.showError(null, res.data);
             this.setServerError(res.data);
         });
index 55956ad..08cf263 100644 (file)
@@ -30,7 +30,7 @@ type SignupUserInfo = {
 };
 
 type AuthActions = 'signin' | 'signup' | 'password/forgot';
-type AuthOptions = {email:string, password:string}|SignupUserInfo|{email:string};
+type AuthOptions = {email:string, password:string, activationToken?: string}|SignupUserInfo|{email:string};
 
 export default class AuthService {
     static $inject = ['$http', '$rootScope', '$state', '$window', 'IgniteMessages', 'gettingStarted', 'User'];
@@ -49,8 +49,8 @@ export default class AuthService {
         return this._auth('signup', userInfo, loginAfterSignup);
     }
 
-    signin(email: string, password: string) {
-        return this._auth('signin', {email, password});
+    signin(email: string, password: string, activationToken?: string) {
+        return this._auth('signin', {email, password, activationToken});
     }
 
     remindPassword(email: string) {
@@ -87,4 +87,12 @@ export default class AuthService {
             })
             .catch((e) => this.Messages.showError(e));
     }
+
+    async resendSignupConfirmation(email: string) {
+        try {
+            return await this.$http.post('/api/v1/activation/resend/', {email});
+        } catch (res) {
+            throw res.data;
+        }
+    }
 }
diff --git a/modules/web-console/frontend/app/modules/user/emailConfirmationInterceptor.ts b/modules/web-console/frontend/app/modules/user/emailConfirmationInterceptor.ts
new file mode 100644 (file)
index 0000000..ce377b1
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {UIRouter} from '@uirouter/angularjs';
+
+registerInterceptor.$inject = ['$httpProvider'];
+
+export function registerInterceptor(http: ng.IHttpProvider) {
+    emailConfirmationInterceptor.$inject = ['$q', '$injector'];
+
+    function emailConfirmationInterceptor($q: ng.IQService, $injector: ng.auto.IInjectorService): ng.IHttpInterceptor {
+        return {
+            responseError(res) {
+                if (res.status === 403 && res.data && res.data.errorCode === 10104)
+                    $injector.get<UIRouter>('$uiRouter').stateService.go('signup-confirmation', {email: res.data.email});
+
+                return $q.reject(res);
+            }
+        };
+    }
+
+    http.interceptors.push(emailConfirmationInterceptor as ng.IHttpInterceptorFactory);
+}
index e11a2d4..c78cc8c 100644 (file)
@@ -20,6 +20,7 @@ import aclData from './permissions';
 
 import Auth from './Auth.service';
 import User from './User.service';
+import {registerInterceptor} from './emailConfirmationInterceptor';
 
 /**
  * @param {ng.auto.IInjectorService} $injector
@@ -94,15 +95,17 @@ function run($root, $transitions, AclService, User, Activities) {
 
 run.$inject = ['$rootScope', '$transitions', 'AclService', 'User', 'IgniteActivitiesData'];
 
-angular.module('ignite-console.user', [
-    'mm.acl',
-    'ignite-console.config',
-    'ignite-console.core'
-])
-.factory('sessionRecoverer', sessionRecoverer)
-.config(['$httpProvider', ($httpProvider) => {
-    $httpProvider.interceptors.push('sessionRecoverer');
-}])
-.service('Auth', Auth)
-.service('User', User)
-.run(run);
+angular
+    .module('ignite-console.user', [
+        'mm.acl',
+        'ignite-console.config',
+        'ignite-console.core'
+    ])
+    .factory('sessionRecoverer', sessionRecoverer)
+    .config(registerInterceptor)
+    .config(['$httpProvider', ($httpProvider) => {
+        $httpProvider.interceptors.push('sessionRecoverer');
+    }])
+    .service('Auth', Auth)
+    .service('User', User)
+    .run(run);