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:
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:
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:
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:
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'];
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.
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:
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];
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
:
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:
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:
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'];
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:
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:
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'];
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
:
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
:
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.
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:
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:
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'];
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:
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:
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);