diff --git a/ghost/admin/app/components/gh-member-settings-form.js b/ghost/admin/app/components/gh-member-settings-form.js index 84898c0247..fb6e69dcca 100644 --- a/ghost/admin/app/components/gh-member-settings-form.js +++ b/ghost/admin/app/components/gh-member-settings-form.js @@ -10,7 +10,6 @@ export default Component.extend({ mediaQueries: service(), isViewingSubview: false, - scratchDescription: '', // Allowed actions setProperty: () => {}, @@ -18,6 +17,7 @@ export default Component.extend({ scratchName: boundOneWay('member.name'), scratchEmail: boundOneWay('member.email'), + scratchNote: boundOneWay('member.note'), subscriptions: computed('member.stripe', function () { let subscriptions = this.member.get('stripe'); if (subscriptions && subscriptions.length > 0) { diff --git a/ghost/admin/app/components/gh-members-lab-setting.js b/ghost/admin/app/components/gh-members-lab-setting.js index 209cce5faf..13392ba0c0 100644 --- a/ghost/admin/app/components/gh-members-lab-setting.js +++ b/ghost/admin/app/components/gh-members-lab-setting.js @@ -21,8 +21,9 @@ export default Component.extend({ yearly: yearlyPlan }; subscriptionSettings.stripeConfig = stripeProcessor.config; - subscriptionSettings.requirePaymentForSetup = !!subscriptionSettings.requirePaymentForSetup; + subscriptionSettings.allowSelfSignup = !!subscriptionSettings.allowSelfSignup; subscriptionSettings.fromAddress = subscriptionSettings.fromAddress || 'noreply'; + return subscriptionSettings; }), @@ -58,8 +59,8 @@ export default Component.extend({ return plan; }); } - if (key === 'requirePaymentForSignup') { - subscriptionSettings.requirePaymentForSignup = !subscriptionSettings.requirePaymentForSignup; + if (key === 'allowSelfSignup') { + subscriptionSettings.allowSelfSignup = !subscriptionSettings.allowSelfSignup; } if (key === 'fromAddress') { subscriptionSettings.fromAddress = event.target.value; @@ -74,7 +75,7 @@ export default Component.extend({ } catch (e) { return { isPaid: false, - requirePaymentForSignup: false, + allowSelfSignup: true, fromAddress: 'noreply', paymentProcessors: [{ adapter: 'stripe', diff --git a/ghost/admin/app/controllers/member.js b/ghost/admin/app/controllers/member.js index 405384b58d..599faae44b 100644 --- a/ghost/admin/app/controllers/member.js +++ b/ghost/admin/app/controllers/member.js @@ -12,8 +12,9 @@ export default Controller.extend({ router: service(), - member: alias('model'), + notifications: service(), + member: alias('model'), subscribedAt: computed('member.createdAt', function () { let memberSince = moment(this.member.createdAt).from(moment()); let createdDate = moment(this.member.createdAt).format('MMM DD, YYYY'); @@ -21,10 +22,10 @@ export default Controller.extend({ }), actions: { - setProperty() { - return; + setProperty(propKey, value) { + this._saveMemberProperty(propKey, value); }, - toggleDeleteTagModal() { + toggleDeleteMemberModal() { this.toggleProperty('showDeleteMemberModal'); }, finaliseDeletion() { @@ -34,9 +35,63 @@ export default Controller.extend({ this.members.decrementProperty('meta.pagination.total'); } this.router.transitionTo('members'); + }, + + toggleUnsavedChangesModal(transition) { + let leaveTransition = this.leaveScreenTransition; + + if (!transition && this.showUnsavedChangesModal) { + this.set('leaveScreenTransition', null); + this.set('showUnsavedChangesModal', false); + return; + } + + if (!leaveTransition || transition.targetName === leaveTransition.targetName) { + this.set('leaveScreenTransition', transition); + + // if a save is running, wait for it to finish then transition + if (this.save.isRunning) { + return this.save.last.then(() => { + transition.retry(); + }); + } + + // we genuinely have unsaved data, show the modal + this.set('showUnsavedChangesModal', true); + } + }, + + leaveScreen() { + let transition = this.leaveScreenTransition; + + 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 model props + this.member.rollbackAttributes(); + + return transition.retry(); } }, + save: task(function* () { + let member = this.member; + try { + return yield member.save(); + } catch (error) { + if (error) { + this.notifications.showAPIError(error, {key: 'member.save'}); + } + } + }).drop(), + + _saveMemberProperty(propKey, newValue) { + let member = this.member; + member.set(propKey, newValue); + }, + fetchMember: task(function* (memberId) { this.set('isLoading', true); yield this.store.findRecord('member', memberId, { diff --git a/ghost/admin/app/controllers/settings/labs.js b/ghost/admin/app/controllers/settings/labs.js index b23c41557a..7963d93bd4 100644 --- a/ghost/admin/app/controllers/settings/labs.js +++ b/ghost/admin/app/controllers/settings/labs.js @@ -8,7 +8,6 @@ import { isRequestEntityTooLargeError, isUnsupportedMediaTypeError } from 'ghost-admin/services/ajax'; -import {computed} from '@ember/object'; import {isBlank} from '@ember/utils'; import {isArray as isEmberArray} from '@ember/array'; import {run} from '@ember/runloop'; @@ -63,25 +62,6 @@ export default Controller.extend({ this.yamlMimeType = YAML_MIME_TYPE; }, - subscriptionSettings: computed('settings.membersSubscriptionSettings', function () { - let subscriptionSettings = this.parseSubscriptionSettings(this.get('settings.membersSubscriptionSettings')); - let stripeProcessor = subscriptionSettings.paymentProcessors.find((proc) => { - return (proc.adapter === 'stripe'); - }); - let monthlyPlan = stripeProcessor.config.plans.find(plan => plan.interval === 'month'); - let yearlyPlan = stripeProcessor.config.plans.find(plan => plan.interval === 'year'); - monthlyPlan.dollarAmount = parseInt(monthlyPlan.amount) ? (monthlyPlan.amount / 100) : 0; - yearlyPlan.dollarAmount = parseInt(yearlyPlan.amount) ? (yearlyPlan.amount / 100) : 0; - stripeProcessor.config.plans = { - monthly: monthlyPlan, - yearly: yearlyPlan - }; - subscriptionSettings.stripeConfig = stripeProcessor.config; - subscriptionSettings.requirePaymentForSetup = !!subscriptionSettings.requirePaymentForSetup; - subscriptionSettings.fromAddress = subscriptionSettings.fromAddress || 'noreply'; - return subscriptionSettings; - }), - actions: { onUpload(file) { let formData = new FormData(); diff --git a/ghost/admin/app/models/member.js b/ghost/admin/app/models/member.js index 094d8564c7..601793abb2 100644 --- a/ghost/admin/app/models/member.js +++ b/ghost/admin/app/models/member.js @@ -4,6 +4,7 @@ import attr from 'ember-data/attr'; export default DS.Model.extend({ name: attr('string'), email: attr('string'), + note: attr('string'), createdAt: attr('moment-utc'), stripe: attr('member-subscription') }); diff --git a/ghost/admin/app/routes/member.js b/ghost/admin/app/routes/member.js index 028bf8b66a..97e474af9b 100644 --- a/ghost/admin/app/routes/member.js +++ b/ghost/admin/app/routes/member.js @@ -1,6 +1,24 @@ 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, { + + router: service(), + + init() { + this._super(...arguments); + this.router.on('routeWillChange', (transition) => { + this.showUnsavedChangesModal(transition); + }); + }, + + beforeModel() { + this._super(...arguments); + return this.get('session.user') + .then(this.transitionAuthor()); + }, -export default AuthenticatedRoute.extend({ model(params) { this._isMemberUpdated = true; return this.store.findRecord('member', params.member_id, { @@ -19,10 +37,31 @@ export default AuthenticatedRoute.extend({ this._super(...arguments); // clear the properties + let {controller} = this; + controller.model.rollbackAttributes(); + this.set('controller.model', null); this._isMemberUpdated = false; }, + actions: { + save() { + this.controller.send('save'); + } + }, + titleToken() { return this.controller.get('member.name'); + }, + + showUnsavedChangesModal(transition) { + if (transition.from && transition.from.name.match(/^member$/) && transition.targetName) { + let {controller} = this; + + if (!controller.member.isDeleted && controller.member.hasDirtyAttributes) { + transition.abort(); + controller.send('toggleUnsavedChangesModal', transition); + return; + } + } } }); diff --git a/ghost/admin/app/serializers/member.js b/ghost/admin/app/serializers/member.js new file mode 100644 index 0000000000..76eff17cb9 --- /dev/null +++ b/ghost/admin/app/serializers/member.js @@ -0,0 +1,15 @@ +/* eslint-disable camelcase */ +import ApplicationSerializer from 'ghost-admin/serializers/application'; + +export default ApplicationSerializer.extend({ + serialize(/*snapshot, options*/) { + let json = this._super(...arguments); + + // Properties that exist on the model but we don't want sent in the payload + delete json.stripe; + // Normalize properties + json.name = json.name || ''; + json.note = json.note || ''; + return json; + } +}); diff --git a/ghost/admin/app/templates/components/gh-member-settings-form.hbs b/ghost/admin/app/templates/components/gh-member-settings-form.hbs index 044487cf5d..a5c546bcbe 100644 --- a/ghost/admin/app/templates/components/gh-member-settings-form.hbs +++ b/ghost/admin/app/templates/components/gh-member-settings-form.hbs @@ -4,7 +4,6 @@ {{#gh-form-group errors=member.errors hasValidated=member.hasValidated property="name"}} {{gh-text-input - disabled=true id="member-name" name="name" value=(readonly scratchName) @@ -29,19 +28,18 @@
{{#gh-form-group errors=member.errors hasValidated=member.hasValidated property="note"}} - + {{gh-textarea - disabled=true - id="member-description" - name="description" + id="member-note" + name="note" class="gh-member-details-textarea" tabindex="3" - value=(readonly scratchDescription) - input=(action (mut scratchDescription) value="target.value") - focus-out=(action 'setProperty' 'description' scratchDescription) + value=(readonly scratchNote) + input=(action (mut scratchNote) value="target.value") + focus-out=(action 'setProperty' 'note' scratchNote) }} - {{gh-error-message errors=member.errors property="description"}} -

Maximum: 500 characters. You’ve used {{gh-count-down-characters scratchDescription 500}}

+ {{gh-error-message errors=member.errors property="note"}} +

Maximum: 500 characters. You’ve used {{gh-count-down-characters scratchNote 500}}

{{/gh-form-group}}
diff --git a/ghost/admin/app/templates/components/gh-members-lab-setting.hbs b/ghost/admin/app/templates/components/gh-members-lab-setting.hbs index 6d6e46c6d9..a19a4a0b0d 100644 --- a/ghost/admin/app/templates/components/gh-members-lab-setting.hbs +++ b/ghost/admin/app/templates/components/gh-members-lab-setting.hbs @@ -1,106 +1,166 @@
-
-
- - {{gh-text-input - value=(readonly subscriptionSettings.stripeConfig.public_token) - input=(action "setSubscriptionSettings" "public_token") - class="mt1" - }} -
-
- - {{gh-text-input - value=(readonly subscriptionSettings.stripeConfig.secret_token) - input=(action "setSubscriptionSettings" "secret_token") - class="mt1" - }} - - Where to find Stripe API keys - + +
+
+
+

Stripe settings

+

Configure Stripe API keys for signups

+
+
+ +
-
-
- {{#gh-form-group}} - -
+ {{#liquid-if membersStripeOpen}} +
+ {{gh-text-input - value=(readonly subscriptionSettings.stripeConfig.plans.monthly.dollarAmount) - type="number" - input=(action "setSubscriptionSettings" "month") - }} + value=(readonly subscriptionSettings.stripeConfig.public_token) + input=(action "setSubscriptionSettings" "public_token") + class="mt1" + }}
- {{/gh-form-group}} +
+ + {{gh-text-input + value=(readonly subscriptionSettings.stripeConfig.secret_token) + input=(action "setSubscriptionSettings" "secret_token") + class="mt1" + }} + + Where to find Stripe API keys + +
+ {{/liquid-if}} +
+ +
+
+
+

Pricing

+

Set monthly and yearly subscription prices

-
- {{#gh-form-group class="description-container"}} - -
- {{gh-text-input - value=(readonly subscriptionSettings.stripeConfig.plans.yearly.dollarAmount) - type="number" - input=(action "setSubscriptionSettings" "year") - }} +
+ +
+
+ + {{#liquid-if membersPricingOpen}} +
+
+ {{#gh-form-group}} + +
+ {{gh-text-input + value=(readonly subscriptionSettings.stripeConfig.plans.monthly.dollarAmount) + type="number" + input=(action "setSubscriptionSettings" "month") + }} +
+ {{/gh-form-group}} +
+
+ {{#gh-form-group class="description-container"}} + +
+ {{gh-text-input + value=(readonly subscriptionSettings.stripeConfig.plans.yearly.dollarAmount) + type="number" + input=(action "setSubscriptionSettings" "year") + }} +
+ {{/gh-form-group}} +
+
+ {{/liquid-if}} +
+ +
+
+
+

Allow free members signup

+

Allow free members signup

+
+
+
+
- {{/gh-form-group}}
-
- - -
-
-
-
Public
+
+
+
+

Default post access

+

Configure restrictions for new posts

+
+
+
- -
-
-
-
Members only
-
-
- -
-
-
-
Paid-members only
+ + {{#liquid-if membersPostAccessOpen}} +
+
+
+
+
Public
+
+
+ +
+
+
+
Members only
+
+
+ +
+
+
+
Paid-members only
+
+ {{/liquid-if}}
-
-
- +
+
+
+

Emails

+

Membership related email settings

+
+
+ +
-
-
-
+ + {{#liquid-if membersEmailOpen}} +
{{#gh-form-group}} - -
+ +
{{gh-text-input value=(readonly subscriptionSettings.fromAddress) input=(action "setSubscriptionSettings" "fromAddress") + class="w20" }} @{{config.blogDomain}}
-
"From" address for sending sign up and sign in emails
+
"From" address for sign up and sign in emails
{{/gh-form-group}}
-
+ {{/liquid-if}} +
\ No newline at end of file diff --git a/ghost/admin/app/templates/member.hbs b/ghost/admin/app/templates/member.hbs index 5dba29fb1c..9b41e5335e 100644 --- a/ghost/admin/app/templates/member.hbs +++ b/ghost/admin/app/templates/member.hbs @@ -10,16 +10,19 @@ {{member.email}} {{/if}} +
+ {{gh-task-button task=save class="gh-btn gh-btn-blue gh-btn-icon" data-test-button="save"}} +
- +

{{if member.name member.name member.email}}

{{#if member.name}} - {{member.email}} – + {{member.email}} – {{/if}} Member since {{this.subscribedAt}}

@@ -28,7 +31,7 @@ {{gh-member-settings-form member=member setProperty=(action "setProperty") isLoading=this.isLoading - showDeleteTagModal=(action "toggleDeleteTagModal")}} + showDeleteTagModal=(action "toggleDeleteMemberModal")}}
+{{#if showUnsavedChangesModal}} + {{gh-fullscreen-modal "leave-settings" + confirm=(action "leaveScreen") + close=(action "toggleUnsavedChangesModal") + modifier="action wide"}} +{{/if}} + {{#if showDeleteMemberModal}} {{gh-fullscreen-modal "delete-member" model=(hash member=member onSuccess=(action "finaliseDeletion")) diff --git a/ghost/admin/app/templates/settings/labs.hbs b/ghost/admin/app/templates/settings/labs.hbs index 4f10636230..e0368a5ac9 100644 --- a/ghost/admin/app/templates/settings/labs.hbs +++ b/ghost/admin/app/templates/settings/labs.hbs @@ -86,43 +86,7 @@
- {{#if config.enableDeveloperExperiments}} -
Members (BETA)
-
-
-
-
-
-
Members
-
Enable free or paid member registration.
-
-
-
{{gh-feature-flag "members"}}
-
-
- - {{#liquid-if feature.labs.members}} - {{gh-members-lab-setting - settings=settings - setDefaultContentVisibility=(action "setDefaultContentVisibility") - setMembersSubscriptionSettings=(action "setMembersSubscriptionSettings") - }} - -
- {{gh-task-button "Save members settings" - task=saveSettings - successText="Saved" - runningText="Saving" - class="gh-btn gh-btn-blue gh-btn-icon" - }} -
- {{/liquid-if}} -
- -
-
- {{/if}} - +
Beta features
@@ -236,6 +200,43 @@ {{/gh-uploader}}
+ + {{#if config.enableDeveloperExperiments}} +
Members (BETA)
+
+
+
+
+
+
Members
+
Enable membership for your site
+
+
+
{{gh-feature-flag "members"}}
+
+
+ + {{#liquid-if feature.labs.members}} + {{gh-members-lab-setting + settings=settings + setDefaultContentVisibility=(action "setDefaultContentVisibility") + setMembersSubscriptionSettings=(action "setMembersSubscriptionSettings") + }} + +
+ {{gh-task-button "Save members settings" + task=saveSettings + successText="Saved" + runningText="Saving" + class="gh-btn gh-btn-blue gh-btn-icon" + }} +
+ {{/liquid-if}} +
+ +
+
+ {{/if}} diff --git a/ghost/admin/app/transforms/member-subscription.js b/ghost/admin/app/transforms/member-subscription.js index ea68575b6f..0320e5c99b 100644 --- a/ghost/admin/app/transforms/member-subscription.js +++ b/ghost/admin/app/transforms/member-subscription.js @@ -18,13 +18,7 @@ export default Transform.extend({ if (isEmberArray(deserialized)) { subscriptionArray = deserialized.map((item) => { - let adapter = item.get('adapter').trim(); - let amount = item.get('amount'); - let plan = item.get('plan').trim(); - let status = item.get('status').trim(); - let validUntil = item.get('validUntil'); - - return {adapter, amount, plan, status, validUntil}; + return item; }).compact(); } else { subscriptionArray = []; diff --git a/ghost/admin/mirage/fixtures/settings.js b/ghost/admin/mirage/fixtures/settings.js index adb163849b..90b9e1575a 100644 --- a/ghost/admin/mirage/fixtures/settings.js +++ b/ghost/admin/mirage/fixtures/settings.js @@ -177,7 +177,7 @@ export default [ id: 23, type: 'members', key: 'members_subscription_settings', - value: '{"isPaid":false,"requirePaymentForSignup":false,"fromAddress":"noreply","paymentProcessors":[{"adapter":"stripe","config":{"secret_token":"","public_token":"","product":{"name":"Ghost Subscription"},"plans":[{"name":"Monthly","currency":"usd","interval":"month","amount":""},{"name":"Yearly","currency":"usd","interval":"year","amount":""}]}}]}', + value: '{"isPaid":false,"allowSelfSignup":true,"fromAddress":"noreply","paymentProcessors":[{"adapter":"stripe","config":{"secret_token":"","public_token":"","product":{"name":"Ghost Subscription"},"plans":[{"name":"Monthly","currency":"usd","interval":"month","amount":""},{"name":"Yearly","currency":"usd","interval":"year","amount":""}]}}]}', created_at: '2019-10-09T09:49:00.000Z', created_by: 1, updated_at: '2019-10-09T09:49:00.000Z', diff --git a/ghost/admin/package.json b/ghost/admin/package.json index 5a003af96b..122e547957 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "2.34.0", + "version": "2.35.0", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org",