mirror of
https://github.com/discourse/discourse.git
synced 2024-11-25 09:42:07 +08:00
FEATURE: new invite acceptance page, where username can be chosen and password can be set
This commit is contained in:
parent
3818c196e0
commit
d0fbb27f3e
|
@ -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 => {
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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 );
|
||||
}
|
||||
});
|
|
@ -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' });
|
||||
});
|
||||
}
|
||||
|
|
13
app/assets/javascripts/discourse/routes/invites-show.js.es6
Normal file
13
app/assets/javascripts/discourse/routes/invites-show.js.es6
Normal 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));
|
||||
}
|
||||
}
|
||||
});
|
1
app/assets/javascripts/discourse/templates/invites.hbs
Normal file
1
app/assets/javascripts/discourse/templates/invites.hbs
Normal file
|
@ -0,0 +1 @@
|
|||
{{outlet}}
|
56
app/assets/javascripts/discourse/templates/invites/show.hbs
Normal file
56
app/assets/javascripts/discourse/templates/invites/show.hbs
Normal 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"}}
|
||||
{{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}}
|
||||
{{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>
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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:
|
||||
|
|
1
public/images/envelope.svg
Normal file
1
public/images/envelope.svg
Normal 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 |
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
40
test/javascripts/acceptance/invite-accept-test.js.es6
Normal file
40
test/javascripts/acceptance/invite-accept-test.js.es6
Normal 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');
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user