diff --git a/app/adapters/user.js b/app/adapters/user.js index cdc5102d3e..d095da0fce 100644 --- a/app/adapters/user.js +++ b/app/adapters/user.js @@ -1,7 +1,8 @@ import ApplicationAdapter from 'ghost-admin/adapters/application'; +import IdUrl from 'ghost-admin/mixins/id-url'; import SlugUrl from 'ghost-admin/mixins/slug-url'; -export default ApplicationAdapter.extend(SlugUrl, { +export default ApplicationAdapter.extend(SlugUrl, IdUrl, { queryRecord(store, type, query) { if (!query || query.id !== 'me') { return this._super(...arguments); diff --git a/app/components/gh-post-settings-menu.js b/app/components/gh-post-settings-menu.js index cfc0eaa818..6967a67d6b 100644 --- a/app/components/gh-post-settings-menu.js +++ b/app/components/gh-post-settings-menu.js @@ -20,6 +20,7 @@ export default Component.extend(SettingsMenuMixin, { session: service(), settings: service(), ui: service(), + rcServices: service('rc-services'), post: null, @@ -36,14 +37,24 @@ export default Component.extend(SettingsMenuMixin, { ogTitleScratch: alias('post.ogTitleScratch'), twitterDescriptionScratch: alias('post.twitterDescriptionScratch'), twitterTitleScratch: alias('post.twitterTitleScratch'), + rcDescriptionScratch: alias('post.rcDescriptionScratch'), + rcTitleScratch: alias('post.rcTitleScratch'), + roomNameScratch: alias('post.roomNameScratch'), slugValue: boundOneWay('post.slug'), - + allowAnnouncements: boundOneWay('settings.isAnnounced'), + allowAuthorRooms: boundOneWay('settings.isAuthorsRooms'), + announce: boundOneWay('allowAnnouncements'), + + roomName: or('roomNameScratch', 'settings.roomName'), facebookDescription: or('ogDescriptionScratch', 'customExcerptScratch', 'seoDescription'), facebookImage: or('post.ogImage', 'post.featureImage'), facebookTitle: or('ogTitleScratch', 'seoTitle'), twitterDescription: or('twitterDescriptionScratch', 'customExcerptScratch', 'seoDescription'), twitterImage: or('post.twitterImage', 'post.featureImage'), twitterTitle: or('twitterTitleScratch', 'seoTitle'), + rcDescription: or('rcDescriptionScratch', 'customExcerptScratch', 'seoDescription'), + rcImage: or('post.rcImage', 'post.featureImage'), + rcTitle: or('rcTitleScratch', 'seoTitle'), seoTitle: computed('metaTitleScratch', 'post.titleScratch', function () { return this.metaTitleScratch || this.post.titleScratch || '(Untitled)'; @@ -116,6 +127,11 @@ export default Component.extend(SettingsMenuMixin, { post.validate({attribute: 'publishedAtBlog'}); } + if (errors.has('roomName')) { + post.set('roomNameScratch', post.get('roomName')); + post.validate({attribute: 'roomName'}); + } + // remove throbbers this.set('_showThrobbers', false); } @@ -163,6 +179,50 @@ export default Component.extend(SettingsMenuMixin, { }); }, + /** + * triggered by user manually changing announce-setting + */ + toggleAnnounce() { + let post = this.post; + let announce = this.announce; + this.toggleProperty('announce'); + post.set('announce', !announce); + post.set('announceChanged', true); + }, + + /** + * triggered by user manually changing room-name + */ + validateRoom(newRoom) { + let oldRoom = this.roomName; + let post = this.post; + let errMessage = 'Room does not exist'; + + // reset errors and validation + post.get('errors').remove('roomName'); + + if (!newRoom) { + newRoom = oldRoom; + } + this.rcServices.getRoom(newRoom) + .then((room) => { + const existingRCRoom = room.data[0].exist && room.data[0].roomname === newRoom; + + if (!existingRCRoom) { + throw errMessage; + } + post.set('roomName', newRoom); + post.set('roomId', room.data[0].rid); + }) + .catch((e) => { + if (e === errMessage){ + post.get('errors').add('roomName', errMessage); + return; + } + throw e; + }); + }, + /** * triggered by user manually changing slug */ @@ -402,6 +462,52 @@ export default Component.extend(SettingsMenuMixin, { }); }, + setRcTitle(rcTitle) { + // Grab the post and current stored twitter title + let post = this.post; + let currentTitle = post.get('rcTitle'); + + // If the title entered matches the stored twitter title, do nothing + if (currentTitle === rcTitle) { + return; + } + + // If the title entered is different, set it as the new twitter title + post.set('rcTitle', rcTitle); + + // Make sure the twitter title is valid and if so, save it into the post + return post.validate({property: 'rcTitle'}).then(() => { + if (post.get('isNew')) { + return; + } + + return this.savePost.perform(); + }); + }, + + setRcDescription(rcDescription) { + // Grab the post and current stored twitter description + let post = this.post; + let currentDescription = post.get('rcDescription'); + + // If the description entered matches the stored twitter description, do nothing + if (currentDescription === rcDescription) { + return; + } + + // If the description entered is different, set it as the new twitter description + post.set('rcDescription', rcDescription); + + // Make sure the twitter description is valid and if so, save it into the post + return post.validate({property: 'rcDescription'}).then(() => { + if (post.get('isNew')) { + return; + } + + return this.savePost.perform(); + }); + }, + setCoverImage(image) { this.set('post.featureImage', image); @@ -480,6 +586,32 @@ export default Component.extend(SettingsMenuMixin, { }); }, + setRcImage(image) { + this.set('post.rcImage', image); + + if (this.get('post.isNew')) { + return; + } + + this.savePost.perform().catch((error) => { + this.showError(error); + this.post.rollbackAttributes(); + }); + }, + + clearRcImage() { + this.set('post.rcImage', ''); + + if (this.get('post.isNew')) { + return; + } + + this.savePost.perform().catch((error) => { + this.showError(error); + this.post.rollbackAttributes(); + }); + }, + changeAuthors(newAuthors) { let post = this.post; diff --git a/app/components/gh-posts-list-item.js b/app/components/gh-posts-list-item.js index 2ff30849ad..dfc4bfc37a 100644 --- a/app/components/gh-posts-list-item.js +++ b/app/components/gh-posts-list-item.js @@ -14,6 +14,7 @@ export default Component.extend({ post: null, active: false, + currentUserId: null, // closure actions onClick() {}, @@ -31,6 +32,12 @@ export default Component.extend({ return authors.map(author => author.get('name') || author.get('email')).join(', '); }), + canEdit: computed('post.authors.[]', 'currentUserId', function () { + let authors = this.get('post.authors'); + authors = authors.map(author => author.get('id')); + return authors.includes(this.currentUserId); + }), + subText: computed('post.{excerpt,customExcerpt,metaDescription}', function () { let text = this.get('post.excerpt') || ''; let customExcerpt = this.get('post.customExcerpt'); diff --git a/app/components/modal-invite-new-user.js b/app/components/modal-invite-new-user.js index ff811bee36..c0c94644a3 100644 --- a/app/components/modal-invite-new-user.js +++ b/app/components/modal-invite-new-user.js @@ -10,12 +10,13 @@ const {Promise} = RSVP; export default ModalComponent.extend(ValidationEngine, { notifications: service(), store: service(), + rcService: service('rc-services'), classNames: 'modal-content invite-new-user', role: null, roles: null, - authorRole: null, + defaultRole: null, validationType: 'inviteUser', @@ -48,28 +49,28 @@ export default ModalComponent.extend(ValidationEngine, { }, validate() { - let email = this.email; + let username = this.username; - // TODO: either the validator should check the email's existence or + // TODO: either the validator should check the username's existence or // the API should return an appropriate error when attempting to save return new Promise((resolve, reject) => this._super().then(() => RSVP.hash({ - users: this.store.findAll('user', {reload: true}), - invites: this.store.findAll('invite', {reload: true}) + rc_users: this.rcService.getUser(username), + users: this.store.findAll('user', {reload: true}) }).then((data) => { - let existingUser = data.users.findBy('email', email); - let existingInvite = data.invites.findBy('email', email); + let existingRCUser = data.rc_users.data[0].exist; + let existingUser = data.users.findBy('rc_username', username); - if (existingUser || existingInvite) { - this.errors.clear('email'); + if (existingUser || !existingRCUser) { + this.errors.clear('username'); if (existingUser) { - this.errors.add('email', 'A user with that email address already exists.'); + this.errors.add('username', 'A user with that username already exists.'); } else { - this.errors.add('email', 'A user with that email address was already invited.'); + this.errors.add('username', 'Username doesnot exist'); } // TODO: this shouldn't be needed, ValidationEngine doesn't mark // properties as validated when validating an entire object - this.hasValidated.addObject('email'); + this.hasValidated.addObject('username'); reject(); } else { resolve(); @@ -77,53 +78,46 @@ export default ModalComponent.extend(ValidationEngine, { }), () => { // TODO: this shouldn't be needed, ValidationEngine doesn't mark // properties as validated when validating an entire object - this.hasValidated.addObject('email'); + this.hasValidated.addObject('username'); reject(); })); }, fetchRoles: task(function * () { let roles = yield this.store.query('role', {permissions: 'assign'}); - let authorRole = roles.findBy('name', 'Author'); + let defaultRole = roles.findBy('name', 'Author'); + defaultRole = defaultRole ? defaultRole : roles.findBy('name', 'Contributor'); this.set('roles', roles); - this.set('authorRole', authorRole); + this.set('defaultRole', defaultRole); if (!this.role) { - this.set('role', authorRole); + this.set('role', defaultRole); } }), sendInvitation: task(function* () { - let email = this.email; + let username = this.username; let role = this.role; let notifications = this.notifications; - let notificationText = `Invitation sent! (${email})`; - let invite; try { yield this.validate(); - invite = this.store.createRecord('invite', { - email, - role - }); + const user = yield this.rcService.addUser(username, role); - yield invite.save(); - - // If sending the invitation email fails, the API will still return a status of 201 - // 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'}); + // Check and notify if user is added + if (user.invitation && user.invitation[0].message === 'User Added') { + notifications.showNotification(user.invitation[0].message, {key: 'invite.send.success'}); } else { - notifications.showNotification(notificationText, {key: 'invite.send.success'}); + notifications.showAlert('Unable to add user', {type: 'error', key: 'invite.send.failed'}); } this.send('closeModal'); } catch (error) { // validation will reject and cause this to be called with no error if (error) { - invite.deleteRecord(); + // invite.deleteRecord(); notifications.showAPIError(error, {key: 'invite.send'}); this.send('closeModal'); } diff --git a/app/controllers/posts.js b/app/controllers/posts.js index 4fbbffb9e1..75237edd8c 100644 --- a/app/controllers/posts.js +++ b/app/controllers/posts.js @@ -97,6 +97,7 @@ export default Controller.extend({ }), _availableAuthors: computed(function () { + this.store.query('user', {limit: 'all'}); return this.get('store').peekAll('user'); }), @@ -116,6 +117,10 @@ export default Controller.extend({ return authors.findBy('slug', author); }), + currentUserId: computed('session.user.id', function () { + return this.get('session.user.id'); + }), + actions: { changeType(type) { this.set('type', get(type, 'value')); diff --git a/app/controllers/settings/general.js b/app/controllers/settings/general.js index c9270671bd..4b0e25eeb8 100644 --- a/app/controllers/settings/general.js +++ b/app/controllers/settings/general.js @@ -21,6 +21,7 @@ export default Controller.extend({ session: service(), settings: service(), ui: service(), + rcServices: service('rc_services'), availableTimezones: null, iconExtensions: null, @@ -28,12 +29,14 @@ export default Controller.extend({ imageExtensions: IMAGE_EXTENSIONS, imageMimeTypes: IMAGE_MIME_TYPES, + _scratchRoom: null, _scratchFacebook: null, _scratchTwitter: null, init() { this._super(...arguments); this.iconExtensions = ICON_EXTENSIONS; + this._scratchRoom = this.get('settings.roomName'); }, privateRSSUrl: computed('config.blogUrl', 'settings.publicHash', function () { @@ -102,6 +105,35 @@ export default Controller.extend({ } }, + toggleIsAnnounced(isAnnounced) { + let settings = this.settings; + + settings.set('isAnnounced', isAnnounced); + + // Change isAuthorsRooms to false when isAnnounce is disabled + if (!isAnnounced) { + settings.set('isAuthorsRooms', false); + } + }, + + toggleIsAuthorsRooms(isAuthorsRooms) { + let settings = this.settings; + + settings.set('isAuthorsRooms', isAuthorsRooms); + }, + + toggleIsComments(isComments) { + let settings = this.settings; + + settings.set('isComments', isComments); + }, + + toggleInviteOnly(inviteOnly) { + let settings = this.settings; + + settings.set('inviteOnly', inviteOnly); + }, + toggleLeaveSettingsModal(transition) { let leaveTransition = this.leaveSettingsTransition; @@ -141,6 +173,42 @@ export default Controller.extend({ return transition.retry(); }, + validateRoom() { + let newRoom = this._scratchRoom; + let oldRoom = this.get('settings.roomName'); + let errMessage = 'Room does not exist'; + + // reset errors and validation + this.get('settings.errors').remove('room'); + this.get('settings.hasValidated').removeObject('room'); + + if (!newRoom) { + newRoom = oldRoom; + } + this.rcServices.getRoom(newRoom) + .then((room) => { + const existingRCRoom = room.data[0].exist && room.data[0].roomname === newRoom; + + if (!existingRCRoom) { + throw errMessage; + } + run.schedule('afterRender', this, function () { + this.set('settings.roomName', newRoom); + this.set('settings.roomId', room.data[0].rid); + }); + }) + .catch((e) => { + if (e === errMessage){ + this.get('settings.errors').add('room', errMessage); + return; + } + throw e; + }) + .finally(() => { + this.get('settings.hasValidated').pushObject('room'); + }); + }, + validateFacebookUrl() { let newUrl = this._scratchFacebook; let oldUrl = this.get('settings.facebook'); diff --git a/app/controllers/staff/index.js b/app/controllers/staff/index.js index e0791844e1..296066cc81 100644 --- a/app/controllers/staff/index.js +++ b/app/controllers/staff/index.js @@ -2,23 +2,50 @@ import Controller from '@ember/controller'; import RSVP from 'rsvp'; import {alias, sort} from '@ember/object/computed'; -import {computed} from '@ember/object'; +import {computed, get} from '@ember/object'; import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency'; +const ROLES = [{ + name: 'All users', + value: null +}, { + name: 'Your Contributors', + value: 'Contributors' +}, { + name: 'Owner', + value: 'Owner' +}, { + name: 'Administrators', + value: 'Administrator' +}, { + name: 'Editors', + value: 'Editor' +}, { + name: 'Authors', + value: 'Author' +}, { + name: 'Contributors', + value: 'Contributor' +}]; + export default Controller.extend({ session: service(), store: service(), showInviteUserModal: false, + role: null, + inviteOrder: null, userOrder: null, + _availableRoles: null, init() { this._super(...arguments); this.inviteOrder = ['email']; this.userOrder = ['name', 'email']; + this._availableRoles = ROLES; }, currentUser: alias('model'), @@ -31,6 +58,19 @@ export default Controller.extend({ return this.store.peekAll('invite'); }), + availableRoles: computed('_availableRoles.[]', 'currentUser', function () { + let options = this.get('_availableRoles'); + if (this.get('currentUser').isAuthorOrContributor) { + return options.filter(role => role.value !== 'Contributors'); + } + return options; + }), + + selectedRole: computed('role', function () { + let roles = this.get('availableRoles'); + return roles.findBy('value', this.get('role')); + }), + filteredInvites: computed('invites.@each.isNew', function () { return this.invites.filterBy('isNew', false); }), @@ -39,21 +79,34 @@ export default Controller.extend({ return this.store.peekAll('user'); }), - activeUsers: computed('allUsers.@each.status', function () { + activeUsers: computed('allUsers.@each.{status,roles,parentId}', 'role', 'currentUser', function () { + let role = this.role; return this.allUsers.filter((user) => { - return user.status !== 'inactive'; + let isParent = false; + if (role === 'Contributors') { + isParent = this.currentUser.get('id') === user.parentId && user.roles.get('firstObject').name === 'Contributor'; + } + return user.status !== 'inactive' && (role ? isParent || user.roles.get('firstObject').name === role : true); }); }), - suspendedUsers: computed('allUsers.@each.status', function () { + suspendedUsers: computed('allUsers.@each.{status,roles,parentId}', 'role','currentUser', function () { + let role = this.role; return this.allUsers.filter((user) => { - return user.status === 'inactive'; + let isParent = false; + if (role === 'Contributors') { + isParent = this.currentUser.get('id') === user.parentId && user.roles.get('firstObject').name === 'Contributor'; + } + return user.status === 'inactive' && (role ? isParent || user.roles.get('firstObject').name === role : true); }); }), actions: { toggleInviteUserModal() { this.toggleProperty('showInviteUserModal'); + }, + changeRole(role) { + this.set('role', get(role, 'value')); } }, @@ -69,7 +122,7 @@ export default Controller.extend({ }), fetchUsers: task(function* () { - yield this.store.query('user', {limit: 'all'}); + yield this.store.query('user', {limit: 'all', include: 'parents'}); }), fetchInvites: task(function* () { diff --git a/app/controllers/staff/user.js b/app/controllers/staff/user.js index 1eaeb68703..f4814ce58e 100644 --- a/app/controllers/staff/user.js +++ b/app/controllers/staff/user.js @@ -18,6 +18,7 @@ export default Controller.extend({ notifications: service(), session: service(), slugGenerator: service(), + store: service(), leaveSettingsTransition: null, dirtyAttributes: false, @@ -39,7 +40,6 @@ export default Controller.extend({ canAssignRoles: or('currentUser.isAdmin', 'currentUser.isOwner'), canChangeEmail: not('isAdminUserOnOwnerProfile'), - canChangePassword: not('isAdminUserOnOwnerProfile'), canMakeOwner: and('currentUser.isOwner', 'isNotOwnProfile', 'user.isAdmin', 'isNotSuspended'), isAdminUserOnOwnerProfile: and('currentUser.isAdmin', 'user.isOwner'), isNotOwnersProfile: not('user.isOwner'), @@ -68,6 +68,10 @@ export default Controller.extend({ return this.store.query('role', {permissions: 'assign'}); }), + parentUser: computed('user.parentId', function () { + return this.store.queryRecord('user', {id: this.get('user.parentId')}); + }), + actions: { changeRole(newRole) { this.user.set('role', newRole); @@ -307,26 +311,6 @@ export default Controller.extend({ toggleUploadImageModal() { this.toggleProperty('showUploadImageModal'); - }, - - // TODO: remove those mutation actions once we have better - // inline validations that auto-clear errors on input - updatePassword(password) { - this.set('user.password', password); - this.get('user.hasValidated').removeObject('password'); - this.get('user.errors').remove('password'); - }, - - updateNewPassword(password) { - this.set('user.newPassword', password); - this.get('user.hasValidated').removeObject('newPassword'); - this.get('user.errors').remove('newPassword'); - }, - - updateNe2Password(password) { - this.set('user.ne2Password', password); - this.get('user.hasValidated').removeObject('ne2Password'); - this.get('user.errors').remove('ne2Password'); } }, diff --git a/app/mixins/id-url.js b/app/mixins/id-url.js new file mode 100644 index 0000000000..f46b009a68 --- /dev/null +++ b/app/mixins/id-url.js @@ -0,0 +1,15 @@ +import Mixin from '@ember/object/mixin'; +import {isBlank} from '@ember/utils'; + +export default Mixin.create({ + buildURL(_modelName, _id, _snapshot, _requestType, query) { + let url = this._super(...arguments); + + if (query && !isBlank(query.id)) { + url += `${query.id}/`; + delete query.id; + } + + return url; + } +}); diff --git a/app/mixins/validation-engine.js b/app/mixins/validation-engine.js index 53c4167b8f..3c41e11000 100644 --- a/app/mixins/validation-engine.js +++ b/app/mixins/validation-engine.js @@ -140,7 +140,9 @@ export default Mixin.create({ // If save to the server fails, reject with server response. return this.validate(options).then(() => { if (typeof this.beforeSave === 'function') { - this.beforeSave(); + return this.beforeSave().then(() => { + return _super.call(this, options); + }); } return _super.call(this, options); }).catch((result) => { diff --git a/app/models/post.js b/app/models/post.js index 72273826d9..98c9e2fac9 100644 --- a/app/models/post.js +++ b/app/models/post.js @@ -1,5 +1,6 @@ import Ember from 'ember'; import Model from 'ember-data/model'; +import RSVP from 'rsvp'; import ValidationEngine from 'ghost-admin/mixins/validation-engine'; import attr from 'ember-data/attr'; import boundOneWay from 'ghost-admin/utils/bound-one-way'; @@ -71,6 +72,8 @@ export default Model.extend(Comparable, ValidationEngine, { ghostPaths: service(), clock: service(), settings: service(), + notifications: service(), + rcServices: service('rc-services'), displayName: 'post', validationType: 'post', @@ -79,6 +82,7 @@ export default Model.extend(Comparable, ValidationEngine, { excerpt: attr('string'), customExcerpt: attr('string'), featured: attr('boolean', {defaultValue: false}), + announce: attr('boolean', {defaultValue: false}), featureImage: attr('string'), canonicalUrl: attr('string'), codeinjectionFoot: attr('string', {defaultValue: ''}), @@ -90,6 +94,9 @@ export default Model.extend(Comparable, ValidationEngine, { twitterImage: attr('string'), twitterTitle: attr('string'), twitterDescription: attr('string'), + rcImage: attr('string'), + rcTitle: attr('string'), + rcDescription: attr('string'), html: attr('string'), locale: attr('string'), metaDescription: attr('string'), @@ -104,6 +111,10 @@ export default Model.extend(Comparable, ValidationEngine, { updatedBy: attr('number'), url: attr('string'), uuid: attr('string'), + roomName: attr('string'), + roomId: attr('string'), + discussionRoomId: attr('string'), + discussionRoomName: attr('string'), authors: hasMany('user', { embedded: 'always', @@ -120,6 +131,7 @@ export default Model.extend(Comparable, ValidationEngine, { return this.get('authors.firstObject'); }), + announceChanged: false, scratch: null, titleScratch: null, @@ -134,6 +146,7 @@ export default Model.extend(Comparable, ValidationEngine, { publishedAtBlogDate: '', publishedAtBlogTime: '', + roomNameScratch: boundOneWay('roomName'), canonicalUrlScratch: boundOneWay('canonicalUrl'), customExcerptScratch: boundOneWay('customExcerpt'), codeinjectionFootScratch: boundOneWay('codeinjectionFoot'), @@ -144,6 +157,8 @@ export default Model.extend(Comparable, ValidationEngine, { ogTitleScratch: boundOneWay('ogTitle'), twitterDescriptionScratch: boundOneWay('twitterDescription'), twitterTitleScratch: boundOneWay('twitterTitle'), + rcDescriptionScratch: boundOneWay('rcDescription'), + rcTitleScratch: boundOneWay('rcTitle'), isPublished: equal('status', 'published'), isDraft: equal('status', 'draft'), @@ -312,9 +327,34 @@ export default Model.extend(Comparable, ValidationEngine, { // // the publishedAtBlog{Date/Time} strings are set separately so they can be // validated, grab that time if it exists and set the publishedAtUTC + // + // Add announcing room, announce, and commentId. beforeSave() { let publishedAtBlogTZ = this.publishedAtBlogTZ; let publishedAtUTC = publishedAtBlogTZ ? publishedAtBlogTZ.utc() : null; this.set('publishedAtUTC', publishedAtUTC); + + let roomName = this.roomName; + const roomId = roomName ? this.roomId : this.get('settings.roomId'); + roomName = roomName ? roomName : this.get('settings.roomName'); + this.set('roomName', roomName); + this.set('roomId', roomId); + + if (!this.announceChanged) { + const announce = this.get('settings.isAnnounced'); + this.set('announce', announce); + } + return new RSVP.Promise((resolve) => { + if (this.get('settings.isComments') && this.isPublished && !this.discussionRoomId) { + return this.rcServices.createDiscussion(this.title).then((room) => { + if (room && room.data[0].created) { + this.set('discussionRoomId', room.data[0].rid); + this.set('discussionRoomName', room.data[0].roomname); + } + return resolve(); + }); + } + return resolve(); + }); } }); diff --git a/app/models/setting.js b/app/models/setting.js index 538d5a19b7..bd4d8bd0b6 100644 --- a/app/models/setting.js +++ b/app/models/setting.js @@ -17,9 +17,16 @@ export default Model.extend(ValidationEngine, { codeinjectionFoot: attr('string'), facebook: attr('facebook-url-user'), twitter: attr('twitter-url-user'), + roomName: attr('string'), + roomId: attr('string'), labs: attr('string'), navigation: attr('navigation-settings'), + serverUrl: attr('string'), + isAnnounced: attr('boolean'), isPrivate: attr('boolean'), + isAuthorsRooms: attr('boolean'), + isComments: attr('boolean'), + inviteOnly: attr('boolean'), publicHash: attr('string'), password: attr('string'), slack: attr('slack-settings'), diff --git a/app/models/user.js b/app/models/user.js index 5865a55310..7985f2ae27 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -12,6 +12,8 @@ export default Model.extend(ValidationEngine, { validationType: 'user', name: attr('string'), + rc_id: attr('string'), + rc_username: attr('string'), slug: attr('string'), email: attr('string'), profileImage: attr('string'), @@ -26,7 +28,9 @@ export default Model.extend(ValidationEngine, { metaDescription: attr('string'), lastLoginUTC: attr('moment-utc'), createdAtUTC: attr('moment-utc'), + // CreatedBy will be updated from server createdBy: attr('number'), + parentId: attr('string', {defaultValue: '1'}), updatedAtUTC: attr('moment-utc'), updatedBy: attr('number'), roles: hasMany('role', { diff --git a/app/routes/editor/new.js b/app/routes/editor/new.js index c29587dae3..c396d96073 100644 --- a/app/routes/editor/new.js +++ b/app/routes/editor/new.js @@ -9,9 +9,20 @@ export default AuthenticatedRoute.extend({ return this.replaceWith('error404', {path, status: 404}); } - return this.get('session.user').then(user => ( - this.store.createRecord(modelName, {authors: [user]}) - )); + return this.get('session.user').then((user) => { + let users = [user]; + if (user.get('isContributor')) { + const pid = user.get('parentId'); + return this.store.queryRecord('user', {id: pid}) + .then((parent) => { + if (parent) { + users.push(parent); + } + return this.store.createRecord(modelName, {authors: users}); + }); + } + return this.store.createRecord(modelName, {authors: users}); + }); }, // there's no specific controller for this route, instead all editor diff --git a/app/routes/posts.js b/app/routes/posts.js index 1cd22a9e14..16df38392b 100644 --- a/app/routes/posts.js +++ b/app/routes/posts.js @@ -44,10 +44,7 @@ export default AuthenticatedRoute.extend({ filterParams.featured = true; } - if (user.isAuthor) { - // authors can only view their own posts - filterParams.authors = user.slug; - } else if (user.isContributor) { + if (user.isContributor) { // Contributors can only view their own draft posts filterParams.authors = user.slug; filterParams.status = 'draft'; diff --git a/app/routes/staff/user.js b/app/routes/staff/user.js index 36f71d8e4d..06badbb06f 100644 --- a/app/routes/staff/user.js +++ b/app/routes/staff/user.js @@ -4,7 +4,7 @@ import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings'; export default AuthenticatedRoute.extend(CurrentUserSettings, { model(params) { - return this.store.queryRecord('user', {slug: params.user_slug, include: 'count.posts'}); + return this.store.queryRecord('user', {slug: params.user_slug, include: 'count.posts,parents'}); }, afterModel(user) { @@ -14,10 +14,11 @@ export default AuthenticatedRoute.extend(CurrentUserSettings, { let isOwnProfile = user.get('id') === currentUser.get('id'); let isAuthorOrContributor = currentUser.get('isAuthorOrContributor'); let isEditor = currentUser.get('isEditor'); + let isParent = user.get('parentId') === currentUser.get('id'); if (isAuthorOrContributor && !isOwnProfile) { this.transitionTo('staff.user', currentUser); - } else if (isEditor && !isOwnProfile && !user.get('isAuthorOrContributor')) { + } else if (isEditor && !isOwnProfile && !isParent) { this.transitionTo('staff'); } }); diff --git a/app/services/rc-services.js b/app/services/rc-services.js new file mode 100644 index 0000000000..15fca45c9b --- /dev/null +++ b/app/services/rc-services.js @@ -0,0 +1,51 @@ +import Service, {inject as service} from '@ember/service'; + +export default Service.extend({ + ajax: service(), + ghostPaths: service(), + session: service(), + + init() { + this._super(...arguments); + }, + + getRoom(room) { + const query = {rname: room}; + let url = this.get('ghostPaths.url').api('rcapi'); + return this.ajax.request(url, {data: query}); + }, + + getUser(username) { + const query = {uname: username}; + let url = this.get('ghostPaths.url').api('rcapi'); + return this.ajax.request(url, {data: query}); + }, + + addUser(username, role) { + let authUrl = this.get('ghostPaths.url').api('authentication', 'adduser'); + const pid = this.get('session.user.id'); + return this.ajax.post(authUrl, { + dataType: 'json', + user: {id: '1'}, + data: { + user: [{ + rc_username: username, + role: role, + created_by: pid + }] + } + }); + }, + + createDiscussion(title) { + const url = this.get('ghostPaths.url').api('rcapi', 'discussion'); + return this.ajax.post(url, { + dataType: 'json', + data: { + room: [{ + title: title + }] + } + }); + } +}); diff --git a/app/services/settings.js b/app/services/settings.js index 3ecd8c0e9c..47bdcba59c 100644 --- a/app/services/settings.js +++ b/app/services/settings.js @@ -27,7 +27,7 @@ export default Service.extend(_ProxyMixin, ValidationEngine, { _loadSettings() { if (!this._loadingPromise) { this._loadingPromise = this.store - .queryRecord('setting', {type: 'blog,theme,private,members'}) + .queryRecord('setting', {type: 'blog,rc_settings,theme,private,members'}) .then((settings) => { this._loadingPromise = null; return settings; diff --git a/app/styles/layouts/editor.css b/app/styles/layouts/editor.css index dc8ad7a0df..a4c4a8a5b2 100644 --- a/app/styles/layouts/editor.css +++ b/app/styles/layouts/editor.css @@ -269,6 +269,64 @@ } +/* Announcement Card Preview */ +.gh-rc-preview { + overflow: hidden; + border-width: 1px; + border-style: solid; + border-color: #e1e8ed; + color: #292f33; + font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; + font-size: 1.4rem; + line-height: 1.3em; + background: #fff; + border-radius: 0.42857em; + + -webkit-font-smoothing: antialiased; +} + +.gh-rc-preview-image { + width: 100%; + height: 160px; + background-size: cover; + background-position: center center; +} + +.gh-rc-preview-content { + padding: 12px 14px; +} + +.gh-rc-preview-title { + max-height: 1.3em; + overflow: hidden; + margin: 0 0 0.15em; + font-weight: bold; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gh-rc-preview-description { + overflow: hidden; + margin-top: 0.32333em; +} + +.gh-rc-preview-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 0.35em; +} + +.gh-rc-preview-footer-left { + max-height: 1.3em; + overflow: hidden; + color: #8899a6; + text-transform: lowercase; + text-overflow: ellipsis; + white-space: nowrap; +} + + /* NEW editor /* ---------------------------------------------------------- */ diff --git a/app/templates/components/gh-post-settings-menu.hbs b/app/templates/components/gh-post-settings-menu.hbs index 9e9c09c65c..1f08d1b383 100644 --- a/app/templates/components/gh-post-settings-menu.hbs +++ b/app/templates/components/gh-post-settings-menu.hbs @@ -44,6 +44,41 @@ {{gh-url-preview slug=slugValue tagName="p" classNames="description"}} + {{#if (and allowAnnouncements (eq post.displayName 'post'))}} +
+ + {{gh-text-input + class="post-setting-room-name" + id="room-name" + name="post-setting-room-name" + readonly=(not allowAuthorRooms) + placeholder=(truncate roomName 40) + value=(readonly roomName) + focus-out=(action "validateRoom" roomNameScratch) + input=(action (mut roomNameScratch) value="target.value") + stopEnterKeyDownPropagation=true + data-test-field="room-name"}} + {{gh-error-message errors=post.errors property="roomName" data-test-error="room-name"}} + {{#if allowAuthorRooms}} +

Set Room Name to Announce

+ {{else}} +

Cannot Change the Room Name

+ {{/if}} +
+ +
+
+ {{/if}}
{{#if (or post.isDraft post.isPublished post.pastScheduledTime)}} @@ -87,7 +122,8 @@ {{gh-error-message errors=post.errors property="customExcerpt" data-test-error="custom-excerpt"}} {{/gh-form-group}} - {{#unless session.user.isAuthorOrContributor}} + {{!-- Author can add other authors, but contributor cannot --}} + {{#unless session.user.isContributor}} {{#gh-form-group class="for-select" errors=post.errors hasValidated=post.hasValidated property="authors" data-test-input="authors"}} {{gh-psm-authors-input selectedAuthors=post.authors updateAuthors=(action "changeAuthors") triggerId="author-list"}} @@ -102,6 +138,13 @@ Extra content for search engines {{svg-jar "arrow-right"}} + +
{{/if}} + {{#if (eq subview "announcement-data")}} +
+ +

Announcement Card

+
+
+ +
+ +
+ {{gh-image-uploader-with-preview + image=post.rcImage + text="Add Message image" + allowUnsplash=true + update=(action "setRcImage") + remove=(action "clearRcImage") + }} + {{#gh-form-group errors=post.errors hasValidated=post.hasValidated property="rcTitle"}} + + {{gh-text-input + class="post-setting-rc-title" + id="rc-title" + name="post-setting-rc-title" + placeholder=(truncate rcTitle 40) + value=(readonly rcTitleScratch) + input=(action (mut rcTitleScratch) value="target.value") + focus-out=(action "setRcTitle" rcTitleScratch) + stopEnterKeyDownPropagation=true + data-test-field="announcement-title"}} + {{gh-error-message errors=post.errors property="rcTitle" data-test-error="announcement-title"}} + {{/gh-form-group}} + + {{#gh-form-group errors=post.errors hasValidated=post.hasValidated property="rcDescription"}} + + {{gh-textarea + class="post-setting-rc-description" + id="rc-description" + name="post-setting-rc-description" + placeholder=(truncate rcDescription 155) + stopEnterKeyDownPropagation="true" + value=(readonly rcDescriptionScratch) + input=(action (mut rcDescriptionScratch) value="target.value") + focus-out=(action "setRcDescription" rcDescriptionScratch) + data-test-field="announcement-description"}} + {{gh-error-message errors=post.errors property="rcDescription" data-test-error="rc-description"}} + {{/gh-form-group}} + +
+ +
+ {{#if rcImage}} +
+ {{/if}} +
+
{{rcTitle}}
+
{{truncate rcDescription 155}}
+ +
+
+
+ +
+
+ {{/if}} + {{#if (eq subview "twitter-data")}}
diff --git a/app/templates/components/gh-posts-list-item.hbs b/app/templates/components/gh-posts-list-item.hbs index b12be90cd0..fbbbdecd71 100644 --- a/app/templates/components/gh-posts-list-item.hbs +++ b/app/templates/components/gh-posts-list-item.hbs @@ -1,7 +1,10 @@

- {{#link-to "editor.edit" this.post.displayName this.post.id class="permalink" title="Edit this post"}} - {{this.post.title}} - {{/link-to}} + + {{#if this.canEdit}} + {{#link-to "editor.edit" this.post.displayName this.post.id class="gh-btn gh-btn-hover-blue right" title="Edit this post"}} + Edit + {{/link-to}} + {{/if}}

{{this.subText}}

diff --git a/app/templates/components/modal-delete-user.hbs b/app/templates/components/modal-delete-user.hbs index f584d15937..f70907c992 100644 --- a/app/templates/components/modal-delete-user.hbs +++ b/app/templates/components/modal-delete-user.hbs @@ -4,28 +4,23 @@ {{svg-jar "close"}} + {{gh-task-button (if user.count.posts "Delete user and their posts" "Delete user") successText="Deleted" task=deleteUser + class="gh-btn gh-btn-red gh-btn-icon" data-test-button="confirm-delete-user"}} +
\ No newline at end of file diff --git a/app/templates/components/modal-invite-new-user.hbs b/app/templates/components/modal-invite-new-user.hbs index 95dfeecfbf..a6bdef2080 100644 --- a/app/templates/components/modal-invite-new-user.hbs +++ b/app/templates/components/modal-invite-new-user.hbs @@ -1,5 +1,5 @@ {{!-- disable mouseDown so it doesn't trigger focus-out validations --}} @@ -8,25 +8,24 @@ +
Rocket-Chat
+
+
+
Rocket.Chat server Url
+
Set the url where rocket chat server is running
+ {{#liquid-if chatServerUrl}} +
+ {{#gh-form-group errors=settings.errors hasValidated=settings.hasValidated property="serverUrl"}} + {{gh-text-input + value=(readonly settings.serverUrl) + input=(action (mut settings.serverUrl) value="target.value") + focus-out=(action "validate" target=settings) + data-test-server-url-input=true + }} + {{gh-error-message errors=settings.errors property="serverUrl"}} +

All the api call will use this url. Verify it before changing.

+ {{/gh-form-group}} +
+ {{/liquid-if}} +
+
+ +
+
+
+
+
Article Announcement
+
+ Allow the announcement of articles when publish. +
+ + {{#if settings.isAnnounced}} +
+ {{#gh-form-group errors=settings.errors hasValidated=settings.hasValidated property="room"}} + {{gh-text-input + value=(readonly settings.roomName) + input=(action (mut _scratchRoom) value="target.value") + focus-out=(action "validateRoom") + data-test-room-input=true + }} + {{gh-error-message errors=settings.errors property="room" data-test-room-error=true}} +

Channel name for announcement

+ {{/gh-form-group}} +
+ {{/if}} +
+
+
+ +
+
+
+
+
+
Authors Room for announcement
+
+ Allow authors to change the room for announcing their articles. +
+
+
+
+ +
+
+
+
+
+
Comments on Articles
+
+ Create a Discussion in RC on Articles when published, and show on footer. +
+
+
+
+ +
+
+
+
+
+
Only Owner, Admin can add users
+
+ If enabled, then only invited users can access. +
+
+
+
+ +
+
+
+
Social accounts
diff --git a/app/templates/setup/three.hbs b/app/templates/setup/three.hbs index 202ab80cbf..721ab07da3 100644 --- a/app/templates/setup/three.hbs +++ b/app/templates/setup/three.hbs @@ -1,5 +1,5 @@
-

Invite staff users

+

Add users

Ghost works best when shared with others. Collaborate, get feedback on your posts & work together on ideas.

diff --git a/app/templates/setup/two.hbs b/app/templates/setup/two.hbs index 7257ea4067..74dfbd8f4f 100644 --- a/app/templates/setup/two.hbs +++ b/app/templates/setup/two.hbs @@ -93,7 +93,7 @@ {{#if task.isRunning}} {{svg-jar "spinner" class="gh-icon-spinner gh-btn-icon-no-margin"}} {{else}} - Last step: Invite staff users {{svg-jar "arrow-right-small" class="gh-btn-icon-right"}} + Last step: Add users {{svg-jar "arrow-right-small" class="gh-btn-icon-right"}} {{/if}} {{/gh-task-button}} diff --git a/app/templates/staff/index.hbs b/app/templates/staff/index.hbs index 478721ccf9..c8f07c1223 100644 --- a/app/templates/staff/index.hbs +++ b/app/templates/staff/index.hbs @@ -1,13 +1,32 @@
-

Staff users

- {{!-- Do not show Invite user button to authors --}} +

Users

+ {{!-- Do not show Invite user button to authors/contributors --}} {{#unless currentUser.isAuthorOrContributor}}
{{/unless}}
+
+
+ {{#power-select + selected=selectedRole + options=availableRoles + searchEnabled=false + onchange=(action "changeRole") + tagName="div" + classNames="gh-contentfilter-menu gh-contentfilter-type" + triggerClass="gh-contentfilter-menu-trigger" + dropdownClass="gh-contentfilter-menu-dropdown" + matchTriggerWidth=false + data-test-type-select=true + as |role| + }} + {{role.name}} + {{/power-select}} +
+
{{#if showInviteUserModal}} {{gh-fullscreen-modal "invite-new-user" @@ -74,23 +93,16 @@
Active users
- {{!-- For authors/contributors, only show their own user --}} - {{#if currentUser.isAuthorOrContributor}} - {{#gh-user-active user=currentUser as |component|}} - {{gh-user-list-item user=currentUser component=component}} + {{#vertical-collection sortedActiveUsers + key="id" + containerSelector=".gh-main" + estimateHeight=75 + as |user| + }} + {{#gh-user-active user=user as |component|}} + {{gh-user-list-item user=user component=component}} {{/gh-user-active}} - {{else}} - {{#vertical-collection sortedActiveUsers - key="id" - containerSelector=".gh-main" - estimateHeight=75 - as |user| - }} - {{#gh-user-active user=user as |component|}} - {{gh-user-list-item user=user component=component}} - {{/gh-user-active}} - {{/vertical-collection}} - {{/if}} + {{/vertical-collection}}
diff --git a/app/templates/staff/user.hbs b/app/templates/staff/user.hbs index 2e2939837c..a490a5556a 100644 --- a/app/templates/staff/user.hbs +++ b/app/templates/staff/user.hbs @@ -124,6 +124,7 @@ {{gh-text-input id="user-name" class="user-name" + readonly=true placeholder="Full Name" autocorrect="off" value=(readonly user.name) @@ -164,6 +165,7 @@ type="email" id="user-email" name="email" + readonly=true placeholder="Email Address" autocapitalize="off" autocorrect="off" @@ -179,6 +181,13 @@

Used for notifications

{{/gh-form-group}} + {{#if user.isContributor}} + {{#gh-form-group errors=user.errors hasValidated=user.hasValidated property="email"}} + + {{parentUser.name}} + {{/gh-form-group}} + {{/if}} + {{#if rolesDropdownIsVisible}}
@@ -279,65 +288,5 @@ {{! user details form }} - - {{!-- If an administrator is viewing Owner's profile then hide inputs for change password --}} - {{#if canChangePassword}} - {{! change password form }} - {{/if}}
diff --git a/app/validators/invite-user.js b/app/validators/invite-user.js index e3c6a2deb9..f58f31546d 100644 --- a/app/validators/invite-user.js +++ b/app/validators/invite-user.js @@ -3,16 +3,13 @@ import validator from 'validator'; import {isBlank} from '@ember/utils'; export default BaseValidator.create({ - properties: ['email', 'role'], + properties: ['username', 'role'], - email(model) { - let email = model.get('email'); + username(model) { + let username = model.get('username'); - if (isBlank(email)) { - model.get('errors').add('email', 'Please enter an email.'); - this.invalidate(); - } else if (!validator.isEmail(email)) { - model.get('errors').add('email', 'Invalid Email.'); + if (isBlank(username)) { + model.get('errors').add('username', 'Please enter a username.'); this.invalidate(); } }, diff --git a/app/validators/post.js b/app/validators/post.js index 3219855edd..d09cf246d1 100644 --- a/app/validators/post.js +++ b/app/validators/post.js @@ -7,6 +7,7 @@ export default BaseValidator.create({ properties: [ 'title', 'authors', + 'roomName', 'customExcerpt', 'canonicalUrl', 'codeinjectionHead', @@ -17,6 +18,8 @@ export default BaseValidator.create({ 'ogDescription', 'twitterTitle', 'twitterDescription', + 'rcTitle', + 'rcDescription', 'publishedAtBlogTime', 'publishedAtBlogDate' ], @@ -120,6 +123,20 @@ export default BaseValidator.create({ this.invalidate(); } }, + + rcTitle(model) { + if (!validator.isLength(model.rcTitle || '', 0, 300)) { + model.errors.add('rcTitle', 'Message Title cannot be longer than 300 characters.'); + this.invalidate(); + } + }, + + rcDescription(model) { + if (!validator.isLength(model.rcDescription || '', 0, 500)) { + model.errors.add('rcDescription', 'Message Description cannot be longer than 500 characters.'); + this.invalidate(); + } + }, // for posts which haven't been published before and where the blog date/time // is blank we should ignore the validation _shouldValidatePublishedAtBlog(model) {