WhatsApp Clone with Meteor CLI and Ionic : 5. Authentication

by - April 02, 2017

On this step we will authenticate and identify users in our app.
Before we go ahead and start extending our app, we will add few packages which will make our lives a bit less complex when it comes to authentication and users management.
First we will add a Meteor package called accounts-phone which gives us the ability to verify a user using an SMS code:
$ meteor add npm-bcrypt@0.8.7
$ meteor add mys:accounts-phone
And second, we will add angular-meteor-auth which provides us with authentication related functions:
$ meteor npm install angular-meteor-auth
Of course, don't forget to load the relevant modules:


4.3 Load angular-meteor-auth moduleclient/scripts/lib/app.js
1
2
3
4
5
6
7
 
22
23
24
25
26
27
28

// Libs
import 'angular-animate';
import 'angular-meteor';
import 'angular-meteor-auth';
import 'angular-moment';
import 'angular-sanitize';
import 'angular-ui-router';
...some lines skipped...
// App
Angular.module(App, [
  'angular-meteor',
  'angular-meteor.auth',
  'angularMoment',
  'ionic'
]);
In order to make the SMS verification work we will need to create a file located in server/sms.js with the following contents:
4.4 Add SMS configurationserver/sms.js
1
2
3
4
5
6
7

import { Meteor } from 'meteor/meteor';
import { Accounts } from 'meteor/accounts-base';
 
if (Meteor.settings && Meteor.settings.ACCOUNTS_PHONE) {
  Accounts._options.adminPhoneNumbers = Meteor.settings.ACCOUNTS_PHONE.ADMIN_NUMBERS;
  Accounts._options.phoneVerificationMasterCode = Meteor.settings.ACCOUNTS_PHONE.MASTER_CODE;
}
If you would like to test the verification with a real phone number, accouts-phone provides an easy access for twilio's API, for more information see accounts-phone's repo.
For debugging purposes if you'd like to add admin phone numbers and mater verification codes which will always pass the verification stage, you may add a settings.json file at the root folder with the following fields:
{
  "ACCOUNTS_PHONE": {
    "ADMIN_NUMBERS": ["123456789", "987654321"],
    "MASTER_CODE": "1234"
  }
}
Now let's create the same flow of Whatsapp for authentication: first we need to ask for the user's phone number, verify it with an SMS message and then ask the user to pick his name.
So these flows are created by 3 views: login, confirmation and profile.
Let's add these states, each with HTML template and controller:
4.5 Create auth route statesclient/scripts/routes.js
2
3
4
5
6
7
8
9
10
 
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

 
import chatsTemplateUrl from '../templates/chats.html';
import chatTemplateUrl from '../templates/chat.html';
import confirmationTemplateUrl from '../templates/confirmation.html';
import loginTemplateUrl from '../templates/login.html';
import profileTemplateUrl from '../templates/profile.html';
import tabsTemplateUrl from '../templates/tabs.html';
 
export default class RoutesConfig extends Config {
...some lines skipped...
            controller: 'ChatCtrl as chat'
          }
        }
      })
      .state('login', {
        url: '/login',
        templateUrl: loginTemplateUrl,
        controller: 'LoginCtrl as logger'
      })
      .state('confirmation', {
        url: '/confirmation/:phone',
        templateUrl: confirmationTemplateUrl,
        controller: 'ConfirmationCtrl as confirmation'
      })
      .state('profile', {
        url: '/profile',
        templateUrl: profileTemplateUrl,
        controller: 'ProfileCtrl as profile'
      });
 
    this.$urlRouterProvider.otherwise('tab/chats');
We will now add the view of login state which includes an input and a save button and later we will add a modal dialog to verify the user's phone:
4.6 Add login viewclient/templates/login.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

<ion-view title="Your phone number">
  <ion-nav-buttons side="right">
    <button ng-click="logger.login()" ng-disabled="!logger.phone || logger.phone.length === 0" class="button button-clear button-positive">Done</button>
  </ion-nav-buttons>
  <ion-content class="login">
    <div class="text-center instructions">
      Please confirm your country code and enter your phone number
    </div>
    <div class="list">
      <label class="item item-input">
        <input ng-model="logger.phone" on-return="logger.login()" type="text" placeholder="Your phone number">
      </label>
    </div>
  </ion-content>
</ion-view>
And for the controller the logic is simple, we ask the user to check again his phone number, and then we will use Accounts API in order to ask for SMS verification:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

