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

Activate/Change forgotten password

Rafał Pitoń 10 лет назад
Родитель
Сommit
9236662037

+ 4 - 0
misago/emberapp/app/controllers/activation/link-sent.js

@@ -0,0 +1,4 @@
+import Ember from 'ember';
+
+export default Ember.ObjectController.extend({
+});

+ 55 - 0
misago/emberapp/app/controllers/activation/request-link.js

@@ -0,0 +1,55 @@
+import Ember from 'ember';
+import rpc from 'misago/utils/rpc';
+
+export default Ember.ObjectController.extend({
+  rpcUrl: 'activation/send-link/',
+  isLoading: false,
+  email: '',
+
+  actions: {
+    submit: function() {
+      if (this.get('isLoading')) {
+        return;
+      }
+
+      var email = Ember.$.trim(this.get('email'));
+
+      if (email === "") {
+        this.get('toast').warning(gettext("Enter e-mail address."));
+        return;
+      }
+
+      this.set('isLoading', true);
+
+      var self = this;
+      rpc(this.get('rpcUrl'), {
+        email: email
+      }).then(function(requestingUser) {
+        self.send('success', requestingUser);
+      }, function(jqXHR) {
+        self.send('error', jqXHR);
+      }).finally(function() {
+        self.set('isLoading', false);
+      });
+    },
+
+    success: function(requestingUser) {
+      this.send('showSentPage', requestingUser);
+      this.set('email', '');
+    },
+
+    error: function(jqXHR) {
+      if (jqXHR.status === 400){
+        var rejection = jqXHR.responseJSON;
+        if (rejection.code === 'banned') {
+          this.send('showBan', rejection.detail);
+          this.set('email', '');
+        } else {
+          this.get('toast').error(rejection.detail);
+        }
+      } else {
+        this.send('toastError', jqXHR);
+      }
+    }
+  }
+});

+ 3 - 52
misago/emberapp/app/controllers/forgotten-password/request-link.js

@@ -1,54 +1,5 @@
-import Ember from 'ember';
-import rpc from 'misago/utils/rpc';
+import RequestLinkController from 'misago/controllers/activation/request-link';
 
