Skip to content
This repository has been archived by the owner on Nov 28, 2022. It is now read-only.

Commit

Permalink
move to a separate Invite model
Browse files Browse the repository at this point in the history
issue TryGhost/Ghost#7420, requires TryGhost/Ghost#7422
- adds a new `Invite` model with associated serializer and test setup
- updates team screen to use invites rather than existing users with the "invited" property
- updates signup process to work with new invite model
- updates setup process to create invites instead of users
- swaps usage of `gh-select-native` for `one-way-select` in the invite modal so that attributes can be set on the `select` element
- updates resend invite process to account for server returning a new model
- rewrites the invite management tests and fixes mirage mocks for invite endpoints
- sorts invites by email address to avoid jumping invites when re-sending
  • Loading branch information
kevinansfield committed Sep 23, 2016
1 parent bb63ee3 commit e115bbd
Show file tree
Hide file tree
Showing 17 changed files with 589 additions and 222 deletions.
60 changes: 37 additions & 23 deletions app/components/gh-user-invited.js
@@ -1,36 +1,48 @@
import Component from 'ember-component';
import computed from 'ember-computed';
import service from 'ember-service/inject';
import {isNotFoundError} from 'ember-ajax/errors';

export default Component.extend({
tagName: '',

user: null,
invite: null,
isSending: false,

notifications: service(),
store: service(),

createdAtUTC: computed('user.createdAtUTC', function () {
let createdAtUTC = this.get('user.createdAtUTC');
createdAt: computed('invite.createdAtUTC', function () {
let createdAtUTC = this.get('invite.createdAtUTC');

return createdAtUTC ? moment(createdAtUTC).fromNow() : '';
}),

expiresAt: computed('invite.expires', function () {
let expires = this.get('invite.expires');

return expires ? moment(expires).fromNow() : '';
}),

actions: {
resend() {
let user = this.get('user');
let invite = this.get('invite');
let notifications = this.get('notifications');

this.set('isSending', true);
user.resendInvite().then((result) => {
let notificationText = `Invitation resent! (${user.get('email')})`;
invite.resend().then((result) => {
let notificationText = `Invitation resent! (${invite.get('email')})`;

// the server deletes the old record and creates a new one when
// resending so we need to update the store accordingly
invite.unloadRecord();
this.get('store').pushPayload('invite', result);

// If sending the invitation email fails, the API will still return a status of 201
// but the user's status in the response object will be 'invited-pending'.
if (result.users[0].status === 'invited-pending') {
// but the invite's status in the response object will be 'invited-pending'.
if (result.invites[0].status === 'invited-pending') {
notifications.showAlert('Invitation email was not sent. Please try resending.', {type: 'error', key: 'invite.resend.not-sent'});
} else {
user.set('status', result.users[0].status);
notifications.showNotification(notificationText, {key: 'invite.resend.success'});
}
}).catch((error) => {
Expand All @@ -41,23 +53,25 @@ export default Component.extend({
},

revoke() {
let user = this.get('user');
let email = user.get('email');
let invite = this.get('invite');
let email = invite.get('email');
let notifications = this.get('notifications');

// reload the user to get the most up-to-date information
user.reload().then(() => {
if (user.get('invited')) {
user.destroyRecord().then(() => {
let notificationText = `Invitation revoked. (${email})`;
notifications.showNotification(notificationText, {key: 'invite.revoke.success'});
}).catch((error) => {
notifications.showAPIError(error, {key: 'invite.revoke'});
});
} else {
// if the user is no longer marked as "invited", then show a warning and reload the route
// reload the invite to get the most up-to-date information
invite.reload().then(() => {
invite.destroyRecord().then(() => {
let notificationText = `Invitation revoked. (${email})`;
notifications.showNotification(notificationText, {key: 'invite.revoke.success'});
}).catch((error) => {
notifications.showAPIError(error, {key: 'invite.revoke'});
});
}).catch((error) => {
if (isNotFoundError(error)) {
// if the invite no longer exists, then show a warning and reload the route
this.sendAction('reload');
notifications.showAlert('This user has already accepted the invitation.', {type: 'error', delayed: true, key: 'invite.revoke.already-accepted'});
notifications.showAlert('This invite has been revoked or a user has already accepted the invitation.', {type: 'error', delayed: true, key: 'invite.revoke.already-accepted'});
} else {
throw error;
}
});
}
Expand Down
33 changes: 18 additions & 15 deletions app/components/modals/invite-new-user.js
Expand Up @@ -53,15 +53,19 @@ export default ModalComponent.extend(ValidationEngine, {
// the API should return an appropriate error when attempting to save
return new Promise((resolve, reject) => {
return this._super().then(() => {
this.get('store').findAll('user', {reload: true}).then((result) => {
let invitedUser = result.findBy('email', email);

if (invitedUser) {
return RSVP.hash({
users: this.get('store').findAll('user', {reload: true}),
invites: this.get('store').findAll('invite', {reload: true})
}).then((data) => {
let existingUser = data.users.findBy('email', email);
let existingInvite = data.invites.findBy('email', email);

if (existingUser || existingInvite) {
this.get('errors').clear('email');
if (invitedUser.get('status') === 'invited' || invitedUser.get('status') === 'invited-pending') {
this.get('errors').add('email', 'A user with that email address was already invited.');
} else {
if (existingUser) {
this.get('errors').add('email', 'A user with that email address already exists.');
} else {
this.get('errors').add('email', 'A user with that email address was already invited.');
}

// TODO: this shouldn't be needed, ValidationEngine doesn't mark
Expand Down Expand Up @@ -90,29 +94,28 @@ export default ModalComponent.extend(ValidationEngine, {
let email = this.get('email');
let role = this.get('role');
let notifications = this.get('notifications');
let newUser;
let invite;

this.validate().then(() => {
this.set('submitting', true);

newUser = this.get('store').createRecord('user', {
invite = this.get('store').createRecord('invite', {
email,
role,
status: 'invited'
role
});

newUser.save().then(() => {
invite.save().then(() => {
let notificationText = `Invitation sent! (${email})`;

// If sending the invitation email fails, the API will still return a status of 201
// but the user's status in the response object will be 'invited-pending'.
if (newUser.get('status') === 'invited-pending') {
// but the invite's status in the response object will be 'invited-pending'.
if (invite.get('status') === 'pending') {
notifications.showAlert('Invitation email was not sent. Please try resending.', {type: 'error', key: 'invite.send.failed'});
} else {
notifications.showNotification(notificationText, {key: 'invite.send.success'});
}
}).catch((error) => {
newUser.deleteRecord();
invite.deleteRecord();
notifications.showAPIError(error, {key: 'invite.send'});
}).finally(() => {
this.send('closeModal');
Expand Down
7 changes: 3 additions & 4 deletions app/controllers/setup/three.js
Expand Up @@ -164,16 +164,15 @@ export default Controller.extend({
this.get('authorRole').then((authorRole) => {
RSVP.Promise.all(
users.map((user) => {
let newUser = this.store.createRecord('user', {
let invite = this.store.createRecord('invite', {
email: user,
status: 'invited',
role: authorRole
});

return newUser.save().then(() => {
return invite.save().then(() => {
return {
email: user,
success: newUser.get('status') === 'invited'
success: invite.get('status') === 'sent'
};
}).catch(() => {
return {
Expand Down
16 changes: 5 additions & 11 deletions app/controllers/team/index.js
@@ -1,24 +1,18 @@
import Controller from 'ember-controller';
import {alias, filter} from 'ember-computed';
import injectService from 'ember-service/inject';
import {sort} from 'ember-computed';

export default Controller.extend({

showInviteUserModal: false,

users: alias('model'),
users: null,
invites: null,

session: injectService(),

activeUsers: filter('users', function (user) {
return /^active|warn-[1-4]|locked$/.test(user.get('status'));
}),

invitedUsers: filter('users', function (user) {
let status = user.get('status');

return status === 'invited' || status === 'invited-pending';
}),
inviteOrder: ['email'],
sortedInvites: sort('invites', 'inviteOrder'),

actions: {
toggleInviteUserModal() {
Expand Down
3 changes: 3 additions & 0 deletions app/mirage/config.js
@@ -1,4 +1,5 @@
import mockAuthentication from './config/authentication';
import mockInvites from './config/invites';
import mockPosts from './config/posts';
import mockRoles from './config/roles';
import mockSettings from './config/settings';
Expand All @@ -19,6 +20,7 @@ export default function () {
// this.put('/posts/:id/', versionMismatchResponse);
// mockSubscribers(this);
this.loadFixtures('settings');
mockInvites(this);
mockSettings(this);
mockThemes(this);

Expand All @@ -38,6 +40,7 @@ export function testConfig() {
// this.logging = true;

mockAuthentication(this);
mockInvites(this);
mockPosts(this);
mockRoles(this);
mockSettings(this);
Expand Down
58 changes: 58 additions & 0 deletions app/mirage/config/invites.js
@@ -0,0 +1,58 @@
import Mirage from 'ember-cli-mirage';
import {paginatedResponse} from '../utils';

export default function mockInvites(server) {
server.get('/invites/', function (db, request) {
let response = paginatedResponse('invites', db.invites, request);
return response;
});

server.get('/invites/:id', function (db, request) {
let {id} = request.params;
let invite = db.invites.find(id);

if (!invite) {
return new Mirage.Response(404, {}, {
errors: [{
errorType: 'NotFoundError',
message: 'Invite not found.'
}]
});
} else {
return {invites: [invite]};
}
});

server.post('/invites/', function (db, request) {
let [attrs] = JSON.parse(request.requestBody).invites;
let [oldInvite] = db.invites.where({email: attrs.email});

if (oldInvite) {
// resend - server deletes old invite and creates a new one with new ID
attrs.id = db.invites[db.invites.length - 1].id + 1;
db.invites.remove(oldInvite.id);
}

/* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */
attrs.token = `${db.invites.length}-token`;
attrs.expires = moment.utc().add(1, 'day').unix();
attrs.created_at = moment.utc().format();
attrs.created_by = 1;
attrs.updated_at = moment.utc().format();
attrs.updated_by = 1;
attrs.status = 'sent';
/* jscs:enable requireCamelCaseOrUpperCaseIdentifiers */

let invite = db.invites.insert(attrs);

return {
invites: [invite]
};
});

server.del('/invites/:id/', function (db, request) {
db.invites.remove(request.params.id);

return new Mirage.Response(204, {}, {});
});
}
14 changes: 14 additions & 0 deletions app/mirage/factories/invite.js
@@ -0,0 +1,14 @@
/* jscs:disable */
import Mirage from 'ember-cli-mirage';

export default Mirage.Factory.extend({
token(i) { return `${i}-token`; },
email(i) { return `invited-user-${i}@example.com`; },
expires() { return moment.utc().add(1, 'day').unix(); },
created_at() { return moment.utc().format(); },
created_by() { return 1; },
updated_at() { return moment.utc().format(); },
updated_by() { return 1; },
status() { return 'sent'; },
roles() { return []; }
});
50 changes: 50 additions & 0 deletions app/models/invite.js
@@ -0,0 +1,50 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import {hasMany} from 'ember-data/relationships';
import computed from 'ember-computed';
import injectService from 'ember-service/inject';

export default Model.extend({
token: attr('string'),
email: attr('string'),
expires: attr('number'),
createdAtUTC: attr('moment-utc'),
createdBy: attr('number'),
updatedAtUTC: attr('moment-utc'),
updatedBy: attr('number'),
status: attr('string'),
roles: hasMany('role', {
embedded: 'always',
async: false
}),

ajax: injectService(),
ghostPaths: injectService(),

role: computed('roles', {
get() {
return this.get('roles.firstObject');
},
set(key, value) {
// Only one role per user, so remove any old data.
this.get('roles').clear();
this.get('roles').pushObject(value);

return value;
}
}),

resend() {
let fullInviteData = this.toJSON();
let inviteData = {
email: fullInviteData.email,
roles: fullInviteData.roles
};
let inviteUrl = this.get('ghostPaths.url').api('invites');

return this.get('ajax').post(inviteUrl, {
data: JSON.stringify({invites: [inviteData]}),
contentType: 'application/json'
});
}
});
22 changes: 1 addition & 21 deletions app/models/user.js
Expand Up @@ -58,12 +58,6 @@ export default Model.extend(ValidationEngine, {
return ['active', 'warn-1', 'warn-2', 'warn-3', 'warn-4', 'locked'].indexOf(this.get('status')) > -1;
}),

invited: computed('status', function () {
return ['invited', 'invited-pending'].indexOf(this.get('status')) > -1;
}),

pending: equal('status', 'invited-pending'),

role: computed('roles', {
get() {
return this.get('roles.firstObject');
Expand Down Expand Up @@ -116,19 +110,5 @@ export default Model.extend(ValidationEngine, {
} catch (error) {
this.get('notifications').showAPIError(error, {key: 'user.change-password'});
}
}).drop(),

resendInvite() {
let fullUserData = this.toJSON();
let userData = {
email: fullUserData.email,
roles: fullUserData.roles
};
let inviteUrl = this.get('ghostPaths.url').api('users');

return this.get('ajax').post(inviteUrl, {
data: JSON.stringify({users: [userData]}),
contentType: 'application/json'
});
}
}).drop()
});

0 comments on commit e115bbd

Please sign in to comment.