Fork me on GitHub

WhatsApp Clone with Meteor CLI and Ionic

Authentication

Note: If you skipped ahead to this section, click here to download a zip of the tutorial at this point.

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 [email protected]
$ 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 module client/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 configuration server/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 states client/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 view client/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 controller client/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-auth see reference.

4.9 Add resolve to auth routes client/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 runner client/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 runner client/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 stylesheet client/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 view client/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 controller client/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 controller client/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 view client/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 controller client/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 controller client/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 stylesheet client/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 method lib/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 message lib/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 state client/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 view client/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 controller client/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 ownership client/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 controller client/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);