-export default Ember.ObjectController.extend({
-  isLoading: false,
-  email: '',
-
-  actions: {
-    submit: function() {
-      if (this.get('isLoading')) {
-        return;
-      }
-
-      var email = Ember.$.trim(this.get('email'));
-
-      if (email === "") {
-        this.get('toast').warning(gettext("Enter e-mail address."));
-        return;
-      }
-
-      this.set('isLoading', true);
-
-      var self = this;
-      rpc('change-password/send-link/', {
-        email: email
-      }).then(function(requestingUser) {
-        self.send('success', requestingUser);
-      }, function(jqXHR) {
-        self.send('error', jqXHR);
-      }).finally(function() {
-        self.set('isLoading', false);
-      });
-    },
-
-    success: function(requestingUser) {
-      this.send('showSentPage', requestingUser);
-      this.set('email', '');
-    },
-
-    error: function(jqXHR) {
-      if (jqXHR.status === 400){
-        var rejection = jqXHR.responseJSON;
-        if (rejection.code === 'banned') {
-          this.send('showBan', rejection.detail);
-          this.set('email', '');
-        } else {
-          this.get('toast').error(rejection.detail);
-        }
-      } else {
-        this.send('toastError', jqXHR);
-      }
-    }
-  }
+export default RequestLinkController.extend({
+  rpcUrl: 'change-password/send-link/'
 });

+ 5 - 4
misago/emberapp/app/controllers/login-modal.js

@@ -101,14 +101,15 @@ export default Ember.Controller.extend({
       }
     },
 
-    // handle go-to links
+    // Go-to links
 
-    forgotPassword: function() {
-      this.transitionToRoute('forgotten-password');
+    activateAccount: function() {
+      this.transitionToRoute('activation');
       Ember.$('#loginModal').modal('hide');
     },
 
-    activateAccount: function() {
+    forgotPassword: function() {
+      this.transitionToRoute('forgotten-password');
       Ember.$('#loginModal').modal('hide');
     }
   }

+ 11 - 0
misago/emberapp/app/router.js

@@ -6,11 +6,22 @@ var Router = Ember.Router.extend({
 });
 
 Router.map(function() {
+  // Auth
+
+  this.route('activation', { path: 'activation/' }, function() {
+    this.route('activate', { path: ':user_id/:token/' });
+  });
   this.route('forgotten-password', { path: 'forgotten-password/' }, function() {
     this.route('change-form', { path: ':user_id/:token/' });
   });
+
+  // Legal
+
   this.route('terms-of-service', { path: 'terms-of-service/' });
   this.route('privacy-policy', { path: 'privacy-policy/' });
+
+  // Error
+
   this.route('error-0', { path: 'error-0/' });
   this.route('error-403', { path: 'error-403/:reason/' });
   this.route('error-404', { path: 'error-404/' });

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

@@ -0,0 +1,26 @@
+import Ember from 'ember';
+import ResetScroll from 'misago/mixins/reset-scroll';
+import rpc from 'misago/utils/rpc';
+
+export default Ember.Route.extend(ResetScroll, {
+  model: function(params) {
+    return rpc('activation/' + params.user_id + '/' + params.token + '/validate-token/');
+  },
+
+  afterModel: function(model) {
+    this.send('openLoginModal');
+    this.get('toast').success(model.detail);
+    return this.transitionTo('index');
+  },
+
+  actions: {
+    error: function(reason) {
+      if (reason.status === 404) {
+        this.get('toast').error(reason.responseJSON.detail);
+        return this.transitionTo('activation');
+      }
+
+      return true;
+    }
+  }
+});

+ 36 - 0
misago/emberapp/app/routes/activation/index.js

@@ -0,0 +1,36 @@
+import Ember from 'ember';
+import ResetScroll from 'misago/mixins/reset-scroll';
+
+export default Ember.Route.extend(ResetScroll, {
+  title: gettext('Request activation link'),
+  templateName: 'activation.request-link',
+
+  sentTitle: gettext('Activation link sent'),
+  sentTemplateName: 'activation.link-sent',
+
+  renderTemplate: function() {
+    this.render(this.get('templateName'));
+  },
+
+  actions: {
+    didTransition: function() {
+      this.send('setTitle', this.get('title'));
+      return true;
+    },
+
+    showSentPage: function(linkRecipient) {
+      this.send('setTitle', this.get('sentTitle'));
+      this.render(this.get('sentTemplateName'), {
+        model: linkRecipient
+      });
+
+      return true;
+    },
+
+    retry: function() {
+      this.send('didTransition');
+      this.renderTemplate();
+      return true;
+    }
+  }
+});

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

@@ -20,7 +20,6 @@ export default Ember.Route.extend({
       complete_title += ' | ' + this.get('settings.forum_name');
 
       document.title = complete_title;
-      return false;
     },
 
     // Error handlers
@@ -76,26 +75,22 @@ export default Ember.Route.extend({
       }
 
       this.get('toast').error(errorMessage);
-      return false;
     },
 
     showBan: function(ban) {
       this.send('setTitle', gettext('You are banned'));
       this.intermediateTransitionTo('error-banned', ban);
-      return false;
     },
 
     // Auth
 
     openLoginModal: function() {
       this.controllerFor("loginModal").send('open');
-      return false;
     },
 
     logOut: function() {
       this.get('auth').logout();
       Ember.$('#hidden-logout-form').submit();
-      return false;
     }
   }
 });

+ 6 - 27
misago/emberapp/app/routes/forgotten-password/index.js

@@ -1,30 +1,9 @@
-import Ember from 'ember';
-import ResetScroll from 'misago/mixins/reset-scroll';
+import RequestLinkRoute from 'misago/routes/activation/index';
 
-export default Ember.Route.extend(ResetScroll, {
-  renderTemplate: function() {
-    this.render('forgotten-password.request-link');
-  },
+export default RequestLinkRoute.extend({
+  title: gettext('Change forgotten password'),
+  templateName: 'forgotten-password.request-link',
 
-  actions: {
-    didTransition: function() {
-      this.send('setTitle', gettext('Change forgotten password'));
-      return true;
-    },
-
-    showSentPage: function(linkRecipient) {
-      this.send('setTitle', gettext('Change password form link sent'));
-      this.render('forgotten-password.link-sent', {
-        model: linkRecipient
-      });
-
-      return true;
-    },
-
-    retry: function() {
-      this.send('didTransition');
-      this.renderTemplate();
-      return true;
-    }
-  }
+  sentTitle: gettext('Change password form link sent'),
+  sentTemplateName: 'forgotten-password.link-sent'
 });

+ 19 - 0
misago/emberapp/app/templates/activation/link-sent.hbs

@@ -0,0 +1,19 @@
+<div class="link-sent-page">
+  <div class="page-header">
+    <div class="container">
+      <h1>{{gettext "Activation link sent"}}</h1>
+    </div>
+  </div>
+
+  <div class="container">
+
+  <p class="lead">
+    {{gettext "%(username)s, we have sent your activation link to %(email)s." username=username email=email}}
+  </p>
+
+  <button class="btn btn-default" {{action "retry"}}>
+    {{gettext "Try again?"}}
+  </button>
+
+  </div>
+</div>

+ 47 - 0
misago/emberapp/app/templates/activation/request-link.hbs

@@ -0,0 +1,47 @@
+<div class="activation-page">
+  <div class="page-header">
+    <div class="container">
+      <h1>{{gettext "Request activation link"}}</h1>
+    </div>
+  </div>
+
+  <div class="container">
+
+    <div class="row">
+      <div class="col-md-8">
+
+        <p>{{gettext "Site administrator may impose requirement on newly regitered accounts to be activated before users will be able to sign in."}}</p>
+
+        <p>{{gettext "Depending on time of registration, you will be able activate your account by clicking special activation link. This link will be valid only for your browser, for seven days or until your account is activated."}}</p>
+
+        <p>{{gettext "To receive this link, enter your account's e-mail addres in form and press \"Send link\" button."}}</p>
+
+      </div>
+      <div class="col-md-4">
+
+        <div class="well well-form">
+          <form {{action "submit" on="submit"}}>
+
+            <div class="form-group">
+              <div class="control-input">
+                {{input type="text" value=email class="form-control" placeholder=(gettext "Your e-mail address")}}
+              </div>
+            </div>
+
+            {{#if isLoading}}
+            <button type="button" class="btn btn-block btn-primary" disabled="disabled">
+              <span class="fa fa-cog fa-spin"></span>
+              {{gettext "Processing..."}}
+            </button>
+            {{else}}
+            <button type="submit" class="btn btn-primary btn-block">{{gettext "Send link"}}</button>
+            {{/if}}
+
+          </form>
+        </div>
+
+      </div>
+    </div>
+
+  </div>
+</div>

+ 1 - 3
misago/emberapp/app/templates/forgotten-password/link-sent.hbs

@@ -1,9 +1,7 @@
 <div class="link-sent-page">
   <div class="page-header">
     <div class="container">
-      <h1>
-        {{gettext "Change password form link sent"}}
-      </h1>
+      <h1>{{gettext "Change password form link sent"}}</h1>
     </div>
   </div>
 

+ 1 - 1
misago/emberapp/app/templates/forgotten-password/request-link.hbs

@@ -12,7 +12,7 @@
 
         <p>{{gettext "Because user passwords are processed in an irreversible way before being saved to database, it is not possible for us to simply send you your password."}}</p>
 
-        <p>{{gettext "Instead, you can change your password using special secure form that will be available by special link valid only for your browser seven days or until your password is changed."}}</p>
+        <p>{{gettext "Instead, you can change your password using special secure form that will be available by special link valid only for your browser, for seven days or until your password is changed."}}</p>
 
         <p>{{gettext "To receive this link, enter your account's e-mail addres in form and press \"Send link\" button."}}</p>
 

+ 249 - 0
misago/emberapp/tests/acceptance/activate-test.js

@@ -0,0 +1,249 @@
+import Ember from 'ember';
+import { module, test } from 'qunit';
+import startApp from '../helpers/start-app';
+import getToastMessage from '../helpers/toast-message';
+
+var application;
+
+module('Acceptance: Account Activation', {
+  beforeEach: function() {
+    application = startApp();
+  },
+
+  afterEach: function() {
+    Ember.$('#hidden-login-form').off('submit.stopInTest');
+    Ember.$('#loginModal').off();
+    Ember.$('body').removeClass('modal-open');
+    Ember.run(application, 'destroy');
+    Ember.$.mockjax.clear();
+  }
+});
+
+test('visiting /activation', function(assert) {
+  assert.expect(1);
+
+  visit('/activation');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'activation.index');
+  });
+});
+
+test('request activation link without entering e-mail', function(assert) {
+  assert.expect(2);
+
+  visit('/activation');
+  click('.activation-page form .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'activation.index');
+    assert.equal(getToastMessage(), 'Enter e-mail address.');
+  });
+});
+
+test('request activation link with invalid e-mail', function(assert) {
+  assert.expect(2);
+
+  var message = 'Entered e-mail is invalid.';
+  Ember.$.mockjax({
+    url: '/api/activation/send-link/',
+    status: 400,
+    responseText: {
+      'detail': message,
+      'code': 'invalid_email'
+    }
+  });
+
+  visit('/activation');
+  fillIn('.activation-page form input', 'not-valid-email');
+  click('.activation-page form .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'activation.index');
+    assert.equal(getToastMessage(), message);
+  });
+});
+
+test('request activation link with non-existing e-mail', function(assert) {
+  assert.expect(2);
+
+  var message = 'No user with this e-mail exists.';
+  Ember.$.mockjax({
+    url: '/api/activation/send-link/',
+    status: 400,
+    responseText: {
+      'detail': message,
+      'code': 'not_found'
+    }
+  });
+
+  visit('/activation');
+  fillIn('.activation-page form input', 'not-valid-email');
+  click('.activation-page form .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'activation.index');
+    assert.equal(getToastMessage(), message);
+  });
+});
+
+test('request activation link with user-activated account', function(assert) {
+  assert.expect(2);
+
+  var message = 'You have to activate your account before you will be able to sign in.';
+  Ember.$.mockjax({
+    url: '/api/activation/send-link/',
+    status: 400,
+    responseText: {
+      'detail': message,
+      'code': 'inactive_user'
+    }
+  });
+
+  visit('/activation');
+  fillIn('.activation-page form input', 'valid@mail.com');
+  click('.activation-page form .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'activation.index');
+    assert.equal(getToastMessage(), message);
+  });
+});
+
+test('request activation link with admin-activated account', function(assert) {
+  assert.expect(2);
+
+  var message = 'Your account has to be activated by Administrator before you will be able to sign in.';
+  Ember.$.mockjax({
+    url: '/api/activation/send-link/',
+    status: 400,
+    responseText: {
+      'detail': message,
+      'code': 'inactive_admin'
+    }
+  });
+
+  visit('/activation');
+  fillIn('.activation-page form input', 'valid@mail.com');
+  click('.activation-page form .btn-primary');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'activation.index');
+    assert.equal(getToastMessage(), message);
+  });
+});
+
+test('request activation link with banned account', function(assert) {
+  assert.expect(2);
+
+  Ember.$.mockjax({
+    url: '/api/activation/send-link/',
+    status: 400,
+    responseText: {
+      'detail': {
+        'expires_on': null,
+        'message': {
+          'plain': 'You are banned for trolling.',
+          'html': '<p>You are banned for trolling.</p>',
+        }
+      },
+      'code': 'banned'
+    }
+  });
+
+  visit('/activation');
+  fillIn('.activation-page form input', 'valid@mail.com');
+  click('.activation-page form .btn-primary');
+
+  andThen(function() {
+    var errorMessage = find('.lead p').text();
+    assert.equal(errorMessage, 'You are banned for trolling.');
+
+    var expirationMessage = find('.error-message>p').text();
+    assert.equal(expirationMessage, 'This ban is permanent.');
+  });
+});
+
+test('request activation link', function(assert) {
+  assert.expect(1);
+
+  Ember.$.mockjax({
+    url: '/api/activation/send-link/',
+    status: 200,
+    responseText: {
+      'username': 'BobBoberson',
+      'email': 'valid@mail.com'
+    }
+  });
+
+  visit('/activation');
+  fillIn('.activation-page form input', 'valid@mail.com');
+  click('.activation-page form .btn-primary');
+
+  andThen(function() {
+    var pageHeader = Ember.$.trim(find('.page-header h1').text());
+    assert.equal(pageHeader, 'Activation link sent');
+  });
+});
+
+test('invalid token is handled', function(assert) {
+  assert.expect(2);
+
+  var message = 'Token was rejected.';
+  Ember.$.mockjax({
+    url: '/api/activation/1/token/validate-token/',
+    status: 404,
+    responseText: {
+      'detail': message
+    }
+  });
+
+  visit('/activation/1/token/');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'activation.index');
+    assert.equal(getToastMessage(), message);
+  });
+});
+
+test('permission denied is handled', function(assert) {
+  assert.expect(2);
+
+  var message = 'Token was rejected.';
+  Ember.$.mockjax({
+    url: '/api/activation/1/token/validate-token/',
+    status: 403,
+    responseText: {
+      'detail': message
+    }
+  });
+
+  visit('/activation/1/token/');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'error-403');
+
+    var errorMessage = Ember.$.trim(find('.lead').text());
+    assert.equal(errorMessage, message);
+  });
+});
+
+test('token is validated', function(assert) {
+  assert.expect(2);
+
+  var message = 'Yur account has been activated!';
+  Ember.$.mockjax({
+    url: '/api/activation/1/token/validate-token/',
+    status: 200,
+    responseText: {
+      'detail': message
+    }
+  });
+
+  visit('/activation/1/token/');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'index');
+    assert.equal(getToastMessage(), message);
+  });
+});

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

