Просмотр исходного кода

WIP registration

* fix #514
* API for registering accounts needs completion
* still needs tests!
Rafał Pitoń 10 лет назад
Родитель
Сommit
0e869dc470
63 измененных файлов с 903 добавлено и 558 удалено
  1. 17 0
      misago/emberapp/app/components/close-modal-button.js
  2. 11 12
      misago/emberapp/app/components/form-row.js
  3. 78 0
      misago/emberapp/app/components/forms/login-form.js
  4. 153 12
      misago/emberapp/app/components/forms/register-form.js
  5. 3 0
      misago/emberapp/app/components/forms/request-link-form.js
  6. 3 1
      misago/emberapp/app/components/forms/set-new-password-form.js
  7. 0 19
      misago/emberapp/app/components/guest-nav.js
  8. 0 112
      misago/emberapp/app/components/login-modal.js
  9. 1 0
      misago/emberapp/app/components/modal-header.js
  10. 10 4
      misago/emberapp/app/components/qacaptcha-field.js
  11. 12 0
      misago/emberapp/app/components/register-button.js
  12. 10 0
      misago/emberapp/app/initializers/modal-service.js
  13. 12 0
      misago/emberapp/app/initializers/registration-modal.js
  14. 0 1
      misago/emberapp/app/resolver.js
  15. 1 1
      misago/emberapp/app/routes/activation/activate.js
  16. 7 0
      misago/emberapp/app/routes/application.js
  17. 0 4
      misago/emberapp/app/services/auth.js
  18. 0 2
      misago/emberapp/app/services/clock.js
  19. 51 0
      misago/emberapp/app/services/modal.js
  20. 15 1
      misago/emberapp/app/services/nocaptcha.js
  21. 5 6
      misago/emberapp/app/services/qacaptcha.js
  22. 51 3
      misago/emberapp/app/services/recaptcha.js
  23. 20 0
      misago/emberapp/app/services/registration-modal.js
  24. 33 1
      misago/emberapp/app/styles/misago/forms.less
  25. 29 0
      misago/emberapp/app/styles/misago/inputs.less
  26. 1 1
      misago/emberapp/app/styles/misago/misago.less
  27. 35 0
      misago/emberapp/app/styles/misago/modals.less
  28. 1 0
      misago/emberapp/app/styles/misago/toast-message.less
  29. 16 0
      misago/emberapp/app/styles/misago/typo.less
  30. 20 16
      misago/emberapp/app/styles/misago/variables.less
  31. 3 2
      misago/emberapp/app/templates/application.hbs
  32. 5 7
      misago/emberapp/app/templates/components/form-row.hbs
  33. 36 0
      misago/emberapp/app/templates/components/forms/login-form.hbs
  34. 62 55
      misago/emberapp/app/templates/components/forms/register-form.hbs
  35. 0 7
      misago/emberapp/app/templates/components/guest-nav.hbs
  36. 0 46
      misago/emberapp/app/templates/components/login-modal.hbs
  37. 2 0
      misago/emberapp/app/templates/components/modal-header.hbs
  38. 14 1
      misago/emberapp/app/templates/components/qacaptcha-field.hbs
  39. 10 1
      misago/emberapp/app/templates/components/recaptcha-field.hbs
  40. 10 0
      misago/emberapp/app/templates/modals/login.hbs
  41. 16 0
      misago/emberapp/app/templates/modals/register/closed.hbs
  42. 16 0
      misago/emberapp/app/templates/modals/register/done.hbs
  43. 11 0
      misago/emberapp/app/templates/modals/register/form.hbs
  44. 9 1
      misago/emberapp/app/templates/navbar.hbs
  45. 0 15
      misago/emberapp/app/templates/register/closed.hbs
  46. 0 15
      misago/emberapp/app/templates/register/done.hbs
  47. 0 18
      misago/emberapp/app/templates/register/form.hbs
  48. 4 3
      misago/emberapp/config/environment.js
  49. 1 1
      misago/emberapp/tests/acceptance/activate-test.js
  50. 17 15
      misago/emberapp/tests/acceptance/error-toast-test.js
  51. 2 2
      misago/emberapp/tests/acceptance/forgotten-password-test.js
  52. 20 20
      misago/emberapp/tests/acceptance/login-test.js
  53. 0 13
      misago/emberapp/tests/unit/components/guest-nav-test.js
  54. 0 46
      misago/emberapp/tests/unit/components/login-modal-test.js
  55. 0 13
      misago/emberapp/tests/unit/components/request-link-form-test.js
  56. 0 13
      misago/emberapp/tests/unit/components/set-new-password-form-test.js
  57. 2 0
      misago/emberapp/vendor/testutils/misago-preload-data.js
  58. 10 0
      misago/users/api/users.py
  59. 42 60
      misago/users/captcha.py
  60. 3 0
      misago/users/forms/register.py
  61. 5 0
      misago/users/migrations/0002_users_settings.py
  62. 1 1
      misago/users/urls/api.py
  63. 7 7
      misago/users/validators.py

+ 17 - 0
misago/emberapp/app/components/close-modal-button.js

@@ -0,0 +1,17 @@
+import Ember from 'ember';
+
+export default Ember.Component.extend({
+  tagName: 'button',
+
+  attributeBindings: ['type'],
+  type: 'button',
+
+  router: function() {
+    return this.container.lookup('router:main');
+  }.property(),
+
+  click: function() {
+    this.modal.hide();
+    this.get('router').transitionTo(this.get('goto'));
+  }
+});

+ 11 - 12
misago/emberapp/app/components/form-row.js

@@ -9,23 +9,22 @@ export default Ember.Component.extend({
   ],
 
   hasFeedback: function() {
-    return this.get('validation') !== null;
+    return typeof this.get('validation') !== 'undefined';
   }.property('validation'),
 
   hasSuccess: function() {
-    return this.get('hasFeedback') && !this.get('hasError');
-  }.property('hasFeedback', 'hasError'),
+    return this.get('validation') === 'ok';
+  }.property('hasFeedback'),
 
   hasError: function() {
-    return this.get('hasFeedback') && this.get('errors');
-  }.property('hasFeedback', 'errors'),
+    return this.get('hasFeedback') && !this.get('hasSuccess');
+  }.property('hasFeedback', 'hasSuccess'),
 
-  errors: function() {
-    if (this.get('hasFeedback')) {
-      return this.get('validation.' + this.get('name')) || null;
+  messages: function() {
+    if (this.get('hasError')) {
+      return this.get('validation');
+    } else {
+      return null;
     }
-
-    return null;
-  }.property('validation', 'name')
-
+  }.property('hasError', 'hasSuccess')
 });

+ 78 - 0
misago/emberapp/app/components/forms/login-form.js

@@ -0,0 +1,78 @@
+import Ember from 'ember';
+
+export default Ember.Component.extend({
+  tagName: 'form',
+
+  isLoading: false,
+  showActivation: false,
+
+  username: '',
+  password: '',
+
+  router: function() {
+    return this.container.lookup('router:main');
+  }.property(),
+
+  submit: function() {
+    if (this.get('isLoading')) {
+      return false;
+    }
+
+    var credentials = {
+      username: Ember.$.trim(this.get('username')),
+      password: Ember.$.trim(this.get('password'))
+    };
+
+    if (!credentials.username || !credentials.password) {
+      this.toast.warning(gettext("Fill out both fields."));
+      return false;
+    }
+
+    var self = this;
+    this.rpc.ajax(this.get('settings.loginApiUrl'), credentials
+    ).then(function() {
+      if (self.isDestroyed) { return; }
+      self.success(credentials);
+    }, function(jqXHR) {
+      if (self.isDestroyed) { return; }
+      self.error(jqXHR);
+    }).finally(function() {
+      if (self.isDestroyed) { return; }
+      self.set('isLoading', false);
+    });
+
+    return false;
+  },
+
+  success: function(credentials) {
+    var $form = Ember.$('#hidden-login-form');
+
+    // refresh CSRF token because parent api call changed it
+    this.csrf.updateFormToken($form);
+
+    // fill out form with user credentials and submit it, this will tell
+    // misago to redirect user back to right page, and will trigger browser's
+    // key ring feature
+    $form.find('input[name=redirect_to]').val(window.location.href);
+    $form.find('input[name=username]').val(credentials.username);
+    $form.find('input[name=password]').val(credentials.password);
+    $form.submit();
+  },
+
+  error: function(jqXHR) {
+    var rejection = jqXHR.responseJSON;
+    if (jqXHR.status !== 400) {
+      this.toast.apiError(jqXHR);
+    } else if (rejection.code === 'inactive_admin') {
+      this.toast.info(rejection.detail);
+    } else if (rejection.code === 'inactive_user') {
+      this.toast.info(rejection.detail);
+      this.set('showActivation', true);
+    } else if (rejection.code === 'banned') {
+      this.get('router').intermediateTransitionTo('error-banned', rejection.detail);
+      this.modal.hide();
+    } else {
+      this.toast.error(rejection.detail);
+    }
+  }
+});

+ 153 - 12
misago/emberapp/app/components/forms/register-form.js

