Browse Source

user control panel in Ember.js done

Rafał Pitoń 10 years ago
parent
commit
f6bd423afd
50 changed files with 1454 additions and 232 deletions
  1. 11 1
      misago/core/tests/test_serializer.py
  2. 98 0
      misago/emberapp/app/components/forms/change-email-form.js
  3. 113 0
      misago/emberapp/app/components/forms/change-password-form.js
  4. 5 12
      misago/emberapp/app/components/forms/change-username-form.js
  5. 6 12
      misago/emberapp/app/components/forms/register-form.js
  6. 25 0
      misago/emberapp/app/components/forms/validated-form.js
  7. 6 2
      misago/emberapp/app/router.js
  8. 0 5
      misago/emberapp/app/routes/activation/activate.js
  9. 25 0
      misago/emberapp/app/routes/options/email/confirm.js
  10. 0 0
      misago/emberapp/app/routes/options/email/index.js
  11. 25 0
      misago/emberapp/app/routes/options/password/confirm.js
  12. 0 0
      misago/emberapp/app/routes/options/password/index.js
  13. 10 1
      misago/emberapp/app/services/auth.js
  14. 9 0
      misago/emberapp/app/styles/misago/loaders.less
  15. 1 25
      misago/emberapp/app/styles/misago/material-icons.less
  16. 5 0
      misago/emberapp/app/styles/misago/navs.less
  17. 11 0
      misago/emberapp/app/templates/activation/loading.hbs
  18. 1 1
      misago/emberapp/app/templates/components/forms/avatar-crop-form.hbs
  19. 51 0
      misago/emberapp/app/templates/components/forms/change-email-form.hbs
  20. 61 0
      misago/emberapp/app/templates/components/forms/change-password-form.hbs
  21. 0 1
      misago/emberapp/app/templates/options/email.hbs
  22. 1 0
      misago/emberapp/app/templates/options/email/index.hbs
  23. 4 0
      misago/emberapp/app/templates/options/email/loading.hbs
  24. 0 0
      misago/emberapp/app/templates/options/password.hbs
  25. 1 0
      misago/emberapp/app/templates/options/password/index.hbs
  26. 4 0
      misago/emberapp/app/templates/options/password/loading.hbs
  27. 196 0
      misago/emberapp/tests/acceptance/change-email-test.js
  28. 201 0
      misago/emberapp/tests/acceptance/change-password-test.js
  29. 1 1
      misago/emberapp/tests/acceptance/change-username-test.js
  30. 1 1
      misago/emberapp/tests/acceptance/registration-test.js
  31. 18 0
      misago/templates/misago/emails/change_email.html
  32. 14 0
      misago/templates/misago/emails/change_email.txt
  33. 0 18
      misago/templates/misago/emails/change_email_password.html
  34. 0 14
      misago/templates/misago/emails/change_email_password.txt
  35. 18 0
      misago/templates/misago/emails/change_password.html
  36. 14 0
      misago/templates/misago/emails/change_password.txt
  37. 65 0
      misago/users/api/userendpoints/changeemail.py
  38. 63 0
      misago/users/api/userendpoints/changepassword.py
  39. 16 0
      misago/users/api/users.py
  40. 0 78
      misago/users/changedcredentials.py
  41. 56 0
      misago/users/credentialchange.py
  42. 30 36
      misago/users/forms/options.py
  43. 0 20
      misago/users/tests/test_changedcredentials.py
  44. 58 0
      misago/users/tests/test_credentialchange.py
  45. 8 0
      misago/users/tests/test_options_views.py
  46. 115 0
      misago/users/tests/test_user_changeemail_api.py
  47. 102 0
      misago/users/tests/test_user_changepassword_api.py
  48. 2 2
      misago/users/testutils.py
  49. 1 0
      misago/users/urls/__init__.py
  50. 2 2
      misago/users/views/options.py

+ 11 - 1
misago/core/tests/test_serializer.py

@@ -4,11 +4,21 @@ from misago.core import serializer
 
 
 class SerializerTests(TestCase):
 class SerializerTests(TestCase):
     def test_serializer(self):
     def test_serializer(self):
-        """serializer dehydrates and hydrates values"""
+        """serializer dehydrates and hydrates values of different types"""
         TEST_CASES = (
         TEST_CASES = (
             'LoremIpsum', 123, [1, 2, '4d'], {'bawww': 'zong', 23: True}
             'LoremIpsum', 123, [1, 2, '4d'], {'bawww': 'zong', 23: True}
         )
         )
 
 
         for wet in TEST_CASES:
         for wet in TEST_CASES:
             dry = serializer.dumps(wet)
             dry = serializer.dumps(wet)
+            self.assertFalse(dry.endswith('='))
             self.assertEqual(wet, serializer.loads(dry))
             self.assertEqual(wet, serializer.loads(dry))
+
+    def test_serializer_handles_paddings(self):
+        """serializer handles missing paddings"""
+        for i in xrange(100):
+            wet = 'Lorem ipsum %s' % ('a' * i)
+            dry = serializer.dumps(wet)
+            self.assertFalse(dry.endswith('='))
+            self.assertEqual(wet, serializer.loads(dry))
+

+ 98 - 0
misago/emberapp/app/components/forms/change-email-form.js

@@ -0,0 +1,98 @@
+import Ember from 'ember';
+import ValidatedForm from 'misago/components/forms/validated-form';
+
+export default ValidatedForm.extend({
+  tagName: 'form',
+  classNames: 'form-horizontal',
+
+  isBusy: false,
+
+  new_email: '',
+  password: '',
+
+  apiUrl: function() {
+    return 'users/' + this.auth.get('user.id') + '/change-email';
+  }.property(),
+
+  newEmailValidation: function() {
+    var value = Ember.$.trim(this.get('new_email'));
+    var state = 'ok';
+
+    var re = /^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i;
+
+    if (value.length === 0) {
+      state = [gettext('Enter new e-mail.')];
+    } else if (value === this.get('auth.user.email')) {
+      state = [gettext('New e-mail is same as current one.')];
+    } else if (!re.test(value)) {
+      state = [gettext('Invalid e-mail address.')];
+    }
+
+    this.set('validation.new_email', state);
+  }.observes('new_email'),
+
+  passwordValidation: function() {
+    var value = Ember.$.trim(this.get('password'));
+
+    if (value.length === 0) {
+      this.set('validation.password', [gettext('Enter current password.')]);
+    } else {
+      this.set('validation.password', 'ok');
+    }
+  }.observes('password'),
+
+  submit: function() {
+    if (this.get('isBusy')) {
+      return false;
+    }
+
+    var data = {
+      new_email: Ember.$.trim(this.get('new_email')),
+      password: Ember.$.trim(this.get('password')),
+    };
+
+    this.newEmailValidation();
+    this.passwordValidation();
+
+    if (this.hasValidationErrors()) {
+      this.toast.error(gettext('Form contains errors.'));
+      return false;
+    }
+
+    this.set('isBusy', true);
+
+    var self = this;
+    this.ajax.post(this.get('apiUrl'), data
+    ).then(function(response) {
+      if (self.isDestroyed) { return; }
+      self.success(response);
+    }, function(jqXHR) {
+      if (self.isDestroyed) { return; }
+      self.error(jqXHR);
+    }).finally(function() {
+      if (self.isDestroyed) { return; }
+      self.set('isBusy', false);
+    });
+
+    return false;
+  },
+
+  success: function(responseJSON) {
+    this.setProperties({
+      'new_email': '',
+      'password': '',
+    });
+    this.resetValidation();
+    this.toast.info(responseJSON.detail);
+  },
+
+  error: function(jqXHR) {
+    var rejection = jqXHR.responseJSON;
+    if (jqXHR.status === 400) {
+      this.toast.error(gettext('Form contains errors.'));
+      this.get('validation').setProperties(rejection);
+    } else {
+      this.toast.apiError(jqXHR);
+    }
+  },
+});

+ 113 - 0
misago/emberapp/app/components/forms/change-password-form.js

