0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-18 02:21:47 -05:00

Refactored staff user modals

refs https://github.com/TryGhost/Team/issues/1734
refs https://github.com/TryGhost/Team/issues/559
refs https://github.com/TryGhost/Ghost/issues/14101

- switches to newer modal patterns ready for later Ember upgrades
- cleaned up the `upload-image` modal which had multiple areas of code that were no longer being used
- disabled `no-duplicate-landmark-elements` template lint rule as it's buggy and mostly gives false positives
This commit is contained in:
Kevin Ansfield 2022-10-03 21:15:27 +01:00
parent 74d66ca9be
commit 85cce39af7
36 changed files with 566 additions and 662 deletions

View file

@ -858,3 +858,32 @@ remove|ember-template-lint|no-unused-block-params|1|0|1|0|6640f2d788e2ebf173ee6a
add|ember-template-lint|no-duplicate-attributes|9|4|9|4|1bd74ed221db1070a4ef257ccb712285f6e4ebc6|1663977600000|1674349200000|1679533200000|app/components/gh-members-segment-select.hbs
add|ember-template-lint|table-groups|29|12|29|12|fd6c7d9c26f38dac21ab2603a31d20617717ab33|1663977600000|1674349200000|1679533200000|app/templates/offers.hbs
add|ember-template-lint|no-unused-block-params|1|0|1|0|1709beda164cdda6c0196211f71d73a81b9251dd|1663977600000|1674349200000|1679533200000|lib/koenig-editor/addon/components/koenig-card-email-cta.hbs
remove|ember-template-lint|no-action|47|93|47|93|a927cb3d62f088a149f64288c634921673563e7d|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|83|29|83|29|c81a8b61bc2a6a24c5103fd71843b71c96a7e413|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|84|27|84|27|a927cb3d62f088a149f64288c634921673563e7d|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|53|99|53|99|7628e64f5e59b4de2f529e029596f5570a4827ae|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|91|29|91|29|509c2ee1c3b1c57ee10b62649404561d5817de7f|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|92|27|92|27|7628e64f5e59b4de2f529e029596f5570a4827ae|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|60|103|60|103|a98190ea4757d865b31ad00184c6cc12a89c1009|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|102|31|102|31|a98190ea4757d865b31ad00184c6cc12a89c1009|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|107|33|107|33|34da22516e03547d6f322fe271a3251add8f3ec9|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|108|31|108|31|a98190ea4757d865b31ad00184c6cc12a89c1009|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|34|54|34|54|5af61080eb82340db68e59c287a1e9273f805b1e|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|39|49|39|49|7f6812a5fb3d34d95b4e27ce4b9691b3065f7b8c|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|40|47|40|47|5af61080eb82340db68e59c287a1e9273f805b1e|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|473|62|473|62|4150a56e60c88b9d8203a48c28069896522205ed|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|493|49|493|49|60dfff263d99797e3bcdf455c9c6ccf8ae539f1e|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|494|47|494|47|a1a5fab148882be2de9da5aca1d7d7e39e65a326|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-duplicate-landmark-elements|459|16|459|16|c7e339eb9f5d83115a7fda2636d1487ba11b133d|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-duplicate-landmark-elements|19|4|19|4|75d318fd9d711332a80f28848acc874ec14b0f4f|1662681600000|1673053200000|1678237200000|app/components/modal-members-label-form.hbs
remove|ember-template-lint|no-duplicate-landmark-elements|16|16|16|16|1661d2edb187b634c8187e5ecb0db15a4c7262fc|1662681600000|1673053200000|1678237200000|app/templates/signin.hbs
remove|ember-template-lint|no-duplicate-landmark-elements|46|16|46|16|f2740bc03b393e8708035d4952a2ab630472bd22|1662681600000|1673053200000|1678237200000|app/templates/settings/navigation.hbs
remove|ember-template-lint|no-duplicate-landmark-elements|398|16|398|16|f8a398a428b26623555526f6e625aeca79d8dc72|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|210|75|210|75|e0b0429cdf24580adc31da56392cc9814a42b2bb|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|218|45|218|45|4a66eafd7ad8fea066c11d298665e40bb02e3fc1|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|219|43|219|43|923e950a7705bc29217afe2e0a81dbf6012777f1|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-invalid-interactive|210|62|210|62|19a5403007099acc29f4f7bbbb8fd008405002eb|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|125|88|125|88|ea7c6e414cc202b6e4049eae4f60eb0829bd3ff0|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|129|35|129|35|ea7c6e414cc202b6e4049eae4f60eb0829bd3ff0|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|136|42|136|42|7a2a7a1b2159d811b310a9b89817108ddff9df88|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|140|35|140|35|7a2a7a1b2159d811b310a9b89817108ddff9df88|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs

View file

@ -4,6 +4,7 @@ module.exports = {
rules: {
'no-forbidden-elements': ['meta', 'html', 'script'],
'no-implicit-this': {allow: ['noop', 'now', 'site-icon-style', 'accent-color-background']},
'no-inline-styles': false
'no-inline-styles': false,
'no-duplicate-landmark-elements': false
}
};