import { _ } from 'meteor/underscore';
import { Accounts } from 'meteor/accounts-base';
import { Controller } from 'angular-ecmascript/module-helpers';
 
export default class LoginCtrl extends Controller {
  login() {
    if (_.isEmpty(this.phone)) return;
 
    const confirmPopup = this.$ionicPopup.confirm({
      title: 'Number confirmation',
      template: '<div>' + this.phone + '</div><div>Is your phone number above correct?</div>',
      cssClass: 'text-center',
      okText: 'Yes',
      okType: 'button-positive button-clear',
      cancelText: 'edit',
      cancelType: 'button-dark button-clear'
    });
 
    confirmPopup.then((res) => {
      if (!res) return;
 
      this.$ionicLoading.show({
        template: 'Sending verification code...'
      });
 
      Accounts.requestPhoneVerification(this.phone, (err) => {
        this.$ionicLoading.hide();
        if (err) return this.handleError(err);
        this.$state.go('confirmation', { phone: this.phone });
      });
    });
  }
 
  handleError(err) {
    this.$log.error('Login error ', err);
 
    this.$ionicPopup.alert({
      title: err.reason || 'Login failed',
      template: 'Please try again',
      okType: 'button-positive button-clear'
    });
  }
}
 
LoginCtrl.$name = 'LoginCtrl';
LoginCtrl.$inject = ['$state', '$ionicLoading', '$ionicPopup', '$log'];
4.8 Load login controllerclient/scripts/lib/app.js
13
14
15
16
17
18
19
 
31
32
33
34
35
36
37

// Modules
import ChatsCtrl from '../controllers/chats.controller';
import ChatCtrl from '../controllers/chat.controller';
import LoginCtrl from '../controllers/login.controller';
import InputDirective from '../directives/input.directive';
import CalendarFilter from '../filters/calendar.filter';
import RoutesConfig from '../routes';
...some lines skipped...
new Loader(App)
  .load(ChatsCtrl)
  .load(ChatCtrl)
  .load(LoginCtrl)
  .load(InputDirective)
  .load(CalendarFilter)
  .load(RoutesConfig);
Note that we didn't provide all the settings for account-phone, so it will run in debug mode. It means that a real SMS won't be sent now, but if you'd like to receive the verification code just open your terminal and view Meteor's logs.
Our next step would be preventing unauthorized users from viewing contents which they have no permission to. In order to do that we will add a pre-requirement to the relevant routes which will require the user to log-in first. angular-meteor-auth provides us with a service which is called $auth, and it has a method called $awaitUser() which returns a promise that will be resolved only once the user has logged in. For more information about angular-meteor-authsee reference.
4.9 Add resolve to auth routesclient/scripts/routes.js
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
 
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72

import tabsTemplateUrl from '../templates/tabs.html';
 
export default class RoutesConfig extends Config {
  constructor() {
    super(...arguments);
 
    this.isAuthorized = ['$auth', this.isAuthorized.bind(this)];
  }
 
