mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-24 23:48:13 -05:00
✨ Added member impersonation (#1497)
refs b0ff1e7cac
- Adds "impersonate" button which would be triggering a popup window with "login url" that allows to log in as a member
This commit is contained in:
parent
4675fb911c
commit
ff4fd2fc9a
10 changed files with 188 additions and 23 deletions
33
ghost/admin/app/components/modal-impersonate-member.js
Normal file
33
ghost/admin/app/components/modal-impersonate-member.js
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import ModalComponent from 'ghost-admin/components/modal-base';
|
||||||
|
import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard';
|
||||||
|
import {alias} from '@ember/object/computed';
|
||||||
|
import {inject as service} from '@ember/service';
|
||||||
|
import {task, timeout} from 'ember-concurrency';
|
||||||
|
|
||||||
|
export default ModalComponent.extend({
|
||||||
|
config: service(),
|
||||||
|
store: service(),
|
||||||
|
|
||||||
|
classNames: 'modal-impersonate-member',
|
||||||
|
|
||||||
|
signinUrl: null,
|
||||||
|
member: alias('model'),
|
||||||
|
|
||||||
|
didInsertElement() {
|
||||||
|
this._super(...arguments);
|
||||||
|
|
||||||
|
this._signinUrlUpdateTask.perform();
|
||||||
|
},
|
||||||
|
|
||||||
|
copySigninUrl: task(function* () {
|
||||||
|
copyTextToClipboard(this.get('signinUrl'));
|
||||||
|
yield timeout(1000);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
|
||||||
|
_signinUrlUpdateTask: task(function*() {
|
||||||
|
const memberSigninURL = yield this.member.fetchSigninUrl.perform();
|
||||||
|
|
||||||
|
this.set('signinUrl', memberSigninURL.url);
|
||||||
|
}).drop()
|
||||||
|
});
|
|
@ -12,10 +12,14 @@ const SCRATCH_PROPS = ['name', 'email', 'note'];
|
||||||
|
|
||||||
export default Controller.extend({
|
export default Controller.extend({
|
||||||
members: controller(),
|
members: controller(),
|
||||||
|
session: service(),
|
||||||
|
dropdown: service(),
|
||||||
notifications: service(),
|
notifications: service(),
|
||||||
router: service(),
|
router: service(),
|
||||||
store: service(),
|
store: service(),
|
||||||
|
|
||||||
|
showImpersonateMemberModal: false,
|
||||||
|
|
||||||
member: alias('model'),
|
member: alias('model'),
|
||||||
|
|
||||||
scratchMember: computed('member', function () {
|
scratchMember: computed('member', function () {
|
||||||
|
@ -39,6 +43,10 @@ export default Controller.extend({
|
||||||
this.toggleProperty('showDeleteMemberModal');
|
this.toggleProperty('showDeleteMemberModal');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
toggleImpersonateMemberModal() {
|
||||||
|
this.toggleProperty('showImpersonateMemberModal');
|
||||||
|
},
|
||||||
|
|
||||||
save() {
|
save() {
|
||||||
return this.save.perform();
|
return this.save.perform();
|
||||||
},
|
},
|
||||||
|
@ -106,13 +114,12 @@ export default Controller.extend({
|
||||||
fetchMember: task(function* (memberId) {
|
fetchMember: task(function* (memberId) {
|
||||||
this.set('isLoading', true);
|
this.set('isLoading', true);
|
||||||
|
|
||||||
yield this.store.findRecord('member', memberId, {
|
let member = yield this.store.findRecord('member', memberId, {
|
||||||
reload: true
|
reload: true
|
||||||
}).then((member) => {
|
|
||||||
this.set('member', member);
|
|
||||||
this.set('isLoading', false);
|
|
||||||
return member;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.set('member', member);
|
||||||
|
this.set('isLoading', false);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
_saveMemberProperty(propKey, newValue) {
|
_saveMemberProperty(propKey, newValue) {
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import Model, {attr, hasMany} from '@ember-data/model';
|
import Model, {attr, hasMany} from '@ember-data/model';
|
||||||
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
|
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
|
||||||
|
import {inject as service} from '@ember/service';
|
||||||
|
import {task} from 'ember-concurrency';
|
||||||
|
|
||||||
export default Model.extend(ValidationEngine, {
|
export default Model.extend(ValidationEngine, {
|
||||||
validationType: 'member',
|
validationType: 'member',
|
||||||
|
@ -12,6 +14,10 @@ export default Model.extend(ValidationEngine, {
|
||||||
subscribed: attr('boolean', {defaultValue: true}),
|
subscribed: attr('boolean', {defaultValue: true}),
|
||||||
labels: hasMany('label', {embedded: 'always', async: false}),
|
labels: hasMany('label', {embedded: 'always', async: false}),
|
||||||
comped: attr('boolean', {defaultValue: false}),
|
comped: attr('boolean', {defaultValue: false}),
|
||||||
|
|
||||||
|
ghostPaths: service(),
|
||||||
|
ajax: service(),
|
||||||
|
|
||||||
// remove client-generated labels, which have `id: null`.
|
// remove client-generated labels, which have `id: null`.
|
||||||
// Ember Data won't recognize/update them automatically
|
// Ember Data won't recognize/update them automatically
|
||||||
// when returned from the server with ids.
|
// when returned from the server with ids.
|
||||||
|
@ -22,5 +28,13 @@ export default Model.extend(ValidationEngine, {
|
||||||
|
|
||||||
labels.removeObjects(oldLabels);
|
labels.removeObjects(oldLabels);
|
||||||
oldLabels.invoke('deleteRecord');
|
oldLabels.invoke('deleteRecord');
|
||||||
}
|
},
|
||||||
|
|
||||||
|
fetchSigninUrl: task(function* () {
|
||||||
|
let url = this.get('ghostPaths.url').api('members', this.get('id'), 'signin_urls');
|
||||||
|
|
||||||
|
let response = yield this.ajax.request(url);
|
||||||
|
|
||||||
|
return response.member_signin_urls[0];
|
||||||
|
}).drop()
|
||||||
});
|
});
|
||||||
|
|
|
@ -96,6 +96,10 @@ p.gh-members-list-email {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-link-copied svg {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.members-header .gh-members-header-search {
|
.members-header .gh-members-header-search {
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
border-right: 1px solid var(--lightgrey-d2);
|
border-right: 1px solid var(--lightgrey-d2);
|
||||||
|
|
|
@ -279,6 +279,11 @@ fieldset[disabled] .gh-btn {
|
||||||
background: var(--white);
|
background: var(--white);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gh-btn-white.gh-btn-green:hover,
|
||||||
|
.gh-btn-white.gh-btn-blue:hover {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
.gh-btn-white span {
|
.gh-btn-white span {
|
||||||
height: 35px;
|
height: 35px;
|
||||||
line-height: 35px;
|
line-height: 35px;
|
||||||
|
|
|
@ -656,6 +656,18 @@ textarea {
|
||||||
background: var(--input-bg-color);
|
background: var(--input-bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gh-input-group .gh-btn {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gh-input-group .gh-btn span {
|
||||||
|
height: 36px;
|
||||||
|
line-height: 36px;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* FFF: Fucking Firefox Fixes
|
/* FFF: Fucking Firefox Fixes
|
||||||
/* ---------------------------------------------------------- */
|
/* ---------------------------------------------------------- */
|
||||||
|
|
|
@ -284,3 +284,34 @@ Pop: Appear from bottom, disappear to bottom
|
||||||
width: 6px;
|
width: 6px;
|
||||||
background: var(--darkgrey-l2);
|
background: var(--darkgrey-l2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Animated icons */
|
||||||
|
.animated-icon path {
|
||||||
|
stroke-dashoffset: 300;
|
||||||
|
stroke-dasharray: 300;
|
||||||
|
animation: icon-dash 3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes icon-dash {
|
||||||
|
0% {
|
||||||
|
stroke-dashoffset: 300;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade in */
|
||||||
|
.fade-in {
|
||||||
|
opacity: 0;
|
||||||
|
animation: fade-in 3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1.0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
<header class="modal-header flex justify-center">
|
||||||
|
<h1 style="margin: 0;">Impersonate</h1>
|
||||||
|
</header>
|
||||||
|
{{!-- disable mouseDown so it doesn't trigger focus-out validations --}}
|
||||||
|
<a class="close" href title="Close" {{action "closeModal"}} {{action (optional this.noop) on="mouseDown"}}>
|
||||||
|
{{svg-jar "close"}}<span class="hidden">Close</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="flex items-center justify-center mt4 mb4">
|
||||||
|
<GhMemberAvatar
|
||||||
|
@member={{this.member}}
|
||||||
|
@sizeClass={{if this.member.name 'f-headline fw4 lh-zero tracked-1' 'f-headline fw4 lh-zero tracked-1'}}
|
||||||
|
@containerClass="w25 h25 gh-member-detail-avatar" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="tc pl4 pr4">
|
||||||
|
This is an authentication link to sign into <strong>{{this.config.blogTitle}}</strong> as <strong>{{this.member.email}}</strong>, you can send it to them if they need it, or use it to sign into their account for customer support.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<div class="gh-input-group">
|
||||||
|
<GhTextInput
|
||||||
|
@id="member-signin-url"
|
||||||
|
@name="member-signin-url"
|
||||||
|
@disabled={{true}}
|
||||||
|
@value={{readonly signinUrl}}
|
||||||
|
/>
|
||||||
|
<GhTaskButton
|
||||||
|
@buttonText="Copy link"
|
||||||
|
@task={{this.copySigninUrl}}
|
||||||
|
@successText="Link copied"
|
||||||
|
@class="gh-btn gh-btn-blue gh-btn-icon" />
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="tc pt4 mb2">This link is only valid for the next <strong>10 minutes</strong></p>
|
||||||
|
</div>
|
|
@ -1,26 +1,37 @@
|
||||||
<section class="gh-canvas">
|
<section class="gh-canvas">
|
||||||
<form class="mb10 member-basic-info-form">
|
<GhCanvasHeader class="gh-canvas-header">
|
||||||
<GhCanvasHeader class="gh-canvas-header">
|
<h2 class="gh-canvas-title" data-test-screen-title>
|
||||||
<h2 class="gh-canvas-title" data-test-screen-title>
|
<LinkTo @route="members" data-test-link="members-back">Members</LinkTo>
|
||||||
<LinkTo @route="members" data-test-link="members-back">Members</LinkTo>
|
<span>{{svg-jar "arrow-right"}}</span>
|
||||||
<span>{{svg-jar "arrow-right"}}</span>
|
{{#if this.member.isNew}}
|
||||||
{{#if this.member.isNew}}
|
New member
|
||||||
New member
|
{{else}}
|
||||||
{{else}}
|
{{or this.member.name this.member.email}}
|
||||||
{{or this.member.name this.member.email}}
|
{{/if}}
|
||||||
{{/if}}
|
</h2>
|
||||||
</h2>
|
|
||||||
<section class="view-actions">
|
|
||||||
<GhTaskButton @class="gh-btn gh-btn-blue gh-btn-icon" @type="button" @task={{this.save}} @data-test-button="save" />
|
|
||||||
</section>
|
|
||||||
</GhCanvasHeader>
|
|
||||||
|
|
||||||
|
<section class="view-actions">
|
||||||
|
{{#if this.session.user.isOwner}}
|
||||||
|
{{#unless this.member.isNew}}
|
||||||
|
<button
|
||||||
|
class="gh-btn gh-btn-white gh-btn-icon mr2"
|
||||||
|
{{on "click" (action "toggleImpersonateMemberModal")}}>
|
||||||
|
<span>Impersonate</span>
|
||||||
|
</button>
|
||||||
|
{{/unless}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<GhTaskButton @class="gh-btn gh-btn-blue gh-btn-icon" @type="button" @task={{this.save}} @data-test-button="save" />
|
||||||
|
</section>
|
||||||
|
</GhCanvasHeader>
|
||||||
|
|
||||||
|
<form class="mb10 member-basic-info-form">
|
||||||
<div class="flex items-center mb10 bt b--lightgrey-d1 pt8">
|
<div class="flex items-center mb10 bt b--lightgrey-d1 pt8">
|
||||||
{{#if (or this.member.name this.member.email)}}
|
{{#if (or this.member.name this.member.email)}}
|
||||||
<GhMemberAvatar
|
<GhMemberAvatar
|
||||||
@member={{this.member}}
|
@member={{this.member}}
|
||||||
@sizeClass={{if this.member.name 'f-subheadline fw4 lh-zero tracked-1' 'f-headline fw4 lh-zero tracked-1'}}
|
@sizeClass={{if this.member.name 'f-subheadline fw4 lh-zero tracked-1' 'f-headline fw4 lh-zero tracked-1'}}
|
||||||
@containerClass="w18 h18 mr4 gh-member-detail-avatar"
|
@containerClass="w20 h20 mr4 gh-member-detail-avatar"
|
||||||
/>
|
/>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="flex items-center justify-center br-100 w18 h18 mr4 gh-new-member-avatar">
|
<div class="flex items-center justify-center br-100 w18 h18 mr4 gh-new-member-avatar">
|
||||||
|
@ -78,3 +89,11 @@
|
||||||
@close={{action "toggleDeleteMemberModal"}}
|
@close={{action "toggleDeleteMemberModal"}}
|
||||||
@modifier="action wide" />
|
@modifier="action wide" />
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.showImpersonateMemberModal}}
|
||||||
|
<GhFullscreenModal
|
||||||
|
@modal="impersonate-member"
|
||||||
|
@model={{this.member}}
|
||||||
|
@close={{action "toggleImpersonateMemberModal"}}
|
||||||
|
@modifier="action wide" />
|
||||||
|
{{/if}}
|
||||||
|
|
|
@ -134,7 +134,7 @@ describe('Acceptance: Members', function () {
|
||||||
// it navigates to the new member route
|
// it navigates to the new member route
|
||||||
expect(currentURL(), 'new member URL').to.equal('/members/new');
|
expect(currentURL(), 'new member URL').to.equal('/members/new');
|
||||||
// it displays the new member form
|
// it displays the new member form
|
||||||
expect(find('.member-basic-info-form .gh-canvas-header h2').textContent, 'settings pane title')
|
expect(find('.gh-canvas-header h2').textContent, 'settings pane title')
|
||||||
.to.contain('New member');
|
.to.contain('New member');
|
||||||
|
|
||||||
// // all fields start blank
|
// // all fields start blank
|
||||||
|
|
Loading…
Add table
Reference in a new issue