@@ -0,0 +1,113 @@
+import Ember from 'ember';
+import ValidatedForm from 'misago/components/forms/validated-form';
+
+export default ValidatedForm.extend({
+  tagName: 'form',
+  classNames: 'form-horizontal',
+
+  isBusy: false,
+
+  new_password: '',
+  repeat_password: '',
+  password: '',
+
+  apiUrl: function() {
+    return 'users/' + this.auth.get('user.id') + '/change-password';
+  }.property(),
+
+  newPasswordValidation: function() {
+    var value = Ember.$.trim(this.get('new_password'));
+
+    if (value.length === 0) {
+      this.set('validation.new_password', [gettext('Enter new password.')]);
+    } else if (value.length < this.get('settings.password_length_min')) {
+      var limit = this.get('settings.password_length_min');
+      var message = ngettext('Valid password must be at least %(limit)s character long.',
+                             'Valid password must be at least %(limit)s characters long.',
+                             limit);
+      this.set('validation.new_password', [interpolate(message, {limit: limit}, true)]);
+    } else {
+      this.set('validation.new_password', 'ok');
+    }
+  }.observes('new_password', 'repeat_password'),
+
+  repeatPasswordValidation: function() {
+    var value = Ember.$.trim(this.get('repeat_password'));
+
+    if (value.length === 0) {
+      this.set('validation.repeat_password', [gettext('Repeat new password.')]);
+    } else if (value !== Ember.$.trim(this.get('new_password'))) {
+      this.set('validation.repeat_password', [gettext('Password differs from new password.')]);
+    } else {
+      this.set('validation.repeat_password', 'ok');
+    }
+  }.observes('new_password', 'repeat_password'),
+
+  passwordValidation: function() {
+    var value = Ember.$.trim(this.get('password'));
+
+    if (value.length === 0) {
+      this.set('validation.password', [gettext('Enter current password.')]);
+    } else {
+      this.set('validation.password', 'ok');
+    }
+  }.observes('password'),
+
+  submit: function() {
+    if (this.get('isBusy')) {
+      return false;
+    }
+
+    var data = {
+      new_password: Ember.$.trim(this.get('new_password')),
+      repeat_password: Ember.$.trim(this.get('repeat_password')),
+      password: Ember.$.trim(this.get('password')),
+    };
+
+    this.newPasswordValidation();
+    this.repeatPasswordValidation();
+    this.passwordValidation();
+
+    if (this.hasValidationErrors()) {
+      this.toast.error(gettext('Form contains errors.'));
+      return false;
+    }
+
+    this.set('isBusy', true);
+
+    var self = this;
+    this.ajax.post(this.get('apiUrl'), data
+    ).then(function(response) {
+      if (self.isDestroyed) { return; }
+      self.success(response);
+    }, function(jqXHR) {
+      if (self.isDestroyed) { return; }
+      self.error(jqXHR);
+    }).finally(function() {
+      if (self.isDestroyed) { return; }
+      self.set('isBusy', false);
+    });
+
+    return false;
+  },
+
+  success: function(responseJSON) {
+    this.setProperties({
+      'new_password': '',
+      'repeat_password': '',
+      'password': '',
+    });
+    this.resetValidation();
+    this.toast.info(responseJSON.detail);
+  },
+
+  error: function(jqXHR) {
+    var rejection = jqXHR.responseJSON;
+    if (jqXHR.status === 400) {
+      this.toast.error(gettext('Form contains errors.'));
+      this.get('validation').setProperties(rejection);
+    } else {
+      this.toast.apiError(jqXHR);
+    }
+  },
+});

+ 5 - 12
misago/emberapp/app/components/forms/change-username-form.js

@@ -1,6 +1,7 @@
 import Ember from 'ember';
 import Ember from 'ember';