  configure() {
    this.$stateProvider
      .state('tab', {
        url: '/tab',
        abstract: true,
        templateUrl: tabsTemplateUrl,
        resolve: {
          user: this.isAuthorized
        }
      })
      .state('tab.chats', {
        url: '/chats',
...some lines skipped...
      .state('profile', {
        url: '/profile',
        templateUrl: profileTemplateUrl,
        controller: 'ProfileCtrl as profile',
        resolve: {
          user: this.isAuthorized
        }
      });
 
    this.$urlRouterProvider.otherwise('tab/chats');
  }
 
  isAuthorized($auth) {
    return $auth.awaitUser();
  }
}
 
RoutesConfig.$inject = ['$stateProvider', '$urlRouterProvider'];
And now we want to handle a case that this promise does not resolve (In case that the user is not logged in), so let's create a new run block to our routes.js file:
4.10 Add routes runnerclient/scripts/routes.js
1
2
3
4
5
 
8
9
10
11
12
13
14
 
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89

import { _ } from 'meteor/underscore';
import { Config, Runner } from 'angular-ecmascript/module-helpers';
 
import chatsTemplateUrl from '../templates/chats.html';
import chatTemplateUrl from '../templates/chat.html';
...some lines skipped...
import profileTemplateUrl from '../templates/profile.html';
import tabsTemplateUrl from '../templates/tabs.html';
 
class RoutesConfig extends Config {
  constructor() {
    super(...arguments);
 
...some lines skipped...
  }
}
 
RoutesConfig.$inject = ['$stateProvider', '$urlRouterProvider'];
 
class RoutesRunner extends Runner {
  run() {
    this.$rootScope.$on('$stateChangeError', (...args) => {
      const err = _.last(args);
 
      if (err === 'AUTH_REQUIRED') {
        this.$state.go('login');
      }
    });
  }
}
 
RoutesRunner.$inject = ['$rootScope', '$state'];
 
export default [RoutesConfig, RoutesRunner];
4.11 Create routes runnerclient/scripts/lib/app.js
16
17
18
19
20
21
22
 
34
35
36
37
38
39
40

import LoginCtrl from '../controllers/login.controller';
import InputDirective from '../directives/input.directive';
import CalendarFilter from '../filters/calendar.filter';
import Routes from '../routes';
 
const App = 'Whatsapp';
 
...some lines skipped...
  .load(LoginCtrl)
  .load(InputDirective)
  .load(CalendarFilter)
  .load(Routes);
 
// Startup
if (Meteor.isCordova) {
And now let's add some css:
4.12 Add login stylesheetclient/styles/login.scss
1
2
3
4
5
6

.login {
  .instructions {
    margin: 50px 0;
    padding: 0 15px;
  }
}
The next step is to add the confirmation view, starting with the HTML:
4.13 Add confirmation viewclient/templates/confirmation.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

<ion-view title="{{ confirmation.phone }}">
  <ion-nav-buttons side="right">
    <button ng-click="confirmation.confirm()" ng-disabled="!confirmation.code || confirmation.code.length === 0" class="button button-clear button-positive">Done</button>
  </ion-nav-buttons>
 
  <ion-content>
    <div class="text-center padding">
      We have sent you an SMS with a code to the number above
    </div>
    <div class="text-center padding">
      To complete your phone number verification WhatsApp, please enter the 4-digit activation code.
    </div>
 
    <div class="list padding-top">
      <label class="item item-input">
        <input ng-model="confirmation.code" on-return="confirmation.confirm()" type="text" placeholder="Code">
      </label>
    </div>
  </ion-content>
</ion-view>
And the controller:
4.14 Create confirmation controllerclient/scripts/controllers/confirmation.controller.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

import { _ } from 'meteor/underscore';
import { Accounts } from 'meteor/accounts-base';
import { Controller } from 'angular-ecmascript/module-helpers';
 
export default class ConfirmationCtrl extends Controller {
  constructor() {
    super(...arguments);
 
    this.phone = this.$state.params.phone;
  }
 
  confirm() {
    if (_.isEmpty(this.code)) return;
 
    Accounts.verifyPhone(this.phone, this.code, (err) => {
      if (err) return this.handleError(err);
      this.$state.go('profile');
    });
  }
 
  handleError(err) {
    this.$log.error('Confirmation error ', err);
 
    this.$ionicPopup.alert({
      title: err.reason || 'Confirmation failed',
      template: 'Please try again',
      okType: 'button-positive button-clear'
    });
  }
}
 
ConfirmationCtrl.$name = 'ConfirmationCtrl';
ConfirmationCtrl.$inject = ['$state', '$ionicPopup', '$log'];
4.15 Load confirmation controllerclient/scripts/lib/app.js
13
14
15
16
17
18
19
 
32
33
34
35
36
37
38

// Modules
import ChatsCtrl from '../controllers/chats.controller';
import ChatCtrl from '../controllers/chat.controller';
import ConfirmationCtrl from '../controllers/confirmation.controller';
import LoginCtrl from '../controllers/login.controller';
import InputDirective from '../directives/input.directive';
import CalendarFilter from '../filters/calendar.filter';
...some lines skipped...
new Loader(App)
  .load(ChatsCtrl)
  .load(ChatCtrl)
  .load(ConfirmationCtrl)
  .load(LoginCtrl)
  .load(InputDirective)
  .load(CalendarFilter)
We will use Accounts API again to verify the user and in case of successful authentication we will transition to the profile state, which we will add in the next step.
Let's implement the profile view, which provides the ability to enter the user's nickname and profile picture:
4.16 Add profile viewclient/templates/profile.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

<ion-view title="Profile">
  <ion-nav-buttons side="right">
    <button ng-click="profile.updateName()" ng-disabled="!profile.name || profile.name.length === 0" class="button button-clear button-positive">Done</button>
  </ion-nav-buttons>
 
  <ion-content class="profile">
    <a class="profile-picture positive">
      <div class="upload-placehoder">
        Add photo
      </div>
    </a>
 
    <div class="instructions">
      Enter your name and add an optional profile picture
    </div>
 
    <div class="list profile-name">
      <label class="item item-input">
        <input ng-model="profile.name" on-return="profile.updateName()" type="text" placeholder="Your name">
      </label>
    </div>
  </ion-content>
</ion-view>
And the controller:
4.17 Create profile controllerclient/scripts/controllers/profile.controller.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

import { _ } from 'meteor/underscore';
import { Controller } from 'angular-ecmascript/module-helpers';
 
export default class ProfileCtrl extends Controller {
  constructor() {
    super(...arguments);
 
    const profile = this.currentUser && this.currentUser.profile;
    this.name = profile ? profile.name : '';
  }
 
  updateName() {
    if (_.isEmpty(this.name)) return;
 
    this.callMethod('updateName', this.name, (err) => {
      if (err) return this.handleError(err);
      this.$state.go('tab.chats');
    });
  }
 
  handleError(err) {
    this.$log.error('Profile save error ', err);
 
    this.$ionicPopup.alert({
      title: err.reason || 'Save failed',
      template: 'Please try again',
      okType: 'button-positive button-clear'
    });
  }
}
 
ProfileCtrl.$name = 'ProfileCtrl';
ProfileCtrl.$inject = ['$state', '$ionicPopup', '$log'];
4.18 Load profile controllerclient/scripts/lib/app.js
15
16
17
18
19
20
21
 
35
36
37
38
39
40
41

import ChatCtrl from '../controllers/chat.controller';
import ConfirmationCtrl from '../controllers/confirmation.controller';
import LoginCtrl from '../controllers/login.controller';
import ProfileCtrl from '../controllers/profile.controller';
import InputDirective from '../directives/input.directive';
import CalendarFilter from '../filters/calendar.filter';
import Routes from '../routes';
...some lines skipped...
  .load(ChatCtrl)
  .load(ConfirmationCtrl)
  .load(LoginCtrl)
  .load(ProfileCtrl)
  .load(InputDirective)
  .load(CalendarFilter)
  .load(Routes);
And some css:
4.19 Add profile stylesheetclient/styles/profile.scss
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

.profile {
  padding-top: 20px;
 
  .profile-picture {
    position: absolute;
    top: 0;
    left: 20px;
    text-align: center;
 
    img {
      display: block;
      max-width: 50px;
      max-height: 50px;
      width: 100%;
      height: 100%;
      border-radius: 50%;
    }
 
    .upload-placehoder {
      width: 50px;
      height: 50px;
      padding: 5px;
      border: 1px solid #808080;
      border-radius: 50%;
      line-height: 18px;
      font-size: 12px;
    }
  }
 
  .instructions {
    min-height: 60px;
    padding: 10px 20px 20px 90px;
    font-size: 14px;
    color: gray;
  }
 
  .profile-name {
    margin-top: 20px;
  }
}
As you can see, the controller uses the server method updateName() which we need to implement in the lib/methods.js:
4.20 Add update name methodlib/methods.js
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

    Chats.update(message.chatId, { $set: { lastMessage: message } });
 
    return messageId;
  },
  updateName(name) {
    if (!this.userId) {
      throw new Meteor.Error('not-logged-in',
        'Must be logged in to update his name.');
    }
 
    check(name, String);
 
    if (name.length === 0) {
      throw Meteor.Error('name-required', 'Must provide a user name');
    }
 
    return Meteor.users.update(this.userId, { $set: { 'profile.name': name } });
  }
});
Meteor sets the user identity in case of a logged in user into the this.userId variable, so we can check if this variable exists in order to verify that the user is logged in.
Now let's add this validation to the newMessage() method we created earlier, and also add the identity of the user to each message he sends.
4.21 Bind user to any new messagelib/methods.js
3
4
5
6
7
8
9
10
11
12
13
 