@@ -13,9 +13,42 @@ export default Ember.Component.extend({
   username: '',
   email: '',
   password: '',
-  captcha: Ember.computed.alias('captcha.value'),
 
-  validation: null,
+  validation: Ember.Object.create({}),
+
+  passwordScore: function() {
+    return this.get('zxcvbn').scorePassword(this.get('password'), [
+      this.get('username'), this.get('email')
+    ]);
+  }.property('password', 'username', 'email'),
+
+  passwordHelpText: function() {
+    if (Ember.$.trim(this.get('password')).length === 0) {
+      return null;
+    }
+
+    return [
+      gettext('Password is very weak.'),
+      gettext('Password is weak.'),
+      gettext('Password is average.'),
+      gettext('Password is strong.'),
+      gettext('Password is very strong.')
+    ][this.get('passwordScore')];
+  }.property('password', 'username', 'email', 'passwordScore'),
+
+  passwordBarWidth: function() {
+    this.$('.progress-bar').css('width', ((this.get('passwordScore') + 1) * 20) + '%');
+  }.observes('password', 'username', 'email', 'passwordScore'),
+
+  passwordBarClass: function() {
+    return 'progress-bar-' + [
+      'danger',
+      'warning',
+      'warning',
+      'primary',
+      'success'
+    ][this.get('passwordScore')];
+  }.property('password', 'username', 'email', 'passwordScore'),
 
   loadServices: function() {
     var self = this;
@@ -26,6 +59,8 @@ export default Ember.Component.extend({
     ];
 
     Ember.RSVP.allSettled(promises).then(function(array) {
+      if (self.isDestroyed) { return; }
+
       if (array[0].state === 'rejected') {
         self.set('isErrored', true);
         console.log('zxcvbn service failed to load.');
@@ -37,40 +72,146 @@ export default Ember.Component.extend({
       }
 
       self.set('isReady', !self.get('isErrored'));
+      self.get('captcha').reset();
     });
   }.on('didInsertElement'),
 
+  usernameValidation: function() {
+    var state = true;
+
+    var value = Ember.$.trim(this.get('username'));
+    var valueLength = value.length;
+
+    var limit = 0;
+    var message = null;
+    if (valueLength < this.get('settings.username_length_min')) {
+      limit = this.get('settings.username_length_min');
+      message = ngettext('Username must be at least one character long.',
+                         'Username must be at least %(limit)s characters long.',
+                         limit);
+      state = [interpolate(message, {limit: limit}, true)];
+    } else if (valueLength > this.get('settings.username_length_max')) {
+      limit = this.get('settings.username_length_max');
+      message = ngettext('Username cannot be longer than one characters.',
+                         'Username cannot be longer than %(limit)s characters.',
+                         limit);
+      state = [interpolate(message, {limit: limit}, true)];
+    } else if (!new RegExp('^[0-9a-z]+$', 'i').test(value)) {
+      state = [gettext('Username can only contain latin alphabet letters and digits.')];
+    }
+
+    if (state === true) {
+      this.set('validation.username', 'ok');
+    } else {
+      this.set('validation.username', state);
+    }
+  }.observes('username'),
+
+  emailValidation: function() {
+    var state = true;
+
+    var value = Ember.$.trim(this.get('email'));
+    var valueLength = value.length;
+
+    var re = /^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i;
+
+    if (valueLength < 4) {
+      state = [gettext('Enter valid e-mail address')];
+    } else if (!re.test(value)) {
+      state = [gettext('Invalid e-mail address.')];
+    }
+
+    if (state === true) {
+      this.set('validation.email', 'ok');
+    } else {
+      this.set('validation.email', state);
+    }
+  }.observes('email'),
+
+  passwordValidation: function() {
+    var state = true;
+
+    var valueLength = Ember.$.trim(this.get('password')).length;
+
+    var limit = this.get('settings.password_length_min');
+    if (valueLength < limit) {
+      var message = ngettext('Valid password must be at least one character long.',
+                             'Valid password must be at least %(limit)s characters long.',
+                             limit);
+      state = [interpolate(message, {limit: limit}, true)];
+    }
+
+    if (state === true) {
+      this.set('validation.password', 'ok');
+    } else {
+      this.set('validation.password', state);
+    }
+  }.observes('password'),
+
+  captchaValidation: function() {
+    this.set('validation.captcha', undefined);
+  }.observes('captcha.value'),
+
   submit: function() {
     if (this.get('isLoading')) {
       return false;
     }
 
+    var data = {
+      username: Ember.$.trim(this.get('username')),
+      email: Ember.$.trim(this.get('email')),
+      password: Ember.$.trim(this.get('password')),
+      captcha: Ember.$.trim(this.get('captcha.value'))
+    };
+
+    var lengths = [data.username.length, data.email.length, data.password.length];
+    if (lengths.indexOf(0) !== -1) {
+      this.toast.error(gettext("Fill out all fields."));
+      return false;
+    }
+
+    if (this.$('.has-error').length) {
+      this.toast.error(gettext("Form contains errors."));
+      return false;
+    }
+
     this.set('isLoading', true);
 
     var self = this;
-    this.rpc.ajax('users', {
-      username: this.get('username'),
-      email: this.get('email'),
-      password: this.get('password'),
-      captcha: this.get('captcha.value')
-    }).then(function(response) {
+    this.rpc.ajax('users', data
+    ).then(function(response) {
+      if (self.isDestroyed) { return; }
       self.success(response);
     }, function(jqXHR) {
-        self.error(jqXHR);
+      if (self.isDestroyed) { return; }
+      self.error(jqXHR);
     }).finally(function() {
+      if (self.isDestroyed) { return; }
       self.set('isLoading', false);
-    })
+    });
 
     return false;
   },
 
   success: function(response) {
     console.log(response);
+    this.get('captcha').reset();
   },
 
   error: function(jqXHR) {
     if (jqXHR.status === 400) {
-      this.set('validation', Ember.Object.create(jqXHR.responseJSON));
+      this.toast.error(gettext("Form contains errors."));
+      this.get('validation').setProperties(jqXHR.responseJSON);
+
+      if (!this.get('validation.captcha')) {
+        this.get('captcha').pass();
+      } else {
+        this.get('captcha').refresh();
+      }
     }
-  }
+  },
+
+  showTermsFootnote: function() {
+    return this.get('settings.terms_of_service') || this.get('settings.terms_of_service_link');
+  }.property('settings')
 });

+ 3 - 0
misago/emberapp/app/components/forms/request-link-form.js

@@ -28,10 +28,13 @@ export default Ember.Component.extend({
     this.rpc.ajax(this.get('url'), {
       email: email
     }).then(function(requestingUser) {
+      if (self.isDestroyed) { return; }
       self.success(requestingUser);
     }, function(jqXHR) {
+      if (self.isDestroyed) { return; }
       self.error(jqXHR);
     }).finally(function() {
+      if (self.isDestroyed) { return; }
       self.set('isLoading', false);
     });
 

+ 3 - 1
misago/emberapp/app/components/forms/set-new-password-form.js

@@ -32,8 +32,10 @@ export default Ember.Component.extend({
     this.rpc.ajax(this.get('url'), {
       password: password
     }).then(function() {
+      if (self.isDestroyed) { return; }
       self.success();
     }, function(jqXHR) {
+      if (self.isDestroyed) { return; }
       self.error(jqXHR);
     }).finally(function() {
       self.set('isLoading', false);
@@ -45,7 +47,7 @@ export default Ember.Component.extend({
   success: function() {
     this.set('password', '');
 
-    this.auth.openLoginModal();
+    this.modal.show('login');
     this.toast.success(gettext("Your password has been changed."));
   },
 

+ 0 - 19
misago/emberapp/app/components/guest-nav.js

@@ -1,19 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Component.extend({
-  classNames: ['guest-nav', 'navbar-right'],
-
-  router: function() {
-    return this.container.lookup('router:main');
-  }.property(),
-
-  actions: {
-    login: function() {
-      this.auth.openLoginModal();
-    },
-
-    register: function() {
-      this.get('router').transitionTo('register');
-    }
-  }
-});

+ 0 - 112
misago/emberapp/app/components/login-modal.js

@@ -1,112 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Component.extend({
-  modal: null,
-
-  isLoading: false,
-  showActivation: false,
-
-  username: '',
-  password: '',
-
-  router: function() {
-    return this.container.lookup('router:main');
-  }.property(),
-
-  setup: function() {
-    this.modal = Ember.$('#loginModal').modal({show: false});
-
-    this.modal.on('shown.bs.modal', function () {
-      Ember.$('#loginModal').focus();
-    });
-
-    var self = this;
-    this.modal.on('hidden.bs.modal', function() {
-      self.reset();
-    });
-  }.on('didInsertElement'),
-
-  reset: function() {
-    this.setProperties({
-      'username': '',
-      'password': '',
-
-      'isLoading': false,
-      'showActivation': false
-    });
-  },
-
-  success: function(credentials) {
-    var $form = Ember.$('#hidden-login-form');
-
-    // refresh CSRF token because parent api call changed it
-    this.csrf.updateFormToken($form);
-
-    // fill out form with user credentials and submit it, this will tell
-    // misago to redirect user back to right page, and will trigger browser's
-    // key ring feature
-    $form.find('input[name=redirect_to]').val(window.location.href);
-    $form.find('input[name=username]').val(credentials.username);
-    $form.find('input[name=password]').val(credentials.password);
-    $form.submit();
-  },
-
-  error: function(jqXHR) {
-    var rejection = jqXHR.responseJSON;
-    if (jqXHR.status !== 400) {
-      this.toast.apiError(jqXHR);
-    } else if (rejection.code === 'inactive_admin') {
-      this.toast.info(rejection.detail);
-    } else if (rejection.code === 'inactive_user') {
-      this.toast.info(rejection.detail);
-      this.set('showActivation', true);
-    } else if (rejection.code === 'banned') {
-      this.get('router').intermediateTransitionTo('error-banned', rejection.detail);
-      Ember.run(function() {
-        Ember.$('#loginModal').modal('hide');
-      });
-    } else {
-      this.toast.error(rejection.detail);
-    }
-  },
-
-  actions: {
-    submit: function() {
-      if (this.get('isLoading')) {
-        return;
-      }
-
-      var credentials = {
-        username: Ember.$.trim(this.get('username')),
-        password: Ember.$.trim(this.get('password'))
-      };
-
-      if (!credentials.username || !credentials.password) {
-        this.toast.warning(gettext("Fill out both fields."));
-        return;
-      }
-
-      var self = this;
-      this.rpc.ajax(this.get('settings.loginApiUrl'), credentials
-      ).then(function() {
-        self.success(credentials);
-      }, function(jqXHR) {
-        self.error(jqXHR);
-      }).finally(function() {
-        self.set('isLoading', false);
-      });
-    },
-
-    // Go-to links
-
-    activateAccount: function() {
-      this.get('router').transitionTo('activation');
-      Ember.$('#loginModal').modal('hide');
-    },
-
-    forgotPassword: function() {
-      this.get('router').transitionTo('forgotten-password');
-      Ember.$('#loginModal').modal('hide');
-    }
-  }
-});

+ 1 - 0
misago/emberapp/app/components/recaptcha-field.js → misago/emberapp/app/components/modal-header.js

@@ -1,4 +1,5 @@
 import Ember from 'ember';
 
 export default Ember.Component.extend({
+  classNames: 'modal-header',
 });

+ 10 - 4
misago/emberapp/app/components/qacaptcha-field.js

@@ -1,8 +1,14 @@
 import Ember from 'ember';
+import FormRow from 'misago/components/form-row';
 
-export default Ember.Component.extend({
-  classNames: 'form-group',
-
+export default FormRow.extend({
+  classNames: 'form-group form-captcha',
   captcha: Ember.inject.service('qacaptcha'),
-  model: Ember.computed.alias('captcha.model')
+  model: Ember.computed.alias('captcha.model'),
+
+  value: '',
+
+  syncWithService: function() {
+    this.get('captcha').set('value', this.get('value'));
+  }.observes('value')
 });

+ 12 - 0
misago/emberapp/app/components/register-button.js

@@ -0,0 +1,12 @@
+import Ember from 'ember';
+
+export default Ember.Component.extend({
+  tagName: 'button',
+  type: 'button',
+
+  modalState: Ember.inject.service('registration-modal'),
+
+  click: function() {
+    this.modal.show(this.get('modalState.template'));
+  }
+});

+ 10 - 0
misago/emberapp/app/initializers/modal-service.js

@@ -0,0 +1,10 @@
+export function initialize(container, application) {
+  [ 'route', 'component' ].forEach((factory) => {
+    application.inject(factory, 'modal', 'service:modal');
+  });
+}
+
+export default {
+  name: 'modal-service',
+  initialize: initialize
+};

+ 12 - 0
misago/emberapp/app/initializers/registration-modal.js

@@ -0,0 +1,12 @@
+import RegistrationModal from 'misago/services/registration-modal';
+
+export function initialize(container, application) {
+  application.register('service:registration-modal', RegistrationModal, { singleton: true });
+
+  application.inject('service:registration-modal', 'settings', 'misago:settings');
+}
+
+export default {
+  name: 'registration-modal',
+  initialize: initialize
+};

+ 0 - 1
misago/emberapp/app/resolver.js

@@ -24,7 +24,6 @@ export default Resolver.extend({
           path += 'forms/' + parsedName.fullNameWithoutType;
         }
 
-        console.log(path);
         return path;
       }
     }

+ 1 - 1
misago/emberapp/app/routes/activation/activate.js

@@ -7,7 +7,7 @@ export default MisagoRoute.extend(ResetScroll, {
   },
 
   afterModel: function(model) {
-    this.auth.openLoginModal();
+    this.modal.show('login');
     this.toast.success(model.detail);
     return this.transitionTo('index');
   },

+ 7 - 0
misago/emberapp/app/routes/application.js

@@ -2,6 +2,13 @@ import MisagoRoute from 'misago/routes/misago';
 
 export default MisagoRoute.extend({
   actions: {
+
+    // Shortcut for opening modals from templates
+
+    showModal: function(template, model) {
+      this.modal.show(template, model);
+    },
+
     // Loading handler
 
     loading: function() {

+ 0 - 4
misago/emberapp/app/services/auth.js

@@ -8,8 +8,4 @@ export default Ember.Service.extend({
   logout: function() {
     Ember.$('#hidden-logout-form').submit();
   },
-
-  openLoginModal: function() {
-    Ember.$('#loginModal').modal('show');
-  }
 });

+ 0 - 2
misago/emberapp/app/services/clock.js

@@ -2,8 +2,6 @@ import Ember from 'ember';
 import ENV from '../config/environment';
 
 export default Ember.Service.extend({
-  availableIn: ['controllers'],
-
   tick: Ember.computed.oneWay('_tick').readOnly(),
 
   doTick: function () {

+ 51 - 0
misago/emberapp/app/services/modal.js

@@ -0,0 +1,51 @@
+import Ember from 'ember';
+
+export default Ember.Service.extend({
+  _modal: null,
+
+  render: function(template, model) {
+    this.container.lookup('route:application').render('modals.' + template, {
+      into: 'application',
+      outlet: 'modal',
+      model: model || null
+    });
+  },
+
+  disconnect: function() {
+    this.container.lookup('route:application').disconnectOutlet({
+      outlet: 'modal',
+      parentView: 'application'
+    });
+  },
+
+  _setupModal: function() {
+    var modal = null;
+
+    modal = Ember.$('#appModal').modal({show: false});
+
+    modal.on('shown.bs.modal', function () {
+      Ember.$('#appModal').focus();
+    });
+
+    var self = this;
+    modal.on('hidden.bs.modal', function() {
+      self.disconnect();
+    });
+
+    this.set('_modal', modal);
+  },
+
+  show: function(template, model) {
+    if (!this.get('_modal')) {
+      this._setupModal();
+    }
+
+    this.render(template, model);
+
+    Ember.$('#appModal').modal('show');
+  },
+
+  hide: function() {
+    Ember.$('#appModal').modal('hide');
+  }
+});

+ 15 - 1
misago/emberapp/app/services/nocaptcha.js

@@ -2,6 +2,7 @@ import Ember from 'ember';
 
 export default Ember.Service.extend({
   field: null,
+  passed: false,
 
   load: function() {
     // CAPTCHA is turned off, so don't load anything
@@ -12,5 +13,18 @@ export default Ember.Service.extend({
 
   value: function() {
     return '';
-  }.property()
+  }.property(),
+
+  pass: function() {
+    this.set('passed', true);
+  },
+
+  reset: function() {
+    this.set('passed', false);
+  },
+
+
+  refresh: function() {
+    return;
+  }
 });

+ 5 - 6
misago/emberapp/app/services/qacaptcha.js

@@ -1,9 +1,12 @@
 import Ember from 'ember';
+import NoCaptcha from 'misago/services/nocaptcha';
 
-export default Ember.Service.extend({
+export default NoCaptcha.extend({
   field: 'qacaptcha-field',
   model: null,
 
+  value: '',
+
   load: function() {
     // Obtain QA question from API
     var promise = this.store.find('captcha-question', 1);
@@ -14,9 +17,5 @@ export default Ember.Service.extend({
     });
 
     return promise;
-  },
-
-  value: function() {
-    return Ember.$('#captcha-question').val() || '';
-  }.property().volatile()
+  }
 });

+ 51 - 3
misago/emberapp/app/services/recaptcha.js

@@ -1,16 +1,64 @@
+/* global grecaptcha */
 import Ember from 'ember';
+import NoCaptcha from 'misago/services/nocaptcha';
 
-export default Ember.Service.extend({
-  field: 'recaptha-field',
+export default NoCaptcha.extend({
+  baseUrl: 'https://www.google.com/recaptcha/api.js?render=explicit',
+  field: 'recaptcha-field',
+  includedJs: false,
+  loadedJs: false,
 
   load: function() {
     // Load reCaptcha library from Google
+    if (!this.get('includedJs')) {
+      this._includeJs();
+    }
+
+    if (!this.get('loadedJs')) {
+      return this._loadingPromise();
+    } else {
+      return this._loadedPromise();
+    }
+  },
+
+  _includeJs: function() {
+    Ember.$.getScript(this.get('url'));
+    this.set('includedJs', true);
+  },
+
+  _loadingPromise: function() {
+    var self = this;
+    return new Ember.RSVP.Promise(function(resolve) {
+      var wait = function() {
+        if (typeof grecaptcha === "undefined") {
+          Ember.run.later(function () {
+            wait();
+          }, 200);
+        } else {
+          self.set('loadedJs', true);
+          resolve();
+        }
+      };
+      wait();
+    });
+  },
+
+  _loadedPromise: function() {
+    // we have already loaded zxcvbn.js, resolve away!
     return new Ember.RSVP.Promise(function(resolve) {
       resolve();
     });
   },
 
+  url: function() {
+    return this.get('baseUrl') + '&hl=' + Ember.$('html').attr('lang');
+  }.property('baseUrl'),
+
+  refresh: function() {
+    grecaptcha.reset();
+  },
+
   value: function() {
-    return '';
+    return grecaptcha.getResponse();
   }.property().volatile()
 });

+ 20 - 0
misago/emberapp/app/services/registration-modal.js

@@ -0,0 +1,20 @@
+import Ember from 'ember';
+
+export default Ember.Service.extend({
+  stage: 'form',
+
+  isForm: Ember.computed.equal('stage', 'form'),
+  isDone: Ember.computed.equal('stage', 'done'),
+  isClosed: Ember.computed.equal('stage', 'closed'),
+
+  resolveStage: function() {
+    if (!this.get('isDone') && this.get('settings.account_activation') === 'closed') {
+      // we didn't complete prior registration and registrations aren't open
+      this.set('stage', 'closed');
+    }
+  },
+
+  template: function() {
+    return 'register.' + this.get('stage');
+  }.property('stage')
+});

+ 33 - 1
misago/emberapp/app/styles/misago/form-panel.less → misago/emberapp/app/styles/misago/forms.less

@@ -3,7 +3,7 @@
 // --------------------------------------------------
 
 
-// Add custom variables
+// Add custom variables for extra customizability
 .panel-form {
   background: @panel-form-bg;
   border: none;
@@ -24,6 +24,37 @@
   }
 }
 
+
+// Footer extra
+.footer-extra {
+  margin-top: @line-height-computed;
+}
+
+
+// Help-block
+.form-group {
+  .help-block {
+    margin-bottom: 0px;
+    padding: 0px;
+
+    font-size: @font-size-small;
+
+    &.errors {
+      font-weight: bold;
+
+      p {
+        margin-bottom: @font-size-small / 2;
+        padding: 0px;
+      }
+
+      &:last-child {
+        margin-bottom: 0px;
+      };
+    }
+  }
+}
+
+
 // Loading/error states
 .panel-form {
   .panel-loader {
@@ -59,6 +90,7 @@
   }
 }
 
+
 // Inputs well, useful if there's one input + button
 .well-form {
   background: @panel-form-bg;

+ 29 - 0
misago/emberapp/app/styles/misago/inputs.less

@@ -16,3 +16,32 @@ input.form-control, textarea.form-control {
     .opacity(50);
   }
 }
+
+
+// Password strength
+.password-strength {
+  height: auto;
+  margin: 0px;
+  margin-top: @line-height-computed / 3;
+
+  .progress-bar {
+    .box-shadow(none);
+    height: @line-height-computed / 2;
+  }
+}
+
+
+// Recaptcha
+.form-recaptcha {
+  padding-bottom: @padding-base-vertical;
+
+  #g-captcha div {
+    margin: 0px auto;
+  }
+
+  p {
+    margin: 0px;
+    margin-top: @line-height-computed * .66;
+    padding: 0px;
+  }
+}

+ 1 - 1
misago/emberapp/app/styles/misago/misago.less

@@ -2,7 +2,7 @@
 @import "buttons.less";
 @import "toast-message.less";
 @import "footer.less";
-@import "form-panel.less";
+@import "forms.less";
 @import "inputs.less";
 @import "modals.less";
 @import "navbar.less";

+ 35 - 0
misago/emberapp/app/styles/misago/modals.less

@@ -8,6 +8,29 @@
 }
 
 
+.modal-message {
+  padding-top: @line-height-computed * 1.5;
+  padding-bottom: @line-height-computed * 1.5;
+
+  p {
+    margin: 0px;
+    margin-top: @line-height-computed / 3;
+
+    text-align: center;
+
+    &.lead {
+      margin-top: 0px;
+
+      font-size: @font-size-large;
+    }
+  }
+}
+
+
+.modal-form {
+}
+
+
 // Sign-in modal
 .modal-sign-in {
   .modal-body {
@@ -34,3 +57,15 @@
     }
   }
 }
+
+
+// Register modal
+.modal-register-form {
+  .modal-body {
+    padding-bottom: 0px;
+
+    .lead {
+      margin-bottom: @line-height-computed;
+    }
+  }
+}

+ 1 - 0
misago/emberapp/app/styles/misago/toast-message.less

@@ -11,6 +11,7 @@
   padding: @alert-padding;
 
   color: @color-text;
+  font-size: @font-size-large;
 
   .fa {
     margin-right: @font-size-base / 2;

+ 16 - 0
misago/emberapp/app/styles/misago/typo.less

@@ -6,3 +6,19 @@
 body {
   font-weight: 300;
 }
+
+
+// in-site link
+.site-link {
+  &, &:link, &:visited {
+    color: @site-link-color;
+  }
+
+  &:hover, &:focus {
+    color: @site-link-hover-color;
+  }
+
+  &:active {
+    color: @site-link-active-color;
+  }
+}

+ 20 - 16
misago/emberapp/app/styles/misago/variables.less

@@ -16,7 +16,7 @@
 
 @brand-info:             #ff5722; // misago uses info state for accents
 
-@brand-primary:          #29b6f6;
+@brand-primary:          #3498db;
 @brand-success:          #2ecc71;
 @brand-warning:          #ffab40;
 @brand-danger:           #ef5350;
@@ -38,6 +38,11 @@
 //** Link hover decoration.
 @link-hover-decoration: underline;
 
+//** Site link styles
+@site-link-color:            @gray-light;
+@site-link-hover-color:      @gray-dark;
+@site-link-active-color:     @gray;
+
 //** In-site link state default
 @state-default:         #7f8c8d;
 @state-hover:           @text-color;
@@ -46,21 +51,7 @@
 
 //== Typography
 //
-//## Font, line-height, and color for body text, headings, and more.
-
-@font-size-base:          16px;
-@font-size-large:         ceil((@font-size-base * 1.25)); // ~18px
-@font-size-small:         ceil((@font-size-base * 0.85)); // ~12px
-
-@font-size-h1:            floor((@font-size-base * 2.6)); // ~36px
-@font-size-h2:            floor((@font-size-base * 2.15)); // ~30px
-@font-size-h3:            ceil((@font-size-base * 1.7)); // ~24px
-@font-size-h4:            ceil((@font-size-base * 1.25)); // ~18px
-@font-size-h5:            @font-size-base;
-@font-size-h6:            ceil((@font-size-base * 0.85)); // ~12px
-
-//** Computed "line-height" (`font-size` * `line-height`) for use with `margin`, `padding`, etc.
-@line-height-computed:    floor((@font-size-base * @line-height-base)); // ~20px
+//## headings
 
 //** By default, this inherits from the `<body>`.
 @headings-font-weight:    300;
@@ -154,6 +145,19 @@
 @alert-danger-text:           #fff;
 
 
+//== Form states and alerts
+//
+//## Define colors for form feedback states and, by default, alerts.
+
+@state-success-text:             #2ecc71;
+@state-success-bg:               #2ecc71;
+@state-success-border:           darken(spin(@state-success-bg, -10), 5%);
+
+@state-danger-text:              #e74c3c;
+@state-danger-bg:                #e74c3c;
+@state-danger-border:            darken(spin(@state-danger-bg, -10), 5%);
+
+
 //== Buttons
 //
 //## For each of Bootstrap's buttons, define text, background and border color.

+ 3 - 2
misago/emberapp/app/templates/application.hbs

@@ -6,5 +6,6 @@
 </div>
 
 {{forum-footer}}
-{{login-modal}}
-{{register-modal}}
+<div class="modal fade" id="appModal" tabindex="-1" role="dialog" aria-labelledby="appModalLabel" aria-hidden="true">
+  {{outlet "modal"}}
+</div>

+ 5 - 7
misago/emberapp/app/templates/components/form-row.hbs

@@ -6,17 +6,15 @@
     <span class="glyphicon {{if hasSuccess "glyphicon-ok" "glyphicon-remove"}} form-control-feedback" aria-hidden="true"></span>
   {{/if}}
 
-  {{#if errors }}
-    <div class="help-block">
-      {{#each error in errors}}
-      <div>
-        <strong>{{error}}</strong>
-      </div>
+  {{#if messages }}
+    <div class="help-block errors">
+      {{#each message in messages}}
+      <p>{{message}}</p>
       {{/each}}
     </div>
   {{/if}}
 
   {{#if help_text}}
-    <span class="help-block">{{help_text}}</span>
+    <p class="help-block">{{help_text}}</p>
   {{/if}}
 </div>

+ 36 - 0
misago/emberapp/app/templates/components/forms/login-form.hbs

@@ -0,0 +1,36 @@
+<div class="modal-body">
+
+  <div class="form-group">
+    <div class="control-input">
+      {{input type="text" value=username class="form-control" placeholder=(gettext "Username or e-mail")}}
+    </div>
+  </div>
+
+  <div class="form-group">
+    <div class="control-input">
+      {{input type="password" value=password class="form-control" placeholder=(gettext "Password")}}
+    </div>
+  </div>
+
+</div>
+<div class="modal-footer">
+  {{#if showActivation}}
+  {{#close-modal-button class="btn btn-block btn-success" goto="forgotten-password"}}
+    {{gettext "Activate account"}}
+  {{/close-modal-button}}
+  {{else}}
+    {{#if isLoading}}
+    <button type="button" class="btn btn-block btn-primary" disabled="disabled">
+      <span class="fa fa-cog fa-spin"></span>
+      {{gettext "Singing in..."}}
+    </button>
+    {{else}}
+    <button type="submit" class="btn btn-block btn-primary">{{gettext "Sign in"}}</button>
+    {{/if}}
+  {{/if}}
+
+  {{#close-modal-button class="btn btn-block btn-default" goto="forgotten-password"}}
+    {{gettext "Forgot password?"}}
+  {{/close-modal-button}}
+
+</div>

+ 62 - 55
misago/emberapp/app/templates/components/forms/register-form.hbs

@@ -1,70 +1,77 @@
-<div class="panel panel-form {{if isErrored "is-errored"}}">
-  {{#if isReady}}
-  <div class="panel-body">
+{{#if isReady}}
+<div class="modal-body">
 
-    {{#form-row label=(gettext "Username") for="id_username" label-class="col-md-4" control-class="col-md-8" validation=validation name="username"}}
-      {{input id="id_username" type="text" class="form-control" value=username}}
-    {{/form-row}}
+  {{#form-row label=(gettext "Username:") for="id_username" label-class="col-md-4" control-class="col-md-8" validation=validation.username}}
+    {{input id="id_username" type="text" class="form-control" autocomplete="off" value=username}}
+  {{/form-row}}
 
-    {{#form-row label=(gettext "E-mail") for="id_email" label-class="col-md-4" control-class="col-md-8" validation=validation name="email"}}
-      {{input id="id_email" type="text" class="form-control" value=email}}
-    {{/form-row}}
+  {{#form-row label=(gettext "E-mail:") for="id_email" label-class="col-md-4" control-class="col-md-8" validation=validation.email}}
+    {{input id="id_email" type="text" class="form-control" autocomplete="off" value=email}}
+  {{/form-row}}
 
-    {{#form-row label=(gettext "Password") for="id_password" label-class="col-md-4" control-class="col-md-8" validation=validation name="password"}}
-      {{input id="id_password" type="password" class="form-control" value=password}}
-    {{/form-row}}
+  {{#form-row label=(gettext "Password:") help_text=passwordHelpText for="id_password" label-class="col-md-4" control-class="col-md-8" validation=validation.password}}
+    {{input id="id_password" type="password" class="form-control" autocomplete="off" value=password}}
 
-    {{#if captcha.field}}
-    {{component captcha.field label-class="col-md-4" control-class="col-md-8"}}
+    {{#if passwordHelpText}}
+    <div class="progress password-strength">
+      <div class="progress-bar {{passwordBarClass}}" role="progressbar" aria-valuenow="{{passwordScore}}" aria-valuemin="0" aria-valuemax="4" style="width: 20%;"></div>
+    </div>
     {{/if}}
+  {{/form-row}}
 
-    <p class="lead">
-      !jak validacja kapci zakonczona, to wyswietlaj statyka &quot;hej, kapcze masz z glowy&quot;.
-      <br>
-      + podepnij feedback do kapci
-    </p>
-
-  </div>
-  <div class="panel-footer">
-    <div class="row">
-      <div class="col-md-8 col-md-offset-4">
+  {{#if captcha.field}}
+  <hr>
+    {{#if captcha.passed}}
+      <p class="lead text-success text-center">
+        <span class="fa fa-check fa-lg"></span>
+        {{gettext "Anti-spam test completed."}}
+      </p>
+    {{else}}
+      {{component captcha.field validation=validation.captcha label-class="col-md-4" control-class="col-md-8"}}
+    {{/if}}
+  {{/if}}
 
-        {{#if isLoading}}
-        <button type="button" class="btn btn-primary" disabled="disabled">
-          <span class="fa fa-cog fa-spin"></span>
-          {{gettext "Registering..."}}
-        </button>
-        {{else}}
-        <button type="submit" class="btn btn-primary">
-          {{gettext "Register account"}}
-        </button>
-        {{/if}}
+</div>
+<div class="modal-footer">
+  <div class="text-center">
 
-        <div class="extra">
-          !tutaj krutkie pierdololo o tym ze rejestracja to akceptacja regulaminu
-        </div>
+    {{#if isLoading}}
+    <button type="button" class="btn btn-primary" disabled="disabled">
+      <span class="fa fa-cog fa-spin"></span>
+      {{gettext "Registering..."}}
+    </button>
+    {{else}}
+    <button type="submit" class="btn btn-primary">
+      {{gettext "Register account"}}
+    </button>
+    {{/if}}
 
-      </div>
+    {{#if showTermsFootnote}}
+    <div class="footer-extra">
+      <p class="text-muted">
+        {{#link-to "terms-of-service" class="site-link" target="_blank"}}
+          <strong>{{gettext "Notice:"}}</strong>
+          {{gettext "By registering account you accept site's terms of service."}}
+        {{/link-to}}
+      </p>
     </div>
+    {{/if}}
 
   </div>
-  {{else if isErrored}}
-  <div class="panel-body panel-error text-center">
-    <div class="error">
+</div>
+{{else if isErrored}}
+<div class="modal-body modal-message">
 
-      <div class="error-icon">
-        <span class="fa fa-warning"></span>
-      </div>
+  <p class="lead">
+    <span class="fa fa-warning fa-lg"></span>
+    {{gettext "This form is not avaialable at the moment."}}
+  </p>
 
-      <div class="error-message">
-        <p>{{gettext "This form is not avaialable at the moment."}}</p>
-      </div>
+  <p>{{gettext "Please contact board administrator."}}</p>
 
-    </div>
-  </div>
-  {{else}}
-  <div class="panel-body panel-loader">
-    <div class="loader"></div>
-  </div>
-  {{/if}}
-</div>
+</div>
+{{else}}
+<div class="modal-body modal-message">
+  <div class="loader"></div>
+</div>
+{{/if}}

+ 0 - 7
misago/emberapp/app/templates/components/guest-nav.hbs

@@ -1,7 +0,0 @@
-<button type="button" class="btn btn-default btn-login navbar-btn btn-sm" {{action "login"}}>
-  {{gettext "Sign in"}}
-</button>
-
-<button type="button" class="btn btn-info btn-logout navbar-btn btn-sm" {{action "register"}}>
-  {{gettext "Join now"}}
-</button>

+ 0 - 46
misago/emberapp/app/templates/components/login-modal.hbs

@@ -1,46 +0,0 @@
-<div class="modal fade" id="loginModal" tabindex="-1" role="dialog" aria-labelledby="loginModalLabel" aria-hidden="true">
-  <div class="modal-dialog modal-sm modal-sign-in">
-    <div class="modal-content">
-      <div class="modal-header">
-        <button type="button" class="close" data-dismiss="modal" aria-label="{{unbound gettext "Close"}}"><span aria-hidden="true">&times;</span></button>
-        <h4 class="modal-title" id="loginModalLabel">{{gettext "Sign in"}}</h4>
-      </div>
-
-      <form {{action "submit" on="submit"}}>
-        <div class="modal-body">
-
-          <div class="form-group">
-            <div class="control-input">
-              {{input type="text" value=username class="form-control" placeholder=(gettext "Username or e-mail")}}
-            </div>
-          </div>
-
-          <div class="form-group">
-            <div class="control-input">
-              {{input type="password" value=password class="form-control" placeholder=(gettext "Password")}}
-            </div>
-          </div>
-
-        </div>
-        <div class="modal-footer">
-          {{#if showActivation}}
-          <button type="button" class="btn btn-block btn-success" {{action "activateAccount"}}>{{gettext "Activate account"}}</button>
-          {{else}}
-            {{#if isLoading}}
-            <button type="button" class="btn btn-block btn-primary" disabled="disabled">
-              <span class="fa fa-cog fa-spin"></span>
-              {{gettext "Singing in..."}}
-            </button>
-            {{else}}
-            <button type="submit" class="btn btn-block btn-primary">{{gettext "Sign in"}}</button>
-            {{/if}}
-          {{/if}}
-
-          <button class="btn btn-block btn-default" {{action "forgotPassword"}}>{{gettext "Forgot password?"}}</button>
-
-        </div>
-      </form>
-
-    </div>
-  </div>
-</div>

+ 2 - 0
misago/emberapp/app/templates/components/modal-header.hbs

@@ -0,0 +1,2 @@
+<button type="button" class="close" data-dismiss="modal" aria-label="{{unbound gettext "Close"}}"><span aria-hidden="true">&times;</span></button>
+{{yield}}

+ 14 - 1
misago/emberapp/app/templates/components/qacaptcha-field.hbs

@@ -1,6 +1,19 @@
 <label for="captcha-question" class="{{label-class}} control-label">{{model.question}}</label>
 <div class="{{control-class}}">
-  <input type="text" class="form-control" id="captcha-question">
+  {{input id="captcha-question" type="text" class="form-control" value=value}}
+
+  {{#if hasFeedback}}
+    <span class="glyphicon {{if hasSuccess "glyphicon-ok" "glyphicon-remove"}} form-control-feedback" aria-hidden="true"></span>
+  {{/if}}
+
+  {{#if messages }}
+    <div class="help-block errors">
+      {{#each message in messages}}
+      <p>{{message}}</p>
+      {{/each}}
+    </div>
+  {{/if}}
+
   {{#if model.help_text}}
   <span class="help-block">{{model.help_text}}</span>
   {{/if}}

+ 10 - 1
misago/emberapp/app/templates/components/recaptcha-field.hbs

@@ -1 +1,10 @@
-I am reCaptcha input!
+<div id="g-captcha"></div>
+
+{{#if messages }}
+  {{#each message in messages}}
+  <p class="text-danger text-center">
+    <span class="fa fa-warning fa-lg"></span>
+    {{message}}
+  </p>
+  {{/each}}
+{{/if}}

+ 10 - 0
misago/emberapp/app/templates/modals/login.hbs

@@ -0,0 +1,10 @@
+<div class="modal-dialog modal-sm modal-sign-in">
+  <div class="modal-content">
+    {{#modal-header}}
+      <h4 class="modal-title" id="loginModalLabel">{{gettext "Sign in"}}</h4>
+    {{/modal-header}}
+
+    {{login-form}}
+
+  </div>
+</div>

+ 16 - 0
misago/emberapp/app/templates/modals/register/closed.hbs

@@ -0,0 +1,16 @@
+<div class="modal-dialog modal-register-closed">
+  <div class="modal-content">
+    <div class="modal-header">
+      <button type="button" class="close" data-dismiss="modal" aria-label="{{unbound gettext "Close"}}"><span aria-hidden="true">&times;</span></button>
+      <h4 class="modal-title" id="loginModalLabel">{{gettext "Register account"}}</h4>
+    </div>
+
+    <div class="modal-body modal-message text-center">
+      <p class="lead">
+        <span class="fa fa-info-circle fa-lg"></span>
+        {{gettext "New registrations are currently not being accepted."}}
+      </p>
+    </div>
+
+  </div>
+</div>

+ 16 - 0
misago/emberapp/app/templates/modals/register/done.hbs

@@ -0,0 +1,16 @@
+<div class="modal-dialog modal-register-done">
+  <div class="modal-content">
+    <div class="modal-header">
+      <button type="button" class="close" data-dismiss="modal" aria-label="{{unbound gettext "Close"}}"><span aria-hidden="true">&times;</span></button>
+      <h4 class="modal-title" id="loginModalLabel">{{gettext "Register account"}}</h4>
+    </div>
+
+    <div class="modal-body modal-message">
+      <p class="lead">
+        <span class="fa fa-info-circle fa-lg"></span>
+        {{gettext "Your account has been registered successfully."}}
+      </p>
+    </div>
+
+  </div>
+</div>

+ 11 - 0
misago/emberapp/app/templates/modals/register/form.hbs

@@ -0,0 +1,11 @@
+<div class="modal-dialog modal-register-form">
+  <div class="modal-content">
+    <div class="modal-header">
+      <button type="button" class="close" data-dismiss="modal" aria-label="{{unbound gettext "Close"}}"><span aria-hidden="true">&times;</span></button>
+      <h4 class="modal-title" id="loginModalLabel">{{gettext "Register account"}}</h4>
+    </div>
+
+    {{register-form}}
+
+  </div>
+</div>

+ 9 - 1
misago/emberapp/app/templates/navbar.hbs

@@ -9,7 +9,15 @@
     {{#if user.isAuthenticated}}
     {{render 'user-nav'}}
     {{else}}
-    {{guest-nav}}
+    <div class="guest-nav navbar-right">
+      <button type="button" class="btn btn-default btn-login navbar-btn btn-sm" {{action "showModal" "login"}}>
+        {{gettext "Sign in"}}
+      </button>
+
+      {{#register-button class="btn btn-info btn-logout navbar-btn btn-sm"}}
+        {{gettext "Join now"}}
+      {{/register-button}}
+    </div>
     {{/if}}
 
   </div><!-- /.container -->

+ 0 - 15
misago/emberapp/app/templates/register/closed.hbs

@@ -1,15 +0,0 @@
-<div class="register-closed-page">
-  <div class="page-header">
-    <div class="container">
-      <h1>{{gettext "Register account"}}</h1>
-    </div>
-  </div>
-
-  <div class="container">
-
-    <p class="lead">
-      {{gettext "New member registrations are not currently accepted."}}
-    </p>
-
-  </div>
-</div>

+ 0 - 15
misago/emberapp/app/templates/register/done.hbs

@@ -1,15 +0,0 @@
-<div class="register-done-page">
-  <div class="page-header">
-    <div class="container">
-      <h1>{{gettext "Register account"}}</h1>
-    </div>
-  </div>
-
-  <div class="container">
-
-    <p class="lead">
-      WIP "what after registration?" page.
-    </p>
-
-  </div>
-</div>

+ 0 - 18
misago/emberapp/app/templates/register/form.hbs

@@ -1,18 +0,0 @@
-<div class="register-form-page">
-  <div class="page-header">
-    <div class="container">
-      <h1>{{gettext "Register account"}}</h1>
-    </div>
-  </div>
-
-  <div class="container">
-    <div class="row">
-      <div class="col-md-8 col-md-offset-2">
-
-        {{register-form}}
-
-      </div>
-    </div>
-
-  </div>
-</div>

+ 4 - 3
misago/emberapp/config/environment.js

@@ -15,12 +15,13 @@ module.exports = function(environment) {
 
     contentSecurityPolicy: {
       'default-src': "'none'",
-      'script-src': "'self' 'unsafe-inline' 'unsafe-eval' https://cdn.mxpnl.com", // Allow scripts from https://cdn.mxpnl.com
+      'frame-src': "https://www.google.com/recaptcha/",
+      'script-src': "'self' 'unsafe-inline' 'unsafe-eval' https://www.google.com/recaptcha/ https://apis.google.com/ https://www.gstatic.com/recaptcha/ https://cdn.mxpnl.com", // Allow scripts from https://cdn.mxpnl.com
       'font-src': "'self' http://fonts.gstatic.com", // Allow fonts to be loaded from http://fonts.gstatic.com
       'connect-src': "'self' https://api.mixpanel.com", // Allow data (ajax/websocket) from api.mixpanel.com, custom-api.local
-      'img-src': "'self'",
+      'img-src': "*",
       'style-src': "'self' 'unsafe-inline' http://fonts.googleapis.com", // Allow inline styles and loaded CSS from http://fonts.googleapis.com
-      'media-src': "'self'"
+      'media-src': "*"
     },
 
     APP: {

+ 1 - 1
misago/emberapp/tests/acceptance/activate-test.js

@@ -12,7 +12,7 @@ module('Acceptance: Account Activation', {
 
   afterEach: function() {
     Ember.$('#hidden-login-form').off('submit.stopInTest');
-    Ember.$('#loginModal').off();
+    Ember.$('#appModal').off();
     Ember.$('body').removeClass('modal-open');
     Ember.run(application, 'destroy');
     Ember.$.mockjax.clear();

+ 17 - 15
misago/emberapp/tests/acceptance/error-toast-test.js

@@ -10,6 +10,8 @@ module('Acceptance: Application Error Handler', {
     application = startApp();
   },
   afterEach: function() {
+    Ember.$('#appModal').off();
+    Ember.$('body').removeClass('modal-open');
     Ember.run(application, 'destroy');
     Ember.$.mockjax.clear();
   }
@@ -26,9 +28,9 @@ test('some unhandled error occured', function(assert) {
   visit('/');
 
   click('.guest-nav .btn-login');
-  fillIn('#loginModal .form-group:first-child input', 'SomeFake');
-  fillIn('#loginModal .form-group:last-child input', 'pass1234');
-  click('#loginModal .btn-primary');
+  fillIn('#appModal .form-group:first-child input', 'SomeFake');
+  fillIn('#appModal .form-group:last-child input', 'pass1234');
+  click('#appModal .btn-primary');
 
   andThen(function() {
     assert.equal(getToastMessage(), 'Unknown error has occured.');
@@ -46,9 +48,9 @@ test('app went away', function(assert) {
   visit('/');
 
   click('.guest-nav .btn-login');
-  fillIn('#loginModal .form-group:first-child input', 'SomeFake');
-  fillIn('#loginModal .form-group:last-child input', 'pass1234');
-  click('#loginModal .btn-primary');
+  fillIn('#appModal .form-group:first-child input', 'SomeFake');
+  fillIn('#appModal .form-group:last-child input', 'pass1234');
+  click('#appModal .btn-primary');
 
   andThen(function() {
     assert.equal(getToastMessage(), 'Lost connection with application.');
@@ -69,9 +71,9 @@ test('not found', function(assert) {
   visit('/');
 
   click('.guest-nav .btn-login');
-  fillIn('#loginModal .form-group:first-child input', 'SomeFake');
-  fillIn('#loginModal .form-group:last-child input', 'pass1234');
-  click('#loginModal .btn-primary');
+  fillIn('#appModal .form-group:first-child input', 'SomeFake');
+  fillIn('#appModal .form-group:last-child input', 'pass1234');
+  click('#appModal .btn-primary');
 
   andThen(function() {
     assert.equal(getToastMessage(), 'Action link is invalid.');
@@ -92,9 +94,9 @@ test('permission denied', function(assert) {
   visit('/');
 
   click('.guest-nav .btn-login');
-  fillIn('#loginModal .form-group:first-child input', 'SomeFake');
-  fillIn('#loginModal .form-group:last-child input', 'pass1234');
-  click('#loginModal .btn-primary');
+  fillIn('#appModal .form-group:first-child input', 'SomeFake');
+  fillIn('#appModal .form-group:last-child input', 'pass1234');
+  click('#appModal .btn-primary');
 
   andThen(function() {
     assert.equal(getToastMessage(), "You don't have permission to perform this action.");
@@ -115,9 +117,9 @@ test('permission denied with reason', function(assert) {
   visit('/');
 
   click('.guest-nav .btn-login');
-  fillIn('#loginModal .form-group:first-child input', 'SomeFake');
-  fillIn('#loginModal .form-group:last-child input', 'pass1234');
-  click('#loginModal .btn-primary');
+  fillIn('#appModal .form-group:first-child input', 'SomeFake');
+  fillIn('#appModal .form-group:last-child input', 'pass1234');
+  click('#appModal .btn-primary');
 
   andThen(function() {
     assert.equal(getToastMessage(), 'Lorem ipsum dolor met.');

+ 2 - 2
misago/emberapp/tests/acceptance/forgotten-password-test.js

@@ -12,7 +12,7 @@ module('Acceptance: Forgotten Password Change', {
 
   afterEach: function() {
     Ember.$('#hidden-login-form').off('submit.stopInTest');
-    Ember.$('#loginModal').off();
+    Ember.$('#appModal').off();
     Ember.$('body').removeClass('modal-open');
     Ember.run(application, 'destroy');
     Ember.$.mockjax.clear();
@@ -325,6 +325,6 @@ test('new password is accepted', function(assert) {
   andThen(function() {
     assert.equal(currentPath(), 'forgotten-password.change-form');
     assert.equal(getToastMessage(), "Your password has been changed.");
-    assert.ok(find('#loginModal').hasClass('in'));
+    assert.ok(find('#appModal').hasClass('in'));
   });
 });

+ 20 - 20
misago/emberapp/tests/acceptance/login-test.js

@@ -16,7 +16,7 @@ module('Acceptance: Login', {
 
   afterEach: function() {
     Ember.$('#hidden-login-form').off('submit.stopInTest');
-    Ember.$('#loginModal').off();
+    Ember.$('#appModal').off();
     Ember.$('body').removeClass('modal-open');
     Ember.run(application, 'destroy');
     Ember.$.mockjax.clear();
@@ -28,7 +28,7 @@ test('login with empty credentials', function(assert) {
 
   visit('/');
   click('.guest-nav button.btn-login');
-  click('#loginModal .btn-primary');
+  click('#appModal .btn-primary');
 
   andThen(function() {
     assert.equal(getToastMessage(), 'Fill out both fields.');
@@ -46,9 +46,9 @@ test('backend errored', function(assert) {
   visit('/');
 
   click('.guest-nav .btn-login');
-  fillIn('#loginModal .form-group:first-child input', 'SomeFake');
-  fillIn('#loginModal .form-group:last-child input', 'pass1234');
-  click('#loginModal .btn-primary');
+  fillIn('#appModal .form-group:first-child input', 'SomeFake');
+  fillIn('#appModal .form-group:last-child input', 'pass1234');
+  click('#appModal .btn-primary');
 
   andThen(function() {
     assert.equal(getToastMessage(), 'Unknown error has occured.');
@@ -71,9 +71,9 @@ test('login with invalid credentials', function(assert) {
   visit('/');
 
   click('.guest-nav .btn-login');
-  fillIn('#loginModal .form-group:first-child input', 'SomeFake');
-  fillIn('#loginModal .form-group:last-child input', 'pass1234');
-  click('#loginModal .btn-primary');
+  fillIn('#appModal .form-group:first-child input', 'SomeFake');
+  fillIn('#appModal .form-group:last-child input', 'pass1234');
+  click('#appModal .btn-primary');
 
   andThen(function() {
     assert.equal(getToastMessage(), message);
@@ -96,9 +96,9 @@ test('login to user-activated account', function(assert) {
   visit('/');
 
   click('.guest-nav .btn-login');
-  fillIn('#loginModal .form-group:first-child input', 'SomeFake');
-  fillIn('#loginModal .form-group:last-child input', 'pass1234');
-  click('#loginModal .btn-primary');
+  fillIn('#appModal .form-group:first-child input', 'SomeFake');
+  fillIn('#appModal .form-group:last-child input', 'pass1234');
+  click('#appModal .btn-primary');
 
   andThen(function() {
     assert.equal(getToastMessage(), message);
@@ -121,9 +121,9 @@ test('login to admin-activated account', function(assert) {
   visit('/');
 
   click('.guest-nav .btn-login');
-  fillIn('#loginModal .form-group:first-child input', 'SomeFake');
-  fillIn('#loginModal .form-group:last-child input', 'pass1234');
-  click('#loginModal .btn-primary');
+  fillIn('#appModal .form-group:first-child input', 'SomeFake');
+  fillIn('#appModal .form-group:last-child input', 'pass1234');
+  click('#appModal .btn-primary');
 
   andThen(function() {
     assert.equal(getToastMessage(), message);
@@ -151,9 +151,9 @@ test('login to banned account', function(assert) {
   visit('/');
 
   click('.guest-nav .btn-login');
-  fillIn('#loginModal .form-group:first-child input', 'SomeFake');
-  fillIn('#loginModal .form-group:last-child input', 'pass1234');
-  click('#loginModal .btn-primary');
+  fillIn('#appModal .form-group:first-child input', 'SomeFake');
+  fillIn('#appModal .form-group:last-child input', 'pass1234');
+  click('#appModal .btn-primary');
 
   andThen(function() {
     assert.equal(currentPath(), 'error-banned');
@@ -180,9 +180,9 @@ test('login successfully', function(assert) {
   visit('/');
 
   click('.guest-nav .btn-login');
-  fillIn('#loginModal .form-group:first-child input', 'SomeFake');
-  fillIn('#loginModal .form-group:last-child input', 'pass1234');
-  click('#loginModal .btn-primary');
+  fillIn('#appModal .form-group:first-child input', 'SomeFake');
+  fillIn('#appModal .form-group:last-child input', 'pass1234');
+  click('#appModal .btn-primary');
 
   andThen(function() {
     assert.equal(Ember.$('#hidden-login-form input[name="username"]').val(), 'SomeFake');

+ 0 - 13
misago/emberapp/tests/unit/components/guest-nav-test.js

@@ -1,13 +0,0 @@
-import {
-  moduleFor,
-  test
-} from 'ember-qunit';
-
-moduleFor('component:guest-nav', 'GuestNavComponent');
-
-test('it exists', function(assert) {
-  assert.expect(1);
-
-  var component = this.subject();
-  assert.ok(component);
-});

+ 0 - 46
misago/emberapp/tests/unit/components/login-modal-test.js

@@ -1,46 +0,0 @@
-import Ember from 'ember';
-import {
-  moduleFor,
-  test
-} from 'ember-qunit';
-
-moduleFor('component:login-modal', 'LoginModalComponent');
-
-test('it exists', function(assert) {
-  assert.expect(1);
-
-  var self = this;
-  Ember.run(function(){
-    var component = self.subject();
-    assert.ok(component);
-  });
-});
-
-test('reset works', function(assert) {
-  assert.expect(8);
-
-  var self = this;
-  Ember.run(function(){
-    var component = self.subject();
-
-    component.set('username', 'TestUsername');
-    component.set('password', 'secretpassword');
-
-    component.set('isLoading', true);
-    component.set('showActivation', true);
-
-    assert.equal(component.get('username'), 'TestUsername');
-    assert.equal(component.get('password'), 'secretpassword');
-
-    assert.equal(component.get('isLoading'), true);
-    assert.equal(component.get('showActivation'), true);
-
-    component.reset();
-
-    assert.equal(component.get('username'), '');
-    assert.equal(component.get('password'), '');
-
-    assert.equal(component.get('isLoading'), false);
-    assert.equal(component.get('showActivation'), false);
-  });
-});

+ 0 - 13
misago/emberapp/tests/unit/components/request-link-form-test.js

@@ -1,13 +0,0 @@
-import {
-  moduleFor,
-  test
-} from 'ember-qunit';
-
-moduleFor('component:request-link-form', 'RequestLinkFormComponent');
-
-test('it exists', function(assert) {
-  assert.expect(1);
-
-  var component = this.subject();
-  assert.ok(component);
-});

+ 0 - 13
misago/emberapp/tests/unit/components/set-new-password-form-test.js

@@ -1,13 +0,0 @@
-import {
-  moduleFor,
-  test
-} from 'ember-qunit';
-
-moduleFor('component:set-new-password-form', 'SetNewPasswordFormController');
-
-test('it exists', function(assert) {
-  assert.expect(1);
-
-  var component = this.subject();
-  assert.ok(component);
-});

+ 2 - 0
misago/emberapp/vendor/testutils/misago-preload-data.js

@@ -5,6 +5,8 @@ window.MisagoData = {
 
     "account_activation": "none",
 
+    "captcha_type": "no",
+
     "forum_name": "Misago",
     "forum_footnote": "This site uses cookies to track and analyse traffic.",
     "forum_index_title": "",

+ 10 - 0
misago/users/api/users.py

@@ -3,6 +3,9 @@ from django.contrib.auth import get_user_model
 from rest_framework import status, viewsets
 from rest_framework.response import Response
 
+from misago.core import forms
+
+from misago.users import captcha
 from misago.users.forms.register import RegisterForm
 
 
@@ -20,7 +23,14 @@ class UserViewSet(viewsets.ViewSet):
         POST to /api/users is treated as new user registration
         """
         form = RegisterForm(request.data)
+
+        try:
+            captcha.test_request(request)
+        except forms.ValidationError as e:
+            form.add_error('captcha', e)
+
         if form.is_valid():
+            captcha.reset_session(request.session)
             return Response({'detail': 'Wolololo!'})
         else:
             return Response(form.errors,

+ 42 - 60
misago/users/captcha.py

@@ -1,3 +1,5 @@
+import requests
+
 from recaptcha.client.captcha import displayhtml, submit as submit_recaptcha
 from django.utils.translation import ugettext_lazy as _
 
@@ -5,82 +7,62 @@ from misago.conf import settings
 from misago.core import forms
 
 
-def validate_recaptcha(request):
-    raise NotImplementedError('reCaptcha 2 is not implemented')
-    answer = self.cleaned_data['qa_answer'].lower()
-
-    for predefined_answer in settings.qa_answers.lower().splitlines():
-        predefined_answer = predefined_answer.strip().lower()
-        if answer == predefined_answer:
-            self.has_qa_captcha = False
-            return self.cleaned_data['qa_answer']
-    else:
-        raise forms.ValidationError(_("Entered answer is incorrect."))
-
-
-def validate_qacaptcha(request):
-    raise NotImplementedError('Q&A captcha is not implemented')
-
+"""
+Session flagging
+"""
+def session_already_passed_test(session):
+    return session.get('passed_captcha')
 
-def validate_nocaptcha(request):
-    return # no captcha means no validation
 
+def mark_session_as_passing(session):
+    session['passed_captcha'] = True
 
-CAPTCHA_TESTS = {
-    're': validate_recaptcha,
-    'qa': validate_qacaptcha,
-    'no': validate_nocaptcha,
-}
 
-def validate_captcha(request):
-    if not session_already_passed_test(request.session):
-        # run test and if it didn't raise validation error,
-        # mark session as passing so we don't troll uses anymore
-        CAPTCHA_TESTS[settings['captcha_type']](request)
-        mark_session_as_passing(request.session)
+def reset_session(session):
+    session.pop('passed_captcha', None)
 
 
 """
-Q&A
+Captcha tests
 """
-def clean_qa_answer(self):
-    answer = self.cleaned_data['qa_answer'].lower()
+def recaptcha_test(request):
+    r = requests.post('https://www.google.com/recaptcha/api/siteverify', data={
+        'secret': settings.recaptcha_secret_key,
+        'response': request.data.get('captcha'),
+        'remoteip': request._misago_real_ip
+    })
+
+    if r.status_code == 200:
+        response_json = r.json()
+        if not response_json.get('success'):
+            raise forms.ValidationError(_("Please try again."))
+    else:
+        raise forms.ValidationError(_("Failed to contact reCAPTCHA API."))
 
+
+def qacaptcha_test(request):
+    answer = request.data.get('captcha', '').lower()
     for predefined_answer in settings.qa_answers.lower().splitlines():
         predefined_answer = predefined_answer.strip().lower()
         if answer == predefined_answer:
-            self.has_qa_captcha = False
-            mark_session_as_passing(self.session)
-            return self.cleaned_data['qa_answer']
+            break
     else:
         raise forms.ValidationError(_("Entered answer is incorrect."))
 
 
-def add_qa_test_to_form(request, test_passed):
-    qa_answer_field = forms.CharField(label=settings.qa_question,
-                                      help_text=settings.qa_help_text,
-                                      required=(not test_passed))
-
-    extra_fields = {
-        'passed_qa_captcha': test_passed,
-        'has_qa_captcha': True,
-        'qa_answer': qa_answer_field,
-        'clean_qa_answer': clean_qa_answer,
-    }
-
-    if test_passed:
-        extra_fields['has_qa_captcha'] = False
-        extra_fields.pop('clean_qa_answer')
-
-    return extra_fields
+def nocaptcha_test(request):
+    return # no captcha means no validation
 
 
-"""
-Session utils
-"""
-def session_already_passed_test(session):
-    return session.get('passed_captcha')
-
+CAPTCHA_TESTS = {
+    're': recaptcha_test,
+    'qa': qacaptcha_test,
+    'no': nocaptcha_test,
+}
 
-def mark_session_as_passing(session):
-    session['passed_captcha'] = True
+def test_request(request):
+    if not session_already_passed_test(request.session):
+        # run test and if it didn't raise validation error,
+        # mark session as passing so we don't troll uses anymore
+        CAPTCHA_TESTS[settings.captcha_type](request)
+        mark_session_as_passing(request.session)

+ 3 - 0
misago/users/forms/register.py

@@ -7,3 +7,6 @@ class RegisterForm(forms.Form):
     email = forms.CharField(validators=[validators.validate_email])
     password = forms.CharField(validators=[validators.validate_password],
                                widget=forms.PasswordInput(render_value=True))
+
+    # placeholder field for setting captcha errors on form
+    captcha = forms.CharField(required=False)

+ 5 - 0
misago/users/migrations/0002_users_settings.py

@@ -55,6 +55,7 @@ def create_users_settings_group(apps, schema_editor):
                         'min_value': 2,
                         'max_value': 255,
                     },
+                    'is_public': True,
                 },
                 {
                     'setting': 'username_length_max',
@@ -66,6 +67,7 @@ def create_users_settings_group(apps, schema_editor):
                         'min_value': 2,
                         'max_value': 255,
                     },
+                    'is_public': True,
                 },
                 {
                     'setting': 'password_length_min',
@@ -78,6 +80,7 @@ def create_users_settings_group(apps, schema_editor):
                         'min_value': 2,
                         'max_value': 255,
                     },
+                    'is_public': True,
                 },
                 {
                     'setting': 'allow_custom_avatars',
@@ -129,6 +132,7 @@ def create_users_settings_group(apps, schema_editor):
                     'field_extra': {
                         'min_value': 0,
                     },
+                    'is_public': True,
                 },
                 {
                     'setting': 'signature_length_max',
@@ -141,6 +145,7 @@ def create_users_settings_group(apps, schema_editor):
                         'min_value': 256,
                         'max_value': 10000,
                     },
+                    'is_public': True,
                 },
                 {
                     'setting': 'subscribe_start',

+ 1 - 1
misago/users/urls/api.py

@@ -18,7 +18,7 @@ urlpatterns += patterns('misago.users.api.changepassword',
     url(r'^change-password/(?P<user_id>\d+)/(?P<token>[a-zA-Z0-9]+)/$', 'change_password', name='change_password'),
 )
 
-urlpatterns = patterns('misago.users.api.captcha',
+urlpatterns += patterns('misago.users.api.captcha',
     url(r'^captcha-questions/(?P<question_id>\d+)/$', 'question', name='captcha_question'),
 )
 

+ 7 - 7
misago/users/validators.py

@@ -54,9 +54,9 @@ def validate_password(value):
     if len(value) < settings.password_length_min:
         message = ungettext(
             'Valid password must be at least one character long.',
-            'Valid password must be at least %(length)d characters long.',
+            'Valid password must be at least %(limit)s characters long.',
             settings.password_length_min)
-        message = message % {'length': settings.password_length_min}
+        message = message % {'limit': settings.password_length_min}
         raise ValidationError(message)
 
 
@@ -92,18 +92,18 @@ def validate_username_content(value):
 def validate_username_length(value):
     if len(value) < settings.username_length_min:
         message = ungettext(
-            'Username must be at least one character long.',
-            'Username must be at least %(length)d characters long.',
+            "Username must be at least one character long.",
+            "Username must be at least %(limit)s characters long.",
             settings.username_length_min)
-        message = message % {'length': settings.username_length_min}
+        message = message % {'limit': settings.username_length_min}
         raise ValidationError(message)
 
     if len(value) > settings.username_length_max:
         message = ungettext(
             "Username cannot be longer than one characters.",
-            "Username cannot be longer than %(length)d characters.",
+            "Username cannot be longer than %(limit)s characters.",
             settings.username_length_max)
-        message = message % {'length': settings.username_length_max}
+        message = message % {'limit': settings.username_length_max}
         raise ValidationError(message)