+import ValidatedForm from 'misago/components/forms/validated-form';
 
 
-export default Ember.Component.extend({
+export default ValidatedForm.extend({
   tagName: 'form',
   tagName: 'form',
   classNames: 'form-horizontal',
   classNames: 'form-horizontal',
 
 
@@ -11,11 +12,6 @@ export default Ember.Component.extend({
   options: null,
   options: null,
   username: '',
   username: '',
 
 
-  validation: null,
-  setValidation: function() {
-    this.set('validation', Ember.Object.create({}));
-  }.on('init'),
-
   apiUrl: function() {
   apiUrl: function() {
     return 'users/' + this.auth.get('user.id') + '/username';
     return 'users/' + this.auth.get('user.id') + '/username';
   }.property(),
   }.property(),
@@ -83,12 +79,9 @@ export default Ember.Component.extend({
       username: Ember.$.trim(this.get('username')),
       username: Ember.$.trim(this.get('username')),
     };
     };
 
 
-    if (data.username.length === 0) {
-      this.toast.error(gettext('Enter new username.'));
-      return false;
-    }
+    this.usernameValidation();
 
 
-    if (this.$('.has-error').length) {
+    if (this.hasValidationErrors()) {
       this.toast.error(gettext('Form contains errors.'));
       this.toast.error(gettext('Form contains errors.'));
       return false;
       return false;
     }
     }
@@ -113,7 +106,7 @@ export default Ember.Component.extend({
 
 
   success: function(responseJSON) {
   success: function(responseJSON) {
     this.set('username', '');
     this.set('username', '');
-    this.set('validation', Ember.Object.create({}));
+    this.resetValidation();
 
 
     this.toast.success(gettext('Your username has been changed.'));
     this.toast.success(gettext('Your username has been changed.'));
     this.get('options').setProperties(responseJSON.options);
     this.get('options').setProperties(responseJSON.options);

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

@@ -1,6 +1,7 @@
 import Ember from 'ember';
 import Ember from 'ember';
+import ValidatedForm from 'misago/components/forms/validated-form';
 
 
-export default Ember.Component.extend({
+export default ValidatedForm.extend({
   tagName: 'form',
   tagName: 'form',
   classNames: 'form-horizontal',
   classNames: 'form-horizontal',
 
 
@@ -14,11 +15,6 @@ export default Ember.Component.extend({
   email: '',
   email: '',
   password: '',
   password: '',
 
 
-  validation: null,
-  setValidation: function() {
-    this.set('validation', Ember.Object.create({}));
-  }.on('init'),
-
   router: function() {
   router: function() {
     return this.container.lookup('router:main');
     return this.container.lookup('router:main');
   }.property(),
   }.property(),
@@ -171,13 +167,11 @@ export default Ember.Component.extend({
       captcha: Ember.$.trim(this.get('captcha.value'))
       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;
-    }
+    this.usernameValidation();
+    this.emailValidation();
+    this.passwordValidation();
 
 
-    if (this.$('.has-error').length) {
+    if (this.hasValidationErrors()) {
       this.toast.error(gettext("Form contains errors."));
       this.toast.error(gettext("Form contains errors."));
       return false;
       return false;
     }
     }

+ 25 - 0
misago/emberapp/app/components/forms/validated-form.js

@@ -0,0 +1,25 @@
+import Ember from 'ember';
+
+export default Ember.Component.extend({
+  validation: null,
+
+  setValidation: function() {
+    this.resetValidation();
+  }.on('init'),
+
+  resetValidation: function() {
+    this.set('validation', Ember.Object.create({}));
+  },
+
+  hasValidationErrors: function() {
+    var self = this;
+    var hasErrors = false;
+
+    Ember.keys(this.get('validation')).forEach(function(field) {
+      if (self.get('validation.' + field) !== 'ok') {
+        hasErrors = true;
+      }
+    });
+    return hasErrors;
+  }
+});

+ 6 - 2
misago/emberapp/app/router.js

@@ -20,8 +20,12 @@ Router.map(function() {
     this.route('forum', { path: 'forum-options/' });
     this.route('forum', { path: 'forum-options/' });
     this.route('signature', { path: 'edit-signature/' });
     this.route('signature', { path: 'edit-signature/' });
     this.route('username', { path: 'change-username/' });
     this.route('username', { path: 'change-username/' });
-    this.route('password', { path: 'change-password/' });
-    this.route('email', { path: 'change-email/' });
+    this.route('password', { path: 'change-password/' }, function() {
+      this.route('confirm', { path: ':token/' });
+    });
+    this.route('email', { path: 'change-email/' }, function() {
+      this.route('confirm', { path: ':token/' });
+    });
   });
   });
 
 
   // Legal
   // Legal

+ 0 - 5
misago/emberapp/app/routes/activation/activate.js

@@ -12,11 +12,6 @@ export default MisagoRoute.extend({
   },
   },
 
 
   actions: {
   actions: {
-    loading: function() {
-      this.set('title', gettext('Activating account...'));
-      return true;
-    },
-
     error: function(reason) {
     error: function(reason) {
       if (reason.status === 400) {
       if (reason.status === 400) {
         this.toast.error(reason.responseJSON.detail);
         this.toast.error(reason.responseJSON.detail);

+ 25 - 0
misago/emberapp/app/routes/options/email/confirm.js

@@ -0,0 +1,25 @@
+import MisagoRoute from 'misago/routes/misago';
+
+export default MisagoRoute.extend({
+  model: function(params) {
+    return this.ajax.post('users/' + this.get('auth.user.id') + '/change-email', {
+      'token': params.token
+    });
+  },
+
+  afterModel: function(model) {
+    this.toast.success(model.detail);
+    return this.transitionTo('options.email');
+  },
+
+  actions: {
+    error: function(reason) {
+      if (reason.status === 400) {
+        this.toast.error(reason.responseJSON.detail);
+        return this.transitionTo('options.email');
+      }
+
+      return true;
+    }
+  }
+});

+ 0 - 0
misago/emberapp/app/routes/options/email.js → misago/emberapp/app/routes/options/email/index.js


+ 25 - 0
misago/emberapp/app/routes/options/password/confirm.js

@@ -0,0 +1,25 @@
+import MisagoRoute from 'misago/routes/misago';
+
+export default MisagoRoute.extend({
+  model: function(params) {
+    return this.ajax.post('users/' + this.get('auth.user.id') + '/change-password', {
+      'token': params.token
+    });
+  },
+
+  afterModel: function(model) {
+    this.toast.success(model.detail);
+    return this.transitionTo('options.password');
+  },
+
+  actions: {
+    error: function(reason) {
+      if (reason.status === 400) {
+        this.toast.error(reason.responseJSON.detail);
+        return this.transitionTo('options.password');
+      }
+
+      return true;
+    }
+  }
+});

+ 0 - 0
misago/emberapp/app/routes/options/password.js → misago/emberapp/app/routes/options/password/index.js


+ 10 - 1
misago/emberapp/app/services/auth.js

@@ -47,7 +47,16 @@ export default Ember.Service.extend({
 
 
   userObserver: function() {
   userObserver: function() {
     this.session.setItem('auth-user', this.get('user'));
     this.session.setItem('auth-user', this.get('user'));
-  }.observes('user.avatar_hash', 'user.username', 'user.slug'),
+  }.observes('user.username',
+             'user.slug',
+             'user.email',
+             'user.is_hiding_presence',
+             'user.avatar_hash',
+             'user.new_notifications',
+             'user.limits_private_thread_invites_to',
+             'user.unread_private_threads',
+             'user.subscribe_to_started_threads',
+             'user.subscribe_to_replied_threads'),
 
 
   // Return user as POJO
   // Return user as POJO
 
 

+ 9 - 0
misago/emberapp/app/styles/misago/loaders.less

@@ -75,6 +75,15 @@
 }
 }
 
 
 
 
+// Loader message
+.loading {
+  .loader-message {
+    color: @loader-color;
+    font-size: @font-size-large;
+    text-align: center;
+  }
+}
+
 // Transmission Bars
 // Transmission Bars
 .loader-compact {
 .loader-compact {
   margin: 0px auto;
   margin: 0px auto;

+ 1 - 25
misago/emberapp/app/styles/misago/material-icons.less

@@ -15,6 +15,7 @@
        url(../fonts/MaterialIcons-Regular.ttf) format('truetype');
        url(../fonts/MaterialIcons-Regular.ttf) format('truetype');
 }
 }
 
 
+
 .material-icons {
 .material-icons {
   font-family: 'Material Icons';
   font-family: 'Material Icons';
   font-weight: normal;
   font-weight: normal;
@@ -61,28 +62,3 @@
 .btn .material-icons {
 .btn .material-icons {
   .opacity(1);
   .opacity(1);
 }
 }
-
-
-/* Rotation animation */
-
-@-webkit-keyframes md-spin {
-  0% {
-    -webkit-transform: rotate(0deg);
-            transform: rotate(0deg);
-  }
-  100% {
-    -webkit-transform: rotate(359deg * -4);
-            transform: rotate(359deg * -4);
-  }
-}
-
-@keyframes md-spin {
-  0% {
-    -webkit-transform: rotate(0deg);
-            transform: rotate(0deg);
-  }
-  100% {
-    -webkit-transform: rotate(359deg * -4);
-            transform: rotate(359deg * -4);
-  }
-}

+ 5 - 0
misago/emberapp/app/styles/misago/navs.less

@@ -31,6 +31,11 @@
       border-color: @sidenav-active-border;
       border-color: @sidenav-active-border;
 
 
       color: @sidenav-active-color;
       color: @sidenav-active-color;
+
+      .material-icons {
+        .opacity(.9);
+        color: @sidenav-hover-color;
+      }
     }
     }
   }
   }
 }
 }

+ 11 - 0
misago/emberapp/app/templates/activation/loading.hbs

@@ -0,0 +1,11 @@
+<div class="loading-page">
+  <div class="container">
+    <div class="loading">
+
+      <div class="loader loader-large"></div>
+
+      <p class="loader-message">{{gettext "Activating account..."}}</p>
+
+    </div>
+  </div>
+</div>

+ 1 - 1
misago/emberapp/app/templates/components/forms/avatar-crop-form.hbs

@@ -8,7 +8,7 @@
   <div class="col-md-6 col-md-offset-3">
   <div class="col-md-6 col-md-offset-3">
 
 
     {{#if isBusy}}
     {{#if isBusy}}
-      <button type="button" class="btn btn-primary btn-block is-busy" disabled="disabled">
+      <button type="button" class="btn btn-primary btn-block btn-busy" disabled="disabled">
         &nbsp;
         &nbsp;
         {{loader-compact}}
         {{loader-compact}}
       </button>
       </button>

+ 51 - 0
misago/emberapp/app/templates/components/forms/change-email-form.hbs

@@ -0,0 +1,51 @@
+<div class="panel panel-form">
+  <div class="panel-heading">
+    <h3 class="panel-title">{{gettext "Change e-mail"}}</h3>
+  </div>
+  <div class="panel-body">
+
+    {{#form-row
+        label=(gettext "New e-mail:")
+        for="id_new_email"
+        labelClass="col-md-4"
+        controlClass="col-md-8"
+        validation=validation.new_email}}
+
+      {{input id="id_new_email" type="text" class="form-control" autocomplete="off" value=new_email}}
+    {{/form-row}}
+
+    {{#form-row
+        label=(gettext "Current password:")
+        for="id_password"
+        labelClass="col-md-4"
+        controlClass="col-md-8"
+        validation=validation.password}}
+
+      {{input id="id_password" type="password" class="form-control" autocomplete="off" value=password}}
+      <div class="help-block">
+        {{#link-to "forgotten-password"}}
+          {{gettext "Click here if you don't remember your password."}}
+        {{/link-to}}
+      </div>
+    {{/form-row}}
+
+  </div>
+  <div class="panel-footer">
+    <div class="row">
+      <div class="col-md-8 col-md-offset-4">
+
+        {{#if isBusy}}
+        <button type="button" class="btn btn-primary btn-block-sm btn-busy" disabled="disabled">
+          {{gettext "Change e-mail"}}
+          {{loader-compact}}
+        </button>
+        {{else}}
+        <button type="submit" class="btn btn-primary btn-block-sm">
+          {{gettext "Change e-mail"}}
+        </button>
+        {{/if}}
+
+      </div>
+    </div>
+  </div>
+</div>

+ 61 - 0
misago/emberapp/app/templates/components/forms/change-password-form.hbs

@@ -0,0 +1,61 @@
+<div class="panel panel-form">
+  <div class="panel-heading">
+    <h3 class="panel-title">{{gettext "Change password"}}</h3>
+  </div>
+  <div class="panel-body">
+
+    {{#form-row
+        label=(gettext "New password:")
+        for="id_new_password"
+        labelClass="col-md-4"
+        controlClass="col-md-8"
+        validation=validation.new_password}}
+
+      {{input id="id_new_password" type="password" class="form-control" autocomplete="off" value=new_password}}
+    {{/form-row}}
+
+    {{#form-row
+        label=(gettext "Repeat password:")
+        for="id_repeat_password"
+        labelClass="col-md-4"
+        controlClass="col-md-8"
+        validation=validation.repeat_password}}
+
+      {{input id="id_repeat_password" type="password" class="form-control" autocomplete="off" value=repeat_password}}
+    {{/form-row}}
+
+    {{#form-row
+        label=(gettext "Current password:")
+        for="id_password"
+        labelClass="col-md-4"
+        controlClass="col-md-8"
+        validation=validation.password}}
+
+      {{input id="id_password" type="password" class="form-control" autocomplete="off" value=password}}
+      <div class="help-block">
+        {{#link-to "forgotten-password"}}
+          {{gettext "Click here if you don't remember your password."}}
+        {{/link-to}}
+      </div>
+    {{/form-row}}
+
+  </div>
+  <div class="panel-footer">
+    <div class="row">
+      <div class="col-md-8 col-md-offset-4">
+
+        {{#if isBusy}}
+        <button type="button" class="btn btn-primary btn-block-sm btn-busy" disabled="disabled">
+          {{gettext "Change password"}}
+          {{loader-compact}}
+        </button>
+        {{else}}
+        <button type="submit" class="btn btn-primary btn-block-sm">
+          {{gettext "Change password"}}
+        </button>
+        {{/if}}
+
+      </div>
+    </div>
+  </div>
+</div>

+ 0 - 1
misago/emberapp/app/templates/options/email.hbs

@@ -1 +0,0 @@
-WOO CHANGE E-MAIL

+ 1 - 0
misago/emberapp/app/templates/options/email/index.hbs

@@ -0,0 +1 @@
+{{change-email-form}}

+ 4 - 0
misago/emberapp/app/templates/options/email/loading.hbs

@@ -0,0 +1,4 @@
+<div class="loading">
+  <div class="loader loader-large"></div>
+  <p class="loader-message">{{gettext "Changing e-mail..."}}</p>
+</div>

+ 0 - 0
misago/emberapp/app/templates/options/password.hbs


+ 1 - 0
misago/emberapp/app/templates/options/password/index.hbs

@@ -0,0 +1 @@
+{{change-password-form}}

+ 4 - 0
misago/emberapp/app/templates/options/password/loading.hbs

@@ -0,0 +1,4 @@
+<div class="loading">
+  <div class="loader loader-large"></div>
+  <p class="loader-message">{{gettext "Changing password..."}}</p>
+</div>

+ 196 - 0
misago/emberapp/tests/acceptance/change-email-test.js

@@ -0,0 +1,196 @@
+import Ember from 'ember';
+import { module, test } from 'qunit';
+import startApp from '../helpers/start-app';
+import getToastMessage from '../helpers/toast-message';
+import createUser from '../helpers/create-user';
+
+var application, container, auth;
+
+module('Acceptance: Change E-mail', {
+  beforeEach: function() {
+    application = startApp();
+    container = application.__container__;
+    auth = container.lookup('service:auth');
+  },
+  afterEach: function() {
+    Ember.run(application, 'destroy');
+    Ember.$.mockjax.clear();
+  }
+});
+
+test('/options/change-email form can be accessed', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  assert.expect(1);
+
+  visit('/options/change-email/');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.email.index');
+  });
+});
+
+test('/options/change-email form handles empty submission', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  assert.expect(4);
+
+  visit('/options/change-email/');
+  click('.panel-form .panel-footer .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.email.index');
+    assert.equal(getToastMessage(), 'Form contains errors.');
+
+    var emailValidation = Ember.$('#id_new_email').parents('.form-group').find('.help-block.errors');
+    assert.equal(Ember.$.trim(emailValidation.text()), 'Enter new e-mail.');
+
+    var passwordValidation = Ember.$('#id_password').parents('.form-group').find('.help-block.errors');
+    assert.equal(Ember.$.trim(passwordValidation.text()), 'Enter current password.');
+  });
+});
+
+test('/options/change-email form handles invalid submission', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  assert.expect(3);
+
+  visit('/options/change-email/');
+  fillIn('#id_new_email', 'not-valid-email');
+  fillIn('#id_password', 'password');
+  click('.panel-form .panel-footer .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.email.index');
+    assert.equal(getToastMessage(), 'Form contains errors.');
+
+    var fieldValidation = Ember.$('#id_new_email').parents('.form-group').find('.help-block.errors');
+    assert.equal(Ember.$.trim(fieldValidation.text()), 'Invalid e-mail address.');
+  });
+});
+
+test('/options/change-email form handles error 400', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  Ember.$.mockjax({
+    url: "/api/users/" + user.get('id') + '/change-email/',
+    status: 400,
+    responseText: {
+      'new_email': ['E-mail is bad.'],
+      'password': ['Password is bad.']
+    }
+  });
+
+  assert.expect(4);
+
+  visit('/options/change-email/');
+  fillIn('#id_new_email', 'valid@email.com');
+  fillIn('#id_password', 'password');
+  click('.panel-form .panel-footer .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.email.index');
+    assert.equal(getToastMessage(), 'Form contains errors.');
+
+    var emailValidation = Ember.$('#id_new_email').parents('.form-group').find('.help-block.errors');
+    assert.equal(Ember.$.trim(emailValidation.text()), 'E-mail is bad.');
+
+    var passwordValidation = Ember.$('#id_password').parents('.form-group').find('.help-block.errors');
+    assert.equal(Ember.$.trim(passwordValidation.text()), 'Password is bad.');
+  });
+});
+
+test('/options/change-email form handles valid submission', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  Ember.$.mockjax({
+    url: "/api/users/" + user.get('id') + '/change-email/',
+    status: 200,
+    responseText: {
+      'detail': 'Success happened!'
+    }
+  });
+
+  assert.expect(2);
+
+  visit('/options/change-email/');
+  fillIn('#id_new_email', 'valid@email.com');
+  fillIn('#id_password', 'password');
+  click('.panel-form .panel-footer .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.email.index');
+    assert.equal(getToastMessage(), 'Success happened!');
+  });
+});
+
+test('/options/change-email/token handles invalid token', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  Ember.$.mockjax({
+    url: "/api/users/" + user.get('id') + '/change-email/',
+    status: 400,
+    responseText: {
+      'detail': 'Token is invalid.'
+    }
+  });
+
+  assert.expect(2);
+
+  visit('/options/change-email/token/');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.email.index');
+    assert.equal(getToastMessage(), 'Token is invalid.');
+  });
+});
+
+test('/options/change-email/token handles valid token', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  Ember.$.mockjax({
+    url: "/api/users/" + user.get('id') + '/change-email/',
+    status: 200,
+    responseText: {
+      'detail': 'E-mail was changed.'
+    }
+  });
+
+  assert.expect(2);
+
+  visit('/options/change-email/token/');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.email.index');
+    assert.equal(getToastMessage(), 'E-mail was changed.');
+  });
+});
+

+ 201 - 0
misago/emberapp/tests/acceptance/change-password-test.js

@@ -0,0 +1,201 @@
+import Ember from 'ember';
+import { module, test } from 'qunit';
+import startApp from '../helpers/start-app';
+import getToastMessage from '../helpers/toast-message';
+import createUser from '../helpers/create-user';
+
+var application, container, auth;
+
+module('Acceptance: Change Password', {
+  beforeEach: function() {
+    application = startApp();
+    container = application.__container__;
+    auth = container.lookup('service:auth');
+  },
+  afterEach: function() {
+    Ember.run(application, 'destroy');
+    Ember.$.mockjax.clear();
+  }
+});
+
+test('/options/change-password form can be accessed', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  assert.expect(1);
+
+  visit('/options/change-password/');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.password.index');
+  });
+});
+
+test('/options/change-password form handles empty submission', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  assert.expect(5);
+
+  visit('/options/change-password/');
+  click('.panel-form .panel-footer .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.password.index');
+    assert.equal(getToastMessage(), 'Form contains errors.');
+
+    var newPasswordValidation = Ember.$('#id_new_password').parents('.form-group').find('.help-block.errors');
+    assert.equal(Ember.$.trim(newPasswordValidation.text()), 'Enter new password.');
+
+    var repeatPasswordValidation = Ember.$('#id_repeat_password').parents('.form-group').find('.help-block.errors');
+    assert.equal(Ember.$.trim(repeatPasswordValidation.text()), 'Repeat new password.');
+
+    var passwordValidation = Ember.$('#id_password').parents('.form-group').find('.help-block.errors');
+    assert.equal(Ember.$.trim(passwordValidation.text()), 'Enter current password.');
+  });
+});
+
+test('/options/change-password form handles invalid submission', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  assert.expect(3);
+
+  visit('/options/change-password/');
+  fillIn('#id_new_password', 'not-valid-password');
+  fillIn('#id_password', 'password');
+  click('.panel-form .panel-footer .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.password.index');
+    assert.equal(getToastMessage(), 'Form contains errors.');
+
+    var repeatPasswordValidation = Ember.$('#id_repeat_password').parents('.form-group').find('.help-block.errors');
+    assert.equal(Ember.$.trim(repeatPasswordValidation.text()), 'Repeat new password.');
+  });
+});
+
+test('/options/change-password form handles error 400', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  Ember.$.mockjax({
+    url: "/api/users/" + user.get('id') + '/change-password/',
+    status: 400,
+    responseText: {
+      'new_password': ['New password is bad.'],
+      'password': ['Password is bad.']
+    }
+  });
+
+  assert.expect(4);
+
+  visit('/options/change-password/');
+  fillIn('#id_new_password', 'V4lidPassword');
+  fillIn('#id_repeat_password', 'V4lidPassword');
+  fillIn('#id_password', 'password');
+  click('.panel-form .panel-footer .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.password.index');
+    assert.equal(getToastMessage(), 'Form contains errors.');
+
+    var newPasswordValidation = Ember.$('#id_new_password').parents('.form-group').find('.help-block.errors');
+    assert.equal(Ember.$.trim(newPasswordValidation.text()), 'New password is bad.');
+
+    var passwordValidation = Ember.$('#id_password').parents('.form-group').find('.help-block.errors');
+    assert.equal(Ember.$.trim(passwordValidation.text()), 'Password is bad.');
+  });
+});
+
+test('/options/change-password form handles valid submission', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  Ember.$.mockjax({
+    url: "/api/users/" + user.get('id') + '/change-password/',
+    status: 200,
+    responseText: {
+      'detail': 'Success happened!'
+    }
+  });
+
+  assert.expect(2);
+
+  visit('/options/change-password/');
+  fillIn('#id_new_password', 'V4lidPassword');
+  fillIn('#id_repeat_password', 'V4lidPassword');
+  fillIn('#id_password', 'password');
+  click('.panel-form .panel-footer .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.password.index');
+    assert.equal(getToastMessage(), 'Success happened!');
+  });
+});
+
+test('/options/change-password/token handles invalid token', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  Ember.$.mockjax({
+    url: "/api/users/" + user.get('id') + '/change-password/',
+    status: 400,
+    responseText: {
+      'detail': 'Token is invalid.'
+    }
+  });
+
+  assert.expect(2);
+
+  visit('/options/change-password/token/');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.password.index');
+    assert.equal(getToastMessage(), 'Token is invalid.');
+  });
+});
+
+test('/options/change-password/token handles valid token', function(assert) {
+  var user = createUser();
+  auth.setProperties({
+    'isAuthenticated': true,
+    'user': user
+  });
+
+  Ember.$.mockjax({
+    url: "/api/users/" + user.get('id') + '/change-password/',
+    status: 200,
+    responseText: {
+      'detail': 'E-mail was changed.'
+    }
+  });
+
+  assert.expect(2);
+
+  visit('/options/change-password/token/');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'options.password.index');
+    assert.equal(getToastMessage(), 'E-mail was changed.');
+  });
+});
+

