From 6441d6af00a12761188515ed37f5763e38a30d09 Mon Sep 17 00:00:00 2001 From: Sag Date: Wed, 6 May 2026 07:31:40 +0200 Subject: [PATCH 1/8] Added paid welcome email for existing members redeeming a gift (#27659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes https://linear.app/ghost/issue/BER-3541 - Existing free members who redeem a gift subscription now receive the paid welcome email — previously only new gift signups got it. - Centralised the paid welcome email enqueue inside `gift-service.redeem()` so new and existing-member redemptions share one code path. - Refactored existing welcome email paths to use the new shared `enqueueWelcomeEmailRun()` method --------- Co-authored-by: Troy Ciesco --- .../server/services/gifts/gift-service.ts | 5 + .../repositories/member-repository.js | 133 +++++++++--------- .../members/gift-subscriptions.test.js | 69 ++++++++- .../services/gifts/gift-service.test.ts | 68 ++++++++- .../repositories/member-repository.test.js | 77 ---------- 5 files changed, 203 insertions(+), 149 deletions(-) diff --git a/ghost/core/core/server/services/gifts/gift-service.ts b/ghost/core/core/server/services/gifts/gift-service.ts index 5e261b873f4..7130e774926 100644 --- a/ghost/core/core/server/services/gifts/gift-service.ts +++ b/ghost/core/core/server/services/gifts/gift-service.ts @@ -5,6 +5,7 @@ import {Gift} from './gift'; import type {GiftRepository} from './gift-repository'; import tpl from '@tryghost/tpl'; import {GIFT_REMINDER_FLOOR_DAYS, GIFT_REMINDER_LEAD_DAYS} from './constants'; +import {MEMBER_WELCOME_EMAIL_SLUGS} from '../member-welcome-emails/constants'; const MS_PER_DAY = 24 * 60 * 60 * 1000; const GIFT_REMINDER_LEAD_MS = GIFT_REMINDER_LEAD_DAYS * MS_PER_DAY; @@ -37,6 +38,7 @@ interface MemberModel { interface MemberRepository { get(filter: Record, options?: Record): Promise; update(data: Record, options?: Record): Promise; + enqueueWelcomeEmailRun(memberId: string, slug: string, options?: Record): Promise; } type Tier = { @@ -325,6 +327,9 @@ export class GiftService { await this.deps.giftRepository.update(redeemed, {transacting}); + // Gift members receive the paid welcome email, as they receive access to paid content + await this.deps.memberRepository.enqueueWelcomeEmailRun(memberId, MEMBER_WELCOME_EMAIL_SLUGS.paid, {transacting}); + return {redeemed, member}; }; diff --git a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js index 9551df91055..1388400decf 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js @@ -173,6 +173,57 @@ module.exports = class MemberRepository { return nickname && nickname.toLowerCase() === 'complimentary'; } + /** + * Looks up the active welcome email automation for the given slug and enqueues a + * `WelcomeEmailAutomationRun` for the member. Dispatches `StartAutomationsPollEvent` + * so the poll picks it up. Returns the created run, or null if there is no active + * automation/email for that slug. + * + * Callers are responsible for any eligibility gating (member status, source, etc.) + * before calling this — this helper just looks up + inserts + dispatches. Pass + * `options.transacting` to run the insert inside an existing transaction; the + * dispatch is automatically deferred until that transaction commits. + * + * @param {string} memberId + * @param {string} slug automation slug, see MEMBER_WELCOME_EMAIL_SLUGS + * @param {object} [options] bookshelf options (transacting, context, etc.) + */ + async enqueueWelcomeEmailRun(memberId, slug, options = {}) { + if (!this._WelcomeEmailAutomation || !this._WelcomeEmailAutomationRun) { + return null; + } + + const automation = await this._WelcomeEmailAutomation.findOne( + {slug}, + {...options, withRelated: ['welcomeEmailAutomatedEmail']} + ); + const email = automation?.related('welcomeEmailAutomatedEmail'); + const isActive = Boolean( + automation && + email && + email.get('lexical') && + automation.get('status') === 'active' + ); + + if (!isActive) { + return null; + } + + const run = await this._WelcomeEmailAutomationRun.add({ + welcome_email_automation_id: automation.id, + member_id: memberId, + next_welcome_email_automated_email_id: email.id, + ready_at: new Date(), + step_started_at: null, + step_attempts: 0, + exit_reason: null + }, options); + + this.dispatchEvent(StartAutomationsPollEvent.create(), options); + + return run; + } + /** * Maps the framework context to members_*.source table record value * @param {Object} context instance of ghost framework context object @@ -382,50 +433,15 @@ module.exports = class MemberRepository { let member; const isFreeSignup = !stripeCustomer && memberData.status === 'free'; - const isGiftSignup = !stripeCustomer && memberData.status === 'gift'; - let welcomeEmailToEnqueue = null; - - if (this._WelcomeEmailAutomation && WELCOME_EMAIL_SOURCES.includes(source)) { - const getActiveWelcomeEmailToEnqueue = async (slug) => { - const automation = await this._WelcomeEmailAutomation.findOne( - {slug}, - {...options, withRelated: ['welcomeEmailAutomatedEmail']} - ); - const email = automation?.related('welcomeEmailAutomatedEmail'); - const isActive = Boolean( - automation && - email && - email.get('lexical') && - automation.get('status') === 'active' - ); - - return isActive ? {automation, email} : null; - }; - - if (isFreeSignup) { - welcomeEmailToEnqueue = await getActiveWelcomeEmailToEnqueue(MEMBER_WELCOME_EMAIL_SLUGS.free); - } else if (isGiftSignup) { - // As gift members get access to a paid tier, they receive the paid welcome email - welcomeEmailToEnqueue = await getActiveWelcomeEmailToEnqueue(MEMBER_WELCOME_EMAIL_SLUGS.paid); - } - } - if (welcomeEmailToEnqueue) { + if (isFreeSignup && WELCOME_EMAIL_SOURCES.includes(source)) { const runMemberCreation = async (transacting) => { const newMember = await this._Member.add({ ...memberData, labels }, {...memberAddOptions, transacting}); - await this._WelcomeEmailAutomationRun.add({ - welcome_email_automation_id: welcomeEmailToEnqueue.automation.id, - member_id: newMember.id, - next_welcome_email_automated_email_id: welcomeEmailToEnqueue.email.id, - ready_at: new Date(), - step_started_at: null, - step_attempts: 0, - exit_reason: null - }, {transacting}); + await this.enqueueWelcomeEmailRun(newMember.id, MEMBER_WELCOME_EMAIL_SLUGS.free, {transacting}); return newMember; }; @@ -435,8 +451,6 @@ module.exports = class MemberRepository { } else { member = await this._Member.transaction(runMemberCreation); } - - this.dispatchEvent(StartAutomationsPollEvent.create(), memberAddOptions); } else { member = await this._Member.add({ ...memberData, @@ -1516,38 +1530,17 @@ module.exports = class MemberRepository { const context = options?.context || {}; const source = this._resolveContextSource(context); - const shouldSendPaidWelcomeEmail = WELCOME_EMAIL_SOURCES.includes(source); - let isPaidWelcomeEmailActive = false; - let paidWelcomeAutomation = null; - let paidWelcomeEmail = null; - if (shouldSendPaidWelcomeEmail && this._WelcomeEmailAutomation) { - paidWelcomeAutomation = await this._WelcomeEmailAutomation.findOne( - {slug: MEMBER_WELCOME_EMAIL_SLUGS.paid}, - {...options, withRelated: ['welcomeEmailAutomatedEmail']} - ); - paidWelcomeEmail = paidWelcomeAutomation?.related('welcomeEmailAutomatedEmail'); - isPaidWelcomeEmailActive = Boolean( - paidWelcomeAutomation && - paidWelcomeEmail && - paidWelcomeEmail.get('lexical') && - paidWelcomeAutomation.get('status') === 'active' - ); - } - // Send paid welcome email if: - // 1. The paid welcome email is active + + // Enqueue paid welcome email if: + // 1. The source is allowed to send welcome emails // 2. The member status changed to 'paid' - // 3. The previous status wasn't 'gift', as gift members already received the paid welcome email on signup - if (updatedMember.get('status') === 'paid' && updatedMember._previousAttributes.status !== 'gift' && isPaidWelcomeEmailActive) { - await this._WelcomeEmailAutomationRun.add({ - welcome_email_automation_id: paidWelcomeAutomation.id, - member_id: memberModel.id, - next_welcome_email_automated_email_id: paidWelcomeEmail.id, - ready_at: new Date(), - step_started_at: null, - step_attempts: 0, - exit_reason: null - }, options); - this.dispatchEvent(StartAutomationsPollEvent.create(), options); + // 3. The previous status wasn't 'gift', as gift members already received the paid welcome email on redemption + if ( + WELCOME_EMAIL_SOURCES.includes(source) && + updatedMember.get('status') === 'paid' && + updatedMember._previousAttributes.status !== 'gift' + ) { + await this.enqueueWelcomeEmailRun(memberModel.id, MEMBER_WELCOME_EMAIL_SLUGS.paid, options); } } } diff --git a/ghost/core/test/e2e-api/members/gift-subscriptions.test.js b/ghost/core/test/e2e-api/members/gift-subscriptions.test.js index c62b43163c0..cd6c9cae188 100644 --- a/ghost/core/test/e2e-api/members/gift-subscriptions.test.js +++ b/ghost/core/test/e2e-api/members/gift-subscriptions.test.js @@ -873,7 +873,7 @@ describe('Gift Subscriptions', function () { const email = 'gift-existing-member@test.com'; // Create member first - await models.Member.add({email, name: 'Existing Member', email_disabled: false}); + const existingMember = await models.Member.add({email, name: 'Existing Member', email_disabled: false}); await DomainEvents.allSettled(); const gift = await createGift(); @@ -881,13 +881,54 @@ describe('Gift Subscriptions', function () { const redirectUrl = new URL(urlUtils.getSiteUrl()); redirectUrl.hash = '#/portal/account?giftRedemption=true'; + let freeWelcomeAutomation; + let paidWelcomeAutomation; + try { + // Set up both free and paid welcome email automations to verify gift + // redemption picks the paid welcome email (not the free one) — same as + // the new-member case above. + const emailDesignSetting = await models.EmailDesignSetting.findOne( + {slug: 'default-automated-email'}, + {require: true} + ); + freeWelcomeAutomation = await models.WelcomeEmailAutomation.add({ + name: 'Free welcome email', + slug: 'member-welcome-email-free', + status: 'active' + }); + await models.WelcomeEmailAutomatedEmail.add({ + welcome_email_automation_id: freeWelcomeAutomation.id, + delay_days: 0, + subject: 'Welcome to the site!', + lexical: JSON.stringify({root: {children: [{type: 'paragraph', children: [{text: 'Welcome'}]}]}}), + email_design_setting_id: emailDesignSetting.id + }); + paidWelcomeAutomation = await models.WelcomeEmailAutomation.add({ + name: 'Paid welcome email', + slug: 'member-welcome-email-paid', + status: 'active' + }); + await models.WelcomeEmailAutomatedEmail.add({ + welcome_email_automation_id: paidWelcomeAutomation.id, + delay_days: 0, + subject: 'Welcome to the paid tier!', + lexical: JSON.stringify({root: {children: [{type: 'paragraph', children: [{text: 'Welcome paid'}]}]}}), + email_design_setting_id: emailDesignSetting.id + }); + await models.Product.edit({ welcome_page_url: '' }, { id: paidProduct.id }); + // The existing free member shouldn't have any welcome runs yet + const runsBefore = await models.WelcomeEmailAutomationRun.findAll({ + filter: `member_id:'${existingMember.id}'` + }); + assert.equal(runsBefore.length, 0, 'Existing free member should have no welcome email runs before redemption'); + const magicLink = await membersService.api.getMagicLink(email, 'subscribe', { giftToken: gift.get('token') }); @@ -916,6 +957,19 @@ describe('Gift Subscriptions', function () { assert.ok(gift.get('redeemed_at')); assert.ok(gift.get('consumes_at')); + // Verify the paid welcome automation enqueued a run for this member, + // and that the free welcome automation did NOT (gift redemption + // delivers the paid welcome email regardless of pre-redemption status). + const welcomeRuns = await models.WelcomeEmailAutomationRun.findAll({ + filter: `member_id:'${member.id}'` + }); + assert.equal(welcomeRuns.length, 1, 'Should enqueue exactly one welcome email automation run for an existing free member redeeming a gift'); + assert.equal( + welcomeRuns.models[0].get('welcome_email_automation_id'), + paidWelcomeAutomation.id, + 'Should enqueue the paid welcome email automation, not the free one' + ); + // Verify gift subscription started staff notification was sent mockManager.assert.sentEmail({ subject: /paid subscription started/i, @@ -933,6 +987,19 @@ describe('Gift Subscriptions', function () { }, { id: paidProduct.id }); + + for (const automation of [freeWelcomeAutomation, paidWelcomeAutomation]) { + if (!automation) { + continue; + } + const runs = await models.WelcomeEmailAutomationRun.findAll({ + filter: `welcome_email_automation_id:'${automation.id}'` + }); + for (const run of runs.models) { + await models.WelcomeEmailAutomationRun.destroy({id: run.id}); + } + await models.WelcomeEmailAutomation.destroy({id: automation.id}); + } } }); }); diff --git a/ghost/core/test/unit/server/services/gifts/gift-service.test.ts b/ghost/core/test/unit/server/services/gifts/gift-service.test.ts index fb06ab874d0..f3b53505e07 100644 --- a/ghost/core/test/unit/server/services/gifts/gift-service.test.ts +++ b/ghost/core/test/unit/server/services/gifts/gift-service.test.ts @@ -47,6 +47,7 @@ describe('GiftService', function () { let memberRepository: { get: sinon.SinonStub; update: sinon.SinonStub; + enqueueWelcomeEmailRun: sinon.SinonStub; }; let staffServiceEmails: { notifyGiftReceived: sinon.SinonStub; @@ -97,7 +98,8 @@ describe('GiftService', function () { memberGet.withArgs('status').returns('free'); return Promise.resolve({id: 'member_1', get: memberGet}); }), - update: sinon.stub().resolves(undefined) + update: sinon.stub().resolves(undefined), + enqueueWelcomeEmailRun: sinon.stub().resolves(undefined) }; staffServiceEmails = { notifyGiftReceived: sinon.stub(), @@ -1289,6 +1291,70 @@ describe('GiftService', function () { sinon.assert.notCalled(giftRepository.update); sinon.assert.notCalled(staffServiceEmails.notifyGiftSubscriptionStarted); }); + + it('enqueues the paid welcome email run for a new gift signup', async function () { + const gift = buildGift(); + const memberGet = sinon.stub(); + memberGet.withArgs('status').returns('gift'); + memberGet.withArgs('name').returns('Member Name'); + memberGet.withArgs('email').returns('member@example.com'); + + giftRepository.getByToken.resolves(gift); + memberRepository.get.resolves({id: 'member_1', get: memberGet}); + + const service = createService(); + await service.redeem('gift-token', 'member_1', {newMember: true}); + + sinon.assert.calledOnceWithExactly( + memberRepository.enqueueWelcomeEmailRun, + 'member_1', + 'member-welcome-email-paid', + {transacting: 'trx'} + ); + }); + + it('enqueues the paid welcome email run when an existing free member redeems a gift', async function () { + const gift = buildGift(); + const memberGet = sinon.stub(); + memberGet.withArgs('status').returns('free'); + memberGet.withArgs('name').returns('Member Name'); + memberGet.withArgs('email').returns('member@example.com'); + + giftRepository.getByToken.resolves(gift); + memberRepository.get.resolves({id: 'member_1', get: memberGet}); + + const service = createService(); + await service.redeem('gift-token', 'member_1'); + + sinon.assert.calledOnceWithExactly( + memberRepository.enqueueWelcomeEmailRun, + 'member_1', + 'member-welcome-email-paid', + {transacting: 'trx'} + ); + }); + + it('passes the external transaction through to the welcome email enqueue', async function () { + const gift = buildGift(); + const memberGet = sinon.stub(); + memberGet.withArgs('status').returns('free'); + memberGet.withArgs('name').returns('Member Name'); + memberGet.withArgs('email').returns('member@example.com'); + + giftRepository.getByToken.resolves(gift); + memberRepository.get.resolves({id: 'member_1', get: memberGet}); + + const service = createService(); + const externalTrx = {executionPromise: Promise.resolve()}; + await service.redeem('gift-token', 'member_1', {transacting: externalTrx}); + + sinon.assert.calledOnceWithExactly( + memberRepository.enqueueWelcomeEmailRun, + 'member_1', + 'member-welcome-email-paid', + {transacting: externalTrx} + ); + }); }); describe('scheduleReminder (via redeem)', function () { diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js index edc9be182ae..531e4d1c855 100644 --- a/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js @@ -1563,83 +1563,6 @@ describe('MemberRepository', function () { assert.equal(runCall.exit_reason, null); }); - it('creates automation run for gift member signup (paid welcome email)', async function () { - // Override stub with paid welcome email - WelcomeEmailAutomation.findOne = sinon.stub().resolves({ - id: 'automation_id_paid', - get: sinon.stub().callsFake((key) => { - const data = {status: 'active'}; - return data[key]; - }), - related: sinon.stub().callsFake((relation) => { - assert.equal(relation, 'welcomeEmailAutomatedEmail'); - return { - id: 'automated_email_id_paid', - get: sinon.stub().callsFake((key) => { - const data = {lexical: '{"root":{}}'}; - return data[key]; - }) - }; - }) - }); - - const repo = new MemberRepository({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberStatusEvent, - MemberSubscribeEventModel: MemberSubscribeEvent, - newslettersService, - WelcomeEmailAutomation, - OfferRedemption: mockOfferRedemption - }); - - await repo.create({email: 'test@example.com', name: 'Test Member', status: 'gift'}, {}); - - sinon.assert.calledOnce(WelcomeEmailAutomation.findOne); - assert.equal(WelcomeEmailAutomation.findOne.firstCall.args[0].slug, 'member-welcome-email-paid'); - - sinon.assert.calledOnce(WelcomeEmailAutomationRun.add); - const runCall = WelcomeEmailAutomationRun.add.firstCall.args[0]; - assert.equal(runCall.welcome_email_automation_id, 'automation_id_paid'); - assert.equal(runCall.member_id, 'member_id_123'); - assert.equal(runCall.next_welcome_email_automated_email_id, 'automated_email_id_paid'); - }); - - it('does NOT create automation run for a gift signup when the paid welcome email is inactive', async function () { - // Override stub with inactive paid welcome email - WelcomeEmailAutomation.findOne = sinon.stub().resolves({ - id: 'automation_id_paid', - get: sinon.stub().callsFake((key) => { - const data = {status: 'inactive'}; - return data[key]; - }), - related: sinon.stub().callsFake(() => ({ - id: 'automated_email_id_paid', - get: sinon.stub().callsFake((key) => { - const data = {lexical: '{"root":{}}'}; - return data[key]; - }) - })) - }); - - const repo = new MemberRepository({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberStatusEvent, - MemberSubscribeEventModel: MemberSubscribeEvent, - newslettersService, - WelcomeEmailAutomation, - OfferRedemption: mockOfferRedemption - }); - - await repo.create({email: 'test@example.com', name: 'Test Member', status: 'gift'}, {}); - - sinon.assert.notCalled(WelcomeEmailAutomationRun.add); - sinon.assert.notCalled(Member.transaction); - }); - it('does not create automation run for disallowed sources', async function () { const repo = new MemberRepository({ Member, From 5b7a4e1a88d9d0512b1afb82db7b942ab12c9aec Mon Sep 17 00:00:00 2001 From: Jannis Fedoruk-Betschki Date: Tue, 5 May 2026 19:45:38 +0200 Subject: [PATCH 2/8] =?UTF-8?q?=F0=9F=8E=A8=20Expanded=20file=20upload=20a?= =?UTF-8?q?llowlist=20with=20non-executable=20formats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://github.com/TryGhost/Ghost/pull/26869 - the allowlist introduced in #26869 protects against executable content on the shared CDN domain, but rejects many legitimate non-executable formats that worked before (camera RAW files, common archive formats, modern image/video containers, 3D/CAD formats, etc.) - the additions are safe because the storage adapter's `getStorageContentType()` already falls back to `application/octet-stream` for any extension whose MIME type is not on the browser-renderable allowlist, forcing a download rather than rendering or executing the file - new extensions cover photography (.np3, .nef, .cr2, .cr3, .arw, .raf, .orf, .rw2, .dng, .tif, .tiff, .bmp, .heic, .heif, .avif), audio (.flac, .aac, .aif, .aiff, .opus, .mid, .midi), video (.webm, .mkv, .avi, .m4v), archives (.7z, .rar, .gz, .tgz, .tar, .bz2), 3D/design (.stl, .obj, .glb, .gltf, .fbx, .blend, .ai, .eps, .xcf), fonts (.ttf, .eot), data/docs (.toml, .tsv, .geojson, .vcf, .numbers, .odp, .ppt, .fb2) - sorted the array alphabetically for easier future maintenance - updated the importer glob test to match the expanded list --- ghost/core/core/shared/config/overrides.json | 74 ++++++++++++++++--- .../unit/server/data/importer/index.test.js | 4 +- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/ghost/core/core/shared/config/overrides.json b/ghost/core/core/shared/config/overrides.json index fdc3d03fe9c..cd23b412f40 100644 --- a/ghost/core/core/shared/config/overrides.json +++ b/ghost/core/core/shared/config/overrides.json @@ -44,53 +44,105 @@ }, "files": { "extensions": [ - ".pdf", - ".json", - ".jsonld", - ".ods", - ".odt", - ".pptx", - ".rtf", - ".txt", - ".xls", - ".xlsx", - ".xml", + ".7z", + ".aac", + ".ai", + ".aif", + ".aiff", ".apkg", + ".arw", + ".avif", + ".avi", + ".blend", + ".bmp", + ".bz2", + ".cr2", + ".cr3", ".css", ".csv", + ".dng", ".doc", ".docx", + ".eot", ".epub", + ".eps", + ".fb2", + ".fbx", + ".flac", + ".geojson", ".gif", + ".glb", + ".gltf", ".gpx", + ".gz", + ".heic", + ".heif", ".html", ".ics", ".ipynb", ".jpeg", ".jpg", ".js", + ".json", + ".jsonld", ".key", ".kml", ".m4a", + ".m4v", ".md", + ".mid", + ".midi", + ".mkv", ".mobi", ".mov", ".mp3", ".mp4", + ".nef", + ".np3", + ".numbers", + ".obj", + ".odp", + ".ods", + ".odt", + ".ogv", + ".opus", + ".orf", ".otf", ".pages", ".paprikarecipes", + ".pdf", ".png", + ".ppt", + ".pptx", ".psd", ".py", + ".raf", + ".rar", + ".rtf", + ".rw2", ".skp", + ".stl", ".svg", + ".tar", + ".tgz", + ".tif", + ".tiff", + ".toml", + ".tsv", + ".ttf", + ".txt", + ".vcf", ".wav", + ".webm", ".webp", ".woff", ".woff2", + ".xcf", + ".xls", ".xlsb", ".xlsm", + ".xlsx", + ".xml", ".yaml", ".zip" ], diff --git a/ghost/core/test/unit/server/data/importer/index.test.js b/ghost/core/test/unit/server/data/importer/index.test.js index 76c444946e9..b61d82c7a13 100644 --- a/ghost/core/test/unit/server/data/importer/index.test.js +++ b/ghost/core/test/unit/server/data/importer/index.test.js @@ -42,7 +42,7 @@ describe('Importer', function () { it('gets the correct extensions', function () { assert(Array.isArray(ImportManager.getExtensions())); - assert.equal(ImportManager.getExtensions().length, 55); + assert.equal(ImportManager.getExtensions().length, 105); assert(ImportManager.getExtensions().includes('.csv')); assert(ImportManager.getExtensions().includes('.json')); assert(ImportManager.getExtensions().includes('.zip')); @@ -108,7 +108,7 @@ describe('Importer', function () { }); it('globs extensions correctly', function () { - const extGlob = '+(.jpg|.jpeg|.gif|.png|.svg|.svgz|.ico|.webp|.mp4|.webm|.ogv|.mp3|.wav|.ogg|.m4a|.pdf|.json|.jsonld|.ods|.odt|.pptx|.rtf|.txt|.xls|.xlsx|.xml|.apkg|.css|.csv|.doc|.docx|.epub|.gpx|.html|.ics|.ipynb|.js|.key|.kml|.md|.mobi|.mov|.otf|.pages|.paprikarecipes|.psd|.py|.skp|.woff|.woff2|.xlsb|.xlsm|.yaml|.zip|.markdown)'; + const extGlob = '+(.jpg|.jpeg|.gif|.png|.svg|.svgz|.ico|.webp|.mp4|.webm|.ogv|.mp3|.wav|.ogg|.m4a|.7z|.aac|.ai|.aif|.aiff|.apkg|.arw|.avif|.avi|.blend|.bmp|.bz2|.cr2|.cr3|.css|.csv|.dng|.doc|.docx|.eot|.epub|.eps|.fb2|.fbx|.flac|.geojson|.glb|.gltf|.gpx|.gz|.heic|.heif|.html|.ics|.ipynb|.js|.json|.jsonld|.key|.kml|.m4v|.md|.mid|.midi|.mkv|.mobi|.mov|.nef|.np3|.numbers|.obj|.odp|.ods|.odt|.opus|.orf|.otf|.pages|.paprikarecipes|.pdf|.ppt|.pptx|.psd|.py|.raf|.rar|.rtf|.rw2|.skp|.stl|.tar|.tgz|.tif|.tiff|.toml|.tsv|.ttf|.txt|.vcf|.woff|.woff2|.xcf|.xls|.xlsb|.xlsm|.xlsx|.xml|.yaml|.zip|.markdown)'; assert.equal(ImportManager.getGlobPattern(ImportManager.getExtensions()), extGlob); assert.equal(ImportManager.getGlobPattern(ImportManager.getDirectories()), '+(images|content|media|files)'); assert.equal(ImportManager.getGlobPattern(JSONHandler.extensions), '+(.json)'); From 42dba3298f8f6d36609eb1f109855ebe032c307d Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 6 May 2026 09:06:06 +0100 Subject: [PATCH 3/8] Fixed missing onboarding flow in React admin shell (#27625) ref https://linear.app/ghost/issue/BER-3587/ The onboarding flow was lost during the Ember->React migration of the Admin shell. This recreates it in the React admin app on a dedicated route, while keeping Analytics as the return destination until the checklist is skipped or completed. Because onboarding has been missing for several months, existing owners may have stale onboarding preferences. This PR records `onboarding.startedAt` when `/setup/done` starts the checklist and only shows `started` checklists whose timestamp is on or after the fixed 30 April 2026 cutoff. Missing or older timestamps are dismissed so long-time product users are not suddenly forced into onboarding. ## Summary - adds a dedicated React admin onboarding route at `/setup/onboarding` - stores checklist state through the user preferences hooks while keeping the legacy accessibility storage detail hidden - redirects active onboarding users away from Analytics until they skip or complete the checklist - updates Ember `/setup/done` so new owner setup is the only path that starts onboarding - records `onboarding.startedAt` and dismisses missing/legacy `startedAt` values before the fixed 30 April 2026 cutoff so existing long-time owners are not forced into onboarding - shares the Shade `ShareModal` base between the onboarding publication share dialog and `PostShareModal` - aligns publication and post share options, including X, Threads, Facebook, LinkedIn, and copy link - adds Shade Storybook coverage for the shared share modal and fixes Storybook dark-mode handling for portaled content - adds onboarding E2E coverage, including pending existing owners and legacy started owners reaching Analytics normally --- .../admin/src/hooks/user-preferences.test.tsx | 37 ++- apps/admin/src/hooks/user-preferences.ts | 15 +- .../components/onboarding-checklist.tsx | 102 ++++++ .../components/onboarding-logo-video.tsx | 40 +++ .../components/onboarding-step-item.tsx | 51 +++ .../components/share-publication-dialog.tsx | 84 +++++ apps/admin/src/onboarding/constants.ts | 44 +++ .../onboarding/hooks/use-onboarding.test.tsx | 209 ++++++++++++ .../src/onboarding/hooks/use-onboarding.ts | 96 ++++++ .../src/onboarding/onboarding-redirect.tsx | 23 ++ .../admin/src/onboarding/onboarding-route.tsx | 105 ++++++ apps/admin/src/routes.tsx | 13 +- apps/admin/src/utils/deep-merge.ts | 2 +- apps/shade/.storybook/preview.tsx | 43 ++- .../post-share-modal/post-share-modal.tsx | 196 +++++------- .../components/features/share-modal/index.ts | 2 + .../share-modal/share-modal.stories.tsx | 300 ++++++++++++++++++ .../features/share-modal/share-modal.tsx | 269 ++++++++++++++++ apps/shade/src/patterns.ts | 2 + e2e/helpers/pages/admin/index.ts | 1 + e2e/helpers/pages/admin/onboarding/index.ts | 1 + .../pages/admin/onboarding/onboarding-page.ts | 25 ++ e2e/tests/admin/onboarding.test.ts | 208 ++++++++++++ ghost/admin/app/routes/setup/done.js | 10 +- ghost/admin/app/services/onboarding.js | 4 +- ghost/admin/mirage/config/authentication.js | 11 +- .../admin/tests/acceptance/onboarding-test.js | 25 +- ghost/admin/tests/acceptance/setup-test.js | 21 +- 28 files changed, 1785 insertions(+), 154 deletions(-) create mode 100644 apps/admin/src/onboarding/components/onboarding-checklist.tsx create mode 100644 apps/admin/src/onboarding/components/onboarding-logo-video.tsx create mode 100644 apps/admin/src/onboarding/components/onboarding-step-item.tsx create mode 100644 apps/admin/src/onboarding/components/share-publication-dialog.tsx create mode 100644 apps/admin/src/onboarding/constants.ts create mode 100644 apps/admin/src/onboarding/hooks/use-onboarding.test.tsx create mode 100644 apps/admin/src/onboarding/hooks/use-onboarding.ts create mode 100644 apps/admin/src/onboarding/onboarding-redirect.tsx create mode 100644 apps/admin/src/onboarding/onboarding-route.tsx create mode 100644 apps/shade/src/components/features/share-modal/index.ts create mode 100644 apps/shade/src/components/features/share-modal/share-modal.stories.tsx create mode 100644 apps/shade/src/components/features/share-modal/share-modal.tsx create mode 100644 e2e/helpers/pages/admin/onboarding/index.ts create mode 100644 e2e/helpers/pages/admin/onboarding/onboarding-page.ts create mode 100644 e2e/tests/admin/onboarding.test.ts diff --git a/apps/admin/src/hooks/user-preferences.test.tsx b/apps/admin/src/hooks/user-preferences.test.tsx index 6aeca134e6e..6d5e4f8d152 100644 --- a/apps/admin/src/hooks/user-preferences.test.tsx +++ b/apps/admin/src/hooks/user-preferences.test.tsx @@ -1,7 +1,7 @@ import { test as baseTest, describe, expect } from "vitest"; import { renderHook, waitFor, act } from "@testing-library/react"; import type { QueryClient } from "@tanstack/react-query"; -import { useUserPreferences, useEditUserPreferences, DEFAULT_NAVIGATION_PREFERENCES } from "./user-preferences"; +import { useUserPreferences, useEditUserPreferences, DEFAULT_NAVIGATION_PREFERENCES, DEFAULT_ONBOARDING_PREFERENCES } from "./user-preferences"; import { HttpResponse, http } from "msw"; import { mockUser } from "@test-utils/factories"; import { waitForQuerySettled } from "@test-utils/test-helpers"; @@ -30,6 +30,7 @@ const fixtures = { }, defaults: { navigation: DEFAULT_NAVIGATION_PREFERENCES, + onboarding: DEFAULT_ONBOARDING_PREFERENCES, } }; @@ -209,6 +210,11 @@ describe("useUserPreferences", () => { queryTest("gracefully handles invalid schema values", async ({ setup }) => { const result = await setup({ accessibility: JSON.stringify({ + onboarding: { + checklistState: "unknown", + completedSteps: "customize-design", + startedAt: "not-a-valid-datetime", + }, whatsNew: { lastSeenDate: "not-a-valid-datetime", }, @@ -225,6 +231,24 @@ describe("useUserPreferences", () => { }); }); + queryTest("parses onboarding startedAt into a date", async ({ setup }) => { + const result = await setup({ + accessibility: JSON.stringify({ + onboarding: { + completedSteps: ["customize-design"], + checklistState: "started", + startedAt: "2026-04-30T10:00:00.000Z", + }, + }), + }); + + expect(result.current.data?.onboarding).toEqual({ + completedSteps: ["customize-design"], + checklistState: "started", + startedAt: new Date("2026-04-30T10:00:00.000Z"), + }); + }); + queryTest("returns undefined when user is not loaded", async ({ server, wrapper }) => { server.use( http.get(USERS_API_URL, () => { @@ -322,6 +346,7 @@ describe("useUserPreferences", () => { await waitFor(() => { expect(result.current.query.data).toEqual({ navigation: DEFAULT_NAVIGATION_PREFERENCES, + onboarding: DEFAULT_ONBOARDING_PREFERENCES, whatsNew: { lastSeenDate: new Date("2025-01-01T00:00:00.000Z"), }, @@ -349,6 +374,7 @@ describe("useUserPreferences", () => { posts: false, }, }, + onboarding: DEFAULT_ONBOARDING_PREFERENCES, whatsNew: { lastSeenDate: new Date("2025-01-01T00:00:00.000Z"), }, @@ -423,6 +449,10 @@ describe("useEditUserPreferences", () => { expanded: { posts: false, members: false }, menu: { visible: true }, }, + onboarding: { + completedSteps: ["customize-design"], + checklistState: "started", + }, nightShift: true, }), }); @@ -430,6 +460,7 @@ describe("useEditUserPreferences", () => { await act(async () => { await mutation.current.mutateAsync({ navigation: { expanded: { posts: true } }, + onboarding: { completedSteps: ["customize-design", "first-post"] }, }); }); @@ -439,6 +470,10 @@ describe("useEditUserPreferences", () => { expanded: { posts: true, members: false }, menu: { visible: true }, // Preserved }, + onboarding: { + completedSteps: ["customize-design", "first-post"], + checklistState: "started", + }, nightShift: true, // Preserved }); }); diff --git a/apps/admin/src/hooks/user-preferences.ts b/apps/admin/src/hooks/user-preferences.ts index 28a66b77d9c..d8d1781dd8f 100644 --- a/apps/admin/src/hooks/user-preferences.ts +++ b/apps/admin/src/hooks/user-preferences.ts @@ -10,6 +10,18 @@ const WhatsNewPreferencesSchema = z.looseObject({ lastSeenDate: isoDatetimeToDate.optional().catch(undefined), }); +export const DEFAULT_ONBOARDING_PREFERENCES = { + completedSteps: [] as string[], + checklistState: "pending" as const, + startedAt: undefined as Date | undefined, +}; + +export const OnboardingPreferencesSchema = z.looseObject({ + completedSteps: z.array(z.string()).default(DEFAULT_ONBOARDING_PREFERENCES.completedSteps).catch(DEFAULT_ONBOARDING_PREFERENCES.completedSteps), + checklistState: z.enum(["pending", "started", "completed", "dismissed"]).default(DEFAULT_ONBOARDING_PREFERENCES.checklistState).catch(DEFAULT_ONBOARDING_PREFERENCES.checklistState), + startedAt: isoDatetimeToDate.optional().catch(DEFAULT_ONBOARDING_PREFERENCES.startedAt), +}); + export const DEFAULT_NAVIGATION_PREFERENCES = { expanded: { posts: true, members: true }, menu: { visible: true }, @@ -28,11 +40,13 @@ export const NavigationPreferencesSchema = z.looseObject({ const PreferencesSchema = z.looseObject({ whatsNew: WhatsNewPreferencesSchema.optional().catch(undefined), nightShift: z.boolean().optional(), + onboarding: OnboardingPreferencesSchema.default(DEFAULT_ONBOARDING_PREFERENCES).catch(DEFAULT_ONBOARDING_PREFERENCES), navigation: NavigationPreferencesSchema.default(DEFAULT_NAVIGATION_PREFERENCES).catch(DEFAULT_NAVIGATION_PREFERENCES), }); export type Preferences = z.infer; export type WhatsNewPreferences = z.infer; +export type OnboardingPreferences = z.infer; export type NavigationPreferences = z.infer; const userPreferencesQueryKey = (user: User | undefined) => ["userPreferences", user?.id, user?.accessibility] as const; @@ -80,7 +94,6 @@ export const useEditUserPreferences = (): UseMutationResult(userPreferencesQueryKey(user)) ?? PreferencesSchema.parse({}); - // TODO: use zod to validate? const newPreferences = deepMerge(currentPreferences, updatedPreferences); const encodedForStorage = PreferencesSchema.encode(newPreferences); diff --git a/apps/admin/src/onboarding/components/onboarding-checklist.tsx b/apps/admin/src/onboarding/components/onboarding-checklist.tsx new file mode 100644 index 00000000000..1476f970be1 --- /dev/null +++ b/apps/admin/src/onboarding/components/onboarding-checklist.tsx @@ -0,0 +1,102 @@ +import {Button} from "@tryghost/shade/components"; +import {LucideIcon} from "@tryghost/shade/utils"; +import {ONBOARDING_STEPS, type OnboardingStep} from "@/onboarding/constants"; +import {OnboardingLogoVideo} from "@/onboarding/components/onboarding-logo-video"; +import {OnboardingStepItem} from "@/onboarding/components/onboarding-step-item"; + +interface OnboardingChecklistProps { + allStepsCompleted: boolean; + completedSteps: string[]; + nextStep: OnboardingStep | undefined; + onComplete: () => void; + onDismiss: () => void; + onStepClick: (step: OnboardingStep) => void; + siteTitle: string; +} + +export function OnboardingChecklist({ + allStepsCompleted, + completedSteps, + nextStep, + onComplete, + onDismiss, + onStepClick, + siteTitle, +}: OnboardingChecklistProps) { + const completedStepSet = new Set(completedSteps); + + return ( +
+
+
+ + {allStepsCompleted ? +

You're all set.

+ : + <> +

Let's get started!

+

Welcome! It's time to set up {siteTitle}.

+ + } +
+ +
+
+ + + Start a new Ghost publication + + + + +
+ + {ONBOARDING_STEPS.map((step, index) => ( + onStepClick(step.id)} + /> + ))} +
+ + {allStepsCompleted && + + } + +

