Contributor Role (#948)

refs https://github.com/TryGhost/Ghost/issues/9314

* added save button for contributor
* hide tag filter & redirect to posts.index if post is published
* update editor controller test to need session service
This commit is contained in:
Austin Burdine 2018-02-07 10:42:46 +01:00 committed by Katharina Irrgang
parent f0501e8997
commit 13ceee3e9f
34 changed files with 286 additions and 26 deletions

View File

@ -81,6 +81,7 @@ export default Controller.extend({
notifications: service(),
router: service(),
slugGenerator: service(),
session: service(),
ui: service(),
/* public properties -----------------------------------------------------*/

View File

@ -58,7 +58,7 @@ export default Controller.extend({
deleteUserActionIsVisible: computed('currentUser', 'canAssignRoles', 'user', function () {
if ((this.get('canAssignRoles') && this.get('isNotOwnProfile') && !this.get('user.isOwner'))
|| (this.get('currentUser.isEditor') && (this.get('isNotOwnProfile')
|| this.get('user.isAuthor')))) {
|| this.get('user.isAuthorOrContributor')))) {
return true;
}
}),

View File

@ -3,7 +3,7 @@ import Mixin from '@ember/object/mixin';
export default Mixin.create({
transitionAuthor() {
return (user) => {
if (user.get('isAuthor')) {
if (user.get('isAuthorOrContributor')) {
return this.transitionTo('team.user', user);
}

View File

@ -3,7 +3,7 @@ import Model from 'ember-data/model';
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
import attr from 'ember-data/attr';
import {computed} from '@ember/object';
import {equal} from '@ember/object/computed';
import {equal, or} from '@ember/object/computed';
import {hasMany} from 'ember-data/relationships';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
@ -46,11 +46,15 @@ export default Model.extend(ValidationEngine, {
// TODO: Once client-side permissions are in place,
// remove the hard role check.
isContributor: equal('role.name', 'Contributor'),
isAuthor: equal('role.name', 'Author'),
isEditor: equal('role.name', 'Editor'),
isAdmin: equal('role.name', 'Administrator'),
isOwner: equal('role.name', 'Owner'),
// This is used in enough places that it's useful to throw it here
isAuthorOrContributor: or('isAuthor', 'isContributor'),
isLoggedIn: computed('id', 'session.user.id', function () {
return this.get('id') === this.get('session.user.id');
}),

View File

@ -119,7 +119,7 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, {
loadServerNotifications(isDelayed) {
if (this.get('session.isAuthenticated')) {
this.get('session.user').then((user) => {
if (!user.get('isAuthor') && !user.get('isEditor')) {
if (!user.get('isAuthorOrContributor') && !user.get('isEditor')) {
this.store.findAll('notification', {reload: true}).then((serverNotifications) => {
serverNotifications.forEach((notification) => {
if (notification.get('top') || notification.get('custom')) {

View File

@ -20,7 +20,12 @@ export default AuthenticatedRoute.extend({
this._super(...arguments);
return this.get('session.user').then((user) => {
if (user.get('isAuthor') && !post.isAuthoredByUser(user)) {
if (user.get('isAuthorOrContributor') && !post.isAuthoredByUser(user)) {
return this.replaceWith('posts.index');
}
// If the post is not a draft and user is contributor, redirect to index
if (user.get('isContributor') && !post.get('isDraft')) {
return this.replaceWith('posts.index');
}
});

View File

@ -45,6 +45,10 @@ export default AuthenticatedRoute.extend(InfinityRoute, {
if (user.get('isAuthor')) {
// authors can only view their own posts
filterParams.author = user.get('slug');
} else if (user.get('isContributor')) {
// Contributors can only view their own draft posts
filterParams.author = user.get('slug');
queryParams.status = 'draft';
} else if (params.author) {
filterParams.author = params.author;
}
@ -78,7 +82,7 @@ export default AuthenticatedRoute.extend(InfinityRoute, {
}
this.get('session.user').then((user) => {
if (!user.get('isAuthor') && !controller._hasLoadedAuthors) {
if (!user.get('isAuthorOrContributor') && !controller._hasLoadedAuthors) {
this.get('store').query('user', {limit: 'all'}).then(() => {
controller._hasLoadedAuthors = true;
});

View File

@ -29,7 +29,7 @@ export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, Infinit
};
// authors do not have permission to hit the invites or suspended users endpoint
if (!user.get('isAuthor')) {
if (!user.get('isAuthorOrContributor')) {
modelPromises.invites = this.store.query('invite', {limit: 'all'})
.then(() => this.store.filter('invite', invite => !invite.get('isNew')));

View File

@ -17,12 +17,12 @@ export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, {
return this.get('session.user').then((currentUser) => {
let isOwnProfile = user.get('id') === currentUser.get('id');
let isAuthor = currentUser.get('isAuthor');
let isAuthorOrContributor = currentUser.get('isAuthorOrContributor');
let isEditor = currentUser.get('isEditor');
if (isAuthor && !isOwnProfile) {
if (isAuthorOrContributor && !isOwnProfile) {
this.transitionTo('team.user', currentUser);
} else if (isEditor && !isOwnProfile && !user.get('isAuthor')) {
} else if (isEditor && !isOwnProfile && !user.get('isAuthorOrContributor')) {
this.transitionTo('team');
}
});

View File

@ -55,6 +55,11 @@
text-align: right;
}
.contributor-save-button {
position: relative;
z-index: 1000;
}
.post-settings {
position: relative;
z-index: 1000;

View File

@ -65,10 +65,12 @@
}}
</div>
{{#unless session.user.isContributor}}
<div class="form-group">
<label for="tag-input">Tags</label>
{{gh-psm-tags-input post=post triggerId="tag-input"}}
</div>
{{/unless}}
{{#gh-form-group errors=post.errors hasValidated=post.hasValidated property="customExcerpt"}}
<label for="custom-excerpt">Excerpt</label>
@ -83,7 +85,7 @@
{{gh-error-message errors=post.errors property="customExcerpt" data-test-error="custom-excerpt"}}
{{/gh-form-group}}
{{#unless session.user.isAuthor}}
{{#unless session.user.isAuthorOrContributor}}
<div class="form-group for-select">
<label for="author-list">Author</label>
<span class="gh-input-icon gh-icon-user">

View File

@ -44,6 +44,7 @@
triggerComponent=triggerComponent
triggerId=triggerId
verticalPosition=verticalPosition
data-test-token-input=true
as |option term|
}}
{{#if option.__isSuggestion__}}

View File

@ -15,7 +15,7 @@
{{#if user.isLocked}}
<span class="gh-badge author">Locked</span>
{{/if}}
{{#unless session.user.isAuthor}}
{{#unless session.user.isAuthorOrContributor}}
{{#each user.roles as |role|}}
<span class="gh-badge {{role.lowerCaseName}}" data-test-role-name>{{role.name}}</span>
{{/each}}

View File

@ -19,11 +19,19 @@
{{/gh-scheduled-post-countdown}}
<section class="view-actions">
{{#unless post.isNew}}
{{gh-publishmenu
post=post
saveTask=save
setSaveType=(action "setSaveType")
onOpen=(action "cancelAutosave")}}
{{#if session.user.isContributor}}
{{gh-task-button "Save"
task=save
runningText="Saving"
class="gh-btn gh-btn-blue gh-btn-icon contributor-save-button"
data-test-contributor-save=true}}
{{else}}
{{gh-publishmenu
post=post
saveTask=save
setSaveType=(action "setSaveType")
onOpen=(action "cancelAutosave")}}
{{/if}}
{{/unless}}
<button type="button" class="post-settings" title="Settings" {{action "openSettingsMenu" target=ui}} data-test-psm-trigger>

View File

@ -25,7 +25,7 @@
{{type.name}}
{{/power-select}}
{{#unless session.user.isAuthor}}
{{#unless session.user.isAuthorOrContributor}}
{{#power-select
placeholder="All authors"
selected=selectedAuthor

View File

@ -7,6 +7,7 @@
</header>
<div class="gh-contentfilter">
<div class="gh-contentfilter-left">
{{#unless session.user.isContributor}}
{{#power-select
selected=selectedType
options=availableTypes
@ -22,8 +23,9 @@
}}
{{type.name}}
{{/power-select}}
{{/unless}}
{{#unless session.user.isAuthor}}
{{#unless session.user.isAuthorOrContributor}}
{{#power-select
selected=selectedAuthor
options=availableAuthors
@ -42,6 +44,7 @@
{{/power-select}}
{{/unless}}
{{#unless session.user.isContributor}}
{{#power-select
selected=selectedTag
options=availableTags
@ -59,6 +62,7 @@
}}
{{tag.name}}
{{/power-select}}
{{/unless}}
</div>
<div class="gh-contentfilter-right">

View File

@ -2,7 +2,7 @@
<header class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>Team members</h2>
{{!-- Do not show Invite user button to authors --}}
{{#unless session.user.isAuthor}}
{{#unless session.user.isAuthorOrContributor}}
<section class="view-actions">
<button class="gh-btn gh-btn-green" {{action "toggleInviteUserModal"}} ><span>Invite People</span></button>
</section>

View File

@ -2,7 +2,7 @@
<header class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>Team members</h2>
{{!-- Do not show Invite user button to authors --}}
{{#unless session.user.isAuthor}}
{{#unless session.user.isAuthorOrContributor}}
<section class="view-actions">
<button class="gh-btn gh-btn-green" {{action "toggleInviteUserModal"}} ><span>Invite People</span></button>
</section>
@ -18,7 +18,7 @@
<section class="gh-team">
{{!-- Show invited users to everyone except authors --}}
{{#unless session.user.isAuthor}}
{{#unless session.user.isAuthorOrContributor}}
{{#if invites}}
<section class="apps-grid-container gh-invited-users" data-test-invited-users>
<span class="apps-grid-title">Invited users</span>
@ -75,8 +75,8 @@
<section class="apps-grid-container gh-active-users" data-test-active-users>
<span class="apps-grid-title">Active users</span>
<div class="apps-grid">
{{!-- For authors only show their own user --}}
{{#if session.user.isAuthor}}
{{!-- For authors/contributors only show their own user --}}
{{#if session.user.isAuthorOrContributor}}
{{#with session.user as |user|}}
{{#gh-user-active user=user as |component|}}
{{gh-user-list-item user=user component=component}}
@ -100,7 +100,7 @@
</section>
{{!-- Don't show if we have no suspended users or logged in as an author --}}
{{#if (and suspendedUsers (not session.user.isAuthor))}}
{{#if (and suspendedUsers (not session.user.isAuthorOrContributor))}}
<section class="apps-grid-container gh-active-users" data-test-suspended-users>
<span class="apps-grid-title">Suspended users</span>
<div class="apps-grid">

View File

@ -158,4 +158,42 @@ describe('Acceptance: Content', function () {
expect(find(`[data-test-post-id="${authorPost.id}"]`), 'author post').to.exist;
});
});
describe('as contributor', function () {
let contributor, contributorPost;
beforeEach(function () {
let contributorRole = server.create('role', {name: 'Contributor'});
contributor = server.create('user', {roles: [contributorRole]});
let adminRole = server.create('role', {name: 'Administrator'});
let admin = server.create('user', {roles: [adminRole]});
// Create posts
contributorPost = server.create('post', {authorId: contributor.id, status: 'draft', title: 'Contributor Post Draft'});
server.create('post', {authorId: contributor.id, status: 'published', title: 'Contributor Published Post'});
server.create('post', {authorId: admin.id, status: 'scheduled', title: 'Admin Post'});
return authenticateSession(application);
});
it('only fetches the contributor\'s draft posts', async function () {
await visit('/');
// Ensure the type, tag, and author selectors don't exist
expect(find('[data-test-type-select]'), 'type selector').to.not.exist;
expect(find('[data-test-tag-select]'), 'tag selector').to.not.exist;
expect(find('[data-test-author-select]'), 'author selector').to.not.exist;
// Trigger a sort request
await selectChoose('[data-test-order-select]', 'Oldest');
// API request includes author filter
let [lastRequest] = server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.filter).to.equal(`author:${contributor.slug}`);
// only contributor's post is shown
expect(find('[data-test-post-id]').length, 'post count').to.equal(1);
expect(find(`[data-test-post-id="${contributorPost.id}"]`), 'author post').to.exist;
});
});
});

View File

@ -28,6 +28,17 @@ describe('Acceptance: Editor', function () {
expect(currentURL(), 'currentURL').to.equal('/signin');
});
it('does not redirect to team page when authenticated as contributor', async function () {
let role = server.create('role', {name: 'Contributor'});
server.create('user', {roles: [role], slug: 'test-user'});
server.create('post');
authenticateSession(application);
await visit('/editor/1');
expect(currentURL(), 'currentURL').to.equal('/editor/1');
});
it('does not redirect to team page when authenticated as author', async function () {
let role = server.create('role', {name: 'Author'});
server.create('user', {roles: [role], slug: 'test-user'});
@ -61,6 +72,45 @@ describe('Acceptance: Editor', function () {
expect(currentURL()).to.equal('/editor/1');
});
describe('when logged in as contributor', function () {
beforeEach(function () {
let role = server.create('role', {name: 'Contributor'});
server.create('user', {roles: [role]});
server.loadFixtures('settings');
return authenticateSession(application);
});
it('renders a save button instead of a publish menu & hides tags input', async function () {
server.createList('post', 2);
// post id 1 is a draft, checking for draft behaviour now
await visit('/editor/1');
expect(currentURL(), 'currentURL').to.equal('/editor/1');
// Expect publish menu to not exist
expect(
find('[data-test-publishmenu-trigger]'),
'publish menu trigger'
).to.not.exist;
// Open post settings menu
await click('[data-test-psm-trigger]');
// Check to make sure that tags input doesn't exist
expect(
find('[data-test-token-input]'),
'tags input'
).to.not.exist;
// post id 2 is published, we should be redirected to index
await visit('/editor/2');
expect(currentURL(), 'currentURL').to.equal('/');
});
});
describe('when logged in', function () {
beforeEach(function () {
let role = server.create('role', {name: 'Administrator'});

View File

@ -28,6 +28,16 @@ describe('Acceptance: Settings - Apps - AMP', function () {
expect(currentURL(), 'currentURL').to.equal('/signin');
});
it('redirects to team page when authenticated as contributor', async function () {
let role = server.create('role', {name: 'Contributor'});
server.create('user', {roles: [role], slug: 'test-user'});
authenticateSession(application);
await visit('/settings/apps/amp');
expect(currentURL(), 'currentURL').to.equal('/team/test-user');
});
it('redirects to team page when authenticated as author', async function () {
let role = server.create('role', {name: 'Author'});
server.create('user', {roles: [role], slug: 'test-user'});

View File

@ -27,6 +27,16 @@ describe('Acceptance: Settings - Apps', function () {
expect(currentURL(), 'currentURL').to.equal('/signin');
});
it('redirects to team page when authenticated as contributor', async function () {
let role = server.create('role', {name: 'Contributor'});
server.create('user', {roles: [role], slug: 'test-user'});
authenticateSession(application);
await visit('/settings/apps');
expect(currentURL(), 'currentURL').to.equal('/team/test-user');
});
it('redirects to team page when authenticated as author', async function () {
let role = server.create('role', {name: 'Author'});
server.create('user', {roles: [role], slug: 'test-user'});

View File

@ -29,6 +29,16 @@ describe('Acceptance: Settings - Code-Injection', function () {
expect(currentURL(), 'currentURL').to.equal('/signin');
});
it('redirects to team page when authenticated as contributor', async function () {
let role = server.create('role', {name: 'Contributor'});
server.create('user', {roles: [role], slug: 'test-user'});
authenticateSession(application);
await visit('/settings/code-injection');
expect(currentURL(), 'currentURL').to.equal('/team/test-user');
});
it('redirects to team page when authenticated as author', async function () {
let role = server.create('role', {name: 'Author'});
server.create('user', {roles: [role], slug: 'test-user'});

View File

@ -26,6 +26,16 @@ describe('Acceptance: Settings - Design', function () {
expect(currentURL(), 'currentURL').to.equal('/signin');
});
it('redirects to team page when authenticated as contributor', async function () {
let role = server.create('role', {name: 'Contributor'});
server.create('user', {roles: [role], slug: 'test-user'});
authenticateSession(application);
await visit('/settings/design');
expect(currentURL(), 'currentURL').to.equal('/team/test-user');
});
it('redirects to team page when authenticated as author', async function () {
let role = server.create('role', {name: 'Author'});
server.create('user', {roles: [role], slug: 'test-user'});

View File

@ -27,6 +27,16 @@ describe('Acceptance: Settings - General', function () {
expect(currentURL(), 'currentURL').to.equal('/signin');
});
it('redirects to team page when authenticated as contributor', async function () {
let role = server.create('role', {name: 'Contributor'});
server.create('user', {roles: [role], slug: 'test-user'});
authenticateSession(application);
await visit('/settings/general');
expect(currentURL(), 'currentURL').to.equal('/team/test-user');
});
it('redirects to team page when authenticated as author', async function () {
let role = server.create('role', {name: 'Author'});
server.create('user', {roles: [role], slug: 'test-user'});

View File

@ -25,6 +25,16 @@ describe('Acceptance: Settings - Labs', function () {
expect(currentURL(), 'currentURL').to.equal('/signin');
});
it('redirects to team page when authenticated as contributor', async function () {
let role = server.create('role', {name: 'Contributor'});
server.create('user', {roles: [role], slug: 'test-user'});
authenticateSession(application);
await visit('/settings/labs');
expect(currentURL(), 'currentURL').to.equal('/team/test-user');
});
it('redirects to team page when authenticated as author', async function () {
let role = server.create('role', {name: 'Author'});
server.create('user', {roles: [role], slug: 'test-user'});

View File

@ -24,6 +24,16 @@ describe('Acceptance: Settings - Apps - Slack', function () {
expect(currentURL(), 'currentURL').to.equal('/signin');
});
it('redirects to team page when authenticated as contributor', async function () {
let role = server.create('role', {name: 'Contributor'});
server.create('user', {roles: [role], slug: 'test-user'});
authenticateSession(application);
await visit('/settings/apps/slack');
expect(currentURL(), 'currentURL').to.equal('/team/test-user');
});
it('redirects to team page when authenticated as author', async function () {
let role = server.create('role', {name: 'Author'});
server.create('user', {roles: [role], slug: 'test-user'});

View File

@ -56,6 +56,16 @@ describe('Acceptance: Settings - Tags', function () {
expect(currentURL()).to.equal('/signin');
});
it('redirects to team page when authenticated as contributor', async function () {
let role = server.create('role', {name: 'Contributor'});
server.create('user', {roles: [role], slug: 'test-user'});
authenticateSession(application);
await visit('/settings/design');
expect(currentURL(), 'currentURL').to.equal('/team/test-user');
});
it('redirects to team page when authenticated as author', async function () {
let role = server.create('role', {name: 'Author'});
server.create('user', {roles: [role], slug: 'test-user'});

View File

@ -23,6 +23,16 @@ describe('Acceptance: Settings - Apps - Unsplash', function () {
expect(currentURL(), 'currentURL').to.equal('/signin');
});
it('redirects to team page when authenticated as contributor', async function () {
let role = server.create('role', {name: 'Contributor'});
server.create('user', {roles: [role], slug: 'test-user'});
authenticateSession(application);
await visit('/settings/apps/unsplash');
expect(currentURL(), 'currentURL').to.equal('/team/test-user');
});
it('redirects to team page when authenticated as author', async function () {
let role = server.create('role', {name: 'Author'});
server.create('user', {roles: [role], slug: 'test-user'});

View File

@ -46,6 +46,18 @@ describe('Acceptance: Subscribers', function () {
.to.equal(0);
});
it('redirects contributors to posts', async function () {
let role = server.create('role', {name: 'Contributor'});
server.create('user', {roles: [role]});
authenticateSession(application);
await visit('/subscribers');
expect(currentURL()).to.equal('/');
expect(find('.gh-nav-main a:contains("Subscribers")').length, 'sidebar link is visible')
.to.equal(0);
});
describe('an admin', function () {
beforeEach(function () {
let role = server.create('role', {name: 'Administrator'});

View File

@ -27,6 +27,18 @@ describe('Acceptance: Team', function () {
expect(currentURL()).to.equal('/signin');
});
it('redirects correctly when authenticated as contributor', async function () {
let role = server.create('role', {name: 'Contributor'});
server.create('user', {roles: [role], slug: 'test-user'});
server.create('user', {slug: 'no-access'});
authenticateSession(application);
await visit('/team/no-access');
expect(currentURL(), 'currentURL').to.equal('/team/test-user');
});
it('redirects correctly when authenticated as author', async function () {
let role = server.create('role', {name: 'Author'});
server.create('user', {roles: [role], slug: 'test-user'});

View File

@ -15,6 +15,7 @@ describe('Unit: Controller: editor', function () {
'service:notifications',
// 'service:router',
'service:slugGenerator',
'service:session',
'service:ui'
]
});

View File

@ -37,7 +37,7 @@ describe('Unit: Helper: gh-user-can-admin', function () {
});
});
describe('Editor and Author roles', function () {
describe('Editor, Author & Contributor roles', function () {
let user = {
get(role) {
if (role === 'isOwner') {

View File

@ -61,6 +61,21 @@ describe('Unit: Model: user', function () {
expect(model.get('role.name')).to.equal('Editor');
});
it('isContributor property is correct', function () {
let model = this.subject();
run(() => {
let role = this.store().push({data: {id: 1, type: 'role', attributes: {name: 'Contributor'}}});
model.set('role', role);
});
expect(model.get('isContributor')).to.be.ok;
expect(model.get('isAuthorOrContributor')).to.be.ok;
expect(model.get('isAuthor')).to.not.be.ok;
expect(model.get('isEditor')).to.not.be.ok;
expect(model.get('isAdmin')).to.not.be.ok;
expect(model.get('isOwner')).to.not.be.ok;
});
it('isAuthor property is correct', function () {
let model = this.subject();
@ -69,6 +84,8 @@ describe('Unit: Model: user', function () {
model.set('role', role);
});
expect(model.get('isAuthor')).to.be.ok;
expect(model.get('isContributor')).to.not.be.ok;
expect(model.get('isAuthorOrContributor')).to.be.ok;
expect(model.get('isEditor')).to.not.be.ok;
expect(model.get('isAdmin')).to.not.be.ok;
expect(model.get('isOwner')).to.not.be.ok;
@ -83,6 +100,8 @@ describe('Unit: Model: user', function () {
});
expect(model.get('isEditor')).to.be.ok;
expect(model.get('isAuthor')).to.not.be.ok;
expect(model.get('isContributor')).to.not.be.ok;
expect(model.get('isAuthorOrContributor')).to.not.be.ok;
expect(model.get('isAdmin')).to.not.be.ok;
expect(model.get('isOwner')).to.not.be.ok;
});
@ -96,6 +115,8 @@ describe('Unit: Model: user', function () {
});
expect(model.get('isAdmin')).to.be.ok;
expect(model.get('isAuthor')).to.not.be.ok;
expect(model.get('isContributor')).to.not.be.ok;
expect(model.get('isAuthorOrContributor')).to.not.be.ok;
expect(model.get('isEditor')).to.not.be.ok;
expect(model.get('isOwner')).to.not.be.ok;
});
@ -109,6 +130,8 @@ describe('Unit: Model: user', function () {
});
expect(model.get('isOwner')).to.be.ok;
expect(model.get('isAuthor')).to.not.be.ok;
expect(model.get('isContributor')).to.not.be.ok;
expect(model.get('isAuthorOrContributor')).to.not.be.ok;
expect(model.get('isAdmin')).to.not.be.ok;
expect(model.get('isEditor')).to.not.be.ok;
});