+ 1 - 1
misago/emberapp/tests/acceptance/change-username-test.js

@@ -315,7 +315,7 @@ test('/options/change-username handles empty form submit', function(assert) {
 
 
   andThen(function() {
   andThen(function() {
     assert.equal(currentPath(), 'options.username');
     assert.equal(currentPath(), 'options.username');
-    assert.equal(getToastMessage(), 'Enter new username.');
+    assert.equal(getToastMessage(), 'Form contains errors.');
   });
   });
 });
 });
 
 

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

@@ -39,7 +39,7 @@ test('register with empty credentials', function(assert) {
   click('#appModal .btn-primary');
   click('#appModal .btn-primary');
 
 
   andThen(function() {
   andThen(function() {
-    assert.equal(getToastMessage(), 'Fill out all fields.');
+    assert.equal(getToastMessage(), 'Form contains errors.');
   });
   });
 });
 });
 
 

+ 18 - 0
misago/templates/misago/emails/change_email.html

@@ -0,0 +1,18 @@
+{% extends "misago/emails/base.html" %}
+{% load i18n %}
+
+
+{% block content %}
+{% blocktrans trimmed with user=recipient %}
+{{ user }}, you are receiving this message because you have changed your e-mail address.
+{% endblocktrans %}
+<br>
+<br>
+{% blocktrans trimmed %}
+To confirm this change, click the link below:
+{% endblocktrans %}
+<br>
+<br>
+<a href="{{ SITE_ADDRESS }}{% url 'misago:options_form' form_name='change-email' token=token %}">{% trans "Save changes" %}</a>
+<br>
+{% endblock content %}