+ More questions? Check out our{" "} + . +

+ + {!allStepsCompleted && + + } +
+
+ ); +} diff --git a/apps/admin/src/onboarding/components/onboarding-logo-video.tsx b/apps/admin/src/onboarding/components/onboarding-logo-video.tsx new file mode 100644 index 00000000000..ab245b2aa9c --- /dev/null +++ b/apps/admin/src/onboarding/components/onboarding-logo-video.tsx @@ -0,0 +1,40 @@ +import logoLoaderDarkUrl from "@/assets/videos/logo-loader-dark.mp4"; +import logoLoaderUrl from "@/assets/videos/logo-loader.mp4"; + +export function OnboardingLogoVideo() { + return ( +
+ + +
+
+ ); +} diff --git a/apps/admin/src/onboarding/components/onboarding-step-item.tsx b/apps/admin/src/onboarding/components/onboarding-step-item.tsx new file mode 100644 index 00000000000..0f9a30e02ed --- /dev/null +++ b/apps/admin/src/onboarding/components/onboarding-step-item.tsx @@ -0,0 +1,51 @@ +import {LucideIcon} from "@tryghost/shade/utils"; +import {type OnboardingStepDefinition} from "@/onboarding/constants"; + +interface OnboardingStepItemProps { + complete: boolean; + id: string; + isBeforeNext: boolean; + isLast: boolean; + isNext: boolean; + onClick: () => void; + step: OnboardingStepDefinition; +} + +export function OnboardingStepItem({ + complete, + id, + isBeforeNext, + isLast, + isNext, + onClick, + step, +}: OnboardingStepItemProps) { + const Icon = step.icon; + const hideBorder = isLast || isBeforeNext || isNext; + const rowClassName = isNext + ? `relative z-10 -mx-8 flex w-[calc(100%+64px)] items-center justify-between rounded-md bg-background px-8 py-6 text-left shadow-[0_1px_0_rgba(17,17,26,0.05),0_0_8px_rgba(17,17,26,0.10)] transition-none dark:ring-1 dark:ring-border ${isLast ? "-mb-[18px]" : "mb-1.5"}` + : `relative flex w-full items-center justify-between bg-transparent py-6 text-left ${hideBorder ? "" : "after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border after:content-['']"}`; + + return ( + + ); +} diff --git a/apps/admin/src/onboarding/components/share-publication-dialog.tsx b/apps/admin/src/onboarding/components/share-publication-dialog.tsx new file mode 100644 index 00000000000..2bad59dadd6 --- /dev/null +++ b/apps/admin/src/onboarding/components/share-publication-dialog.tsx @@ -0,0 +1,84 @@ +import {Button} from "@tryghost/shade/components"; +import {ShareModal, type ShareModalSocialLink} from "@tryghost/shade/patterns"; + +interface SharePublicationDialogProps { + description: string; + imageUrl: string; + onOpenChange: (open: boolean) => void; + open: boolean; + siteTitle: string; + siteUrl: string; +} + +export function SharePublicationDialog({ + description, + imageUrl, + onOpenChange, + open, + siteTitle, + siteUrl, +}: SharePublicationDialogProps) { + const encodedUrl = encodeURIComponent(siteUrl); + const socialLinks: ShareModalSocialLink[] = [ + { + href: `https://twitter.com/intent/tweet?url=${encodedUrl}`, + id: "ob-share-on-x", + label: "Share your publication on X", + service: "x", + }, + { + href: `https://threads.net/intent/post?text=${encodedUrl}`, + id: "ob-share-on-threads", + label: "Share your publication on Threads", + service: "threads", + }, + { + href: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, + id: "ob-share-on-fb", + label: "Share your publication on Facebook", + service: "facebook", + }, + { + href: `https://www.linkedin.com/feed/?shareActive=true&text=${encodedUrl}`, + id: "ob-share-on-li", + label: "Share your publication on LinkedIn", + service: "linkedin", + }, + ]; + + return ( + + Set your publication's cover image and description in{" "} + . +

+ )} + open={open} + preview={{ + description, + imageURL: imageUrl, + title: siteTitle, + url: siteUrl, + }} + socialLinks={socialLinks} + title="Share your publication" + variant="publication" + onClose={() => onOpenChange(false)} + onOpenChange={onOpenChange} + /> + ); +} diff --git a/apps/admin/src/onboarding/constants.ts b/apps/admin/src/onboarding/constants.ts new file mode 100644 index 00000000000..b324c505a3b --- /dev/null +++ b/apps/admin/src/onboarding/constants.ts @@ -0,0 +1,44 @@ +import type React from "react"; +import {LucideIcon} from "@tryghost/shade/utils"; + +type OnboardingStepDefinitionShape = { + description: string; + icon: React.ComponentType<{className?: string}>; + id: string; + route?: string; + title: string; +}; + +export const ONBOARDING_STEPS = [ + { + description: "Craft a look that reflects your brand and style.", + icon: LucideIcon.Brush, + id: "customize-design", + route: "/settings/design/edit?ref=setup", + title: "Customize your design", + }, + { + description: "Get to know a writing experience you'll love.", + icon: LucideIcon.PenLine, + id: "first-post", + route: "/editor/post", + title: "Explore the editor", + }, + { + description: "Add members and grow your readership.", + icon: LucideIcon.UserPlus, + id: "build-audience", + route: "/members", + title: "Build your audience", + }, + { + description: "Expand your reach on social media.", + icon: LucideIcon.Megaphone, + id: "share-publication", + route: undefined, + title: "Share your publication", + }, +] as const satisfies readonly OnboardingStepDefinitionShape[]; + +export type OnboardingStepDefinition = typeof ONBOARDING_STEPS[number]; +export type OnboardingStep = OnboardingStepDefinition["id"]; diff --git a/apps/admin/src/onboarding/hooks/use-onboarding.test.tsx b/apps/admin/src/onboarding/hooks/use-onboarding.test.tsx new file mode 100644 index 00000000000..a0de978195c --- /dev/null +++ b/apps/admin/src/onboarding/hooks/use-onboarding.test.tsx @@ -0,0 +1,209 @@ +import {act, renderHook, waitFor} from "@testing-library/react"; +import {describe, expect, test as baseTest} from "vitest"; +import {HttpResponse, http} from "msw"; +import {mockUser} from "@test-utils/factories"; +import {queryClientFixtures, type TestWrapperComponent} from "@test-utils/fixtures/query-client"; +import {serverFixture} from "@test-utils/fixtures/msw"; +import {useOnboarding} from "./use-onboarding"; +import type {QueryClient} from "@tanstack/react-query"; +import type {SetupServer} from "msw/node"; +import type {UpdateUserRequestBody, UsersResponseType, User} from "@tryghost/admin-x-framework/api/users"; + +const USERS_API_URL = "/ghost/api/admin/users/me/"; +const USER_UPDATE_API_URL = "/ghost/api/admin/users/:id/"; + +const ownerRole = { + id: "owner-role", + name: "Owner", + description: "Owner", + created_at: "2024-01-01T00:00:00.000Z", + updated_at: "2024-01-01T00:00:00.000Z", +} as const; + +async function setupOnboarding( + server: SetupServer, + wrapper: TestWrapperComponent, + userOverrides: Partial = {} +) { + server.use( + http.get(USERS_API_URL, () => { + return HttpResponse.json({ + users: [{ + ...mockUser, + roles: [ownerRole], + ...userOverrides, + }], + }); + }), + http.put<{ id: string }, UpdateUserRequestBody, UsersResponseType>( + USER_UPDATE_API_URL, + async ({request}) => { + const body = await request.json(); + return HttpResponse.json({ + users: [{ + ...mockUser, + roles: [ownerRole], + accessibility: body.users[0]?.accessibility ?? "", + }], + }); + } + ) + ); + + const {result} = renderHook(() => useOnboarding(), {wrapper}); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + return result; +} + +const onboardingTest = baseTest.extend<{ + server: SetupServer; + queryClient: QueryClient; + wrapper: TestWrapperComponent; + setup: (userOverrides?: Partial) => ReturnType; +}>({ + ...serverFixture, + ...queryClientFixtures, + setup: async ({server, wrapper}, provide) => { + await provide((userOverrides) => setupOnboarding(server, wrapper, userOverrides)); + }, +}); + +describe("useOnboarding", () => { + onboardingTest("shows checklist for owners when onboarding is started", async ({setup}) => { + const result = await setup({ + accessibility: JSON.stringify({ + onboarding: { + completedSteps: ["customize-design"], + checklistState: "started", + startedAt: "2026-04-30T10:00:00.000Z", + }, + }), + }); + + expect(result.current.shouldShowChecklist).toBe(true); + expect(result.current.nextStep).toBe("first-post"); + expect(result.current.allStepsCompleted).toBe(false); + }); + + onboardingTest("does not show checklist for non-owner users", async ({setup}) => { + const result = await setup({ + accessibility: JSON.stringify({ + onboarding: { + completedSteps: [], + checklistState: "started", + startedAt: "2026-04-30T10:00:00.000Z", + }, + }), + roles: [{ + id: "admin-role", + name: "Administrator", + description: "Admin", + created_at: "2024-01-01T00:00:00.000Z", + updated_at: "2024-01-01T00:00:00.000Z", + }], + }); + + expect(result.current.shouldShowChecklist).toBe(false); + }); + + onboardingTest("updates completed steps without duplicating existing steps", async ({setup}) => { + const result = await setup({ + accessibility: JSON.stringify({ + onboarding: { + completedSteps: ["customize-design"], + checklistState: "started", + startedAt: "2026-04-30T10:00:00.000Z", + }, + }), + }); + + await act(async () => { + await result.current.markStepCompleted("customize-design"); + await result.current.markStepCompleted("first-post"); + }); + + await waitFor(() => { + expect(result.current.completedSteps).toEqual(["customize-design", "first-post"]); + }); + }); + + onboardingTest("updates checklist state", async ({setup}) => { + const result = await setup({ + accessibility: JSON.stringify({ + onboarding: { + completedSteps: [], + checklistState: "started", + startedAt: "2026-04-30T10:00:00.000Z", + }, + }), + }); + + await act(async () => { + await result.current.dismissChecklist(); + }); + + await waitFor(() => { + expect(result.current.checklistState).toBe("dismissed"); + expect(result.current.shouldShowChecklist).toBe(false); + }); + }); + + onboardingTest("starts checklist with a start date", async ({setup}) => { + const result = await setup({ + accessibility: JSON.stringify({ + onboarding: { + completedSteps: ["customize-design"], + checklistState: "pending", + }, + }), + }); + + await act(async () => { + await result.current.startChecklist(); + }); + + await waitFor(() => { + expect(result.current.checklistState).toBe("started"); + expect(result.current.completedSteps).toEqual([]); + expect(result.current.hasActiveStartedAt).toBe(true); + expect(result.current.shouldShowChecklist).toBe(true); + }); + }); + + onboardingTest("dismisses started checklist when startedAt is missing", async ({setup}) => { + const result = await setup({ + accessibility: JSON.stringify({ + onboarding: { + completedSteps: [], + checklistState: "started", + }, + }), + }); + + expect(result.current.shouldShowChecklist).toBe(false); + + await waitFor(() => { + expect(result.current.checklistState).toBe("dismissed"); + }); + }); + + onboardingTest("dismisses started checklist when startedAt is before the cutoff", async ({setup}) => { + const result = await setup({ + accessibility: JSON.stringify({ + onboarding: { + completedSteps: [], + checklistState: "started", + startedAt: "2026-04-29T23:59:59.999Z", + }, + }), + }); + + expect(result.current.shouldShowChecklist).toBe(false); + + await waitFor(() => { + expect(result.current.checklistState).toBe("dismissed"); + }); + }); +}); diff --git a/apps/admin/src/onboarding/hooks/use-onboarding.ts b/apps/admin/src/onboarding/hooks/use-onboarding.ts new file mode 100644 index 00000000000..e9d25a8307b --- /dev/null +++ b/apps/admin/src/onboarding/hooks/use-onboarding.ts @@ -0,0 +1,96 @@ +import {useCallback, useEffect, useMemo, useRef} from "react"; +import {useCurrentUser} from "@tryghost/admin-x-framework/api/current-user"; +import {isOwnerUser} from "@tryghost/admin-x-framework/api/users"; +import {useEditUserPreferences, useUserPreferences} from "@/hooks/user-preferences"; +import type {OnboardingPreferences} from "@/hooks/user-preferences"; +import {ONBOARDING_STEPS, type OnboardingStep} from "@/onboarding/constants"; + +const ONBOARDING_STARTED_AT_CUTOFF = new Date("2026-04-30T00:00:00.000Z"); + +function isAfterOnboardingStartedAtCutoff(date: Date | undefined) { + if (!date) { + return false; + } + + return date >= ONBOARDING_STARTED_AT_CUTOFF; +} + +export function useOnboarding() { + const {data: currentUser, isLoading: isUserLoading} = useCurrentUser(); + const {data: preferences, isLoading: isPreferencesLoading} = useUserPreferences(); + const {mutateAsync: editPreferences} = useEditUserPreferences(); + const hasAttemptedInvalidStartedStateDismissalRef = useRef(false); + + const completedSteps = useMemo(() => preferences?.onboarding.completedSteps || [], [preferences?.onboarding.completedSteps]); + const completedStepSet = useMemo(() => new Set(completedSteps), [completedSteps]); + const checklistState = preferences?.onboarding.checklistState || "pending"; + const startedAt = preferences?.onboarding.startedAt; + const hasActiveStartedAt = isAfterOnboardingStartedAtCutoff(startedAt); + const isOwner = currentUser ? isOwnerUser(currentUser) : false; + const shouldShowChecklist = isOwner && checklistState === "started" && hasActiveStartedAt; + const nextStep = ONBOARDING_STEPS.find(step => !completedStepSet.has(step.id))?.id; + const allStepsCompleted = ONBOARDING_STEPS.every(step => completedStepSet.has(step.id)); + + const updateOnboarding = useCallback((updates: { + completedSteps?: string[]; + checklistState?: OnboardingPreferences["checklistState"]; + startedAt?: Date; + }) => { + return editPreferences({ + onboarding: updates, + }); + }, [editPreferences]); + + const markStepCompleted = useCallback(async (step: OnboardingStep) => { + if (completedStepSet.has(step)) { + return; + } + + await updateOnboarding({ + completedSteps: [...completedSteps, step], + }); + }, [completedStepSet, completedSteps, updateOnboarding]); + + const dismissChecklist = useCallback(() => { + return updateOnboarding({checklistState: "dismissed"}); + }, [updateOnboarding]); + + useEffect(() => { + if (isUserLoading || isPreferencesLoading || !isOwner || checklistState !== "started" || hasActiveStartedAt || hasAttemptedInvalidStartedStateDismissalRef.current) { + return; + } + + hasAttemptedInvalidStartedStateDismissalRef.current = true; + void dismissChecklist().catch((error) => { + hasAttemptedInvalidStartedStateDismissalRef.current = false; + console.error(error); + }); + }, [checklistState, dismissChecklist, hasActiveStartedAt, isOwner, isPreferencesLoading, isUserLoading]); + + const startChecklist = useCallback(() => { + return updateOnboarding({ + completedSteps: [], + checklistState: "started", + startedAt: new Date(), + }); + }, [updateOnboarding]); + + const completeChecklist = useCallback(() => { + return updateOnboarding({checklistState: "completed"}); + }, [updateOnboarding]); + + return { + allStepsCompleted, + checklistState, + completeChecklist, + completedSteps, + dismissChecklist, + hasActiveStartedAt, + isOwner, + shouldShowChecklist, + isLoading: isUserLoading || isPreferencesLoading, + markStepCompleted, + nextStep, + startChecklist, + }; +} diff --git a/apps/admin/src/onboarding/onboarding-redirect.tsx b/apps/admin/src/onboarding/onboarding-redirect.tsx new file mode 100644 index 00000000000..21f288772db --- /dev/null +++ b/apps/admin/src/onboarding/onboarding-redirect.tsx @@ -0,0 +1,23 @@ +import type React from "react"; +import {Navigate, useLocation} from "@tryghost/admin-x-framework"; +import {useOnboarding} from "@/onboarding/hooks/use-onboarding"; + +interface OnboardingRedirectProps { + children: React.ReactNode; +} + +export function OnboardingRedirect({children}: OnboardingRedirectProps) { + const location = useLocation(); + const onboarding = useOnboarding(); + + if (onboarding.isLoading) { + return null; + } + + if (onboarding.shouldShowChecklist) { + const returnTo = `${location.pathname}${location.search}`; + return ; + } + + return children; +} diff --git a/apps/admin/src/onboarding/onboarding-route.tsx b/apps/admin/src/onboarding/onboarding-route.tsx new file mode 100644 index 00000000000..dd441811fb9 --- /dev/null +++ b/apps/admin/src/onboarding/onboarding-route.tsx @@ -0,0 +1,105 @@ +import {Navigate, useNavigate, useSearchParams} from "@tryghost/admin-x-framework"; +import {getSettingValue, useBrowseSettings} from "@tryghost/admin-x-framework/api/settings"; +import {useBrowseSite} from "@tryghost/admin-x-framework/api/site"; +import {useRef, useState} from "react"; +import {OnboardingChecklist} from "@/onboarding/components/onboarding-checklist"; +import {SharePublicationDialog} from "@/onboarding/components/share-publication-dialog"; +import {useOnboarding} from "@/onboarding/hooks/use-onboarding"; +import {ONBOARDING_STEPS, type OnboardingStep} from "./constants"; + +function getSafeReturnTo(value: string | null) { + return value && /^\/analytics(?:\/|\?|$)/.test(value) ? value : "/analytics"; +} + +export default function OnboardingRoute() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const returnTo = getSafeReturnTo(searchParams.get("returnTo")); + const onboarding = useOnboarding(); + const settings = useBrowseSettings(); + const site = useBrowseSite(); + const isLeavingRef = useRef(false); + const [isLeaving, setIsLeaving] = useState(false); + const [shareDialogOpen, setShareDialogOpen] = useState(false); + + const siteTitle = String(getSettingValue(settings.data?.settings, "title") || site.data?.site.title || "your publication"); + const description = String(getSettingValue(settings.data?.settings, "description") || site.data?.site.description || ""); + const imageUrl = String(getSettingValue(settings.data?.settings, "cover_image") || ""); + const siteUrl = site.data?.site.url || "/"; + + const { + allStepsCompleted, + completeChecklist, + completedSteps, + dismissChecklist, + shouldShowChecklist, + isLoading, + markStepCompleted, + nextStep, + } = onboarding; + + if (isLoading || site.isLoading || isLeaving || isLeavingRef.current) { + return null; + } + + if (!shouldShowChecklist) { + return ; + } + + const navigateAfterUpdate = async (update: () => Promise) => { + isLeavingRef.current = true; + setIsLeaving(true); + try { + await update(); + navigate(returnTo, {crossApp: true, replace: true}); + } catch (error) { + isLeavingRef.current = false; + setIsLeaving(false); + console.error(error); + } + }; + + const handleStepClick = async (step: OnboardingStep) => { + if (step === "share-publication") { + await markStepCompleted(step); + setShareDialogOpen(true); + return; + } + + await markStepCompleted(step); + + const stepRoute = ONBOARDING_STEPS.find(({id}) => id === step)?.route; + if (stepRoute) { + navigate(stepRoute, {crossApp: true}); + } + }; + + return ( + <> + { + void navigateAfterUpdate(completeChecklist); + }} + onDismiss={() => { + void navigateAfterUpdate(dismissChecklist); + }} + onStepClick={(step) => { + void handleStepClick(step); + }} + /> + + + + ); +} diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx index c3dd3ef440a..17b8893affe 100644 --- a/apps/admin/src/routes.tsx +++ b/apps/admin/src/routes.tsx @@ -14,6 +14,7 @@ import MyProfileRedirect from "./my-profile-redirect"; import { EmberFallback, ForceUpgradeGuard } from "./ember-bridge"; import type { RouteHandle } from "./ember-bridge"; import { MembersRoute } from "./members-route"; +import { OnboardingRedirect } from "./onboarding/onboarding-redirect"; import { NotFound } from "./not-found"; @@ -98,12 +99,18 @@ export const routes: RouteObject[] = [ }, { element: ( - - - + + + + + ), children: statsRoutes, }, + { + path: "setup/onboarding", + lazy: lazyComponent(() => import("./onboarding/onboarding-route")), + }, { path: `network`, loader: () => redirect("/activitypub"), diff --git a/apps/admin/src/utils/deep-merge.ts b/apps/admin/src/utils/deep-merge.ts index 6141c33a8af..531ada9acc9 100644 --- a/apps/admin/src/utils/deep-merge.ts +++ b/apps/admin/src/utils/deep-merge.ts @@ -1,7 +1,7 @@ /** * Deep partial type that makes all properties optional recursively. */ -export type DeepPartial = T extends object ? { +export type DeepPartial = T extends Array ? Array> : T extends object ? { [P in keyof T]?: DeepPartial; } : T; diff --git a/apps/shade/.storybook/preview.tsx b/apps/shade/.storybook/preview.tsx index 27be90dfa23..fae5019a4d4 100644 --- a/apps/shade/.storybook/preview.tsx +++ b/apps/shade/.storybook/preview.tsx @@ -45,6 +45,35 @@ const customViewports = { }, }; +const StorybookSchemeDecorator = ({Story, scheme}: {Story: React.ComponentType; scheme: string}) => { + React.useEffect(() => { + const isDark = scheme === 'dark'; + + document.documentElement.classList.toggle('dark', isDark); + document.body.classList.toggle('dark', isDark); + + return () => { + document.documentElement.classList.remove('dark'); + document.body.classList.remove('dark'); + }; + }, [scheme]); + + return ( +
+ {/* 👇 Decorators in Storybook also accept a function. Replace with Story() to enable it */} + + + +
+ ); +}; + const preview: Preview = { parameters: { actions: { argTypesRegex: "^on[A-Z].*" }, @@ -88,19 +117,7 @@ const preview: Preview = { (Story, context) => { let {scheme} = context.globals; - return ( -
- {/* 👇 Decorators in Storybook also accept a function. Replace with Story() to enable it */} - - - -
); + return ; }, ], globalTypes: { diff --git a/apps/shade/src/components/features/post-share-modal/post-share-modal.tsx b/apps/shade/src/components/features/post-share-modal/post-share-modal.tsx index 1e1a5fb826a..d6167b17198 100644 --- a/apps/shade/src/components/features/post-share-modal/post-share-modal.tsx +++ b/apps/shade/src/components/features/post-share-modal/post-share-modal.tsx @@ -1,137 +1,101 @@ -import {H3} from '@/components/layout/heading'; +import ShareModal, {type ShareModalSocialLink} from '@/components/features/share-modal/share-modal'; import {Button} from '@/components/ui/button'; -import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog'; import * as DialogPrimitive from '@radix-ui/react-dialog'; -import {Check, Link, X} from 'lucide-react'; -import React, {useState} from 'react'; +import React from 'react'; interface PostShareModalProps extends React.ComponentPropsWithoutRef { + author?: string; + children?: React.ReactNode; + description?: React.ReactNode; emailOnly?: boolean; + faviconURL?: string; + featureImageURL?: string; + onClose?: () => void; + postExcerpt?: string; + postTitle?: string; postURL?: string; primaryTitle?: string; secondaryTitle?: string; - description?: React.ReactNode; - featureImageURL?: string; - postTitle?: string; - postExcerpt?: string; - faviconURL?: string; siteTitle?: string; - author?: string; - onClose?: () => void; - children?: React.ReactNode; } -const PostShareModal: React.FC = ( - {emailOnly = false, - postURL = '', - primaryTitle = 'Your post is published.', - secondaryTitle = 'Spread the word!', - description = '', - featureImageURL = '', - postTitle = '', - postExcerpt = '', - faviconURL = '', - siteTitle = '', - author = '', - onClose = () => {}, - children, - ...props}) => { - const [isCopied, setIsCopied] = useState(false); - - const handleCopyLink = async () => { - try { - await navigator.clipboard.writeText(postURL); - setIsCopied(true); - // Reset the copied state after 2 seconds - setTimeout(() => setIsCopied(false), 2000); - } catch { - // Could add toast notification for copy failure - } - }; - +const PostShareModal: React.FC = ({ + author = '', + children, + description = '', + emailOnly = false, + faviconURL = '', + featureImageURL = '', + onClose = () => {}, + postExcerpt = '', + postTitle = '', + postURL = '', + primaryTitle = 'Your post is published.', + secondaryTitle = 'Spread the word!', + siteTitle = '', + ...props +}) => { const encodedPostTitle = encodeURIComponent(postTitle); const encodedPostURL = encodeURIComponent(postURL); const encodedPostURLTitle = encodeURIComponent(`${postTitle} ${postURL}`); + const socialLinks: ShareModalSocialLink[] = emailOnly ? [] : [ + { + href: `https://twitter.com/intent/tweet?text=${encodedPostTitle}%0A${encodedPostURL}`, + label: 'Share on X', + service: 'x' + }, + { + href: `https://threads.net/intent/post?text=${encodedPostURLTitle}`, + label: 'Share on Threads', + service: 'threads' + }, + { + href: `https://www.facebook.com/sharer/sharer.php?u=${encodedPostURL}`, + label: 'Share on Facebook', + service: 'facebook' + }, + { + href: `https://www.linkedin.com/shareArticle?mini=true&title=${encodedPostTitle}&url=${encodedPostURL}`, + label: 'Share on LinkedIn', + service: 'linkedin' + } + ]; return ( - - - {children} - - -
- -
- - - {primaryTitle}
- {secondaryTitle} -
- {description && - - {description} - - } -
- - {featureImageURL && -
- } -
+ ), + title: postTitle, + url: postURL + }} + primaryTitle={primaryTitle} + secondaryTitle={secondaryTitle} + socialLinks={socialLinks} + variant="post" + onClose={onClose} + {...props} + > + {children} +
); }; diff --git a/apps/shade/src/components/features/share-modal/index.ts b/apps/shade/src/components/features/share-modal/index.ts new file mode 100644 index 00000000000..f2e2a3d2f94 --- /dev/null +++ b/apps/shade/src/components/features/share-modal/index.ts @@ -0,0 +1,2 @@ +export {default} from './share-modal'; +export type {ShareModalSocialLink} from './share-modal'; diff --git a/apps/shade/src/components/features/share-modal/share-modal.stories.tsx b/apps/shade/src/components/features/share-modal/share-modal.stories.tsx new file mode 100644 index 00000000000..5233e7d0b23 --- /dev/null +++ b/apps/shade/src/components/features/share-modal/share-modal.stories.tsx @@ -0,0 +1,300 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {useState} from 'react'; +import {Button} from '@/components/ui/button'; +import ShareModal, {type ShareModalSocialLink} from './share-modal'; + +const meta = { + title: 'Features / Share Modal', + component: ShareModal, + tags: ['autodocs'], + argTypes: { + children: { + table: { + disable: true + } + } + } +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const postUrl = 'https://example.com/copy-clipboard-react-guide'; +const encodedPostTitle = encodeURIComponent('Copy to Clipboard in React: Complete Guide'); +const encodedPostUrl = encodeURIComponent(postUrl); +const encodedPostTitleAndUrl = encodeURIComponent(`Copy to Clipboard in React: Complete Guide ${postUrl}`); + +const publicationUrl = 'https://ghost.org'; +const encodedPublicationUrl = encodeURIComponent(publicationUrl); + +const postSocialLinks: ShareModalSocialLink[] = [ + { + href: `https://twitter.com/intent/tweet?text=${encodedPostTitle}%0A${encodedPostUrl}`, + label: 'Share on X', + service: 'x' + }, + { + href: `https://threads.net/intent/post?text=${encodedPostTitleAndUrl}`, + label: 'Share on Threads', + service: 'threads' + }, + { + href: `https://www.facebook.com/sharer/sharer.php?u=${encodedPostUrl}`, + label: 'Share on Facebook', + service: 'facebook' + }, + { + href: `https://www.linkedin.com/shareArticle?mini=true&title=${encodedPostTitle}&url=${encodedPostUrl}`, + label: 'Share on LinkedIn', + service: 'linkedin' + } +]; + +const publicationSocialLinks: ShareModalSocialLink[] = [ + { + href: `https://twitter.com/intent/tweet?url=${encodedPublicationUrl}`, + label: 'Share your publication on X', + service: 'x' + }, + { + href: `https://threads.net/intent/post?text=${encodedPublicationUrl}`, + label: 'Share your publication on Threads', + service: 'threads' + }, + { + href: `https://www.facebook.com/sharer/sharer.php?u=${encodedPublicationUrl}`, + label: 'Share your publication on Facebook', + service: 'facebook' + }, + { + href: `https://www.linkedin.com/feed/?shareActive=true&text=${encodedPublicationUrl}`, + label: 'Share your publication on LinkedIn', + service: 'linkedin' + } +]; + +const postArgs = { + copyURL: postUrl, + description: <> + Your post was published on your site and sent to 3 subscribers of Ghost Blog, on June 13th at 12:02. + , + preview: { + description: 'A comprehensive guide to implementing copy-to-clipboard functionality in React applications with proper error handling and user feedback.', + imageURL: 'https://picsum.photos/800/600?random=1', + meta: ( +
+
+
+ Ghost Blog + + Jane Smith +
+
+ ), + title: 'Copy to Clipboard in React: Complete Guide', + url: postUrl + }, + primaryTitle: 'Your post is published.', + secondaryTitle: 'Spread the word!', + socialLinks: postSocialLinks, + variant: 'post' as const +}; + +const publicationArgs = { + actionsLayout: 'footer' as const, + copyURL: publicationUrl, + guidance: ( +

+ Set your publication's cover image and description in . +

+ ), + preview: { + description: 'Thoughts, stories and ideas.', + imageURL: 'https://picsum.photos/800/600?random=2', + title: 'Ghostbusters', + url: publicationUrl + }, + socialLinks: publicationSocialLinks, + title: 'Share your publication', + variant: 'publication' as const +}; + +const postSource = `const [isOpen, setIsOpen] = useState(false); + +const postUrl = 'https://example.com/copy-clipboard-react-guide'; +const encodedPostTitle = encodeURIComponent('Copy to Clipboard in React: Complete Guide'); +const encodedPostUrl = encodeURIComponent(postUrl); + +Your post was published on your site and sent to 3 subscribers.} + open={isOpen} + preview={{ + description: 'A comprehensive guide to implementing copy-to-clipboard functionality in React applications.', + imageURL: 'https://picsum.photos/800/600?random=1', + meta: ( +
+
+
+ Ghost Blog + + Jane Smith +
+
+ ), + title: 'Copy to Clipboard in React: Complete Guide', + url: postUrl + }} + primaryTitle="Your post is published." + secondaryTitle="Spread the word!" + socialLinks={[ + { + href: \`https://twitter.com/intent/tweet?text=\${encodedPostTitle}%0A\${encodedPostUrl}\`, + label: 'Share on X', + service: 'x' + } + ]} + variant="post" + onClose={() => setIsOpen(false)} + onOpenChange={setIsOpen} +> + +`; + +const publicationSource = `const [isOpen, setIsOpen] = useState(false); + +const publicationUrl = 'https://ghost.org'; +const encodedPublicationUrl = encodeURIComponent(publicationUrl); + + + Set your publication's cover image and description in{' '} + . +

+ )} + open={isOpen} + preview={{ + description: 'Thoughts, stories and ideas.', + imageURL: 'https://picsum.photos/800/600?random=2', + title: 'Ghostbusters', + url: publicationUrl + }} + socialLinks={[ + { + href: \`https://threads.net/intent/post?text=\${encodedPublicationUrl}\`, + label: 'Share your publication on Threads', + service: 'threads' + } + ]} + title="Share your publication" + variant="publication" + onClose={() => setIsOpen(false)} + onOpenChange={setIsOpen} +> + +
`; + +export const Post: Story = { + args: { + ...postArgs + }, + render: (args) => { + const PostExample = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + setIsOpen(false)} + onOpenChange={setIsOpen} + > + + + ); + }; + + return ; + }, + parameters: { + docs: { + source: { + code: postSource + } + } + } +}; + +export const Publication: Story = { + args: { + ...publicationArgs + }, + render: (args) => { + const PublicationExample = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + setIsOpen(false)} + onOpenChange={setIsOpen} + > + + + ); + }; + + return ; + }, + parameters: { + docs: { + source: { + code: publicationSource + } + } + } +}; + +export const ControlledPublication: Story = { + args: { + copyURL: publicationUrl, + preview: { + title: 'Ghostbusters', + url: publicationUrl + } + }, + parameters: { + docs: { + source: { + code: publicationSource.replace('Share publication', 'Open publication share modal') + } + } + }, + render: () => { + const ControlledExample = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + setIsOpen(false)} + onOpenChange={setIsOpen} + > + + + ); + }; + + return ; + } +}; diff --git a/apps/shade/src/components/features/share-modal/share-modal.tsx b/apps/shade/src/components/features/share-modal/share-modal.tsx new file mode 100644 index 00000000000..723636456aa --- /dev/null +++ b/apps/shade/src/components/features/share-modal/share-modal.tsx @@ -0,0 +1,269 @@ +import {H3} from '@/components/layout/heading'; +import {Button} from '@/components/ui/button'; +import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog'; +import {cn} from '@/lib/utils'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import {Check, Copy, Image as ImageIcon, Link, X} from 'lucide-react'; +import React, {useState} from 'react'; + +type ShareService = 'x' | 'threads' | 'facebook' | 'linkedin'; + +export type ShareModalSocialLink = { + href: string; + id?: string; + label: string; + service: ShareService; + title?: string; +}; + +interface ShareModalPreview { + description?: React.ReactNode; + imageURL?: string; + meta?: React.ReactNode; + title: React.ReactNode; + url: string; +} + +interface ShareModalProps extends React.ComponentPropsWithoutRef { + actionsLayout?: 'footer' | 'stacked'; + children?: React.ReactNode; + closeButtonId?: string; + copyButtonId?: string; + copyButtonTestId?: string; + copyLabel?: string; + copySuccessLabel?: string; + copyURL: string; + contentProps?: React.ComponentPropsWithoutRef; + description?: React.ReactNode; + footerAction?: React.ReactNode; + guidance?: React.ReactNode; + onClose?: () => void; + preview: ShareModalPreview; + primaryTitle?: React.ReactNode; + secondaryTitle?: React.ReactNode; + socialLinks?: ShareModalSocialLink[]; + title?: React.ReactNode; + variant?: 'post' | 'publication'; +} + +async function copyTextToClipboard(text: string) { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // Fall back for browser contexts where the async clipboard API is blocked. + } + } + + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '0'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); +} + +function SocialIcon({service}: {service: ShareService}) { + if (service === 'threads') { + return ( + + ); + } + + if (service === 'facebook') { + return ( + + ); + } + + if (service === 'linkedin') { + return ( + + ); + } + + return ( + + ); +} + +function SocialLinks({layout, links}: {layout: 'footer' | 'stacked'; links: ShareModalSocialLink[]}) { + if (layout === 'stacked') { + return ( +
+ {links.map(link => ( + + ))} +
+ ); + } + + return ( +
+ {links.map(link => ( + + + + ))} +
+ ); +} + +const ShareModal: React.FC = ({ + actionsLayout = 'footer', + children, + closeButtonId, + copyButtonId, + copyButtonTestId, + copyLabel = 'Copy link', + copySuccessLabel = 'Copied!', + copyURL, + contentProps, + description, + footerAction, + guidance, + onClose = () => {}, + preview, + primaryTitle, + secondaryTitle, + socialLinks = [], + title, + variant = 'post', + ...props +}) => { + const [isCopied, setIsCopied] = useState(false); + + const handleCopyLink = async () => { + await copyTextToClipboard(copyURL); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + }; + + const showPostHeader = variant === 'post'; + const {className: contentClassName, ...dialogContentProps} = contentProps || {}; + const content = ( + + {showPostHeader && ( +
+ +
+ )} + + {showPostHeader ? + + {primaryTitle && {primaryTitle}} + {primaryTitle && secondaryTitle &&
} + {secondaryTitle && {secondaryTitle}} +
+ : + {title} + } + {!showPostHeader && ( + + )} + {description && + + {description} + + } +
+ + + {preview.imageURL ? +
+ : + !showPostHeader && ( +
+ +
+ ) + } +
+ {showPostHeader ? +

{preview.title}

+ : +
{preview.title}
+ } + {preview.description && ( +

{preview.description}

+ )} + {preview.meta} +
+
+ + {guidance} + + {actionsLayout === 'stacked' ? + <> +
+ {copyURL} + +
+ + + : + + {footerAction || ( + <> + + + + )} + + } +
+ ); + + return ( + + {children ? + + {children} + + : + null + } + {content} + + ); +}; + +export default ShareModal; diff --git a/apps/shade/src/patterns.ts b/apps/shade/src/patterns.ts index db148e08146..22d8ea3f8be 100644 --- a/apps/shade/src/patterns.ts +++ b/apps/shade/src/patterns.ts @@ -3,6 +3,8 @@ export * from './components/features/filters/filters'; export {default as ColorPicker} from './components/features/color-picker/color-picker'; export type {ColorPickerProps} from './components/features/color-picker/color-picker'; export {default as PostShareModal} from './components/features/post-share-modal'; +export {default as ShareModal} from './components/features/share-modal'; +export type {ShareModalSocialLink} from './components/features/share-modal'; export * from './components/features/table-filter-tabs/table-filter-tabs'; export * from './components/features/utm-campaign-tabs/utm-campaign-tabs'; export type {CampaignType, TabType} from './components/features/utm-campaign-tabs/utm-campaign-tabs'; diff --git a/e2e/helpers/pages/admin/index.ts b/e2e/helpers/pages/admin/index.ts index 2e5a433a799..b8a837912e1 100644 --- a/e2e/helpers/pages/admin/index.ts +++ b/e2e/helpers/pages/admin/index.ts @@ -7,6 +7,7 @@ export * from './login-verify-page'; export * from './settings'; export * from './whats-new'; export * from './analytics'; +export * from './onboarding'; export * from './posts'; export * from './tags'; export * from './sidebar'; diff --git a/e2e/helpers/pages/admin/onboarding/index.ts b/e2e/helpers/pages/admin/onboarding/index.ts new file mode 100644 index 00000000000..f7449e6951c --- /dev/null +++ b/e2e/helpers/pages/admin/onboarding/index.ts @@ -0,0 +1 @@ +export * from './onboarding-page'; diff --git a/e2e/helpers/pages/admin/onboarding/onboarding-page.ts b/e2e/helpers/pages/admin/onboarding/onboarding-page.ts new file mode 100644 index 00000000000..994b550d3da --- /dev/null +++ b/e2e/helpers/pages/admin/onboarding/onboarding-page.ts @@ -0,0 +1,25 @@ +import {AdminPage} from '@/admin-pages'; +import {Locator, Page} from '@playwright/test'; + +export class OnboardingPage extends AdminPage { + public readonly checklist: Locator; + public readonly completeButton: Locator; + public readonly copyLinkButton: Locator; + public readonly shareModal: Locator; + public readonly skipButton: Locator; + + constructor(page: Page) { + super(page); + + this.pageUrl = '/ghost/#/setup/onboarding'; + this.checklist = page.getByTestId('onboarding-checklist'); + this.completeButton = page.getByTestId('onboarding-complete'); + this.copyLinkButton = page.getByTestId('onboarding-copy-link'); + this.shareModal = page.getByTestId('onboarding-share-modal'); + this.skipButton = page.getByTestId('onboarding-skip'); + } + + step(stepId: string) { + return this.page.getByTestId(`onboarding-step-${stepId}`); + } +} diff --git a/e2e/tests/admin/onboarding.test.ts b/e2e/tests/admin/onboarding.test.ts new file mode 100644 index 00000000000..3249c152983 --- /dev/null +++ b/e2e/tests/admin/onboarding.test.ts @@ -0,0 +1,208 @@ +import {AnalyticsOverviewPage, OnboardingPage} from '@/admin-pages'; +import {expect, test} from '@/helpers/playwright'; +import type {Page} from '@playwright/test'; + +type ChecklistState = 'pending' | 'started' | 'completed' | 'dismissed'; + +const allSteps = ['customize-design', 'first-post', 'build-audience', 'share-publication']; +const activeStartedAt = '2026-05-01T00:00:00.000Z'; +const navigationSteps: Array<[string, RegExp]> = [ + ['customize-design', /\/ghost\/#\/settings\/design\/edit\?ref=setup/], + ['first-post', /\/ghost\/#\/editor\/post/], + ['build-audience', /\/ghost\/#\/members/] +]; + +test.use({isolation: 'per-test'}); + +async function getCurrentUser(page: Page) { + const response = await page.request.get('/ghost/api/admin/users/me/?include=roles'); + expect(response.ok()).toBe(true); + + const body = await response.json(); + return body.users[0]; +} + +async function setOnboardingState(page: Page, checklistState: ChecklistState, completedSteps: string[] = [], startedAt: string | null | undefined = checklistState === 'started' ? activeStartedAt : undefined) { + const user = await getCurrentUser(page); + const preferences = user.accessibility ? JSON.parse(user.accessibility) : {}; + + preferences.onboarding = { + completedSteps, + checklistState + }; + + if (startedAt) { + preferences.onboarding.startedAt = startedAt; + } + + const response = await page.request.put(`/ghost/api/admin/users/${user.id}/?include=roles`, { + data: { + users: [{ + ...user, + accessibility: JSON.stringify(preferences) + }] + } + }); + expect(response.ok()).toBe(true); + + await page.reload({waitUntil: 'load'}); +} + +async function getOnboardingPreferences(page: Page) { + const user = await getCurrentUser(page); + const preferences = user.accessibility ? JSON.parse(user.accessibility) : {}; + + return preferences.onboarding; +} + +async function expectOnboardingRoute(page: Page, {returnTo = '/analytics'}: {returnTo?: string} = {}) { + await expect(page).toHaveURL((url) => { + const hashUrl = new URL(url.hash.slice(1), 'http://ghost.local'); + + return hashUrl.pathname === '/setup/onboarding' && hashUrl.searchParams.get('returnTo') === returnTo; + }); +} + +async function startOnboarding(page: Page) { + await setOnboardingState(page, 'started'); + await page.goto(`/ghost/?onboardingTest=${Date.now()}#/setup/onboarding?returnTo=%2Fanalytics`); + + const onboardingPage = new OnboardingPage(page); + await expect(onboardingPage.checklist).toBeVisible(); +} + +test.describe('Ghost Admin - Onboarding Checklist', () => { + test('new owner setup flow lands on onboarding', async ({page}) => { + await setOnboardingState(page, 'pending', ['customize-design']); + + await page.goto('/ghost/#/setup/done'); + + const onboardingPage = new OnboardingPage(page); + await expect(onboardingPage.checklist).toBeVisible(); + await expectOnboardingRoute(page); + + const preferences = await getOnboardingPreferences(page); + expect(preferences).toMatchObject({ + checklistState: 'started', + completedSteps: [] + }); + expect(typeof preferences.startedAt).toBe('string'); + }); + + test('analytics routes redirect to onboarding while active', async ({page}) => { + await startOnboarding(page); + + await page.goto('/ghost/#/analytics'); + + let onboardingPage = new OnboardingPage(page); + await expect(onboardingPage.checklist).toBeVisible(); + await expectOnboardingRoute(page); + + await page.goto('/ghost/#/analytics/web'); + + onboardingPage = new OnboardingPage(page); + await expect(onboardingPage.checklist).toBeVisible(); + await expectOnboardingRoute(page, {returnTo: '/analytics/web'}); + }); + + test('completed and dismissed users reach Analytics normally', async ({page}) => { + const analyticsPage = new AnalyticsOverviewPage(page); + + await setOnboardingState(page, 'completed', allSteps); + await analyticsPage.goto(); + await expect(analyticsPage.header).toBeVisible(); + + await setOnboardingState(page, 'dismissed'); + await analyticsPage.goto(); + await expect(analyticsPage.header).toBeVisible(); + }); + + test('pending users reach Analytics normally and are not started by the React route', async ({page}) => { + const analyticsPage = new AnalyticsOverviewPage(page); + + await setOnboardingState(page, 'pending', ['customize-design']); + await analyticsPage.goto(); + await expect(analyticsPage.header).toBeVisible(); + + await page.goto('/ghost/#/setup/onboarding?returnTo=%2Fanalytics%3Fsource%3Dweb'); + await expect(page).toHaveURL(/\/ghost\/#\/analytics\?source=web$/); + + const preferences = await getOnboardingPreferences(page); + expect(preferences).toMatchObject({ + checklistState: 'pending', + completedSteps: ['customize-design'] + }); + }); + + test('legacy started users without startedAt reach Analytics and are dismissed', async ({page}) => { + const analyticsPage = new AnalyticsOverviewPage(page); + + await setOnboardingState(page, 'started', [], null); + await analyticsPage.goto(); + + await expect(analyticsPage.header).toBeVisible(); + + await expect.poll(async () => { + return (await getOnboardingPreferences(page))?.checklistState; + }).toBe('dismissed'); + + await expect.poll(async () => { + return (await getOnboardingPreferences(page))?.completedSteps; + }).toEqual([]); + }); + + navigationSteps.forEach(([step, expectedUrl]) => { + test(`${step} step marks complete and navigates`, async ({page}) => { + await startOnboarding(page); + + const onboardingPage = new OnboardingPage(page); + await onboardingPage.step(step).click(); + + await expect(page).toHaveURL(expectedUrl); + + const preferences = await getOnboardingPreferences(page); + expect(preferences.completedSteps).toContain(step); + }); + }); + + test('share step opens the dialog and marks the step complete', async ({page}) => { + await startOnboarding(page); + + const onboardingPage = new OnboardingPage(page); + await onboardingPage.step('share-publication').click(); + + await expect(onboardingPage.shareModal).toBeVisible(); + + const preferences = await getOnboardingPreferences(page); + expect(preferences.completedSteps).toContain('share-publication'); + }); + + test('skip returns to the preserved analytics URL', async ({page}) => { + await startOnboarding(page); + await page.goto('/ghost/#/analytics?source=web'); + + const onboardingPage = new OnboardingPage(page); + await expectOnboardingRoute(page, {returnTo: '/analytics?source=web'}); + await onboardingPage.skipButton.click(); + + await expect(page).toHaveURL(/\/ghost\/#\/analytics\?source=web$/); + + const preferences = await getOnboardingPreferences(page); + expect(preferences.checklistState).toBe('dismissed'); + }); + + test('completing all steps returns to the preserved analytics URL', async ({page}) => { + await startOnboarding(page); + await setOnboardingState(page, 'started', allSteps); + await page.goto('/ghost/#/setup/onboarding?returnTo=%2Fanalytics%3Fsource%3Dweb'); + + const onboardingPage = new OnboardingPage(page); + await expectOnboardingRoute(page, {returnTo: '/analytics?source=web'}); + await onboardingPage.completeButton.click(); + + await expect(page).toHaveURL(/\/ghost\/#\/analytics\?source=web$/); + + const preferences = await getOnboardingPreferences(page); + expect(preferences.checklistState).toBe('completed'); + }); +}); diff --git a/ghost/admin/app/routes/setup/done.js b/ghost/admin/app/routes/setup/done.js index edeaa025a36..0b4fce120f7 100644 --- a/ghost/admin/app/routes/setup/done.js +++ b/ghost/admin/app/routes/setup/done.js @@ -6,19 +6,19 @@ export default class SetupFinishingTouchesRoute extends AuthenticatedRoute { @inject config; @service feature; @service onboarding; - @service router; @service session; @service settings; - beforeModel() { - super.beforeModel(...arguments); + async beforeModel() { + await super.beforeModel(...arguments); if (this.session.user.isOwnerOnly) { - this.onboarding.startChecklist(); + await this.onboarding.startChecklist(); } if (this.session.user?.isAdmin) { - return this.router.transitionTo('/analytics'); + // The React admin app owns /setup/onboarding, so hand off via hash navigation. + window.location.hash = '/setup/onboarding?returnTo=/analytics'; } } } diff --git a/ghost/admin/app/services/onboarding.js b/ghost/admin/app/services/onboarding.js index 26c8338d82a..951fee10d95 100644 --- a/ghost/admin/app/services/onboarding.js +++ b/ghost/admin/app/services/onboarding.js @@ -3,7 +3,8 @@ import {action} from '@ember/object'; const EMPTY_SETTINGS = { completedSteps: [], - checklistState: 'pending' // pending, started, completed, dismissed + checklistState: 'pending', // pending, started, completed, dismissed + startedAt: undefined }; export default class OnboardingService extends Service { @@ -65,6 +66,7 @@ export default class OnboardingService extends Service { settings.completedSteps = []; settings.checklistState = 'started'; + settings.startedAt = new Date().toISOString(); await this._saveSettings(settings); } diff --git a/ghost/admin/mirage/config/authentication.js b/ghost/admin/mirage/config/authentication.js index c178278474b..61b7a055f06 100644 --- a/ghost/admin/mirage/config/authentication.js +++ b/ghost/admin/mirage/config/authentication.js @@ -60,13 +60,13 @@ export default function mockAuthentication(server) { /* Setup ---------------------------------------------------------------- */ - server.post('/authentication/setup', function ({roles, users}, request) { - let attrs = JSON.parse(request.requestBody).setup; - let role = roles.findBy({name: 'Owner'}); + server.post('/authentication/setup', function (schema, request) { + let [attrs] = JSON.parse(request.requestBody).setup; + let role = schema.roles.findBy({name: 'Owner'}); // create owner role unless already exists if (!role) { - role = roles.create({name: 'Owner'}); + role = schema.roles.create({name: 'Owner'}); } attrs.roles = [role]; @@ -74,8 +74,7 @@ export default function mockAuthentication(server) { attrs.slug = dasherize(attrs.email.split('@')[0]); } - // NOTE: server does not use the user factory to fill in blank fields - return users.create(attrs); + return schema.create('user', attrs); }); server.get('/authentication/setup/', function () { diff --git a/ghost/admin/tests/acceptance/onboarding-test.js b/ghost/admin/tests/acceptance/onboarding-test.js index 0958b9ce2a6..b0d9f87c057 100644 --- a/ghost/admin/tests/acceptance/onboarding-test.js +++ b/ghost/admin/tests/acceptance/onboarding-test.js @@ -1,6 +1,6 @@ import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support'; import {cleanupMockAnalyticsApps, mockAnalyticsApps} from '../helpers/mock-analytics-apps'; -import {currentURL, find, visit} from '@ember/test-helpers'; +import {currentURL, find, visit, waitUntil} from '@ember/test-helpers'; import {describe, it} from 'mocha'; import {enableMembers} from '../helpers/members'; import {expect} from 'chai'; @@ -45,6 +45,19 @@ describe('Acceptance: Onboarding', function () { // Onboarding checklist tests removed — checklist is now rendered by // the React analytics app, not Ember. + + it('setup/done starts onboarding and redirects to the React onboarding route', async function () { + await visit('/setup/done'); + + await waitUntil(() => window.location.hash === '#/setup/onboarding?returnTo=/analytics'); + expect(window.location.hash).to.equal('#/setup/onboarding?returnTo=/analytics'); + + let user = this.server.schema.users.first(); + let preferences = JSON.parse(user.accessibility); + expect(preferences.onboarding.completedSteps).to.deep.equal([]); + expect(preferences.onboarding.checklistState).to.equal('started'); + expect(preferences.onboarding.startedAt).to.match(/^\d{4}-\d{2}-\d{2}T/); + }); }); describe('checklist (non-owner)', function () { @@ -61,6 +74,16 @@ describe('Acceptance: Onboarding', function () { // onboarding isn't shown expect(checklist()).to.not.exist; }); + + it('setup/done redirects to the React onboarding route without starting onboarding', async function () { + await visit('/setup/done'); + + await waitUntil(() => window.location.hash === '#/setup/onboarding?returnTo=/analytics'); + expect(window.location.hash).to.equal('#/setup/onboarding?returnTo=/analytics'); + + let user = this.server.schema.users.first(); + expect(user.accessibility).to.be.null; + }); }); describe('unauthenticated', function () { diff --git a/ghost/admin/tests/acceptance/setup-test.js b/ghost/admin/tests/acceptance/setup-test.js index e8be65a1ed1..4b72f12ee6b 100644 --- a/ghost/admin/tests/acceptance/setup-test.js +++ b/ghost/admin/tests/acceptance/setup-test.js @@ -2,7 +2,7 @@ import {Response} from 'miragejs'; import {afterEach, beforeEach, describe, it} from 'mocha'; import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support'; import {cleanupMockAnalyticsApps, mockAnalyticsApps} from '../helpers/mock-analytics-apps'; -import {click, currentURL, fillIn, find, findAll} from '@ember/test-helpers'; +import {click, currentURL, fillIn, find, findAll, waitUntil} from '@ember/test-helpers'; import {expect} from 'chai'; import {setupApplicationTest} from 'ember-mocha'; import {setupMirage} from 'ember-cli-mirage/test-support'; @@ -32,6 +32,9 @@ describe('Acceptance: Setup', function () { if (!server.schema.settings.all().length) { server.loadFixtures('settings'); } + if (!server.schema.themes.all().length) { + server.loadFixtures('themes'); + } // mimick a new blog server.get('/authentication/setup/', function () { @@ -120,9 +123,10 @@ describe('Acceptance: Setup', function () { await fillIn('[data-test-blog-title-input]', 'Blog Title'); await click('[data-test-button="setup"]'); - // it redirects to the dashboard - expect(currentURL(), 'url after submitting account details') - .to.equal('/analytics'); + // it starts onboarding and hands off to the React onboarding route + await waitUntil(() => window.location.hash === '#/setup/onboarding?returnTo=/analytics'); + expect(window.location.hash, 'url after submitting account details') + .to.equal('#/setup/onboarding?returnTo=/analytics'); }); it('handles validation errors in setup', async function () { @@ -214,15 +218,20 @@ describe('Acceptance: Setup', function () { describe('?firstStart=true', function () { beforeEach(async function () { + this.server.loadFixtures('configs'); + this.server.loadFixtures('settings'); + this.server.loadFixtures('themes'); + let role = this.server.create('role', {name: 'Owner'}); this.server.create('user', {roles: [role], slug: 'owner'}); await authenticateSession(); }); - it('transitions to dashboard', async function () { + it('transitions to onboarding', async function () { await visit('/?firstStart=true'); - expect(currentURL()).to.equal('/analytics'); + await waitUntil(() => window.location.hash === '#/setup/onboarding?returnTo=/analytics'); + expect(window.location.hash).to.equal('#/setup/onboarding?returnTo=/analytics'); }); }); }); From 74a72982f8607b54f57fa0c2c80498b25b4f0a67 Mon Sep 17 00:00:00 2001 From: Weyland Swart <49831538+weylandswart@users.noreply.github.com> Date: Wed, 6 May 2026 09:21:20 +0100 Subject: [PATCH 4/8] Updated email filters on members page (#27695) Closes https://linear.app/ghost/issue/BER-3609/open-rate-filter-applies-empty-exact-match-immediately Switches is to is greater than and removes the default value for all email filters. --- apps/posts/src/views/members/member-fields.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/posts/src/views/members/member-fields.ts b/apps/posts/src/views/members/member-fields.ts index e6127d04de0..f7ccdea8729 100644 --- a/apps/posts/src/views/members/member-fields.ts +++ b/apps/posts/src/views/members/member-fields.ts @@ -389,8 +389,7 @@ export const memberFields = defineFields({ ui: { label: 'Emails sent (all time)', type: 'number', - defaultOperator: 'is', - defaultValue: 0, + defaultOperator: 'is-greater', min: 0, className: 'w-24' }, @@ -401,8 +400,7 @@ export const memberFields = defineFields({ ui: { label: 'Emails opened (all time)', type: 'number', - defaultOperator: 'is', - defaultValue: 0, + defaultOperator: 'is-greater', min: 0, className: 'w-24' }, @@ -413,8 +411,7 @@ export const memberFields = defineFields({ ui: { label: 'Open rate (all time)', type: 'number', - defaultOperator: 'is', - defaultValue: 0, + defaultOperator: 'is-greater', min: 0, max: 100, suffix: '%', From fcc65165233adb7f4e056d4d49fbaedfbc87793c Mon Sep 17 00:00:00 2001 From: Sag Date: Wed, 6 May 2026 10:30:21 +0200 Subject: [PATCH 5/8] Added member activity entry when gift subscription ends (#27649) closes https://linear.app/ghost/issue/BER-3596 - When a gift subscription's validity period ends and the member drops back to free, the admin member-activity feed now renders an "ended paid subscription" entry - Backend reads from the existing `members_status_events` rows (`from_status: gift, to_status: free`), so no new write path or schema change is needed - Copy and icon match the paid-subscription `expired` event so the lifecycle reads consistently to publishers --- ghost/admin/app/helpers/parse-member-event.js | 8 ++ ghost/admin/app/utils/member-event-types.js | 2 + .../unit/helpers/parse-member-event-test.js | 14 +++ .../unit/utils/member-event-types-test.js | 14 +-- .../repositories/event-repository.js | 44 ++++++++- .../repositories/event-repository.test.js | 98 +++++++++++++++++++ 6 files changed, 172 insertions(+), 8 deletions(-) diff --git a/ghost/admin/app/helpers/parse-member-event.js b/ghost/admin/app/helpers/parse-member-event.js index 11407c6e9fd..91a9ddcc22f 100644 --- a/ghost/admin/app/helpers/parse-member-event.js +++ b/ghost/admin/app/helpers/parse-member-event.js @@ -148,6 +148,10 @@ export default class ParseMemberEventHelper extends Helper { icon = 'gift'; } + if (event.type === 'gift_ended_event') { + icon = 'subscriptions'; + } + if (event.type === 'email_change_event') { icon = 'email-changed'; } @@ -279,6 +283,10 @@ export default class ParseMemberEventHelper extends Helper { if (event.type === 'gift_redemption_event') { return 'started paid subscription via gift'; } + + if (event.type === 'gift_ended_event') { + return 'ended paid subscription'; + } } /** diff --git a/ghost/admin/app/utils/member-event-types.js b/ghost/admin/app/utils/member-event-types.js index 1f20b32a200..94db184f3f6 100644 --- a/ghost/admin/app/utils/member-event-types.js +++ b/ghost/admin/app/utils/member-event-types.js @@ -37,9 +37,11 @@ export function toggleEventType(eventType, currentExcludedEvents = []) { if (excludedEvents.has('subscription_event')) { excludedEvents.delete('subscription_event'); excludedEvents.delete('gift_redemption_event'); + excludedEvents.delete('gift_ended_event'); } else { excludedEvents.add('subscription_event'); excludedEvents.add('gift_redemption_event'); + excludedEvents.add('gift_ended_event'); } } else if (eventType === 'payment_event') { if (excludedEvents.has('payment_event')) { diff --git a/ghost/admin/tests/unit/helpers/parse-member-event-test.js b/ghost/admin/tests/unit/helpers/parse-member-event-test.js index dd32e73b645..cef76c440b3 100644 --- a/ghost/admin/tests/unit/helpers/parse-member-event-test.js +++ b/ghost/admin/tests/unit/helpers/parse-member-event-test.js @@ -113,4 +113,18 @@ describe('Unit: Helper: parse-member-event', function () { expect(result.info).to.equal('Free'); }); }); + + describe('gift_ended_event', function () { + it('returns "ended paid subscription" action', function () { + const event = buildEvent({type: 'gift_ended_event'}); + const result = helper.compute([event]); + expect(result.action).to.equal('ended paid subscription'); + }); + + it('returns "event-subscriptions" icon', function () { + const event = buildEvent({type: 'gift_ended_event'}); + const result = helper.compute([event]); + expect(result.icon).to.equal('event-subscriptions'); + }); + }); }); diff --git a/ghost/admin/tests/unit/utils/member-event-types-test.js b/ghost/admin/tests/unit/utils/member-event-types-test.js index 41b4a151df5..7f6f04e196b 100644 --- a/ghost/admin/tests/unit/utils/member-event-types-test.js +++ b/ghost/admin/tests/unit/utils/member-event-types-test.js @@ -29,14 +29,14 @@ describe('Unit | Utility | event-type-utils', function () { expect(newExcludedEvents).to.equal(''); }); - it('should toggle subscription_event together with gift_redemption_event', function () { + it('should toggle subscription_event together with gift_redemption_event and gift_ended_event', function () { const newExcludedEvents = toggleEventType('subscription_event', []); - expect(newExcludedEvents).to.equal('subscription_event,gift_redemption_event'); + expect(newExcludedEvents).to.equal('subscription_event,gift_redemption_event,gift_ended_event'); }); it('should toggle subscription_event group off when toggling subscription_event off', function () { - const newExcludedEvents = toggleEventType('subscription_event', ['subscription_event', 'gift_redemption_event']); + const newExcludedEvents = toggleEventType('subscription_event', ['subscription_event', 'gift_redemption_event', 'gift_ended_event']); expect(newExcludedEvents).to.equal(''); }); @@ -44,19 +44,19 @@ describe('Unit | Utility | event-type-utils', function () { it('should preserve previously-excluded payment group when toggling subscription_event', function () { const newExcludedEvents = toggleEventType('subscription_event', ['payment_event', 'donation_event', 'gift_purchase_event']); - expect(newExcludedEvents).to.equal('payment_event,donation_event,gift_purchase_event,subscription_event,gift_redemption_event'); + expect(newExcludedEvents).to.equal('payment_event,donation_event,gift_purchase_event,subscription_event,gift_redemption_event,gift_ended_event'); }); it('should preserve previously-excluded subscription group when toggling payment_event', function () { - const newExcludedEvents = toggleEventType('payment_event', ['subscription_event', 'gift_redemption_event']); + const newExcludedEvents = toggleEventType('payment_event', ['subscription_event', 'gift_redemption_event', 'gift_ended_event']); - expect(newExcludedEvents).to.equal('subscription_event,gift_redemption_event,payment_event,donation_event,gift_purchase_event'); + expect(newExcludedEvents).to.equal('subscription_event,gift_redemption_event,gift_ended_event,payment_event,donation_event,gift_purchase_event'); }); it('should accept a comma-separated string for currentExcludedEvents', function () { const newExcludedEvents = toggleEventType('subscription_event', 'payment_event,donation_event,gift_purchase_event'); - expect(newExcludedEvents).to.equal('payment_event,donation_event,gift_purchase_event,subscription_event,gift_redemption_event'); + expect(newExcludedEvents).to.equal('payment_event,donation_event,gift_purchase_event,subscription_event,gift_redemption_event,gift_ended_event'); }); it('should return correct divider need based on event groups', function () { diff --git a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js index ba9c67cbc15..5a82996a507 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js @@ -86,7 +86,8 @@ module.exports = class EventRepository { {type: 'payment_event', action: 'getPaymentEvents'}, {type: 'email_change_event', action: 'getEmailChangeEvent'}, {type: 'gift_purchase_event', action: 'getGiftPurchaseEvents'}, - {type: 'gift_redemption_event', action: 'getGiftRedemptionEvents'} + {type: 'gift_redemption_event', action: 'getGiftRedemptionEvents'}, + {type: 'gift_ended_event', action: 'getGiftEndedEvents'} ); if (this._AutomatedEmailRecipient) { @@ -136,6 +137,7 @@ module.exports = class EventRepository { login_event: 0, subscription_event: 1, gift_redemption_event: 1, + gift_ended_event: 1, newsletter_event: 2, signup_event: 3 }; @@ -554,6 +556,46 @@ module.exports = class EventRepository { }; } + async getGiftEndedEvents(options = {}, filter) { + options = { + ...options, + withRelated: ['member'], + filter: 'from_status:gift+to_status:free+custom:true', + useBasicCount: true, + mongoTransformer: chainTransformers( + // First set the filter manually + replaceCustomFilterTransformer(filter), + + // Map the used keys in that filter + ...mapKeys({ + 'data.created_at': 'created_at', + 'data.member_id': 'member_id' + }) + ) + }; + + const {data: models, meta} = await this._MemberStatusEvent.findPage(options); + + const data = models.map((model) => { + const json = model.toJSON(options); + + return { + type: 'gift_ended_event', + data: { + id: json.id, + member: json.member || null, + member_id: json.member_id, + created_at: json.created_at + } + }; + }); + + return { + data, + meta + }; + } + async getCommentEvents(options = {}, filter) { options = { ...options, diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js index 6242378d910..00671a001cd 100644 --- a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js @@ -658,4 +658,102 @@ describe('EventRepository', function () { assert.equal(event.data.member_id, null); }); }); + + describe('getGiftEndedEvents', function () { + let eventRepository; + let fake; + + before(function () { + fake = sinon.fake.returns({data: [{ + toJSON: () => ({ + id: 'status-event-1', + member_id: 'member-abc', + member: {id: 'member-abc', name: 'Test Member', email: 'member@example.com'}, + from_status: 'gift', + to_status: 'free', + created_at: '2024-10-15T08:00:00.000Z' + }) + }]}); + eventRepository = new EventRepository({ + EmailRecipient: null, + MemberSubscribeEvent: null, + MemberPaymentEvent: null, + MemberStatusEvent: { + findPage: fake + }, + MemberLoginEvent: null, + MemberPaidSubscriptionEvent: null, + labsService: null + }); + }); + + afterEach(function () { + fake.resetHistory(); + }); + + it('queries with correct options', async function () { + await eventRepository.getGiftEndedEvents({ + filter: 'not used', + order: 'created_at desc, id desc' + }, { + type: 'unused' + }); + + sinon.assert.calledOnceWithMatch(fake, { + withRelated: ['member'], + filter: 'from_status:gift+to_status:free+custom:true', + order: 'created_at desc, id desc' + }); + }); + + it('returns correctly formatted gift_ended_event', async function () { + const result = await eventRepository.getGiftEndedEvents({ + order: 'created_at desc, id desc' + }, {}); + + assert.equal(result.data.length, 1); + + const event = result.data[0]; + + assert.equal(event.type, 'gift_ended_event'); + assert.equal(event.data.id, 'status-event-1'); + assert.equal(event.data.member_id, 'member-abc'); + assert.equal(event.data.created_at, '2024-10-15T08:00:00.000Z'); + assert.deepEqual(event.data.member, { + id: 'member-abc', + name: 'Test Member', + email: 'member@example.com' + }); + }); + + it('sets member to null when member relation is not present', async function () { + const nullMemberFake = sinon.fake.returns({data: [{ + toJSON: () => ({ + id: 'status-event-2', + member_id: 'member-xyz', + member: null, + from_status: 'gift', + to_status: 'free', + created_at: '2024-11-01T12:00:00.000Z' + }) + }]}); + const repo = new EventRepository({ + EmailRecipient: null, + MemberSubscribeEvent: null, + MemberPaymentEvent: null, + MemberStatusEvent: { + findPage: nullMemberFake + }, + MemberLoginEvent: null, + MemberPaidSubscriptionEvent: null, + labsService: null + }); + + const result = await repo.getGiftEndedEvents({}, {}); + const event = result.data[0]; + + assert.equal(event.data.member, null); + assert.equal(event.data.member_id, 'member-xyz'); + }); + }); }); From db7b3f8e2a0a826a11ca510fb3a9b69a16dfc71c Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 6 May 2026 09:59:52 +0100 Subject: [PATCH 6/8] Fixed apps/admin TypeScript build for ShareModal contentProps (#27696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/BER-3587/ Follow-up to #27625 — the apps/admin build was failing with a TS2353 error because Radix's `DialogContentProps` type does not include an index signature for `data-*` attributes, so passing `data-testid` (or any `data-*` attr) as a literal in `contentProps` failed strict excess-property checks. - widened `ShareModal`'s `contentProps` type to additionally accept arbitrary `data-*` keys, matching how the attribute is consumed on the rendered DOM element - removed the vestigial `data-test-modal` attribute from the onboarding share dialog; nothing references it (the e2e helper uses the `data-testid` selector) --- .../src/onboarding/components/share-publication-dialog.tsx | 1 - apps/shade/src/components/features/share-modal/share-modal.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/admin/src/onboarding/components/share-publication-dialog.tsx b/apps/admin/src/onboarding/components/share-publication-dialog.tsx index 2bad59dadd6..72e51efc54c 100644 --- a/apps/admin/src/onboarding/components/share-publication-dialog.tsx +++ b/apps/admin/src/onboarding/components/share-publication-dialog.tsx @@ -52,7 +52,6 @@ export function SharePublicationDialog({ closeButtonId="ob-close-share-modal" contentProps={{ className: "dark:bg-surface-elevated", - "data-test-modal": "onboarding-share", "data-testid": "onboarding-share-modal", }} copyButtonId="ob-copy-publication-link" diff --git a/apps/shade/src/components/features/share-modal/share-modal.tsx b/apps/shade/src/components/features/share-modal/share-modal.tsx index 723636456aa..3f099026f6d 100644 --- a/apps/shade/src/components/features/share-modal/share-modal.tsx +++ b/apps/shade/src/components/features/share-modal/share-modal.tsx @@ -33,7 +33,7 @@ interface ShareModalProps extends React.ComponentPropsWithoutRef; + contentProps?: React.ComponentPropsWithoutRef & Record<`data-${string}`, string | undefined>; description?: React.ReactNode; footerAction?: React.ReactNode; guidance?: React.ReactNode; From 5841022fb176f99ac412ba84c10a1fda09d2cacb Mon Sep 17 00:00:00 2001 From: Sag Date: Wed, 6 May 2026 11:44:01 +0200 Subject: [PATCH 7/8] Fixed gift-to-paid member activity entry reverting after first payment (#27681) closes https://linear.app/ghost/issue/BER-3542 Fixes the member activity feed entry for gift members who continue into a paid subscription. When a gift member upgraded to a paid subscription, the subscription event initially had enough context to show `continued paid subscription after gift`. However, `getSubscriptionEvents()` deleted the eager-loaded `paidStatusEvent` relation from the shared `SubscriptionCreatedEvent` Bookshelf model while serializing the first event row. Because multiple `MemberPaidSubscriptionEvent` rows can share the same `SubscriptionCreatedEvent` instance, later rows could lose access to `paidStatusEvent`. That caused `previous_status` to become `null`, and the activity feed could revert to the generic `started paid subscription` wording after the next payment/update event. This change: - keeps `paidStatusEvent` available on the shared relation while deriving `previous_status` - removes `subscriptionCreatedEvent.paidStatusEvent` only from the serialized event payload - adds unit coverage for shared subscription-created-event relations so the regression is caught --- .../repositories/event-repository.js | 5 +- .../repositories/event-repository.test.js | 114 ++++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js index 5a82996a507..2f8f8ec4b5b 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js @@ -255,10 +255,6 @@ module.exports = class EventRepository { // Prevent toJSON on stripeSubscription (we don't have everything loaded) delete model.relations.stripeSubscription; - // paidStatusEvent is a helper relation only used to derive previous_status above - if (subscriptionCreatedEvent && subscriptionCreatedEvent.id) { - delete subscriptionCreatedEvent.relations.paidStatusEvent; - } const d = { ...model.toJSON(options), attribution: model.get('type') === 'created' && subscriptionCreatedEvent && subscriptionCreatedEvent.id ? this._memberAttributionService.getEventAttribution(subscriptionCreatedEvent) : null, @@ -267,6 +263,7 @@ module.exports = class EventRepository { tierName }; delete d.stripeSubscription; + delete d.subscriptionCreatedEvent?.paidStatusEvent; return { type: 'subscription_event', data: d diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js index 00671a001cd..12b53e352c2 100644 --- a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js @@ -756,4 +756,118 @@ describe('EventRepository', function () { assert.equal(event.data.member_id, 'member-xyz'); }); }); + + describe('getSubscriptionEvents', function () { + // Builds a Bookshelf-shaped mock that mirrors how the model is used inside + // getSubscriptionEvents, including the eager-load behaviour where multiple + // MemberPaidSubscriptionEvent rows that share a subscription_id receive the + // SAME SubscriptionCreatedEvent instance via .related(). + function buildModels({sharedSubscriptionCreatedEvent}) { + function makeRelated(map) { + return name => map[name] ?? {id: undefined, related: () => ({id: undefined})}; + } + + const sharedRelated = makeRelated({ + subscriptionCreatedEvent: sharedSubscriptionCreatedEvent, + stripeSubscription: {related: () => ({related: () => ({related: () => null})})} + }); + + const buildModel = (attrs) => { + const relations = { + subscriptionCreatedEvent: sharedSubscriptionCreatedEvent, + stripeSubscription: {related: () => ({related: () => ({related: () => null})})} + }; + return { + id: attrs.id, + relations, + related: name => relations[name] ?? sharedRelated(name), + get: key => attrs[key], + toJSON: () => { + const paidStatusEvent = sharedSubscriptionCreatedEvent.related('paidStatusEvent'); + + return { + ...attrs, + subscriptionCreatedEvent: { + id: sharedSubscriptionCreatedEvent.id, + paidStatusEvent: paidStatusEvent && paidStatusEvent.id ? { + id: paidStatusEvent.id, + from_status: paidStatusEvent.get('from_status'), + to_status: paidStatusEvent.get('to_status') + } : undefined + } + }; + } + }; + }; + + return [ + // Order matches findPage(order: 'created_at desc, id desc'): + // the newer "updated" row comes first, the original "created" row second. + buildModel({ + id: 'mpse-updated', + type: 'updated', + member_id: 'member1', + subscription_id: 'sub1', + created_at: '2026-05-05T18:21:31.000Z' + }), + buildModel({ + id: 'mpse-created', + type: 'created', + member_id: 'member1', + subscription_id: 'sub1', + created_at: '2026-05-05T15:49:44.000Z' + }) + ]; + } + + it('preserves previous_status on every row when multiple events share a subscription_id', async function () { + // One SubscriptionCreatedEvent shared across both paid-subscription rows + // (this is what Bookshelf's belongsTo eager-load gives us when the foreign + // key is duplicated). The paidStatusEvent on it represents the gift-to-paid + // transition. + const paidStatusEvent = { + id: 'mse-gift-to-paid', + get: key => ({from_status: 'gift', to_status: 'paid'}[key]) + }; + const sharedSubscriptionCreatedEvent = { + id: 'sce1', + relations: {paidStatusEvent, memberCreatedEvent: {id: undefined}}, + related(name) { + return this.relations[name] ?? {id: undefined}; + } + }; + + const models = buildModels({sharedSubscriptionCreatedEvent}); + const findPage = sinon.fake.resolves({ + data: models, + meta: {pagination: {total: models.length}} + }); + + const eventRepository = new EventRepository({ + EmailRecipient: null, + MemberSubscribeEvent: null, + MemberPaymentEvent: null, + MemberStatusEvent: null, + MemberLoginEvent: null, + MemberPaidSubscriptionEvent: {findPage}, + memberAttributionService: {getEventAttribution: () => null}, + labsService: null + }); + + const result = await eventRepository.getSubscriptionEvents({}, ''); + + assert.equal(result.data.length, 2); + const created = result.data.find(e => e.data.type === 'created'); + const updated = result.data.find(e => e.data.type === 'updated'); + + // The original `created` row should still report previous_status='gift' + // even when iterated AFTER another row that shares its SubscriptionCreatedEvent. + assert.equal(created.data.previous_status, 'gift'); + + // The helper relation should be removed from the serialized payload + assert.equal(created.data.subscriptionCreatedEvent.paidStatusEvent, undefined); + assert.equal(updated.data.subscriptionCreatedEvent.paidStatusEvent, undefined); + assert.equal(sharedSubscriptionCreatedEvent.related('paidStatusEvent').get('from_status'), 'gift'); + }); + }); }); From 7c3ca72ab1677a1f57ac998a1d2c87ef24daac45 Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Tue, 5 May 2026 13:41:09 +0100 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=90=9B=20Tolerated=20absolute=20and?= =?UTF-8?q?=20pre-prefixed=20paths=20in=20S3Storage=20key=20building?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/tryghost/issue/ONC-1673 Several callers pass either absolute filesystem paths (built via path.join with getContentPath) or paths that already include the storagePath prefix (built via getTargetDir) when calling exists(), save() or delete(). path.posix.join concatenated those onto storagePath verbatim, producing malformed bucket keys that embedded the local filesystem prefix or duplicated the storagePath segment. The exists() probe then targeted a different key than the eventual write, defeating uniqueness checks; on stricter bucket policies the malformed HEAD threw non-NotFound errors, surfacing as the bookmark favicon fallback users have been reporting. Routed all keys through toCanonicalRelativePath, a chain of named handlers (fromAbsoluteFilesystemPath, fromStoragePathPrefixed, fromLeadingSlashPath) that each handle one input shape and return null when their shape doesn't apply, mirroring how LocalStorageBase absorbs the same shapes via _resolveAndValidateStoragePath. Existing path-traversal protection still fires for `..` segments and the change is forward-only — no existing S3 objects move or rename. --- .../core/server/adapters/storage/S3Storage.ts | 38 +++++++- .../adapters/storage/s3-storage.test.ts | 96 +++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/ghost/core/core/server/adapters/storage/S3Storage.ts b/ghost/core/core/server/adapters/storage/S3Storage.ts index 433c1b9fce0..aec0fe146c0 100644 --- a/ghost/core/core/server/adapters/storage/S3Storage.ts +++ b/ghost/core/core/server/adapters/storage/S3Storage.ts @@ -395,7 +395,7 @@ export default class S3Storage extends StorageBase { }); } - const pathWithStorage = path.posix.join(this.storagePath, relativePath); + const pathWithStorage = path.posix.join(this.storagePath, this.toCanonicalRelativePath(relativePath)); if (!pathWithStorage.startsWith(this.storagePath + '/') && pathWithStorage !== this.storagePath) { throw new errors.IncorrectUsageError({ @@ -410,6 +410,42 @@ export default class S3Storage extends StorageBase { return `${this.tenantPrefix}/${pathWithStorage}`; } + private toCanonicalRelativePath(input: string): string { + return this.fromAbsoluteFilesystemPath(input) + ?? this.fromStoragePathPrefixed(input) + ?? this.fromLeadingSlashPath(input) + ?? input; + } + + private fromAbsoluteFilesystemPath(input: string): string | null { + if (!path.posix.isAbsolute(input)) { + return null; + } + const marker = `/${this.storagePath}/`; + const idx = input.lastIndexOf(marker); + if (idx !== -1) { + return input.slice(idx + marker.length); + } + if (input.endsWith(`/${this.storagePath}`)) { + return ''; + } + return null; + } + + private fromStoragePathPrefixed(input: string): string | null { + if (input === this.storagePath || input.startsWith(`${this.storagePath}/`)) { + return path.posix.relative(this.storagePath, input); + } + return null; + } + + private fromLeadingSlashPath(input: string): string | null { + if (!path.posix.isAbsolute(input)) { + return null; + } + return input.replace(/^\/+/, ''); + } + private isNotFound(error: unknown): boolean { return error instanceof NotFound || error instanceof NoSuchKey; } diff --git a/ghost/core/test/unit/server/adapters/storage/s3-storage.test.ts b/ghost/core/test/unit/server/adapters/storage/s3-storage.test.ts index 50711405c6d..25d386bac83 100644 --- a/ghost/core/test/unit/server/adapters/storage/s3-storage.test.ts +++ b/ghost/core/test/unit/server/adapters/storage/s3-storage.test.ts @@ -494,6 +494,102 @@ describe('S3Storage', function () { ); }); + describe('absolute and pre-prefixed paths are normalised before building keys', function () { + // Some legacy callers build their target by joining `getContentPath(...)` + // with an extra segment, producing an absolute filesystem path. Without + // normalisation the absolute prefix gets concatenated into the bucket + // key. These tests pin the behaviour so the keys produced for an + // absolute or pre-prefixed input match the keys for the equivalent + // relative input, mirroring `LocalStorageBase`. + + it('strips an absolute filesystem prefix that contains the storagePath segment', function () { + const {storage} = createStorage(); + + const fromAbsolute = (storage as any).buildKey('/var/lib/ghost/content/files/2024/06/image.jpg'); + const fromRelative = (storage as any).buildKey('2024/06/image.jpg'); + + assert.equal(fromAbsolute, fromRelative); + assert.equal(fromAbsolute, 'configurable/prefix/content/files/2024/06/image.jpg'); + }); + + it('strips a leading / when callers pass it via `getTargetDir(storagePath)`', function () { + const {storage} = createStorage(); + + // External-media-inliner shape: targetDir = `storagePath/year/month`, + // joined with a filename and handed to exists()/save(). + const fromPrefixed = (storage as any).buildKey('content/files/2024/06/image.jpg'); + const fromRelative = (storage as any).buildKey('2024/06/image.jpg'); + + assert.equal(fromPrefixed, fromRelative); + }); + + it('preserves the historical leading-slash-as-decoration behaviour when the path has no storagePath segment', function () { + // handle-image-sizes calls exists(req.url) with values like + // `/size/w1200/...`. These are conceptually relative to the storage + // root and must continue to map to the same key as before. + const {storage} = createStorage(); + + const key = (storage as any).buildKey('/size/w1200/2024/06/photo.jpg'); + + assert.equal(key, 'configurable/prefix/content/files/size/w1200/2024/06/photo.jpg'); + }); + + it('exists() probes the same key for absolute, pre-prefixed and relative inputs', async function () { + const {storage, sendStub} = createStorage(); + const {HeadObjectCommand: HeadObjectCmd} = await import('@aws-sdk/client-s3'); + + sendStub.rejects(createNotFoundError()); + + await storage.exists('image.jpg', '/var/lib/ghost/content/files/2024/06'); + await storage.exists('image.jpg', 'content/files/2024/06'); + await storage.exists('image.jpg', '2024/06'); + + const keys = sendStub.getCalls().map(call => (call.args[0] as InstanceType).input.Key); + assert.equal(keys.length, 3); + assert.ok(keys.every(k => k === 'configurable/prefix/content/files/2024/06/image.jpg'), + `expected all three exists() probes to target the same key, got: ${JSON.stringify(keys)}`); + }); + + it('save() writes to the same key whether targetDir is absolute, pre-prefixed or relative', async function () { + const {storage, sendStub} = createStorage(); + + sinon.stub(storage, 'exists').resolves(false); + sinon.stub(fs.promises, 'stat').resolves({size: 256} as fs.Stats); + sinon.stub(fs.promises, 'readFile').resolves(Buffer.from('image-bytes')); + + const targetDirs = [ + '/var/lib/ghost/content/files/2024/06', + 'content/files/2024/06', + '2024/06' + ]; + + const keys: (string | undefined)[] = []; + for (const targetDir of targetDirs) { + sendStub.resetHistory(); + await storage.save({path: '/tmp/image.jpg', name: 'image.jpg'}, targetDir); + const command = sendStub.firstCall.args[0] as PutObjectCommand; + keys.push(command.input.Key); + } + + assert.ok(keys.every(k => k === 'configurable/prefix/content/files/2024/06/image.jpg'), + `expected all save()s to land at the same key, got: ${JSON.stringify(keys)}`); + }); + + it('still rejects path traversal that would escape the storage root', function () { + const {storage} = createStorage(); + + // Belt and braces: the existing `..` protection still fires when + // the input passes through normalisation. + assert.throws(() => { + (storage as any).buildKey('/var/lib/ghost/content/files/../../../etc/passwd'); + }, /not a valid URL/); + + assert.throws(() => { + (storage as any).buildKey('content/files/../../etc/passwd'); + }, /not a valid URL/); + }); + }); + describe('Multipart Upload', function () { function createMockReadStream(fileContent: Buffer) { return Readable.from(fileContent);