From 2c2c2a4a099511be4d187b7cca02bca71e9ed16c Mon Sep 17 00:00:00 2001 From: Aileen Booker Date: Thu, 21 May 2026 15:22:41 +0400 Subject: [PATCH 01/15] Harden Billing app readiness handling (#28025) ref https://app.incident.io/ghost/incidents/284 BMA now sends an explicit readiness message after rendering a visible shell, so Admin should keep its fallback UI active until that validated message arrives instead of treating early token, route, or subscription messages as loaded. --- .../admin/app/components/gh-billing-iframe.js | 62 ++++++--- ghost/admin/app/services/billing.js | 128 +++++++++++++++-- .../components/gh-billing-iframe-test.js | 130 ++++++++++++++++++ .../components/gh-billing-modal-test.js | 59 +++++++- .../admin/tests/unit/services/billing-test.js | 70 ++++++++++ 5 files changed, 414 insertions(+), 35 deletions(-) create mode 100644 ghost/admin/tests/integration/components/gh-billing-iframe-test.js diff --git a/ghost/admin/app/components/gh-billing-iframe.js b/ghost/admin/app/components/gh-billing-iframe.js index 26228d5abd5..8e44549b149 100644 --- a/ghost/admin/app/components/gh-billing-iframe.js +++ b/ghost/admin/app/components/gh-billing-iframe.js @@ -37,34 +37,62 @@ export default class GhBillingIframe extends Component { return; } - // only process messages coming from the billing iframe - if (event?.data && this.billing.getIframeURL().includes(event?.origin)) { - this.billing.markBillingAppLoaded(); + if (!this.billing.isValidBillingIframeMessage(event)) { + return; + } - if (event.data?.request === 'token') { - this._handleTokenRequest(); - } + const data = event.data; - if (event.data?.request === 'forceUpgradeInfo') { - this._handleForceUpgradeRequest(); - } + if (data?.request === 'billingAppReady') { + this.billing.markBillingAppLoaded(data); - if (event.data?.subscription) { - await this._handleSubscriptionUpdate(event.data); + if (data?.route) { + this.billing.handleRouteChangeInIframe(data.route); } + + return; + } + + this.billing.recordBillingAppPreReadyMessage(data); + + if (data?.route) { + this.billing.handleRouteChangeInIframe(data.route); + } + + if (data?.request === 'token') { + this._handleTokenRequest(); + } + + if (data?.request === 'forceUpgradeInfo') { + this._handleForceUpgradeRequest(); + } + + if (data?.subscription) { + await this._handleSubscriptionUpdate(data); } } + _postMessageToBillingIframe(message) { + const billingIframeWindow = this.billing.getBillingIframe()?.contentWindow; + const billingAppOrigin = this.billing.getBillingAppOrigin(); + + if (!billingIframeWindow || !billingAppOrigin) { + return; + } + + billingIframeWindow.postMessage(message, billingAppOrigin); + } + _handleTokenRequest() { const handleNoPermission = () => { // no permission means the current user requesting the token is not the owner of the site. this.isOwner = false; // Avoid letting the BMA waiting for a message and send an empty token response instead - this.billing.getBillingIframe().contentWindow.postMessage({ + this._postMessageToBillingIframe({ request: 'token', response: null - }, '*'); + }); }; if (!this.session.user?.isOwnerOnly) { @@ -75,10 +103,10 @@ export default class GhBillingIframe extends Component { const ghostIdentityUrl = this.ghostPaths.url.api('identities'); this.ajax.request(ghostIdentityUrl).then((response) => { const token = response?.identities?.[0]?.token; - this.billing.getBillingIframe().contentWindow.postMessage({ + this._postMessageToBillingIframe({ request: 'token', response: token - }, '*'); + }); this.isOwner = true; }).catch((error) => { @@ -101,14 +129,14 @@ export default class GhBillingIframe extends Component { email: owner?.email }; } - this.billing.getBillingIframe().contentWindow.postMessage({ + this._postMessageToBillingIframe({ request: 'forceUpgradeInfo', response: { forceUpgrade: this.config.hostSettings?.forceUpgrade, isOwner: this.isOwner, ownerUser } - }, '*'); + }); } async _handleSubscriptionUpdate(data) { diff --git a/ghost/admin/app/services/billing.js b/ghost/admin/app/services/billing.js index 42c7d29e652..372dd800250 100644 --- a/ghost/admin/app/services/billing.js +++ b/ghost/admin/app/services/billing.js @@ -24,6 +24,11 @@ export default class BillingService extends Service { @tracked billingAppLoaded = false; @tracked billingAppLoadFailureReported = false; + @tracked billingAppPreReadyMessageCount = 0; + @tracked billingAppPreReadyMessageTypes = []; + @tracked billingAppLastPreReadyMessageType = null; + @tracked billingAppReadyReceivedAt = null; + @tracked billingAppReadyPayload = null; billingAppLoadTimeout = null; billingAppRetryTimeout = null; @@ -35,18 +40,6 @@ export default class BillingService extends Service { _loadListenerAttachedTo = null; - constructor() { - super(...arguments); - - if (this.config.hostSettings?.billing?.url) { - window.addEventListener('message', (event) => { - if (event && event.data && event.data.route) { - this.handleRouteChangeInIframe(event.data.route); - } - }); - } - } - willDestroy() { super.willDestroy(...arguments); this.clearBillingAppLoadMonitor(); @@ -79,6 +72,7 @@ export default class BillingService extends Service { this.billingAppLoaded = false; this.billingAppLoadAttempts = 0; this.billingAppLoadFailureReported = false; + this.resetBillingAppLoadDiagnostics(); } this.billingAppLoadAttempts += 1; @@ -116,6 +110,7 @@ export default class BillingService extends Service { } this.billingAppIframeLoadFired = false; this.billingAppIframeSrcSetAt = Date.now(); + this.resetBillingAppLoadDiagnostics(); iframe.src = this.getIframeURL(); } @@ -129,11 +124,102 @@ export default class BillingService extends Service { this.setBillingIframeSrc(); } - markBillingAppLoaded() { + markBillingAppLoaded(payload = null) { this.billingAppLoaded = true; + this.billingAppLoadFailureReported = false; + this.billingAppReadyReceivedAt = Date.now(); + this.billingAppReadyPayload = payload; this.clearBillingAppLoadMonitor(); } + resetBillingAppLoadDiagnostics() { + this.billingAppPreReadyMessageCount = 0; + this.billingAppPreReadyMessageTypes = []; + this.billingAppLastPreReadyMessageType = null; + this.billingAppReadyReceivedAt = null; + this.billingAppReadyPayload = null; + } + + recordBillingAppPreReadyMessage(data) { + if (this.billingAppLoaded || this.billingAppReadyReceivedAt || this.billingAppLoadFailureReported) { + return; + } + + if (!this.billingAppLoadTimeout && !this.billingAppRetryTimeout) { + return; + } + + const messageType = this.getBillingAppMessageType(data); + + this.billingAppPreReadyMessageCount += 1; + this.billingAppLastPreReadyMessageType = messageType; + + if (!this.billingAppPreReadyMessageTypes.includes(messageType)) { + this.billingAppPreReadyMessageTypes = [ + ...this.billingAppPreReadyMessageTypes, + messageType + ]; + } + } + + getBillingAppMessageType(data) { + if (data?.request) { + return data.request; + } + + if (data?.route) { + return 'route'; + } + + if (data?.subscription) { + return 'subscription'; + } + + if (data?.query) { + return data.query; + } + + return 'unknown'; + } + + getBillingAppOrigin() { + const iframeURL = this.getIframeURL({fetchOwner: false}); + + if (!iframeURL) { + return null; + } + + try { + return new URL(iframeURL).origin; + } catch (e) { + return null; + } + } + + isValidBillingIframeMessage(event) { + if (!event?.data) { + return false; + } + + const billingAppOrigin = this.getBillingAppOrigin(); + + if (!billingAppOrigin || event.origin !== billingAppOrigin) { + return false; + } + + const billingIframeWindow = this.getBillingIframe()?.contentWindow; + + if (!billingIframeWindow) { + return false; + } + + if (event.source !== billingIframeWindow) { + return false; + } + + return true; + } + clearBillingAppLoadMonitor() { if (this.billingAppLoadTimeout) { clearTimeout(this.billingAppLoadTimeout); @@ -159,6 +245,8 @@ export default class BillingService extends Service { const iframe = this.getBillingIframe(); const visibilityState = document.visibilityState; + const iframeSrc = iframe?.src || null; + const configuredBillingOrigin = this.getBillingAppOrigin(); // Fields are kept flat on `billing_monitor` because Sentry's default // `normalizeDepth` of 3 stringifies anything deeper to '[Object]', @@ -203,6 +291,8 @@ export default class BillingService extends Service { is_force_upgrade: !!this.config.hostSettings?.forceUpgrade, location_hash: window.location.hash, retry_delays_ms: this.billingAppLoadRetryDelaysMs, + iframe_src: iframeSrc, + configured_billing_origin: configuredBillingOrigin, document_visibility_state: visibilityState, iframe_offset_parent_visible: iframe ? iframe.offsetParent !== null : null, iframe_computed_display: computedDisplay, @@ -211,6 +301,10 @@ export default class BillingService extends Service { iframe_rect_height: rectHeight, iframe_load_fired: this.billingAppIframeLoadFired, ms_since_src_set: this.billingAppIframeSrcSetAt ? Date.now() - this.billingAppIframeSrcSetAt : null, + non_ready_message_count: this.billingAppPreReadyMessageCount, + non_ready_message_types: this.billingAppPreReadyMessageTypes.join(','), + last_non_ready_message_type: this.billingAppLastPreReadyMessageType, + ready_received: false, navigator_online: navigator.onLine, connection_effective_type: navigator.connection?.effectiveType ?? null, bma_boot_accessible: bmaBootAccessible, @@ -228,9 +322,13 @@ export default class BillingService extends Service { }); } - getIframeURL() { + getIframeURL(options = {}) { + const {fetchOwner = true} = options; + // initiate getting owner user in the background - this.getOwnerUser(); + if (fetchOwner) { + this.getOwnerUser(); + } let url = this.config.hostSettings?.billing?.url; diff --git a/ghost/admin/tests/integration/components/gh-billing-iframe-test.js b/ghost/admin/tests/integration/components/gh-billing-iframe-test.js new file mode 100644 index 00000000000..055c3004215 --- /dev/null +++ b/ghost/admin/tests/integration/components/gh-billing-iframe-test.js @@ -0,0 +1,130 @@ +import hbs from 'htmlbars-inline-precompile'; +import sinon from 'sinon'; +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {find, render, settled} from '@ember/test-helpers'; +import {setupRenderingTest} from 'ember-mocha'; + +describe('Integration: Component: gh-billing-iframe', function () { + setupRenderingTest(); + + let billing; + + async function postBillingMessage(data, options = {}) { + const iframe = find('#billing-frame'); + + window.dispatchEvent(new MessageEvent('message', { + data, + origin: options.origin ?? 'https://billing.example.test', + source: options.source ?? iframe.contentWindow + })); + + await settled(); + } + + beforeEach(function () { + billing = this.owner.lookup('service:billing'); + + sinon.stub(billing, 'getIframeURL').returns('https://billing.example.test/pro'); + sinon.stub(billing, 'startBillingAppLoadMonitor'); + }); + + afterEach(function () { + billing.clearBillingAppLoadMonitor(); + sinon.restore(); + }); + + it('marks the billing app loaded after billingAppReady from the validated iframe', async function () { + const markBillingAppLoaded = sinon.spy(billing, 'markBillingAppLoaded'); + + await render(hbs``); + + await postBillingMessage({ + request: 'billingAppReady', + route: '/plans', + state: 'content', + release: 'test', + timestamp: Date.now() + }); + + expect(markBillingAppLoaded.calledOnce).to.be.true; + expect(markBillingAppLoaded.firstCall.args[0]).to.include({ + request: 'billingAppReady', + route: '/plans', + state: 'content', + release: 'test' + }); + expect(billing.billingAppLoaded).to.be.true; + }); + + it('handles valid non-ready token messages without marking the billing app loaded', async function () { + const markBillingAppLoaded = sinon.spy(billing, 'markBillingAppLoaded'); + + await render(hbs``); + + const iframe = find('#billing-frame'); + const postMessage = sinon.stub(iframe.contentWindow, 'postMessage'); + + await postBillingMessage({request: 'token'}); + + expect(markBillingAppLoaded.called).to.be.false; + expect(billing.billingAppLoaded).to.be.false; + expect(postMessage.calledOnceWithExactly({ + request: 'token', + response: null + }, 'https://billing.example.test')).to.be.true; + }); + + it('handles valid route messages without marking the billing app loaded', async function () { + const markBillingAppLoaded = sinon.spy(billing, 'markBillingAppLoaded'); + const handleRouteChangeInIframe = sinon.spy(billing, 'handleRouteChangeInIframe'); + + await render(hbs``); + + await postBillingMessage({route: '/plans'}); + + expect(markBillingAppLoaded.called).to.be.false; + expect(handleRouteChangeInIframe.calledOnceWithExactly('/plans')).to.be.true; + expect(billing.billingAppLoaded).to.be.false; + }); + + it('ignores messages from an invalid origin', async function () { + const markBillingAppLoaded = sinon.spy(billing, 'markBillingAppLoaded'); + + await render(hbs``); + + const iframe = find('#billing-frame'); + const postMessage = sinon.stub(iframe.contentWindow, 'postMessage'); + + await postBillingMessage({request: 'token'}, {origin: 'https://evil.example.test'}); + await postBillingMessage({request: 'billingAppReady'}, {origin: 'https://evil.example.test'}); + + expect(markBillingAppLoaded.called).to.be.false; + expect(postMessage.called).to.be.false; + expect(billing.billingAppLoaded).to.be.false; + }); + + it('ignores messages from the wrong source window', async function () { + const markBillingAppLoaded = sinon.spy(billing, 'markBillingAppLoaded'); + + await render(hbs``); + + await postBillingMessage({request: 'billingAppReady'}, {source: window}); + + expect(markBillingAppLoaded.called).to.be.false; + expect(billing.billingAppLoaded).to.be.false; + }); + + it('ignores messages when the billing iframe window is unavailable', async function () { + const markBillingAppLoaded = sinon.spy(billing, 'markBillingAppLoaded'); + + await render(hbs``); + + sinon.stub(billing, 'getBillingIframe').returns(null); + + await postBillingMessage({request: 'billingAppReady'}, {source: window}); + + expect(markBillingAppLoaded.called).to.be.false; + expect(billing.billingAppLoaded).to.be.false; + }); +}); diff --git a/ghost/admin/tests/integration/components/gh-billing-modal-test.js b/ghost/admin/tests/integration/components/gh-billing-modal-test.js index 28bac3830e4..4528900044d 100644 --- a/ghost/admin/tests/integration/components/gh-billing-modal-test.js +++ b/ghost/admin/tests/integration/components/gh-billing-modal-test.js @@ -9,14 +9,36 @@ describe('Integration: Component: gh-billing-modal', function () { setupRenderingTest(); let billing; + let configManager; + let limit; + let stateBridge; + + async function postBillingMessage(data) { + const iframe = find('#billing-frame'); + + window.dispatchEvent(new MessageEvent('message', { + data, + origin: 'https://billing.example.test', + source: iframe.contentWindow + })); + + await settled(); + } beforeEach(function () { billing = this.owner.lookup('service:billing'); + configManager = this.owner.lookup('service:config-manager'); + limit = this.owner.lookup('service:limit'); + stateBridge = this.owner.lookup('service:state-bridge'); + billing.billingAppLoaded = false; billing.billingAppLoadFailureReported = false; sinon.stub(billing, 'getIframeURL').returns('https://billing.example.test'); sinon.stub(billing, 'startBillingAppLoadMonitor'); + sinon.stub(configManager, 'fetch').resolves(); + sinon.stub(limit, 'reload'); + sinon.stub(stateBridge, 'triggerSubscriptionChange'); }); afterEach(function () { @@ -24,19 +46,50 @@ describe('Integration: Component: gh-billing-modal', function () { sinon.restore(); }); - it('shows a loading state until the billing app sends its first message', async function () { + it('shows a loading state until the billing app sends billingAppReady', async function () { await render(hbs``); expect(find('[data-test-billing-loading]')).to.exist; expect(find('[data-test-billing-load-error]')).to.not.exist; - billing.markBillingAppLoaded(); - await settled(); + await postBillingMessage({ + request: 'billingAppReady', + route: '/plans', + state: 'loading', + release: 'test', + timestamp: Date.now() + }); expect(find('[data-test-billing-loading]')).to.not.exist; expect(find('[data-test-billing-load-error]')).to.not.exist; }); + it('keeps the loading state for valid non-ready billing app messages', async function () { + await render(hbs``); + + await postBillingMessage({request: 'token'}); + expect(find('[data-test-billing-loading]')).to.exist; + + await postBillingMessage({request: 'forceUpgradeInfo'}); + expect(find('[data-test-billing-loading]')).to.exist; + + await postBillingMessage({route: '/plans'}); + expect(find('[data-test-billing-loading]')).to.exist; + + await postBillingMessage({ + subscription: { + status: 'active' + } + }); + expect(find('[data-test-billing-loading]')).to.exist; + + await postBillingMessage({ + request: 'billingAppReady', + state: 'content' + }); + expect(find('[data-test-billing-loading]')).to.not.exist; + }); + it('shows a customer-facing error when the billing app does not become ready', async function () { billing.billingAppLoadFailureReported = true; diff --git a/ghost/admin/tests/unit/services/billing-test.js b/ghost/admin/tests/unit/services/billing-test.js index d7aba5025e5..e9ddd445111 100644 --- a/ghost/admin/tests/unit/services/billing-test.js +++ b/ghost/admin/tests/unit/services/billing-test.js @@ -129,8 +129,14 @@ describe('Unit: Service: billing', function () { attempts: 2, has_billing_url: true, is_force_upgrade: false, + iframe_src: null, + configured_billing_origin: 'https://billing.example.test', iframe_load_fired: true, billing_window_open: true, + non_ready_message_count: 0, + non_ready_message_types: '', + last_non_ready_message_type: null, + ready_received: false, navigator_online: navigator.onLine, document_visibility_state: document.visibilityState, bma_boot_accessible: false, @@ -140,6 +146,70 @@ describe('Unit: Service: billing', function () { expect(billingMonitor.ms_since_src_set).to.be.a('number').and.to.be.at.least(1234); }); + it('reports pre-ready message diagnostics to Sentry', async function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + sinon.stub(service, 'getBillingIframe').returns(null); + service.billingAppLoadAttempts = 2; + service.billingAppLoadTimeout = setTimeout(() => {}, 10000); + service.billingAppIframeSrcSetAt = Date.now() - 100; + + service.recordBillingAppPreReadyMessage({request: 'token'}); + service.recordBillingAppPreReadyMessage({route: '/plans'}); + service.recordBillingAppPreReadyMessage({ + subscription: { + status: 'active' + } + }); + service.reportBillingAppLoadFailure(); + + await waitUntil(() => testkit.reports().length > 0); + + const billingMonitor = testkit.reports()[0].originalReport.contexts.ghost.billing_monitor; + expect(billingMonitor).to.deep.include({ + non_ready_message_count: 3, + non_ready_message_types: 'token,route,subscription', + last_non_ready_message_type: 'subscription', + ready_received: false + }); + }); + + it('resets billing app load diagnostics when a fresh iframe load starts', function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + const iframe = {src: '', addEventListener: sinon.stub()}; + sinon.stub(service, 'getBillingIframe').returns(iframe); + + service.billingAppLoadTimeout = setTimeout(() => {}, 10000); + service.recordBillingAppPreReadyMessage({request: 'token'}); + service.markBillingAppLoaded({request: 'billingAppReady'}); + + service.setBillingIframeSrc(); + + expect(service.billingAppPreReadyMessageCount).to.equal(0); + expect(service.billingAppPreReadyMessageTypes).to.deep.equal([]); + expect(service.billingAppLastPreReadyMessageType).to.be.null; + expect(service.billingAppReadyReceivedAt).to.be.null; + expect(service.billingAppReadyPayload).to.be.null; + }); + + it('treats late billingAppReady as recovery after a reported load failure', function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + const readyPayload = { + request: 'billingAppReady', + state: 'content' + }; + service.billingAppLoadFailureReported = true; + + service.markBillingAppLoaded(readyPayload); + + expect(service.billingAppLoaded).to.be.true; + expect(service.billingAppLoadFailureReported).to.be.false; + expect(service.billingAppReadyReceivedAt).to.be.a('number'); + expect(service.billingAppReadyPayload).to.equal(readyPayload); + }); + it('does not report when the billing app becomes ready before the timeout', function () { const service = this.owner.lookup('service:billing'); billingService = service; From f809c44c69cd1cd1be78e62dbb7578bf0c9016da Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Thu, 21 May 2026 12:44:42 +0100 Subject: [PATCH 02/15] Added Danger Zone action to reset all authentication credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new button under Settings → Advanced → Danger Zone rotates every API key secret, locks every staff user (rotating their password and writing a `security_action` audit row), wipes every staff session, and asks each registered scheduler to reissue queued URLs under the new keys. This lets a site owner recover from a suspected credential compromise in one click instead of editing the database directly. Suspended staff users have their password rotated but stay suspended; active staff hit the standard reset-on-signin flow on their next sign-in. Members are unaffected. --- apps/admin-x-framework/src/api/security.ts | 14 ++ .../settings/advanced/advanced-settings.tsx | 2 +- .../settings/advanced/danger-zone.tsx | 73 ++++++- .../advanced/labs/private-features.tsx | 4 + .../src/components/sidebar.tsx | 1 + .../acceptance/advanced/dangerzone.test.ts | 35 +++- .../settings/sections/danger-zone-section.ts | 38 ++++ .../pages/admin/settings/sections/index.ts | 1 + .../pages/admin/settings/settings-page.ts | 4 +- e2e/tests/admin/settings/danger-zone.test.ts | 62 ++++++ ghost/admin/package.json | 2 +- ghost/core/core/boot.js | 16 +- .../adapters/scheduling/scheduling-base.js | 41 ++++ .../core/server/adapters/scheduling/types.ts | 36 ++++ .../server/api/endpoints/authentication.js | 31 ++- .../core/server/api/endpoints/schedules.js | 30 --- .../serializers/output/authentication.js | 10 + ...0-rename-reset-all-passwords-permission.js | 21 ++ .../server/data/schema/fixtures/fixtures.json | 6 +- ghost/core/core/server/models/api-key.js | 18 ++ ghost/core/core/server/models/user.js | 18 ++ ghost/core/core/server/services/auth/index.js | 4 + .../services/auth/reset-authentication.ts | 75 +++++++ .../core/server/services/automations/index.js | 36 ++-- .../gifts/gift-bookshelf-repository.ts | 10 + .../services/gifts/gift-reminder-scheduler.ts | 109 ++++++++++ .../server/services/gifts/gift-repository.ts | 1 + .../services/gifts/gift-service-wrapper.js | 17 +- .../server/services/gifts/gift-service.ts | 62 +----- .../server/services/internal-keys/index.ts | 14 +- .../server/services/post-scheduling/index.js | 29 --- .../server/services/post-scheduling/index.ts | 14 ++ .../post-scheduling/post-scheduler-service.js | 126 ----------- .../post-scheduling/post-scheduling.ts | 138 ++++++++++++ ghost/core/core/server/services/users.js | 45 ++-- .../web/api/endpoints/admin/middleware.js | 12 +- .../server/web/api/endpoints/admin/routes.js | 2 +- ghost/core/core/shared/labs.js | 3 +- ghost/core/package.json | 2 +- ghost/core/test/.eslintrc.js | 3 +- .../admin/__snapshots__/config.test.js.snap | 1 + .../test/e2e-api/admin/api-tokens.test.js | 169 +++++---------- .../integration/migrations/migration.test.js | 2 +- .../__snapshots__/authentication.test.js.snap | 13 ++ .../legacy/api/admin/authentication.test.js | 61 ++++-- .../scheduling/scheduling-base.test.js | 106 ++++++++++ .../unit/server/data/schema/integrity.test.js | 2 +- .../core/test/unit/server/models/user.test.js | 43 ++++ .../auth/reset-authentication.test.ts | 169 +++++++++++++++ .../server/services/automations/index.test.js | 44 ++-- .../gifts/gift-reminder-scheduler.test.ts | 196 ++++++++++++++++++ .../services/gifts/gift-service.test.ts | 161 +++----------- ...ervice.test.js => post-scheduling.test.js} | 86 ++++---- .../services/users/users-service.test.js | 126 ++++++++--- ghost/core/test/utils/fixtures/fixtures.json | 6 +- 55 files changed, 1676 insertions(+), 674 deletions(-) create mode 100644 apps/admin-x-framework/src/api/security.ts create mode 100644 e2e/helpers/pages/admin/settings/sections/danger-zone-section.ts create mode 100644 e2e/tests/admin/settings/danger-zone.test.ts create mode 100644 ghost/core/core/server/adapters/scheduling/types.ts create mode 100644 ghost/core/core/server/data/migrations/versions/6.41/2026-05-13-12-00-00-rename-reset-all-passwords-permission.js create mode 100644 ghost/core/core/server/services/auth/reset-authentication.ts create mode 100644 ghost/core/core/server/services/gifts/gift-reminder-scheduler.ts delete mode 100644 ghost/core/core/server/services/post-scheduling/index.js create mode 100644 ghost/core/core/server/services/post-scheduling/index.ts delete mode 100644 ghost/core/core/server/services/post-scheduling/post-scheduler-service.js create mode 100644 ghost/core/core/server/services/post-scheduling/post-scheduling.ts create mode 100644 ghost/core/test/unit/server/adapters/scheduling/scheduling-base.test.js create mode 100644 ghost/core/test/unit/server/services/auth/reset-authentication.test.ts create mode 100644 ghost/core/test/unit/server/services/gifts/gift-reminder-scheduler.test.ts rename ghost/core/test/unit/server/services/post-scheduling/{post-scheduler-service.test.js => post-scheduling.test.js} (55%) diff --git a/apps/admin-x-framework/src/api/security.ts b/apps/admin-x-framework/src/api/security.ts new file mode 100644 index 00000000000..4ac50908c3c --- /dev/null +++ b/apps/admin-x-framework/src/api/security.ts @@ -0,0 +1,14 @@ +import {createMutation} from '../utils/api/hooks'; + +export interface ResetAuthResponse { + security_action: Array<{ + action: 'reset_authentication'; + api_keys_rotated: number; + users_locked: number; + }>; +} + +export const useResetAuth = createMutation({ + method: 'POST', + path: () => '/authentication/reset/' +}); diff --git a/apps/admin-x-settings/src/components/settings/advanced/advanced-settings.tsx b/apps/admin-x-settings/src/components/settings/advanced/advanced-settings.tsx index d29d0062a52..ca5389fa323 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/advanced-settings.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/advanced-settings.tsx @@ -13,7 +13,7 @@ export const searchKeywords = { codeInjection: ['advanced', 'code injection', 'head', 'footer'], labs: ['advanced', 'labs', 'alpha', 'private', 'beta', 'flag', 'routes', 'redirect', 'translation', 'editor', 'portal'], history: ['advanced', 'history', 'log', 'events', 'user events', 'staff', 'audit', 'action'], - dangerzone: ['danger', 'danger zone', 'delete', 'content', 'delete all content', 'delete site'] + dangerzone: ['danger zone', 'delete all content', 'delete site', 'reset all authentication', 'reset api keys', 'reset password', 'compromised credentials', 'lock staff users', 'sign out all staff'] }; const AdvancedSettings: React.FC = () => { diff --git a/apps/admin-x-settings/src/components/settings/advanced/danger-zone.tsx b/apps/admin-x-settings/src/components/settings/advanced/danger-zone.tsx index 57d1f0360c0..4cdbb8d65cb 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/danger-zone.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/danger-zone.tsx @@ -1,15 +1,30 @@ import NiceModal from '@ebay/nice-modal-react'; import React from 'react'; import TopLevelGroup from '../../top-level-group'; -import {Button, ConfirmationModal, SettingGroupHeader, showToast, withErrorBoundary} from '@tryghost/admin-x-design-system'; +import useStaffUsers from '../../../hooks/use-staff-users'; +import {Button, ConfirmationModal, ListItem, SettingGroupHeader, showToast, withErrorBoundary} from '@tryghost/admin-x-design-system'; +import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {useDeleteAllContent} from '@tryghost/admin-x-framework/api/db'; +import {useGlobalData} from '../../providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useQueryClient} from '@tryghost/admin-x-framework'; +import {useResetAuth} from '@tryghost/admin-x-framework/api/security'; const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => { const {mutateAsync: deleteAllContent} = useDeleteAllContent(); + const {mutateAsync: resetAuth} = useResetAuth(); const client = useQueryClient(); const handleError = useHandleError(); + const {config} = useGlobalData(); + const {totalUsers} = useStaffUsers(); + + const resetAuthEnabled = Boolean(config?.labs?.dangerZoneResetAuth); + + const resetAuthStaffSentence = totalUsers === 1 + ? 'You will be signed out and must reset your password before signing back in.' + : totalUsers > 1 + ? `All ${totalUsers} staff users, including you, will be signed out and must reset their password before signing back in.` + : 'All staff users, including you, will be signed out and must reset their password before signing back in.'; const handleDeleteAllContent = () => { NiceModal.show(ConfirmationModal, { @@ -33,17 +48,67 @@ const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => { }); }; + const handleResetAuth = () => { + NiceModal.show(ConfirmationModal, { + title: 'Reset all authentication?', + prompt: ( + <> +

+ This rotates every API key on your site. Any integration using one will stop working until you reconfigure it with the new key from Settings → Advanced → Integrations. +

+

+ {resetAuthStaffSentence} Your members aren't affected. +

+ + ), + okLabel: 'Reset all authentication', + okRunningLabel: 'Resetting...', + okColor: 'red', + onOk: async (modal) => { + try { + const response = await resetAuth(null); + const result = response?.security_action?.[0]; + const keys = result?.api_keys_rotated ?? 0; + const users = result?.users_locked ?? 0; + showToast({ + title: `Rotated ${keys} API ${keys === 1 ? 'key' : 'keys'} and locked ${users} ${users === 1 ? 'user' : 'users'}. You will be signed out shortly.`, + type: 'success' + }); + modal?.remove(); + window.location.href = getGhostPaths().adminRoot; + } catch (e) { + handleError(e); + } + } + }); + }; + return ( + } keywords={keywords} navid='dangerzone' testId='dangerzone' > -
-
); }; diff --git a/apps/admin-x-settings/test/acceptance/membership/analytics.test.ts b/apps/admin-x-settings/test/acceptance/membership/analytics.test.ts index 621bf289fcd..980e9648fde 100644 --- a/apps/admin-x-settings/test/acceptance/membership/analytics.test.ts +++ b/apps/admin-x-settings/test/acceptance/membership/analytics.test.ts @@ -135,6 +135,45 @@ test.describe('Analytics settings', async () => { expect(hasDownloadUrl).toBe(true); }); + test('Disables post analytics export button and shows loading state while downloading', async ({page}) => { + await mockApi({page, requests: createMockApiConfig({})}); + + let exportRequestCount = 0; + let resolveExportRequest: (() => void) | undefined; + + await page.route(/\/ghost\/api\/admin\/posts\/export\/\?limit=1000$/, async (route) => { + exportRequestCount += 1; + await new Promise((resolve) => { + resolveExportRequest = resolve; + }); + await route.fulfill({ + status: 200, + body: 'csv data', + headers: { + 'content-type': 'text/csv' + } + }); + }); + + await page.goto('/'); + + const section = page.getByTestId('migrationtools'); + + await section.getByRole('tab', {name: 'Export'}).click(); + + const postAnalyticsButton = section.getByTestId('post-analytics-export-button'); + await postAnalyticsButton.click(); + + await expect.poll(() => exportRequestCount).toBe(1); + await expect(postAnalyticsButton).toBeDisabled(); + await expect(postAnalyticsButton).toContainText('Loading...'); + + resolveExportRequest?.(); + + await expect(postAnalyticsButton).toBeEnabled(); + expect(exportRequestCount).toBe(1); + }); + test('Supports read only settings', async ({page}) => { await mockApi({page, requests: { ...globalDataRequests,