+ 14 - 0
misago/templates/misago/emails/change_email.txt

@@ -0,0 +1,14 @@
+{% extends "misago/emails/base.txt" %}
+{% load i18n %}
+
+
+{% block content %}
+{% blocktrans trimmed with user=recipient %}
+{{ user }}, you are receiving this message because you have changed your e-mail address.
+{% endblocktrans %}
+
+{% blocktrans trimmed %}
+To confirm this change, click the link below:
+{% endblocktrans %}
+{{ SITE_ADDRESS }}{% url 'misago:options_form' form_name='change-email' token=token %}
+{% endblock content %}

+ 0 - 18
misago/templates/misago/emails/change_email_password.html

@@ -1,18 +0,0 @@
-{% extends "misago/emails/base.html" %}
-{% load i18n %}
-
-
-{% block content %}
-{% blocktrans trimmed with user=recipient %}
-{{ user }}, you are receiving this message because you have made changes in your account email and password.
-{% endblocktrans %}
-<br>
-<br>
-{% blocktrans trimmed %}
-To confirm those changes, click the link below:
-{% endblocktrans %}
-<br>
-<br>
-<a href="{{ SITE_ADDRESS }}{% url 'misago:usercp_confirm_email_password_change' token=credentials_token %}">{% trans "Save changes" %}</a>
-<br>
-{% endblock content %}

+ 0 - 14
misago/templates/misago/emails/change_email_password.txt

@@ -1,14 +0,0 @@
-{% extends "misago/emails/base.txt" %}
-{% load i18n %}
-
-
-{% block content %}
-{% blocktrans trimmed with user=recipient %}
-{{ user }}, you are receiving this message because you have made changes in your account email and password.
-{% endblocktrans %}
-
-{% blocktrans trimmed %}
-To confirm those changes, click the link below:
-{% endblocktrans %}
-{{ SITE_ADDRESS }}{% url 'misago:usercp_confirm_email_password_change' token=credentials_token %}
-{% endblock content %}

+ 18 - 0
misago/templates/misago/emails/change_password.html

@@ -0,0 +1,18 @@
+{% extends "misago/emails/base.html" %}
+{% load i18n %}
+
+
+{% block content %}
+{% blocktrans trimmed with user=recipient %}
+{{ user }}, you are receiving this message because you have changed your password.
+{% endblocktrans %}
+<br>
+<br>
+{% blocktrans trimmed %}
+To confirm this change, click the link below:
+{% endblocktrans %}
+<br>
+<br>
+<a href="{{ SITE_ADDRESS }}{% url 'misago:options_form' form_name='change-password' token=token %}">{% trans "Save changes" %}</a>
+<br>
+{% endblock content %}

+ 14 - 0
misago/templates/misago/emails/change_password.txt

@@ -0,0 +1,14 @@
+{% extends "misago/emails/base.txt" %}
+{% load i18n %}
+
+
+{% block content %}
+{% blocktrans trimmed with user=recipient %}
+{{ user }}, you are receiving this message because you have changed your password.
+{% endblocktrans %}
+
+{% blocktrans trimmed %}
+To confirm this change, click the link below:
+{% endblocktrans %}
+{{ SITE_ADDRESS }}{% url 'misago:options_form' form_name='change-password' token=token %}
+{% endblock content %}

