AngularJS and Password Managers: Can’t we all just get along?

(Note: This article has been updated to use AngularJS 1.0.8 and Bootstrap 3.0.0. If you are using earlier versions the syntax may be slightly different.) We came across an issue using AngularJS with some of the password manager browser plugins (i.e. 1Password, LastPass). For some reason when these plugins tried pre-filling our forms, the AngularJS validation system would not recognize that the input value changed. After much investigation and support requests, one of the vendors figured out the events AngularJS was listening to on the controls for updating the scope were not firing. So we created a new directive to use with our custom rcSubmit directive to solve this. The directive is called ‘rcVerifySet‘. First we had to extend rcSubmit a little:

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
   var rcSubmitDirective = {
       'rcSubmit': ['$parse', function ($parse) {
           return {
               restrict: 'A',
               require: ['rcSubmit', '?form'],
               controller: ['$scope', function ($scope) {
 
                   var formController = null;
                   var attemptHandlers = []; 
                   this.attempted = false;
 
                   this.onAttempt = function(handler) {                       attemptHandlers.push(handler);                   }; 
                   this.setAttempted = function() {
                       this.attempted = true;
 
                       angular.forEach(attemptHandlers, function (handler) {                           handler();                       });                   };
 
                   this.setFormController = function(controller) {
                       formController = controller;
                   };
 
                   this.needsAttention = function (fieldModelController) {
                       if (!formController) return false;
 
                       if (fieldModelController) {
                           return fieldModelController.$invalid && 
                             (fieldModelController.$dirty || this.attempted);
                       } else {
                           return formController && formController.$invalid && 
                             (formController.$dirty || this.attempted);
                       }
                   };
               }],
               compile: function(cElement, cAttributes, transclude) {
                   return {
                       pre: function(scope, formElement, attributes, controllers) {
 
                           var submitController = controllers[0];
                           var formController = (controllers.length > 1) ? controllers[1] : null;
 
                           submitController.setFormController(formController);
 
                           scope.rc = scope.rc || {};
                           scope.rc[attributes.name] = submitController;
                       },
                       post: function(scope, formElement, attributes, controllers) {
 
                           var submitController = controllers[0];
                           var formController = (controllers.length > 1) ? controllers[1] : null;
                           var fn = $parse(attributes.rcSubmit);
 
                           formElement.bind('submit', function () {
                               submitController.setAttempted();
                               if (!scope.$$phase) scope.$apply();
 
                               if (!formController.$valid) return false;
 
                               scope.$apply(function() {
                                   fn(scope, {$event:event});
                               });
                           });
                       }
                 };
               }
           };
       }]
   };

All we are doing is creating a method to pass in a handler that we can execute whenever submission is attempted (onAttempt). When the form submission is attempted, any handlers will also be executed. We now need a directive to consume this and do something on the submission attempt. Enter ‘rcVerifySet‘:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    var rcVerifySetDirective = {
      'rcVerifySet': function () {
        return {
          restrict: 'A',
          require: ['^rcSubmit', 'ngModel'],
          link: function (scope, element, attributes, controllers) {
            var submitController = controllers[0];
            var modelController = controllers[1];
 
            submitController.onAttempt(function() {
              modelController.$setViewValue(element.val());
            });
          }
        };
      }
    };

All the rcVerifySet directive does is add a handler to the attempt controller. When executed, this allows us to examine elements that are modified outside of angular (i.e. via plugin) to make sure they are applied to the scope before they are submitted. The resulting html would be:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
    <form name="loginForm" novalidate
          ng-app="LoginApp" ng-controller="LoginController" rc-submit="login()">
        <div class="form-group"
            ng-class="{'has-error': rc.loginForm.needsAttention(loginForm.username)}">
            <input class="form-control" name="username" type="text" placeholder="Username" 
                   required ng-model="session.username" rc-verify-set />  
            <span class="help-block" 
                  ng-show="loginForm.username.$error.required">Required</span>
        </div>
        <div class="form-group"
            ng-class="{'has-error': rc.loginForm.needsAttention(loginForm.password)}">
            <input class="form-control" name="password" type="password" placeholder="Password"
                   required ng-model="session.password" rc-verify-set />
            <span class="help-block"
                  ng-show="loginForm.password.$error.required">Required</span>
        </div>
        <div class="form-group">
            <button type="submit" class="btn btn-primary" value="Login" title="Login">
                <span>Login</span>
            </button>
        </div>
    </form>

It should be noted that there is an issue logged for this in AngularJS: https://github.com/angular/angular.js/issues/3133. It appears 1Password is implementing a fix for this issue based on some of our feedback. We have also notified LastPass, but have not heard a response yet (Update: LastPass has now also addressed the issue). Either way, the issue may exist with numerous plugins and this directive may be useful to verify the scope in situations where you think plugins are used. For instance, don’t forget register pages and reset-password pages.

We’ve thrown together a Plunker to see things in action. The source to these directives and an example can be found on our Github. You can access all of our custom directives there including a new one we threw together last week to use Mailgun’s cool email address validation service.