diff --git a/ghost/admin/app/components/gh-user-invited.js b/ghost/admin/app/components/gh-user-invited.js index c7918a8dc4..badf662c83 100644 --- a/ghost/admin/app/components/gh-user-invited.js +++ b/ghost/admin/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) => { @@ -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; } }); } diff --git a/ghost/admin/app/components/modals/invite-new-user.js b/ghost/admin/app/components/modals/invite-new-user.js index 06984c2c67..9cc85c347a 100644 --- a/ghost/admin/app/components/modals/invite-new-user.js +++ b/ghost/admin/app/components/modals/invite-new-user.js @@ -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'); diff --git a/ghost/admin/app/controllers/setup/three.js b/ghost/admin/app/controllers/setup/three.js index 9f0d80e53a..cf48cacddf 100644 --- a/ghost/admin/app/controllers/setup/three.js +++ b/ghost/admin/app/controllers/setup/three.js @@ -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 { diff --git a/ghost/admin/app/controllers/team/index.js b/ghost/admin/app/controllers/team/index.js index eb28ca27c3..aea8fc6e67 100644 --- a/ghost/admin/app/controllers/team/index.js +++ b/ghost/admin/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() { diff --git a/ghost/admin/app/mirage/config.js b/ghost/admin/app/mirage/config.js index 18c7688d1c..6dc79c5781 100644 --- a/ghost/admin/app/mirage/config.js +++ b/ghost/admin/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'; @@ -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); diff --git a/ghost/admin/app/mirage/config/invites.js b/ghost/admin/app/mirage/config/invites.js new file mode 100644 index 0000000000..7208c6ef9b --- /dev/null +++ b/ghost/admin/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, {}, {}); + }); +} diff --git a/ghost/admin/app/mirage/factories/invite.js b/ghost/admin/app/mirage/factories/invite.js new file mode 100644 index 0000000000..62b21c804c --- /dev/null +++ b/ghost/admin/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 []; } +}); diff --git a/ghost/admin/app/models/invite.js b/ghost/admin/app/models/invite.js new file mode 100644 index 0000000000..9fa42052d4 --- /dev/null +++ b/ghost/admin/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' + }); + } +}); diff --git a/ghost/admin/app/models/user.js b/ghost/admin/app/models/user.js index 2c29cb1ed2..677622163b 100644 --- a/ghost/admin/app/models/user.js +++ b/ghost/admin/app/models/user.js @@ -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() }); diff --git a/ghost/admin/app/routes/team/index.js b/ghost/admin/app/routes/team/index.js index 40c19d7c80..cd9b613256 100644 --- a/ghost/admin/app/routes/team/index.js +++ b/ghost/admin/app/routes/team/index.js @@ -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(); diff --git a/ghost/admin/app/serializers/invite.js b/ghost/admin/app/serializers/invite.js new file mode 100644 index 0000000000..fc842ee9a9 --- /dev/null +++ b/ghost/admin/app/serializers/invite.js @@ -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'} + } +}); diff --git a/ghost/admin/app/templates/components/modals/invite-new-user.hbs b/ghost/admin/app/templates/components/modals/invite-new-user.hbs index 353029213f..d83b078ea3 100644 --- a/ghost/admin/app/templates/components/modals/invite-new-user.hbs +++ b/ghost/admin/app/templates/components/modals/invite-new-user.hbs @@ -27,12 +27,14 @@
- {{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") }}
diff --git a/ghost/admin/app/templates/team/index.hbs b/ghost/admin/app/templates/team/index.hbs index dd3725c2c8..6a0839a6e1 100644 --- a/ghost/admin/app/templates/team/index.hbs +++ b/ghost/admin/app/templates/team/index.hbs @@ -23,22 +23,23 @@ }} {{!-- Do not show invited users to authors --}} {{#unless session.user.isAuthor}} - {{#if invitedUsers}} + {{#if invites}}

Invited users

- {{#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|}}
ic
- {{user.email}}
- {{#if user.pending}} + {{invite.email}}
+ {{#if invite.pending}} Invitation not sent - please try again {{else}} - Invitation sent: {{component.createdAtUTC}} + Invitation sent: {{component.createdAt}}, + expires {{component.expiresAt}} {{/if}}
@@ -46,13 +47,13 @@ {{#if component.isSending}} Sending Invite... {{else}} - + Revoke - + Resend - {{#each user.roles as |role|}} + {{#each invite.roles as |role|}} {{role.name}} {{/each}} {{/if}} @@ -66,7 +67,7 @@

Active users

- {{#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|}} diff --git a/ghost/admin/tests/acceptance/setup-test.js b/ghost/admin/tests/acceptance/setup-test.js index 02cb71121f..df0e3e7c54 100644 --- a/ghost/admin/tests/acceptance/setup-test.js +++ b/ghost/admin/tests/acceptance/setup-test.js @@ -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] }; }); diff --git a/ghost/admin/tests/acceptance/team-test.js b/ghost/admin/tests/acceptance/team-test.js index fcd71d26cc..be24512252 100644 --- a/ghost/admin/tests/acceptance/team-test.js +++ b/ghost/admin/tests/acceptance/team-test.js @@ -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); + }); + }); + }); }); diff --git a/ghost/admin/tests/unit/models/invite-test.js b/ghost/admin/tests/unit/models/invite-test.js new file mode 100644 index 0000000000..a631ad79b3 --- /dev/null +++ b/ghost/admin/tests/unit/models/invite-test.js @@ -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'); + }); + }); + } +); diff --git a/ghost/admin/tests/unit/models/user-test.js b/ghost/admin/tests/unit/models/user-test.js index 5110403979..5c1a509493 100644 --- a/ghost/admin/tests/unit/models/user-test.js +++ b/ghost/admin/tests/unit/models/user-test.js @@ -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();