From a78afb574921ec618965004bc40041022dbe8760 Mon Sep 17 00:00:00 2001 From: Peter Zimon Date: Mon, 18 Oct 2021 13:27:42 +0200 Subject: [PATCH] Added Staff as subpages of Settings - The Admin is being restructured with Offers. This commit moves Staff under Settings. --- .../app/components/gh-user-list-item.hbs | 2 +- .../app/controllers/settings/staff/index.js | 89 ++++ .../app/controllers/settings/staff/user.js | 497 ++++++++++++++++++ ghost/admin/app/router.js | 8 + .../admin/app/routes/settings/staff/index.js | 29 + ghost/admin/app/routes/settings/staff/user.js | 72 +++ ghost/admin/app/templates/settings.hbs | 22 +- .../app/templates/settings/staff/index.hbs | 147 ++++++ .../templates/settings/staff/user-loading.hbs | 19 + .../app/templates/settings/staff/user.hbs | 409 ++++++++++++++ 10 files changed, 1282 insertions(+), 12 deletions(-) create mode 100644 ghost/admin/app/controllers/settings/staff/index.js create mode 100644 ghost/admin/app/controllers/settings/staff/user.js create mode 100644 ghost/admin/app/routes/settings/staff/index.js create mode 100644 ghost/admin/app/routes/settings/staff/user.js create mode 100644 ghost/admin/app/templates/settings/staff/index.hbs create mode 100644 ghost/admin/app/templates/settings/staff/user-loading.hbs create mode 100644 ghost/admin/app/templates/settings/staff/user.hbs diff --git a/ghost/admin/app/components/gh-user-list-item.hbs b/ghost/admin/app/components/gh-user-list-item.hbs index 3c6534857b..9bb9a228b5 100644 --- a/ghost/admin/app/components/gh-user-list-item.hbs +++ b/ghost/admin/app/components/gh-user-list-item.hbs @@ -1,5 +1,5 @@
- +
diff --git a/ghost/admin/app/controllers/settings/staff/index.js b/ghost/admin/app/controllers/settings/staff/index.js new file mode 100644 index 0000000000..39a7f23941 --- /dev/null +++ b/ghost/admin/app/controllers/settings/staff/index.js @@ -0,0 +1,89 @@ +/* eslint-disable ghost/ember/alias-model-in-controller */ +import Controller from '@ember/controller'; +import RSVP from 'rsvp'; +import {alias, sort} from '@ember/object/computed'; +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; + +export default Controller.extend({ + session: service(), + store: service(), + + showInviteUserModal: false, + showResetAllPasswordsModal: false, + + inviteOrder: null, + userOrder: null, + + init() { + this._super(...arguments); + this.inviteOrder = ['email']; + this.userOrder = ['name', 'email']; + }, + + currentUser: alias('model'), + + sortedInvites: sort('filteredInvites', 'inviteOrder'), + sortedActiveUsers: sort('activeUsers', 'userOrder'), + sortedSuspendedUsers: sort('suspendedUsers', 'userOrder'), + + filteredInvites: computed.filterBy('invites', 'isNew', false), + + invites: computed(function () { + return this.store.peekAll('invite'); + }), + + allUsers: computed(function () { + return this.store.peekAll('user'); + }), + + activeUsers: computed('allUsers.@each.status', function () { + return this.allUsers.filter((user) => { + return user.status !== 'inactive'; + }); + }), + + suspendedUsers: computed('allUsers.@each.status', function () { + return this.allUsers.filter((user) => { + return user.status === 'inactive'; + }); + }), + + actions: { + toggleInviteUserModal() { + this.toggleProperty('showInviteUserModal'); + }, + toggleResetAllPasswordsModal() { + this.toggleProperty('showResetAllPasswordsModal'); + } + }, + + backgroundUpdate: task(function* () { + let users = this.fetchUsers.perform(); + let invites = this.fetchInvites.perform(); + + try { + yield RSVP.all([users, invites]); + } catch (error) { + this.send('error', error); + } + }), + + fetchUsers: task(function* () { + yield this.store.query('user', {limit: 'all'}); + }), + + fetchInvites: task(function* () { + if (this.currentUser.isAuthorOrContributor) { + return; + } + + // ensure roles are loaded before invites. Invites do not have embedded + // role records which means Ember Data will throw errors when trying to + // read the invite.role data when the role has not yet been loaded + yield this.store.query('role', {limit: 'all'}); + + return yield this.store.query('invite', {limit: 'all'}); + }) +}); diff --git a/ghost/admin/app/controllers/settings/staff/user.js b/ghost/admin/app/controllers/settings/staff/user.js new file mode 100644 index 0000000000..0aa737f324 --- /dev/null +++ b/ghost/admin/app/controllers/settings/staff/user.js @@ -0,0 +1,497 @@ +import Controller from '@ember/controller'; +import boundOneWay from 'ghost-admin/utils/bound-one-way'; +import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard'; +import isNumber from 'ghost-admin/utils/isNumber'; +import validator from 'validator'; +import windowProxy from 'ghost-admin/utils/window-proxy'; +import {alias, and, not, or, readOnly} from '@ember/object/computed'; +import {computed} from '@ember/object'; +import {isArray as isEmberArray} from '@ember/array'; +import {run} from '@ember/runloop'; +import {inject as service} from '@ember/service'; +import {task, taskGroup, timeout} from 'ember-concurrency'; + +export default Controller.extend({ + ajax: service(), + config: service(), + dropdown: service(), + ghostPaths: service(), + limit: service(), + notifications: service(), + session: service(), + slugGenerator: service(), + utils: service(), + + personalToken: null, + limitErrorMessage: null, + personalTokenRegenerated: false, + leaveSettingsTransition: null, + dirtyAttributes: false, + showDeleteUserModal: false, + showSuspendUserModal: false, + showTransferOwnerModal: false, + showUploadCoverModal: false, + showUploadImageModal: false, + showRegenerateTokenModal: false, + showRoleSelectionModal: false, + _scratchFacebook: null, + _scratchTwitter: null, + + saveHandlers: taskGroup().enqueue(), + + user: alias('model'), + currentUser: alias('session.user'), + + email: readOnly('user.email'), + slugValue: boundOneWay('user.slug'), + + canChangeEmail: not('isAdminUserOnOwnerProfile'), + canChangePassword: not('isAdminUserOnOwnerProfile'), + canMakeOwner: and('currentUser.isOwnerOnly', 'isNotOwnProfile', 'user.isAdminOnly', 'isNotSuspended'), + isAdminUserOnOwnerProfile: and('currentUser.isAdminOnly', 'user.isOwnerOnly'), + isNotOwnersProfile: not('user.isOwnerOnly'), + isNotSuspended: not('user.isSuspended'), + rolesDropdownIsVisible: and('currentUser.isAdmin', 'isNotOwnProfile', 'isNotOwnersProfile'), + userActionsAreVisible: or('deleteUserActionIsVisible', 'canMakeOwner'), + + isNotOwnProfile: not('isOwnProfile'), + isOwnProfile: computed('user.id', 'currentUser.id', function () { + return this.get('user.id') === this.get('currentUser.id'); + }), + + deleteUserActionIsVisible: computed('currentUser.{isAdmin,isEditor}', 'user.{isOwnerOnly,isAuthorOrContributor}', 'isOwnProfile', function () { + // users can't delete themselves + if (this.isOwnProfile) { + return false; + } + + if ( + // owners/admins can delete any non-owner user + (this.currentUser.get('isAdmin') && !this.user.isOwnerOnly) || + // editors can delete any author or contributor + (this.currentUser.get('isEditor') && this.user.isAuthorOrContributor) + ) { + return true; + } + + return false; + }), + + coverTitle: computed('user.name', function () { + return `${this.get('user.name')}'s Cover Image`; + }), + + roles: computed(function () { + return this.store.query('role', {permissions: 'assign'}); + }), + + actions: { + toggleRoleSelectionModal(event) { + event?.preventDefault?.(); + this.toggleProperty('showRoleSelectionModal'); + }, + + changeRole(newRole) { + this.user.set('role', newRole); + this.set('dirtyAttributes', true); + }, + + toggleDeleteUserModal() { + if (this.deleteUserActionIsVisible) { + this.toggleProperty('showDeleteUserModal'); + } + }, + + suspendUser() { + this.user.set('status', 'inactive'); + return this.save.perform(); + }, + + toggleSuspendUserModal() { + if (this.deleteUserActionIsVisible) { + this.toggleProperty('showSuspendUserModal'); + } + }, + + unsuspendUser() { + this.user.set('status', 'active'); + return this.save.perform(); + }, + + toggleUnsuspendUserModal() { + if (this.deleteUserActionIsVisible) { + if (this.user.role.name !== 'Contributor' + && this.limit.limiter + && this.limit.limiter.isLimited('staff') + ) { + this.limit.limiter.errorIfWouldGoOverLimit('staff') + .then(() => { + this.toggleProperty('showUnsuspendUserModal'); + }) + .catch((error) => { + if (error.errorType === 'HostLimitError') { + this.limitErrorMessage = error.message; + this.toggleProperty('showUnsuspendUserModal'); + } else { + this.notifications.showAPIError(error, {key: 'staff.limit'}); + } + }); + } else { + this.toggleProperty('showUnsuspendUserModal'); + } + } + }, + + validateFacebookUrl() { + let newUrl = this._scratchFacebook; + let oldUrl = this.get('user.facebook'); + let errMessage = ''; + + // reset errors and validation + this.get('user.errors').remove('facebook'); + this.get('user.hasValidated').removeObject('facebook'); + + if (newUrl === '') { + // Clear out the Facebook url + this.set('user.facebook', ''); + return; + } + + // _scratchFacebook will be null unless the user has input something + if (!newUrl) { + newUrl = oldUrl; + } + + try { + // strip any facebook URLs out + newUrl = newUrl.replace(/(https?:\/\/)?(www\.)?facebook\.com/i, ''); + + // don't allow any non-facebook urls + if (newUrl.match(/^(http|\/\/)/i)) { + throw 'invalid url'; + } + + // strip leading / if we have one then concat to full facebook URL + newUrl = newUrl.replace(/^\//, ''); + newUrl = `https://www.facebook.com/${newUrl}`; + + // don't allow URL if it's not valid + if (!validator.isURL(newUrl)) { + throw 'invalid url'; + } + + this.set('user.facebook', ''); + run.schedule('afterRender', this, function () { + this.set('user.facebook', newUrl); + }); + } catch (e) { + if (e === 'invalid url') { + errMessage = 'The URL must be in a format like ' + + 'https://www.facebook.com/yourPage'; + this.get('user.errors').add('facebook', errMessage); + return; + } + + throw e; + } finally { + this.get('user.hasValidated').pushObject('facebook'); + } + }, + + validateTwitterUrl() { + let newUrl = this._scratchTwitter; + let oldUrl = this.get('user.twitter'); + let errMessage = ''; + + // reset errors and validation + this.get('user.errors').remove('twitter'); + this.get('user.hasValidated').removeObject('twitter'); + + if (newUrl === '') { + // Clear out the Twitter url + this.set('user.twitter', ''); + return; + } + + // _scratchTwitter will be null unless the user has input something + if (!newUrl) { + newUrl = oldUrl; + } + + if (newUrl.match(/(?:twitter\.com\/)(\S+)/) || newUrl.match(/([a-z\d.]+)/i)) { + let username = []; + + if (newUrl.match(/(?:twitter\.com\/)(\S+)/)) { + [, username] = newUrl.match(/(?:twitter\.com\/)(\S+)/); + } else { + [username] = newUrl.match(/([^/]+)\/?$/mi); + } + + // check if username starts with http or www and show error if so + if (username.match(/^(http|www)|(\/)/) || !username.match(/^[a-z\d._]{1,15}$/mi)) { + errMessage = !username.match(/^[a-z\d._]{1,15}$/mi) ? 'Your Username is not a valid Twitter Username' : 'The URL must be in a format like https://twitter.com/yourUsername'; + + this.get('user.errors').add('twitter', errMessage); + this.get('user.hasValidated').pushObject('twitter'); + return; + } + + newUrl = `https://twitter.com/${username}`; + + this.get('user.hasValidated').pushObject('twitter'); + + this.set('user.twitter', ''); + run.schedule('afterRender', this, function () { + this.set('user.twitter', newUrl); + }); + } else { + errMessage = 'The URL must be in a format like ' + + 'https://twitter.com/yourUsername'; + this.get('user.errors').add('twitter', errMessage); + this.get('user.hasValidated').pushObject('twitter'); + return; + } + }, + + transferOwnership() { + let user = this.user; + let url = this.get('ghostPaths.url').api('users', 'owner'); + + this.dropdown.closeDropdowns(); + + return this.ajax.put(url, { + data: { + owner: [{ + id: user.get('id') + }] + } + }).then((response) => { + // manually update the roles for the users that just changed roles + // because store.pushPayload is not working with embedded relations + if (response && isEmberArray(response.users)) { + response.users.forEach((userJSON) => { + let updatedUser = this.store.peekRecord('user', userJSON.id); + let role = this.store.peekRecord('role', userJSON.roles[0].id); + + updatedUser.set('role', role); + }); + } + + this.notifications.showAlert(`Ownership successfully transferred to ${user.get('name')}`, {type: 'success', key: 'owner.transfer.success'}); + }).catch((error) => { + this.notifications.showAPIError(error, {key: 'owner.transfer'}); + }); + }, + + toggleLeaveSettingsModal(transition) { + let leaveTransition = this.leaveSettingsTransition; + + if (!transition && this.showLeaveSettingsModal) { + this.set('leaveSettingsTransition', null); + this.set('showLeaveSettingsModal', false); + return; + } + + if (!leaveTransition || transition.targetName === leaveTransition.targetName) { + this.set('leaveSettingsTransition', transition); + + // if a save is running, wait for it to finish then transition + if (this.get('saveHandlers.isRunning')) { + return this.get('saveHandlers.last').then(() => { + transition.retry(); + }); + } + + // we genuinely have unsaved data, show the modal + this.set('showLeaveSettingsModal', true); + } + }, + + leaveSettings() { + let transition = this.leaveSettingsTransition; + let user = this.user; + + if (!transition) { + this.notifications.showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'}); + return; + } + + // roll back changes on user props + user.rollbackAttributes(); + // roll back the slugValue property + if (this.dirtyAttributes) { + this.set('slugValue', user.get('slug')); + this.set('dirtyAttributes', false); + } + + return transition.retry(); + }, + + toggleTransferOwnerModal() { + if (this.canMakeOwner) { + this.toggleProperty('showTransferOwnerModal'); + } + }, + + toggleUploadCoverModal() { + this.toggleProperty('showUploadCoverModal'); + }, + + 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'); + }, + + confirmRegenerateTokenModal() { + this.set('showRegenerateTokenModal', true); + }, + + cancelRegenerateTokenModal() { + this.set('showRegenerateTokenModal', false); + }, + + regenerateToken() { + let url = this.get('ghostPaths.url').api('users', 'me', 'token'); + + return this.ajax.put(url, {data: {}}).then(({apiKey}) => { + this.set('personalToken', apiKey.id + ':' + apiKey.secret); + this.set('personalTokenRegenerated', true); + }).catch((error) => { + this.notifications.showAPIError(error, {key: 'token.regenerate'}); + }); + } + }, + + _exportDb(filename) { + this.utils.downloadFile(`${this.ghostPaths.url.api('db')}?filename=${filename}`); + }, + + deleteUser: task(function *() { + try { + const result = yield this.user.destroyRecord(); + + if (result._meta && result._meta.filename) { + this._exportDb(result._meta.filename); + // give the iframe some time to trigger the download before + // it's removed from the dom when transitioning + yield timeout(300); + } + + this.notifications.closeAlerts('user.delete'); + this.store.unloadAll('post'); + this.transitionToRoute('staff'); + } catch (error) { + this.notifications.showAlert('The user could not be deleted. Please try again.', {type: 'error', key: 'user.delete.failed'}); + throw error; + } + }), + + updateSlug: task(function* (newSlug) { + let slug = this.get('user.slug'); + + newSlug = newSlug || slug; + newSlug = newSlug.trim(); + + // Ignore unchanged slugs or candidate slugs that are empty + if (!newSlug || slug === newSlug) { + this.set('slugValue', slug); + + return true; + } + + let serverSlug = yield this.slugGenerator.generateSlug('user', newSlug); + + // If after getting the sanitized and unique slug back from the API + // we end up with a slug that matches the existing slug, abort the change + if (serverSlug === slug) { + return true; + } + + // Because the server transforms the candidate slug by stripping + // certain characters and appending a number onto the end of slugs + // to enforce uniqueness, there are cases where we can get back a + // candidate slug that is a duplicate of the original except for + // the trailing incrementor (e.g., this-is-a-slug and this-is-a-slug-2) + + // get the last token out of the slug candidate and see if it's a number + let slugTokens = serverSlug.split('-'); + let check = Number(slugTokens.pop()); + + // if the candidate slug is the same as the existing slug except + // for the incrementor then the existing slug should be used + if (isNumber(check) && check > 0) { + if (slug === slugTokens.join('-') && serverSlug !== newSlug) { + this.set('slugValue', slug); + + return true; + } + } + + this.set('slugValue', serverSlug); + this.set('dirtyAttributes', true); + + return true; + }).group('saveHandlers'), + + save: task(function* () { + let user = this.user; + let slugValue = this.slugValue; + let slugChanged; + + if (user.get('slug') !== slugValue) { + slugChanged = true; + user.set('slug', slugValue); + } + + try { + user = yield user.save({format: false}); + + // If the user's slug has changed, change the URL and replace + // the history so refresh and back button still work + if (slugChanged) { + let currentPath = window.location.hash; + + let newPath = currentPath.split('/'); + newPath[newPath.length - 1] = user.get('slug'); + newPath = newPath.join('/'); + + windowProxy.replaceState({path: newPath}, '', newPath); + } + + this.set('dirtyAttributes', false); + this.notifications.closeAlerts('user.update'); + + return user; + } catch (error) { + // validation engine returns undefined so we have to check + // before treating the failure as an API error + if (error) { + this.notifications.showAPIError(error, {key: 'user.update'}); + } + } + }).group('saveHandlers'), + + copyContentKey: task(function* () { + copyTextToClipboard(this.personalToken); + yield timeout(this.isTesting ? 50 : 3000); + }) +}); diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js index 873c25b5ff..59654b1e73 100644 --- a/ghost/admin/app/router.js +++ b/ghost/admin/app/router.js @@ -54,6 +54,14 @@ Router.map(function () { }); }); + this.route('settings.staff', {path: '/settings/staff'}, function () { + this.route('user', {path: ':user_slug'}); + }); + + // this.route('staff', function () { + // this.route('user', {path: ':user_slug'}); + // }); + // this.route('settings.products', {path: '/settings/products'}); // this.route('settings.product.new', {path: '/settings/product/new'}); // this.route('settings.product', {path: '/settings/product/:product_id'}); diff --git a/ghost/admin/app/routes/settings/staff/index.js b/ghost/admin/app/routes/settings/staff/index.js new file mode 100644 index 0000000000..aa6ff25868 --- /dev/null +++ b/ghost/admin/app/routes/settings/staff/index.js @@ -0,0 +1,29 @@ +import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings'; +import {inject as service} from '@ember/service'; + +export default AuthenticatedRoute.extend(CurrentUserSettings, { + infinity: service(), + session: service(), + + model() { + return this.session.user; + }, + + setupController(controller) { + this._super(...arguments); + controller.backgroundUpdate.perform(); + }, + + actions: { + reload() { + this.controller.backgroundUpdate.perform(); + } + }, + + buildRouteInfoMetadata() { + return { + titleToken: 'Staff' + }; + } +}); diff --git a/ghost/admin/app/routes/settings/staff/user.js b/ghost/admin/app/routes/settings/staff/user.js new file mode 100644 index 0000000000..1ac3fc1e19 --- /dev/null +++ b/ghost/admin/app/routes/settings/staff/user.js @@ -0,0 +1,72 @@ +/* eslint-disable camelcase */ +import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +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'}); + }, + + afterModel(user) { + this._super(...arguments); + + const currentUser = this.session.user; + + let isOwnProfile = user.get('id') === currentUser.get('id'); + let isAuthorOrContributor = currentUser.get('isAuthorOrContributor'); + let isEditor = currentUser.get('isEditor'); + + if (isAuthorOrContributor && !isOwnProfile) { + this.transitionTo('settings.staff.user', currentUser); + } else if (isEditor && !isOwnProfile && !user.get('isAuthorOrContributor')) { + this.transitionTo('settings.staff'); + } + + if (isOwnProfile) { + this.store.queryRecord('api-key', {id: 'me'}).then((apiKey) => { + this.controller.set('personalToken', apiKey.id + ':' + apiKey.secret); + this.controller.set('personalTokenRegenerated', false); + }); + } + }, + + serialize(model) { + return {user_slug: model.get('slug')}; + }, + + actions: { + didTransition() { + this.modelFor('settings.staff.user').get('errors').clear(); + }, + + save() { + this.get('controller.save').perform(); + }, + + willTransition(transition) { + let controller = this.controller; + let user = controller.user; + let dirtyAttributes = controller.dirtyAttributes; + let modelIsDirty = user.get('hasDirtyAttributes'); + + // always reset the password properties on the user model when leaving + if (user) { + user.set('password', ''); + user.set('newPassword', ''); + user.set('ne2Password', ''); + } + + if (modelIsDirty || dirtyAttributes) { + transition.abort(); + controller.send('toggleLeaveSettingsModal', transition); + return; + } + } + }, + + buildRouteInfoMetadata() { + return { + titleToken: 'Staff - User' + }; + } +}); diff --git a/ghost/admin/app/templates/settings.hbs b/ghost/admin/app/templates/settings.hbs index 78ccf43e1a..d55e9a6525 100644 --- a/ghost/admin/app/templates/settings.hbs +++ b/ghost/admin/app/templates/settings.hbs @@ -48,7 +48,7 @@ {{#if (feature "offers")}} - + {{svg-jar "staff"}}

Staff

@@ -78,6 +78,16 @@
Advanced
+ {{#if (feature "offers")}} + + {{svg-jar "module"}} +
+

Integrations

+

Make Ghost work with apps and tools

+
+
+ {{/if}} + {{svg-jar "brackets"}}
@@ -85,16 +95,6 @@

Add code to your publication

- - {{#if (feature "offers")}} - - {{svg-jar "module"}} -
-

Integrations

-

Make Ghost work with apps and tools

-
-
- {{/if}} {{svg-jar "labs"}} diff --git a/ghost/admin/app/templates/settings/staff/index.hbs b/ghost/admin/app/templates/settings/staff/index.hbs new file mode 100644 index 0000000000..62b91d81d2 --- /dev/null +++ b/ghost/admin/app/templates/settings/staff/index.hbs @@ -0,0 +1,147 @@ +
+ +

+ Settings + {{svg-jar "arrow-right"}} + Staff +

+ {{!-- Do not show Invite user button to authors --}} + {{#unless this.currentUser.isAuthorOrContributor}} +
+ {{#if (gh-user-can-admin this.session.user)}} + + + + {{svg-jar "settings"}} + + + + +
  • + +
  • +
    +
    + {{/if}} + +
    + {{/unless}} +
    + + {{#if this.showInviteUserModal}} + + {{/if}} + + {{#if this.showResetAllPasswordsModal}} + + {{/if}} + +
    + {{!-- Show invited users to everyone except authors --}} + {{#unless this.currentUser.isAuthorOrContributor}} + {{#if this.invites}} +
    +

    Invited users

    +
    + + {{#each this.sortedInvites as |invite|}} + +
    +
    +
    + {{svg-jar "email"}}ic +
    +

    {{invite.email}}

    +

    + {{#if invite.pending}} + + Invitation not sent - please try again + + {{else}} + + Invitation sent: {{component.createdAt}}, + {{if component.isExpired "expired" "expires"}} {{component.expiresAt}} + + {{/if}} +

    +
    +
    +
    +
    + {{#if component.isSending}} + Sending Invite... + {{else}} + + Revoke + + + Resend + + + {{invite.role.name}} + {{/if}} +
    +
    +
    +
    +
    + {{/each}} + +
    +
    + {{/if}} + {{/unless}} + +
    +

    Active users

    +
    + {{!-- For authors/contributors, only show their own user --}} + {{#if this.currentUser.isAuthorOrContributor}} + + + + {{else}} + {{#vertical-collection this.sortedActiveUsers + key="id" + containerSelector=".gh-main" + estimateHeight=75 + as |user| + }} + + + + {{/vertical-collection}} + {{/if}} +
    +
    +
    + + {{!-- Don't show if we have no suspended users or logged in as an author --}} + {{#if (and this.suspendedUsers (not this.currentUser.isAuthorOrContributor))}} +
    + Suspended users +
    + {{#each this.sortedSuspendedUsers key="id" as |user|}} + + + + {{/each}} +
    +
    + {{/if}} +
    diff --git a/ghost/admin/app/templates/settings/staff/user-loading.hbs b/ghost/admin/app/templates/settings/staff/user-loading.hbs new file mode 100644 index 0000000000..9167696e79 --- /dev/null +++ b/ghost/admin/app/templates/settings/staff/user-loading.hbs @@ -0,0 +1,19 @@ +
    + +

    + Settings + {{svg-jar "arrow-right"}} + Staff + {{svg-jar "arrow-right"}} + {{this.user.name}} +

    + +
    +
    Save
    +
    +
    + +
    + +
    +
    diff --git a/ghost/admin/app/templates/settings/staff/user.hbs b/ghost/admin/app/templates/settings/staff/user.hbs new file mode 100644 index 0000000000..7d2dac14cb --- /dev/null +++ b/ghost/admin/app/templates/settings/staff/user.hbs @@ -0,0 +1,409 @@ +
    + +

    + Settings + {{svg-jar "arrow-right"}} + Staff + {{svg-jar "arrow-right"}} + {{this.user.name}} + + {{#if this.user.isSuspended}} + Suspended + {{/if}} +

    + + {{#if this.showLeaveSettingsModal}} + + {{/if}} + +
    + {{#if this.userActionsAreVisible}} + + + + {{svg-jar "settings"}} + + + + + {{#if this.canMakeOwner}} +
  • + + {{#if this.showTransferOwnerModal}} + + {{/if}} +
  • + {{/if}} + {{#if this.deleteUserActionIsVisible}} +
  • + +
  • + {{#if this.user.isActive}} +
  • + +
  • + {{/if}} + {{#if this.user.isSuspended}} +
  • + +
  • + {{/if}} + {{/if}} +
    +
    + {{/if}} + + + + {{#if this.showDeleteUserModal}} + + {{/if}} + + {{#if this.showSuspendUserModal}} + + {{/if}} + + {{#if this.showUnsuspendUserModal}} + {{#if this.limitErrorMessage}} + + {{else}} + + {{/if}} + {{/if}} +
    +
    + + {{#if user.isLocked}} +

    {{svg-jar "info"}}This user account is locked. To sign in, ask this user to perform a password reset on their account.

    + {{/if}} + + {{!--
    --}} +
    +
    + {{! user details form }} + + {{!-- If an administrator is viewing Owner's profile then hide inputs for change password --}} + {{#if this.canChangePassword}} + {{! change password form }} + {{/if}} + + {{#if this.isOwnProfile}} + + {{/if}} +
    +
    +