15
16
17
18
19
20
21

 
Meteor.methods({
  newMessage(message) {
    if (!this.userId) {
      throw new Meteor.Error('not-logged-in',
        'Must be logged in to send message.');
    }
 
    check(message, {
      type: String,
      text: String,
...some lines skipped...
    });
 
    message.timestamp = new Date();
    message.userId = this.userId;
 
    const messageId = Messages.insert(message);
    Chats.update(message.chatId, { $set: { lastMessage: message } });
Great, now the last missing feature is logout. Let's add a state for the settings view:
4.22 Add settings route stateclient/scripts/routes.js
6
7
8
9
10
11
12
 
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75

import confirmationTemplateUrl from '../templates/confirmation.html';
import loginTemplateUrl from '../templates/login.html';
import profileTemplateUrl from '../templates/profile.html';
import settingsTemplateUrl from '../templates/settings.html';
import tabsTemplateUrl from '../templates/tabs.html';
 
class RoutesConfig extends Config {
...some lines skipped...
        resolve: {
          user: this.isAuthorized
        }
      })
      .state('tab.settings', {
        url: '/settings',
        views: {
          'tab-settings': {
            templateUrl: settingsTemplateUrl,
            controller: 'SettingsCtrl as settings',
          }
        }
      });
 
    this.$urlRouterProvider.otherwise('tab/chats');
And create the view which contains the logout button:
4.23 Add settings viewclient/templates/settings.html
1
2
3
4
5
6
7

<ion-view view-title="Settings">
  <ion-content>
    <div class="padding text-center">
      <button ng-click="settings.logout()" class="button button-clear button-assertive">Logout</button>
    </div>
  </ion-content>
</ion-view>
Now let's implement this method inside the SettingsCtrl:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

import { Meteor } from 'meteor/meteor';
import { Controller } from 'angular-ecmascript/module-helpers';
 
export default class SettingsCtrl extends Controller {
  logout() {
    Meteor.logout((err) => {
      if (err) return this.handleError(err);
      this.$state.go('login');
    })
  }
 
  handleError (err) {
    this.$log.error('Settings modification error', err);
 
    this.$ionicPopup.alert({
      title: err.reason || 'Settings modification failed',
      template: 'Please try again',
      okType: 'button-positive button-clear'
    });
  }
}
 
SettingsCtrl.$inject = ['$state', '$ionicPopup', '$log'];
4.25 Load settings controllerclient/scripts/lib/app.js
16
17
18
19
20
21
22
 
37
38
39
40
41
42
43

import ConfirmationCtrl from '../controllers/confirmation.controller';
import LoginCtrl from '../controllers/login.controller';
import ProfileCtrl from '../controllers/profile.controller';
import SettingsCtrl from '../controllers/settings.controller';
import InputDirective from '../directives/input.directive';
import CalendarFilter from '../filters/calendar.filter';
import Routes from '../routes';
...some lines skipped...
  .load(ConfirmationCtrl)
  .load(LoginCtrl)
  .load(ProfileCtrl)
  .load(SettingsCtrl)
  .load(InputDirective)
  .load(CalendarFilter)
  .load(Routes);
We also need to modify the way we identify our users inside the messages list, so let's do it:
4.26 Classify message ownershipclient/templates/chat.html
5
6
7
8
9
10
11

  <ion-content class="chat" delegate-handle="chatScroll">
    <div class="message-list">
      <div ng-repeat="message in chat.messages" class="message-wrapper">
        <div class="message" ng-class="message.userId === $root.currentUser._id ? 'message-mine' : 'message-other'">
          <div class="message-text">{{ message.text }}</div>
          <span class="message-timestamp">{{ message.timestamp | amDateFormat: 'HH:mm' }}</span>
        </div>
And the last missing feature is about adding auto-scroll to the messages list in order to keep the view scrolled down when new messages arrive:

4.27 Add auto-scroll to chat controllerclient/scripts/controllers/chat.controller.js
20
21
22
23
24
25
26
27
 
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74

        return Chats.findOne(this.chatId);
      }
    });
 
    this.autoScroll();
  }
 
  sendMessage() {
...some lines skipped...
    }
  }
 
  autoScroll() {
    let recentMessagesNum = this.messages.length;
 
    this.autorun(() => {
      const currMessagesNum = this.getCollectionReactively('messages').length;
      const animate = recentMessagesNum != currMessagesNum;
      recentMessagesNum = currMessagesNum;
      this.scrollBottom(animate);
    });
  }
 
  scrollBottom(animate) {
    this.$timeout(() => {
      this.$ionicScrollDelegate.$getByHandle('chatScroll').scrollBottom(animate);

You May Also Like

0 comments