Ghost/ghost/admin/app/controllers/team/user.js
Kevin Ansfield 0572c33e0f 🐛 Fixed failing Facebook URL validation for group urls (#906)
closes https://github.com/TryGhost/Ghost/issues/9160
- simplified FB validation to allow any valid FB url (autocomplete of raw usernames and facebook-like URLs is still in place)
- fixed a bug with sticky Twitter validation message, if you entered an invalid URL then changed it to an invalid username you still saw the invalid URL message (surfaced through new test helpers for validating facebook/twitter fields)
2017-11-10 16:38:30 +00:00

466 lines
16 KiB
JavaScript

import Controller from '@ember/controller';
import Ember from 'ember';
import boundOneWay from 'ghost-admin/utils/bound-one-way';
import isNumber from 'ghost-admin/utils/isNumber';
import windowProxy from 'ghost-admin/utils/window-proxy';
import {alias, and, not, or, readOnly} from '@ember/object/computed';
import {computed} from '@ember/object';
import {htmlSafe} from '@ember/string';
import {isArray as isEmberArray} from '@ember/array';
import {run} from '@ember/runloop';
import {inject as service} from '@ember/service';
import {task, taskGroup} from 'ember-concurrency';
// ember-cli-shims doesn't export this
const {Handlebars} = Ember;
export default Controller.extend({
leaveSettingsTransition: null,
dirtyAttributes: false,
showDeleteUserModal: false,
showSuspendUserModal: false,
showTransferOwnerModal: false,
showUploadCoverModal: false,
showUplaodImageModal: false,
_scratchFacebook: null,
_scratchTwitter: null,
ajax: service(),
config: service(),
dropdown: service(),
ghostPaths: service(),
notifications: service(),
session: service(),
slugGenerator: service(),
user: alias('model'),
currentUser: alias('session.user'),
email: readOnly('model.email'),
slugValue: boundOneWay('model.slug'),
canAssignRoles: or('currentUser.isAdmin', 'currentUser.isOwner'),
canChangeEmail: not('isAdminUserOnOwnerProfile'),
canChangePassword: not('isAdminUserOnOwnerProfile'),
canMakeOwner: and('currentUser.isOwner', 'isNotOwnProfile', 'user.isAdmin'),
isAdminUserOnOwnerProfile: and('currentUser.isAdmin', 'user.isOwner'),
isNotOwnersProfile: not('user.isOwner'),
rolesDropdownIsVisible: and('isNotOwnProfile', 'canAssignRoles', 'isNotOwnersProfile'),
userActionsAreVisible: or('deleteUserActionIsVisible', 'canMakeOwner'),
isOwnProfile: computed('user.id', 'currentUser.id', function () {
return this.get('user.id') === this.get('currentUser.id');
}),
isNotOwnProfile: not('isOwnProfile'),
deleteUserActionIsVisible: computed('currentUser', 'canAssignRoles', 'user', function () {
if ((this.get('canAssignRoles') && this.get('isNotOwnProfile') && !this.get('user.isOwner'))
|| (this.get('currentUser.isEditor') && (this.get('isNotOwnProfile')
|| this.get('user.isAuthor')))) {
return true;
}
}),
// duplicated in gh-user-active -- find a better home and consolidate?
userDefault: computed('ghostPaths', function () {
return `${this.get('ghostPaths.assetRoot')}/img/user-image.png`;
}),
userImageBackground: computed('user.profileImage', 'userDefault', function () {
let url = this.get('user.profileImage') || this.get('userDefault');
let safeUrl = Handlebars.Utils.escapeExpression(url);
return htmlSafe(`background-image: url(${safeUrl})`);
}),
// end duplicated
coverDefault: computed('ghostPaths', function () {
return `${this.get('ghostPaths.assetRoot')}/img/user-cover.png`;
}),
coverImageBackground: computed('user.coverImage', 'coverDefault', function () {
let url = this.get('user.coverImage') || this.get('coverDefault');
let safeUrl = Handlebars.Utils.escapeExpression(url);
return htmlSafe(`background-image: url(${safeUrl})`);
}),
coverTitle: computed('user.name', function () {
return `${this.get('user.name')}'s Cover Image`;
}),
roles: computed(function () {
return this.store.query('role', {permissions: 'assign'});
}),
_deleteUser() {
if (this.get('deleteUserActionIsVisible')) {
let user = this.get('user');
return user.destroyRecord();
}
},
_deleteUserSuccess() {
this.get('notifications').closeAlerts('user.delete');
this.store.unloadAll('post');
this.transitionToRoute('team');
},
_deleteUserFailure() {
this.get('notifications').showAlert('The user could not be deleted. Please try again.', {type: 'error', key: 'user.delete.failed'});
},
saveHandlers: taskGroup().enqueue(),
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.get('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.get('user');
let slugValue = this.get('slugValue');
let slugChanged;
if (user.get('slug') !== slugValue) {
slugChanged = true;
user.set('slug', slugValue);
}
try {
let model = yield user.save({format: false});
let currentPath,
newPath;
// If the user's slug has changed, change the URL and replace
// the history so refresh and back button still work
if (slugChanged) {
currentPath = window.location.hash;
newPath = currentPath.split('/');
newPath[newPath.length - 1] = model.get('slug');
newPath = newPath.join('/');
windowProxy.replaceState({path: newPath}, '', newPath);
}
this.set('dirtyAttributes', false);
this.get('notifications').closeAlerts('user.update');
return model;
} catch (error) {
// validation engine returns undefined so we have to check
// before treating the failure as an API error
if (error) {
this.get('notifications').showAPIError(error, {key: 'user.update'});
}
}
}).group('saveHandlers'),
actions: {
changeRole(newRole) {
this.get('user').set('role', newRole);
this.set('dirtyAttributes', true);
},
deleteUser() {
return this._deleteUser().then(() => {
this._deleteUserSuccess();
}, () => {
this._deleteUserFailure();
});
},
toggleDeleteUserModal() {
if (this.get('deleteUserActionIsVisible')) {
this.toggleProperty('showDeleteUserModal');
}
},
suspendUser() {
this.get('user').set('status', 'inactive');
return this.get('save').perform();
},
toggleSuspendUserModal() {
if (this.get('deleteUserActionIsVisible')) {
this.toggleProperty('showSuspendUserModal');
}
},
unsuspendUser() {
this.get('user').set('status', 'active');
return this.get('save').perform();
},
toggleUnsuspendUserModal() {
if (this.get('deleteUserActionIsVisible')) {
this.toggleProperty('showUnsuspendUserModal');
}
},
validateFacebookUrl() {
let newUrl = this.get('_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.get('_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.get('user');
let url = this.get('ghostPaths.url').api('users', 'owner');
this.get('dropdown').closeDropdowns();
return this.get('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 user = this.store.peekRecord('user', userJSON.id);
let role = this.store.peekRecord('role', userJSON.roles[0].id);
user.set('role', role);
});
}
this.get('notifications').showAlert(`Ownership successfully transferred to ${user.get('name')}`, {type: 'success', key: 'owner.transfer.success'});
}).catch((error) => {
this.get('notifications').showAPIError(error, {key: 'owner.transfer'});
});
},
toggleLeaveSettingsModal(transition) {
let leaveTransition = this.get('leaveSettingsTransition');
if (!transition && this.get('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.get('leaveSettingsTransition');
let user = this.get('user');
if (!transition) {
this.get('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 model props
user.rollbackAttributes();
// roll back the slugValue property
if (this.get('dirtyAttributes')) {
this.set('slugValue', user.get('slug'));
this.set('dirtyAttributes', false);
}
return transition.retry();
},
toggleTransferOwnerModal() {
if (this.get('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');
}
}
});