Fork me on GitHub

WhatsApp Clone with Meteor and Ionic CLI

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.

Firt we will update our api and 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 update the client, and add authentication packages to it. We will add accounts-phone which is the same package we installed in our api, only this time it's for the client, and angular-meteor-auth which is an extension for Angular based on angular-meteor, and it contains different utility functions to help us implement authentication.

In terminal, type:

$ npm install accounts-phone --save
$ npm install angular-meteor-auth --save

And import them in our index.js:

5.3 Import auth modules in index js src/index.js
8
9
10
11
12
13
14
15
16
17
import 'script!lib/ionic/js/ionic';
import 'script!lib/ionic/js/ionic-angular';
import 'script!meteor-client-side/dist/meteor-client-side.bundle';
import 'script!accounts-base-client-side/dist/accounts-base-client-side.bundle';
import 'script!accounts-phone/dist/accounts-phone.bundle.min';
import 'script!angular-meteor/dist/angular-meteor.bundle';
import 'script!angular-meteor-auth/dist/angular-meteor-auth';
import 'script!angular-moment/angular-moment';
// api
import 'api/methods';

And since angular-meteor-auth is an Angular extension we will need to add it as a module dependency in our app:

5.4 Add angular-meteor-auth module to app dependencies src/app.js
14
15
16
17
18
19
20
 
Angular.module(App, [
  'angular-meteor',
  'angular-meteor.auth',
  'angularMoment',
  'ionic'
]);

Inorder to make the SMS verification work we will need to create a file locaed in api/server/sms.js with the following contents:

5.5 Add sms configuration to api api/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"
  }
}

We're going to create the same flow of Whatsapp for authentication using 3 views:

  • Login - Asks for the user's phone number.
  • Confirmation - Verifies a user's phone number by an SMS authentication.
  • Profile - Asks a user to pickup its name.

Before we jump into implementing them, we will add a pre-requirement to the relevant routes which 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.

5.6 Add auth resolve to route states src/routes.js
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 
40
41
42
43
44
45
46
47
export default class RoutesConfig extends Config {
  static $inject = ['$stateProvider', '$urlRouterProvider']
 
  constructor() {
    super(...arguments);
 
    this.isAuthorized = ['$auth', this::this.isAuthorized];
  }
 
