FEATURE: new invite acceptance page, where username can be chosen and password can be set

This commit is contained in:
Neil Lalonde 2017-02-13 16:19:41 -05:00
parent 3818c196e0
commit d0fbb27f3e
21 changed files with 511 additions and 175 deletions

View File

@ -1,17 +1,15 @@
import { ajax } from 'discourse/lib/ajax';
import debounce from 'discourse/lib/debounce';
import ModalFunctionality from 'discourse/mixins/modal-functionality';
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";
import UsernameValidation from "discourse/mixins/username-validation";
export default Ember.Controller.extend(ModalFunctionality, PasswordValidation, {
export default Ember.Controller.extend(ModalFunctionality, PasswordValidation, UsernameValidation, {
login: Ember.inject.controller(),
uniqueUsernameValidation: null,
globalNicknameExists: false,
complete: false,
accountPasswordConfirm: 0,
accountChallenge: 0,
@ -24,8 +22,6 @@ export default Ember.Controller.extend(ModalFunctionality, PasswordValidation, {
hasAuthOptions: Em.computed.notEmpty('authOptions'),
canCreateLocal: setting('enable_local_logins'),
showCreateForm: Em.computed.or('hasAuthOptions', 'canCreateLocal'),
maxUsernameLength: setting('max_username_length'),
minUsernameLength: setting('min_username_length'),
resetForm() {
// We wrap the fields in a structure so we can assign a value
@ -167,128 +163,6 @@ export default Ember.Controller.extend(ModalFunctionality, PasswordValidation, {
}
}.observes('emailValidation', 'accountEmail'),
fetchExistingUsername: debounce(function() {
const self = this;
Discourse.User.checkUsername(null, this.get('accountEmail')).then(function(result) {
if (result.suggestion && (Ember.isEmpty(self.get('accountUsername')) || self.get('accountUsername') === self.get('authOptions.username'))) {
self.set('accountUsername', result.suggestion);
self.set('prefilledUsername', result.suggestion);
}
});
}, 500),
usernameMatch: function() {
if (this.usernameNeedsToBeValidatedWithEmail()) {
if (this.get('emailValidation.failed')) {
if (this.shouldCheckUsernameMatch()) {
return this.set('uniqueUsernameValidation', InputValidation.create({
failed: true,
reason: I18n.t('user.username.enter_email')
}));
} else {
return this.set('uniqueUsernameValidation', InputValidation.create({ failed: true }));
}
} else if (this.shouldCheckUsernameMatch()) {
this.set('uniqueUsernameValidation', InputValidation.create({
failed: true,
reason: I18n.t('user.username.checking')
}));
return this.checkUsernameAvailability();
}
}
}.observes('accountEmail'),
basicUsernameValidation: function() {
this.set('uniqueUsernameValidation', null);
if (this.get('accountUsername') === this.get('prefilledUsername')) {
return InputValidation.create({
ok: true,
reason: I18n.t('user.username.prefilled')
});
}
// If blank, fail without a reason
if (Ember.isEmpty(this.get('accountUsername'))) {
return InputValidation.create({
failed: true
});
}
// If too short
if (this.get('accountUsername').length < Discourse.SiteSettings.min_username_length) {
return InputValidation.create({
failed: true,
reason: I18n.t('user.username.too_short')
});
}
// If too long
if (this.get('accountUsername').length > this.get('maxUsernameLength')) {
return InputValidation.create({
failed: true,
reason: I18n.t('user.username.too_long')
});
}
this.checkUsernameAvailability();
// Let's check it out asynchronously
return InputValidation.create({
failed: true,
reason: I18n.t('user.username.checking')
});
}.property('accountUsername'),
shouldCheckUsernameMatch: function() {
return !Ember.isEmpty(this.get('accountUsername')) && this.get('accountUsername').length >= this.get('minUsernameLength');
},
checkUsernameAvailability: debounce(function() {
const _this = this;
if (this.shouldCheckUsernameMatch()) {
return Discourse.User.checkUsername(this.get('accountUsername'), this.get('accountEmail')).then(function(result) {
_this.set('isDeveloper', false);
if (result.available) {
if (result.is_developer) {
_this.set('isDeveloper', true);
}
return _this.set('uniqueUsernameValidation', InputValidation.create({
ok: true,
reason: I18n.t('user.username.available')
}));
} else {
if (result.suggestion) {
return _this.set('uniqueUsernameValidation', InputValidation.create({
failed: true,
reason: I18n.t('user.username.not_available', result)
}));
} else if (result.errors) {
return _this.set('uniqueUsernameValidation', InputValidation.create({
failed: true,
reason: result.errors.join(' ')
}));
} else {
return _this.set('uniqueUsernameValidation', InputValidation.create({
failed: true,
reason: I18n.t('user.username.enter_email')
}));
}
}
});
}
}, 500),
// Actually wait for the async name check before we're 100% sure we're good to go
usernameValidation: function() {
const basicValidation = this.get('basicUsernameValidation');
const uniqueUsername = this.get('uniqueUsernameValidation');
return uniqueUsername ? uniqueUsername : basicValidation;
}.property('uniqueUsernameValidation', 'basicUsernameValidation'),
usernameNeedsToBeValidatedWithEmail() {
return( this.get('globalNicknameExists') || false );
},
@on('init')
fetchConfirmationValue() {
return ajax('/users/hp.json').then(json => {

View File

@ -0,0 +1,66 @@
import { default as computed } from 'ember-addons/ember-computed-decorators';
import getUrl from 'discourse-common/lib/get-url';
import DiscourseURL from 'discourse/lib/url';
import { ajax } from 'discourse/lib/ajax';
import PasswordValidation from "discourse/mixins/password-validation";
import UsernameValidation from "discourse/mixins/username-validation";
import { findAll as findLoginMethods } from 'discourse/models/login-method';
export default Ember.Controller.extend(PasswordValidation, UsernameValidation, {
invitedBy: Ember.computed.alias('model.invited_by'),
email: Ember.computed.alias('model.email'),
accountUsername: Ember.computed.alias('model.username'),
passwordRequired: Ember.computed.notEmpty('accountPassword'),
successMessage: null,
errorMessage: null,
inviteImageUrl: getUrl('/images/envelope.svg'),
@computed
welcomeTitle() {
return I18n.t('invites.welcome_to', {site_name: this.siteSettings.title});
},
@computed('email')
yourEmailMessage(email) {
return I18n.t('invites.your_email', {email: email});
},
@computed
externalAuthsEnabled() {
return findLoginMethods(this.siteSettings, this.capabilities, this.site.isMobileDevice).length > 0;
},
@computed('usernameValidation.failed', 'passwordValidation.failed')
submitDisabled(usernameFailed, passwordFailed) {
return usernameFailed || passwordFailed;
},
actions: {
submit() {
ajax({
url: `/invites/show/${this.get('model.token')}.json`,
type: 'PUT',
data: {
username: this.get('accountUsername'),
password: this.get('accountPassword')
}
}).then(result => {
if (result.success) {
this.set('successMessage', result.message || I18n.t('invites.success'));
this.set('redirectTo', result.redirect_to);
DiscourseURL.redirectTo(result.redirect_to || '/');
} 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;
});
}
}
});

View File

@ -0,0 +1,134 @@
import InputValidation from 'discourse/models/input-validation';
import debounce from 'discourse/lib/debounce';
import { setting } from 'discourse/lib/computed';
export default Ember.Mixin.create({
uniqueUsernameValidation: null,
globalNicknameExists: false, // TODO: remove this
maxUsernameLength: setting('max_username_length'),
minUsernameLength: setting('min_username_length'),
fetchExistingUsername: debounce(function() {
const self = this;
Discourse.User.checkUsername(null, this.get('accountEmail')).then(function(result) {
if (result.suggestion && (Ember.isEmpty(self.get('accountUsername')) || self.get('accountUsername') === self.get('authOptions.username'))) {
self.set('accountUsername', result.suggestion);
self.set('prefilledUsername', result.suggestion);
}
});
}, 500),
usernameMatch: function() {
if (this.usernameNeedsToBeValidatedWithEmail()) {
if (this.get('emailValidation.failed')) {
if (this.shouldCheckUsernameMatch()) {
return this.set('uniqueUsernameValidation', InputValidation.create({
failed: true,
reason: I18n.t('user.username.enter_email')
}));
} else {
return this.set('uniqueUsernameValidation', InputValidation.create({ failed: true }));
}
} else if (this.shouldCheckUsernameMatch()) {
this.set('uniqueUsernameValidation', InputValidation.create({
failed: true,
reason: I18n.t('user.username.checking')
}));
return this.checkUsernameAvailability();
}
}
}.observes('accountEmail'),
basicUsernameValidation: function() {
this.set('uniqueUsernameValidation', null);
if (this.get('accountUsername') === this.get('prefilledUsername')) {
return InputValidation.create({
ok: true,
reason: I18n.t('user.username.prefilled')
});
}
// If blank, fail without a reason
if (Ember.isEmpty(this.get('accountUsername'))) {
return InputValidation.create({
failed: true
});
}
// If too short
if (this.get('accountUsername').length < Discourse.SiteSettings.min_username_length) {
return InputValidation.create({
failed: true,
reason: I18n.t('user.username.too_short')
});
}
// If too long
if (this.get('accountUsername').length > this.get('maxUsernameLength')) {
return InputValidation.create({
failed: true,
reason: I18n.t('user.username.too_long')
});
}
this.checkUsernameAvailability();
// Let's check it out asynchronously
return InputValidation.create({
failed: true,
reason: I18n.t('user.username.checking')
});
}.property('accountUsername'),
shouldCheckUsernameMatch: function() {
return !Ember.isEmpty(this.get('accountUsername')) && this.get('accountUsername').length >= this.get('minUsernameLength');
},
checkUsernameAvailability: debounce(function() {
const _this = this;
if (this.shouldCheckUsernameMatch()) {
return Discourse.User.checkUsername(this.get('accountUsername'), this.get('accountEmail')).then(function(result) {
_this.set('isDeveloper', false);
if (result.available) {
if (result.is_developer) {
_this.set('isDeveloper', true);
}
return _this.set('uniqueUsernameValidation', InputValidation.create({
ok: true,
reason: I18n.t('user.username.available')
}));
} else {
if (result.suggestion) {
return _this.set('uniqueUsernameValidation', InputValidation.create({
failed: true,
reason: I18n.t('user.username.not_available', result)
}));
} else if (result.errors) {
return _this.set('uniqueUsernameValidation', InputValidation.create({
failed: true,
reason: result.errors.join(' ')
}));
} else {
return _this.set('uniqueUsernameValidation', InputValidation.create({
failed: true,
reason: I18n.t('user.username.enter_email')
}));
}
}
});
}
}, 500),
// Actually wait for the async name check before we're 100% sure we're good to go
usernameValidation: function() {
const basicValidation = this.get('basicUsernameValidation');
const uniqueUsername = this.get('uniqueUsernameValidation');
return uniqueUsername ? uniqueUsername : basicValidation;
}.property('uniqueUsernameValidation', 'basicUsernameValidation'),
usernameNeedsToBeValidatedWithEmail() {
return( this.get('globalNicknameExists') || false );
}
});

View File

@ -142,4 +142,8 @@ export default function() {
this.route('tagGroups', {path: '/tag_groups', resetNamespace: true}, function() {
this.route('show', {path: '/:id'});
});
this.route('invites', { path: '/invites', resetNamespace: true }, function() {
this.route('show', { path: '/:token' });
});
}

View File

@ -0,0 +1,13 @@
import PreloadStore from 'preload-store';
export default Discourse.Route.extend({
titleToken() {
return I18n.t('invites.accept_title');
},
model(params) {
if (PreloadStore.get("invite_info")) {
return PreloadStore.getAndRemove("invite_info").then(json => _.merge(params, json));
}
}
});

View File

@ -0,0 +1 @@
{{outlet}}

View File

@ -0,0 +1,56 @@
<div class="container invites-show clearfix">
<h2>{{welcomeTitle}}</h2>
<div class="two-col">
<div class="col-image">
<img src={{inviteImageUrl}}>
</div>
<div class="col-form">
<p>{{i18n 'invites.invited_by'}}</p>
<p>{{user-info user=invitedBy}}</p>
{{#if successMessage}}
<p>{{successMessage}}</p>
{{else}}
<p>{{i18n 'invites.form_instructions'}}</p>
{{#if externalAuthsEnabled}}
<p>{{i18n 'invites.social_login_available'}}</p>
{{/if}}
<form>
<label>{{i18n 'user.username.title'}}</label>
<div class="input username-input">
{{input value=accountUsername id="new-account-username" name="username" maxlength=maxUsernameLength autocomplete="off"}}
&nbsp;{{input-tip validation=usernameValidation id="username-validation"}}
</div>
<label>{{i18n 'user.change_password.set_password'}}</label>
<div class="input password-input">
{{password-field value=accountPassword type="password" id="new-account-password" capsLockOn=capsLockOn}}
&nbsp;{{input-tip validation=passwordValidation}}
</div>
<div class="instructions">
<div class="caps-lock-warning {{unless capsLockOn 'invisible'}}"><i class="fa fa-exclamation-triangle"></i> {{i18n 'login.caps_lock_warning'}}</div>
</div>
<p>{{{yourEmailMessage}}}</p>
<button class='btn btn-primary' {{action "submit"}} disabled={{submitDisabled}}>{{i18n 'invites.accept_invite'}}</button>
{{#if errorMessage}}
<br/><br/>
<div class='alert alert-error'>{{errorMessage}}</div>
{{/if}}
</form>
{{/if}}
</div>
</div>
</div>

View File

@ -72,6 +72,25 @@ $input-width: 220px;
}
}
.invites-show {
.two-col {
position: relative;
}
.col-image {
position: absolute;
top: 0;
left: 0;
}
form {
margin-top: 24px;
label {
font-weight: bold;
}
}
}
// alternate login / create new account buttons should be de-emphasized

View File

@ -59,16 +59,46 @@
}
.password-reset {
.password-reset, .invites-show {
.col-form {
padding-top: 40px;
padding-left: 20px;
}
h2 {
margin-bottom: 12px;
}
.password-reset-img {
.col-image img {
width: 200px;
height: 200px;
}
}
.password-reset {
.col-form {
padding-top: 40px;
}
}
.invites-show {
padding-top: 20px;
.two-col {
margin-top: 30px;
}
.col-image {
width: 200px;
img {
width: 200px;
}
}
.col-form {
margin-left: 200px;
.inline-invite-img {
display: none;
}
}
form {
label, .input {
margin-left: 20px;
}
}
}

View File

@ -86,7 +86,7 @@ $input-width: 184px;
}
.password-reset {
.password-reset, .invites-show {
margin-top: 30px;
.col-image {
padding-top: 12px;
@ -104,12 +104,22 @@ $input-width: 184px;
.tip {
display: block;
margin: 6px 0;
max-width: 180px;
}
}
.password-reset .tip {
max-width: 180px;
}
.discourse-touch .password-reset {
.instructions {
margin-bottom: 16px;
}
}
.invites-show {
.col-image {
display: none;
}
}

View File

@ -2,8 +2,8 @@ require_dependency 'rate_limiter'
class InvitesController < ApplicationController
# TODO tighten this, why skip check on everything?
skip_before_filter :check_xhr, :preload_json
skip_before_filter :check_xhr, except: [:perform_accept_invitation]
skip_before_filter :preload_json, except: [:show]
skip_before_filter :redirect_to_login_if_required
before_filter :ensure_logged_in, only: [:destroy, :create, :create_invite_link, :resend_invite, :resend_all_invites, :upload_csv]
@ -12,31 +12,49 @@ class InvitesController < ApplicationController
def show
expires_now
render layout: 'no_ember'
invite = Invite.find_by(invite_key: params[:id])
if invite.present?
store_preloaded("invite_info", MultiJson.dump({
invited_by: UserNameSerializer.new(invite.invited_by, scope: guardian, root: false),
email: invite.email,
username: UserNameSuggester.suggest(invite.email)
}))
render layout: 'application'
else
flash.now[:error] = I18n.t('invite.not_found')
render layout: 'no_ember'
end
end
def perform_accept_invitation
invite = Invite.find_by(invite_key: params[:id])
if invite.present?
user = invite.redeem
if user.present?
log_on_user(user)
begin
user = invite.redeem(username: params[:username], password: params[:password])
if user.present?
log_on_user(user)
# Send a welcome message if required
user.enqueue_welcome_message('welcome_invite') if user.send_welcome_message
topic = invite.topics.first
if topic.present?
redirect_to path("#{topic.relative_url}")
return
# Send a welcome message if required
user.enqueue_welcome_message('welcome_invite') if user.send_welcome_message
end
end
redirect_to path("/")
topic = user.present? ? invite.topics.first : nil
render json: {
success: true,
redirect_to: topic.present? ? path("#{topic.relative_url}") : path("/")
}
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
render json: {
success: false,
errors: e.record&.errors&.to_hash || {}
}
end
else
flash.now[:error] = I18n.t('invite.not_found')
render layout: 'no_ember'
render json: { success: false, message: I18n.t('invite.not_found') }
end
end

View File

@ -438,11 +438,11 @@ class UsersController < ApplicationController
format.json do
if request.put?
if @error || @user&.errors&.any?
if @error || @user.errors&.any?
render json: {
success: false,
message: @error,
errors: @user&.errors&.to_hash,
errors: @user.errors.to_hash,
is_developer: UsernameCheckerService.is_developer?(@user.email)
}
else

View File

@ -51,8 +51,8 @@ class Invite < ActiveRecord::Base
invalidated_at.nil?
end
def redeem
InviteRedeemer.new(self).redeem unless expired? || destroyed? || !link_valid?
def redeem(username: nil, name: nil, password: nil)
InviteRedeemer.new(self, username, name, password).redeem unless expired? || destroyed? || !link_valid?
end
def self.extend_permissions(topic, user, invited_by)

View File

@ -1,4 +1,4 @@
InviteRedeemer = Struct.new(:invite, :username, :name) do
InviteRedeemer = Struct.new(:invite, :username, :name, :password) do
def redeem
Invite.transaction do
@ -18,7 +18,7 @@ InviteRedeemer = Struct.new(:invite, :username, :name) do
end
# extracted from User cause it is very specific to invites
def self.create_user_from_invite(invite, username, name)
def self.create_user_from_invite(invite, username, name, password=nil)
user_exists = User.find_by_email(invite.email)
return user if user_exists
@ -31,6 +31,11 @@ InviteRedeemer = Struct.new(:invite, :username, :name) do
user = User.new(email: invite.email, username: available_username, name: available_name, active: true, trust_level: SiteSetting.default_invitee_trust_level)
if password
user.password_required!
user.password = password
end
user.moderator = true if invite.moderator? && invite.invited_by.staff?
user.save!
@ -66,7 +71,7 @@ InviteRedeemer = Struct.new(:invite, :username, :name) do
def get_invited_user
result = get_existing_user
result ||= InviteRedeemer.create_user_from_invite(invite, username, name)
result ||= InviteRedeemer.create_user_from_invite(invite, username, name, password)
result.send_welcome_message = false
result
end

View File

@ -3,11 +3,5 @@
<div class='alert alert-error'>
<%=flash[:error]%>
</div>
<%else%>
<h2><%= t 'activation.welcome_to', site_name: SiteSetting.title %></h2>
<br/>
<%= button_to(perform_accept_invite_path, method: :put, class: 'btn btn-primary') do %>
<%= t 'invite.accept_invite' %>
<% end %>
<%end%>
</div>

View File

@ -1065,6 +1065,15 @@ en:
github:
title: "with GitHub"
message: "Authenticating with GitHub (make sure pop up blockers are not enabled)"
invites:
accept_title: "Invitation"
welcome_to: "Welcome to %{site_name}!"
invited_by: "You were invited by:"
form_instructions: "You can choose your username and set your password now, or later from your preferences."
social_login_available: "After your account is created, you'll be able to sign in with social login."
your_email: "Your account's email address will be <b>%{email}</b>."
accept_invite: "Accept Invitation"
success: "Your account has been created and you're now logged in."
password_reset:
continue: "Continue to %{site_name}"

View File

@ -137,7 +137,6 @@ en:
<<: *errors
invite:
accept_invite: "Accept Invitation"
not_found: "Your invite token is invalid. Please contact the site's administrator."
bulk_invite:

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(37,10)" id="g20"><path id="path22" style="fill:#ccd6dd;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 c 0,-2.209 -1.791,-4 -4,-4 l -28,0 c -2.209,0 -4,1.791 -4,4 l 0,18 c 0,2.209 1.791,4 4,4 l 28,0 c 2.209,0 4,-1.791 4,-4 L 0,0 Z"/></g><g transform="translate(12.9497,19.3643)" id="g24"><path id="path26" style="fill:#99aab5;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 -11.313,-11.313 c -0.027,-0.028 -0.037,-0.063 -0.06,-0.091 0.34,-0.571 0.814,-1.043 1.384,-1.384 0.029,0.023 0.063,0.033 0.09,0.059 L 1.415,-1.414 c 0.39,0.391 0.39,1.022 0,1.414 C 1.023,0.391 0.391,0.391 0,0"/></g><g transform="translate(36.4229,7.96)" id="g28"><path id="path30" style="fill:#99aab5;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 c -0.021,0.028 -0.033,0.063 -0.06,0.09 l -11.312,11.314 c -0.392,0.391 -1.024,0.391 -1.415,0 -0.391,-0.391 -0.391,-1.023 0,-1.414 L -1.474,-1.324 c 0.027,-0.027 0.062,-0.037 0.09,-0.06 C -0.812,-1.044 -0.34,-0.57 0,0"/></g><g transform="translate(33,32)" id="g32"><path id="path34" style="fill:#99aab5;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 -28,0 c -2.209,0 -4,-1.791 -4,-4 l 0,-1.03 14.528,-14.495 c 1.894,-1.894 4.988,-1.894 6.884,0 L 4,-5.009 4,-4 C 4,-1.791 2.209,0 0,0"/></g><g transform="translate(33,32)" id="g36"><path id="path38" style="fill:#e1e8ed;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 -28,0 c -1.588,0 -2.949,-0.934 -3.595,-2.275 l 14.766,-14.767 c 1.562,-1.562 4.096,-1.562 5.657,0 L 3.595,-2.275 C 2.949,-0.934 1.589,0 0,0"/></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -2,6 +2,21 @@ require 'rails_helper'
describe InvitesController do
context '.show' do
it "shows error if invite not found" do
get :show, id: 'nopeNOPEnope'
expect(response).to render_template(layout: 'no_ember')
expect(flash[:error]).to be_present
end
it "renders the accept invite page if invite exists" do
i = Fabricate(:invite)
get :show, id: i.invite_key
expect(response).to render_template(layout: 'application')
expect(flash[:error]).to be_nil
end
end
context '.destroy' do
it 'requires you to be logged in' do
@ -127,12 +142,14 @@ describe InvitesController do
context 'with an invalid invite id' do
before do
put :perform_accept_invitation, id: "doesn't exist"
xhr :put, :perform_accept_invitation, id: "doesn't exist", format: :json
end
it "redirects to the root" do
expect(response).to be_success
expect(flash[:error]).to be_present
json = JSON.parse(response.body)
expect(json["success"]).to eq(false)
expect(json["message"]).to eq(I18n.t('invite.not_found'))
end
it "should not change the session" do
@ -145,12 +162,14 @@ describe InvitesController do
let(:invite) { topic.invite_by_email(topic.user, "iceking@adventuretime.ooo") }
let(:deleted_invite) { invite.destroy; invite }
before do
put :perform_accept_invitation, id: deleted_invite.invite_key
xhr :put, :perform_accept_invitation, id: deleted_invite.invite_key, format: :json
end
it "redirects to the root" do
expect(response).to be_success
expect(flash[:error]).to be_present
json = JSON.parse(response.body)
expect(json["success"]).to eq(false)
expect(json["message"]).to eq(I18n.t('invite.not_found'))
end
it "should not change the session" do
@ -164,24 +183,43 @@ describe InvitesController do
it 'redeems the invite' do
Invite.any_instance.expects(:redeem)
put :perform_accept_invitation, id: invite.invite_key
xhr :put, :perform_accept_invitation, id: invite.invite_key, format: :json
end
context 'when redeem returns a user' do
let(:user) { Fabricate(:coding_horror) }
context 'success' do
subject { xhr :put, :perform_accept_invitation, id: invite.invite_key, format: :json }
before do
Invite.any_instance.expects(:redeem).returns(user)
put :perform_accept_invitation, id: invite.invite_key
end
it 'logs in the user' do
subject
expect(session[:current_user_id]).to eq(user.id)
end
it 'redirects to the first topic the user was invited to' do
expect(response).to redirect_to(topic.relative_url)
subject
json = JSON.parse(response.body)
expect(json["success"]).to eq(true)
expect(json["redirect_to"]).to eq(topic.relative_url)
end
end
context 'failure' do
subject { xhr :put, :perform_accept_invitation, id: invite.invite_key, format: :json }
it "doesn't log in the user if there's a validation error" do
user.errors.add(:password, :common)
Invite.any_instance.expects(:redeem).raises(ActiveRecord::RecordInvalid.new(user))
subject
expect(response).to be_success
json = JSON.parse(response.body)
expect(json["success"]).to eq(false)
expect(json["errors"]["password"]).to be_present
end
end
@ -194,12 +232,12 @@ describe InvitesController do
it 'sends a welcome message if set' do
user.send_welcome_message = true
user.expects(:enqueue_welcome_message).with('welcome_invite')
put :perform_accept_invitation, id: invite.invite_key
xhr :put, :perform_accept_invitation, id: invite.invite_key, format: :json
end
it "doesn't send a welcome message if not set" do
user.expects(:enqueue_welcome_message).with('welcome_invite').never
put :perform_accept_invitation, id: invite.invite_key
xhr :put, :perform_accept_invitation, id: invite.invite_key, format: :json
end
end
end

View File

@ -3,20 +3,38 @@ require 'rails_helper'
describe InviteRedeemer do
describe '#create_user_from_invite' do
let(:user) { InviteRedeemer.create_user_from_invite(Fabricate(:invite, email: 'walter.white@email.com'), 'walter', 'Walter White') }
it "should be created correctly" do
user = InviteRedeemer.create_user_from_invite(Fabricate(:invite, email: 'walter.white@email.com'), 'walter', 'Walter White')
expect(user.username).to eq('walter')
expect(user.name).to eq('Walter White')
expect(user).to be_active
expect(user.email).to eq('walter.white@email.com')
end
it "can set the password too" do
password = 's3cure5tpasSw0rD'
user = InviteRedeemer.create_user_from_invite(Fabricate(:invite, email: 'walter.white@email.com'), 'walter', 'Walter White', password)
expect(user).to have_password
expect(user.confirm_password?(password)).to eq(true)
end
it "raises exception with record and errors" do
error = nil
begin
InviteRedeemer.create_user_from_invite(Fabricate(:invite, email: 'walter.white@email.com'), 'walter', 'Walter White', 'aaa')
rescue ActiveRecord::RecordInvalid => e
error = e
end
expect(error).to be_present
expect(error.record.errors[:password]).to be_present
end
end
describe "#redeem" do
let(:invite) { Fabricate(:invite) }
let(:name) { 'john snow' }
let(:username) { 'kingofthenorth' }
let(:password) { 'know5nOthiNG'}
let(:invite_redeemer) { InviteRedeemer.new(invite, username, name) }
it "should redeem the invite" do
@ -39,5 +57,12 @@ describe InviteRedeemer do
expect(user.username).to eq(username)
expect(user.invited_by).to eq(nil)
end
it "can set password" do
inviter = invite.invited_by
user = InviteRedeemer.new(invite, username, name, password).redeem
expect(user).to have_password
expect(user.confirm_password?(password)).to eq(true)
end
end
end

View File

@ -0,0 +1,40 @@
import { acceptance } from "helpers/qunit-helpers";
import PreloadStore from 'preload-store';
acceptance("Invite Accept");
test("Invite Acceptance Page", () => {
PreloadStore.store('invite_info', {
invited_by: {"id":123,"username":"neil","avatar_template":"/user_avatar/localhost/neil/{size}/25_1.png","name":"Neil Lalonde","title":"team"},
email: "invited@asdf.com",
username: "invited"
});
visit("/invites/myvalidinvitetoken");
andThen(() => {
ok(exists("#new-account-username"), "shows the username input");
equal(find("#new-account-username").val(), "invited", "username is prefilled");
ok(exists("#new-account-password"), "shows the password input");
not(exists('.invites-show .btn-primary:disabled'), 'submit is enabled');
});
fillIn("#new-account-username", 'a');
andThen(() => {
ok(exists(".username-input .bad"), "username is not valid");
ok(exists('.invites-show .btn-primary:disabled'), 'submit is disabled');
});
fillIn("#new-account-password", 'aaa');
andThen(() => {
ok(exists(".password-input .bad"), "password is not valid");
ok(exists('.invites-show .btn-primary:disabled'), 'submit is disabled');
});
fillIn("#new-account-username", 'validname');
fillIn("#new-account-password", 'secur3ty4Y0uAndMe');
andThen(() => {
ok(exists(".username-input .good"), "username is valid");
ok(exists(".password-input .good"), "password is valid");
not(exists('.invites-show .btn-primary:disabled'), 'submit is enabled');
});
});