Separate invites and users (#277)

closes https://github.com/TryGhost/Ghost/issues/7420, requires https://github.com/TryGhost/Ghost/pull/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
This commit is contained in:
Kevin Ansfield 2016-09-26 18:03:53 +02:00 committed by Katharina Irrgang
parent 91d6097d9e
commit 467ee93b21
17 changed files with 597 additions and 230 deletions

View File

@ -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) => {
@ -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;
}
});
}

View File

@ -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);
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 (invitedUser) {
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
@ -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');

View File

@ -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 {

View File

@ -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() {

View File

@ -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';
@ -19,6 +20,7 @@ export default function () {
// this.put('/posts/:id/', versionMismatchResponse);
// mockSubscribers(this);
this.loadFixtures('settings');
mockInvites(this);
mockSettings(this);
mockThemes(this);
@ -38,6 +40,7 @@ export function testConfig() {
// this.logging = true;
mockAuthentication(this);
mockInvites(this);
mockPosts(this);
mockRoles(this);
mockSettings(this);

View File

@ -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, {}, {});
});
}

View File

@ -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 []; }
});

View File

@ -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'
});
}
});

View File

@ -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');
@ -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()
});

View File

@ -2,6 +2,8 @@ import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings';
import PaginationMixin from 'ghost-admin/mixins/pagination';
import styleBody from 'ghost-admin/mixins/style-body';
import RSVP from 'rsvp';
import {isBlank} from 'ember-utils';
export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, PaginationMixin, {
titleToken: 'Team',
@ -10,20 +12,37 @@ export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, Paginat
paginationModel: 'user',
paginationSettings: {
status: 'active',
status: 'all',
limit: 20
},
model() {
this.loadFirstPage();
return this.get('session.user').then((user) => {
let modelPromises = {
users: this.loadFirstPage().then(() => {
return this.store.filter('user', (user) => {
return !user.get('isNew') && !isBlank(user.get('status'));
});
})
};
return this.store.query('user', {limit: 'all', status: 'invited'}).then(() => {
return this.store.filter('user', () => {
return true;
});
// authors do not have permission to hit the invites endpoint
if (!user.get('isAuthor')) {
modelPromises.invites = this.store.query('invite', {limit: 'all'}).then(() => {
return this.store.filter('invite', (invite) => {
return !invite.get('isNew');
});
});
}
return RSVP.hash(modelPromises);
});
},
setupController(controller, models) {
controller.setProperties(models);
},
actions: {
reload() {
this.refresh();

View File

@ -0,0 +1,10 @@
import ApplicationSerializer from 'ghost-admin/serializers/application';
import EmbeddedRecordsMixin from 'ember-data/serializers/embedded-records-mixin';
export default ApplicationSerializer.extend(EmbeddedRecordsMixin, {
attrs: {
roles: {embedded: 'always'},
createdAtUTC: {key: 'created_at'},
updatedAtUTC: {key: 'updated_at'}
}
});

View File

@ -27,12 +27,14 @@
<div class="form-group for-select">
<label for="new-user-role">Role</label>
<span class="gh-select" tabindex="0">
{{gh-select-native id="new-user-role"
content=roles
{{one-way-select
id="new-user-role"
name="role"
options=roles
optionValuePath="id"
optionLabelPath="name"
selection=role
action="setRole"
value=role
update=(action "setRole")
}}
</span>
</div>

View File

@ -23,22 +23,23 @@
}}
{{!-- Do not show invited users to authors --}}
{{#unless session.user.isAuthor}}
{{#if invitedUsers}}
{{#if invites}}
<section class="user-list invited-users">
<h4 class="user-list-title">Invited users</h4>
{{#each invitedUsers as |user|}}
{{#gh-user-invited user=user reload="reload" as |component|}}
{{#each sortedInvites as |invite|}}
{{#gh-user-invited invite=invite reload="reload" as |component|}}
<div class="user-list-item">
<span class="user-list-item-icon icon-mail">ic</span>
<div class="user-list-item-body">
<span class="name">{{user.email}}</span><br>
{{#if user.pending}}
<span class="name">{{invite.email}}</span><br>
{{#if invite.pending}}
<span class="description-error">
Invitation not sent - please try again
</span>
{{else}}
<span class="description">
Invitation sent: {{component.createdAtUTC}}
Invitation sent: {{component.createdAt}},
expires {{component.expiresAt}}
</span>
{{/if}}
</div>
@ -46,13 +47,13 @@
{{#if component.isSending}}
<span>Sending Invite...</span>
{{else}}
<a class="user-list-action" href="#" {{action "revoke" target=component}}>
<a class="user-list-action" href="#revoke" {{action "revoke" target=component}}>
Revoke
</a>
<a class="user-list-action" href="#" {{action "resend" target=component}}>
<a class="user-list-action" href="#resend" {{action "resend" target=component}}>
Resend
</a>
{{#each user.roles as |role|}}
{{#each invite.roles as |role|}}
<span class="role-label {{role.lowerCaseName}}">{{role.name}}</span>
{{/each}}
{{/if}}
@ -66,7 +67,7 @@
<section class="user-list active-users">
<h4 class="user-list-title">Active users</h4>
{{#each activeUsers key="id" as |user|}}
{{#each users key="id" as |user|}}
{{!-- For authors only shows users as a list, otherwise show users with links to user page --}}
{{#unless session.user.isAuthor}}
{{#gh-user-active user=user as |component|}}

View File

@ -266,13 +266,13 @@ describe('Acceptance: Setup', function () {
it('handles validation errors in step 3', function () {
let input = '[name="users"]';
let postCount = 0;
let button, formGroup, user;
let button, formGroup, invite;
invalidateSession(application);
server.loadFixtures('roles');
server.post('/users', function (db, request) {
let [params] = JSON.parse(request.requestBody).users;
server.post('/invites', function (db, request) {
let [params] = JSON.parse(request.requestBody).invites;
postCount++;
@ -288,10 +288,21 @@ describe('Acceptance: Setup', function () {
});
}
// TODO: duplicated from mirage/config/invites - extract method?
/* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */
params.token = `${db.invites.length}-token`;
params.expires = moment.utc().add(1, 'day').unix();
params.created_at = moment.utc().format();
params.created_by = 1;
params.updated_at = moment.utc().format();
params.updated_by = 1;
params.status = 'sent';
/* jscs:enable requireCamelCaseOrUpperCaseIdentifiers */
// valid
user = db.users.insert(params);
invite = db.invites.insert(params);
return {
users: [user]
invites: [invite]
};
});

View File

@ -61,12 +61,12 @@ describe('Acceptance: Team', function () {
});
});
describe('when logged in', function () {
let admin;
describe('when logged in as admin', function () {
let admin, adminRole;
beforeEach(function () {
let role = server.create('role', {name: 'Administrator'});
admin = server.create('user', {roles: [role]});
adminRole = server.create('role', {name: 'Administrator'});
admin = server.create('user', {email: 'admin@example.com', roles: [adminRole]});
server.loadFixtures();
@ -113,127 +113,242 @@ describe('Acceptance: Team', function () {
});
});
describe('invite new user', function () {
it('can manage invites', function () {
let emailInputField = '.fullscreen-modal input[name="email"]';
// @TODO: Evaluate after the modal PR goes in
it('modal loads correctly', function () {
visit('/team');
visit('/team');
andThen(() => {
// url is correct
expect(currentURL(), 'currentURL').to.equal('/team');
andThen(() => {
// invite user button exists
expect(
find('.view-actions .btn-green').text().trim(),
'invite people button text'
).to.equal('Invite People');
// invite user button exists
expect(find('.view-actions .btn-green').html(), 'invite people button text')
.to.equal('Invite People');
});
// existing users are listed
expect(
find('.user-list.active-users .user-list-item').length,
'initial number of active users'
).to.equal(1);
click('.view-actions .btn-green');
expect(
find('.user-list.active-users .user-list-item:first-of-type .role-label').text().trim(),
'active user\'s role label'
).to.equal('Administrator');
andThen(() => {
let roleOptions = find('#new-user-role select option');
function checkOwnerExists() {
for (let i in roleOptions) {
if (roleOptions[i].tagName === 'option' && roleOptions[i].text === 'Owner') {
return true;
}
}
return false;
}
function checkSelectedIsAuthor() {
for (let i in roleOptions) {
if (roleOptions[i].selected) {
return roleOptions[i].text === 'Author';
}
}
return false;
}
// should be 3 available roles
expect(roleOptions.length, 'number of available roles').to.equal(3);
expect(checkOwnerExists(), 'owner role isn\'t available').to.be.false;
expect(checkSelectedIsAuthor(), 'author role is selected initially').to.be.true;
});
// no invites are shown
expect(
find('.user-list.invited-users .user-list-item').length,
'initial number of invited users'
).to.equal(0);
});
it('sends an invite correctly', function () {
visit('/team');
// click the invite people button
click('.view-actions .btn-green');
andThen(() => {
expect(find('.user-list.invited-users .user-list-item').length, 'number of invited users').to.equal(0);
});
andThen(() => {
let roleOptions = find('.fullscreen-modal select[name="role"] option');
click('.view-actions .btn-green');
click(emailInputField);
triggerEvent(emailInputField, 'blur');
function checkOwnerExists() {
for (let i in roleOptions) {
if (roleOptions[i].tagName === 'option' && roleOptions[i].text === 'Owner') {
return true;
}
}
return false;
}
andThen(() => {
expect(find('.modal-body .form-group:first').hasClass('error'), 'email input has error status').to.be.true;
expect(find('.modal-body .form-group:first .response').text()).to.contain('Please enter an email.');
});
function checkSelectedIsAuthor() {
for (let i in roleOptions) {
if (roleOptions[i].selected) {
return roleOptions[i].text === 'Author';
}
}
return false;
}
fillIn(emailInputField, 'test@example.com');
click('.fullscreen-modal .btn-green');
// modal is displayed
expect(
find('.fullscreen-modal h1').text().trim(),
'correct modal is displayed'
).to.equal('Invite a New User');
andThen(() => {
expect(find('.user-list.invited-users .user-list-item').length, 'number of invited users').to.equal(1);
expect(find('.user-list.invited-users .user-list-item:first .name').text(), 'name of invited user').to.equal('test@example.com');
});
// number of roles is correct
expect(
find('.fullscreen-modal select[name="role"] option').length,
'number of selectable roles'
).to.equal(3);
click('.user-list.invited-users .user-list-item:first .user-list-item-aside .user-list-action:contains("Revoke")');
andThen(() => {
expect(find('.user-list.invited-users .user-list-item').length, 'number of invited users').to.equal(0);
});
expect(checkOwnerExists(), 'owner role isn\'t available').to.be.false;
expect(checkSelectedIsAuthor(), 'author role is selected initially').to.be.true;
});
it('fails sending an invite correctly', function () {
server.create('user', {email: 'test1@example.com'});
server.create('user', {email: 'test2@example.com', status: 'invited'});
// submit valid invite form
fillIn('.fullscreen-modal input[name="email"]', 'invite1@example.com');
click('.fullscreen-modal .btn-green');
visit('/team');
andThen(() => {
// modal closes
expect(
find('.fullscreen-modal').length,
'number of modals after sending invite'
).to.equal(0);
// check our users lists are what we expect
andThen(() => {
expect(find('.user-list.invited-users .user-list-item').length, 'number of invited users')
.to.equal(1);
// number of active users is 2 because of the logged-in user
expect(find('.user-list.active-users .user-list-item').length, 'number of active users')
.to.equal(2);
});
// invite is displayed, has correct e-mail + role
expect(
find('.invited-users .user-list-item').length,
'number of invites after first invite'
).to.equal(1);
// click the "invite new user" button to open the modal
click('.view-actions .btn-green');
expect(
find('.invited-users span.name').first().text().trim(),
'displayed email of first invite'
).to.equal('invite1@example.com');
// fill in and submit the invite user modal with an existing user
fillIn(emailInputField, 'test1@example.com');
click('.fullscreen-modal .btn-green');
expect(
find('.invited-users span.role-label').first().text().trim(),
'displayed role of first invite'
).to.equal('Author');
andThen(() => {
// check the inline-validation
expect(find('.fullscreen-modal .error .response').text().trim(), 'inviting existing user error')
.to.equal('A user with that email address already exists.');
});
// number of users is unchanged
expect(
find('.active-users .user-list-item').length,
'number of active users after first invite'
).to.equal(1);
});
// fill in and submit the invite user modal with an invited user
fillIn(emailInputField, 'test2@example.com');
click('.fullscreen-modal .btn-green');
// submit new invite with different role
click('.view-actions .btn-green');
fillIn('.fullscreen-modal input[name="email"]', 'invite2@example.com');
fillIn('.fullscreen-modal select[name="role"]', '2');
click('.fullscreen-modal .btn-green');
andThen(() => {
// check the inline-validation
expect(find('.fullscreen-modal .error .response').text().trim(), 'inviting invited user error')
.to.equal('A user with that email address was already invited.');
andThen(() => {
// number of invites increases
expect(
find('.invited-users .user-list-item').length,
'number of invites after second invite'
).to.equal(2);
// ensure that there's been no change in our user lists
expect(find('.user-list.invited-users .user-list-item').length, 'number of invited users after failed invites')
.to.equal(1);
expect(find('.user-list.active-users .user-list-item').length, 'number of active users after failed invites')
.to.equal(2);
});
// invite has correct e-mail + role
expect(
find('.invited-users span.name').last().text().trim(),
'displayed email of second invite'
).to.equal('invite2@example.com');
expect(
find('.invited-users span.role-label').last().text().trim(),
'displayed role of second invite'
).to.equal('Editor');
});
// submit invite form with existing user
click('.view-actions .btn-green');
fillIn('.fullscreen-modal input[name="email"]', 'admin@example.com');
click('.fullscreen-modal .btn-green');
andThen(() => {
// validation message is displayed
expect(
find('.fullscreen-modal .error .response').text().trim(),
'inviting existing user error'
).to.equal('A user with that email address already exists.');
});
// submit invite form with existing invite
fillIn('.fullscreen-modal input[name="email"]', 'invite1@example.com');
click('.fullscreen-modal .btn-green');
andThen(() => {
// validation message is displayed
expect(
find('.fullscreen-modal .error .response').text().trim(),
'inviting invited user error'
).to.equal('A user with that email address was already invited.');
});
// submit invite form with an invalid email
fillIn('.fullscreen-modal input[name="email"]', 'test');
click('.fullscreen-modal .btn-green');
andThen(() => {
// validation message is displayed
expect(
find('.fullscreen-modal .error .response').text().trim(),
'inviting invalid email error'
).to.equal('Invalid Email.');
});
click('.fullscreen-modal a.close');
// revoke latest invite
click('.invited-users .user-list-item:last-of-type a[href="#revoke"]');
andThen(() => {
// number of invites decreases
expect(
find('.invited-users .user-list-item').length,
'number of invites after revoke'
).to.equal(1);
// notification is displayed
expect(
find('.gh-notification').text().trim(),
'notifications contain revoke'
).to.match(/Invitation revoked\. \(invite2@example\.com\)/);
// correct invite is removed
expect(
find('.invited-users span.name').text().trim(),
'displayed email of remaining invite'
).to.equal('invite1@example.com');
});
// add another invite to test ordering on resend
click('.view-actions .btn-green');
fillIn('.fullscreen-modal input[name="email"]', 'invite3@example.com');
click('.fullscreen-modal .btn-green');
andThen(() => {
// new invite should be last in the list
expect(
find('.invited-users span.name').last().text().trim(),
'last invite email in list'
).to.equal('invite3@example.com');
});
// resend first invite
click('.invited-users .user-list-item:first-of-type a[href="#resend"]');
andThen(() => {
// notification is displayed
expect(
find('.gh-notification').text().trim(),
'notifications contain resend'
).to.match(/Invitation resent! \(invite1@example\.com\)/);
// first invite is still at the top
expect(
find('.invited-users span.name').first().text().trim(),
'first invite email in list'
).to.equal('invite1@example.com');
});
// regression test: can revoke a resent invite
click('.invited-users .user-list-item:first-of-type a[href="#resend"]');
click('.invited-users .user-list-item:first-of-type a[href="#revoke"]');
andThen(() => {
// number of invites decreases
expect(
find('.invited-users .user-list-item').length,
'number of invites after resend/revoke'
).to.equal(1);
// notification is displayed
expect(
find('.gh-notification').text().trim(),
'notifications contain revoke after resend/revoke'
).to.match(/Invitation revoked\. \(invite1@example\.com\)/);
});
});
@ -665,4 +780,42 @@ describe('Acceptance: Team', function () {
});
});
});
describe('when logged in as author', function () {
let author, authorRole, adminRole;
beforeEach(function () {
adminRole = server.create('role', {name: 'Administrator'});
authorRole = server.create('role', {name: 'Author'});
author = server.create('user', {roles: [authorRole]});
server.loadFixtures();
server.get('/invites/', function () {
return new Mirage.Response(403, {}, {
errors: [{
errorType: 'NoPermissionError',
message: 'You do not have permission to perform this action'
}]
});
});
return authenticateSession(application);
});
it('can access the team page', function () {
let user1 = server.create('user', {roles: [adminRole]});
let invite1 = server.create('invite', {roles: [authorRole]});
errorOverride();
visit('/team');
andThen(() => {
errorReset();
expect(currentPath()).to.equal('team.index');
expect(find('.gh-alert').length).to.equal(0);
});
});
});
});

View File

@ -0,0 +1,87 @@
import { expect } from 'chai';
import { describeModel, it } from 'ember-mocha';
import run from 'ember-runloop';
import Pretender from 'pretender';
describeModel(
'invite',
'Unit: Model: invite',
{
needs: [
'model:role',
'serializer:application',
'serializer:invite',
'transform:moment-utc',
'service:ghost-paths',
'service:ajax',
'service:session',
'service:feature'
]
},
function() {
it('role property returns first role in array', function () {
let model = this.subject();
run(() => {
let role = this.store().push({data: {id: 1, type: 'role', attributes: {name: 'Author'}}});
model.get('roles').pushObject(role);
});
expect(model.get('role.name')).to.equal('Author');
run(() => {
let role = this.store().push({data: {id: 1, type: 'role', attributes: {name: 'Editor'}}});
model.set('role', role);
});
expect(model.get('role.name')).to.equal('Editor');
});
describe('with network', function () {
let server;
beforeEach(function () {
server = new Pretender();
});
afterEach(function () {
server.shutdown();
});
it('resend hits correct endpoint', function () {
let model = this.subject();
let role;
server.post('/ghost/api/v0.1/invites/', function () {
return [200, {}, '{}'];
});
run(() => {
role = this.store().push({data: {id: 1, type: 'role', attributes: {name: 'Editor'}}});
model.set('email', 'resend-test@example.com');
model.set('role', role);
model.resend();
});
expect(
server.handledRequests.length,
'number of requests'
).to.equal(1);
let [lastRequest] = server.handledRequests;
let requestBody = JSON.parse(lastRequest.requestBody);
let [invite] = requestBody.invites;
expect(
requestBody.invites.length,
'number of invites in request body'
).to.equal(1);
expect(invite.email).to.equal('resend-test@example.com');
expect(
invite.roles.length,
'number of roles in request body'
).to.equal(1);
expect(invite.roles[0], 'role ID').to.equal('1');
});
});
}
);

View File

@ -37,37 +37,6 @@ describeModel(
expect(model.get('active')).to.not.be.ok;
});
it('invited property is correct', function () {
let model = this.subject({
status: 'invited'
});
expect(model.get('invited')).to.be.ok;
run(() => { model.set('status', 'invited-pending'); });
expect(model.get('invited')).to.be.ok;
run(() => { model.set('status', 'active'); });
expect(model.get('invited')).to.not.be.ok;
run(() => { model.set('status', 'inactive'); });
expect(model.get('invited')).to.not.be.ok;
});
it('pending property is correct', function () {
let model = this.subject({
status: 'invited-pending'
});
expect(model.get('pending')).to.be.ok;
run(() => { model.set('status', 'invited'); });
expect(model.get('pending')).to.not.be.ok;
run(() => { model.set('status', 'inactive'); });
expect(model.get('pending')).to.not.be.ok;
});
it('role property is correct', function () {
let model = this.subject();