+ 65 - 0
misago/users/api/userendpoints/changeemail.py

@@ -0,0 +1,65 @@
+from django.core.exceptions import ValidationError
+from django.db import IntegrityError
+from django.utils.translation import ugettext as _
+
+from rest_framework import status
+from rest_framework.response import Response
+
+from misago.conf import settings
+from misago.core.mail import mail_user
+
+from misago.users.forms.options import ChangeEmailForm
+from misago.users.credentialchange import (store_new_credential,
+                                           read_new_credential)
+
+
+def change_email_endpoint(request, pk=None):
+    if 'token' in request.data:
+        return use_token(request, request.data['token'])
+    else:
+        return handle_form_submission(request)
+
+
+def handle_form_submission(request):
+    form = ChangeEmailForm(request.data, user=request.user)
+    if form.is_valid():
+        token = store_new_credential(
+            request, 'email', form.cleaned_data['new_email'])
+
+        mail_subject = _("Confirm e-mail change on %(forum_title)s forums")
+        mail_subject = mail_subject % {'forum_title': settings.forum_name}
+
+        # swap address with new one so email is sent to new address
+        request.user.email = form.cleaned_data['new_email']
+
+        mail_user(request, request.user, mail_subject,
+                  'misago/emails/change_email',
+                  {'token': token})
+
+        message = _("E-mail change confirmation link was sent to new address.")
+        return Response({'detail': message})
+    else:
+        return Response(form.errors, status=status.HTTP_400_BAD_REQUEST)
+
+
+def token_error_handler(f):
+    def decorator(request, token):
+        try:
+            return f(request, token)
+        except (ValueError, IntegrityError):
+            message = _("E-mail change link has expired. Please try again.")
+            return Response({'detail': message},
+                            status=status.HTTP_400_BAD_REQUEST)
+
+    return decorator
+
+
+@token_error_handler
+def use_token(request, token):
+    new_email = read_new_credential(request, 'email', token)
+    if new_email:
+        request.user.set_email(new_email)
+        request.user.save()
+        return Response({'detail': _("Your e-mail has been changed.")})
+    else:
+        raise ValueError()

+ 63 - 0
misago/users/api/userendpoints/changepassword.py

@@ -0,0 +1,63 @@
+from django.contrib.auth import update_session_auth_hash
+from django.core.exceptions import ValidationError
+from django.utils.translation import ugettext as _
+
+from rest_framework import status
+from rest_framework.response import Response
+
+from misago.conf import settings
+from misago.core.mail import mail_user
+
+from misago.users.forms.options import ChangePasswordForm
+from misago.users.credentialchange import (store_new_credential,
+                                           read_new_credential)
+
+
+def change_password_endpoint(request, pk=None):
+    if 'token' in request.data:
+        return use_token(request, request.data['token'])
+    else:
+        return handle_form_submission(request)
+
+
+def handle_form_submission(request):
+    form = ChangePasswordForm(request.data, user=request.user)
+    if form.is_valid():
+        token = store_new_credential(
+            request, 'password', form.cleaned_data['new_password'])
+
+        mail_subject = _("Confirm password change on %(forum_title)s forums")
+        mail_subject = mail_subject % {'forum_title': settings.forum_name}
+
+        mail_user(request, request.user, mail_subject,
+                  'misago/emails/change_password',
+                  {'token': token})
+
+        return Response({'detail': _("Password change confirmation link "
+                                     "was sent to your address.")})
+    else:
+        return Response(form.errors, status=status.HTTP_400_BAD_REQUEST)
+
+
+def token_error_handler(f):
+    def decorator(request, token):
+        try:
+            return f(request, token)
+        except ValueError:
+            message = _("Password change link has expired. Please try again.")
+            return Response({'detail': message},
+                            status=status.HTTP_400_BAD_REQUEST)
+
+    return decorator
+
+
+@token_error_handler
+def use_token(request, token):
+    new_password = read_new_credential(request, 'password', token)
+    if new_password:
+        request.user.set_password(new_password)
+        request.user.save()
+        update_session_auth_hash(request, request.user)
+        return Response({'detail': _("Your password has been changed.")})
+    else:
+        raise ValueError()

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

@@ -17,6 +17,8 @@ from misago.users.api.userendpoints.avatar import avatar_endpoint
 from misago.users.api.userendpoints.create import create_endpoint
 from misago.users.api.userendpoints.create import create_endpoint
 from misago.users.api.userendpoints.signature import signature_endpoint
 from misago.users.api.userendpoints.signature import signature_endpoint
 from misago.users.api.userendpoints.username import username_endpoint
 from misago.users.api.userendpoints.username import username_endpoint
+from misago.users.api.userendpoints.changeemail import change_email_endpoint
+from misago.users.api.userendpoints.changepassword import change_password_endpoint
 
 
 
 
 class UserViewSetPermission(BasePermission):
 class UserViewSetPermission(BasePermission):
@@ -82,3 +84,17 @@ class UserViewSet(viewsets.GenericViewSet):
             request.user, pk, _("You can't change other users signatures."))
             request.user, pk, _("You can't change other users signatures."))
 
 
         return signature_endpoint(request)
         return signature_endpoint(request)
+
+    @detail_route(methods=['post'])
+    def change_password(self, request, pk=None):
+        allow_self_only(
+            request.user, pk, _("You can't change other users passwords."))
+
+        return change_password_endpoint(request)
+
+    @detail_route(methods=['post'])
+    def change_email(self, request, pk=None):
+        allow_self_only(request.user, pk,
+                        _("You can't change other users e-mail addresses."))
+
+        return change_email_endpoint(request)

+ 0 - 78
misago/users/changedcredentials.py

@@ -1,78 +0,0 @@
-"""
-Changed credentials service
-
-Stores new e-mail and password in cache
-"""
-from hashlib import sha256
-
-from misago.conf import settings
-from misago.core.cache import cache
-from misago.users import tokens
-
-
-__all__ = ['cache_new_credentials', 'get_new_credentials']
-
-
-TOKEN_NAME = 'new_credentials'
-CACHE_PATTERN = 'new_credentials_%s'
-CACHE_TIMEOUT = 3600 * 48
-
-
-def cache_new_credentials(user, new_email, new_password):
-    new_credentials = {
-        'user_pk': user.pk,
-        'email': new_email,
-        'email_checksum': _make_checksum(user, new_email),
-        'password': new_password,
-        'password_checksum': _make_checksum(user, new_password),
-    }
-
-    cache.set(_make_cache_name(user), new_credentials, CACHE_TIMEOUT)
-    return _make_token(user)
-
-
-def get_new_credentials(user, token):
-    if token != _make_token(user):
-        return None
-
-    new_credentials = cache.get(_make_cache_name(user), 'nada')
-
-    if new_credentials == 'nada':
-        raise Exception('CACHE NOT FOUND')
-        return None
-
-    if new_credentials['user_pk'] != user.pk:
-        return None
-
-    email_checksum = _make_checksum(user, new_credentials['email'])
-    if new_credentials['email_checksum'] != email_checksum:
-        raise Exception('MAIL CHECKSUM FAIL')
-        return None
-
-    password_checksum = _make_checksum(user, new_credentials['password'])
-    if new_credentials['password_checksum'] != password_checksum:
-        raise Exception('PASS CHECKSUM FAIL')
-        return None
-
-    return new_credentials
-
-
-def _make_token(user):
-    return tokens.make(user, TOKEN_NAME)
-
-
-def _make_cache_name(user):
-    return CACHE_PATTERN % _make_token(user)
-
-
-def _make_checksum(user, value):
-    seeds = (
-        user.pk,
-        user.email,
-        user.password,
-        user.last_login.replace(microsecond=0, tzinfo=None),
-        settings.SECRET_KEY,
-        unicode(value)
-    )
-
-    return sha256('+'.join([unicode(s) for s in seeds])).hexdigest()

+ 56 - 0
misago/users/credentialchange.py

