mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-25 09:03:12 +03: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
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({
|
||||
members: controller(),
|
||||
session: service(),
|
||||
dropdown: service(),
|
||||
notifications: service(),
|
||||
router: service(),
|
||||
store: service(),
|
||||
|
||||
showImpersonateMemberModal: false,
|
||||
|
||||
member: alias('model'),
|
||||
|
||||
scratchMember: computed('member', function () {
|
||||
@ -39,6 +43,10 @@ export default Controller.extend({
|
||||
this.toggleProperty('showDeleteMemberModal');
|
||||
},
|
||||
|
||||
toggleImpersonateMemberModal() {
|
||||
this.toggleProperty('showImpersonateMemberModal');
|
||||
},
|
||||
|
||||
save() {
|
||||
return this.save.perform();
|
||||
},
|
||||
@ -106,13 +114,12 @@ export default Controller.extend({
|
||||
fetchMember: task(function* (memberId) {
|
||||
this.set('isLoading', true);
|
||||
|
||||
yield this.store.findRecord('member', memberId, {
|
||||
let member = yield this.store.findRecord('member', memberId, {
|
||||
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) {
|
||||
|
@ -1,5 +1,7 @@
|
||||
import Model, {attr, hasMany} from '@ember-data/model';
|
||||
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, {
|
||||
validationType: 'member',
|
||||
@ -12,6 +14,10 @@ export default Model.extend(ValidationEngine, {
|
||||
subscribed: attr('boolean', {defaultValue: true}),
|
||||
labels: hasMany('label', {embedded: 'always', async: false}),
|
||||
comped: attr('boolean', {defaultValue: false}),
|
||||
|
||||
ghostPaths: service(),
|
||||
ajax: service(),
|
||||
|
||||
// remove client-generated labels, which have `id: null`.
|
||||
// Ember Data won't recognize/update them automatically
|
||||
// when returned from the server with ids.
|
||||
@ -22,5 +28,13 @@ export default Model.extend(ValidationEngine, {
|
||||
|
||||
labels.removeObjects(oldLabels);
|
||||
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;
|
||||
}
|
||||
|
||||
.member-link-copied svg {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.members-header .gh-members-header-search {
|
||||
margin-right: 12px;
|
||||
border-right: 1px solid var(--lightgrey-d2);
|
||||
|
@ -279,6 +279,11 @@ fieldset[disabled] .gh-btn {
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
.gh-btn-white.gh-btn-green:hover,
|
||||
.gh-btn-white.gh-btn-blue:hover {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.gh-btn-white span {
|
||||
height: 35px;
|
||||
line-height: 35px;
|
||||
|
@ -656,6 +656,18 @@ textarea {
|
||||
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
|
||||
/* ---------------------------------------------------------- */
|
||||
|
@ -284,3 +284,34 @@ Pop: Appear from bottom, disappear to bottom
|
||||
width: 6px;
|
||||
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">
|
||||
<form class="mb10 member-basic-info-form">
|
||||
<GhCanvasHeader class="gh-canvas-header">
|
||||
<h2 class="gh-canvas-title" data-test-screen-title>
|
||||
<LinkTo @route="members" data-test-link="members-back">Members</LinkTo>
|
||||
<span>{{svg-jar "arrow-right"}}</span>
|
||||
{{#if this.member.isNew}}
|
||||
New member
|
||||
{{else}}
|
||||
{{or this.member.name this.member.email}}
|
||||
{{/if}}
|
||||
</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>
|
||||
<GhCanvasHeader class="gh-canvas-header">
|
||||
<h2 class="gh-canvas-title" data-test-screen-title>
|
||||
<LinkTo @route="members" data-test-link="members-back">Members</LinkTo>
|
||||
<span>{{svg-jar "arrow-right"}}</span>
|
||||
{{#if this.member.isNew}}
|
||||
New member
|
||||
{{else}}
|
||||
{{or this.member.name this.member.email}}
|
||||
{{/if}}
|
||||
</h2>
|
||||
|
||||
<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">
|
||||
{{#if (or this.member.name this.member.email)}}
|
||||
<GhMemberAvatar
|
||||
@member={{this.member}}
|
||||
@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}}
|
||||
<div class="flex items-center justify-center br-100 w18 h18 mr4 gh-new-member-avatar">
|
||||
@ -78,3 +89,11 @@
|
||||
@close={{action "toggleDeleteMemberModal"}}
|
||||
@modifier="action wide" />
|
||||
{{/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
|
||||
expect(currentURL(), 'new member URL').to.equal('/members/new');
|
||||
// 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');
|
||||
|
||||
// // all fields start blank
|
||||
|
Loading…
Reference in New Issue
Block a user