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
:
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:
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:
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.
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:
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];
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:
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
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';
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:
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:
1
2
3
4
5
6
.login {
.instructions {
margin: 50px 0;
padding: 0 15px;
}
}
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:
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:
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';
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:
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:
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:
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';
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:
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:
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;
}
}
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
:
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:
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:
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:
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'
});
}
}
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:
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:
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:
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);