@@ -5,7 +5,7 @@ import getToastMessage from '../helpers/toast-message';
 
 var application;
 
-module('Acceptance: ForgottenPassword', {
+module('Acceptance: Forgotten Password Change', {
   beforeEach: function() {
     application = startApp();
   },

+ 95 - 0
misago/users/api/activation.py

@@ -0,0 +1,95 @@
+from django.contrib.auth import get_user_model
+from django.core.exceptions import ValidationError
+from django.core.urlresolvers import reverse
+from django.shortcuts import get_object_or_404
+from django.utils.translation import ugettext as _
+from django.views.decorators.cache import never_cache
+from django.views.decorators.csrf import csrf_protect
+from django.views.decorators.debug import sensitive_post_parameters
+
+from rest_framework import status
+from rest_framework.decorators import api_view
+from rest_framework.response import Response
+
+from misago.conf import settings
+from misago.core.mail import mail_user
+
+from misago.users.decorators import deny_authenticated, deny_banned_ips
+from misago.users.forms.auth import ResendActivationForm
+from misago.users.tokens import (make_activation_token,
+                                 is_activation_token_valid)
+from misago.users.validators import validate_password
+
+
+def activation_api_view(f):
+    @sensitive_post_parameters()
+    @api_view(['POST'])
+    @never_cache
+    @deny_authenticated
+    @csrf_protect
+    @deny_banned_ips
+    def decorator(request, *args, **kwargs):
+        if 'user_id' in kwargs:
+            User = get_user_model()
+            user = get_object_or_404(User.objects, pk=kwargs.pop('user_id'))
+            kwargs['user'] = user
+
+            if not is_activation_token_valid(user, kwargs['token']):
+                message = _("Your link is invalid. Please try again.")
+                return Response({'detail': message},
+                                status=status.HTTP_404_NOT_FOUND)
+
+            form = ResendActivationForm()
+            try:
+                form.confirm_user_not_banned(user)
+            except ValidationError:
+                message = _("Your link has expired. Please request new one.")
+                return Response({'detail': message},
+                                status=status.HTTP_404_NOT_FOUND)
+
+            try:
+                form.confirm_can_be_activated(user)
+            except ValidationError as e:
+                return Response({'detail': e.messages[0]},
+                                status=status.HTTP_404_NOT_FOUND)
+
+        return f(request, *args, **kwargs)
+    return decorator
+
+
+@activation_api_view
+def send_link(request):
+    form = ResendActivationForm(request.DATA)
+    if form.is_valid():
+        requesting_user = form.user_cache
+
+        mail_subject = _("Change %(user)s password "
+                         "on %(forum_title)s forums")
+        subject_formats = {'user': requesting_user.username,
+                           'forum_title': settings.forum_name}
+        mail_subject = mail_subject % subject_formats
+
+        confirmation_token = make_activation_token(requesting_user)
+
+        mail_user(request, requesting_user, mail_subject,
+                  'misago/emails/change_password_form_link',
+                  {'confirmation_token': confirmation_token})
+
+        return Response({
+            'username': form.user_cache.username,
+            'email': form.user_cache.email
+        })
+    else:
+        return Response(form.get_errors_dict(),
+                        status=status.HTTP_400_BAD_REQUEST)
+
+
+@activation_api_view
+def validate_token(request, user, token):
+    user.requires_activation = False
+    user.save(update_fields=['requires_activation'])
+
+    message = _("%(user)s, your account has been activated")
+    return Response({
+        'detail': message % {'user': user.username}
+    })

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

@@ -1,5 +1,5 @@
 from django.contrib.auth import get_user_model
-from django.core.exceptions import PermissionDenied, ValidationError
+from django.core.exceptions import ValidationError
 from django.core.urlresolvers import reverse
 from django.shortcuts import get_object_or_404
 from django.utils.translation import ugettext as _

+ 11 - 6
misago/users/forms/auth.py

@@ -147,19 +147,24 @@ class GetUserForm(MisagoAuthMixin, forms.Form):
 
 
 class ResendActivationForm(GetUserForm):
-    def confirm_allowed(self, user):
-        self.confirm_user_not_banned(user)
-
+    def confirm_can_be_activated(self, user):
         username_format = {'user': user.username}
 
         if not user.requires_activation:
-            message = _("%(user)s, your account is already active.")
-            raise forms.ValidationError(message % username_format)
+            message = _("%(user)s, your account is already activated.")
+            raise forms.ValidationError(message % username_format,
+                                        code='already_active')
 
         if user.requires_activation_by_admin:
             message = _("%(user)s, only administrator may activate "
                         "your account.")
-            raise forms.ValidationError(message % username_format)
+            raise forms.ValidationError(message % username_format,
+                                        code='inactive_admin')
+
+    def confirm_allowed(self, user):
+        self.confirm_user_not_banned(user)
+        self.confirm_can_be_activated(user)
+
 
 
 class ResetPasswordForm(GetUserForm):

+ 155 - 0
misago/users/tests/test_activation_api.py

@@ -0,0 +1,155 @@
+from django.contrib.auth import get_user_model
+from django.core import mail
+from django.core.urlresolvers import reverse
+from django.test import TestCase
+
+from misago.users.models import Ban, BAN_USERNAME
+from misago.users.tokens import make_activation_token
+
+
+class SendLinkAPITests(TestCase):
+    def setUp(self):
+        User = get_user_model()
+        self.user = User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        self.user.requires_activation = 1
+        self.user.save()
+
+        self.link = reverse('misago:api:activation_send_link')
+
+    def test_submit_valid(self):
+        """request activation link api sends reset link mail"""
+        response = self.client.post(self.link, data={'email': self.user.email})
+        self.assertEqual(response.status_code, 200)
+
+        self.assertIn('Change Bob password', mail.outbox[0].subject)
+
+    def test_submit_invalid(self):
+        """request activation link api errors for invalid email"""
+        response = self.client.post(self.link, data={'email': 'fake@mail.com'})
+        self.assertEqual(response.status_code, 400)
+        self.assertIn('not_found', response.content)
+
+        self.assertTrue(not mail.outbox)
+
+    def test_submit_banned(self):
+        """request activation link api errors for banned users"""
+        Ban.objects.create(check_type=BAN_USERNAME,
+                           banned_value=self.user.username,
+                           user_message='Nope!')
+
+        response = self.client.post(self.link, data={'email': self.user.email})
+        self.assertEqual(response.status_code, 400)
+        self.assertIn('Nope!', response.content)
+
+        self.assertTrue(not mail.outbox)
+
+    def test_view_submit_active_user(self):
+        """request activation link api errors for active user"""
+        self.user.requires_activation = 0
+        self.user.save()
+
+        response = self.client.post(self.link, data={'email': self.user.email})
+        self.assertEqual(response.status_code, 400)
+        self.assertIn('Bob, your account is already activated.',
+                      response.content)
+
+    def test_view_submit_inactive_user(self):
+        """request activation link api errors for admin-activated users"""
+        self.user.requires_activation = 2
+        self.user.save()
+
+        response = self.client.post(self.link, data={'email': self.user.email})
+        self.assertEqual(response.status_code, 400)
+        self.assertIn('inactive_admin', response.content)
+
+        self.assertTrue(not mail.outbox)
+
+        # but succeed for user-activated
+        self.user.requires_activation = 1
+        self.user.save()
+
+        response = self.client.post(self.link, data={'email': self.user.email})
+        self.assertEqual(response.status_code, 200)
+
+        self.assertTrue(mail.outbox)
+
+
+class ValidateTokenAPITests(TestCase):
+    def setUp(self):
+        User = get_user_model()
+        self.user = User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        self.user.requires_activation = 1
+        self.user.save()
+
+        self.link = reverse(
+            'misago:api:activation_validate_token',
+            kwargs={
+                'user_id': self.user.id,
+                'token': make_activation_token(self.user)
+            })
+
+    def test_submit_valid(self):
+        """validate link api returns success and activates user"""
+        response = self.client.post(self.link)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(self.user.username, response.content)
+
+        user = get_user_model().objects.get(id=self.user.id)
+        self.assertFalse(user.requires_activation)
+
+    def test_submit_invalid_token(self):
+        """validate link api errors for invalid token"""
+        response = self.client.post(reverse(
+            'misago:api:activation_validate_token',
+            kwargs={
+                'user_id': self.user.id,
+                'token': 'sadsadsadsdsassdsa'
+            }))
+        self.assertEqual(response.status_code, 404)
+        self.assertIn('Your link is invalid.', response.content)
+
+    def test_submit_invalid_user(self):
+        """validate link api errors for invalid user"""
+        response = self.client.post(reverse(
+            'misago:api:activation_validate_token',
+            kwargs={
+                'user_id': 123,
+                'token': 'sadsadsadsdsassdsa'
+            }))
+        self.assertEqual(response.status_code, 404)
+
+    def test_submit_banned(self):
+        """validate link api errors for banned user"""
+        Ban.objects.create(check_type=BAN_USERNAME,
+                           banned_value=self.user.username,
+                           user_message='Nope!')
+
+        response = self.client.post(self.link)
+        self.assertEqual(response.status_code, 404)
+        self.assertIn('Your link has expired.', response.content)
+
+    def test_view_submit_active_user(self):
+        """validate link api errors for active user"""
+        self.user.requires_activation = 0
+        self.user.save()
+
+        response = self.client.post(self.link)
+        self.assertEqual(response.status_code, 404)
+        self.assertIn('Bob, your account is already activated.',
+                      response.content)
+
+    def test_view_submit_inactive_user(self):
+        """validate link api errors for inactive user"""
+        self.user.requires_activation = 1
+        self.user.save()
+
+        response = self.client.post(self.link)
+        self.assertEqual(response.status_code, 200)
+
+        self.user.requires_activation = 2
+        self.user.save()
+
+        response = self.client.post(self.link)
+        self.assertEqual(response.status_code, 404)
+        self.assertIn('Bob, only administrator may activate your account.',
+                      response.content)

+ 22 - 93
misago/users/tests/test_activation_views.py

@@ -1,111 +1,40 @@
 from django.contrib.auth import get_user_model
-from django.core import mail
 from django.core.urlresolvers import reverse
 from django.test import TestCase
 
-from misago.users.models import Ban, BAN_USERNAME
 from misago.users.tokens import make_activation_token
 
 
 class ActivationViewsTests(TestCase):
-    def test_view_get_returns_200(self):
-        """request activation view returns 200 on GET"""
+    def test_request_view_returns_200(self):
+        """request new activation link view returns 200"""
         response = self.client.get(reverse('misago:request_activation'))
         self.assertEqual(response.status_code, 200)
 
-    def test_view_submit(self):
-        """request activation view sends mail"""
-        User = get_user_model()
-        User.objects.create_user('Bob', 'bob@test.com', 'Pass.123',
-                                 requires_activation=1)
-
-        response = self.client.post(
-            reverse('misago:request_activation'),
-            data={'username': 'Bob'})
-
-        self.assertEqual(response.status_code, 302)
-
-        self.assertIn('Account activation', mail.outbox[0].subject)
-
-    def test_view_submit_banned(self):
-        """request activation for banned shows error"""
-        User = get_user_model()
-        User.objects.create_user('Bob', 'bob@test.com', 'Pass.123',
-                                 requires_activation=1)
-        Ban.objects.create(check_type=BAN_USERNAME,
-                           banned_value='bob',
-                           user_message='Nope!')
-
-        response = self.client.post(
-            reverse('misago:request_activation'),
-            data={'username': 'Bob'})
-        self.assertEqual(response.status_code, 200)
-        self.assertIn('Nope!', response.content)
-
-        self.assertTrue(not mail.outbox)
-
-    def test_view_submit_active(self):
-        """request activation for active shows error"""
-        User = get_user_model()
-        User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
-
-        response = self.client.post(
-            reverse('misago:request_activation'),
-            data={'username': 'Bob'})
-        self.assertEqual(response.status_code, 200)
-        self.assertIn('already active', response.content)
-
-        self.assertTrue(not mail.outbox)
-
-    def test_view_activate_banned(self):
-        """activate banned user shows error"""
-        User = get_user_model()
-        test_user = User.objects.create_user('Bob', 'bob@test.com', 'Pass.123',
-                                             requires_activation=1)
-        Ban.objects.create(check_type=BAN_USERNAME,
-                           banned_value='bob',
-                           user_message='Nope!')
-
-        activation_token = make_activation_token(test_user)
-
-        response = self.client.get(
-            reverse('misago:activate_by_token',
-                    kwargs={'user_id': test_user.pk,
-                            'token': activation_token}))
-        self.assertEqual(response.status_code, 302)
-
-        test_user = User.objects.get(pk=test_user.pk)
-        self.assertEqual(test_user.requires_activation, 1)
-
-    def test_view_activate_active(self):
-        """activate active user shows error"""
+    def test_activate_view_returns_200(self):
+        """activate account view returns 200"""
         User = get_user_model()
         test_user = User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
 
-        activation_token = make_activation_token(test_user)
-
         response = self.client.get(
-            reverse('misago:activate_by_token',
-                    kwargs={'user_id': test_user.pk,
-                            'token': activation_token}))
-        self.assertEqual(response.status_code, 302)
-
-        test_user = User.objects.get(pk=test_user.pk)
-        self.assertEqual(test_user.requires_activation, 0)
-
-    def test_view_activate_inactive(self):
-        """activate inactive user passess"""
-        User = get_user_model()
-        test_user = User.objects.create_user('Bob', 'bob@test.com', 'Pass.123',
-                                             requires_activation=1)
-
-        activation_token = make_activation_token(test_user)
+            reverse('misago:activate_by_token', kwargs={
+                'user_id': test_user.id,
+                'token': make_activation_token(test_user)
+            }))
+        self.assertEqual(response.status_code, 200)
 
+        # test invalid user
         response = self.client.get(
-            reverse('misago:activate_by_token',
-                    kwargs={'user_id': test_user.pk,
-                            'token': activation_token}))
-        self.assertEqual(response.status_code, 302)
+            reverse('misago:activate_by_token', kwargs={
+                'user_id': 7681,
+                'token': 'a7d8sa97d98sa798dsa'
+            }))
+        self.assertEqual(response.status_code, 200)
 
-        test_user = User.objects.get(pk=test_user.pk)
-        self.assertEqual(test_user.requires_activation, 0)
+        # test invalid token
+        response = self.client.get(
+            reverse('misago:activate_by_token', kwargs={
+                'user_id': test_user.id,
+                'token': 'asd79as87ds9a8d7sa'
+            }))
+        self.assertEqual(response.status_code, 200)

+ 2 - 3
misago/users/urls/__init__.py

@@ -14,9 +14,8 @@ urlpatterns += patterns('misago.users.views.register',
 
 
 urlpatterns += patterns('misago.users.views.activation',
-    url(r'^activation/request/$', 'request_activation', name="request_activation"),
-    url(r'^activation/sent/$', 'activation_sent', name="activation_sent"),
-    url(r'^activation/(?P<user_id>\d+)/(?P<token>[a-zA-Z0-9]+)/$', 'activate_by_token', name="activate_by_token"),
+    url(r'^activation/$', 'activation_noscript', name="request_activation"),
+    url(r'^activation/(?P<user_id>\d+)/(?P<token>[a-zA-Z0-9]+)/$', 'activation_noscript', name="activate_by_token"),
 )
 
 

+ 4 - 0
misago/users/urls/api.py

@@ -6,6 +6,10 @@ urlpatterns = patterns('misago.users.api.auth',
     url(r'^auth/$', 'user', name='auth_user'),
 )
 
+urlpatterns += patterns('misago.users.api.activation',
+    url(r'^activation/send-link/$', 'send_link', name="activation_send_link"),
+    url(r'^activation/(?P<user_id>\d+)/(?P<token>[a-zA-Z0-9]+)/validate-token/$', 'validate_token', name="activation_validate_token"),
+)
 
 urlpatterns += patterns('misago.users.api.changepassword',
     url(r'^change-password/send-link/$', 'send_link', name='change_password_send_link'),

+ 11 - 108
misago/users/views/activation.py

@@ -1,112 +1,15 @@
-from django.contrib import messages
-from django.contrib.auth import get_user_model
-from django.http import Http404
-from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.translation import ugettext as _
+from django.views.decorators.debug import sensitive_post_parameters
 
-from misago.conf import settings
-from misago.core.mail import mail_user
+from misago.core.views import noscript
+from misago.users.decorators import deflect_authenticated, deflect_banned_ips
 
-from misago.users.bans import get_user_ban
-from misago.users.decorators import deny_authenticated, deny_banned_ips
-from misago.users.forms.auth import ResendActivationForm
-from misago.users.models import ACTIVATION_REQUIRED_NONE
-from misago.users.tokens import (make_activation_token,
-                                 is_activation_token_valid)
 
-
-def activation_view(f):
-    @deny_authenticated
-    @deny_banned_ips
-    def decorator(*args, **kwargs):
-        return f(*args, **kwargs)
-    return decorator
-
-
-@activation_view
-def request_activation(request):
-    form = ResendActivationForm()
-
-    if request.method == 'POST':
-        form = ResendActivationForm(request.POST)
-        if form.is_valid():
-            requesting_user = form.user_cache
-            request.session['activation_sent_to'] = requesting_user.pk
-
-            mail_subject = _("Account activation on %(forum_title)s forums")
-            mail_subject = mail_subject % {'forum_title': settings.forum_name}
-
-            activation_token = make_activation_token(requesting_user)
-
-            mail_user(
-                request, requesting_user, mail_subject,
-                'misago/emails/activation/by_user',
-                {'activation_token': activation_token})
-
-            return redirect('misago:activation_sent')
-
-    return render(request, 'misago/activation/request.html',
-                  {'form': form})
-
-
-@activation_view
-def activation_sent(request):
-    requesting_user_pk = request.session.get('activation_sent_to')
-    if not requesting_user_pk:
-        raise Http404()
-
-    User = get_user_model()
-    requesting_user = get_object_or_404(User.objects, pk=requesting_user_pk)
-
-    return render(request, 'misago/activation/sent.html',
-                  {'requesting_user': requesting_user})
-
-
-class ActivationStopped(Exception):
-    pass
-
-
-class ActivationError(Exception):
-    pass
-
-
-@activation_view
-def activate_by_token(request, user_id, token):
-    User = get_user_model()
-    inactive_user = get_object_or_404(User.objects, pk=user_id)
-
-    try:
-        if not inactive_user.requires_activation:
-            message = _("%(user)s, your account is already active.")
-            message = message % {'user': inactive_user.username}
-            raise ActivationStopped(message)
-        if inactive_user.requires_activation_by_admin:
-            message = _("%(user)s, your account can be activated "
-                        "only by one of the administrators.")
-            message = message % {'user': inactive_user.username}
-            raise ActivationStopped(message)
-        if get_user_ban(inactive_user):
-            message = _("%(user)s, your account is banned "
-                        "and can't be activated.")
-            message = message % {'user': inactive_user.username}
-            raise ActivationError(message)
-        if not is_activation_token_valid(inactive_user, token):
-            message = _("%(user)s, your activation link is invalid. "
-                        "Try again or request new activation message.")
-            message = message % {'user': inactive_user.username}
-            raise ActivationError(message)
-    except ActivationStopped as e:
-        messages.info(request, e.args[0])
-        return redirect('misago:index')
-    except ActivationError as e:
-        messages.error(request, e.args[0])
-        return redirect('misago:request_activation')
-
-    inactive_user.requires_activation = ACTIVATION_REQUIRED_NONE
-    inactive_user.save(update_fields=['requires_activation'])
-
-    message = _("%(user)s, your account has been activated!")
-    message = message % {'user': inactive_user.username}
-    messages.success(request, message)
-
-    return redirect(settings.LOGIN_URL)
+@sensitive_post_parameters()
+@deflect_authenticated
+@deflect_banned_ips
+def activation_noscript(request, user_id=None, token=None):
+    return noscript(request, **{
+        'title': _("Activate your account"),
+        'message': _("To activate your account enable JavaScript."),
+    })

+ 0 - 2
misago/users/views/forgottenpassword.py

@@ -2,8 +2,6 @@ from django.utils.translation import ugettext as _
 from django.views.decorators.debug import sensitive_post_parameters
 
 from misago.core.views import noscript
-
-from misago.users.bans import get_user_ban
 from misago.users.decorators import deflect_authenticated, deflect_banned_ips