  configure() {
    this.$stateProvider
      .state('tab', {
        url: '/tab',
        abstract: true,
        templateUrl: 'templates/tabs.html',
        resolve: {
          user: this.isAuthorized
        }
      })
      .state('tab.chats', {
        url: '/chats',
...some lines skipped...
 
    this.$urlRouterProvider.otherwise('tab/chats');
  }
 
  isAuthorized($auth) {
    return $auth.awaitUser();
  }
}

And now we want to handle a case where this promise does not resolve (In case the user is not logged in), so let’s create new RouteConfig that uses Angular's config phase:

5.7 Add routes runner to handle route state change error src/routes.js
1
2
3
4
5
6
7
 
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import { _ } from 'meteor/underscore';
import { Config, Runner } from 'angular-ecmascript/module-helpers';
 
class RoutesConfig extends Config {
  static $inject = ['$stateProvider', '$urlRouterProvider']
 
  constructor() {
...some lines skipped...
  isAuthorized($auth) {
    return $auth.awaitUser();
  }
}
 
class RoutesRunner extends Runner {
  static $inject = ['$rootScope', '$state']
 
  run() {
    this.$rootScope.$on('$stateChangeError', (...args) => {
      const err = _.last(args);
 
      if (err === 'AUTH_REQUIRED') {
        this.$state.go('login');
      }
    });
  }
}
 
export default [RoutesConfig, RoutesRunner];
5.8 Load routes runner src/app.js
8
9
10
11
12
13
14
 
24
25
26
27
28
29
30
import ChatsCtrl from './controllers/chats.controller';
import InputDirective from './directives/input.directive';
import CalendarFilter from './filters/calendar.filter';
import Routes from './routes';
 
const App = 'whatsapp';
 
...some lines skipped...
  .load(ChatsCtrl)
  .load(InputDirective)
  .load(CalendarFilter)
  .load(Routes);
 
Ionic.Platform.ready(() => {
  if (Keyboard) {

Cool, now that we're set, let's start implementing the views we mentioned earlier. We will start with the login view.

The login view contains an input and a save button, and after the save button has been saved, we should be forwarded to the confirmation view, right after an SMS has been sent to the entered phone number:

5.9 Add login view www/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:

5.10 Add login controller src/controllers/login.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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import { _ } from 'meteor/underscore';
import { Accounts } from 'meteor/accounts-base';
import { Controller } from 'angular-ecmascript/module-helpers';
 
export default class LoginCtrl extends Controller {
  static $inject = ['$state', '$ionicLoading', '$ionicPopup', '$log']
 
  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';
5.11 Load login controller src/app.js
6
7
8
9
10
11
12
 
23
24
25
26
27
28
29
 
import ChatCtrl from './controllers/chat.controller';
import ChatsCtrl from './controllers/chats.controller';
import LoginCtrl from './controllers/login.controller';
import InputDirective from './directives/input.directive';
import CalendarFilter from './filters/calendar.filter';
import Routes from './routes';
...some lines skipped...
new Loader(App)
  .load(ChatCtrl)
  .load(ChatsCtrl)
  .load(LoginCtrl)
  .load(InputDirective)
  .load(CalendarFilter)
  .load(Routes);

Let's add its route state:

5.12 Add login route state src/routes.js
37
38
39
40
41
42
43
44
45
46
47
            controller: 'ChatCtrl as chat'
          }
        }
      })
      .state('login', {
        url: '/login',
        templateUrl: 'templates/login.html',
        controller: 'LoginCtrl as logger'
      });
 
    this.$urlRouterProvider.otherwise('tab/chats');

And some style:

5.13 Add login stylesheet scss/login.scss
1
2
3
4
5
6
.login {
  .instructions {
    margin: 50px 0;
    padding: 0 15px;
  }
}
5.14 Import login stylesheet scss/ionic.app.scss
23
24
25
26
 
@import "chat";
@import "chats";
@import "login";

Up next, would be the confirmation view.

We will use accounts API again to verify the user and in case of successful authentication we will transition to the profile view. The same routine of implementation goes on.

Template:

5.15 Add confirmation view www/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>

Controller:

5.16 Add confirmation controller src/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
34
import { _ } from 'meteor/underscore';
import { Accounts } from 'meteor/accounts-base';
import { Controller } from 'angular-ecmascript/module-helpers';
 
export default class ConfirmationCtrl extends Controller {
  static $inject = ['$state', '$ionicPopup', '$log']
 
  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';
5.17 Load confirmation controller src/app.js
6
7
8
9
10
11
12
 
24
25
26
27
28
29
30
 
import ChatCtrl from './controllers/chat.controller';
import ChatsCtrl from './controllers/chats.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(ChatCtrl)
  .load(ChatsCtrl)
  .load(ConfirmationCtrl)
  .load(LoginCtrl)
  .load(InputDirective)
  .load(CalendarFilter)

And a route state:

5.18 Add confirmation route state src/routes.js
42
43
44
45
46
47
48
49
50
51
52
        url: '/login',
        templateUrl: 'templates/login.html',
        controller: 'LoginCtrl as logger'
      })
      .state('confirmation', {
        url: '/confirmation/:phone',
        templateUrl: 'templates/confirmation.html',
        controller: 'ConfirmationCtrl as confirmation'
      });
 
    this.$urlRouterProvider.otherwise('tab/chats');

Let's proceed to the last view in the authentication flow. The Profile view provides the ability to enter the user's nickname and profile picture (Which, unfortunately, is not implemented in this tutorial yet).

Template:

5.19 Add profile view www/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>

Controller:

5.20 Add profile controller src/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
34
import { _ } from 'meteor/underscore';
import { Controller } from 'angular-ecmascript/module-helpers';
 
export default class ProfileCtrl extends Controller {
  static $inject = ['$state', '$ionicPopup', '$log']
 
  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';
5.21 Load profile controller src/app.js
8
9
10
11
12
13
14
 
27
28
29
30
31
32
33
import ChatsCtrl from './controllers/chats.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(ChatsCtrl)
  .load(ConfirmationCtrl)
  .load(LoginCtrl)
  .load(ProfileCtrl)
  .load(InputDirective)
  .load(CalendarFilter)
  .load(Routes);

Route state:

5.22 Add profile route state src/routes.js
47
48
49
50
51
52
53
54
55
56
57
58
59
60
        url: '/confirmation/:phone',
        templateUrl: 'templates/confirmation.html',
        controller: 'ConfirmationCtrl as confirmation'
      })
      .state('profile', {
        url: '/profile',
        templateUrl: 'templates/profile.html',
        controller: 'ProfileCtrl as profile',
        resolve: {
          user: this.isAuthorized
        }
      });
 