@@ -0,0 +1,56 @@
+"""
+Changed credentials service
+
+Stores new e-mail and password in cache
+"""
+from hashlib import sha256
+from django.conf import settings
+from misago.core import serializer
+
+
+__all__ = ['create_change_token', 'read_token']
+
+
+def store_new_credential(request, credential_type, credential_value):
+    credential_key = 'new_credential_%s' % credential_type
+    token = _make_change_token(request.user, credential_type)
+
+    request.session[credential_key] = {
+        'user_pk': request.user.pk,
+        'credential': credential_value,
+        'token': token,
+    }
+
+    return token
+
+
+def read_new_credential(request, credential_type, link_token):
+    try:
+        credential_key = 'new_credential_%s' % credential_type
+        new_credential = request.session.pop(credential_key)
+    except KeyError:
+        return None
+
+    if new_credential['user_pk'] != request.user.pk:
+        return None
+
+    current_token = _make_change_token(request.user, credential_type)
+    if link_token != current_token:
+        return None
+    if new_credential['token'] != current_token:
+        return None
+
+    return new_credential['credential']
+
+
+def _make_change_token(user, token_type):
+    seeds = (
+        user.pk,
+        user.email,
+        user.password,
+        user.last_login.replace(microsecond=0, tzinfo=None),
+        settings.SECRET_KEY,
+        unicode(token_type)
+    )
+
+    return sha256('+'.join([unicode(s) for s in seeds])).hexdigest()

+ 30 - 36
misago/users/forms/options.py

@@ -47,53 +47,47 @@ class EditSignatureForm(forms.ModelForm):
         return data
         return data
 
 
 
 
-class ChangeEmailPasswordForm(forms.Form):
-    current_password = forms.CharField(
-        label=_("Current password"),
-        max_length=200,
-        required=False,
-        widget=forms.PasswordInput())
-
-    new_email = forms.CharField(
-        label=_("New e-mail"),
-        max_length=200,
-        required=False)
-
-    new_password = forms.CharField(
-        label=_("New password"),
-        max_length=200,
-        required=False,
-        widget=forms.PasswordInput())
+class ChangePasswordForm(forms.Form):
+    password = forms.CharField(max_length=200)
+    new_password = forms.CharField(max_length=200)
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         self.user = kwargs.pop('user', None)
         self.user = kwargs.pop('user', None)
-        super(ChangeEmailPasswordForm, self).__init__(*args, **kwargs)
+        super(ChangePasswordForm, self).__init__(*args, **kwargs)
 
 
-    def clean(self):
-        data = super(ChangeEmailPasswordForm, self).clean()
+    def clean_password(self):
+        if not self.user.check_password(self.cleaned_data['password']):
+            raise forms.ValidationError(_("Entered password is invalid."))
 
 
-        current_password = data.get('current_password')
-        new_email = data.get('new_email')
-        new_password = data.get('new_password')
+    def clean_new_password(self):
+        data = self.cleaned_data['new_password']
+        validate_password(data)
+        return data
 
 
-        if not data.get('current_password'):
-            message = _("You have to enter your current password.")
-            raise forms.ValidationError(message)
 
 
-        if not self.user.check_password(current_password):
+class ChangeEmailForm(forms.Form):
+    password = forms.CharField(max_length=200)
+    new_email = forms.CharField(max_length=200)
+
+    def __init__(self, *args, **kwargs):
+        self.user = kwargs.pop('user', None)
+        super(ChangeEmailForm, self).__init__(*args, **kwargs)
+
+    def clean_password(self):
+        if not self.user.check_password(self.cleaned_data['password']):
             raise forms.ValidationError(_("Entered password is invalid."))
             raise forms.ValidationError(_("Entered password is invalid."))
 
 
-        if not (new_email or new_password):
-            message = _("You have to enter new e-mail or password.")
+    def clean_new_email(self):
+        data = self.cleaned_data['new_email']
+
+        if not data:
+            message = _("You have to enter new e-mail address.")
             raise forms.ValidationError(message)
             raise forms.ValidationError(message)
 
 
-        if new_email:
-            if new_email.lower() == self.user.email.lower():
-                message = _("New e-mail is same as current one.")
-                raise forms.ValidationError(message)
-            validate_email(new_email)
+        if data.lower() == self.user.email.lower():
+            message = _("New e-mail is same as current one.")
+            raise forms.ValidationError(message)
 
 
-        if new_password:
-            validate_password(new_password)
+        validate_email(data)
 
 
         return data
         return data

+ 0 - 20
misago/users/tests/test_changedcredentials.py

@@ -1,20 +0,0 @@
-from django.contrib.auth import get_user_model
-from django.test import TestCase
-
-from misago.users import changedcredentials
-
-
-class ChangedCredentialsTests(TestCase):
-    def test_credentials_change(self):
-        """changedcredentials module allows for credentials change"""
-        User = get_user_model()
-        test_user = User.objects.create_user('Bob', 'bob@bob.com', 'pass123')
-
-        credentials_token = changedcredentials.cache_new_credentials(
-            test_user, 'newbob@test.com', 'newpass123')
-
-        new_credentials = changedcredentials.get_new_credentials(
-            test_user, credentials_token)
-
-        self.assertEqual(new_credentials['email'], 'newbob@test.com')
-        self.assertEqual(new_credentials['password'], 'newpass123')

+ 58 - 0
misago/users/tests/test_credentialchange.py

@@ -0,0 +1,58 @@
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+
+from misago.users import credentialchange
+
+
+class MockRequest(object):
+    def __init__(self, user):
+        self.session = {}
+        self.user = user
+
+
+class CredentialChangeTests(TestCase):
+    def setUp(self):
+        User = get_user_model()
+        self.user = User.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+
+    def test_valid_token_generation(self):
+        """credentialchange module allows for store and read of change token"""
+        request = MockRequest(self.user)
+        token = credentialchange.store_new_credential(
+            request, 'email', 'newbob@test.com')
+
+        email = credentialchange.read_new_credential(request, 'email', token)
+        self.assertEqual(email, 'newbob@test.com')
+
+    def test_email_change_invalidated_token(self):
+        """token is invalidated by email change"""
+        request = MockRequest(self.user)
+        token = credentialchange.store_new_credential(
+            request, 'email', 'newbob@test.com')
+
+        self.user.set_email('egebege@test.com')
+        self.user.save()
+
+        email = credentialchange.read_new_credential(request, 'email', token)
+        self.assertIsNone(email)
+
+    def test_password_change_invalidated_token(self):
+        """token is invalidated by password change"""
+        request = MockRequest(self.user)
+        token = credentialchange.store_new_credential(
+            request, 'email', 'newbob@test.com')
+
+        self.user.set_password('Egebeg!123')
+        self.user.save()
+
+        email = credentialchange.read_new_credential(request, 'email', token)
+        self.assertIsNone(email)
+
+    def test_invalid_token_is_handled(self):
+        """there are no explosions in invalid tokens handling"""
+        request = MockRequest(self.user)
+        token = credentialchange.store_new_credential(
+            request, 'email', 'newbob@test.com')
+
+        email = credentialchange.read_new_credential(request, 'em4il', token)
+        self.assertIsNone(email)

+ 8 - 0
misago/users/tests/test_options_views.py

@@ -14,3 +14,11 @@ class OptionsViewsTests(AuthenticatedUserTestCase):
             'form_name': 'some-fake-form'
             'form_name': 'some-fake-form'
         }))
         }))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
+
+    def test_token_form_view_returns_200(self):
+        """/options/some-form has no show stoppers"""
+        response = self.client.get(reverse('misago:options_form', kwargs={
+            'form_name': 'some-fake-form',
+            'token': 's0m3-t0k3n'
+        }))
+        self.assertEqual(response.status_code, 200)

+ 115 - 0
misago/users/tests/test_user_changeemail_api.py

