diff --git a/app/assets/javascripts/discourse/controllers/create-account.js.es6 b/app/assets/javascripts/discourse/controllers/create-account.js.es6 index 3f0d180c292..a3d4ef4aa47 100644 --- a/app/assets/javascripts/discourse/controllers/create-account.js.es6 +++ b/app/assets/javascripts/discourse/controllers/create-account.js.es6 @@ -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 => { diff --git a/app/assets/javascripts/discourse/controllers/invites-show.js.es6 b/app/assets/javascripts/discourse/controllers/invites-show.js.es6 new file mode 100644 index 00000000000..f6f5fa8150f --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/invites-show.js.es6 @@ -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; + }); + } + } +}); diff --git a/app/assets/javascripts/discourse/mixins/username-validation.js.es6 b/app/assets/javascripts/discourse/mixins/username-validation.js.es6 new file mode 100644 index 00000000000..1bbf64d9f23 --- /dev/null +++ b/app/assets/javascripts/discourse/mixins/username-validation.js.es6 @@ -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 ); + } +}); diff --git a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 index 6ea5b9bd5bd..99a2aa0886a 100644 --- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 +++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 @@ -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' }); + }); } diff --git a/app/assets/javascripts/discourse/routes/invites-show.js.es6 b/app/assets/javascripts/discourse/routes/invites-show.js.es6 new file mode 100644 index 00000000000..9acc266ced8 --- /dev/null +++ b/app/assets/javascripts/discourse/routes/invites-show.js.es6 @@ -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)); + } + } +}); diff --git a/app/assets/javascripts/discourse/templates/invites.hbs b/app/assets/javascripts/discourse/templates/invites.hbs new file mode 100644 index 00000000000..e2147cab02d --- /dev/null +++ b/app/assets/javascripts/discourse/templates/invites.hbs @@ -0,0 +1 @@ +{{outlet}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/invites/show.hbs b/app/assets/javascripts/discourse/templates/invites/show.hbs new file mode 100644 index 00000000000..7c75e8bda24 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/invites/show.hbs @@ -0,0 +1,56 @@ +
+ +

{{welcomeTitle}}

+ +
+
+ +
+ +
+

{{i18n 'invites.invited_by'}}

+ +

{{user-info user=invitedBy}}

+ + {{#if successMessage}} +

{{successMessage}}

+ {{else}} +

{{i18n 'invites.form_instructions'}}

+ + {{#if externalAuthsEnabled}} +

{{i18n 'invites.social_login_available'}}

+ {{/if}} + +
+ + +
+ {{input value=accountUsername id="new-account-username" name="username" maxlength=maxUsernameLength autocomplete="off"}} +  {{input-tip validation=usernameValidation id="username-validation"}} +
+ + + +
+ {{password-field value=accountPassword type="password" id="new-account-password" capsLockOn=capsLockOn}} +  {{input-tip validation=passwordValidation}} +
+ +
+
{{i18n 'login.caps_lock_warning'}}
+
+ +

{{{yourEmailMessage}}}

+ + + + {{#if errorMessage}} +

+
{{errorMessage}}
+ {{/if}} + +
+ {{/if}} +
+
+
diff --git a/app/assets/stylesheets/common/base/login.scss b/app/assets/stylesheets/common/base/login.scss index e8eb255b2a4..7fbc598b424 100644 --- a/app/assets/stylesheets/common/base/login.scss +++ b/app/assets/stylesheets/common/base/login.scss @@ -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 diff --git a/app/assets/stylesheets/desktop/login.scss b/app/assets/stylesheets/desktop/login.scss index 0e349367ca5..8eafec8f207 100644 --- a/app/assets/stylesheets/desktop/login.scss +++ b/app/assets/stylesheets/desktop/login.scss @@ -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; + } + } +} diff --git a/app/assets/stylesheets/mobile/login.scss b/app/assets/stylesheets/mobile/login.scss index aaf5cee0dff..41ba55d67bd 100644 --- a/app/assets/stylesheets/mobile/login.scss +++ b/app/assets/stylesheets/mobile/login.scss @@ -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; + } +} \ No newline at end of file diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index ca2aaa3a6a7..e4990e0af16 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -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 diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index cc8ef2860f1..b06e6721efa 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -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 diff --git a/app/models/invite.rb b/app/models/invite.rb index e3e16c8ff49..c83fb7c66ea 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -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) diff --git a/app/models/invite_redeemer.rb b/app/models/invite_redeemer.rb index 05e5e31c32b..e16f66ecd85 100644 --- a/app/models/invite_redeemer.rb +++ b/app/models/invite_redeemer.rb @@ -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 diff --git a/app/views/invites/show.html.erb b/app/views/invites/show.html.erb index 1fdd39e3ee3..cc4b0acc1e7 100644 --- a/app/views/invites/show.html.erb +++ b/app/views/invites/show.html.erb @@ -3,11 +3,5 @@
<%=flash[:error]%>
- <%else%> -

<%= t 'activation.welcome_to', site_name: SiteSetting.title %>

-
- <%= button_to(perform_accept_invite_path, method: :put, class: 'btn btn-primary') do %> - <%= t 'invite.accept_invite' %> - <% end %> <%end%> diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index adced32c71c..7eee6facba0 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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 %{email}." + accept_invite: "Accept Invitation" + success: "Your account has been created and you're now logged in." password_reset: continue: "Continue to %{site_name}" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 090b3bca008..c73371c9040 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -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: diff --git a/public/images/envelope.svg b/public/images/envelope.svg new file mode 100644 index 00000000000..6400f5af466 --- /dev/null +++ b/public/images/envelope.svg @@ -0,0 +1 @@ +image/svg+xml diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb index 6c9ace61b2a..146a007753f 100644 --- a/spec/controllers/invites_controller_spec.rb +++ b/spec/controllers/invites_controller_spec.rb @@ -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 diff --git a/spec/models/invite_redeemer_spec.rb b/spec/models/invite_redeemer_spec.rb index 4b5d1bcc447..c06b11e73d0 100644 --- a/spec/models/invite_redeemer_spec.rb +++ b/spec/models/invite_redeemer_spec.rb @@ -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 diff --git a/test/javascripts/acceptance/invite-accept-test.js.es6 b/test/javascripts/acceptance/invite-accept-test.js.es6 new file mode 100644 index 00000000000..2a58c89e9b8 --- /dev/null +++ b/test/javascripts/acceptance/invite-accept-test.js.es6 @@ -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'); + }); +});