    this.$urlRouterProvider.otherwise('tab/chats');

Style:

5.23 Add profile stylesheet scss/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;
  }
}
5.24 Import profile stylesheet scss/ionic.app.scss
24
25
26
27
@import "chat";
@import "chats";
@import "login";
@import "profile";

The authentication flow is complete. Now once we start our application for the first time this is what we should see:

{{tutorialImage 'ionic' '7.png' 500}}

If you will take a look at step 5.20, the ProfileCtrl uses a server method called updateName which is yet to be implemented. Let's implement it in our api:

5.25 Add update name method to api api/server/methods.js
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
    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 this.userId to contain some information about the current logged in user, so by checking for this variable's existence we know if there is a user logged in or not.

Now let's add this validation to the newMessage() method we've just created, and attach the user's id to each message he sends:

5.26 Bind users to new messages api/server/methods.js
4
5
6
7
8
9
10
11
12
13
14
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, {
      text: String,
      chatId: String
    });
 
    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 the settings view which contains the logout button:

5.27 Add settings view www/templates/settings.html
1
2
3
4
5
6
7
8
 
<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>

Let's implement the SettingsCtrl containing the logic for logging out:

5.28 Add settings controller src/controllers/settings.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
import { Meteor } from 'meteor/meteor';
import { Controller } from 'angular-ecmascript/module-helpers';
 
export default class SettingsCtrl extends Controller {
  static $inject = ['$state', '$ionicPopup', '$log']
 
  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'
    });
  }
}
5.29 Load settings controller src/app.js
9
10
11
12
13
14
15
 
29
30
31
32
33
34
35
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);

And to make things work, we need to add the appropriate route state:

5.30 Add settings route state src/routes.js
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
          }
        }
      })
      .state('tab.settings', {
        url: '/settings',
        views: {
          'tab-settings': {
            templateUrl: 'templates/settings.html',
            controller: 'SettingsCtrl as settings',
          }
        }
      })
      .state('login', {
        url: '/login',
        templateUrl: 'templates/login.html',

And this is how our settings page should look like:

Now that we have our user id bounded to each message, we can determine the real ownership of each message. So, let's update our chat view accordingly:

5.31 Add ownership to incomming messages in chat view www/templates/chat.html
6
7
8
9
10
11
12
  <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>

Great! Everything works well now, but let's take our chatting experience one step further. Let's add an auto-scrolling feature, so our conversation would look more fluent and we won't have to scroll down any time our chat space is full:

5.32 Add auto scroll to chat src/controllers/chat.controller.js
21
22
23
24
25
26
27
28
 
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
        return Chats.findOne(this.chatId);
      }
    });
 
    this.autoScrollBottom();
  }
 
  sendMessage() {
...some lines skipped...
    }
  }
 
  autoScrollBottom() {
    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);