FEATURE: redesign the change password page to use javascript and validations

This commit is contained in:
Neil Lalonde 2017-01-31 15:42:12 -05:00
parent b251d11518
commit c4e10f2a9d
20 changed files with 365 additions and 122 deletions

View File

@ -33,6 +33,7 @@
//= require ./discourse/models/permission-type
//= require ./discourse/models/user-action-group
//= require ./discourse/models/category
//= require ./discourse/models/input-validation
//= require ./discourse/lib/ajax-error
//= require ./discourse/lib/search
//= require ./discourse/lib/user-search

View File

@ -5,8 +5,9 @@ import { setting } from 'discourse/lib/computed';
import { on } from 'ember-addons/ember-computed-decorators';
import { emailValid } from 'discourse/lib/utilities';
import InputValidation from 'discourse/models/input-validation';
import PasswordValidation from "discourse/mixins/password-validation";
export default Ember.Controller.extend(ModalFunctionality, {
export default Ember.Controller.extend(ModalFunctionality, PasswordValidation, {
login: Ember.inject.controller(),
uniqueUsernameValidation: null,
@ -16,7 +17,6 @@ export default Ember.Controller.extend(ModalFunctionality, {
accountChallenge: 0,
formSubmitted: false,
rejectedEmails: Em.A([]),
rejectedPasswords: Em.A([]),
prefilledUsername: null,
userFields: null,
isDeveloper: false,
@ -85,10 +85,6 @@ export default Ember.Controller.extend(ModalFunctionality, {
});
}.property(),
passwordInstructions: function() {
return this.get('isDeveloper') ? I18n.t('user.password.instructions', {count: Discourse.SiteSettings.min_admin_password_length}) : I18n.t('user.password.instructions', {count: Discourse.SiteSettings.min_password_length});
}.property('isDeveloper'),
nameInstructions: function() {
return I18n.t(Discourse.SiteSettings.full_name_required ? 'user.name.instructions_required' : 'user.name.instructions');
}.property(),
@ -293,55 +289,6 @@ export default Ember.Controller.extend(ModalFunctionality, {
return( this.get('globalNicknameExists') || false );
},
// Validate the password
passwordValidation: function() {
if (!this.get('passwordRequired')) {
return InputValidation.create({ ok: true });
}
// If blank, fail without a reason
const password = this.get("accountPassword");
if (Ember.isEmpty(this.get('accountPassword'))) {
return InputValidation.create({ failed: true });
}
// If too short
const passwordLength = this.get('isDeveloper') ? Discourse.SiteSettings.min_admin_password_length : Discourse.SiteSettings.min_password_length;
if (password.length < passwordLength) {
return InputValidation.create({
failed: true,
reason: I18n.t('user.password.too_short')
});
}
if (this.get('rejectedPasswords').includes(password)) {
return InputValidation.create({
failed: true,
reason: I18n.t('user.password.common')
});
}
if (!Ember.isEmpty(this.get('accountUsername')) && this.get('accountPassword') === this.get('accountUsername')) {
return InputValidation.create({
failed: true,
reason: I18n.t('user.password.same_as_username')
});
}
if (!Ember.isEmpty(this.get('accountEmail')) && this.get('accountPassword') === this.get('accountEmail')) {
return InputValidation.create({
failed: true,
reason: I18n.t('user.password.same_as_email')
});
}
// Looks good!
return InputValidation.create({
ok: true,
reason: I18n.t('user.password.ok')
});
}.property('accountPassword', 'rejectedPasswords.[]', 'accountUsername', 'accountEmail', 'isDeveloper'),
@on('init')
fetchConfirmationValue() {
return ajax('/users/hp.json').then(json => {

View File

@ -0,0 +1,53 @@
import { default as computed } from 'ember-addons/ember-computed-decorators';
import getUrl from 'discourse-common/lib/get-url';
import { ajax } from 'discourse/lib/ajax';
import PasswordValidation from "discourse/mixins/password-validation";
export default Ember.Controller.extend(PasswordValidation, {
isDeveloper: Ember.computed.alias('model.is_developer'),
passwordRequired: true,
errorMessage: null,
successMessage: null,
requiresApproval: false,
@computed()
continueButtonText() {
return I18n.t('password_reset.continue', {site_name: this.siteSettings.title});
},
lockImageUrl: getUrl('/images/lock.svg'),
actions: {
submit() {
ajax({
url: `/users/password-reset/${this.get('model.token')}.json`,
type: 'PUT',
data: {
password: this.get('accountPassword')
}
}).then(result => {
if (result.success) {
this.set('successMessage', result.message);
this.set('redirectTo', result.redirect_to);
if (result.requires_approval) {
this.set('requiresApproval', true);
}
} else {
if (result.errors && result.errors.password && result.errors.password.length > 0) {
this.get('rejectedPasswords').pushObject(this.get('accountPassword'));
this.get('rejectedPasswordsMessages').set(this.get('accountPassword'), result.errors.password[0]);
}
if (result.message) {
this.set('errorMessage', result.message);
}
}
}).catch(response => {
throw response;
});
},
done() {
window.location.pathname = this.get('redirectTo') || Discourse.getURL("/");
}
}
});

View File

@ -0,0 +1,71 @@
import InputValidation from 'discourse/models/input-validation';
import { default as computed } from 'ember-addons/ember-computed-decorators';
export default Ember.Mixin.create({
rejectedPasswords: null,
init() {
this._super();
this.set('rejectedPasswords', []);
this.set('rejectedPasswordsMessages', Ember.Map.create());
},
@computed('passwordMinLength')
passwordInstructions() {
return I18n.t('user.password.instructions', {count: this.get('passwordMinLength')});
},
@computed('isDeveloper')
passwordMinLength() {
return this.get('isDeveloper') ? this.siteSettings.min_admin_password_length : this.siteSettings.min_password_length;
},
@computed('accountPassword', 'passwordRequired', 'rejectedPasswords.[]', 'accountUsername', 'accountEmail', 'isDeveloper')
passwordValidation(password, passwordRequired, rejectedPasswords, accountUsername, accountEmail, isDeveloper) {
if (!passwordRequired) {
return InputValidation.create({ ok: true });
}
if (rejectedPasswords.includes(password)) {
return InputValidation.create({
failed: true,
reason: this.get('rejectedPasswordsMessages').get(password) || I18n.t('user.password.common')
});
}
// If blank, fail without a reason
if (Ember.isEmpty(password)) {
return InputValidation.create({ failed: true });
}
// If too short
const passwordLength = isDeveloper ? this.siteSettings.min_admin_password_length : this.siteSettings.min_password_length;
if (password.length < passwordLength) {
return InputValidation.create({
failed: true,
reason: I18n.t('user.password.too_short')
});
}
if (!Ember.isEmpty(accountUsername) && password === accountUsername) {
return InputValidation.create({
failed: true,
reason: I18n.t('user.password.same_as_username')
});
}
if (!Ember.isEmpty(accountEmail) && password === accountEmail) {
return InputValidation.create({
failed: true,
reason: I18n.t('user.password.same_as_email')
});
}
// Looks good!
return InputValidation.create({
ok: true,
reason: I18n.t('user.password.ok')
});
}
});

View File

@ -63,6 +63,7 @@ export default function() {
// User routes
this.route('users', { resetNamespace: true });
this.route('password-reset', { path: '/users/password-reset/:token' });
this.route('user', { path: '/users/:username', resetNamespace: true }, function() {
this.route('summary');
this.route('userActivity', { path: '/activity', resetNamespace: true }, function() {

View File

@ -0,0 +1,21 @@
import PreloadStore from 'preload-store';
import { ajax } from 'discourse/lib/ajax';
export default Discourse.Route.extend({
titleToken() {
return I18n.t('login.reset_password');
},
model(params) {
if (PreloadStore.get("password_reset")) {
return PreloadStore.getAndRemove("password_reset").then(json => _.merge(params, json));
}
},
afterModel(model) {
// confirm token here so email clients who crawl URLs don't invalidate the link
if (model) {
return ajax({ url: `/users/confirm-email-token/${model.token}.json`, dataType: 'json' });
}
}
});

View File

@ -0,0 +1,40 @@
<div class="container password-reset clearfix">
<div class="pull-left col-image">
<img src={{lockImageUrl}} class="password-reset-img">
</div>
<div class="pull-left col-form">
{{#if successMessage}}
<p>{{successMessage}}</p>
{{#if requiresApproval}}
<p>{{i18n 'login.not_approved'}}</p>
{{else}}
<a class="btn" href="{{redirectTo}}" {{action "done"}}>{{continueButtonText}}</a>
{{/if}}
{{else}}
<form>
<h2>{{i18n 'user.change_password.choose'}}</h2>
<div class="input">
{{password-field value=accountPassword type="password" id="new-account-password" capsLockOn=capsLockOn}}
&nbsp;{{input-tip validation=passwordValidation}}
</div>
<div class="instructions">
<label>{{passwordInstructions}}</label>
<div class="caps-lock-warning {{unless capsLockOn 'invisible'}}"><i class="fa fa-exclamation-triangle"></i> {{i18n 'login.caps_lock_warning'}}</div>
</div>
<button class='btn' {{action "submit"}}>{{i18n 'user.change_password.set_password'}}</button>
{{#if errorMessage}}
<br/><br/>
<div class='alert alert-error'>{{errorMessage}}</div>
{{/if}}
</form>
{{/if}}
</div>
</div>

View File

@ -64,6 +64,15 @@ $input-width: 220px;
}
}
.password-reset {
.instructions {
label {
color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%));
}
}
}
// alternate login / create new account buttons should be de-emphasized
button#login-link, button#new-account-link

View File

@ -43,12 +43,6 @@
}
}
tr.instructions {
label {
color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%));
}
}
.tos-agree {
margin-bottom: 12px;
}
@ -57,4 +51,24 @@
margin-top: 15px;
}
.instructions {
label {
color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%));
}
}
}
.password-reset {
.col-form {
padding-top: 40px;
padding-left: 20px;
}
h2 {
margin-bottom: 12px;
}
.password-reset-img {
width: 200px;
height: 200px;
}
}

View File

@ -84,3 +84,32 @@ $input-width: 184px;
}
}
}
.password-reset {
margin-top: 30px;
.col-image {
padding-top: 12px;
}
.password-reset-img {
width: 50px;
height: 50px;
}
.col-form {
padding-left: 8px;
}
h2 {
margin-bottom: 12px;
}
.tip {
display: block;
margin: 6px 0;
max-width: 180px;
}
}
.discourse-touch .password-reset {
.instructions {
margin-bottom: 16px;
}
}

View File

@ -405,8 +405,6 @@ class UsersController < ApplicationController
user_id = secure_session["password-#{token}"].to_i
@user = User.find(user_id) if user_id > 0
end
else
@invalid_token = true
end
if !@user
@ -424,12 +422,42 @@ class UsersController < ApplicationController
Invite.invalidate_for_email(@user.email) # invite link can't be used to log in anymore
secure_session["password-#{token}"] = nil
logon_after_password_reset
return redirect_to(wizard_path) if Wizard.user_requires_completion?(@user)
end
end
end
render layout: 'no_ember'
respond_to do |format|
format.html do
if @error
render layout: 'no_ember'
else
store_preloaded("password_reset", MultiJson.dump({ is_developer: UsernameCheckerService.is_developer?(@user.email) }))
end
return redirect_to(wizard_path) if Wizard.user_requires_completion?(@user)
end
format.json do
if request.put?
if @error || @user&.errors&.any?
render json: {
success: false,
message: @error,
errors: @user&.errors&.to_hash,
is_developer: UsernameCheckerService.is_developer?(@user.email)
}
else
render json: {
success: true,
message: @success,
requires_approval: !Guardian.new(@user).can_access_forum?,
redirect_to: Wizard.user_requires_completion?(@user) ? wizard_path : nil
}
end
else
render json: {is_developer: UsernameCheckerService.is_developer?(@user.email)}
end
end
end
end
def confirm_email_token

View File

@ -2,7 +2,7 @@
<html lang="<%= SiteSetting.default_locale %>">
<head>
<meta charset="utf-8">
<title><%=SiteSetting.title%></title>
<title><%= content_for?(:title) ? yield(:title) + ' - ' + SiteSetting.title : SiteSetting.title %></title>
<meta name="description" content="">
<%= render partial: "layouts/head" %>
<%= render partial: "common/special_font_face" %>

View File

@ -11,59 +11,18 @@
<% end %>
</div>
<% end %>
<%if @success%>
<p>
<%= @success %>
<%- if @requires_approval %>
<%= t 'login.not_approved' %>
<% else %>
<br>
<br>
<a class="btn" href="<%= path "/" %>"><%= t('password_reset.continue', site_name: SiteSetting.title) %></a>
<% end %>
</p>
<% else %>
<%if @user.present? %>
<h3>
<% if @user.has_password? %>
<%= t 'password_reset.choose_new' %>
<% else %>
<%= t 'password_reset.choose' %>
<% end %>
</h3>
<%=form_tag({}, method: :put) do %>
<p>
<span style="display: none;"><input name="username" type="text" value="<%= @user.username %>"></span>
<input id="user_password" name="password" size="30" type="password" maxlength="<%= User.max_password_length %>" onkeypress="capsLock(event)">
<label><%= t('js.user.password.instructions', count: @user.admin? ? SiteSetting.min_admin_password_length : SiteSetting.min_password_length) %></label>
</p>
<div id="capsLockWarning" class="caps-lock-warning" style="visibility:hidden"><i class="fa fa-exclamation-triangle"></i> <%= t 'js.login.caps_lock_warning' %></div>
<p>
<%=submit_tag( @user.has_password? ? t('password_reset.update') : t('password_reset.save'), class: 'btn')%>
</p>
<%end%>
<%end%>
<%end%>
</div>
<% content_for :title do %><%=t "password_reset.title" %><% end %>
<%- content_for(:no_ember_head) do %>
<meta name="referrer" content="never">
<%= script "ember_jquery" %>
<%= render_google_universal_analytics_code %>
<%- end %>
<script type="text/javascript">
document.getElementById('user_password').focus();
function capsLock(e) {
kc = e.keyCode?e.keyCode:e.which;
sk = e.shiftKey?e.shiftKey:((kc == 16)?true:false);
(((kc >= 65 && kc <= 90) && !sk)||((kc >= 97 && kc <= 122) && sk)) ? document.getElementById('capsLockWarning').style.visibility = 'visible' : document.getElementById('capsLockWarning').style.visibility = 'hidden';
}
$.ajax('<%= path "/users/confirm-email-token/#{params[:token]}" %>', {dataType: 'json'});
</script>
<%- content_for(:head) do %>
<meta name="referrer" content="never">
<%- end %>
<%= render_google_analytics_code %>

View File

@ -655,6 +655,8 @@ en:
error: "(error)"
action: "Send Password Reset Email"
set_password: "Set Password"
choose_new: "Choose a new password"
choose: "Choose a password"
change_about:
title: "Change About Me"
@ -1041,6 +1043,7 @@ en:
to_continue: "Please Log In"
preferences: "You need to be logged in to change your user preferences."
forgot: "I don't recall my account details"
not_approved: "Your account hasn't been approved yet. You will be notified by email when you are ready to log in."
google:
title: "with Google"
message: "Authenticating with Google (make sure pop up blockers are not enabled)"
@ -1063,6 +1066,9 @@ en:
title: "with GitHub"
message: "Authenticating with GitHub (make sure pop up blockers are not enabled)"
password_reset:
continue: "Continue to %{site_name}"
emoji_set:
apple_international: "Apple/International"
google: "Google"

View File

@ -580,7 +580,6 @@ en:
title: 'Reset Password'
success: "You successfully changed your password and are now logged in."
success_unapproved: "You successfully changed your password."
continue: "Continue to %{site_name}"
change_email:
confirmed: "Your email has been updated."

1
public/images/lock.svg Normal file
View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 47.5 47.5" style="enable-background:new 0 0 47.5 47.5;" xml:space="preserve" version="1.1" id="svg2"><metadata id="metadata8"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/></cc:Work></rdf:RDF></metadata><defs id="defs6"><clipPath id="clipPath16" clipPathUnits="userSpaceOnUse"><path id="path18" d="M 0,38 38,38 38,0 0,0 0,38 Z"/></clipPath></defs><g transform="matrix(1.25,0,0,-1.25,0,47.5)" id="g10"><g id="g12"><g clip-path="url(#clipPath16)" id="g14"><g transform="translate(19,32.9995)" id="g20"><path id="path22" style="fill:#bcbec0;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 c -5.523,0 -10,-4.477 -10,-10 l 0,-10 4,0 0,10 c 0,3.313 2.687,6 6,6 3.313,0 6,-2.687 6,-6 l 0,-10 4,0 0,10 C 10,-4.477 5.523,0 0,0"/></g><g transform="translate(31,5)" id="g24"><path id="path26" style="fill:#ffac33;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 c 0,-2.209 -1.791,-4 -4,-4 l -16,0 c -2.209,0 -4,1.791 -4,4 l 0,10 c 0,2.209 1.791,4 4,4 l 16,0 c 2.209,0 4,-1.791 4,-4 L 0,0 Z"/></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -218,8 +218,8 @@ describe UsersController do
it 'disallows login' do
expect(assigns[:error]).to be_present
expect(session[:current_user_id]).to be_blank
expect(assigns[:invalid_token]).to eq(nil)
expect(response).to be_success
expect(response).to render_template(layout: 'no_ember')
end
end
@ -231,8 +231,8 @@ describe UsersController do
it 'disallows login' do
expect(assigns[:error]).to be_present
expect(session[:current_user_id]).to be_blank
expect(assigns[:invalid_token]).to eq(true)
expect(response).to be_success
expect(response).to render_template(layout: 'no_ember')
end
end

View File

@ -0,0 +1,64 @@
import { acceptance } from "helpers/qunit-helpers";
import PreloadStore from 'preload-store';
import { parsePostData } from "helpers/create-pretender";
acceptance("Password Reset", {
setup() {
const response = (object) => {
return [
200,
{"Content-Type": "application/json"},
object
];
};
server.get('/users/confirm-email-token/myvalidtoken.json', () => { //eslint-disable-line
return response({success: "OK"});
});
server.put('/users/password-reset/myvalidtoken.json', request => { //eslint-disable-line
const body = parsePostData(request.requestBody);
if (body.password === "jonesyAlienSlayer") {
return response({success: false, errors: {password: ["is the name of your cat"]}});
} else {
return response({success: "OK", message: I18n.t('password_reset.success')});
}
});
}
});
test("Password Reset Page", () => {
PreloadStore.store('password_reset', {is_developer: false});
visit("/users/password-reset/myvalidtoken");
andThen(() => {
ok(exists(".password-reset input"), "shows the input");
ok(find('.password-reset .instructions').html().trim().includes(`${Discourse.SiteSettings.min_password_length} char`), "shows correct min length");
});
fillIn('.password-reset input', 'perf3ctly5ecur3');
andThen(() => {
ok(exists(".password-reset .tip.good"), "input looks good");
});
fillIn('.password-reset input', '123');
andThen(() => {
ok(exists(".password-reset .tip.bad"), "input is not valid");
ok(find(".password-reset .tip.bad").html().trim().includes(I18n.t('user.password.too_short')), "password too short");
});
fillIn('.password-reset input', 'jonesyAlienSlayer');
click('.password-reset form button');
andThen(() => {
ok(exists(".password-reset .tip.bad"), "input is not valid");
ok(find(".password-reset .tip.bad").html().trim().includes("is the name of your cat"), "server validation error message shows");
});
fillIn('.password-reset input', 'perf3ctly5ecur3');
click('.password-reset form button');
andThen(() => {
ok(!exists(".password-reset form"), "form is gone");
ok(exists(".password-reset .btn"), "button is shown");
});
});

View File

@ -11,7 +11,7 @@ test('basicUsernameValidation', function() {
var subject = this.subject;
var testInvalidUsername = function(username, expectedReason) {
var controller = subject();
var controller = subject({ siteSettings: Discourse.SiteSettings });
controller.set('accountUsername', username);
equal(controller.get('basicUsernameValidation.failed'), true, 'username should be invalid: ' + username);
equal(controller.get('basicUsernameValidation.reason'), expectedReason, 'username validation reason: ' + username + ', ' + expectedReason);
@ -21,7 +21,7 @@ test('basicUsernameValidation', function() {
testInvalidUsername('x', I18n.t('user.username.too_short'));
testInvalidUsername('123456789012345678901', I18n.t('user.username.too_long'));
var controller = subject();
var controller = subject({ siteSettings: Discourse.SiteSettings });
controller.set('accountUsername', 'porkchops');
controller.set('prefilledUsername', 'porkchops');
equal(controller.get('basicUsernameValidation.ok'), true, 'Prefilled username is valid');
@ -31,7 +31,7 @@ test('basicUsernameValidation', function() {
test('passwordValidation', function() {
var subject = this.subject;
var controller = subject();
var controller = subject({ siteSettings: Discourse.SiteSettings });
controller.set('passwordRequired', true);
controller.set('accountEmail', 'pork@chops.com');
controller.set('accountUsername', 'porkchops');
@ -42,7 +42,7 @@ test('passwordValidation', function() {
equal(controller.get('passwordValidation.reason'), I18n.t('user.password.ok'), 'Password is valid');
var testInvalidPassword = function(password, expectedReason) {
var c = subject();
var c = subject({ siteSettings: Discourse.SiteSettings });
c.set('accountPassword', password);
equal(c.get('passwordValidation.failed'), true, 'password should be invalid: ' + password);
equal(c.get('passwordValidation.reason'), expectedReason, 'password validation reason: ' + password + ', ' + expectedReason);

View File

@ -1,7 +1,7 @@
import storePretender from 'helpers/store-pretender';
import fixturePretender from 'helpers/fixture-pretender';
function parsePostData(query) {
export function parsePostData(query) {
const result = {};
query.split("&").forEach(function(part) {
const item = part.split("=");
@ -18,7 +18,7 @@ function parsePostData(query) {
});
return result;
}
};
function response(code, obj) {
if (typeof code === "object") {