@@ -0,0 +1,115 @@
+from django.contrib.auth import get_user_model
+from django.core import mail
+from misago.users.testutils import AuthenticatedUserTestCase
+
+
+class UserChangeEmailTests(AuthenticatedUserTestCase):
+    """
+    tests for user change email RPC (/api/users/1/change-email/)
+    """
+    def setUp(self):
+        super(UserChangeEmailTests, self).setUp()
+        self.link = '/api/users/%s/change-email/' % self.user.pk
+
+    def test_unsupported_methods(self):
+        """api isn't supporting GET"""
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 405)
+
+    def test_change_email(self):
+        """api allows users to change their e-mail addresses"""
+        response = self.client.post(self.link, data={
+            'new_email': 'new@email.com',
+            'password': self.USER_PASSWORD
+        })
+        self.assertEqual(response.status_code, 200)
+
+        self.assertIn('Confirm e-mail change', mail.outbox[0].subject)
+        for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
+            if line.startswith('http://'):
+                token = line.rstrip('/').split('/')[-1]
+                break
+        else:
+            self.fail("E-mail sent didn't contain confirmation url")
+
+        response = self.client.post(self.link, data={'token': token})
+        self.assertEqual(response.status_code, 200)
+
+        self.reload_user()
+        self.assertEqual(self.user.email, 'new@email.com')
+
+    def test_invalid_password(self):
+        """api errors correctly for invalid password"""
+        response = self.client.post(self.link, data={
+            'new_email': 'new@email.com',
+            'password': 'Lor3mIpsum'
+        })
+        self.assertEqual(response.status_code, 400)
+        self.assertIn('password is invalid', response.content)
+
+    def test_invalid_input(self):
+        """api errors correctly for invalid input"""
+        response = self.client.post(self.link, data={
+            'new_email': '',
+            'password': self.USER_PASSWORD
+        })
+        self.assertEqual(response.status_code, 400)
+        self.assertIn('enter new e-mail', response.content)
+
+        response = self.client.post(self.link, data={
+            'new_email': 'newmail',
+            'password': self.USER_PASSWORD
+        })
+        self.assertEqual(response.status_code, 400)
+        self.assertIn('valid email address', response.content)
+
+    def test_email_taken(self):
+        """api validates email usage"""
+        User = get_user_model()
+        User.objects.create_user('BobBoberson', 'new@email.com', 'Pass.123')
+
+        response = self.client.post(self.link, data={
+            'new_email': 'new@email.com',
+            'password': self.USER_PASSWORD
+        })
+        self.assertEqual(response.status_code, 400)
+        self.assertIn('not available', response.content)
+
+    def test_invalid_token(self):
+        """api handles invalid token"""
+        response = self.client.post(self.link, data={
+            'new_email': 'new@email.com',
+            'password': self.USER_PASSWORD
+        })
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(self.link, data={'token': 'invalid-token'})
+        self.assertEqual(response.status_code, 400)
+
+        self.reload_user()
+        self.assertTrue(self.user.email != 'new@email.com')
+
+    def test_expired_token(self):
+        """api handles invalid token"""
+        response = self.client.post(self.link, data={
+            'new_email': 'new@email.com',
+            'password': self.USER_PASSWORD
+        })
+        self.assertEqual(response.status_code, 200)
+
+        for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
+            if line.startswith('http://'):
+                token = line.rstrip('/').split('/')[-1]
+                break
+        else:
+            self.fail("E-mail sent didn't contain confirmation url")
+
+        self.user.set_password('L0lN0p3!')
+        self.user.save()
+        self.login_user(self.user, 'L0lN0p3!')
+
+        response = self.client.post(self.link, data={'token': 'invalid-token'})
+        self.assertEqual(response.status_code, 400)
+
+        self.reload_user()
+        self.assertTrue(self.user.email != 'new@email.com')

+ 102 - 0
misago/users/tests/test_user_changepassword_api.py

@@ -0,0 +1,102 @@
+from django.contrib.auth import get_user_model
+from django.core import mail
+from misago.users.testutils import AuthenticatedUserTestCase
+
+
+class UserChangePasswordTests(AuthenticatedUserTestCase):
+    """
+    tests for user change password RPC (/api/users/1/change-password/)
+    """
+    def setUp(self):
+        super(UserChangePasswordTests, self).setUp()
+        self.link = '/api/users/%s/change-password/' % self.user.pk
+
+    def test_unsupported_methods(self):
+        """api isn't supporting GET"""
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 405)
+
+    def test_change_email(self):
+        """api allows users to change their passwords"""
+        response = self.client.post(self.link, data={
+            'new_password': 'N3wP@55w0rd',
+            'password': self.USER_PASSWORD
+        })
+        self.assertEqual(response.status_code, 200)
+
+        self.assertIn('Confirm password change', mail.outbox[0].subject)
+        for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
+            if line.startswith('http://'):
+                token = line.rstrip('/').split('/')[-1]
+                break
+        else:
+            self.fail("E-mail sent didn't contain confirmation url")
+
+        response = self.client.post(self.link, data={'token': token})
+        self.assertEqual(response.status_code, 200)
+
+        self.reload_user()
+        self.assertTrue(self.user.check_password('N3wP@55w0rd'))
+
+    def test_invalid_password(self):
+        """api errors correctly for invalid password"""
+        response = self.client.post(self.link, data={
+            'new_password': 'N3wP@55w0rd',
+            'password': 'Lor3mIpsum'
+        })
+        self.assertEqual(response.status_code, 400)
+        self.assertIn('password is invalid', response.content)
+
+    def test_invalid_input(self):
+        """api errors correctly for invalid input"""
+        response = self.client.post(self.link, data={
+            'new_password': '',
+            'password': self.USER_PASSWORD
+        })
+        self.assertEqual(response.status_code, 400)
+        self.assertIn('enter new password', response.content)
+
+        response = self.client.post(self.link, data={
+            'new_password': 'n',
+            'password': self.USER_PASSWORD
+        })
+        self.assertEqual(response.status_code, 400)
+        self.assertIn('password must be', response.content)
+
+    def test_invalid_token(self):
+        """api handles invalid token"""
+        response = self.client.post(self.link, data={
+            'new_password': 'N3wP@55w0rd',
+            'password': self.USER_PASSWORD
+        })
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(self.link, data={'token': 'invalid-token'})
+        self.assertEqual(response.status_code, 400)
+
+        self.reload_user()
+        self.assertFalse(self.user.check_password('N3wP@55w0rd'))
+
+    def test_expired_token(self):
+        """api handles invalid token"""
+        response = self.client.post(self.link, data={
+            'new_password': 'N3wP@55w0rd',
+            'password': self.USER_PASSWORD
+        })
+        self.assertEqual(response.status_code, 200)
+
+        for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
+            if line.startswith('http://'):
+                token = line.rstrip('/').split('/')[-1]
+                break
+        else:
+            self.fail("E-mail sent didn't contain confirmation url")
+
+        self.user.set_email('new@email.com')
+        self.user.save()
+
+        response = self.client.post(self.link, data={'token': 'invalid-token'})
+        self.assertEqual(response.status_code, 400)
+
+        self.reload_user()
+        self.assertFalse(self.user.check_password('N3wP@55w0rd'))

+ 2 - 2
misago/users/testutils.py

@@ -24,10 +24,10 @@ class UserTestCase(TestCase):
         return User.objects.create_superuser(
         return User.objects.create_superuser(
             "TestSuperUser", "test@superuser.com", self.USER_PASSWORD)
             "TestSuperUser", "test@superuser.com", self.USER_PASSWORD)
 
 
-    def login_user(self, user):
+    def login_user(self, user, password=None):
         self.client.post('/api/auth/', data={
         self.client.post('/api/auth/', data={
             'username': user.email,
             'username': user.email,
-            'password': self.USER_PASSWORD,
+            'password': password or self.USER_PASSWORD,
         })
         })
         self.client.get(reverse('misago:index'))
         self.client.get(reverse('misago:index'))
 
 

+ 1 - 0
misago/users/urls/__init__.py

@@ -22,6 +22,7 @@ urlpatterns += patterns('misago.users.views.forgottenpassword',
 urlpatterns += patterns('misago.users.views.options',
 urlpatterns += patterns('misago.users.views.options',
     url(r'^options/$', 'index', name='options'),
     url(r'^options/$', 'index', name='options'),
     url(r'^options/(?P<form_name>[-a-zA-Z]+)/$', 'form', name='options_form'),
     url(r'^options/(?P<form_name>[-a-zA-Z]+)/$', 'form', name='options_form'),
+    url(r'^options/(?P<form_name>[-a-zA-Z]+)/(?P<token>[-a-zA-Z0-9]+)/$', 'form', name='options_form'),
 )
 )
 
 
 
 

+ 2 - 2
misago/users/views/options.py

@@ -12,10 +12,10 @@ def index(request):
 
 
 
 
 @deflect_guests
 @deflect_guests
-def form(request, form_name):
+def form(request, form_name, token=None):
     return noscript(request, **{
     return noscript(request, **{
         'title': _("Options"),
         'title': _("Options"),
-        'message': _("To change options enable JS."),
+        'message': _("To change options enable JavaScript."),
     })
     })