mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-28 05:37:34 +03:00
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:
parent
91d6097d9e
commit
467ee93b21
@ -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(() => {
|
||||
// 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'});
|
||||
});
|
||||
} else {
|
||||
// if the user is no longer marked as "invited", then show a warning and reload the route
|
||||
}).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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -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');
|
||||
|
@ -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 {
|
||||
|
@ -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() {
|
||||
|
@ -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);
|
||||
|
58
ghost/admin/app/mirage/config/invites.js
Normal file
58
ghost/admin/app/mirage/config/invites.js
Normal 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, {}, {});
|
||||
});
|
||||
}
|
14
ghost/admin/app/mirage/factories/invite.js
Normal file
14
ghost/admin/app/mirage/factories/invite.js
Normal 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 []; }
|
||||
});
|
50
ghost/admin/app/models/invite.js
Normal file
50
ghost/admin/app/models/invite.js
Normal 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'
|
||||
});
|
||||
}
|
||||
});
|
@ -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()
|
||||
});
|
||||
|
@ -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,18 +12,35 @@ 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: {
|
||||
|
10
ghost/admin/app/serializers/invite.js
Normal file
10
ghost/admin/app/serializers/invite.js
Normal 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'}
|
||||
}
|
||||
});
|
@ -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>
|
||||
|
@ -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|}}
|
||||
|
@ -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]
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -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,26 +113,41 @@ 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');
|
||||
|
||||
andThen(() => {
|
||||
// url is correct
|
||||
expect(currentURL(), 'currentURL').to.equal('/team');
|
||||
|
||||
// invite user button exists
|
||||
expect(find('.view-actions .btn-green').html(), 'invite people button text')
|
||||
.to.equal('Invite People');
|
||||
expect(
|
||||
find('.view-actions .btn-green').text().trim(),
|
||||
'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);
|
||||
|
||||
expect(
|
||||
find('.user-list.active-users .user-list-item:first-of-type .role-label').text().trim(),
|
||||
'active user\'s role label'
|
||||
).to.equal('Administrator');
|
||||
|
||||
// no invites are shown
|
||||
expect(
|
||||
find('.user-list.invited-users .user-list-item').length,
|
||||
'initial number of invited users'
|
||||
).to.equal(0);
|
||||
});
|
||||
|
||||
// click the invite people button
|
||||
click('.view-actions .btn-green');
|
||||
|
||||
andThen(() => {
|
||||
let roleOptions = find('#new-user-role select option');
|
||||
let roleOptions = find('.fullscreen-modal select[name="role"] option');
|
||||
|
||||
function checkOwnerExists() {
|
||||
for (let i in roleOptions) {
|
||||
@ -152,88 +167,188 @@ describe('Acceptance: Team', function () {
|
||||
return false;
|
||||
}
|
||||
|
||||
// should be 3 available roles
|
||||
expect(roleOptions.length, 'number of available roles').to.equal(3);
|
||||
// modal is displayed
|
||||
expect(
|
||||
find('.fullscreen-modal h1').text().trim(),
|
||||
'correct modal is displayed'
|
||||
).to.equal('Invite a New User');
|
||||
|
||||
// number of roles is correct
|
||||
expect(
|
||||
find('.fullscreen-modal select[name="role"] option').length,
|
||||
'number of selectable roles'
|
||||
).to.equal(3);
|
||||
|
||||
expect(checkOwnerExists(), 'owner role isn\'t available').to.be.false;
|
||||
expect(checkSelectedIsAuthor(), 'author role is selected initially').to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
it('sends an invite correctly', function () {
|
||||
visit('/team');
|
||||
// submit valid invite form
|
||||
fillIn('.fullscreen-modal input[name="email"]', 'invite1@example.com');
|
||||
click('.fullscreen-modal .btn-green');
|
||||
|
||||
andThen(() => {
|
||||
expect(find('.user-list.invited-users .user-list-item').length, 'number of invited users').to.equal(0);
|
||||
// modal closes
|
||||
expect(
|
||||
find('.fullscreen-modal').length,
|
||||
'number of modals after sending invite'
|
||||
).to.equal(0);
|
||||
|
||||
// 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);
|
||||
|
||||
expect(
|
||||
find('.invited-users span.name').first().text().trim(),
|
||||
'displayed email of first invite'
|
||||
).to.equal('invite1@example.com');
|
||||
|
||||
expect(
|
||||
find('.invited-users span.role-label').first().text().trim(),
|
||||
'displayed role of first invite'
|
||||
).to.equal('Author');
|
||||
|
||||
// number of users is unchanged
|
||||
expect(
|
||||
find('.active-users .user-list-item').length,
|
||||
'number of active users after first invite'
|
||||
).to.equal(1);
|
||||
});
|
||||
|
||||
// submit new invite with different role
|
||||
click('.view-actions .btn-green');
|
||||
click(emailInputField);
|
||||
triggerEvent(emailInputField, 'blur');
|
||||
|
||||
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.');
|
||||
});
|
||||
|
||||
fillIn(emailInputField, 'test@example.com');
|
||||
fillIn('.fullscreen-modal input[name="email"]', 'invite2@example.com');
|
||||
fillIn('.fullscreen-modal select[name="role"]', '2');
|
||||
click('.fullscreen-modal .btn-green');
|
||||
|
||||
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 invites increases
|
||||
expect(
|
||||
find('.invited-users .user-list-item').length,
|
||||
'number of invites after second invite'
|
||||
).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');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails sending an invite correctly', function () {
|
||||
server.create('user', {email: 'test1@example.com'});
|
||||
server.create('user', {email: 'test2@example.com', status: 'invited'});
|
||||
|
||||
visit('/team');
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
// click the "invite new user" button to open the modal
|
||||
// submit invite form with existing user
|
||||
click('.view-actions .btn-green');
|
||||
|
||||
// fill in and submit the invite user modal with an existing user
|
||||
fillIn(emailInputField, 'test1@example.com');
|
||||
fillIn('.fullscreen-modal input[name="email"]', 'admin@example.com');
|
||||
click('.fullscreen-modal .btn-green');
|
||||
|
||||
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.');
|
||||
// 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.');
|
||||
});
|
||||
|
||||
// fill in and submit the invite user modal with an invited user
|
||||
fillIn(emailInputField, 'test2@example.com');
|
||||
// submit invite form with existing invite
|
||||
fillIn('.fullscreen-modal input[name="email"]', 'invite1@example.com');
|
||||
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.');
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
87
ghost/admin/tests/unit/models/invite-test.js
Normal file
87
ghost/admin/tests/unit/models/invite-test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
@ -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();
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user