View file

@ -248,10 +248,11 @@ export default Component.extend({
message = `The image type you uploaded is not supported. Please use ${validExtensions}`;
} else if (isRequestEntityTooLargeError(error)) {
message = 'The image you uploaded was larger than the maximum file size your server allows.';
} else if (error.payload.errors && !isBlank(error.payload.errors[0].message)) {
} else if (!isBlank(error.payload?.errors[0]?.message)) {
message = error.payload.errors[0].message;
} else {
message = 'Something went wrong :(';
console.error(error); // eslint-disable-line
}
this.set('failureMessage', message);

View file

@ -1,31 +0,0 @@
<header class="modal-header" data-test-modal="delete-user">
<h1>Are you sure you want to delete this user?</h1>
</header>
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
<div class="modal-body">
{{#if this.user.count.posts}}
<p>
<strong>{{this.user.name}}</strong> will be permanently deleted and their <strong data-test-text="user-post-count">{{gh-pluralize this.user.count.posts 'post'}}</strong> will be automatically assigned to <strong>{{this.ownerUser.name}}</strong>.
</p>
<p class="gh-transfer-tag">
To make these easy to find in the future, each post will be given an internal tag of <strong>#{{this.user.slug}}</strong>
</p>
{{else}}
<p>
<strong>{{this.user.name}}</strong> will be permanently deleted.
</p>
{{/if}}
</div>
<div class="modal-footer">
<button class="gh-btn" data-test-button="cancel-delete-user" type="button" {{action "closeModal"}}>
<span>Cancel</span>
</button>
<GhTaskButton @buttonText="Delete user"
@successText="Deleted"
@task={{this.deleteUser}}
@class="gh-btn gh-btn-red gh-btn-icon"
data-test-button="confirm-delete-user" />
</div>

View file

@ -1,31 +0,0 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import {alias} from '@ember/object/computed';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default ModalComponent.extend({
store: service(),
// Allowed actions
confirm: () => {},
user: alias('model'),
actions: {
confirm() {
this.deleteUser.perform();
}
},
get ownerUser() {
return this.store.peekAll('user').findBy('isOwnerOnly', true);
},
deleteUser: task(function* () {
try {
yield this.confirm();
} finally {
this.send('closeModal');
}
}).drop()
});

View file

@ -1,5 +1,4 @@
<div class="gh-member-import-wrapper {{if (or (eq this.state 'MAPPING') (eq this.state 'UPLOADING')) "wide"}}">
{{!-- template-lint-disable no-duplicate-landmark-elements --}}
{{#if (eq this.state 'INIT')}}
<header class="modal-header" data-test-modal="import-members">
<h1>Import members</h1>

View file

@ -1,20 +0,0 @@
<header class="modal-header">
<h1>Regenerate your Staff Access Token</h1>
</header>
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
<div class="modal-body">
<p>
You can regenerate your Staff Access Token any time, but any scripts or applications using it will need to be updated.
</p>
{{#if this.errorMessage}}
<p class='red'> {{this.errorMessage}}</p>
{{/if}}
</div>
<div class="modal-footer">
<button class="gh-btn" autofocus type="button" {{action "closeModal"}}><span>Cancel</span></button>
<button class="gh-btn gh-btn-icon gh-btn-red" type="button" {{action "confirm"}}>
<span>Regenerate your Staff Access Token</span>
</button>
</div>

View file

@ -1,10 +0,0 @@
import ModalComponent from 'ghost-admin/components/modal-base';
export default ModalComponent.extend({
actions: {
confirm() {
this.confirm();
this.send('closeModal');
}
}
});

View file

@ -1,16 +0,0 @@
<header class="modal-header">
<h1>Change user role</h1>
</header>
<a class="close" href="" role="button" title="Close" {{on "click" this.close}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
<div class="modal-body" {{did-insert this.setRoleFromModel}}>
<GhRoleSelection
@selected={{this.role}}
@setRole={{fn (mut this.role)}}
/>
</div>
<div class="modal-footer">
<button class="gh-btn" type="button" {{on "click" this.close}}><span>Cancel</span></button>
<button class="gh-btn gh-btn-black" type="button" {{on "click" this.confirmAction}}><span>Change role</span></button>
</div>

View file

@ -1,39 +0,0 @@
import ModalBase from 'ghost-admin/components/modal-base';
import classic from 'ember-classic-decorator';
import {action} from '@ember/object';
import {tracked} from '@glimmer/tracking';
// TODO: update modals to work fully with Glimmer components
@classic
export default class ModalPostPreviewComponent extends ModalBase {
@tracked role;
// TODO: rename to confirm() when modals have full Glimmer support
@action
confirmAction() {
this.confirm(this.role);
this.close();
}
@action
close(event) {
event?.preventDefault?.();
this.closeModal();
}
@action
setRoleFromModel() {
this.role = this.model;
}
actions = {
confirm() {
this.confirmAction(...arguments);
},
// needed because ModalBase uses .send() for keyboard events
closeModal() {
this.close();
}
};
}

View file

@ -1,13 +0,0 @@
<header class="modal-header">
<h1>Are you sure you want to suspend this user?</h1>
</header>
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
<div class="modal-body">
<strong>WARNING:</strong> This user will no longer be able to log in but their posts will be kept.
</div>
<div class="modal-footer">
<button class="gh-btn" type="button" {{action "closeModal"}}><span>Cancel</span></button>
<GhTaskButton @buttonText="Suspend" @successText="Suspended" @task={{this.suspendUser}} @class="gh-btn gh-btn-red gh-btn-icon" data-test-modal-confirm="true" />
</div>

View file

@ -1,24 +0,0 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import {alias} from '@ember/object/computed';
import {task} from 'ember-concurrency';
export default ModalComponent.extend({
// Allowed actions
confirm: () => {},
user: alias('model'),
actions: {
confirm() {
return this.suspendUser.perform();
}
},
suspendUser: task(function* () {
try {
yield this.confirm();
} finally {
this.send('closeModal');
}
}).drop()
});

View file

@ -1,16 +0,0 @@
<header class="modal-header">
<h1>Transfer Ownership</h1>
</header>
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
<div class="modal-body">
<p>
Are you sure you want to transfer the ownership of this blog?
You will not be able to undo this action.
</p>
</div>
<div class="modal-footer">
<button class="gh-btn" type="button" {{action "closeModal"}}><span>Cancel</span></button>
<GhTaskButton @buttonText="Yep - I'm sure" @task={{this.transferOwnership}} @class="gh-btn gh-btn-red gh-btn-icon" />
</div>

View file

@ -1,23 +0,0 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import {task} from 'ember-concurrency';
export default ModalComponent.extend({
user: null,
// Allowed actions
confirm: () => {},
actions: {
confirm() {
this.transferOwnership.perform();
}
},
transferOwnership: task(function* () {
try {
yield this.confirm();
} finally {
this.send('closeModal');
}
}).drop()
});

View file

@ -1,13 +0,0 @@
<header class="modal-header">
<h1>Are you sure you want to un-suspend this user?</h1>
</header>
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
<div class="modal-body">
<strong>WARNING:</strong> This user will be able to log in again and will have the same permissions they had previously.
</div>
<div class="modal-footer">
<button class="gh-btn" type="button" {{action "closeModal"}}><span>Cancel</span></button>
<GhTaskButton @buttonText="Un-suspend" @successText="Suspended" @task={{this.unsuspendUser}} @class="gh-btn gh-btn-red gh-btn-icon" data-test-modal-confirm="true" />
</div>

View file

@ -1,24 +0,0 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import {alias} from '@ember/object/computed';
import {task} from 'ember-concurrency';
export default ModalComponent.extend({
// Allowed actions
confirm: () => {},
user: alias('model'),
actions: {
confirm() {
return this.unsuspendUser.perform();
}
},
unsuspendUser: task(function* () {
try {
yield this.confirm();
} finally {
this.send('closeModal');
}
}).drop()
});

View file

@ -1,20 +0,0 @@
<header class="modal-header" data-test-modal="upgrade-unsuspended-user-host-limit">
<h1>Upgrade to un-suspend this user</h1>
</header>
<button class="close" title="Close" type="button" {{on "click" this.closeModal}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
<p>
{{html-safe this.model.message}} To give this user access to Ghost, upgrade to a different plan.
</p>
</div>
<div class="modal-footer">
<button class="gh-btn" data-test-button="cancel-upgrade" type="button" {{on "click" this.closeModal}}>
<span>Cancel</span>
</button>
<button class="gh-btn gh-btn-green" data-test-button="upgrade-plan" type="button" {{on "click" (action "upgrade")}}>
<span>Upgrade my plan</span>
</button>
</div>

View file

@ -1,16 +0,0 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import {inject as service} from '@ember/service';
export default ModalComponent.extend({
router: service(),
actions: {
upgrade() {
this.router.transitionTo('pro');
},
confirm() {
this.send('upgrade');
}
}
});

View file

@ -1,32 +0,0 @@
<div class="modal-body">
{{#if this.url}}
<div class="gh-image-uploader -with-image">
<div><img src={{this.url}}></div>
<a class="image-delete" title="Delete" {{action 'removeImage'}}>
{{svg-jar "trash"}}
<span class="hidden">Delete</span>
</a>
</div>
{{else}}
<GhImageUploader
@image={{this.newUrl}}
@saveButton={{false}}
@update={{action "fileUploaded"}}
@uploadStarted={{action "isUploading"}}
@uploadFinished={{action "isUploading"}}
@accept={{this.model.accept}}
@extensions={{this.model.extensions}}
@uploadUrl={{this.model.uploadUrl}}
@paramsHash={{this.model.paramsHas}}
/>
{{/if}}
</div>
<div class="modal-footer">
<button class="gh-btn" type="button" {{action "closeModal"}}><span>Cancel</span></button>
{{#if this._isUploading}}
<button class="gh-btn gh-btn-black right gh-btn-icon disabled" type="button"><span>Save</span></button>
{{else}}
<GhTaskButton @task={{this.uploadImage}} @class="gh-btn gh-btn-black right gh-btn-icon" data-test-modal-accept-button={{true}} />
{{/if}}
</div>

View file

@ -1,109 +0,0 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import cajaSanitizers from 'ghost-admin/utils/caja-sanitizers';
import {computed} from '@ember/object';
import {isEmpty} from '@ember/utils';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default ModalComponent.extend({
config: service(),
notifications: service(),
model: null,
url: '',
newUrl: '',
_isUploading: false,
image: computed('model.{model,imageProperty}', {
get() {
let imageProperty = this.get('model.imageProperty');
return this.get(`model.model.${imageProperty}`);
},
set(key, value) {
let model = this.get('model.model');
let imageProperty = this.get('model.imageProperty');
return model.set(imageProperty, value);
}
}),
didReceiveAttrs() {
this._super(...arguments);
let image = this.image;
this.set('url', image);
this.set('newUrl', image);
},
actions: {
fileUploaded(url) {
this.set('url', url);
this.set('newUrl', url);
},
removeImage() {
this.set('url', '');
this.set('newUrl', '');
},
confirm() {
this.uploadImage.perform();
},
isUploading() {
this.toggleProperty('_isUploading');
}
},
// TODO: should validation be handled in the gh-image-uploader component?
// pro - consistency everywhere, simplification here
// con - difficult if the "save" is happening externally as it does here
//
// maybe it should be handled at the model level?
// - automatically present everywhere
// - file uploads should always result in valid urls so it should only
// affect the url input form
keyDown() {
this._setErrorState(false);
},
_setErrorState(state) {
if (state) {
this.element.querySelector('.url').classList.add('error');
} else {
this.element.querySelector('.url').classList.remove('error');
}
},
_validateUrl(url) {
if (!isEmpty(url) && !cajaSanitizers.url(url)) {
this._setErrorState(true);
return {message: 'Image URI is not valid'};
}
return true;
},
// end validation
uploadImage: task(function* () {
let model = this.get('model.model');
let newUrl = this.newUrl;
let result = this._validateUrl(newUrl);
let notifications = this.notifications;
if (result === true) {
this.set('image', newUrl);
try {
yield model.save();
} catch (e) {
notifications.showAPIError(e, {key: 'image.upload'});
} finally {
this.send('closeModal');
}
}
}).drop()
});

View file

@ -0,0 +1,34 @@
<div class="modal-content" data-test-modal="delete-user">
<header class="modal-header">
<h1>Are you sure you want to delete this user?</h1>
</header>
<button type="button" class="close" title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
{{#if @data.user.count.posts}}
<p>
<strong>{{@data.user.name}}</strong> will be permanently deleted and their <strong data-test-text="user-post-count">{{gh-pluralize @data.user.count.posts 'post'}}</strong> will be automatically assigned to <strong>{{this.ownerUser.name}}</strong>.
</p>
<p class="gh-transfer-tag">
To make these easy to find in the future, each post will be given an internal tag of <strong>#{{@data.user.slug}}</strong>
</p>
{{else}}
<p>
<strong>{{@data.user.name}}</strong> will be permanently deleted.
</p>
{{/if}}
</div>
<div class="modal-footer">
<button class="gh-btn" data-test-button="cancel-delete-user" type="button" {{on "click" @close}}>
<span>Cancel</span>
</button>
<GhTaskButton
@buttonText="Delete user"
@successText="Deleted"
@task={{this.deleteUserTask}}
@class="gh-btn gh-btn-red gh-btn-icon"
data-test-button="confirm-delete-user" />
</div>
</div>

View file

@ -0,0 +1,31 @@
import Component from '@glimmer/component';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default class DeleteUserModal extends Component {
@service notifications;
@service router;
@service store;
get ownerUser() {
return this.store.peekAll('user').findBy('isOwnerOnly', true);
}
@task({drop: true})
*deleteUserTask() {
try {
const {user} = this.args.data;
yield user.destroyRecord();
this.notifications.closeAlerts('user.delete');
this.store.unloadAll('post');
this.router.transitionTo('settings.staff');
} catch (error) {
this.notifications.showAlert('The user could not be deleted. Please try again.', {type: 'error', key: 'user.delete.failed'});
throw error;
} finally {
this.args.close();
}
}
}

View file

@ -0,0 +1,22 @@
<div class="modal-content" data-test-modal="regenerate-staff-token">
<header class="modal-header">
<h1>Regenerate your Staff Access Token</h1>
</header>
<button type="button" class="close" title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
<p>
You can regenerate your Staff Access Token any time, but any scripts or applications using it will need to be updated.
</p>
{{#if this.errorMessage}}
<p class='red'> {{this.errorMessage}}</p>
{{/if}}
</div>
<div class="modal-footer">
<button class="gh-btn" type="button" {{on "click" @close}}><span>Cancel</span></button>
<button class="gh-btn gh-btn-icon gh-btn-red" type="button" {{on "click" this.regenerateStaffToken}}>
<span>Regenerate your Staff Access Token</span>
</button>
</div>
</div>

View file

@ -0,0 +1,23 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class RegenerateStaffTokenModal extends Component {
@service ajax;
@service ghostPaths;
@service notifications;
@action
async regenerateStaffToken() {
const url = this.ghostPaths.url.api('users', 'me', 'token');
try {
const {apiKey} = await this.ajax.put(url, {data: {}});
this.args.close(`${apiKey.id}:${apiKey.secret}`);
} catch (error) {
this.notifications.showAPIError(error, {key: 'token.regenerate'});
this.args.close();
}
}
}

View file

@ -0,0 +1,18 @@
<div class="modal-content" data-test-modal="select-role">
<header class="modal-header">
<h1>Change user role</h1>
</header>
<button type="button" class="close" title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
<GhRoleSelection
@selected={{this.role}}
@setRole={{fn (mut this.role)}}
/>
</div>
<div class="modal-footer">
<button class="gh-btn" type="button" {{on "click" @close}}><span>Cancel</span></button>
<button class="gh-btn gh-btn-black" type="button" {{on "click" (fn @close this.role)}}><span>Change role</span></button>
</div>
</div>

View file

@ -0,0 +1,11 @@
import Component from '@glimmer/component';
import {tracked} from '@glimmer/tracking';
export default class SelectRoleModal extends Component {
@tracked role;
constructor() {
super(...arguments);
this.role = this.args.data.currentRole;
}
}

View file

@ -0,0 +1,21 @@
<div class="modal-content">
<header class="modal-header">
<h1>Are you sure you want to suspend this user?</h1>
</header>
<button type="button" class="close" title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
<strong>WARNING:</strong> This user will no longer be able to log in but their posts will be kept.
</div>
<div class="modal-footer">
<button class="gh-btn" type="button" {{on "click" @close}}><span>Cancel</span></button>
<GhTaskButton
@buttonText="Suspend"
@successText="Suspended"
@task={{this.suspendUserTask}}
@class="gh-btn gh-btn-red gh-btn-icon"
data-test-modal-confirm="true"
/>
</div>
</div>

View file

@ -0,0 +1,24 @@
import Component from '@glimmer/component';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default class SuspendUserModal extends Component {
@service notifications;
@task({drop: true})
*suspendUserTask() {
try {
const {user, saveTask} = this.args.data;
user.status = 'inactive';
yield saveTask.perform();
this.notifications.closeAlerts('user.suspend');
} catch (error) {
this.notifications.showAlert('The user could not be suspended. Please try again.', {type: 'error', key: 'user.suspend.failed'});
throw error;
} finally {
this.args.close();
}
}
}

View file

@ -0,0 +1,22 @@
<div class="modal-content" data-test-modal="transfer-owner">
<header class="modal-header">
<h1>Transfer Ownership</h1>
</header>
<button type="button" class="close" title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
<p>
Are you sure you want to transfer the ownership of this blog?
You will not be able to undo this action.
</p>
</div>
<div class="modal-footer">
<button class="gh-btn" type="button" {{on "click" @close}}><span>Cancel</span></button>
<GhTaskButton
@buttonText="Yep - I'm sure"
@task={{this.transferOwnershipTask}}
@class="gh-btn gh-btn-red gh-btn-icon"
/>
</div>
</div>

View file

@ -0,0 +1,47 @@
import Component from '@glimmer/component';
import {isArray} from '@ember/array';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default class TransferOwnershipModal extends Component {
@service ajax;
@service dropdown;
@service ghostPaths;
@service notifications;
@service store;
@task({drop: true})
*transferOwnershipTask() {
try {
const {user} = this.args.data;
this.dropdown.closeDropdowns();
const response = yield this.ajax.put(url, {
data: {
owner: [{
id: user.id
}]
}
});
// manually update the roles for the users that just changed roles
// because store.pushPayload is not working with embedded relations
if (isArray(response?.users)) {
response.users.forEach((userJSON) => {
const updatedUser = this.store.peekRecord('user', userJSON.id);
const role = this.store.peekRecord('role', userJSON.roles[0].id);
updatedUser.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'});
throw error;
} finally {
this.args.close();
}
}
}

View file

@ -0,0 +1,46 @@
{{#if this.hostLimitError}}
<div class="modal-content" data-test-modal="upgrade-unsuspend-user-host-limit">
<header class="modal-header">
<h1>Upgrade to un-suspend this user</h1>
</header>
<button class="close" title="Close" type="button" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
<p>
{{html-safe this.hostLimitError}} To give this user access to Ghost, upgrade to a different plan.
</p>
</div>
<div class="modal-footer">
<button class="gh-btn" data-test-button="cancel-upgrade" type="button" {{on "click" @close}}>
<span>Cancel</span>
</button>
<button class="gh-btn gh-btn-green" data-test-button="upgrade-plan" type="button" {{on "click" this.upgrade}}>
<span>Upgrade my plan</span>
</button>
</div>
</div>
{{else}}
<div class="modal-content" data-test-modal="unsuspend-user">
<header class="modal-header">
<h1>Are you sure you want to un-suspend this user?</h1>
</header>
<button type="button" class="close" title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
<strong>WARNING:</strong> This user will be able to log in again and will have the same permissions they had previously.
</div>
<div class="modal-footer">
<button class="gh-btn" type="button" {{on "click" @close}}><span>Cancel</span></button>
<GhTaskButton
@buttonText="Un-suspend"
@successText="Suspended"
@task={{this.unsuspendUserTask}}
@class="gh-btn gh-btn-red gh-btn-icon"
data-test-modal-confirm="true"
/>
</div>
</div>
{{/if}}

View file

@ -0,0 +1,57 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
export default class UnsuspendUserModal extends Component {
@service limit;
@service notifications;
@service router;
@tracked hostLimitError = null;
constructor() {
super(...arguments);
this.checkHostLimitsTask.perform();
}
@action
upgrade() {
this.router.transitionTo('pro');
this.args.close();
}
@task
*checkHostLimitsTask() {
if (this.args.data.user.role.name !== 'Contributor' && this.limit.limiter.isLimited('staff')) {
try {
yield this.limit.limiter.errorIfWouldGoOverLimit('staff');
} catch (error) {
if (error.errorType === 'HostLimitError') {
this.hostLimitError = error.message;
} else {
this.notifications.showAPIError(error, {key: 'staff.limit'});
this.args.close();
}
}
}
}
@task({drop: true})
*unsuspendUserTask() {
try {
const {user, saveTask} = this.args.data;
user.status = 'active';
yield saveTask.perform();
this.notifications.closeAlerts('user.unsuspend');
} catch (error) {
this.notifications.showAlert('The user could not be unsuspended. Please try again.', {type: 'error', key: 'user.unsuspend.failed'});
throw error;
} finally {
this.args.close();
}
}
}

View file

@ -0,0 +1,38 @@
<div class="modal-content" data-test-modal="upload-image">
<div class="modal-body">
{{#if this.url}}
<div class="gh-image-uploader -with-image">
<div><img src={{this.url}} alt="" role="presentation"></div>
<button type="button" class="image-delete" title="Delete" {{on "click" this.removeImage}}>
{{svg-jar "trash"}}
<span class="hidden">Delete</span>
</button>
</div>
{{else}}
<GhImageUploader
@image={{this.url}}
@saveButton={{false}}
@update={{this.fileUploaded}}
@uploadStarted={{fn (mut this.isUploading) true}}
@uploadFinished={{fn (mut this.isUploading) false}}
@accept={{@data.accept}}
@extensions={{@data.extensions}}
@uploadUrl={{@data.uploadUrl}}
@paramsHash={{@data.paramsHas}}
/>
{{/if}}
</div>
<div class="modal-footer">
<button class="gh-btn" type="button" {{on "click" @close}}><span>Cancel</span></button>
{{#if this.isUploading}}
<button class="gh-btn gh-btn-black right gh-btn-icon disabled" type="button"><span>Save</span></button>
{{else}}
<GhTaskButton
@task={{this.uploadImageTask}}
@class="gh-btn gh-btn-black right gh-btn-icon"
data-test-modal-accept-button
/>
{{/if}}
</div>
</div>

View file

@ -0,0 +1,52 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
export default class UploadImageModal extends Component {
@service notifications;
@tracked errorMessage;
@tracked isUploading = false;
@tracked url = '';
constructor() {
super(...arguments);
this.url = this._getModelProperty();
}
@action
fileUploaded(url) {
this.url = url;
}
@action
removeImage() {
this.url = '';
}
@task({drop: true})
*uploadImageTask() {
this._setModelProperty(this.url);
try {
yield this.args.data.model.save();
} catch (e) {
this.notifications.showAPIError(e, {key: 'image.upload'});
} finally {
this.args.close();
}
}
_getModelProperty() {
const {model, modelProperty} = this.args.data;
return model[modelProperty];
}
_setModelProperty(url) {
const {model, modelProperty} = this.args.data;
model[modelProperty] = url;
return url;
}
}

View file

@ -1,4 +1,11 @@
import Controller from '@ember/controller';
import DeleteUserModal from '../../../components/settings/staff/modals/delete-user';
import RegenerateStaffTokenModal from '../../../components/settings/staff/modals/regenerate-staff-token';
import SelectRoleModal from '../../../components/settings/staff/modals/select-role';
import SuspendUserModal from '../../../components/settings/staff/modals/suspend-user';
import TransferOwnershipModal from '../../../components/settings/staff/modals/transfer-ownership';
import UnsuspendUserModal from '../../../components/settings/staff/modals/unsuspend-user';
import UploadImageModal from '../../../components/settings/staff/modals/upload-image';
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';
@ -6,7 +13,6 @@ import validator from 'validator';
import windowProxy from 'ghost-admin/utils/window-proxy';
import {action, computed} from '@ember/object';
import {alias, and, not, or, readOnly} from '@ember/object/computed';
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';
@ -14,26 +20,17 @@ import {task, taskGroup, timeout} from 'ember-concurrency';
export default Controller.extend({
ajax: service(),
config: service(),
dropdown: service(),
ghostPaths: service(),
limit: service(),
membersUtils: service(),
modals: service(),
notifications: service(),
session: service(),
slugGenerator: service(),
utils: service(),
membersUtils: service(),
personalToken: null,
limitErrorMessage: null,
personalTokenRegenerated: false,
dirtyAttributes: false,
showDeleteUserModal: false,
showSuspendUserModal: false,
showTransferOwnerModal: false,
showUploadCoverModal: false,
showUploadImageModal: false,
showRegenerateTokenModal: false,
showRoleSelectionModal: false,
_scratchFacebook: null,
_scratchTwitter: null,
@ -88,62 +85,6 @@ export default Controller.extend({
}),
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');
@ -255,50 +196,6 @@ export default Controller.extend({
}
},
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'});
});
},
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) {
@ -317,28 +214,77 @@ export default Controller.extend({
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'});
});
}
},
deleteUser: action(async function () {
if (this.deleteUserActionIsVisible) {
await this.modals.open(DeleteUserModal, {
user: this.model
});
}
}),
suspendUser: action(async function () {
if (this.deleteUserActionIsVisible) {
await this.modals.open(SuspendUserModal, {
user: this.model,
saveTask: this.save
});
}
}),
unsuspendUser: action(async function () {
if (this.deleteUserActionIsVisible) {
await this.modals.open(UnsuspendUserModal, {
user: this.model,
saveTask: this.save
});
}
}),
transferOwnership: action(async function () {
if (this.canMakeOwner) {
await this.modals.open(TransferOwnershipModal, {
user: this.model
});
}
}),
regenerateStaffToken: action(async function () {
const apiToken = await this.modals.open(RegenerateStaffTokenModal);
if (apiToken) {
this.set('personalToken', apiToken);
this.set('personalTokenRegenerated', true);
}
}),
selectRole: action(async function () {
const newRole = await this.modals.open(SelectRoleModal, {
currentRole: this.model.role
});
if (newRole) {
this.user.role = newRole;
this.set('dirtyAttributes', true);
}
}),
changeCoverImage: action(async function () {
await this.modals.open(UploadImageModal, {
model: this.model,
modelProperty: 'coverImage'
});
}),
changeProfileImage: action(async function () {
await this.modals.open(UploadImageModal, {
model: this.model,
modelProperty: 'profileImage'
});
}),
reset: action(function () {
this.user.rollbackAttributes();
this.user.password = '';
@ -362,19 +308,6 @@ export default Controller.extend({
}
}),
deleteUser: task(function *() {
try {
yield this.user.destroyRecord();
this.notifications.closeAlerts('user.delete');
this.store.unloadAll('post');
this.transitionToRoute('settings.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');

View file

@ -31,33 +31,27 @@
<GhDropdown @name="user-actions-menu" @tagName="ul" @classNames="user-actions-menu dropdown-menu dropdown-align-right">
{{#if this.canMakeOwner}}
<li>
<button type="button" {{action "toggleTransferOwnerModal"}}>
<button type="button" {{on "click" this.transferOwnership}}>
Make owner
</button>
{{#if this.showTransferOwnerModal}}
<GhFullscreenModal @modal="transfer-owner"
@confirm={{action "transferOwnership"}}
@close={{action "toggleTransferOwnerModal"}}
@modifier="action wide" />
{{/if}}
</li>
{{/if}}
{{#if this.deleteUserActionIsVisible}}
<li>
<button class="delete" data-test-delete-button type="button" {{action "toggleDeleteUserModal"}}>
<button class="delete" data-test-delete-button type="button" {{on "click" this.deleteUser}}>
Delete user
</button>
</li>
{{#if this.user.isActive}}
<li>
<button class="suspend" data-test-suspend-button type="button" {{action "toggleSuspendUserModal"}}>
<button class="suspend" data-test-suspend-button type="button" {{on "click" this.suspendUser}}>
Suspend user
</button>
</li>
{{/if}}
{{#if this.user.isSuspended}}
<li>
<button class="unsuspend" data-test-unsuspend-button type="button" {{action "toggleUnsuspendUserModal"}}>
<button class="unsuspend" data-test-unsuspend-button type="button" {{on "click" this.unsuspendUser}}>
Un-suspend user
</button>
</li>
@ -74,39 +68,6 @@
{{/if}}
<GhTaskButton @class="gh-btn gh-btn-primary gh-btn-icon" @task={{this.save}} data-test-save-button={{true}} />
{{#if this.showDeleteUserModal}}
<GhFullscreenModal @modal="delete-user"
@model={{this.user}}
@confirm={{action (perform this.deleteUser)}}
@close={{action "toggleDeleteUserModal"}}
@modifier="action wide" />
{{/if}}
{{#if this.showSuspendUserModal}}
<GhFullscreenModal @modal="suspend-user"
@model={{this.user}}
@confirm={{action "suspendUser"}}
@close={{action "toggleSuspendUserModal"}}
@modifier="action wide" />
{{/if}}
{{#if this.showUnsuspendUserModal}}
{{#if this.limitErrorMessage}}
<GhFullscreenModal @modal="upgrade-unsuspend-user-host-limit"
@model={{hash
message=this.limitErrorMessage
}}
@close={{action "toggleUnsuspendUserModal"}}
@modifier="action wide" />
{{else}}
<GhFullscreenModal @modal="unsuspend-user"
@model={{this.user}}
@confirm={{action "unsuspendUser"}}
@close={{action "toggleUnsuspendUserModal"}}
@modifier="action wide" />
{{/if}}
{{/if}}
</section>
</GhCanvasHeader>
@ -120,24 +81,12 @@
<form class="user-profile" novalidate="novalidate" autocomplete="off" {{action (perform this.save) on="submit"}}>
<figure class="user-cover" style={{background-image-style this.user.coverImageUrl}}>
<button type="button" class="gh-btn gh-btn-default user-cover-edit" {{action "toggleUploadCoverModal"}}><span>Change cover</span></button>
{{#if this.showUploadCoverModal}}
<GhFullscreenModal @modal="upload-image"
@model={{hash model=this.user imageProperty="coverImage"}}
@close={{action "toggleUploadCoverModal"}}
@modifier="action wide" />
{{/if}}
<button type="button" class="gh-btn gh-btn-default user-cover-edit" {{on "click" this.changeCoverImage}}><span>Change cover</span></button>
</figure>
<figure class="user-image bg-whitegrey">
<div id="user-image" class="img" style={{background-image-style this.user.profileImageUrl}}><span class="hidden">{{this.user.name}}"s picture</span></div>
<button type="button" {{action "toggleUploadImageModal"}} class="edit-user-image">Edit picture</button>
{{#if this.showUploadImageModal}}
<GhFullscreenModal @modal="upload-image"
@model={{hash model=this.user imageProperty="profileImage" paramsHash=(hash purpose="profile_image")}}
@close={{action "toggleUploadImageModal"}}
@modifier="action wide" />
{{/if}}
<button type="button" class="edit-user-image" {{on "click" this.changeProfileImage}}>Edit picture</button>
</figure>
<div class="pa5">
@ -205,19 +154,9 @@
{{#if this.rolesDropdownIsVisible}}
<div class="form-group">
<label for="user-role">Role</label>
<div class="gh-input pointer" {{on "click" (action "toggleRoleSelectionModal")}}>{{this.user.role.name}}{{svg-jar "arrow-down-small"}}</div>
<button type="button" class="gh-input tl" {{on "click" this.selectRole}}>{{this.user.role.name}}{{svg-jar "arrow-down-small"}}</button>
<p>What permissions should this user have?</p>
</div>
{{#if this.showRoleSelectionModal}}
<GhFullscreenModal
@modal="select-user-role"
@model={{readonly this.user.role}}
@confirm={{action "changeRole"}}
@close={{action "toggleRoleSelectionModal"}}
@modifier="change-role"
/>
{{/if}}
{{/if}}
<GhFormGroup @errors={{this.user.errors}} @hasValidated={{this.user.hasValidated}} @property="location">
@ -468,14 +407,14 @@
onclick="this.select()"
/>
<div class="app-api-personal-token-buttons child">
<button type="button" {{action "confirmRegenerateTokenModal"}} class="app-button-regenerate" data-tooltip="Regenerate">
<button type="button" class="app-button-regenerate" {{on "click" this.regenerateStaffToken}} data-tooltip="Regenerate">
{{svg-jar "reload" class="w4 h4 stroke-midgrey"}}
</button>
<button type="button" {{action (perform this.copyContentKey)}} class="app-button-copy">
{{#if this.copyContentKey.isRunning}}
{{svg-jar "check-circle" class="w3 v-mid mr2 stroke-white"}} Copied
{{svg-jar "check-circle" class="w3 v-mid mr2 stroke-white"}} Copied
{{else}}
Copy
Copy
{{/if}}
</button>
</div>
@ -486,13 +425,6 @@
{{#if this.personalTokenRegenerated}}
<p class="green">Staff access token was successfully regenerated </p>
{{/if}}
{{#if this.showRegenerateTokenModal}}
<GhFullscreenModal @modal="regenerate-token"
@confirm={{action "regenerateToken"}}
@close={{action "cancelRegenerateTokenModal"}}
@modifier="action wide" />
{{/if}}
</GhFormGroup>
</fieldset>
</div>