diff --git a/.agents/bin/docker b/.agents/bin/docker new file mode 100755 index 00000000000..1d64b5e1e69 --- /dev/null +++ b/.agents/bin/docker @@ -0,0 +1,2 @@ +#!/bin/sh +exec sudo --preserve-env=PUBLIC_URL /usr/bin/docker "$@" diff --git a/.agents/resume b/.agents/resume new file mode 100755 index 00000000000..788ed7c48da --- /dev/null +++ b/.agents/resume @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! systemctl is-active --quiet amp-svc-docker.service || ! systemctl is-active --quiet amp-svc-ghost.service; then + amp orb services ensure +fi diff --git a/.agents/setup b/.agents/setup new file mode 100755 index 00000000000..f00662f1510 --- /dev/null +++ b/.agents/setup @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +node_version="$(cat .node-version)" +node_dir="$HOME/.local/node-v${node_version}-linux-x64" + +if [[ ! -x "$node_dir/bin/node" ]]; then + archive="/tmp/node-v${node_version}-linux-x64.tar.xz" + curl -fsSLo "$archive" "https://nodejs.org/dist/v${node_version}/node-v${node_version}-linux-x64.tar.xz" + mkdir -p "$HOME/.local" + tar -xJf "$archive" -C "$HOME/.local" + rm -f "$archive" +fi + +export PATH="$node_dir/bin:$PATH" +corepack enable --install-directory "$node_dir/bin" +pnpm setup + +profile_marker="# Ghost orb Node.js" +profile_export="export PATH=\"$node_dir/bin:\$PATH\"" +if grep -Fqx "$profile_marker" "$HOME/.bash_profile" 2>/dev/null; then + sed -i "/^${profile_marker}$/ { n; c\\ +$profile_export + }" "$HOME/.bash_profile" +else + cat >> "$HOME/.bash_profile" </dev/null 2>&1; then + sudo install -m 0755 -d /etc/apt/keyrings + sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc + sudo chmod a+r /etc/apt/keyrings/docker.asc + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null + sudo apt-get update + sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +fi diff --git a/.agents/skills/convert-internal-package-to-typescript/SKILL.md b/.agents/skills/convert-internal-package-to-typescript/SKILL.md index c125c765d4d..a2e892884a5 100644 --- a/.agents/skills/convert-internal-package-to-typescript/SKILL.md +++ b/.agents/skills/convert-internal-package-to-typescript/SKILL.md @@ -62,6 +62,9 @@ Use the subject `Changed file extensions to TypeScript`. Apply the package contract from `packages/README.md`: shared config packages, minimal package-local config, ESM metadata and exports, standard scripts, and a single compiled output unless a verified consumer requires an exception. +Replace the package's `migration` status with +`ghostPackage.goldenPath: compliant` only after every mechanical golden-path +check passes. Use the subject `Converted to TypeScript`. diff --git a/.agents/skills/migrate-internal-package/SKILL.md b/.agents/skills/migrate-internal-package/SKILL.md index 66c6d952ca3..208eae9eae1 100644 --- a/.agents/skills/migrate-internal-package/SKILL.md +++ b/.agents/skills/migrate-internal-package/SKILL.md @@ -88,6 +88,8 @@ commits manually. After the subtree commit, add focused integration commits that: - make the package private with an internal placeholder version; +- set `ghostPackage.goldenPath` to `migration` and `ghostPackage.reason` to a + concise explanation of the remaining modernization work; - switch Ghost consumers to `workspace:*`; - update the lockfile with `pnpm`; - minimally adapt configuration and tests to work in Ghost; diff --git a/.agents/skills/migrate-internal-package/references/legacy-integration.md b/.agents/skills/migrate-internal-package/references/legacy-integration.md index 8efa2b3e2f3..bc29be43e92 100644 --- a/.agents/skills/migrate-internal-package/references/legacy-integration.md +++ b/.agents/skills/migrate-internal-package/references/legacy-integration.md @@ -9,6 +9,8 @@ modernize the implementation. Change only what Ghost needs to consume and verify the package: - set `"private": true` and the internal placeholder version; +- set `ghostPackage.goldenPath` to `migration` and describe the remaining + modernization work in `ghostPackage.reason`; - point repository metadata at Ghost; - remove public publishing configuration; - switch Ghost consumers to `workspace:*`; diff --git a/.amp/services.yaml b/.amp/services.yaml new file mode 100644 index 00000000000..43ae16ac2eb --- /dev/null +++ b/.amp/services.yaml @@ -0,0 +1,24 @@ +services: + docker: + command: sudo dockerd --group root + + ghost: + command: | + export PATH="$PWD/.agents/bin:$HOME/.local/node-v$(cat .node-version)-linux-x64/bin:$PATH" + attempts=0 + until docker info >/dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 60 ]; then + echo "Docker did not become ready after 60 seconds" >&2 + exit 1 + fi + sleep 1 + done + export DEV_COMPOSE_FILES="-f compose.dev.orb.yaml" + export GHOST_URL="$PUBLIC_URL" + unset PORT + exec pnpm dev + port: 2370 + portal: + title: Ghost development + description: Full source environment with Admin HMR, backend reloads, MySQL, Redis, and Mailpit. diff --git a/.changeset/salty-cities-change.md b/.changeset/salty-cities-change.md new file mode 100644 index 00000000000..3acdbbd601c --- /dev/null +++ b/.changeset/salty-cities-change.md @@ -0,0 +1,20 @@ +--- +"@tryghost/adapter-base-cache": none +"@tryghost/adapter-base-scheduling": none +"@tryghost/adapter-base-sso": none +"@tryghost/kg-card-factory": none +"@tryghost/kg-clean-basic-html": none +"@tryghost/kg-converters": none +"@tryghost/kg-default-cards": none +"@tryghost/kg-default-nodes": none +"@tryghost/kg-default-transforms": none +"@tryghost/kg-html-to-lexical": none +"@tryghost/kg-lexical-html-renderer": none +"@tryghost/kg-markdown-html-renderer": none +"@tryghost/kg-unsplash-selector": none +"@tryghost/kg-utils": none +"@tryghost/koenig-lexical": none +"ghost-storage-base": none +--- + +Removed duplicate package license files because pnpm includes the repository root license automatically. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 631f889097b..c706d694bd0 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -38,7 +38,7 @@ If your change is **user-facing** please prepend the first line of your commit w - ✨ Feature - 🎨 Improvement / change - 🐛 Bug Fix -- 🌐 i18n (translation) submissions [[See Translating Ghost docs for more detail](https://www.notion.so/5af2858289b44f9194f73f8a1e17af59?pvs=25#bef8c9988e294a4b9a6dd624136de36f)] +- 🌐 i18n (translation) submissions [[See Translating Ghost docs for more detail](../docs/contributing/translating-ghost.md)] - 💡 Anything else flagged to users or whoever is writing release notes Good commit message examples: [new feature](https://github.com/TryGhost/Ghost/commit/61db6defde3b10a4022c86efac29cf15ae60983f), [bug fix](https://github.com/TryGhost/Ghost/commit/6ef835bb5879421ae9133541ebf8c4e560a4a90e) and [translation](https://github.com/TryGhost/Ghost/commit/83904c1611ae7ab3257b3b7d55f03e50cead62d7). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06bf1df3222..8fb7fb09df3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,6 +161,16 @@ jobs: - 'package.json' - 'scripts/check-agent-skill-links.js' - 'scripts/test/check-agent-skill-links.test.js' + package-standards: + - 'packages/**' + - 'package.json' + - 'pnpm-workspace.yaml' + - 'scripts/check-internal-packages.js' + - 'scripts/create-package.js' + - 'scripts/lib/constants.js' + - 'scripts/lib/package-template.js' + - 'scripts/test/check-internal-packages.test.js' + - '.github/workflows/ci.yml' core: - *shared # Repository documentation and ownership metadata do not affect @@ -279,6 +289,7 @@ jobs: changed_core: ${{ steps.changed.outputs.core }} changed_any_code: ${{ steps.changed.outputs.any-code }} changed_docs: ${{ steps.changed.outputs.docs }} + changed_package_standards: ${{ steps.changed.outputs.package-standards }} changed_tb_cli: ${{ steps.changed.outputs.tb-cli }} # Single gate for the build + browser-E2E lane. True for tags, or when a # changed file could affect a running Ghost instance (see the `e2e` path @@ -421,6 +432,26 @@ jobs: - name: Lint agent skills run: node scripts/check-agent-skill-links.js + job_lint_packages: + name: Lint packages + runs-on: ubuntu-slim + needs: [job_setup] + if: needs.job_setup.outputs.changed_package_standards == 'true' + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Check internal package golden path + run: node scripts/check-internal-packages.js + + - name: Test internal package checker + run: node --test scripts/test/check-internal-packages.test.js + job_i18n: runs-on: ubuntu-latest needs: [job_setup] @@ -2051,6 +2082,7 @@ jobs: job_migration_integrity_check, job_lint, job_lint_docs, + job_lint_packages, job_i18n, job_build_admin, job_pack, diff --git a/.gitignore b/.gitignore index e063404e12f..86dbe0262e7 100644 --- a/.gitignore +++ b/.gitignore @@ -198,6 +198,9 @@ yalc.lock # useful for keeping local plans etc /ai +# Amp generates thread-specific portal state at runtime +/.amp/portals/ + # direnv environment loader files .envrc diff --git a/apps/admin-toolbar/LICENSE b/apps/admin-toolbar/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/apps/admin-toolbar/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/apps/admin-toolbar/README.md b/apps/admin-toolbar/README.md index 18679b1eef5..81daa495c7c 100644 --- a/apps/admin-toolbar/README.md +++ b/apps/admin-toolbar/README.md @@ -7,19 +7,25 @@ scripts where bundle size matters more than ecosystem compatibility. ## Development +Run `pnpm dev:public` from the monorepo root to start the standard development +environment and the Admin Toolbar watcher. To work on this package by itself, +run these commands from this directory: + ```bash pnpm build # one-off build -pnpm dev # build + preview with watch (started automatically by pnpm dev from root) -pnpm test # build + run tests against UMD bundle +pnpm dev # watch and rebuild umd/admin-toolbar.min.js +pnpm test # build + run tests against the built bundle ``` ## How it's served In production, the script is loaded from jsDelivr via the `adminToolbar` config in `defaults.json`, following the same CDN pattern as portal, comments-ui, and -the other public apps. In development, the Docker Dockerfile overrides the URL -to proxy through Caddy to the local vite preview server on port 4176. +the other public apps. In development, `docker/ghost-dev/Dockerfile` overrides +that URL to `/ghost/assets/admin-toolbar/admin-toolbar.min.js`, which the dev +gateway serves straight off disk from this package's `umd/` directory — so the +watcher's output is picked up on the next request. # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). diff --git a/apps/admin-x-framework/src/api/member-custom-fields.ts b/apps/admin-x-framework/src/api/member-custom-fields.ts index b289bf03ea0..c550b327b71 100644 --- a/apps/admin-x-framework/src/api/member-custom-fields.ts +++ b/apps/admin-x-framework/src/api/member-custom-fields.ts @@ -105,7 +105,10 @@ export const userTypeForField = (field: MemberCustomField): MemberCustomFieldUse // A custom field CSV column offered as an import mapping target: the column name the // backend reads (`value`) and a human label for the picker. -export type MemberCustomFieldCsvColumn = {label: string; value: string}; +// The field's type rides along so a picker can show what kind of thing the column holds +// without being handed the fields as well. Every column of a composite carries the +// composite's own type, which is what its icon is drawn from. +export type MemberCustomFieldCsvColumn = {label: string; value: string; type: FieldType}; /** * The CSV import mapping targets for a set of custom fields: one per column the export @@ -118,7 +121,8 @@ export const memberCustomFieldCsvColumns = (fields: MemberCustomField[]): Member const labels = partLabelsFor(field.type); return csvColumnsForField({key: field.key, type: field.type}).map(({column, subField}) => ({ label: subField === null ? field.name : `${field.name} (${labels[subField]})`, - value: column + value: column, + type: field.type })); }); }; @@ -173,7 +177,30 @@ export const useCreateMemberCustomField = createMutation '/members/custom_fields/', body: field => ({members_custom_fields: [field]}), - invalidateQueries: {dataType} + invalidateQueries: {dataType}, + // The created field is put into the cached lists as well as refetched, so a screen that + // has just made one can use it in the same breath instead of waiting for a round trip or + // keeping its own copy until one arrives. The refetch above remains the truth; this only + // decides what is on screen until it lands. + // + // Appended, because the API assigns a new field the last position. Keyed de-dup because + // the refetch may already have landed, and a list that does not hold the created field is + // left alone: a browse filtered to active fields should not be handed an archived one, and + // by the same token no list here is asked to take a field it did not ask for. + updateQueries: { + dataType, + emberUpdateType: 'skip', + update: (newData, currentData) => { + const current = currentData as MemberCustomFieldsResponseType | undefined; + if (!current?.members_custom_fields) { + return currentData; + } + const created = newData.members_custom_fields.filter( + field => !current.members_custom_fields.some(existing => existing.key === field.key) + ); + return {...current, members_custom_fields: [...current.members_custom_fields, ...created]}; + } + } }); // Keys are immutable after creation (the API rejects changes); `name` and diff --git a/apps/admin-x-framework/src/api/members.ts b/apps/admin-x-framework/src/api/members.ts index ac37fbcbd7d..a13e18f97b6 100644 --- a/apps/admin-x-framework/src/api/members.ts +++ b/apps/admin-x-framework/src/api/members.ts @@ -259,8 +259,11 @@ function buildImportMembersFormData({file, labels = [], mapping = {}}: ImportMem formData.append('labels', label); } + // An empty target is the caller naming a column to leave out of the import, and the + // importer only honours a column it is told about — so it has to survive to the wire. + // Null and undefined stay dropped: they are a column with nothing said about it. for (const [key, val] of Object.entries(mapping)) { - if (val) { + if (typeof val === 'string') { formData.append(`mapping[${key}]`, val); } } diff --git a/apps/admin-x-framework/src/api/settings.ts b/apps/admin-x-framework/src/api/settings.ts index 65793b7468a..2511af8aba8 100644 --- a/apps/admin-x-framework/src/api/settings.ts +++ b/apps/admin-x-framework/src/api/settings.ts @@ -3,7 +3,7 @@ import {Config} from './config'; // Types -export type SettingValue = string | boolean | null; +export type SettingValue = string | boolean | number | null; export type Setting = { key: string; diff --git a/apps/admin-x-framework/src/test/msw-utils.ts b/apps/admin-x-framework/src/test/msw-utils.ts index 0d33c2b3d4b..c4b06e35d7a 100644 --- a/apps/admin-x-framework/src/test/msw-utils.ts +++ b/apps/admin-x-framework/src/test/msw-utils.ts @@ -31,6 +31,10 @@ const fixtures = { navigation: [], secondary_navigation: [], llms_enabled: true, + machine_payments_enabled: false, + machine_payments_currency: null, + machine_payments_amount: 100, + machine_payments_stripe_profile_id: null, meta_title: null, meta_description: null }, diff --git a/apps/admin-x-framework/src/utils/errors.ts b/apps/admin-x-framework/src/utils/errors.ts index e039ab8bbe6..9291a191bb9 100644 --- a/apps/admin-x-framework/src/utils/errors.ts +++ b/apps/admin-x-framework/src/utils/errors.ts @@ -118,14 +118,19 @@ export class ValidationError extends JSONError { export const errorsWithMessage = [ValidationError, ThemeValidationError, HostLimitError, EmailError]; -// The API error serializer puts the human-readable text in `context`; -// `message` is only a generic summary +/** + * What the server said went wrong, for showing to a person. + * + * The API serializer rewrites `message` to a generic summary and leaves the sentence that + * explains the failure — and usually what to do about it — in `context`, so that is read + * first. Any error carrying an API body is read the same way: a caller should not have to + * know which class it got back to find the text, and reading only one class left every + * other one showing "Could not save…" while the reason sat unread in the payload. + */ export function getErrorMessage(error: unknown, fallback: string): string { - if (error instanceof ValidationError && error.data?.errors[0]) { - return error.data.errors[0].context || error.data.errors[0].message; - } + const apiError = error instanceof JSONError ? error.data?.errors?.[0] : undefined; - return fallback; + return apiError?.context || apiError?.message || fallback; } // Frontend errors diff --git a/apps/admin-x-framework/test/unit/api/member-custom-fields.test.ts b/apps/admin-x-framework/test/unit/api/member-custom-fields.test.ts index e1d0dc8b8d3..ef695ed6f63 100644 --- a/apps/admin-x-framework/test/unit/api/member-custom-fields.test.ts +++ b/apps/admin-x-framework/test/unit/api/member-custom-fields.test.ts @@ -35,7 +35,7 @@ describe('member custom fields api helpers', () => { describe('memberCustomFieldCsvColumns', () => { it('gives a scalar field one target labelled by its name', () => { expect(memberCustomFieldCsvColumns([field({key: 'nickname', name: 'Nickname'})])).toEqual([ - {label: 'Nickname', value: 'custom_fields.nickname'} + {label: 'Nickname', value: 'custom_fields.nickname', type: 'short_text'} ]); }); @@ -43,12 +43,12 @@ describe('member custom fields api helpers', () => { const columns = memberCustomFieldCsvColumns([field({key: 'shipping_address', name: 'Shipping Address', type: 'address'})]); expect(columns).toEqual([ - {label: 'Shipping Address (Address line 1)', value: 'custom_fields.shipping_address.line1'}, - {label: 'Shipping Address (Address line 2)', value: 'custom_fields.shipping_address.line2'}, - {label: 'Shipping Address (City)', value: 'custom_fields.shipping_address.city'}, - {label: 'Shipping Address (State)', value: 'custom_fields.shipping_address.state'}, - {label: 'Shipping Address (Postal code)', value: 'custom_fields.shipping_address.postal_code'}, - {label: 'Shipping Address (Country)', value: 'custom_fields.shipping_address.country'} + {label: 'Shipping Address (Address line 1)', value: 'custom_fields.shipping_address.line1', type: 'address'}, + {label: 'Shipping Address (Address line 2)', value: 'custom_fields.shipping_address.line2', type: 'address'}, + {label: 'Shipping Address (City)', value: 'custom_fields.shipping_address.city', type: 'address'}, + {label: 'Shipping Address (State)', value: 'custom_fields.shipping_address.state', type: 'address'}, + {label: 'Shipping Address (Postal code)', value: 'custom_fields.shipping_address.postal_code', type: 'address'}, + {label: 'Shipping Address (Country)', value: 'custom_fields.shipping_address.country', type: 'address'} ]); }); @@ -62,7 +62,7 @@ describe('member custom fields api helpers', () => { const future = field({key: 'mystery', name: 'Mystery', type: 'a_type_from_the_future' as MemberCustomField['type']}); expect(memberCustomFieldCsvColumns([future])).toEqual([ - {label: 'Mystery', value: 'custom_fields.mystery'} + {label: 'Mystery', value: 'custom_fields.mystery', type: 'a_type_from_the_future'} ]); }); }); diff --git a/apps/admin-x-framework/test/unit/utils/errors.test.ts b/apps/admin-x-framework/test/unit/utils/errors.test.ts index 1fdcd405901..91b0797c914 100644 --- a/apps/admin-x-framework/test/unit/utils/errors.test.ts +++ b/apps/admin-x-framework/test/unit/utils/errors.test.ts @@ -337,6 +337,34 @@ describe('errors utils', () => { expect(getErrorMessage(error, 'Fallback message')).toBe('Validation error, cannot edit label.'); }); + // Not just validation: the serializer treats every error the same way, so a caller + // should not have to know which class came back to find the sentence it carries. + const makeHostLimitError = (context: string | null) => { + const data: ErrorResponse = { + errors: [{ + code: 'CUSTOM_FIELDS_LIMIT_REACHED', + context, + details: null, + ghostErrorCode: null, + help: 'Help text', + id: 'error-id', + message: 'Cannot create custom field.', + property: null, + type: 'HostLimitError' + }] + }; + return new HostLimitError({} as Response, data); + }; + + it('returns the context of any error carrying an API body, not only a validation one', () => { + const error = makeHostLimitError('Custom fields are limited to 20 per site.'); + expect(getErrorMessage(error, 'Fallback message')).toBe('Custom fields are limited to 20 per site.'); + }); + + it('falls back to the API message for a non-validation error with no context', () => { + expect(getErrorMessage(makeHostLimitError(null), 'Fallback message')).toBe('Cannot create custom field.'); + }); + it('returns the fallback for other errors', () => { expect(getErrorMessage(new Error('boom'), 'Fallback message')).toBe('Fallback message'); expect(getErrorMessage(undefined, 'Fallback message')).toBe('Fallback message'); diff --git a/apps/admin/src/layout/app-sidebar/nav-content.tsx b/apps/admin/src/layout/app-sidebar/nav-content.tsx index 71819d23abb..720bd64aa47 100644 --- a/apps/admin/src/layout/app-sidebar/nav-content.tsx +++ b/apps/admin/src/layout/app-sidebar/nav-content.tsx @@ -16,7 +16,7 @@ import { useIsActiveLink } from "./use-is-active-link"; import { useEmberRouting } from "@/ember-bridge"; import { useFeatureFlag } from "@tryghost/admin-x-framework/hooks"; -const LEGACY_MEMBERS_ACTIVE_ROUTES = ['member', 'member.new', 'members-activity']; +const LEGACY_MEMBERS_ACTIVE_ROUTES = ['members-activity']; function PostsNavItemContent({isActive, to}: {isActive: boolean; to: string}) { return ( diff --git a/apps/admin/src/member-detail-gate.test.tsx b/apps/admin/src/member-detail-gate.test.tsx deleted file mode 100644 index e6f6aaf6028..00000000000 --- a/apps/admin/src/member-detail-gate.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React from 'react'; -import {MemberDetailGate} from './member-detail-gate'; -import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {render, screen, waitFor} from '@testing-library/react'; - -const {mockUseBrowseConfig} = vi.hoisted(() => ({ - mockUseBrowseConfig: vi.fn() -})); - -vi.mock('@tryghost/admin-x-framework/api/config', () => ({ - useBrowseConfig: mockUseBrowseConfig -})); - -vi.mock('./ember-bridge', () => ({ - EmberFallback: () => React.createElement('div', {'data-testid': 'ember-fallback'}), - useEmberFeatureFlag: (flag: string) => { - const stateBridge = window.EmberBridge?.state; - if (!stateBridge?.isFeatureEnabled) { - return undefined; - } - return stateBridge.isFeatureEnabled(flag) ?? null; - } -})); - -vi.mock('./members/detail/member-detail', () => ({ - default: () => React.createElement('div', {'data-testid': 'react-member-detail'}) -})); - -const configResult = (overrides: Record) => ({ - data: undefined, - isError: false, - isLoading: false, - ...overrides -}); - -const withLabs = (labs: Record) => configResult({data: {config: {labs}}}); - -describe('MemberDetailGate', () => { - beforeEach(() => { - mockUseBrowseConfig.mockReset(); - }); - - it('renders Ember while the flag is off', () => { - mockUseBrowseConfig.mockReturnValue(withLabs({memberDetailsReact: false})); - - render(); - - expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); - }); - - it('renders React while the flag is on', async () => { - mockUseBrowseConfig.mockReturnValue(withLabs({memberDetailsReact: true})); - - render(); - - // The React screen is lazily imported, so it arrives a tick later. - await waitFor(() => { - expect(screen.getByTestId('react-member-detail')).toBeInTheDocument(); - }); - }); - - it('renders Ember when the flag is absent from config', () => { - mockUseBrowseConfig.mockReturnValue(withLabs({})); - - render(); - - expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); - }); - - it('renders Ember when the config query fails', () => { - // A failed config read must not blank the screen — Ember owns this URL - // by default and still serves it, so degrading to Ember keeps the - // member detail working. Reporting is left to the framework's default - // error handler on useBrowseConfig. - mockUseBrowseConfig.mockReturnValue(configResult({isError: true, data: undefined})); - - render(); - - expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); - }); - - it('renders Ember when the config query resolves with no data', () => { - mockUseBrowseConfig.mockReturnValue(configResult({data: undefined})); - - render(); - - expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); - }); - - it('renders nothing while config is loading', () => { - // Deliberately not falling back to Ember here: doing so would un-hide - // the Ember shell and flash the old screen on every cold load for - // admins who have the flag on. - mockUseBrowseConfig.mockReturnValue(configResult({isLoading: true})); - - const {container} = render(); - - expect(screen.queryByTestId('ember-fallback')).not.toBeInTheDocument(); - expect(screen.queryByTestId('react-member-detail')).not.toBeInTheDocument(); - expect(container).toBeEmptyDOMElement(); - }); -}); diff --git a/apps/admin/src/member-detail-gate.tsx b/apps/admin/src/member-detail-gate.tsx deleted file mode 100644 index 7ef1efcd98a..00000000000 --- a/apps/admin/src/member-detail-gate.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { FlagGatedRoute } from "./flag-gated-route"; -import { lazy } from "react"; - -/** - * Serves `/members/:member_id` — covering both edit (`:member_id`) and create - * (the `new` sentinel) — from the React member detail screen when the - * `memberDetailsReact` Labs flag is on, and from Ember otherwise. The gating - * semantics (loading, error, and flag branching) live in FlagGatedRoute. - */ -const MemberDetailReact = lazy(() => import("./members/detail/member-detail")); - -export function MemberDetailGate() { - return ; -} - -export default MemberDetailGate; diff --git a/apps/admin/src/members/components/bulk-action-modals/import-members-gate.tsx b/apps/admin/src/members/components/bulk-action-modals/import-members-gate.tsx new file mode 100644 index 00000000000..49317c8c05a --- /dev/null +++ b/apps/admin/src/members/components/bulk-action-modals/import-members-gate.tsx @@ -0,0 +1,35 @@ +import {ImportMembersModal as BaselineImportMembersModal} from '@/members/components/bulk-action-modals/import-members-modal'; +import {ImportMembersModal as CustomFieldsImportMembersModal} from '@/members/components/bulk-action-modals/import-members/custom-fields/import-members-modal'; +import {useFeatureFlag} from '@tryghost/admin-x-framework/hooks'; +import type {ImportResponse} from '@/members/components/bulk-action-modals/import-members/state'; + +interface ImportMembersGateProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onComplete?: (importResponse?: ImportResponse) => void; + onClose?: (importResponse?: ImportResponse) => void; +} + +/** + * Serves the members CSV import from the custom fields experience when the `membersCustomFields` + * Labs flag is on, and from the import as it shipped otherwise. + * + * Two whole implementations rather than one with the flag threaded through it. The mapping step + * diverges in almost every part — what a row offers, what a row means, what the request carries — + * and while both lived in one file every change was opt-out: five reached the flag-off path + * before anyone noticed. Here the baseline is a file this feature never edits, so it cannot + * regress by being forgotten about. + * + * The cost is real and worth stating: a fix to the import has to be applied to both, or knowingly + * to one, until the flag goes and the baseline is deleted. + */ +export function ImportMembersGate(props: ImportMembersGateProps) { + const customFieldsEnabled = useFeatureFlag('membersCustomFields'); + + if (customFieldsEnabled) { + return ; + } + return ; +} + +export default ImportMembersGate; diff --git a/apps/admin/src/members/components/bulk-action-modals/import-members-modal.tsx b/apps/admin/src/members/components/bulk-action-modals/import-members-modal.tsx index b518708a485..36ee7519abd 100644 --- a/apps/admin/src/members/components/bulk-action-modals/import-members-modal.tsx +++ b/apps/admin/src/members/components/bulk-action-modals/import-members-modal.tsx @@ -307,7 +307,14 @@ export function ImportMembersModal({ return ( - + {/* While mapping, the dialog is bounded to the viewport and laid out as a column, so + the table takes the leftover height and the footer stays reachable on a short + screen. 8vmin matches the dialog's own top offset. */} + {title} diff --git a/apps/admin/src/members/components/bulk-action-modals/import-members/components/mapping-step.tsx b/apps/admin/src/members/components/bulk-action-modals/import-members/components/mapping-step.tsx index eebc4b18870..f419cc737b6 100644 --- a/apps/admin/src/members/components/bulk-action-modals/import-members/components/mapping-step.tsx +++ b/apps/admin/src/members/components/bulk-action-modals/import-members/components/mapping-step.tsx @@ -55,7 +55,10 @@ export function MappingStep({ return ( <> -
+ {/* A flex column inside the height-bounded dialog: the table box below grows into + whatever room is left, so a short file keeps a small modal and a long one + fills the viewport rather than scrolling inside a fixed height. */} +
{fileData === null ? (
@@ -63,10 +66,10 @@ export function MappingStep({ ) : ( <>
-
+
diff --git a/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/create-field-form.tsx b/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/create-field-form.tsx new file mode 100644 index 00000000000..2832090a87c --- /dev/null +++ b/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/create-field-form.tsx @@ -0,0 +1,123 @@ +import {Button, Field, FieldError, FieldGroup, FieldLabel, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; +import {CustomFieldTypeOption} from '@/shared/member-custom-fields/custom-field-type-option'; +import {memberCustomFieldUserTypes} from '@tryghost/admin-x-framework/api/member-custom-fields'; +import {suggestedFieldName} from '@/members/components/bulk-action-modals/import-members/custom-fields/mapping'; +import {useEffect, useRef, useState} from 'react'; +import type {MemberCustomField} from '@tryghost/admin-x-framework/api/member-custom-fields'; + +interface CreateFieldFormProps { + // The CSV column this field is being created for. Seeds the name, since a header is + // usually close to what the publisher would have called the field anyway. + columnKey: string; + // The request in flight and what came back of it. Owned by the step, which runs the + // create: this asks for a field and shows what it is told, and knows nothing about how + // either happens. + isSaving: boolean; + // On the name input, for a refusal the publisher can fix by typing. + nameError: string | null; + // Below the form, for one they cannot. + saveError: string | null; + // False once a refusal cannot be argued with — a site at its field ceiling — so the + // button stops inviting the same doomed request. + canRetry: boolean; + onSubmit: (name: string, type: MemberCustomField['type']) => void; + // Editing answers the last refusal: it was about what was there before. + onEdit: () => void; + onCancel: () => void; +} + +/** + * Name and type for a field being created mid-mapping. + * + * Holds only what is being typed. Whether a name is acceptable is the server's answer — it + * owns the rule and the sentence for breaking it, and a copy here would be a second opinion + * that could disagree. + */ +export function CreateFieldForm({columnKey, isSaving, nameError, saveError, canRetry, onSubmit, onEdit, onCancel}: CreateFieldFormProps) { + const [name, setName] = useState(() => suggestedFieldName(columnKey)); + const [typeId, setTypeId] = useState(memberCustomFieldUserTypes[0].id); + + // Opened from a row that may sit at the bottom edge of the scroll area, in which case the + // form lands below the fold and its buttons are out of reach. `nearest` scrolls only when + // some of it is actually hidden, so a form that already fits doesn't move the table, and + // the scroll margin below carries it clear of the edge rather than flush against it. + const formRef = useRef(null); + useEffect(() => { + formRef.current?.scrollIntoView({block: 'nearest'}); + }, []); + + const submit = () => onSubmit(name, typeId); + + return ( +
{ + if (event.key === 'Escape') { + event.stopPropagation(); + // Swallowed but not acted on mid-save, matching the Cancel button beside it: + // backing out of a create already in flight would leave its answer nowhere. + if (!isSaving) { + onCancel(); + } + return; + } + // Enter submits, as it would in a form — there is no form element here because + // this sits inside the import dialog's own, and nesting one is invalid. + if (event.key === 'Enter' && !isSaving && canRetry) { + event.preventDefault(); + submit(); + } + }} + > + + + Name + { + setName(e.target.value); + onEdit(); + }} + /> + {nameError && {nameError}} + + + Type + + +
+ + +
+
+ + {saveError &&

{saveError}

} +
+ ); +} diff --git a/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/field-picker.tsx b/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/field-picker.tsx new file mode 100644 index 00000000000..b144d1b7a57 --- /dev/null +++ b/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/field-picker.tsx @@ -0,0 +1,260 @@ +import CustomFieldIcon from '@/shared/member-custom-fields/custom-field-icon'; +import {Button, Command, CommandCheck, CommandGroup, CommandInput, CommandItem, CommandList, Popover, PopoverContent, PopoverTrigger, inputSurfaceClasses} from '@tryghost/shade/components'; +import {LucideIcon, cn} from '@tryghost/shade/utils'; +import {useRef} from 'react'; +import {type MemberCustomFieldCsvColumn} from '@tryghost/admin-x-framework/api/member-custom-fields'; + +// One list, two sections, so what a column can be imported as is answered by reading rather +// than by trying each kind in turn. The section a field sits in is what tells a native "Name" +// apart from a custom field someone called "Name", so the trigger repeats it once chosen. +const NATIVE_GROUP = 'Membership field'; +const CUSTOM_GROUP = 'Custom field'; + +// Icons for the native targets. Written here rather than taken from somewhere shared because +// nothing shared exists: the members filter picker hand-writes its own switch, and analytics +// its own inline. Custom fields do have a registry, which is why they use CustomFieldIcon +// below instead of appearing here — a new field type gets its icon from that one place. +const NATIVE_ICONS: Record = { + email: LucideIcon.Mail, + name: LucideIcon.User, + note: LucideIcon.StickyNote, + subscribed_to_emails: LucideIcon.Send, + stripe_customer_id: LucideIcon.CreditCard, + // A comp, in the sense a venue means it: paid access granted rather than bought. Gift is + // taken, and would be the wrong reading anyway — gift_id is an actual gift someone sent. + complimentary_plan: LucideIcon.Ticket, + labels: LucideIcon.Tag, + created_at: LucideIcon.Calendar, + gift_id: LucideIcon.Gift, + import_tier: LucideIcon.Star +}; + +function NativeIcon({value, className}: {value: string; className?: string}) { + const Icon = NATIVE_ICONS[value] ?? LucideIcon.Type; + return ; +} + +interface FieldPickerProps { + className?: string; + columnKey: string; + value: string | null; + disabled?: boolean; + invalid?: boolean; + fieldMappings: {label: string; value: string}[]; + customFieldMappings: MemberCustomFieldCsvColumn[]; + // Open and search are the caller's, not this component's: creating a composite has to + // reopen this row's picker filtered to the field it just made, so which picker is open and + // what it is filtered by have to be sayable from outside. + open: boolean; + search: string; + onOpenChange: (open: boolean) => void; + onSearchChange: (search: string) => void; + onSelect: (target: string) => void; + onCreateField: () => void; + triggerRef: (node: HTMLElement | null) => void; +} + +export function FieldPicker({ + className, + columnKey, + value, + disabled, + invalid, + fieldMappings, + customFieldMappings, + open, + search, + onOpenChange, + onSearchChange, + onSelect, + onCreateField, + triggerRef +}: FieldPickerProps) { + // Choosing to add a field replaces this list with a form, and the form's first input should + // hold the focus rather than the button that opened the list. Two things are in the way, and + // both are the modal popover doing its job: it hands focus back to the trigger on close, and + // its focus trap stays alive through the exit animation — so a form mounted alongside it has + // its autoFocus pulled straight back inside. Both are dealt with at teardown, below. + const openingCreateForm = useRef(false); + + const selectedCustom = customFieldMappings.find(field => field.value === value); + const selectedNative = fieldMappings.find(field => field.value === value); + const selected = selectedCustom ?? selectedNative; + + const choose = (target: string) => { + onOpenChange(false); + onSelect(target); + }; + + return ( + { + // aria-disabled leaves the trigger live, so the refusal happens here rather + // than in the DOM: it can be hovered and focused, and it does not open. + if (disabled) { + return; + } + onOpenChange(next); + }} + > + + {/* aria-disabled rather than the disabled attribute, which Shade pairs with + pointer-events-none — and an element taking no pointer events can show no + cursor, so there was nothing to say "not this row" on hover. Live, it keeps + the cursor and stays reachable by keyboard; onOpenChange does the refusing. + + The fade is gentler than the button's own 50% because the border has to + survive it: at 50% on a greyed row the control read as absent. */} + + + {/* Sized and spaced as the members filter picker is (members-filters.tsx:156, + filters.tsx:2636), rather than to its own taste: it is the same control doing the + same job a few screens away, and two dialects of it is one too many. + + Bounded to the room Radix says it has rather than to a number of its own: a row + near the bottom of a tall table has little of it, and a fixed height there runs + off the screen with the list still scrolling inside. A column so the search box + keeps its height and the list takes what is left. */} + { + if (!openingCreateForm.current) { + return; + } + openingCreateForm.current = false; + // Don't return focus to the trigger, and only now open the form: this fires + // as the trap releases, so the form mounts with nothing left to fight. + event.preventDefault(); + onCreateField(); + }} + > + + + {/* Shade's own max-height stands; only the shrinking is added. flex-1 with + min-h-0 lets the list give up height to the available-height cap on the + content above, which is what keeps a row near the bottom of the dialog + from opening a list that runs off the screen. */} + + + {fieldMappings.map(field => ( + choose(field.value)}> + + {field.label} + {value === field.value && } + + ))} + + + {customFieldMappings.map(field => ( + choose(field.value)}> + + {field.label} + {value === field.value && } + + ))} + + {/* Its own group so it is the one thing search cannot take away: cmdk + hands a group's forceMount down to every item in it, so leaving this + among the fields would have pinned all of them too. A search matching + no field is the strongest signal there is that the field wanted does + not exist yet, and the answer to that is the way to make one — which + is why there is no empty state behind it. */} + + {/* With a plus, mirroring the label picker below this table. A + publisher can name a field something like "New field", so the + colour is what sets this apart from one. */} + { + openingCreateForm.current = true; + onOpenChange(false); + }} + > + + Add custom field + + + + + + + ); +} diff --git a/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/import-members-modal.tsx b/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/import-members-modal.tsx new file mode 100644 index 00000000000..34f742e1ccd --- /dev/null +++ b/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/import-members-modal.tsx @@ -0,0 +1,479 @@ +// Every step but the mapping one is shared with the baseline modal: they are the same file +// upload, progress and result screens, and nothing about custom fields reaches them. +import {CompleteStep, ErrorStep, InitStep, ProcessingStep} from '@/members/components/bulk-action-modals/import-members/components'; +import {MappingStep} from '@/members/components/bulk-action-modals/import-members/custom-fields/mapping-step'; +import {AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Button, LoadingIndicator} from '@tryghost/shade/components'; +import {useDirtyConfirmation} from '@tryghost/shade/patterns'; +import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@tryghost/shade/components'; +import {HostLimitError, JSONError, RequestEntityTooLargeError, ValidationError, getErrorMessage} from '@tryghost/admin-x-framework/errors'; +import {type ImportResponse} from '@/members/components/bulk-action-modals/import-members/state'; +import {MembersFieldMapping, detectFieldTypes, getFieldMappings} from '@/members/components/bulk-action-modals/import-members/custom-fields/mapping'; +import {buildImportResponse} from '@/members/components/bulk-action-modals/import-members/upload'; +import {cn} from '@tryghost/shade/utils'; +import {createInitialImportState, importReducer} from '@/members/components/bulk-action-modals/import-members/reducer'; +import {isImportMembersCompleteResponse, useImportMembers} from '@tryghost/admin-x-framework/api/members'; +import {memberCustomFieldCsvColumns, useBrowseMemberCustomFields} from '@tryghost/admin-x-framework/api/member-custom-fields'; +import {parseCSV} from '@/members/components/bulk-action-modals/import-members/csv'; +import {useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState} from 'react'; +import {useFeatureFlag} from '@tryghost/admin-x-framework/hooks'; +import {useLabelPicker} from '@/members/hooks/use-label-picker'; + +interface ImportMembersModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onComplete?: (importResponse?: ImportResponse) => void; + onClose?: (importResponse?: ImportResponse) => void; +} + +export function ImportMembersModal({ + open, + onOpenChange, + onComplete, + onClose +}: ImportMembersModalProps) { + const [state, dispatch] = useReducer(importReducer, undefined, createInitialImportState); + const errorCsvUrlRef = useRef(null); + const {mutateAsync: importMembers} = useImportMembers(); + const importMemberTier = useFeatureFlag('importMemberTier'); + + // Defined custom fields become mapping targets. Browse returns active fields only, which + // are the ones the importer writes to. No flag check anywhere in this file: the gate does + // not render it unless membersCustomFields is on. + const {data: customFieldsData, isError: customFieldsFailed} = useBrowseMemberCustomFields(); + // A field created from the mapping step is in here the moment it is created: the create + // mutation puts it into the cached list, so there is no window where a row points at a + // column the picker cannot name yet. + const customFieldColumns = useMemo( + () => memberCustomFieldCsvColumns(customFieldsData?.members_custom_fields ?? []), + [customFieldsData] + ); + // The file-reader effect waits for this before its first parse: the custom field + // definitions must be loaded or auto-detection would miss custom_fields.* columns on a + // fast upload. It flips false -> true once and stays true (a refetch keeps data defined), + // so readiness never re-triggers the read. + // Ready, or never going to be. A failure has no representation in `data`, so waiting on it + // alone leaves the file unparsed and the step on a spinner with nothing said — for a query + // whose only job is to add targets to a list. Failing it costs the custom fields; blocking + // on it costs the import. + const customFieldsReady = customFieldsData !== undefined || customFieldsFailed; + // Detection options are read inside the effect through this ref rather than as deps, so + // a later refetch of the options can't re-run the read and overwrite a mapping the user + // has begun editing. + const detectOptionsRef = useRef({importMemberTier, customFieldColumns}); + // Assigned in an effect rather than during render: a ref written while rendering can tear + // if a render is thrown away, and the read below happens after paint either way. + useLayoutEffect(() => { + detectOptionsRef.current = {importMemberTier, customFieldColumns}; + }, [importMemberTier, customFieldColumns]); + // Native targets only. Each row's kind decides which list its picker shows, and custom + // targets reach it through customFieldColumns. Auto-detection still sees both, via + // detectOptionsRef above. + const fieldMappings = useMemo( + () => getFieldMappings({importMemberTier}), + [importMemberTier] + ); + + // Whether email is mapped is the mapping step's to answer, because it is the only thing + // that knows which columns are actually in the import — a second answer here could + // disagree, which is how a mapped-but-deselected email column would pass. + const labelPicker = useLabelPicker({ + selectedSlugs: state.selectedLabelSlugs, + onSelectionChange: (slugs: string[]) => { + setHasEdits(true); + dispatch({type: 'SET_SELECTED_LABEL_SLUGS', selectedLabelSlugs: slugs}); + } + }); + + const revokeErrorCsvUrl = useCallback(() => { + if (errorCsvUrlRef.current && typeof URL.revokeObjectURL === 'function') { + URL.revokeObjectURL(errorCsvUrlRef.current); + errorCsvUrlRef.current = null; + } else if (errorCsvUrlRef.current) { + errorCsvUrlRef.current = null; + } + }, []); + + const reset = useCallback(() => { + revokeErrorCsvUrl(); + // The modal is never unmounted (members-actions keeps it mounted and toggles `open`), + // so anything held outside the reducer outlives Start over unless it is cleared here. + setHasEdits(false); + dispatch({type: 'RESET'}); + }, [revokeErrorCsvUrl]); + + useEffect(() => { + return () => { + revokeErrorCsvUrl(); + }; + }, [revokeErrorCsvUrl]); + + // Escape and a click on the backdrop stay as they are — they are how a dialog is dismissed + // and taking them away costs more than it saves. What they must not do is silently discard + // a mapped file: which columns are in, what each fills, the labels chosen. So dismissal + // asks first, using the same confirmation the settings modals use. + const {confirm, dialogProps} = useDirtyConfirmation(); + // Whether anything would actually be lost by leaving. A file that has only been parsed is + // not worth asking about: re-uploading it reproduces the same detected mapping. What cannot + // be reproduced is what the publisher changed since, so that is what this tracks. + const [hasEdits, setHasEdits] = useState(false); + + const handleOpenChange = useCallback((isOpen: boolean) => { + if (!isOpen && state.status === 'UPLOADING') { + return; + } + if (!isOpen) { + // Only the mapping step holds anything: every other one is either empty or shows a + // result that closing is the natural end of. + confirm(state.status === 'MAPPING' && hasEdits, () => { + const importResponse = state.importResponse ?? undefined; + reset(); + onClose?.(importResponse); + onOpenChange(false); + }); + return; + } + onOpenChange(isOpen); + }, [confirm, hasEdits, onClose, onOpenChange, reset, state.importResponse, state.status]); + + useEffect(() => { + if (!state.file || !customFieldsReady) { + return; + } + + let ignoreReaderEvents = false; + const reader = new FileReader(); + reader.onload = (e) => { + if (ignoreReaderEvents) { + return; + } + try { + const text = e.target?.result as string; + const data = parseCSV(text); + + if (data.length > 0) { + const detectedMapping = detectFieldTypes(data, detectOptionsRef.current); + const fieldMapping = new MembersFieldMapping(detectedMapping); + + dispatch({ + type: 'PARSE_SUCCESS', + fileData: data, + mapping: fieldMapping, + mappingError: null + }); + } else { + dispatch({ + type: 'PARSE_SUCCESS', + fileData: [], + mapping: null, + mappingError: 'File is empty, nothing to import. Please select a different file.' + }); + } + } catch { + dispatch({ + type: 'PARSE_FAILURE', + mappingError: 'Failed to parse this file. Please try another CSV file.' + }); + } + }; + reader.onerror = () => { + if (ignoreReaderEvents) { + return; + } + dispatch({ + type: 'PARSE_FAILURE', + mappingError: `Failed to read file${reader.error?.message ? `: ${reader.error.message}` : ''}` + }); + }; + reader.onabort = () => { + if (ignoreReaderEvents) { + return; + } + dispatch({ + type: 'PARSE_FAILURE', + mappingError: 'File read was interrupted. Please try again.' + }); + }; + reader.readAsText(state.file); + + return () => { + ignoreReaderEvents = true; + if (reader.readyState === FileReader.LOADING) { + reader.abort(); + } + }; + }, [state.file, customFieldsReady]); + + const validateFile = useCallback((file: File): boolean => { + const match = /(?:\.([^.]+))?$/.exec(file.name); + const extension = match?.[1]; + if (!extension || extension.toLowerCase() !== 'csv') { + dispatch({ + type: 'SET_FILE_ERROR', + fileError: 'The file type you uploaded is not supported' + }); + return false; + } + dispatch({type: 'SET_FILE_ERROR', fileError: null}); + return true; + }, []); + + const handleFileSelected = useCallback((file: File) => { + if (validateFile(file)) { + dispatch({type: 'SELECT_FILE', file}); + } + }, [validateFile]); + + const handleUpdateMapping = useCallback((from: string, to: string | null) => { + if (!state.mapping) { + return; + } + + setHasEdits(true); + + const nextMapping = state.mapping.updateMapping(from, to); + const nextError = state.fileData && state.fileData.length === 0 + ? 'File is empty, nothing to import. Please select a different file.' + : null; + + dispatch({ + type: 'UPDATE_MAPPING', + mapping: nextMapping, + mappingError: nextError + }); + }, [state.fileData, state.mapping]); + + // The row maps onto the column the form resolved — or onto nothing yet, for a composite, + // whose parts are all candidates and whose choice the mapping step asks in the picker. + // Either way the field joins the targets, so the other columns of a composite can be + // mapped by hand: guessing which of them holds a postcode is how data ends up in the + // wrong part. + // + // Nothing else is re-detected: a mapping already edited stays as they left it. + const handleFieldCreated = useCallback((columnKey: string, column: string | null) => { + if (column) { + handleUpdateMapping(columnKey, column); + } + }, [handleUpdateMapping]); + + const handleUpload = useCallback(async (importMapping: Record) => { + if (!state.file || state.mappingError) { + dispatch({type: 'SET_SHOW_MAPPING_ERRORS', showMappingErrors: true}); + return; + } + + dispatch({type: 'UPLOAD_START'}); + + const selectedLabelNames: string[] = []; + for (const slug of state.selectedLabelSlugs) { + const label = labelPicker.labels.find(l => l.slug === slug); + if (label) { + selectedLabelNames.push(label.name); + } + } + + try { + // Sent as given. The mapping step composes it from the mapping and what is in the + // import, which is knowledge this modal does not have and must not second-guess. + const importData = await importMembers({ + file: state.file, + labels: selectedLabelNames, + mapping: importMapping + }); + + if (isImportMembersCompleteResponse(importData)) { + const importResponse = buildImportResponse(importData); + revokeErrorCsvUrl(); + errorCsvUrlRef.current = importResponse.errorCsvUrl; + + dispatch({ + type: 'UPLOAD_COMPLETE', + importResponse + }); + onComplete?.(importResponse); + return; + } + + // Per the upload API contract a 2xx response is either complete (handled + // above) or accepted for background processing; importMembers() has already + // thrown for any error status. + dispatch({type: 'UPLOAD_ACCEPTED'}); + onComplete?.(); + } catch (error) { + if (error instanceof RequestEntityTooLargeError) { + dispatch({ + type: 'UPLOAD_ERROR', + errorMessage: 'The file you uploaded was larger than the maximum file size your server allows.' + }); + return; + } + + const apiError = error instanceof JSONError ? error.data?.errors?.[0] : null; + + if (error instanceof HostLimitError && apiError?.code === 'EMAIL_VERIFICATION_NEEDED') { + dispatch({ + type: 'UPLOAD_ERROR', + errorMessage: getErrorMessage(error, error.message), + errorHeader: 'Woah there cowboy, that\'s a big list', + showTryAgainButton: false + }); + onComplete?.(); + return; + } + + if (error instanceof ValidationError || apiError?.type === 'DataImportError') { + dispatch({ + type: 'UPLOAD_ERROR', + errorMessage: getErrorMessage(error, error instanceof Error ? error.message : 'An unexpected error occurred, please try again') + }); + return; + } + + dispatch({ + type: 'UPLOAD_ERROR', + errorMessage: 'An unexpected error occurred, please try again' + }); + } + }, [ + state.file, + state.mappingError, + state.selectedLabelSlugs, + labelPicker.labels, + importMembers, + revokeErrorCsvUrl, + onComplete + ]); + + const isWide = state.status === 'MAPPING' || state.status === 'UPLOADING'; + + const title = useMemo(() => { + switch (state.status) { + case 'PROCESSING': + return 'Import in progress'; + case 'COMPLETE': + return 'Import complete'; + case 'ERROR': + return state.errorHeader; + default: + return 'Import members'; + } + }, [state.errorHeader, state.status]); + + return ( + + {/* The mapping table needs the extra width only when it carries the kind + column; without it the dialog stays the size it has always been. + While mapping, the dialog is also bounded to the viewport and laid out + as a column, so the table can take the leftover height and the footer + is always reachable. 8vmin matches the dialog's own top offset. */} + + + {title} + + Import members from a CSV file. + + + + {state.status === 'INIT' && ( + handleOpenChange(false)} + onDropAccepted={handleFileSelected} + onDropRejected={() => dispatch({ + type: 'SET_FILE_ERROR', + fileError: 'The file type you uploaded is not supported' + })} + /> + )} + + {/* Still parsing: the step is handed a file that has been read, so waiting for one + is the container's business and never reaches the table. A parse that failed + gives an empty set rather than none, so the step still renders to say so. */} + {(state.status === 'MAPPING' || state.status === 'UPLOADING') && state.fileData === null && ( +
+ +
+ )} + + {(state.status === 'MAPPING' || state.status === 'UPLOADING') && state.fileData !== null && ( + setHasEdits(true)} + onDataPreviewIndexChange={(nextIndex) => { + dispatch({ + type: 'SET_DATA_PREVIEW_INDEX', + dataPreviewIndex: nextIndex + }); + }} + onFieldCreated={handleFieldCreated} + onStartOver={reset} + onUpdateMapping={handleUpdateMapping} + onUpload={importMapping => void handleUpload(importMapping)} + /> + )} + + {state.status === 'PROCESSING' && ( + handleOpenChange(false)} + onUploadAnotherFile={reset} + /> + )} + + {state.status === 'COMPLETE' && state.importResponse && ( + handleOpenChange(false)} + onReset={reset} + /> + )} + + {state.status === 'ERROR' && ( + handleOpenChange(false)} + onTryAgain={reset} + /> + )} +
+ {/* Worded and shaped as the member detail's leave guard is (member-detail.tsx:480-494), + which is the same question a screen away: Shade's DirtyConfirmDialog says Stay/Leave + and talks about saving, and neither fits a members screen or an import that has + nothing to save. Its controller is still what decides whether to ask. + + The cancel label names what the publisher goes back to, as the member detail's "Keep + editing" and the automations editor's "Keep working" do — which here is mapping. + + Raised above the import dialog it interrupts, which owns z-50. */} + + {/* Escape closes the guard and nothing else: without this it reaches the import + dialog underneath and closes the very thing the guard is asking about. */} + event.stopPropagation()}> + + {/* Not "unsaved changes": nothing here is saved or savable, so there is no + such state to be in. Nor "discard this import" — no import exists yet to + be discarded. The title names the act the buttons perform, and the line + below says what it costs without repeating it. */} + Leave without importing? + The columns you have mapped will be lost. + + + Keep mapping + + + + +
+ ); +} diff --git a/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/mapping-step.tsx b/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/mapping-step.tsx new file mode 100644 index 00000000000..d6041901aa5 --- /dev/null +++ b/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/mapping-step.tsx @@ -0,0 +1,594 @@ +import {Button, Checkbox, DialogFooter, LoadingIndicator, Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@tryghost/shade/components'; +import {CreateFieldForm} from '@/members/components/bulk-action-modals/import-members/custom-fields/create-field-form'; +import {APIError, HostLimitError, JSONError, ValidationError, getErrorMessage} from '@tryghost/admin-x-framework/errors'; +import {memberCustomFieldCsvColumns, memberCustomFieldParts, useCreateMemberCustomField} from '@tryghost/admin-x-framework/api/member-custom-fields'; +import {useHandleError} from '@tryghost/admin-x-framework/hooks'; +import {FieldPicker} from '@/members/components/bulk-action-modals/import-members/custom-fields/field-picker'; +import {LabelPicker} from '@/members/label-picker'; +import {LucideIcon, cn, formatNumber} from '@tryghost/shade/utils'; +import {MembersFieldMapping, columnsOf} from '@/members/components/bulk-action-modals/import-members/custom-fields/mapping'; +import {Fragment, useCallback, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import {type MemberCustomField, type MemberCustomFieldCsvColumn} from '@tryghost/admin-x-framework/api/member-custom-fields'; +import {type UseLabelPickerResult} from '@/members/hooks/use-label-picker'; + +interface MappingPreviewRow { + key: string; + value: string; + mapTo: string | null; +} + +// An import the table refuses to send: why, and which columns are to blame. One value rather +// than two, because a message shown with the wrong rows marked is worse than either alone. +interface IncompleteImport { + message: string; + columns: ReadonlySet; +} + +interface MappingStepProps { + status: 'MAPPING' | 'UPLOADING'; + fileData: Record[]; + mapping: MembersFieldMapping | null; + mappingError: string | null; + showMappingErrors: boolean; + dataPreviewIndex: number; + fieldMappings: {label: string; value: string}[]; + customFieldMappings: MemberCustomFieldCsvColumn[]; + labelPicker: UseLabelPickerResult; + onUpdateMapping: (from: string, to: string | null) => void; + onFieldCreated: (columnKey: string, column: string | null) => void; + onDataPreviewIndexChange: (next: number) => void; + onStartOver: () => void; + // Whether a column has been switched in or out of the import, which is the one decision + // this table keeps to itself. Everything else the publisher changes goes through + // onUpdateMapping, so between them the modal knows if there is anything to lose. + onColumnsChanged: () => void; + // Carries what the import should write, column by column. The table is the only thing + // that knows both the mapping and which columns are in the import, so it says the whole + // of it rather than handing over a correction to apply. + onUpload: (importMapping: Record) => void; +} + +// A set with one member added or removed, since both halves of the in-or-out decision below +// are held as memberships. +function toggled(set: ReadonlySet, key: string, present: boolean): ReadonlySet { + const next = new Set(set); + if (present) { + next.add(key); + } else { + next.delete(key); + } + return next; +} + +/** + * The CSV column a row should map onto, resolved from the field the server actually created + * rather than from the type that was asked for. + * + * Null for a composite, which spans several columns and so has no single answer. Which of them + * this column holds is asked in the field picker afterwards, not here: it is the same question + * as any other mapping, and the picker already knows how to ask it. + */ +function columnFor(field: MemberCustomField): string | null { + if (memberCustomFieldParts(field.type)) { + return null; + } + return memberCustomFieldCsvColumns([field])[0]?.value ?? null; +} + +export function MappingStep({ + status, + fileData, + mapping, + mappingError, + showMappingErrors, + dataPreviewIndex, + fieldMappings, + customFieldMappings, + labelPicker, + onUpdateMapping, + onFieldCreated, + onDataPreviewIndexChange, + onStartOver, + onColumnsChanged, + onUpload +}: MappingStepProps) { + // Columns switched on without a target yet, and columns switched off that still have one. + // Between them and the mapping, a column is either in the import or out of it — and what it + // was mapped to survives being switched off, because that is a different decision. + const [pendingColumns, setPendingColumns] = useState>(new Set()); + const [excludedColumns, setExcludedColumns] = useState>(new Set()); + // Raised when Import is pressed on a table that cannot be imported, and cleared by any + // answer to it. Set afresh on every press, so pressing Import again scrolls back to the + // first offender below rather than sitting inert. + const [incomplete, setIncomplete] = useState(null); + + // Each row's field picker, so a refusal can bring the column it names into view. A long + // file scrolls, and naming a column the publisher would have to go looking for is barely + // better than not naming it. + const fieldTriggers = useRef(new Map()); + + // After the refusal has rendered rather than while handling the click: the message appears + // below the table and takes its height from it, so scrolling first would aim at a table + // that is about to get shorter and leave the row half under the new bottom edge. Layout, + // not effect, so it lands before the frame the message arrives in is painted. + useLayoutEffect(() => { + const [firstUndecided] = incomplete?.columns ?? []; + if (!firstUndecided) { + return; + } + // 'nearest' scrolls only as far as it has to, leaving a column already in view where + // it was; scroll-my on the trigger keeps it off the edge it arrives at. + fieldTriggers.current.get(firstUndecided)?.scrollIntoView({ + block: 'nearest', + behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' + }); + }, [incomplete]); + + // The column whose create form is open, shown as a row beneath that column's own. Held + // here rather than by the modal, which only ever forwarded it, so it clears with the + // table on Start over instead of outliving the file it belonged to. + const [createFieldForColumn, setCreateFieldForColumn] = useState(null); + + // Which row's field picker is open, and what it is filtered by. Held here rather than in + // each picker because creating a composite has to reopen the picker it was launched from, + // filtered to the field just made — and only one can be open at a time regardless. + const [openPicker, setOpenPicker] = useState<{columnKey: string; search: string} | null>(null); + + // A row other than the one being answered is faded out, and is inert to match: reading as + // disabled while still taking clicks is a mixed signal, and it is also what kept the create + // form reachable for unmounting while its own request was still in flight. + // The create lives here rather than in the form because its outcome is this step's + // business: a scalar finishes a row's mapping, a composite reopens the picker, and a + // refusal has to keep the form on screen to be shown in. The form asks; this answers. + const {mutateAsync: createField, isPending: isCreating} = useCreateMemberCustomField(); + const [nameError, setNameError] = useState(null); + const [saveError, setSaveError] = useState(null); + const [canRetry, setCanRetry] = useState(true); + const handleError = useHandleError(); + + const clearCreateErrors = useCallback(() => { + setNameError(null); + setSaveError(null); + setCanRetry(true); + }, []); + + const reportCreateFailure = (error: unknown) => { + const apiError = error instanceof JSONError ? error.data?.errors?.[0] : null; + + // A refusal the publisher can answer by typing belongs on the input they would change. + if (error instanceof ValidationError && apiError?.property === 'name') { + setNameError(getErrorMessage(error, 'Invalid name')); + return; + } + + // The server's own sentence wherever there is one — for the site's field ceiling it is + // the only thing that explains the refusal. The fallback covers the errors carrying no + // API body at all (maintenance, timeout, unreachable), whose class message is the text. + setSaveError(getErrorMessage(error, error instanceof APIError ? error.message : 'Could not create the custom field, please try again.')); + // Nothing they can type changes a ceiling or a permission. + setCanRetry(!(error instanceof HostLimitError) && apiError?.type !== 'NoPermissionError'); + + // The ceiling is an expected answer rather than a fault; everything else reaching here + // (a 500, a timeout, an expired session) should be visible to us. No toast: the form is + // still on screen saying it, an inch above the button they pressed. + if (!(error instanceof HostLimitError)) { + handleError(error, {withToast: false}); + } + }; + + // The whole create, from asking to what its answer means for the row. Named rather than + // inlined into the form's prop: it is the step's business end, not markup. + const createFieldForRow = async (rowKey: string, name: string, type: MemberCustomField['type']) => { + clearCreateErrors(); + + let field; + try { + const response = await createField({name, type}); + field = response.members_custom_fields?.[0]; + } catch (error) { + reportCreateFailure(error); + return; + } + + if (!field) { + // A success carrying nothing. The field may well have been made, so saying it could + // not be would have them make a second one; this says what is true and leaves the + // row to be mapped by hand from the list. + setSaveError('The field was created, but this column could not be mapped to it. Choose it from the list.'); + setCanRetry(false); + handleError(new Error('Custom field create returned no field')); + return; + } + + const column = columnFor(field); + onFieldCreated(rowKey, column); + setCreateFieldForColumn(null); + // Creating a field for a column the import was refused over is an answer to that + // refusal, so it clears with the others. A composite is not answered yet — its part is + // still to choose — but the message named a column, not a part, and leaving it up would + // keep pointing at a row that has moved on. + setIncomplete(null); + + if (column) { + // A scalar is fully mapped now, so the form just goes; focus returns to the row + // rather than to the body. + fieldTriggers.current.get(rowKey)?.focus(); + return; + } + // A composite spans several columns, so which of them this one holds is still open. + // Asked in the picker, filtered to the new field: the same question as any other + // mapping, rather than a third control on the form for the one type that needs it. + showPicker(rowKey, field.name); + }; + + const isDimmed = (row: MappingPreviewRow) => Boolean(createFieldForColumn) && createFieldForColumn !== row.key; + // Locked only while a create is actually in flight — long enough that the form cannot be + // taken away before its own request settles, and no longer, so moving on to another column + // still puts a form the publisher has finished with away. + const isRowLocked = (_row: MappingPreviewRow) => status === 'UPLOADING' || isCreating; + + // Opening a row's picker puts the create form away: the publisher has moved on to another + // decision, and leaving it open would strand a half-filled form under someone else's + // dropdown. The form's own selects live inside it and are unaffected. + const showPicker = (columnKey: string, search: string) => { + // The refusal belonged to the form being put away. Left standing it would greet the + // next form, under an input nobody has touched, and a ceiling refusal would arrive + // with its Save already disabled. + clearCreateErrors(); + setCreateFieldForColumn(null); + setOpenPicker({columnKey, search}); + }; + + const showCreateForm = (columnKey: string) => { + clearCreateErrors(); + setCreateFieldForColumn(columnKey); + }; + + // A target belongs to one column, so mapping it here takes it from whichever column held + // it. That column keeps its place in the import with the field still to answer: it did not + // ask to lose anything, and should not silently drop out of the import with nothing said. + const claimTarget = (row: MappingPreviewRow, target: string) => { + const previousHolder = mapping?.getKeyByValue(target); + if (previousHolder && previousHolder !== row.key) { + setPendingColumns(previous => toggled(previous, previousHolder, true)); + } + onUpdateMapping(row.key, target); + }; + + const isImported = (row: MappingPreviewRow) => !excludedColumns.has(row.key) + && (Boolean(row.mapTo) || pendingColumns.has(row.key)); + + // Switching a column off leaves its field alone: excluding a column from this import and + // choosing what it holds are different answers, and switching it back on should not make + // the publisher pick again. + const setImported = (row: MappingPreviewRow, imported: boolean) => { + setIncomplete(null); + onColumnsChanged(); + setExcludedColumns(previous => toggled(previous, row.key, !imported)); + setPendingColumns(previous => toggled(previous, row.key, imported && !row.mapTo)); + if (!imported && createFieldForColumn === row.key) { + setCreateFieldForColumn(null); + } + }; + + // Both refusals read the same way, and the mapping error wins: a file with no email column + // cannot be imported at all, whereas an undecided column is one answer away. + // Gated as the shipped step gates it, deliberately. The gate is dead — it only opens when + // Import is pressed, and that button is disabled in the one state that sets a mapping error + // — but opening it changes nothing a publisher can reach: papaparse does not throw, so the + // parse and read messages behind it cannot fire, and the empty-file case the table already + // reports in its own body. Left matching the shipped import until csv.ts stops discarding + // papaparse's errors, which is what would make any of these reachable. See BER ticket. + const visibleError = (showMappingErrors && mappingError) || incomplete?.message; + // A refusal that names columns is marked on their own selects, so the table is left alone: + // reddening the whole of it would point at every row for the sake of one. Every other + // refusal is about the file rather than a row anyone can be sent to, so the table carries it. + const tableError = Boolean(visibleError) && !incomplete?.columns.size; + + const columnCount = 4; + + // Every column the file has, not the ones the previewed row happens to carry. Papaparse + // omits keys for a row with fewer cells than the header, so reading columns off a single row + // lets a ragged CSV hide one from the table entirely — and a column the mapping never names + // is carried through by the importer rather than left out, which is the opposite of what a + // publisher who never saw it would expect. The preview index chooses which values are shown; + // it must never decide which columns exist. + // Derived rather than passed: all three are facts about the file and where the preview + // sits in it, both of which are already here. Sent down as well, they could only ever + // differ from their source by a bug. + const membersCount = fileData.length; + const hasPrevRecord = dataPreviewIndex > 0; + const hasNextRecord = Boolean(fileData[dataPreviewIndex + 1]); + + const columnKeys = useMemo(() => columnsOf(fileData), [fileData]); + + const currentlyDisplayedData: MappingPreviewRow[] = mapping + ? columnKeys.map(key => ({ + key, + value: fileData[dataPreviewIndex]?.[key] ?? '', + mapTo: mapping.get(key) + })) + : []; + + // What this import writes: one entry per column in the file — the field it fills, empty for + // a column left out, or null for a column in the import with no field chosen yet. + // Everything asking what is being imported reads this one value, so the checks below and + // the request itself cannot disagree. + // + // Empty rather than omitted, because the importer carries a column the mapping does not + // name through under its own header — which is how an unnamed custom_fields.* column + // survives to be read. Leaving a column out of the mapping is the opposite of leaving it + // out of the import. + const importMapping: Record = Object.fromEntries( + currentlyDisplayedData.map(row => [row.key, isImported(row) ? row.mapTo : '']) + ); + + // What refuses this import, read off the mapping actually being sent. With custom fields + // on it is answered here, because this is the only place that knows both the mapping and + // which columns are in the import. A file-level problem (empty, unreadable) is the modal's + // to report and it refuses the upload itself, so nothing about the mapping is worth saying + // ahead of it. + // + // Off, both checks belong to the modal exactly as they always did. + const importRefusal = (): IncompleteImport | null => { + if (mappingError) { + return null; + } + + if (!Object.values(importMapping).includes('email')) { + return { + message: 'Please map "Email" to one of the fields in the CSV, and make sure it is selected.', + columns: new Set() + }; + } + + // Null is a column in the import with nothing chosen for it, which is the one state + // the table can hold that cannot be sent. + const undecided = Object.keys(importMapping).filter(column => importMapping[column] === null); + if (undecided.length === 0) { + return null; + } + + return { + message: `Choose a field for ${undecided.map(column => `"${column}"`).join(', ')}, or deselect ${undecided.length === 1 ? 'it' : 'them'}.`, + columns: new Set(undecided) + }; + }; + + const handleImport = () => { + const refusal = importRefusal(); + if (refusal) { + setIncomplete(refusal); + return; + } + onUpload(importMapping); + }; + + return ( + <> + {/* A flex column inside the height-bounded dialog: the table box below grows into + whatever room is left, so a short file keeps a small modal and a long one + fills the viewport rather than scrolling inside a fixed 400px. */} +
+ {( + <> +
+
+
+ + + + Import this column + + Field + +
+ + Sample data (#{formatNumber(dataPreviewIndex + 1)}) + +
+ + +
+
+
+ Import as +
+
+ + {currentlyDisplayedData.length > 0 ? ( + currentlyDisplayedData.map(row => ( + + {/* While a create form is open, the rows it doesn't concern step back, so + the pair being worked on reads as the foreground without anything being + covered. Opacity on the rows themselves, not a layer over them. */} + {/* A row that is out of the import is tinted rather than + faded. Alpha on the token, not opacity on the row: at + full strength it read as heavier than the rows that + matter, and fading the row instead took the field + control's border down with it. Fading is left for the + create form, where dropping everything back is the + point. + + No row hover: Shade paints it onto each cell through + group-hover, so it is turned off there. Nothing here + responds to a row being pointed at — the controls in + it have their own hover — and the hover token is + lighter than the tint above, so on a row out of the + import it read as the row coming back to life. */} + td]:group-hover:bg-transparent', + !isImported(row) && 'bg-muted/50', + isDimmed(row) && 'opacity-40' + )}> + + setImported(row, checked === true)} + /> + + {row.key} + + {row.value || '\u00A0'} + + + {/* Hidden rather than unmounted for a column out of the + import: the control it would offer cannot be used, and a + disabled one has to be styled, faded and explained. + visibility keeps its box, so the row does not change + height as columns go in and out, and takes it out of the + tab order and out of reach of the pointer without a + second mechanism. The mapping is not lost either way — + it comes back with the row when it is selected again. */} + { + if (node) { + fieldTriggers.current.set(row.key, node); + } else { + fieldTriggers.current.delete(row.key); + } + }} + value={row.mapTo} + onCreateField={() => showCreateForm(row.key)} + onOpenChange={next => (next ? showPicker(row.key, '') : setOpenPicker(null))} + onSearchChange={search => setOpenPicker({columnKey: row.key, search})} + onSelect={(target) => { + setIncomplete(null); + claimTarget(row, target); + }} + /> + + + {/* The create form is a row of the table rather than a layer over it, so it + scrolls with the rows, needs no anchoring or collision handling, and can't + be scrolled out from under itself. It follows the row it belongs to, which + keeps that column's name and sample value in view while deciding. */} + {createFieldForColumn === row.key && ( + + + {/* Raised rather than floating: it reads as a surface above the table + while remaining a row of it, so nothing has to be anchored to the + row or repositioned when the table scrolls. */} +
+ { + clearCreateErrors(); + setCreateFieldForColumn(null); + // Unmounting the form drops focus to the body, leaving a + // keyboard publisher to tab back from the top of the table. + fieldTriggers.current.get(row.key)?.focus(); + }} + onEdit={clearCreateErrors} + onSubmit={(name, type) => void createFieldForRow(row.key, name, type)} + /> +
+
+
+ )} +
+ )) + ) : ( + + + No data found in the uploaded CSV. + + + )} +
+
+
+
+ + {visibleError &&

{visibleError}

} + + {membersCount > 0 && ( +

+ If an email address in your CSV matches an existing member, they will be updated with the mapped values. +

+ )} + +
+ + +
+ + )} +
+ + + + + + + ); +} diff --git a/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/mapping.test.ts b/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/mapping.test.ts new file mode 100644 index 00000000000..c2b6a535976 --- /dev/null +++ b/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/mapping.test.ts @@ -0,0 +1,78 @@ +import {detectFieldTypes, getFieldMappings, suggestedFieldName} from '@/members/components/bulk-action-modals/import-members/custom-fields/mapping'; +import {describe, expect, it} from 'vitest'; +import {type MemberCustomFieldCsvColumn} from '@tryghost/admin-x-framework/api/member-custom-fields'; + +// Only what this modal's mapping module adds. Column detection, sampling, the mapping class and +// the error wording are the shared module's, and are covered by its own tests — the custom field +// cases below stay here because they are the reason this modal narrows what it offers. +describe('custom fields mapping helpers', () => { + + it('adds tier as an available field mapping when enabled', () => { + const fieldMappings = getFieldMappings({importMemberTier: true}); + + expect(fieldMappings).toContainEqual({label: 'Tier', value: 'import_tier'}); + }); + + const customFieldColumns: MemberCustomFieldCsvColumn[] = [ + {label: 'Nickname', value: 'custom_fields.nickname', type: 'short_text'}, + {label: 'Shipping Address (Line 1)', value: 'custom_fields.shipping_address.line1', type: 'address'}, + {label: 'Shipping Address (First name)', value: 'custom_fields.shipping_address.first_name', type: 'address'} + ]; + + it('auto-detects a custom field column by its namespaced header', () => { + const mapping = detectFieldTypes([ + {email: 'user@example.com', 'custom_fields.nickname': 'Bex'} + ], {customFieldColumns}); + + expect(mapping['custom_fields.nickname']).toBe('custom_fields.nickname'); + }); + + // The /name/i heuristic must not claim a custom field column containing "name". + it('does not map a name-like custom field column to the member name', () => { + const mapping = detectFieldTypes([ + {email: 'user@example.com', 'custom_fields.shipping_address.first_name': 'Bex'} + ], {customFieldColumns}); + + expect(mapping.name).toBeUndefined(); + expect(mapping['custom_fields.shipping_address.first_name']).toBe('custom_fields.shipping_address.first_name'); + }); + + // Even a namespaced column with no offered target (its field archived after export, + // or a hand-built file) must not be claimed as the member name. + it('does not map a name-like namespaced column that has no offered target', () => { + const mapping = detectFieldTypes([ + {email: 'user@example.com', 'custom_fields.former_field.last_name': 'Bex'} + ]); + + expect(mapping.name).toBeUndefined(); + }); + + // An email-typed custom field must not be bound to the member email (and thereby + // dropped) just because its values look like email addresses. + it('does not bind an email-valued custom field column to the member email', () => { + const mapping = detectFieldTypes([ + {'custom_fields.contact_email': 'contact@example.com', email: 'member@example.com'} + ], {customFieldColumns: [{label: 'Contact email', value: 'custom_fields.contact_email', type: 'short_text'}]}); + + expect(mapping.email).toBe('email'); + expect(mapping['custom_fields.contact_email']).toBe('custom_fields.contact_email'); + }); + + describe('suggestedFieldName', () => { + it('strips the namespace and un-slugs a column from a Ghost export', () => { + expect(suggestedFieldName('custom_fields.nickname')).toBe('Nickname'); + expect(suggestedFieldName('custom_fields.favorite-animal')).toBe('Favorite animal'); + }); + + // The part is a separate question the form asks, so it must not end up in the name. + it('names the field, not the part, for a composite column', () => { + expect(suggestedFieldName('custom_fields.home-address.city')).toBe('Home address'); + expect(suggestedFieldName('custom_fields.home-address.postal_code')).toBe('Home address'); + }); + + it('keeps a column from anywhere else as the publisher wrote it', () => { + expect(suggestedFieldName('Job Title')).toBe('Job Title'); + expect(suggestedFieldName('job_title')).toBe('Job title'); + }); + }); +}); diff --git a/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/mapping.ts b/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/mapping.ts new file mode 100644 index 00000000000..470e9120fb4 --- /dev/null +++ b/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/mapping.ts @@ -0,0 +1,43 @@ +import {FIELD_MAPPINGS, IMPORT_TIER_FIELD_MAPPING} from '@/members/components/bulk-action-modals/import-members/mapping'; +import {isCustomFieldColumn} from '@tryghost/admin-x-framework/api/member-custom-fields'; + +/** + * What this modal adds to the shipped mapping vocabulary, and nothing it already says. + * + * Column detection, sampling, the mapping class and the error wording are the same questions + * with the same answers on both sides of the flag, so they are re-exported rather than copied: + * a fix to one is a fix to both, and there is no version of them that can drift. + * + * The class in particular has to come from there rather than be redeclared — it carries a + * private field, so a second declaration is a distinct type to TypeScript however identical + * its shape, and this modal could not hand its own mapping to the reducer it shares. + */ +export {MembersFieldMapping, columnsOf, detectFieldTypes, formatImportError, sampleData} from '@/members/components/bulk-action-modals/import-members/mapping'; + +// The native targets only. Custom fields are offered from their own list, chosen by kind, so +// they are not folded in here — auto-detection still sees both, through the shared detection. +export function getFieldMappings({importMemberTier = false}: {importMemberTier?: boolean} = {}) { + return [ + ...FIELD_MAPPINGS, + ...(importMemberTier ? [IMPORT_TIER_FIELD_MAPPING] : []) + ]; +} + +/** + * The field name to suggest for a column no defined field matches. + * + * A namespaced column comes from a Ghost export, so it carries a namespace that is noise + * to a publisher and a key that was machine-minted from a name in the first place: both + * are stripped back towards what someone would have typed. A column from anywhere else is + * already the publisher's own wording, so only its separators and first letter are + * touched. It is a starting point either way, and the form lets them edit it. + */ +export function suggestedFieldName(column: string): string { + // A custom field column is `custom_fields.` or `custom_fields..`. The name + // being suggested is the field's, so the part is dropped: `custom_fields.home-address.city` + // names a field called "Home address" whose City part this column holds, and the part is + // asked for separately. A bare `custom_fields` column has no key, so it suggests the namespace itself. + const segments = isCustomFieldColumn(column) ? column.split('.').slice(1, 2) : [column]; + const words = (segments[0] ?? column).replace(/[._-]+/g, ' ').trim(); + return words.charAt(0).toUpperCase() + words.slice(1); +} diff --git a/apps/admin/src/members/components/bulk-action-modals/import-members/mapping.test.ts b/apps/admin/src/members/components/bulk-action-modals/import-members/mapping.test.ts index acb54cef345..bc0d3186ecd 100644 --- a/apps/admin/src/members/components/bulk-action-modals/import-members/mapping.test.ts +++ b/apps/admin/src/members/components/bulk-action-modals/import-members/mapping.test.ts @@ -1,7 +1,30 @@ -import {MembersFieldMapping, detectFieldTypes, formatImportError, getFieldMappings, sampleData} from '@/members/components/bulk-action-modals/import-members/mapping'; +import {MembersFieldMapping, columnsOf, detectFieldTypes, formatImportError, getFieldMappings, sampleData} from '@/members/components/bulk-action-modals/import-members/mapping'; import {describe, expect, it} from 'vitest'; +import type {MemberCustomFieldCsvColumn} from '@tryghost/admin-x-framework/api/member-custom-fields'; describe('mapping helpers', () => { + // Papaparse omits keys for a row with fewer cells than the header rather than padding it, + // so reading the first row alone loses every column a short first row does not reach — and + // a column nothing names is carried through by the importer, imported without ever being + // shown to the publisher. + it('finds every column even when the first row is short', () => { + const ragged = [ + {email: 'one@example.com'}, + {email: 'two@example.com', name: 'Two', note: 'hello'} + ] as unknown as Record[]; + + expect(columnsOf(ragged)).toEqual(['email', 'name', 'note']); + expect(Object.keys(detectFieldTypes(ragged))).toContain('note'); + }); + + it('does not offer papaparse overflow as a column', () => { + const overflowing = [ + {email: 'one@example.com', __parsed_extra: 'spare'} + ] as unknown as Record[]; + + expect(columnsOf(overflowing)).toEqual(['email']); + }); + it('samples non-empty entries per column', () => { const sampled = sampleData([ {email: '', name: 'A'}, @@ -89,17 +112,17 @@ describe('mapping helpers', () => { expect(mapping.gift_id).toBe('gift_id'); }); - const customFieldColumns = [ - {label: 'Nickname', value: 'custom_fields.nickname'}, - {label: 'Shipping Address (Line 1)', value: 'custom_fields.shipping_address.line1'}, - {label: 'Shipping Address (First name)', value: 'custom_fields.shipping_address.first_name'} + const customFieldColumns: MemberCustomFieldCsvColumn[] = [ + {label: 'Nickname', value: 'custom_fields.nickname', type: 'short_text'}, + {label: 'Shipping Address (Line 1)', value: 'custom_fields.shipping_address.line1', type: 'address'}, + {label: 'Shipping Address (First name)', value: 'custom_fields.shipping_address.first_name', type: 'address'} ]; it('offers the custom field columns as mapping targets', () => { const mappings = getFieldMappings({customFieldColumns}); - expect(mappings).toContainEqual({label: 'Nickname', value: 'custom_fields.nickname'}); - expect(mappings).toContainEqual({label: 'Shipping Address (Line 1)', value: 'custom_fields.shipping_address.line1'}); + expect(mappings).toContainEqual(expect.objectContaining({label: 'Nickname', value: 'custom_fields.nickname'})); + expect(mappings).toContainEqual(expect.objectContaining({label: 'Shipping Address (Line 1)', value: 'custom_fields.shipping_address.line1'})); }); it('auto-detects a custom field column by its namespaced header', () => { @@ -135,7 +158,7 @@ describe('mapping helpers', () => { it('does not bind an email-valued custom field column to the member email', () => { const mapping = detectFieldTypes([ {'custom_fields.contact_email': 'contact@example.com', email: 'member@example.com'} - ], {customFieldColumns: [{label: 'Contact email', value: 'custom_fields.contact_email'}]}); + ], {customFieldColumns: [{label: 'Contact email', value: 'custom_fields.contact_email', type: 'short_text'}]}); expect(mapping.email).toBe('email'); expect(mapping['custom_fields.contact_email']).toBe('custom_fields.contact_email'); diff --git a/apps/admin/src/members/components/bulk-action-modals/import-members/mapping.ts b/apps/admin/src/members/components/bulk-action-modals/import-members/mapping.ts index a873bcd6d56..0876894253f 100644 --- a/apps/admin/src/members/components/bulk-action-modals/import-members/mapping.ts +++ b/apps/admin/src/members/components/bulk-action-modals/import-members/mapping.ts @@ -1,12 +1,10 @@ -import {isCustomFieldColumn} from '@tryghost/admin-x-framework/api/member-custom-fields'; - -type CustomFieldColumn = {label: string; value: string}; +import {isCustomFieldColumn, type MemberCustomFieldCsvColumn} from '@tryghost/admin-x-framework/api/member-custom-fields'; type FieldMappingOptions = { importMemberTier?: boolean; // Custom field CSV columns offered as mapping targets (see memberCustomFieldCsvColumns); // empty when the feature is off. - customFieldColumns?: CustomFieldColumn[]; + customFieldColumns?: MemberCustomFieldCsvColumn[]; }; export const FIELD_MAPPINGS = [ @@ -21,7 +19,7 @@ export const FIELD_MAPPINGS = [ {label: 'Gift ID', value: 'gift_id'} ]; -const IMPORT_TIER_FIELD_MAPPING = {label: 'Tier', value: 'import_tier'}; +export const IMPORT_TIER_FIELD_MAPPING = {label: 'Tier', value: 'import_tier'}; const SUPPORTED_TYPES = [ 'email', @@ -105,13 +103,37 @@ export class MembersFieldMapping { * Locate 10 non-empty cells from the start/middle(ish)/end of each column (30 non-empty values in total). * If the data contains 30 rows or fewer, all rows should be validated. */ +/** + * Every column the file has. + * + * Papaparse omits keys for a row carrying fewer cells than the header rather than padding it + * out, so the first row is only the full set for a rectangular file. A hand-edited or partly + * exported CSV whose first row is short would otherwise have its remaining columns go + * undetected — and a column nothing names is carried through by the importer, so it would be + * imported without ever appearing to the publisher. + * + * `__parsed_extra` is where papaparse collects the overflow of a row with more cells than + * headers. It is not a column and no mapping can name it. + */ +export function columnsOf(data: Record[]): string[] { + const keys = new Set(); + for (const row of data) { + for (const key of Object.keys(row)) { + if (key !== '__parsed_extra') { + keys.add(key); + } + } + } + return [...keys]; +} + export function sampleData(data: Record[], validationSampleSize = 30): Record[] { if (!data || data.length <= validationSampleSize) { return data; } const validatedSet: Record[] = []; - const sampleKeys = Object.keys(data[0]); + const sampleKeys = columnsOf(data); sampleKeys.forEach((key) => { const nonEmptyKeyEntries = data.filter(entry => entry[key] && entry[key].trim() !== ''); @@ -159,7 +181,7 @@ export function detectFieldTypes(data: Record[], options: FieldM // we only checked sampled entries. A custom field column auto-maps to itself here; // isCustomFieldColumn holds it apart from the fuzzy core-field heuristics below. if (data.length > 0) { - for (const key of Object.keys(data[0])) { + for (const key of columnsOf(data)) { if (!mapping.name && /name/i.test(key) && !isCustomFieldColumn(key)) { mapping.name = key; continue; diff --git a/apps/admin/src/members/components/bulk-action-modals/index.ts b/apps/admin/src/members/components/bulk-action-modals/index.ts index 9f5e1b14c6b..ba8b97a9c97 100644 --- a/apps/admin/src/members/components/bulk-action-modals/index.ts +++ b/apps/admin/src/members/components/bulk-action-modals/index.ts @@ -2,4 +2,6 @@ export {AddLabelModal} from './add-label-modal'; export {RemoveLabelModal} from './remove-label-modal'; export {UnsubscribeModal} from './unsubscribe-modal'; export {DeleteModal} from './delete-modal'; -export {ImportMembersModal} from './import-members-modal'; +// The gate picks between the import as it shipped and the custom fields experience, so +// members-actions goes on importing one name and knowing nothing about the flag. +export {ImportMembersGate as ImportMembersModal} from './import-members-gate'; diff --git a/apps/admin/src/members/detail/member-activity-feed.tsx b/apps/admin/src/members/detail/member-activity-feed.tsx index c2bc2329b06..9ecf3445a53 100644 --- a/apps/admin/src/members/detail/member-activity-feed.tsx +++ b/apps/admin/src/members/detail/member-activity-feed.tsx @@ -185,8 +185,8 @@ const MemberActivityFeed: React.FC = ({memberId, hasMul // `EmptyIndicator` so the parity assertion for that string holds. if (!memberId) { return ( -
-

Activity

+
+

Activity

{/* Same wrapper padding as the Subscriptions empty state (`member-subscriptions-section.tsx`) so both cards line @@ -204,8 +204,8 @@ const MemberActivityFeed: React.FC = ({memberId, hasMul } return ( -
-

Activity

+
+

Activity

{isLoading ? ( diff --git a/apps/admin/src/members/detail/member-detail-custom-fields.acceptance.test.tsx b/apps/admin/src/members/detail/member-detail-custom-fields.acceptance.test.tsx index 34cc4c5959d..a5d3516a430 100644 --- a/apps/admin/src/members/detail/member-detail-custom-fields.acceptance.test.tsx +++ b/apps/admin/src/members/detail/member-detail-custom-fields.acceptance.test.tsx @@ -3,7 +3,7 @@ import {page, userEvent} from 'vitest/browser'; import {fakeAdminEndpoint, fakeMembers, member, renderAdminApp, type Member} from '@test-utils/acceptance'; -const FLAGS = {labs: {memberDetailsReact: true, membersCustomFields: true}}; +const FLAGS = {labs: {membersCustomFields: true}}; const FIELDS = [ {key: 'job_title', name: 'Job title', type: 'short_text', created_at: '2026-07-14T00:00:00.000Z', updated_at: null}, diff --git a/apps/admin/src/members/detail/member-detail-leave-guard.acceptance.test.tsx b/apps/admin/src/members/detail/member-detail-leave-guard.acceptance.test.tsx index 5a29e2aaf8e..9f375d2b909 100644 --- a/apps/admin/src/members/detail/member-detail-leave-guard.acceptance.test.tsx +++ b/apps/admin/src/members/detail/member-detail-leave-guard.acceptance.test.tsx @@ -3,8 +3,6 @@ import {page} from 'vitest/browser'; import {fakeAdminEndpoint, fakeMembers, member, renderAdminApp, type Member} from '@test-utils/acceptance'; -const FLAGS = {labs: {memberDetailsReact: true}}; - function fakeMemberDetailWorld(m: Member) { fakeMembers([m]); fakeAdminEndpoint('GET', new RegExp(`^/members/${m.id}/`), {members: [m]}); @@ -28,7 +26,7 @@ describe('Member detail leave guard', () => { it('guards leaving via the breadcrumb (react-router link) with unsaved edits', async () => { const m = member({name: 'Ada Lovelace'}); fakeMemberDetailWorld(m); - await renderAdminApp(`/members/${m.id}`, FLAGS); + await renderAdminApp(`/members/${m.id}`); await page.getByLabelText('Name').fill('Ada B'); await page.getByTestId('member-detail').getByRole('link', {name: 'Members'}).click(); @@ -39,7 +37,7 @@ describe('Member detail leave guard', () => { it('guards leaving via the sidebar (native hash anchor) with unsaved edits', async () => { const m = member({name: 'Ada Lovelace'}); fakeMemberDetailWorld(m); - await renderAdminApp(`/members/${m.id}`, FLAGS); + await renderAdminApp(`/members/${m.id}`); await page.getByLabelText('Name').fill('Ada B'); await page.getByRole('link', {name: 'Members'}).first().click(); @@ -50,7 +48,7 @@ describe('Member detail leave guard', () => { it('keeps editing on cancel and completes the navigation on Leave', async () => { const m = member({name: 'Ada Lovelace'}); fakeMemberDetailWorld(m); - await renderAdminApp(`/members/${m.id}`, FLAGS); + await renderAdminApp(`/members/${m.id}`); await page.getByLabelText('Name').fill('Ada B'); await page.getByRole('link', {name: 'Members'}).first().click(); diff --git a/apps/admin/src/members/detail/member-detail.tsx b/apps/admin/src/members/detail/member-detail.tsx index 0178b12074c..305e33d8a95 100644 --- a/apps/admin/src/members/detail/member-detail.tsx +++ b/apps/admin/src/members/detail/member-detail.tsx @@ -284,9 +284,9 @@ const MemberDetailPage: React.FC = ({paidMembersEnabled, const blocker = useBlocker(({currentLocation, nextLocation}) => !bypassGuardRef.current && hasUnsavedChanges && currentLocation.pathname !== nextLocation.pathname); // Native `` navigations (the sidebar, links into Ember // routes) never reach the react-router blocker above — see the hook. - // Ember's own guard (`trailing-hash.js`) can't cover this screen either: - // with `memberDetailsReact` on, the Ember member route aborts and never - // registers into the `unsaved-changes` service. + // Ember's own guard (`trailing-hash.js`) can't cover this screen either, + // since the screen has no Ember route to register into the + // `unsaved-changes` service. const anchorGuard = useHashLinkNavigationGuard(hasUnsavedChanges); const isBlocked = blocker.state === 'blocked' || anchorGuard.isBlocked; diff --git a/apps/admin/src/members/hooks/use-member-views.ts b/apps/admin/src/members/hooks/use-member-views.ts index 1c9c36cf89f..f4930c6626a 100644 --- a/apps/admin/src/members/hooks/use-member-views.ts +++ b/apps/admin/src/members/hooks/use-member-views.ts @@ -5,7 +5,7 @@ import { buildViewsForSave, parseSharedViewsJSON } from '@/members/member-views'; -import {getSettingValue, useBrowseSettings, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; +import {type SettingsResponseType, getSettingValue, useBrowseSettings, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {parseAllSharedViewsJSON} from '@/members/shared-views'; import {useCallback, useEffect, useMemo, useRef} from 'react'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; @@ -21,7 +21,7 @@ export type {MemberView, SharedViewsParseResult} from '../member-views'; const SHARED_VIEWS_INVALID_ERROR = 'Cannot modify saved views because shared_views is invalid'; -function getSharedViewsJSON(settingsData: {settings: Array<{key: string; value: string | boolean | null}>} | undefined): string { +function getSharedViewsJSON(settingsData: SettingsResponseType | undefined): string { return getSettingValue(settingsData?.settings ?? null, 'shared_views') ?? '[]'; } diff --git a/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx b/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx new file mode 100644 index 00000000000..ef945712c4a --- /dev/null +++ b/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx @@ -0,0 +1,558 @@ +import {describe, expect, it} from 'vitest'; +import {page, userEvent} from 'vitest/browser'; + +import {fakeAdminEndpoint, fakeMembers, member, renderAdminApp} from '@test-utils/acceptance'; +import {membersScreen} from './members.screen'; + +const FLAGS = {labs: {membersCustomFields: true}}; + +// A `nickname` column no defined field matches, alongside the columns auto-detection claims. +// `name` is present deliberately: it takes the /name/i heuristic, which would otherwise map +// `nickname` to the member's name and leave nothing unmatched to create. +const CSV = 'email,name,nickname,city,postcode\nada@example.com,Ada Lovelace,Countess,London,E1 6AN\n'; + +// A hand-edited or partially-exported file: row 1 carries fewer cells than the header, which +// papaparse reports as a row with fewer keys rather than padding it out. +const RAGGED_CSV = 'email,name,note\nada@example.com\ngrace@example.com,Grace Hopper,Hi\n'; + +// A Ghost export re-imported somewhere its field no longer exists: archived since, or a +// different site. The header says what the column is even though nothing matches it. +const EXPORTED_CSV = 'email,custom_fields.nickname\nada@example.com,Countess\n'; + +/** + * The world the import modal reads: no custom fields defined yet, a create that mints a + * key from the name the way the service does, and a browse reflecting what has been + * created, so the picker behaves as it would against a real site. + */ +function fakeCustomFieldsWorld() { + const fields: Array> = []; + fakeMembers([member({name: 'Ada Lovelace'})]); + fakeAdminEndpoint('GET', '/members/custom_fields/', () => ({members_custom_fields: fields})); + const uploadApi = fakeAdminEndpoint('POST', '/members/upload/', { + meta: {stats: {imported: 1, invalid: []}, import_label: {name: 'Import', slug: 'import'}} + }); + const createApi = fakeAdminEndpoint('POST', '/members/custom_fields/', ({body}) => { + const [input] = (body as {members_custom_fields: Array<{name: string; type: string}>}).members_custom_fields; + const field = { + key: input.name.trim().toLowerCase().replace(/\s+/g, '-'), + name: input.name.trim(), + type: input.type, + status: 'active', + created_at: '2026-08-05T00:00:00.000Z', + updated_at: null + }; + fields.push(field); + return {members_custom_fields: [field]}; + }); + return {createApi, uploadApi}; +} + +/** + * The mapping as it went over the wire: the upload is multipart, carrying one + * `mapping[]` field per column the request names. + */ +function sentMapping(body: unknown): Record { + return Object.fromEntries( + Object.entries(body as Record) + .filter(([key]) => key.startsWith('mapping[')) + .map(([key, value]) => [key.slice('mapping['.length, -1), value]) + ); +} + +async function openMappingStep(csv: string = CSV) { + await membersScreen.openActionsMenu(); + await membersScreen.menuItem(/Import members/).click(); + + const dropzone = page.getByRole('button', {name: /select or drop a csv file/i}); + await expect.element(dropzone).toBeVisible(); + const input = dropzone.element().querySelector('input[type=file]'); + if (!(input instanceof HTMLInputElement)) { + throw new Error('CSV upload input was not rendered'); + } + await page.elementLocator(input).upload(new File([csv], 'members.csv', {type: 'text/csv'})); +} + +const fieldSelect = (column: string) => page.getByRole('combobox', {name: `Field for ${column}`}); +const createForm = () => page.getByTestId('import-create-custom-field'); +const importToggle = (column: string) => page.getByRole('checkbox', {name: `Import ${column}`}); + +async function openCreateForm(column: string) { + await importToggle(column).click(); + await fieldSelect(column).click(); + await page.getByRole('option', {name: 'Add custom field'}).click(); +} + +describe('Import members custom fields', () => { + it('creates a custom field for an unmatched column and maps the column onto it', async () => { + const {createApi} = fakeCustomFieldsWorld(); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + + await importToggle('nickname').click(); + await expect.element(fieldSelect('nickname')).toHaveTextContent('Select field'); + + await fieldSelect('nickname').click(); + await page.getByRole('option', {name: 'Add custom field'}).click(); + + const dialog = page.getByTestId('import-create-custom-field'); + await expect.element(dialog).toBeVisible(); + // Suggested from the column, so the common case is nothing to type — and focused, so + // the uncommon case is typing over it. The picker is a modal popover whose focus trap + // outlives its exit animation, and has twice pulled the focus back out of this form. + await expect.element(dialog.getByLabelText('Name')).toHaveValue('Nickname'); + await expect.element(dialog.getByLabelText('Name')).toHaveFocus(); + await dialog.getByRole('button', {name: 'Save'}).click(); + + await expect.poll(() => createApi.lastRequest?.body).toEqual({ + members_custom_fields: [{name: 'Nickname', type: 'short_text'}] + }); + + // The row carries the new field immediately, from the create response: the browse query + // is invalidated but not awaited, so waiting for the refetch would leave the picker + // blank in between. And the trigger names the list it came from, so a custom field + // called "Name" could not be read as the member's own name once the list is closed. + await expect.element(fieldSelect('nickname')).toHaveTextContent('Nickname'); + await expect.element(fieldSelect('nickname')).toHaveTextContent('Custom field'); + }); + + it('puts the create form away when another picker is opened', async () => { + fakeCustomFieldsWorld(); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + + await openCreateForm('nickname'); + await expect.element(createForm()).toBeVisible(); + + // The publisher has moved on to another decision, and a half-filled form stranded + // under someone else's dropdown helps nobody. `email` because a column out of the + // import has no control to open. + await fieldSelect('email').click(); + + await expect.element(createForm()).not.toBeInTheDocument(); + }); + + it('keeps its field when a column is deselected', async () => { + fakeCustomFieldsWorld(); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + + await openCreateForm('nickname'); + await page.getByTestId('import-create-custom-field').getByRole('button', {name: 'Save'}).click(); + await expect.element(fieldSelect('nickname')).toHaveTextContent('Nickname'); + + // Excluding a column from this import and choosing what it holds are different answers, + // so switching it back on must not make the publisher pick again. + await importToggle('nickname').click(); + await importToggle('nickname').click(); + + await expect.element(fieldSelect('nickname')).toHaveTextContent('Nickname'); + }); + + // A composite spans several columns, so the form has nothing to say about which one this + // is. It creates the field and hands the question back to the picker, filtered to what it + // just made — the same question as any other mapping, asked where the others are. + it('creates a composite field and asks which part the column holds', async () => { + const {createApi} = fakeCustomFieldsWorld(); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + + await openCreateForm('city'); + + const dialog = page.getByTestId('import-create-custom-field'); + await expect.element(dialog).toBeVisible(); + await userEvent.fill(dialog.getByLabelText('Name'), 'Shipping address'); + + await dialog.getByRole('combobox', {name: 'Type'}).click(); + await page.getByRole('option', {name: 'Address'}).click(); + await dialog.getByRole('button', {name: 'Save'}).click(); + + await expect.poll(() => createApi.lastRequest?.body).toEqual({ + members_custom_fields: [{name: 'Shipping address', type: 'address'}] + }); + + // The form is gone and its row's picker is open in its place, showing that field's + // parts and nothing else — the search is what narrows it, so it can still be cleared. + await expect.element(createForm()).not.toBeInTheDocument(); + await expect.element(page.getByRole('option', {name: 'Shipping address (City)'})).toBeVisible(); + await expect.element(page.getByRole('option', {name: 'Email', exact: true})).not.toBeInTheDocument(); + + await page.getByRole('option', {name: 'Shipping address (City)'}).click(); + + // Mapped onto the part chosen, not the field's first column. + await expect.element(fieldSelect('city')).toHaveTextContent('Shipping address (City)'); + }); + + it('pins a name the site already uses to the name input', async () => { + fakeCustomFieldsWorld(); + fakeAdminEndpoint('POST', '/members/custom_fields/', { + errors: [{message: 'A custom field with this name already exists.', property: 'name', type: 'ValidationError'}] + }, {status: 422}); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + + await openCreateForm('nickname'); + await createForm().getByRole('button', {name: 'Save'}).click(); + + // On the input they would fix, not in a corner: the clashing field may be archived, + // which they cannot see from here. + await expect.element(createForm().getByText('A custom field with this name already exists.')).toBeVisible(); + await expect.element(fieldSelect('nickname')).toHaveTextContent('Select field'); + }); + + it('shows why the site cannot take another field, and stops offering to retry', async () => { + fakeCustomFieldsWorld(); + fakeAdminEndpoint('POST', '/members/custom_fields/', { + errors: [{ + // As Ghost serialises a host limit: a generic summary in `message`, the sentence + // that explains the refusal in `context`. + message: 'Cannot create custom field.', + context: 'Custom fields are limited to 20 per site. Delete a field you no longer need to make room.', + code: 'CUSTOM_FIELDS_LIMIT_REACHED', + type: 'HostLimitError' + }] + }, {status: 403}); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + + await openCreateForm('nickname'); + await createForm().getByRole('button', {name: 'Save'}).click(); + + // The server's own sentence, because it is the only thing that explains the refusal. + await expect.element(createForm().getByText(/limited to 20 per site/)).toBeVisible(); + // Retrying cannot succeed, so the button that invites it is disabled until they change + // something. + await expect.element(createForm().getByRole('button', {name: 'Save'})).toBeDisabled(); + }); + + it('says the field exists when the response carries nothing to map onto', async () => { + fakeCustomFieldsWorld(); + // A 2xx the client cannot use: an older bundle against a newer server, a proxy that + // reshapes the envelope. The field is created either way, so the form must not claim + // otherwise and send them to create a second one. + fakeAdminEndpoint('POST', '/members/custom_fields/', {members_custom_fields: []}); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + + await openCreateForm('nickname'); + await createForm().getByRole('button', {name: 'Save'}).click(); + + await expect.element(createForm().getByText(/was created, but this column could not be mapped/)).toBeVisible(); + await expect.element(fieldSelect('nickname')).toHaveTextContent('Select field'); + }); + + // Leaving a column out of the mapping is how the importer is told to carry it through + // under its own header, which for a custom_fields.* column means importing it. So a + // deselected column has to be named with an empty target, not omitted. + it('names every column it is not importing rather than omitting it', async () => { + const {uploadApi} = fakeCustomFieldsWorld(); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + + // `name` is auto-detected, so this deselects a column that has a field; the three + // undetected columns are already out, having nothing to import them as. + await importToggle('name').click(); + await page.getByRole('button', {name: 'Import 1 member'}).click(); + + await expect.poll(() => uploadApi.lastRequest && sentMapping(uploadApi.lastRequest.body)).toEqual({ + email: 'email', + name: '', + nickname: '', + city: '', + postcode: '' + }); + }); + + // Being deselected and having lost a target are separate facts, and the first outranks the + // second. Losing a target puts a column back in the import so it cannot silently vanish + // with nothing said — but a column the publisher already switched off asked to be out, and + // it stays out. Collapsing the two into one in-or-out decision would resurrect it here. + it('keeps a deselected column out when another column takes the field it held', async () => { + const {uploadApi} = fakeCustomFieldsWorld(); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + + await importToggle('name').click(); + await expect.element(importToggle('name')).not.toBeChecked(); + + // `nickname` claims Name, which `name` was auto-detected onto — so `name` loses its + // target the way a switched-off column never asked to. + await importToggle('nickname').click(); + await fieldSelect('nickname').click(); + await page.getByRole('option', {name: 'Name', exact: true}).click(); + + // Still out, and with nothing to show for it: a column out of the import has no field + // control at all, so the target it lost cannot be read off the row either. + await expect.element(importToggle('name')).not.toBeChecked(); + await expect.element(fieldSelect('name')).not.toBeInTheDocument(); + + await page.getByRole('button', {name: 'Import 1 member'}).click(); + await expect.poll(() => uploadApi.lastRequest && sentMapping(uploadApi.lastRequest.body)).toEqual({ + email: 'email', + name: '', + nickname: 'name', + city: '', + postcode: '' + }); + }); + + // The columns a publisher can see are the columns they can exclude, and a column the mapping + // never names is carried through by the importer rather than left out. So a short row must + // not be able to hide one: everything the header declares has to reach the table and the + // request, whichever sample happens to be on screen. + it('shows every column the file has, not just the previewed row', async () => { + const {uploadApi} = fakeCustomFieldsWorld(); + await renderAdminApp('/members', FLAGS); + await openMappingStep(RAGGED_CSV); + + // Row 1 holds only an email, so reading columns off it would leave these two off the + // table with no way to say anything about them. + await expect.element(importToggle('name')).toBeVisible(); + await expect.element(importToggle('note')).toBeVisible(); + + // Detected too, not merely displayed: a column the publisher has to map by hand because + // an earlier row was short is barely better than one they never saw. + await expect.element(fieldSelect('name')).toHaveTextContent('Name'); + await expect.element(fieldSelect('note')).toHaveTextContent('Note'); + + await importToggle('note').click(); + await page.getByRole('button', {name: 'Import 2 members'}).click(); + + // Named and emptied, not omitted — omitting it is how the importer is told to keep it. + await expect.poll(() => uploadApi.lastRequest && sentMapping(uploadApi.lastRequest.body)).toEqual({ + email: 'email', + name: 'name', + note: '' + }); + }); + + // The upload result and its refusals are re-implemented in this modal, so they are asserted + // here rather than inherited: the shipped modal's own suite covers the file the gate no + // longer serves when the flag is on. + describe('after the upload', () => { + async function importAll() { + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + await page.getByRole('button', {name: 'Import 1 member'}).click(); + } + + it('shows the result when the import completes', async () => { + fakeCustomFieldsWorld(); + await importAll(); + + await expect.element(page.getByText('Import complete')).toBeVisible(); + }); + + it('says the file was too large rather than blaming the data', async () => { + fakeCustomFieldsWorld(); + fakeAdminEndpoint('POST', '/members/upload/', {errors: [{message: 'too big'}]}, {status: 413}); + await importAll(); + + await expect.element(page.getByText(/larger than the maximum file size/)).toBeVisible(); + }); + + it('shows the host limit refusal and does not offer to try again', async () => { + fakeCustomFieldsWorld(); + fakeAdminEndpoint('POST', '/members/upload/', { + errors: [{ + // As Ghost serialises it: generic summary in `message`, the sentence that + // explains the refusal in `context`. + message: 'Cannot import members.', + context: 'Woah there, that is a lot of members.', + code: 'EMAIL_VERIFICATION_NEEDED', + type: 'HostLimitError' + }] + }, {status: 403}); + await importAll(); + + // The server's own sentence, and no retry: nothing the publisher can do here changes + // a limit, so a button inviting them to repeat the request would only mislead. + await expect.element(page.getByText(/that is a lot of members/)).toBeVisible(); + await expect.element(page.getByRole('button', {name: /Try again/})).not.toBeInTheDocument(); + }); + + it('shows what the server refused about the data', async () => { + fakeCustomFieldsWorld(); + fakeAdminEndpoint('POST', '/members/upload/', { + errors: [{message: 'The file has no email column.', type: 'DataImportError'}] + }, {status: 422}); + await importAll(); + + await expect.element(page.getByText('The file has no email column.')).toBeVisible(); + }); + }); + + // A query whose only job is to add targets to a list must not be able to stop the import. + it('imports with membership fields when custom fields cannot be loaded', async () => { + fakeMembers([member({name: 'Ada Lovelace'})]); + fakeAdminEndpoint('GET', '/members/custom_fields/', {errors: [{message: 'nope'}]}, {status: 500}); + const uploadApi = fakeAdminEndpoint('POST', '/members/upload/', { + meta: {stats: {imported: 1, invalid: []}, import_label: {name: 'Import', slug: 'import'}} + }); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + + // The table is reached at all — waiting on a failed query would leave it on a spinner. + await expect.element(fieldSelect('email')).toHaveTextContent('Email'); + await page.getByRole('button', {name: 'Import 1 member'}).click(); + + await expect.poll(() => uploadApi.lastRequest && sentMapping(uploadApi.lastRequest.body)).toMatchObject({ + email: 'email' + }); + }); + + it('clears a refusal when the column it named is answered by creating a field', async () => { + fakeCustomFieldsWorld(); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + + // Refused: in the import with nothing chosen for it. + await importToggle('nickname').click(); + await page.getByRole('button', {name: 'Import 1 member'}).click(); + await expect.element(page.getByText(/Choose a field for "nickname"/)).toBeVisible(); + await expect.element(fieldSelect('nickname')).toHaveAttribute('aria-invalid', 'true'); + + // Creating one is that question answered, so the refusal goes with it. + await fieldSelect('nickname').click(); + await page.getByRole('option', {name: 'Add custom field'}).click(); + await createForm().getByRole('button', {name: 'Save'}).click(); + + await expect.element(page.getByText(/Choose a field for/)).not.toBeInTheDocument(); + await expect.element(fieldSelect('nickname')).not.toHaveAttribute('aria-invalid'); + }); + + it('will not import a file whose email column is deselected', async () => { + const {uploadApi} = fakeCustomFieldsWorld(); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + + // Mapped, but out of the import. The request would go without an email column and + // every row would come back missing one. + await importToggle('email').click(); + await page.getByRole('button', {name: 'Import 1 member'}).click(); + + await expect.element(page.getByText(/make sure it is selected/)).toBeVisible(); + expect(uploadApi.requests).toHaveLength(0); + }); + + it('marks the column it cannot import a selected row as, and lets it through once answered', async () => { + const {uploadApi} = fakeCustomFieldsWorld(); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + + await importToggle('nickname').click(); + await page.getByRole('button', {name: 'Import 1 member'}).click(); + + // On the row that has to change, not only in prose: the offending column may have + // scrolled out of view by the time the message is read. + await expect.element(page.getByText(/Choose a field for "nickname"/)).toBeVisible(); + await expect.element(fieldSelect('nickname')).toHaveAttribute('aria-invalid', 'true'); + expect(uploadApi.requests).toHaveLength(0); + + await fieldSelect('nickname').click(); + await page.getByRole('option', {name: 'Note'}).click(); + await expect.element(fieldSelect('nickname')).not.toHaveAttribute('aria-invalid'); + + await page.getByRole('button', {name: 'Import 1 member'}).click(); + await expect.poll(() => uploadApi.lastRequest && sentMapping(uploadApi.lastRequest.body)).toMatchObject({ + nickname: 'note' + }); + }); + + // The whole point of one list: what a column can be imported as is answered by reading it, + // not by switching a kind picker to find out what is behind each setting. + it('offers membership and custom fields in one searchable list', async () => { + fakeCustomFieldsWorld(); + await renderAdminApp('/members', FLAGS); + await openMappingStep(EXPORTED_CSV); + + // A Ghost export re-imported where its field no longer exists: nothing matches, so it + // starts out of the import with nothing chosen for it. + await importToggle('custom_fields.nickname').click(); + await expect.element(fieldSelect('custom_fields.nickname')).toHaveTextContent('Select field'); + await fieldSelect('custom_fields.nickname').click(); + + // Both kinds are reachable from the one list, without choosing between them first. + // Exact, or "Email" also matches "Subscribed to emails". + await expect.element(page.getByRole('option', {name: 'Email', exact: true})).toBeVisible(); + await expect.element(page.getByRole('option', {name: 'Add custom field'})).toBeVisible(); + + // And searchable, which is what makes the member fields usable as one list rather than + // two — there are ten of them before a site defines a single custom field. + await userEvent.fill(page.getByPlaceholder('Search fields...'), 'stripe'); + await expect.element(page.getByRole('option', {name: 'Stripe Customer ID'})).toBeVisible(); + await expect.element(page.getByRole('option', {name: 'Email', exact: true})).not.toBeInTheDocument(); + }); + + // A search matching no field is the strongest signal there is that the field wanted does not + // exist yet, so the way to make one has to survive the search that proves it. + it('offers to add a field when the search matches none', async () => { + fakeCustomFieldsWorld(); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + + await importToggle('nickname').click(); + await fieldSelect('nickname').click(); + await userEvent.fill(page.getByPlaceholder('Search fields...'), 'zzzz'); + + await expect.element(page.getByRole('option', {name: 'Email', exact: true})).not.toBeInTheDocument(); + await page.getByRole('option', {name: 'Add custom field'}).click(); + + await expect.element(createForm()).toBeVisible(); + }); + + // The part is chosen in the picker now, so it can be left unchosen — which the form's old + // guard made impossible. The column is in the import with nothing to fill, and Import says so. + it('refuses the import when a composite is created and no part is chosen', async () => { + const {uploadApi} = fakeCustomFieldsWorld(); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + + await openCreateForm('city'); + await userEvent.fill(createForm().getByLabelText('Name'), 'Shipping address'); + await createForm().getByRole('combobox', {name: 'Type'}).click(); + await page.getByRole('option', {name: 'Address'}).click(); + await createForm().getByRole('button', {name: 'Save'}).click(); + + // Dismissed without answering which part the column holds. + await expect.element(page.getByRole('option', {name: 'Shipping address (City)'})).toBeVisible(); + await userEvent.keyboard('{Escape}'); + + await page.getByRole('button', {name: 'Import 1 member'}).click(); + await expect.element(page.getByText(/Choose a field for "city"/)).toBeVisible(); + expect(uploadApi.requests).toHaveLength(0); + }); + + // Dismissing is left alone — it is how a dialog is closed — but it must not silently discard + // a mapping. Nothing has been changed here, so there is nothing to lose and nothing to ask. + it('closes without asking when nothing has been changed', async () => { + fakeCustomFieldsWorld(); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + await expect.element(fieldSelect('email')).toBeVisible(); + + await userEvent.keyboard('{Escape}'); + + await expect.element(page.getByText('Leave without importing?')).not.toBeInTheDocument(); + await expect.element(fieldSelect('email')).not.toBeInTheDocument(); + }); + + it('asks before discarding a mapping that has been changed', async () => { + fakeCustomFieldsWorld(); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + + await importToggle('nickname').click(); + await userEvent.keyboard('{Escape}'); + + // Keeping goes back to exactly where they were, with the change intact. + await expect.element(page.getByText('Leave without importing?')).toBeVisible(); + await page.getByRole('button', {name: 'Keep mapping'}).click(); + await expect.element(fieldSelect('email')).toBeVisible(); + await expect.element(importToggle('nickname')).toBeChecked(); + + await userEvent.keyboard('{Escape}'); + await page.getByRole('button', {name: 'Leave'}).click(); + await expect.element(fieldSelect('email')).not.toBeInTheDocument(); + }); + +}); \ No newline at end of file diff --git a/apps/admin/src/members/import-members-gate.acceptance.test.tsx b/apps/admin/src/members/import-members-gate.acceptance.test.tsx new file mode 100644 index 00000000000..172096c6de2 --- /dev/null +++ b/apps/admin/src/members/import-members-gate.acceptance.test.tsx @@ -0,0 +1,54 @@ +import {describe, expect, it} from 'vitest'; +import {page} from 'vitest/browser'; + +import {fakeAdminEndpoint, fakeMembers, member, renderAdminApp} from '@test-utils/acceptance'; +import {membersScreen} from './members.screen'; + +const CSV = 'email,name\nada@example.com,Ada Lovelace\n'; + +/** + * The one thing the split can break. + * + * Both implementations have their own tests: the import as it shipped is covered by + * import-members/modal.test.tsx, and the custom fields experience by + * import-members-custom-fields.acceptance.test.tsx. Neither can regress from the other's + * changes, because they share no file. What is left is whether the gate hands over to the + * right one, which is what this asserts — by a marker only that implementation renders. + */ +async function openMappingStep(labs: Record) { + fakeMembers([member({name: 'Ada Lovelace'})]); + fakeAdminEndpoint('GET', '/members/custom_fields/', {members_custom_fields: []}); + await renderAdminApp('/members', {labs}); + + await membersScreen.openActionsMenu(); + await membersScreen.menuItem(/Import members/).click(); + + const dropzone = page.getByRole('button', {name: /select or drop a csv file/i}); + await expect.element(dropzone).toBeVisible(); + const input = dropzone.element().querySelector('input[type=file]'); + if (!(input instanceof HTMLInputElement)) { + throw new Error('CSV upload input was not rendered'); + } + await page.elementLocator(input).upload(new File([CSV], 'members.csv', {type: 'text/csv'})); +} + +describe('Import members gate', () => { + it('serves the custom fields import when the flag is on', async () => { + await openMappingStep({membersCustomFields: true}); + + // A checkbox per column exists only in the custom fields experience: it is what decides + // whether a column is imported there, a job the select does in the import as it shipped. + await expect.element(page.getByRole('checkbox', {name: 'Import name'})).toBeVisible(); + }); + + it('serves the import as it shipped when the flag is off', async () => { + await openMappingStep({}); + + // No checkbox, and the selects carry no accessible name — the shipped import never gave + // them one, which is itself a marker that this is that file and not ours. + await expect.element(page.getByRole('checkbox', {name: 'Import name'})).not.toBeInTheDocument(); + await expect.element(page.getByRole('combobox').first()).toBeVisible(); + await page.getByRole('combobox').first().click(); + await expect.element(page.getByRole('option', {name: 'Not imported'})).toBeVisible(); + }); +}); diff --git a/apps/admin/src/members/label-picker/label-picker.tsx b/apps/admin/src/members/label-picker/label-picker.tsx index 4511e595dbe..8f7dc5c172f 100644 --- a/apps/admin/src/members/label-picker/label-picker.tsx +++ b/apps/admin/src/members/label-picker/label-picker.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; import { Badge, type ComboboxOptionSource, @@ -13,6 +13,16 @@ import {type Label} from '@tryghost/admin-x-framework/api/labels'; import {LucideIcon} from '@tryghost/shade/utils'; import {canCreateLabel} from './can-create-label'; +// What the list used to be capped at (max-h-64), and the least worth showing rather than +// collapsing to a sliver. Between them it takes whatever room is below the field. +const PREFERRED_HEIGHT = 256; +const MIN_HEIGHT = 120; +// Between the field and the scrollable list: the dropdown's mt-1 offset and its border. The +// space is measured from the field, so this is spent before the list gets any of it. +const DROPDOWN_CHROME = 6; +// Kept clear of the viewport edge so the list never sits flush against it. +const GUTTER = 16; + export interface LabelPickerProps { labels: Label[]; optionSource: ComboboxOptionSource; @@ -264,6 +274,19 @@ const ComboboxPicker: React.FC = ({ const [search, setSearch] = useState(''); const inputRef = useRef(null); const containerRef = useRef(null); + const [maxHeight, setMaxHeight] = useState(PREFERRED_HEIGHT); + + // No portal here (see the click-outside note below), so nothing anchors this for us: at a + // fixed height the list runs off-screen when the field sits low in a tall dialog. Opens + // downward as it always has and takes whatever room is below, scrolling inside it. + // Selecting labels wraps the field to another row and moves it, so that counts too. + useLayoutEffect(() => { + if (!open || !containerRef.current) { + return; + } + const spaceBelow = window.innerHeight - containerRef.current.getBoundingClientRect().bottom - DROPDOWN_CHROME - GUTTER; + setMaxHeight(Math.max(MIN_HEIGHT, Math.min(PREFERRED_HEIGHT, spaceBelow))); + }, [open, selectedSlugs.length]); const handleSearchChange = useCallback((value: string) => { setSearch(value); @@ -330,7 +353,7 @@ const ComboboxPicker: React.FC = ({
) : ( - + import("./members/detail/member-detail")) } ] }; diff --git a/apps/admin/src/settings/advanced/integrations.acceptance.test.tsx b/apps/admin/src/settings/advanced/integrations.acceptance.test.tsx index 85bf96bb74a..9b2e6b88284 100644 --- a/apps/admin/src/settings/advanced/integrations.acceptance.test.tsx +++ b/apps/admin/src/settings/advanced/integrations.acceptance.test.tsx @@ -122,7 +122,8 @@ describe("Advanced integrations", () => { await modal.getByRole("button", {name: "Save"}).click(); await expect.element(section).toHaveTextContent(/Test description/); expect(editApi.requests).toHaveLength(1); - await modal.getByRole("button", {name: "Close"}).click(); + // The modal closes itself once the saved state resets — clicking Close races that. + await expect.element(modal).not.toBeInTheDocument(); await section.getByText("My integration").hover(); await section.getByRole("button", {name: "Delete"}).click(); diff --git a/apps/admin/src/settings/advanced/migration-tools-export.acceptance.test.tsx b/apps/admin/src/settings/advanced/migration-tools-export.acceptance.test.tsx new file mode 100644 index 00000000000..ef475a2c17e --- /dev/null +++ b/apps/admin/src/settings/advanced/migration-tools-export.acceptance.test.tsx @@ -0,0 +1,62 @@ +import {describe, expect, it} from "vitest"; +import {page} from "vitest/browser"; + +import {configResponse, fakeSettingsScreens, renderAdminApp} from "@test-utils/acceptance"; +import {settingsScreen} from "@/settings/settings.screen"; + +async function openExportTab() { + const section = settingsScreen.section("migrationtools"); + await section.getByRole("tab", {name: "Export"}).click(); + return section; +} + +describe("Migration tools export", () => { + it("keeps the individual export buttons without the selfServeArchives flag", async () => { + fakeSettingsScreens(); + await renderAdminApp("/settings/advanced"); + + const section = await openExportTab(); + await expect.element(section.getByRole("button", {name: "Content & settings"})).toBeVisible(); + await expect.element(section.getByRole("button", {name: "Post analytics"})).toBeVisible(); + await expect.element(section.getByRole("button", {name: "Export data"})).not.toBeInTheDocument(); + }); + + it("offers the sync export dialog without media when no archive host is configured", async () => { + fakeSettingsScreens(); + await renderAdminApp("/settings/advanced", {labs: {selfServeArchives: true}}); + + const section = await openExportTab(); + await section.getByRole("button", {name: "Export data"}).click(); + + const dialog = page.getByRole("dialog"); + await expect.element(dialog.getByText("downloaded as a single zip", {exact: false})).toBeVisible(); + await expect.element(dialog.getByText("Members", {exact: true})).toBeVisible(); + await expect.element(dialog.getByText("Media files", {exact: true})).not.toBeInTheDocument(); + + await dialog.getByRole("button", {name: "Export", exact: true}).click(); + await expect.element(dialog.getByText("Preparing your export", {exact: false})).toBeVisible(); + }); + + it("offers media and email delivery when an archive host is configured", async () => { + fakeSettingsScreens(); + const config = configResponse({labs: {selfServeArchives: true}}); + (config.config as {hostSettings?: object}).hostSettings = { + ...(config.config as {hostSettings?: object}).hostSettings, + export: {generate_archive_url: "https://archives.example.com/generate"}, + }; + await renderAdminApp("/settings/advanced", { + labs: {selfServeArchives: true}, + boot: {browseConfig: {response: config}}, + }); + + const section = await openExportTab(); + await section.getByRole("button", {name: "Export data"}).click(); + + const dialog = page.getByRole("dialog"); + await expect.element(dialog.getByText("sent to you by email", {exact: false})).toBeVisible(); + await expect.element(dialog.getByText("Media files", {exact: true})).toBeVisible(); + + await dialog.getByRole("button", {name: "Export", exact: true}).click(); + await expect.element(dialog.getByText("Exporting data", {exact: false})).toBeVisible(); + }); +}); diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx b/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx index 18205b5fa54..d1fc22aa5b1 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx @@ -55,10 +55,6 @@ const features: Feature[] = [{ title: 'Get helper deduplication', description: 'Deduplicate identical {{#get}} helper queries within a single request to avoid redundant database calls', flag: 'getHelperDeduplication' -}, { - title: 'React member details', - description: 'Renders the member detail screen (/members/:id) from the React app instead of the Ember screen. Gates the migration behind a runtime toggle so we can compare both implementations.', - flag: 'memberDetailsReact' }, { title: 'React tag details', description: 'Renders the tag detail screen (/tags/:slug) from the React app instead of the Ember screen. Gates the migration behind a runtime toggle so we can compare both implementations.', @@ -75,6 +71,14 @@ const features: Feature[] = [{ title: 'Gift subscription customization', description: 'Enables fixed-duration gift subscription purchases before publisher configuration is available', flag: 'giftSubCustomization' +}, { + title: 'Self-serve archives', + description: 'Replaces the individual export buttons with a single "Export data" flow for downloading a full site archive', + flag: 'selfServeArchives' +}, { + title: 'Machine payments', + description: 'Let AI agents pay for access to paid-members markdown (.md) URLs via Stripe Machine Payments Protocol', + flag: 'machinePayments' }]; const AlphaFeatures: React.FC = () => { diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/export-all-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/export-all-modal.tsx new file mode 100644 index 00000000000..17286d65129 --- /dev/null +++ b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/export-all-modal.tsx @@ -0,0 +1,205 @@ +import React, {useEffect, useRef, useState} from 'react'; +import { + Button, + Checkbox, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + LoadingIndicator +} from '@tryghost/shade/components'; +import {LucideIcon} from '@tryghost/shade/utils'; +import {useCurrentUser} from '@tryghost/admin-x-framework/api/current-user'; + +export type ExportMode = 'sync' | 'async'; + +type ExportComponentKey = 'content' | 'members' | 'analytics' | 'themes' | 'routes' | 'media'; + +type ExportComponent = { + key: ExportComponentKey; + label: string; + description: string; + defaultChecked: boolean; + asyncOnly?: boolean; +}; + +const EXPORT_COMPONENTS: ExportComponent[] = [ + {key: 'content', label: 'Content & settings', description: 'Posts, pages, tags, tiers and settings (JSON)', defaultChecked: true}, + {key: 'members', label: 'Members', description: 'All members with labels and subscription status (CSV)', defaultChecked: true}, + {key: 'analytics', label: 'Post analytics', description: 'Sends, opens, clicks and conversions per post (CSV)', defaultChecked: true}, + {key: 'themes', label: 'Themes', description: 'All installed themes, including custom code', defaultChecked: true}, + {key: 'routes', label: 'Redirects & routes', description: 'routes.yaml and redirects configuration', defaultChecked: true}, + {key: 'media', label: 'Media files', description: 'All images, video and audio files. May significantly increase export size and duration', defaultChecked: false, asyncOnly: true} +]; + +type ExportPhase = 'select' | 'confirmed' | 'preparing' | 'done'; + +const ExportAllModal: React.FC<{open: boolean; onOpenChange: (open: boolean) => void; mode: ExportMode}> = ({open, onOpenChange, mode}) => { + const {data: currentUser} = useCurrentUser(); + const [phase, setPhase] = useState('select'); + const [selected, setSelected] = useState>(() => { + const initial = {} as Record; + EXPORT_COMPONENTS.forEach((component) => { + initial[component.key] = component.defaultChecked; + }); + return initial; + }); + const mockTimerRef = useRef>(); + const resetTimerRef = useRef>(); + + const email = currentUser?.email; + const visibleComponents = EXPORT_COMPONENTS.filter(component => mode === 'async' || !component.asyncOnly); + const noneSelected = visibleComponents.every(component => !selected[component.key]); + + const handleOpenChange = (next: boolean) => { + onOpenChange(next); + if (next) { + clearTimeout(resetTimerRef.current); + setPhase('select'); + return; + } + clearTimeout(mockTimerRef.current); + clearTimeout(resetTimerRef.current); + // Reset for the next open, after the close animation + resetTimerRef.current = setTimeout(() => setPhase('select'), 200); + }; + + // Static UX/UI mockup, nothing is wired to a backend + const startExport = () => { + if (mode === 'async') { + setPhase('confirmed'); + return; + } + setPhase('preparing'); + mockTimerRef.current = setTimeout(() => { + triggerMockDownload(); + setPhase('done'); + }, 10000); + }; + + const triggerMockDownload = () => { + const emptyZip = new Uint8Array([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]); + const url = URL.createObjectURL(new Blob([emptyZip], {type: 'application/zip'})); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'ghost-export.zip'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + }; + + useEffect(() => { + return () => { + clearTimeout(mockTimerRef.current); + clearTimeout(resetTimerRef.current); + }; + }, []); + + return ( + + + {phase === 'select' && ( + <> + + Export data + + {mode === 'async' + ? 'Choose what to include. Your export will be prepared in the background and a download link sent to you by email.' + : <> + Your export will be downloaded as a single zip file.{' '} + Images, videos and files are not included.{' '} + Learn more → + } + + +
+ {visibleComponents.map(component => ( + + ))} +
+ + + + + + )} + + {phase === 'confirmed' && ( + <> + + + Exporting data… + + + + A link to download your data will be sent to your email {email} once + the export is complete. The link will be valid for 7 days. You can now close this window. + + + + + + )} + + {phase === 'preparing' && ( + <> + + + Preparing your export… + + + + Your download will start automatically when it’s ready. Keep this window open. + + + + + + )} + + {phase === 'done' && ( + <> + + + Export downloaded + + + + Your export has been downloaded as a zip file. + + + + + + )} + +
+ ); +}; + +export default ExportAllModal; diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-export.tsx b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-export.tsx index 1b5ed1726a0..f487eb9dd42 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-export.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-export.tsx @@ -1,14 +1,23 @@ +import ExportAllModal, {type ExportMode} from './export-all-modal'; import React from 'react'; +import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; import {Button, LoadingIndicator} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {blobDownloadFromEndpoint} from '@tryghost/admin-x-framework/helpers'; import {downloadAllContent} from '@tryghost/admin-x-framework/api/db'; +import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; const MigrationToolsExport: React.FC = () => { const [isExportingPosts, setIsExportingPosts] = React.useState(false); + const [exportAllOpen, setExportAllOpen] = React.useState(false); const handleError = useHandleError(); + const hasSelfServeArchives = useFeatureFlag('selfServeArchives'); + const {data: configData} = useBrowseConfig(); + const hostSettings = configData?.config?.hostSettings as {export?: {generate_archive_url?: string}} | undefined; + const mode: ExportMode = hostSettings?.export?.generate_archive_url ? 'async' : 'sync'; + const exportPosts = async () => { if (isExportingPosts) { return; @@ -25,6 +34,19 @@ const MigrationToolsExport: React.FC = () => { } }; + if (hasSelfServeArchives) { + return ( + <> +
+ +
+ + + ); + } + return (
diff --git a/apps/admin/src/settings/app/components/settings/membership/tiers.tsx b/apps/admin/src/settings/app/components/settings/membership/tiers.tsx index d20901f5ff2..81655e51fb0 100644 --- a/apps/admin/src/settings/app/components/settings/membership/tiers.tsx +++ b/apps/admin/src/settings/app/components/settings/membership/tiers.tsx @@ -1,15 +1,20 @@ -import React, {useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import StripeButton from '@/settings/app/components/stripe-button'; import TiersList from './tiers/tiers-list'; import TopLevelGroup from '@/settings/app/components/top-level-group'; import clsx from 'clsx'; -import {Button, Indicator, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; +import useCurrencyInput from '@/settings/app/hooks/use-currency-input'; +import {Button, Field, FieldDescription, FieldError, FieldLabel, Indicator, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, MultiSelectCombobox, Popover, PopoverContent, PopoverTrigger, Switch, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; +import {ChevronDown} from 'lucide-react'; import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; +import {SettingGroupContent} from '@tryghost/shade/patterns'; +import {type Setting, checkStripeEnabled, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {type Tier, getActiveTiers, getArchivedTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; -import {checkStripeEnabled} from '@tryghost/admin-x-framework/api/settings'; +import {currencySelectGroups, validateCurrencyAmount} from '@/settings/app/utils/currency'; import {formatNumber} from '@tryghost/shade/utils'; import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; import {withErrorBoundary} from '@/settings/app/components/error-boundary'; @@ -28,18 +33,72 @@ const StripeConnectedButton: React.FC<{className?: string; onClick: () => void;} const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => { const [selectedTab, setSelectedTab] = useState('active-tiers'); + const [currencyOpen, setCurrencyOpen] = useState(false); + const [machinePaymentsAmountError, setMachinePaymentsAmountError] = useState(); const {settings, config} = useGlobalData(); const {data: {tiers, meta, isEnd} = {}, fetchNextPage} = useBrowseTiers(); + const {mutateAsync: editSettings} = useEditSettings(); + const machinePaymentSaveQueue = useRef(Promise.resolve()); const activeTiers = getActiveTiers(tiers || []); const archivedTiers = getArchivedTiers(tiers || []); + const defaultPaidTierCurrency = activeTiers.find(tier => tier.type === 'paid' && tier.currency)?.currency || 'USD'; const {updateRoute} = useSettingsNavigation(); const limiter = useLimiter(); const {showLimit} = useConfirmation(); + const handleError = useHandleError(); + const stripeEnabled = checkStripeEnabled(settings, config); + + const [ + machinePaymentsEnabledValue, + machinePaymentsCurrencyValue, + machinePaymentsAmountValue, + llmsEnabledValue + ] = getSettingValues(settings, [ + 'machine_payments_enabled', + 'machine_payments_currency', + 'machine_payments_amount', + 'llms_enabled' + ]); + + const llmsEnabled = llmsEnabledValue !== false; + const machinePaymentsEnabled = machinePaymentsEnabledValue === true; + const hasMachinePaymentsLab = config?.labs?.machinePayments === true; + // Admin and core deploy independently — only render controls once the + // settings key exists in the browse payload (labs alone is not enough). + const backendSupportsMachinePayments = settings?.some(s => s.key === 'machine_payments_enabled'); + const canEnableMachinePayments = llmsEnabled && stripeEnabled; + const machinePaymentsCurrency = (machinePaymentsCurrencyValue || defaultPaidTierCurrency) as string; + const machinePaymentsAmount = Number(machinePaymentsAmountValue || 100); + const amountInput = useCurrencyInput(machinePaymentsAmount, (cents) => { + const validationError = validateCurrencyAmount(cents, machinePaymentsCurrency, {allowZero: false}); + setMachinePaymentsAmountError(validationError); + if (!validationError) { + saveMachinePaymentSettings([{key: 'machine_payments_amount', value: cents}]); + } + }); + const currencyOptions = currencySelectGroups().flatMap(group => group.options.map(option => ({ + ...option, + metadata: {groupKey: group.key, groupLabel: group.label} + }))); + + const machinePaymentsHint = !llmsEnabled + ? 'llms.txt must be enabled to use agent payments' + : !stripeEnabled + ? 'Connect Stripe to accept payments from AI agents' + : 'Charge AI agents for access to paid-members markdown posts'; + + // Clear stored enablement when prerequisites drop so re-enabling llms/Stripe + // does not silently reactivate charges. + useEffect(() => { + if (backendSupportsMachinePayments && machinePaymentsEnabled && !canEnableMachinePayments) { + saveMachinePaymentSettings([{key: 'machine_payments_enabled', value: false}]); + } + }, [backendSupportsMachinePayments, machinePaymentsEnabled, canEnableMachinePayments]); const openConnectModal = async () => { // Allow Stripe despite the limit when it's already connected, so it's // possible to disconnect or update the settings. - if (limiter?.isDisabled('limitStripeConnect') && !checkStripeEnabled(settings, config)) { + if (limiter?.isDisabled('limitStripeConnect') && !stripeEnabled) { try { await limiter.errorIfWouldGoOverLimit('limitStripeConnect'); } catch (error) { @@ -59,8 +118,18 @@ const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => { return [...t].sort((a, b) => (a.monthly_price ?? 0) - (b.monthly_price ?? 0)); }; + const saveMachinePaymentSettings = (updates: Setting[]) => { + machinePaymentSaveQueue.current = machinePaymentSaveQueue.current + .then(async () => { + await editSettings(updates); + }) + .catch((error) => { + handleError(error); + }); + }; + let content; - if (checkStripeEnabled(settings, config)) { + if (stripeEnabled) { content = ( @@ -75,12 +144,14 @@ const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => { content = tier.type === 'free')} />; } + const stripeButton = stripeEnabled ? + + : + ; + return ( - : - } + customButtons={stripeButton} description='Set prices and paid member sign up settings' keywords={keywords} navid='tiers' @@ -88,7 +159,7 @@ const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => { title='Tiers' >
- {checkStripeEnabled(settings, config) ? + {stripeEnabled ? : @@ -103,6 +174,82 @@ const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => { > {`Load more (showing ${formatNumber(tiers?.length || 0)}/${formatNumber(meta?.pagination.total || 0)} tiers)`} } + + {hasMachinePaymentsLab && backendSupportsMachinePayments && ( + +
+
+

Accept payments from AI agents

+

{machinePaymentsHint}

+
+ { + saveMachinePaymentSettings([ + {key: 'machine_payments_enabled', value: checked} + ]); + }} + /> +
+ + {machinePaymentsEnabled && canEnableMachinePayments && ( + + Price per post + + SPT/card charges use this currency. Tempo crypto charges the same minor units as USDC. + + + amountInput.onBlur()} + onChange={event => amountInput.onChange(event.target.value)} + onKeyDown={() => setMachinePaymentsAmountError(undefined)} + /> + + + + + {machinePaymentsCurrency} + + + + + ({ + key: option.metadata?.groupKey as string, + label: option.metadata?.groupLabel as string + })} + i18n={{searchPlaceholder: 'Search currencies...'}} + isMultiSelect={false} + options={currencyOptions} + values={[machinePaymentsCurrency]} + autoCloseOnSelect + onChange={(values) => { + const next = values[0] || 'USD'; + setMachinePaymentsAmountError( + validateCurrencyAmount(machinePaymentsAmount, next, {allowZero: false}) + ); + saveMachinePaymentSettings([ + {key: 'machine_payments_currency', value: next} + ]); + }} + onClose={() => setCurrencyOpen(false)} + /> + + + + + {machinePaymentsAmountError && {machinePaymentsAmountError}} + + )} +
+ )} ); }; diff --git a/apps/admin/src/settings/membership/tiers.acceptance.test.tsx b/apps/admin/src/settings/membership/tiers.acceptance.test.tsx index b91adcad088..723380acb1c 100644 --- a/apps/admin/src/settings/membership/tiers.acceptance.test.tsx +++ b/apps/admin/src/settings/membership/tiers.acceptance.test.tsx @@ -21,14 +21,24 @@ const supporterTier = tier({ benefits: ["Simple benefit"], }); -function stripeSettings() { - return settingsResponse({settings: { - stripe_connect_display_name: "Dummy", - stripe_connect_livemode: false, - stripe_connect_account_id: "acct_123", - stripe_connect_publishable_key: "pk_test_123", - stripe_connect_secret_key: "sk_test_123", - }}); +function stripeSettings(overrides: Parameters[0] = {}) { + return settingsResponse({ + ...overrides, + settings: { + stripe_connect_display_name: "Dummy", + stripe_connect_livemode: false, + stripe_connect_account_id: "acct_123", + stripe_connect_publishable_key: "pk_test_123", + stripe_connect_secret_key: "sk_test_123", + ...overrides.settings, + }, + }); +} + +function withoutSettings(keys: string[]) { + const response = stripeSettings({labs: {machinePayments: true}}); + response.settings = response.settings.filter(({key}) => !keys.includes(key)); + return response; } function stripeLimitConfig() { @@ -258,6 +268,30 @@ describe("Tier settings", () => { await expect.element(settingsScreen.stripeModal()).toBeVisible(); }); + it("shows agent payment controls when the lab is on and the backend has deployed them", async () => { + fakeSettingsScreens(); + fakeTiers([freeTier, supporterTier]); + await renderAdminApp("/settings", { + labs: {machinePayments: true}, + boot: {browseSettings: {response: stripeSettings({labs: {machinePayments: true}})}}, + }); + + await expect.element(settingsScreen.tiers().getByText("Accept payments from AI agents")).toBeVisible(); + await expect.element(settingsScreen.tiers().getByTestId("machine-payments-toggle")).toBeVisible(); + }); + + it("hides agent payment controls when the backend has not deployed them", async () => { + fakeSettingsScreens(); + fakeTiers([freeTier, supporterTier]); + await renderAdminApp("/settings", { + labs: {machinePayments: true}, + boot: {browseSettings: {response: withoutSettings(["machine_payments_enabled"])}}, + }); + + await expect(settingsScreen.tiers().getByText("Accept payments from AI agents")).toHaveCount(0); + await expect(settingsScreen.tiers().getByTestId("machine-payments-toggle")).toHaveCount(0); + }); + it("blocks direct access to Stripe connection when the plan limit applies", async () => { fakeSettingsScreens(); fakeTiers([freeTier, supporterTier]); diff --git a/apps/admin/src/tags/detail/tag-detail-form.tsx b/apps/admin/src/tags/detail/tag-detail-form.tsx index c5c4c11a489..4e33c215ebf 100644 --- a/apps/admin/src/tags/detail/tag-detail-form.tsx +++ b/apps/admin/src/tags/detail/tag-detail-form.tsx @@ -325,7 +325,7 @@ const TagDetailForm: React.FC = ({draft, errors, blogUrl, di height='128px' title={<>Tag header {'{{ghost_head}}'}} value={draft.codeinjectionHead} - onChange={value => onChange({codeinjectionHead: value})} + onChange={(value: string) => onChange({codeinjectionHead: value})} /> = ({draft, errors, blogUrl, di height='128px' title={<>Tag footer {'{{ghost_foot}}'}} value={draft.codeinjectionFoot} - onChange={value => onChange({codeinjectionFoot: value})} + onChange={(value: string) => onChange({codeinjectionFoot: value})} /> diff --git a/apps/admin/test-utils/acceptance/worker.ts b/apps/admin/test-utils/acceptance/worker.ts index b3aac92c086..cd9d5f537a5 100644 --- a/apps/admin/test-utils/acceptance/worker.ts +++ b/apps/admin/test-utils/acceptance/worker.ts @@ -211,6 +211,42 @@ export interface FakeEndpointOptions { * to resource fakes / boot overrides / `fakeAdminEndpoint`. Returns a * capture of every matched request for payload assertions. */ +/** + * A request body as something a test can assert against. + * + * JSON where the request is JSON. Multipart bodies are read as an object too, keyed by field + * name, with files reduced to their name and type — otherwise a file upload's body is invisible + * to tests and the only assertable thing about it is that it happened. Repeated fields (labels, + * `mapping[column]`) collect into arrays. Anything else stays undefined, including a request + * with no body at all. + */ +async function parseRequestBody(request: Request): Promise { + const contentType = request.headers.get('content-type') ?? ''; + + if (!contentType.includes('multipart/form-data') && !contentType.includes('application/x-www-form-urlencoded')) { + try { + return await request.clone().json(); + } catch { + return undefined; + } + } + + try { + const form = await request.clone().formData(); + // Collected as a Map so a field named after a prototype method cannot read as one + // already seen, then collapsed: a field that appeared once is worth asserting against + // as its own value rather than as a one-element array. + const fields = new Map(); + form.forEach((value, key) => { + const parsed = value instanceof File ? {filename: value.name, type: value.type} : value; + fields.set(key, [...(fields.get(key) ?? []), parsed]); + }); + return Object.fromEntries([...fields].map(([key, values]) => [key, values.length === 1 ? values[0] : values])); + } catch { + return undefined; + } +} + export function fakeEndpoint(method: string, url: string, response: unknown, { status = 200 }: FakeEndpointOptions = {}): EndpointCapture { const expectedMethod = method.toUpperCase(); const requests: CapturedEndpointRequest[] = []; @@ -221,13 +257,7 @@ export function fakeEndpoint(method: string, url: string, response: unknown, { s return undefined; } - let body: unknown; - try { - body = await request.clone().json(); - } catch { - body = undefined; - } - requests.push({ url: request.url, body }); + requests.push({ url: request.url, body: await parseRequestBody(request) }); return HttpResponse.json(response as Record, { status }); }) @@ -322,14 +352,7 @@ export function fakeAdminEndpoint( return undefined; } - let body: unknown; - try { - body = await request.clone().json(); - } catch { - body = undefined; - } - - const captured: CapturedEndpointRequest = { url: request.url, body }; + const captured: CapturedEndpointRequest = { url: request.url, body: await parseRequestBody(request) }; requests.push(captured); const responseBody: unknown = diff --git a/apps/announcement-bar/LICENSE b/apps/announcement-bar/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/apps/announcement-bar/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/apps/announcement-bar/README.md b/apps/announcement-bar/README.md index ad56d5839fa..8567ff7a3e5 100644 --- a/apps/announcement-bar/README.md +++ b/apps/announcement-bar/README.md @@ -4,17 +4,17 @@ ### Pre-requisites -- Run `pnpm` in Ghost monorepo root -- Run `pnpm` in this directory +- Run `pnpm setup` in the Ghost monorepo root -### Running via Ghost `pnpm dev` in root folder +### Running via Ghost from the monorepo root + +Start Ghost with the public-app watchers enabled: -Announcement Bar runs automatically when using Ghost's development command from the monorepo root: ```bash -pnpm dev +pnpm dev:public ``` -This starts all frontend apps (including Announcement Bar.) +This starts the standard development environment and the Announcement Bar watcher. To run only the package's build watcher, use `pnpm dev` from this directory. ## Release @@ -32,8 +32,8 @@ In either case, you need sufficient permissions to release `@tryghost` packages 2. Merge the release commit to `main` 3. Wait until a new version of Ghost is released -To use the new version of signup form in Ghost, update the version in Ghost core's default configuration (currently at `core/shared/config/default.json`) +To use the new version of Announcement Bar in Ghost, update the version in Ghost core's default configuration (currently at `core/shared/config/default.json`) # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). diff --git a/apps/comments-ui/LICENSE b/apps/comments-ui/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/apps/comments-ui/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/apps/comments-ui/README.md b/apps/comments-ui/README.md index 2df85697efe..d4c19b62a86 100644 --- a/apps/comments-ui/README.md +++ b/apps/comments-ui/README.md @@ -6,15 +6,18 @@ Comments widget that is embedded at the bottom of posts in Ghost. ### Pre-requisites -- Run `pnpm` in Ghost monorepo root +- Run `pnpm setup` in the Ghost monorepo root -### Running via Ghost `pnpm dev` in root folder +### Running via Ghost from the monorepo root + +Start Ghost with the public-app watchers enabled: -Comments UI runs automatically when using Ghost's development command from the monorepo root: ```bash -pnpm dev +pnpm dev:public ``` +This starts the standard development environment and the Comments UI watcher. To run only the package's build watcher, use `pnpm dev` from this directory. + ## Release A patch release can be rolled out instantly in production, whereas a minor/major release requires the Ghost monorepo to be updated and released. In either case, you need sufficient permissions to release `@tryghost` packages on NPM. @@ -36,4 +39,4 @@ If the CI doesn't clear JsDelivr cache to get the new version out instantly, you # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). diff --git a/apps/ember-admin/README.md b/apps/ember-admin/README.md index 95939f71d3b..c4e415164c2 100644 --- a/apps/ember-admin/README.md +++ b/apps/ember-admin/README.md @@ -56,4 +56,4 @@ ember exam --help # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage. +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage. diff --git a/apps/ember-admin/app/components/gh-explore-iframe.hbs b/apps/ember-admin/app/components/gh-explore-iframe.hbs deleted file mode 100644 index 25f3309c64b..00000000000 --- a/apps/ember-admin/app/components/gh-explore-iframe.hbs +++ /dev/null @@ -1,9 +0,0 @@ - \ No newline at end of file diff --git a/apps/ember-admin/app/components/gh-explore-iframe.js b/apps/ember-admin/app/components/gh-explore-iframe.js deleted file mode 100644 index eec811159a7..00000000000 --- a/apps/ember-admin/app/components/gh-explore-iframe.js +++ /dev/null @@ -1,77 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; - -export default class GhExploreIframe extends Component { - @service explore; - @service router; - @service feature; - - constructor() { - super(...arguments); - window.addEventListener('message', this.handleIframeMessage); - } - - willDestroy() { - super.willDestroy(...arguments); - window.removeEventListener('message', this.handleIframeMessage); - } - - @action - setup() { - // Only begin setup when Explore window is toggled open - // to avoid unnecessary loading of assets - if (this.explore.exploreWindowOpen) { - this.explore.getExploreIframe().src = this.explore.iframeURL; - } - } - - @action - async handleIframeMessage(event) { - if (this.isDestroyed || this.isDestroying) { - return; - } - - // only process messages coming from the explore iframe - if (event?.data && this.explore.iframeURL.includes(event?.origin)) { - if (event.data?.request === 'apiUrl') { - this._handleUrlRequest(); - } - - if (event.data?.route) { - this._handleRouteUpdate(event.data); - } - - if (event.data?.siteData) { - this._handleSiteDataUpdate(event.data); - } - } - } - - @action - async handleDarkModeChange() { - if (this.explore.exploreWindowOpen) { - this.explore.sendUIUpdate({darkMode: this.feature.nightShift}); - } - } - - // The iframe can send route updates to navigate to within Admin, as some routes - // have to be rendered within the iframe and others require to break out of it. - _handleRouteUpdate(data) { - const route = data.route; - this.explore.isIframeTransition = route?.includes('/explore'); - this.explore.toggleExploreWindow(this.explore.isIframeTransition); - this.router.transitionTo(route); - } - - _handleUrlRequest() { - this.explore.getExploreIframe().contentWindow.postMessage({ - request: 'apiUrl', - response: {apiUrl: this.explore.apiUrl, darkMode: this.feature.nightShift} - }, '*'); - } - - _handleSiteDataUpdate(data) { - this.explore.siteData = data?.siteData ?? {}; - } -} diff --git a/apps/ember-admin/app/components/gh-explore-modal.hbs b/apps/ember-admin/app/components/gh-explore-modal.hbs deleted file mode 100644 index e05dcbcfde9..00000000000 --- a/apps/ember-admin/app/components/gh-explore-modal.hbs +++ /dev/null @@ -1,5 +0,0 @@ -
-
- -
-
\ No newline at end of file diff --git a/apps/ember-admin/app/components/gh-explore-modal.js b/apps/ember-admin/app/components/gh-explore-modal.js deleted file mode 100644 index 90b5bf04b6d..00000000000 --- a/apps/ember-admin/app/components/gh-explore-modal.js +++ /dev/null @@ -1,10 +0,0 @@ -import Component from '@glimmer/component'; -import {inject as service} from '@ember/service'; - -export default class GhExploreModal extends Component { - @service explore; - - get visibilityClass() { - return this.explore.exploreWindowOpen ? 'gh-explore' : 'gh-explore closed'; - } -} diff --git a/apps/ember-admin/app/components/gh-member-details-activity.hbs b/apps/ember-admin/app/components/gh-member-details-activity.hbs index f5faa1aac37..3d314e5e279 100644 --- a/apps/ember-admin/app/components/gh-member-details-activity.hbs +++ b/apps/ember-admin/app/components/gh-member-details-activity.hbs @@ -28,7 +28,7 @@ {{/if}}

- View member profile → + View member profile →

diff --git a/apps/ember-admin/app/components/gh-member-label-input.hbs b/apps/ember-admin/app/components/gh-member-label-input.hbs deleted file mode 100644 index ab4e00d819b..00000000000 --- a/apps/ember-admin/app/components/gh-member-label-input.hbs +++ /dev/null @@ -1,42 +0,0 @@ - -
- - {{label.name}} - - {{#if (and @allowEdit label.slug)}} - - {{/if}} -
-
diff --git a/apps/ember-admin/app/components/gh-member-label-input.js b/apps/ember-admin/app/components/gh-member-label-input.js deleted file mode 100644 index 0221bcf3486..00000000000 --- a/apps/ember-admin/app/components/gh-member-label-input.js +++ /dev/null @@ -1,173 +0,0 @@ -import Component from '@glimmer/component'; -import {TrackedArray} from 'tracked-built-ins'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -export default class GhMemberLabelInput extends Component { - @service store; - @service labelsManager; - - @tracked _searchedLabels = new TrackedArray(); - - _searchedLabelsQuery = null; - _searchedLabelsMeta = null; - - _powerSelectAPI = null; - - get availableLabels() { - const selectedLabels = this.selectedLabels; - return this.labelsManager.labels.filter(label => !selectedLabels.includes(label)); - } - - get useServerSideSearch() { - return !this.labelsManager.hasLoadedAll; - } - - get selectedLabels() { - if (typeof this.args.labels === 'object') { - if (this.args.labels?.length && typeof this.args.labels[0] === 'string') { - return this.args.labels.map((d) => { - return this.labelsManager.findBySlug(d); - }).filter(Boolean); - } - return this.args.labels || []; - } - return []; - } - - @action - addSearchedLabels(labels) { - const existingIds = new Set([ - ...this.selectedLabels.map(l => l.id), - ...this._searchedLabels.map(l => l.id) - ]); - const deduplicatedLabels = labels.filter(label => !existingIds.has(label.id)); - this._searchedLabels.push(...deduplicatedLabels); - } - - @action - registerPowerSelectAPI(api) { - this._powerSelectAPI = api; - } - - @action - async loadInitialLabels() { - if (!this.labelsManager.hasLoaded) { - await this.labelsManager.loadMoreTask.perform(); - } - } - - @task({drop: true}) - *loadMoreLabelsTask() { - const isSearch = !!this._powerSelectAPI?.searchText; - if (isSearch) { - if (!this.useServerSideSearch) { - return; - } - - if (this.searchLabelsTask.isRunning) { - return; - } - - if (!this._searchedLabelsMeta || (this._searchedLabelsMeta.pagination.pages <= this._searchedLabelsMeta.pagination.page)) { - return; - } - - const page = this._searchedLabelsMeta.pagination.page + 1; - const labels = yield this.labelsManager.searchLabelsTask.perform(this._searchedLabelsQuery, {page}); - this.addSearchedLabels(labels.toArray()); - this._searchedLabelsMeta = labels.meta; - } else { - yield this.labelsManager.loadMoreTask.perform(); - } - } - - @task({restartable: true}) - *searchLabelsTask(term) { - this._searchedLabelsQuery = term; - const labels = yield this.labelsManager.searchLabelsTask.perform(term); - this._searchedLabelsMeta = labels.meta; - - this._searchedLabels = new TrackedArray(); - this.addSearchedLabels(labels.toArray()); - return this._searchedLabels; - } - - @action - showCreateWhen(term) { - const availableLabelNames = this._searchedLabels.map(label => label.name.toLowerCase()); - availableLabelNames.push(...this.selectedLabels.map(label => label.name.toLowerCase())); - - const foundMatchingLabelName = availableLabelNames.includes(term.toLowerCase()); - return !foundMatchingLabelName; - } - - willDestroy() { - super.willDestroy?.(...arguments); - this.store.peekAll('label').forEach((label) => { - if (label.get('isNew')) { - this.store.deleteRecord(label); - } - }); - } - - @action - updateLabels(newLabels) { - let currentLabels = this.selectedLabels; - - // destroy new+unsaved labels that are no longer selected - currentLabels.forEach(function (label) { - if (!newLabels.includes(label) && label.get('isNew')) { - label.destroyRecord(); - } - }); - - this.args.onChange(newLabels); - } - - @action - editLabel(label, event) { - event.stopPropagation(); - this.args.onLabelEdit?.(label.slug); - } - - @action - createLabel(labelName) { - let currentLabels = this.selectedLabels; - let currentLabelNames = currentLabels.map(label => label.get('name').toLowerCase()); - let labelToAdd; - - labelName = labelName.trim(); - - // abort if label is already selected - if (currentLabelNames.includes(labelName.toLowerCase())) { - return; - } - - // find existing label if there is one - labelToAdd = this._findLabelByName(labelName); - - // create new label if no match - if (!labelToAdd) { - labelToAdd = this.store.createRecord('label', { - name: labelName - }); - } - - // push label onto member relationship - currentLabels.pushObject(labelToAdd); - this.args.onChange(currentLabels); - } - - _findLabelByName(name) { - let withMatchingName = function (label) { - if (label.__isSuggestion__) { - return false; - } - return label.name.toLowerCase() === name.toLowerCase(); - }; - return this._searchedLabels.find(withMatchingName) || this.labelsManager.labels.find(withMatchingName); - } -} diff --git a/apps/ember-admin/app/components/gh-member-settings-form.hbs b/apps/ember-admin/app/components/gh-member-settings-form.hbs deleted file mode 100644 index 085995188d1..00000000000 --- a/apps/ember-admin/app/components/gh-member-settings-form.hbs +++ /dev/null @@ -1,311 +0,0 @@ -
-
- - -
-
-
-
- - - - - - - - - - - -
- - - - - - - - - - -

Maximum: 500 characters. You've used - {{gh-count-down-characters this.scratchMember.note 500}}

-
-
-
- - {{#if this.canShowMultipleNewsletters}} - - {{/if}} - - {{#if this.membersUtils.paidMembersEnabled}} -

Subscriptions

- - {{#unless this.tiers}} -
-
- {{#unless this.isCreatingComplimentary}} -
- {{svg-jar "no-data-subscription"}} -

No subscriptions

-
- {{/unless}} - - {{#if this.isAddComplimentaryAllowed}} - {{#if this.isCreatingComplimentary}} - - {{else}} - - {{/if}} - {{/if}} -
-
- {{/unless}} - - {{#if this.tiers}} -
-
- {{#each this.tiers as |tier|}} - {{#each tier.subscriptions as |sub index|}} -
-
-
- {{#if sub.hasActiveDiscount}} - {{sub.discountedPrice.currencySymbol}} - {{gh-price-amount sub.discountedPrice.amount}} - {{sub.originalPrice.currencySymbol}}{{gh-price-amount sub.originalPrice.amount}} - {{else}} - {{sub.price.currencySymbol}} - {{gh-price-amount sub.price.amount}} - {{/if}} -
-
{{if (eq sub.price.interval "year") "yearly" "monthly"}}
-
-
-

- {{tier.name}} - {{#if (eq sub.status "canceled")}} - Canceled - {{else if sub.cancel_at_period_end}} - Canceled - {{else if sub.compExpiry}} - Active - {{else if sub.giftExpiry}} - Active - {{else if sub.trialUntil}} - Active - {{else}} - Active - {{/if}} - {{#if (gt tier.subscriptions.length 1)}} - {{tier.subscriptions.length}} subscriptions - {{/if}} -

-
- {{sub.priceLabel}} - {{sub.validityDetails}} -
- -
- {{#if sub.isGift}} - {{! Gift subscriptions have no action menu }} - {{else if sub.isComplimentary}} - - - - {{svg-jar "dotdotdot"}} - - - - -
  • - -
  • -
    -
    - {{else}} - - - - {{svg-jar "dotdotdot"}} - - - - -
  • - - View Stripe customer - -
  • -
  • -
  • - - View Stripe subscription - -
  • -
  • - {{#if (not-eq sub.status "canceled")}} - {{#if sub.cancel_at_period_end}} - - {{else}} - - {{/if}} - {{/if}} -
  • -
    -
    - {{/if}} -
    - {{/each}} - - {{#if (eq tier.subscriptions.length 0)}} -
    -
    -
    - Complimentary - Active -
    -
    Created on
    -
    -
    -
    -
    - $ - 0 -
    -
    yearly
    -
    - - - - {{svg-jar "dotdotdot"}} - - - - -
  • - -
  • -
    -
    -
    -
    - {{/if}} - {{/each}} -
    -
    - {{/if}} - - {{#if (and this.tiers this.isAddComplimentaryAllowed)}} - - {{/if}} - {{/if}} - - -
    - -
    - -
    - -{{#if this.showMemberTierModal}} - - - -{{/if}} diff --git a/apps/ember-admin/app/components/gh-member-settings-form.js b/apps/ember-admin/app/components/gh-member-settings-form.js deleted file mode 100644 index 900cc64701b..00000000000 --- a/apps/ember-admin/app/components/gh-member-settings-form.js +++ /dev/null @@ -1,242 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {didCancel, task} from 'ember-concurrency'; -import {getSubscriptionData} from 'ghost-admin/utils/subscription-data'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -export default class extends Component { - @service membersUtils; - @service ghostPaths; - @service ajax; - @service store; - @service feature; - @service settings; - - constructor(...args) { - super(...args); - this.member = this.args.member; - this.scratchMember = this.args.scratchMember; - } - - @tracked showMemberTierModal = false; - @tracked tiersList; - @tracked newslettersList; - @tracked loadingSubscriptionId = null; - - get isAddComplimentaryAllowed() { - if (!this.membersUtils.paidMembersEnabled) { - return false; - } - - if (this.member.get('isNew')) { - return false; - } - - if (this.member.get('tiers')?.length > 0) { - return false; - } - - // complimentary subscriptions are assigned to tiers so it only - // makes sense to show the "add complimentary" buttons when there's a - // tier to assign the complimentary subscription to - const hasAnActivePaidTier = !!this.tiersList?.length; - - return hasAnActivePaidTier; - } - - get isCreatingComplimentary() { - return this.args.isSaveRunning; - } - - get tiers() { - let subscriptions = this.member.get('subscriptions') || []; - - // Create the tiers from `subscriptions.price.tier` - let tiers = subscriptions - .map(subscription => (subscription.tier || subscription.price.tier)) - .filter((value, index, self) => { - // Deduplicate by taking the first object by `id` - return typeof value.id !== 'undefined' && self.findIndex(element => (element.tier_id || element.id) === (value.tier_id || value.id)) === index; - }); - - let subsWithPrice = subscriptions.filter(sub => !!sub.price); - let subscriptionData = subsWithPrice.map(sub => getSubscriptionData(sub)); - - return tiers.map((tier) => { - let tierSubscriptions = subscriptionData.filter((subscription) => { - return subscription?.price?.tier?.tier_id === (tier.tier_id || tier.id); - }); - return { - ...tier, - subscriptions: tierSubscriptions - }; - }); - } - - get customer() { - let firstSubscription = this.member.get('subscriptions').firstObject; - let customer = firstSubscription?.customer; - - if (customer) { - return { - ...customer, - startDate: firstSubscription?.startDate - }; - } - return null; - } - - get canShowMultipleNewsletters() { - return ( - this.settings.editorDefaultEmailRecipients !== 'disabled' - ); - } - - @action - updateNewsletterPreference(event) { - if (!event.target.checked) { - this.member.set('newsletters', []); - } else if (this.newslettersList.firstObject) { - const newsletter = this.newslettersList.firstObject; - this.member.set('newsletters', [newsletter]); - } - } - - @action - setup() { - try { - this.fetchTiers.perform(); - this.fetchNewsletters.perform(); - } catch (e) { - // Do not throw cancellation errors - if (didCancel(e)) { - return; - } - - throw e; - } - } - - @action - setProperty(property, value) { - this.args.setProperty(property, value); - } - - @action - updateProperty(event){ - this.args.setProperty(event.currentTarget.name, event.target.value); - } - - @action - setLabels(labels) { - this.member.set('labels', labels); - } - - @action - setMemberNewsletters(newsletters) { - this.member.set('newsletters', newsletters); - } - - @action - closeMemberTierModal() { - this.showMemberTierModal = false; - } - - @action - cancelSubscription(subscriptionId) { - this.loadingSubscriptionId = subscriptionId; - this.cancelSubscriptionTask.perform(subscriptionId); - } - - @action - removeComplimentary(tierId) { - this.loadingSubscriptionId = `complimentary-${tierId}`; - this.removeComplimentaryTask.perform(tierId); - } - - @action - continueSubscription(subscriptionId) { - this.loadingSubscriptionId = subscriptionId; - this.continueSubscriptionTask.perform(subscriptionId); - } - - @task({drop: true}) - *cancelSubscriptionTask(subscriptionId) { - try { - let url = this.ghostPaths.url.api('members', this.member.get('id'), 'subscriptions', subscriptionId); - - let response = yield this.ajax.put(url, { - data: { - cancel_at_period_end: true - } - }); - - this.store.pushPayload('member', response); - return response; - } finally { - this.loadingSubscriptionId = null; - } - } - - @task({drop: true}) - *removeComplimentaryTask(tierId) { - try { - let url = this.ghostPaths.url.api(`members/${this.member.get('id')}`); - let tiers = this.member.get('tiers') || []; - - const updatedTiers = tiers - .filter(tier => tier.id !== tierId) - .map(tier => ({id: tier.id})); - - let response = yield this.ajax.put(url, { - data: { - members: [{ - id: this.member.get('id'), - email: this.member.get('email'), - tiers: updatedTiers - }] - } - }); - - this.store.pushPayload('member', response); - return response; - } finally { - this.loadingSubscriptionId = null; - } - } - - @task({drop: true}) - *continueSubscriptionTask(subscriptionId) { - try { - let url = this.ghostPaths.url.api('members', this.member.get('id'), 'subscriptions', subscriptionId); - - let response = yield this.ajax.put(url, { - data: { - cancel_at_period_end: false - } - }); - - this.store.pushPayload('member', response); - return response; - } finally { - this.loadingSubscriptionId = null; - } - } - - @task({drop: true}) - *fetchTiers() { - this.tiersList = yield this.store.query('tier', {filter: 'type:paid+active:true', include: 'monthly_price,yearly_price'}); - } - - @task({drop: true}) - *fetchNewsletters() { - this.newslettersList = yield this.store.query('newsletter', {filter: 'status:active'}); - if (this.member.get('isNew')) { - const defaultNewsletters = this.newslettersList.filter((newsletter) => { - return newsletter.subscribeOnSignup && newsletter.visibility === 'members'; - }); - this.setMemberNewsletters(defaultNewsletters); - } - } -} diff --git a/apps/ember-admin/app/components/member/activity-feed-empty.hbs b/apps/ember-admin/app/components/member/activity-feed-empty.hbs deleted file mode 100644 index 5b13e14eda0..00000000000 --- a/apps/ember-admin/app/components/member/activity-feed-empty.hbs +++ /dev/null @@ -1,7 +0,0 @@ -
    -
    {{svg-jar "no-data-list"}}
    -

    Activity

    -

    - All events related to this member will be shown here. -

    -
    \ No newline at end of file diff --git a/apps/ember-admin/app/components/member/activity-feed.hbs b/apps/ember-admin/app/components/member/activity-feed.hbs deleted file mode 100644 index c0a35ddb0c1..00000000000 --- a/apps/ember-admin/app/components/member/activity-feed.hbs +++ /dev/null @@ -1,70 +0,0 @@ -

    Activity

    -{{#if @member.isNew}} -
    -
    - -
    -
    -{{else}} - {{#let (members-event-fetcher filter=(members-event-filter member=@member.id excludedEvents=this.excludedEventTypes) pageSize=5 memberId=@member.id) as |eventsFetcher|}} -
    -
    -
    - {{#if eventsFetcher.isLoading}} -
    - {{else if eventsFetcher.data}} - {{#each eventsFetcher.data as |rawEvent|}} - {{#let (parse-member-event rawEvent eventsFetcher.hasMultipleNewsletters) as |event|}} -
    -
    -
    - {{svg-jar event.icon class=event.iconClass}} -
    -
    -
    - - - {{capitalize-first-letter event.action}} - {{#if event.info}} - ({{event.info}}) - {{/if}} - {{#if event.route}} - {{event.join}} - {{event.object}} - {{else if event.url}} - {{event.join}} - {{event.object}} - {{else if event.email}} - {{event.join}} - - {{/if}} - - {{#if event.description}} -
    -
    - {{event.description}} -
    -
    - {{/if}} -
    -
    -
    - {{moment-from-now event.timestamp}} -
    -
    -
    -
    - {{/let}} - {{/each}} - - - {{else}} - - {{/if}} -
    -
    -
    - {{/let}} -{{/if}} diff --git a/apps/ember-admin/app/components/member/activity-feed.js b/apps/ember-admin/app/components/member/activity-feed.js deleted file mode 100644 index 29cbda7aff8..00000000000 --- a/apps/ember-admin/app/components/member/activity-feed.js +++ /dev/null @@ -1,36 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; - -export default class ActivityFeed extends Component { - @service feature; - - linkScrollerTimeout = null; // needs to be global so can be cleared when needed across functions - excludedEventTypes = ['aggregated_click_event']; - - @action - enterLinkURL(event) { - event.stopPropagation(); - const parent = event.target; - const child = event.target.querySelector('span'); - - clearTimeout(this.linkScrollerTimeout); - if (child.offsetWidth > parent.offsetWidth) { - this.linkScrollerTimeout = setTimeout(() => { - parent.classList.add('scroller'); - child.style.transform = `translateX(-${(child.offsetWidth - parent.offsetWidth) + 8}px)`; - }, 100); - } - } - - @action - leaveLinkURL(event) { - event.stopPropagation(); - clearTimeout(this.linkScrollerTimeout); - const parent = event.target; - const child = event.target.querySelector('span'); - - child.style.transform = 'translateX(0)'; - parent.classList.remove('scroller'); - } -} diff --git a/apps/ember-admin/app/components/member/newsletter-preference.hbs b/apps/ember-admin/app/components/member/newsletter-preference.hbs deleted file mode 100644 index d92f876b32e..00000000000 --- a/apps/ember-admin/app/components/member/newsletter-preference.hbs +++ /dev/null @@ -1,65 +0,0 @@ -

    Newsletters

    -
    - {{#unless this.suppressionData.suppressed}} - {{#each this.newsletters as |newsletter|}} -
    -
    -

    {{newsletter.name}}

    -
    -
    - -
    -
    - {{/each}} - {{/unless}} - - {{#if this.suppressionData.suppressed}} -
    - {{#if (eq this.suppressionData.reason 'fail')}} - {{svg-jar "suppression-notice-bounced" class="gh-member-newsletter-icon"}} - {{/if}} - - {{#if (eq this.suppressionData.reason 'spam')}} - {{svg-jar "suppression-notice-flagged" class="gh-member-newsletter-icon"}} - {{/if}} - -

    Email disabled

    - -

    - {{#if (eq this.suppressionData.reason 'fail')}} - Bounced on {{this.suppressionData.date}} - {{/if}} - - {{#if (eq this.suppressionData.reason 'spam')}} - Flagged as spam on {{this.suppressionData.date}} - {{/if}} - - Learn more -

    - - -
    - {{else}} - - {{/if}} -
    diff --git a/apps/ember-admin/app/components/member/newsletter-preference.js b/apps/ember-admin/app/components/member/newsletter-preference.js deleted file mode 100644 index c1cc6809b5b..00000000000 --- a/apps/ember-admin/app/components/member/newsletter-preference.js +++ /dev/null @@ -1,94 +0,0 @@ -import Component from '@glimmer/component'; -import moment from 'moment-timezone'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -export default class MembersNewsletterPreference extends Component { - @service ajax; - @service notifications; - @service ghostPaths; - @service store; - - @tracked filterValue; - @tracked isReEnabling = false; - - constructor(...args) { - super(...args); - } - - get newsletters() { - if (this.args.newsletters?.length > 0) { - return this.args.newsletters.map((d) => { - return { - name: d.name, - description: d.description, - subscribed: !!this.args.member?.newsletters?.find((n) => { - return n.id === d.id; - }), - id: d.id, - forId: `${d.id}-checkbox` - }; - }); - } - return []; - } - - get suppressionData() { - const {emailSuppression} = this.args.member; - const timestamp = emailSuppression?.info?.timestamp; - const formattedDate = timestamp ? moment(new Date(timestamp)).format('D MMM YYYY') : null; - - return { - suppressed: emailSuppression?.suppressed, - reason: emailSuppression?.info?.reason, - date: formattedDate - }; - } - - @action - updateNewsletterPreference(newsletter, event) { - let updatedNewsletters = []; - - const selectedNewsletter = this.args.newsletters.find((d) => { - return d.id === newsletter.id; - }); - - if (!event.target.checked) { - updatedNewsletters = this.args.member.newsletters.filter((d) => { - return d.id !== newsletter.id; - }); - } else { - updatedNewsletters = this.args.member.newsletters.filter((d) => { - return d.id !== newsletter.id; - }).concat(selectedNewsletter); - } - this.args.setMemberNewsletters(updatedNewsletters); - } - - @action - async reEnableEmail() { - this.isReEnabling = true; - - try { - const url = `${this.ghostPaths.url.api('members', this.args.member.id)}suppression`; - await this.ajax.delete(url); - - // Refresh the member data to get updated suppression status - await this.store.findRecord('member', this.args.member.id, { - reload: true, - include: 'tiers' - }); - - this.notifications.showNotification('Email re-enabled successfully', { - type: 'success' - }); - } catch (error) { - this.notifications.showAlert('Failed to re-enable email. Please try again.', { - type: 'error' - }); - } finally { - this.isReEnabling = false; - } - } -} diff --git a/apps/ember-admin/app/components/member/subscription-detail-box.hbs b/apps/ember-admin/app/components/member/subscription-detail-box.hbs deleted file mode 100644 index d23beef001d..00000000000 --- a/apps/ember-admin/app/components/member/subscription-detail-box.hbs +++ /dev/null @@ -1,49 +0,0 @@ - -
    -
    -
    -

    - Created — {{@sub.startDate}} -

    - {{#if (eq @sub.attribution.referrerSource 'Unknown')}} - {{else}} -

    - Source — {{@sub.attribution.referrerSource}} -

    - {{/if}} - {{#if (and @sub.attribution @sub.attribution.title)}} -

    - {{#if (and @sub.attribution.id (eq @sub.attribution.type "post"))}} - Page — {{ @sub.attribution.title }} - {{else if @sub.attribution.url}} - Page — {{ @sub.attribution.title }} - {{else}} - Page — {{ @sub.attribution.title }} - {{/if}} -

    - {{/if}} -
    - {{#if this.formattedOffers.length}} -
    -
    - {{#each this.formattedOffers as |offer|}} -

    - {{offer.label}} — {{offer.detail}} -

    - {{/each}} -
    -
    - {{/if}} - {{#if @sub.cancellationReason}} -
    -
    -

    Cancellation reason

    -
    {{@sub.cancellationReason}}
    -
    -
    - {{/if}} -
    -
    diff --git a/apps/ember-admin/app/components/member/subscription-detail-box.js b/apps/ember-admin/app/components/member/subscription-detail-box.js deleted file mode 100644 index d58ac5bef1c..00000000000 --- a/apps/ember-admin/app/components/member/subscription-detail-box.js +++ /dev/null @@ -1,17 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {getOfferDisplayData} from 'ghost-admin/utils/subscription-data'; -import {tracked} from '@glimmer/tracking'; - -export default class SubscriptionDetailBox extends Component { - @tracked showDetails = false; - - get formattedOffers() { - return (this.args.sub.offer_redemptions ?? []).map(offer => getOfferDisplayData(offer, this.args.sub)); - } - - @action - toggleSubscriptionExpanded() { - this.showDetails = !this.showDetails; - } -} diff --git a/apps/ember-admin/app/components/members/modals/delete-member.hbs b/apps/ember-admin/app/components/members/modals/delete-member.hbs deleted file mode 100644 index 8301e9b3419..00000000000 --- a/apps/ember-admin/app/components/members/modals/delete-member.hbs +++ /dev/null @@ -1,52 +0,0 @@ - diff --git a/apps/ember-admin/app/components/members/modals/delete-member.js b/apps/ember-admin/app/components/members/modals/delete-member.js deleted file mode 100644 index 78db057f3fd..00000000000 --- a/apps/ember-admin/app/components/members/modals/delete-member.js +++ /dev/null @@ -1,47 +0,0 @@ -import Component from '@glimmer/component'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -export default class DeleteMemberModal extends Component { - @service notifications; - - @tracked shouldCancelSubscriptions = false; - - get member() { - return this.args.data.member; - } - - get hasActiveStripeSubscriptions() { - const subscriptions = this.member.get('subscriptions'); - - if (!subscriptions || subscriptions.length === 0) { - return false; - } - - const firstActiveStripeSubscription = subscriptions.find((subscription) => { - return ['active', 'trialing', 'unpaid', 'past_due'].includes(subscription.status); - }); - - return firstActiveStripeSubscription !== undefined; - } - - @task({drop: true}) - *deleteMemberTask() { - const options = { - adapterOptions: { - cancel: this.shouldCancelSubscriptions - } - }; - - try { - yield this.member.destroyRecord(options); - this.args.data.afterDelete?.(); - this.args.close(true); - } catch (e) { - this.notifications.showAPIError(e, {key: 'member.delete'}); - this.args.close(false); - throw e; - } - } -} diff --git a/apps/ember-admin/app/components/members/modals/disable-commenting.hbs b/apps/ember-admin/app/components/members/modals/disable-commenting.hbs deleted file mode 100644 index 39591a41081..00000000000 --- a/apps/ember-admin/app/components/members/modals/disable-commenting.hbs +++ /dev/null @@ -1,45 +0,0 @@ - diff --git a/apps/ember-admin/app/components/members/modals/disable-commenting.js b/apps/ember-admin/app/components/members/modals/disable-commenting.js deleted file mode 100644 index 07e657ae0cd..00000000000 --- a/apps/ember-admin/app/components/members/modals/disable-commenting.js +++ /dev/null @@ -1,39 +0,0 @@ -import Component from '@glimmer/component'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -export default class DisableCommentingModal extends Component { - @service notifications; - @service ajax; - @service ghostPaths; - - @tracked hideComments = false; - - get member() { - return this.args.data.member; - } - - @task({drop: true}) - *disableCommentingTask() { - try { - const url = this.ghostPaths.url.api('members', this.member.id, 'commenting', 'disable'); - yield this.ajax.post(url, { - data: JSON.stringify({ - reason: 'Disabled from member settings', - hide_comments: this.hideComments - }), - contentType: 'application/json' - }); - - this.args.data.afterDisable?.(); - this.notifications.showNotification(`Commenting has been disabled for ${this.member.name || this.member.email}.`, {type: 'success'}); - this.args.close(true); - return true; - } catch (e) { - this.notifications.showAPIError(e, {key: 'member.disable-commenting'}); - this.args.close(false); - throw e; - } - } -} diff --git a/apps/ember-admin/app/components/members/modals/logout-member.hbs b/apps/ember-admin/app/components/members/modals/logout-member.hbs deleted file mode 100644 index 824fd5615ac..00000000000 --- a/apps/ember-admin/app/components/members/modals/logout-member.hbs +++ /dev/null @@ -1,30 +0,0 @@ - diff --git a/apps/ember-admin/app/components/members/modals/logout-member.js b/apps/ember-admin/app/components/members/modals/logout-member.js deleted file mode 100644 index f07de06de55..00000000000 --- a/apps/ember-admin/app/components/members/modals/logout-member.js +++ /dev/null @@ -1,31 +0,0 @@ -import Component from '@glimmer/component'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; - -export default class LogoutMemberModal extends Component { - @service notifications; - @service ajax; - @service ghostPaths; - - get member() { - return this.args.data.member; - } - - @task({drop: true}) - *logoutMemberTask() { - try { - const url = this.ghostPaths.url.api('/members/', this.member.id, '/sessions/'); - const options = {}; - yield this.ajax.delete(url, options); - - this.args.data.afterLogout?.(); - this.notifications.showNotification(`${this.member.name || this.member.email} has been signed out from all devices.`, {type: 'success'}); - this.args.close(true); - return true; - } catch (e) { - this.notifications.showAPIError(e, {key: 'member.logout'}); - this.args.close(false); - throw e; - } - } -} diff --git a/apps/ember-admin/app/components/modal-member-tier.hbs b/apps/ember-admin/app/components/modal-member-tier.hbs deleted file mode 100644 index 4486cb2c432..00000000000 --- a/apps/ember-admin/app/components/modal-member-tier.hbs +++ /dev/null @@ -1,89 +0,0 @@ - - - -
    - -
    - - \ No newline at end of file diff --git a/apps/ember-admin/app/components/modal-member-tier.js b/apps/ember-admin/app/components/modal-member-tier.js deleted file mode 100644 index 733d08e3e6b..00000000000 --- a/apps/ember-admin/app/components/modal-member-tier.js +++ /dev/null @@ -1,185 +0,0 @@ -import ModalComponent from 'ghost-admin/components/modal-base'; -import moment from 'moment-timezone'; -import {action} from '@ember/object'; -import {didCancel, task} from 'ember-concurrency'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -export default class ModalMemberTier extends ModalComponent { - @service store; - @service ghostPaths; - @service ajax; - - @tracked price; - @tracked tier; - @tracked tiers = []; - @tracked selectedTier = null; - @tracked loadingTiers = false; - @tracked expiryAt = 'forever'; - @tracked customExpiryDate = moment().startOf('day'); - - @tracked expiryOptions = [ - { - label: 'Forever', - duration: 'forever' - }, - { - label: '1 Week', - duration: 'week' - }, - { - label: '1 Month', - duration: 'month' - }, - { - label: '6 Months', - duration: 'half-year' - }, - { - label: '1 Year', - duration: 'year' - }, - { - label: 'Custom', - duration: 'custom' - } - ]; - - @task({drop: true}) - *fetchTiers() { - this.tiers = yield this.store.query('tier', {filter: 'type:paid+active:true', include: 'monthly_price,yearly_price,benefits'}); - - this.loadingTiers = false; - if (this.tiers.length > 0) { - this.selectedTier = this.tiers.firstObject.id; - } - } - - get activeSubscriptions() { - const subscriptions = this.member.get('subscriptions') || []; - return subscriptions.filter((sub) => { - return ['active', 'trialing', 'unpaid', 'past_due'].includes(sub.status); - }); - } - - get member() { - return this.model; - } - - get cannotAddPrice() { - return !this.price || this.price.amount !== 0; - } - - get minCustomDate() { - return moment().startOf('day'); - } - - @action - setup() { - this.loadingTiers = true; - try { - this.fetchTiers.perform(); - } catch (e) { - // Do not throw cancellation errors - if (didCancel(e)) { - return; - } - - throw e; - } - } - - @action - setTier(tierId) { - this.selectedTier = tierId; - } - - @action - setPrice(price) { - this.price = price; - } - - @action - confirmAction() { - return this.addTier.perform(); - } - - @action - close(event) { - event?.preventDefault?.(); - this.closeModal(); - } - - @action - updateExpiry(expiryDuration) { - this.expiryAt = expiryDuration; - } - - @action - updateCustomExpiryDate(date) { - this.customExpiryDate = moment(date).startOf('day'); - } - - @task({drop: true}) - *addTier() { - const url = `${this.ghostPaths.url.api(`members/${this.member.get('id')}`)}?include=tiers`; - - // Cancel existing active subscriptions for member - for (let i = 0; i < this.activeSubscriptions.length; i++) { - const subscription = this.activeSubscriptions[i]; - const cancelUrl = this.ghostPaths.url.api(`members/${this.member.get('id')}/subscriptions/${subscription.id}`); - yield this.ajax.put(cancelUrl, { - data: { - status: 'canceled' - } - }); - } - - let expiryAt = null; - - if (this.expiryAt === 'week') { - expiryAt = moment.utc().add(7, 'days').startOf('day').toISOString(); - } else if (this.expiryAt === 'month') { - expiryAt = moment.utc().add(1, 'month').startOf('day').toISOString(); - } else if (this.expiryAt === 'half-year') { - expiryAt = moment.utc().add(6, 'months').startOf('day').toISOString(); - } else if (this.expiryAt === 'year') { - expiryAt = moment.utc().add(1, 'year').startOf('day').toISOString(); - } else if (this.expiryAt === 'custom') { - expiryAt = this.customExpiryDate - .endOf('day') - .set('milliseconds', 0) // Prevent db rounding up to the next day - .add(moment().utcOffset(), 'minutes') // Adjust for timezone offset - .toISOString(); - } - const tiersData = { - id: this.selectedTier - }; - if (expiryAt) { - tiersData.expiry_at = expiryAt; - } - const response = yield this.ajax.put(url, { - data: { - members: [{ - id: this.member.get('id'), - email: this.member.get('email'), - tiers: [tiersData] - }] - } - }); - - this.store.pushPayload('member', response); - this.closeModal(); - return response; - } - - actions = { - confirm() { - this.confirmAction(...arguments); - }, - // needed because ModalBase uses .send() for keyboard events - closeModal() { - this.close(); - } - }; -} diff --git a/apps/ember-admin/app/components/posts/debug.hbs b/apps/ember-admin/app/components/posts/debug.hbs index 6d4661a78f6..92ce4d1aa94 100644 --- a/apps/ember-admin/app/components/posts/debug.hbs +++ b/apps/ember-admin/app/components/posts/debug.hbs @@ -68,13 +68,13 @@
    {{#if failure.member.id}} - +

    {{failure.recipient.name}}

    {{failure.recipient.email}}

    -
    + {{else}}
    @@ -125,13 +125,13 @@
    {{#if failure.member.id}} - +

    {{failure.recipient.name}}

    {{failure.recipient.email}}

    -
    + {{else}}
    diff --git a/apps/ember-admin/app/controllers/application.js b/apps/ember-admin/app/controllers/application.js index 820b40f2a47..212cb4c908e 100644 --- a/apps/ember-admin/app/controllers/application.js +++ b/apps/ember-admin/app/controllers/application.js @@ -6,7 +6,6 @@ import {inject as service} from '@ember/service'; export default class ApplicationController extends Controller { @service billing; - @service explore; @service router; @service session; @service settings; diff --git a/apps/ember-admin/app/controllers/explore.js b/apps/ember-admin/app/controllers/explore.js deleted file mode 100644 index 207b0e134d5..00000000000 --- a/apps/ember-admin/app/controllers/explore.js +++ /dev/null @@ -1,58 +0,0 @@ -import Controller from '@ember/controller'; -import {action} from '@ember/object'; -import {inject} from 'ghost-admin/decorators/inject'; -import {inject as service} from '@ember/service'; - -export default class ExploreController extends Controller { - @inject config; - @service explore; - @service router; - - get exploreCredentials() { - const explore = this.model.findBy('slug', 'ghost-explore'); - const adminKey = explore.adminKey; - - return adminKey.secret; - } - - get visibilityClass() { - return this.explore.isIframeTransition ? 'explore iframe-explore-container' : ' explore fullscreen-explore-container'; - } - - @action - closeConnect() { - if (this.explore.isIframeTransition) { - this.explore.sendRouteUpdate({path: '/explore'}); - this.router.transitionTo('/explore'); - } else { - this.router.transitionTo('/analytics'); - } - } - - @action - submitExploreSite() { - const token = this.exploreCredentials; - const apiUrl = this.explore.apiUrl; - - const query = new URLSearchParams(); - - query.append('token', token); - query.append('url', apiUrl); - - if (this.explore.isIframeTransition) { - this.explore.sendRouteUpdate({path: this.explore.submitRoute, queryParams: query.toString()}); - - // Set a short timeout to give Explore enough time to navigate - // to the submit page and fetch the required site data - setTimeout(() => { - this.explore.toggleExploreWindow(true); - }, 500); - } else { - // Ghost Explore URL to submit a new site - const destination = new URL(`${this.explore.exploreUrl}${this.explore.submitRoute}`); - destination.search = query; - - window.location = destination.toString(); - } - } -} diff --git a/apps/ember-admin/app/controllers/member.js b/apps/ember-admin/app/controllers/member.js deleted file mode 100644 index 2e002e76ffa..00000000000 --- a/apps/ember-admin/app/controllers/member.js +++ /dev/null @@ -1,352 +0,0 @@ -import Controller from '@ember/controller'; -import DeleteMemberModal from '../components/members/modals/delete-member'; -import DisableCommentingModal from '../components/members/modals/disable-commenting'; -import EmberObject, {action, defineProperty} from '@ember/object'; -import LogoutMemberModal from '../components/members/modals/logout-member'; -import boundOneWay from 'ghost-admin/utils/bound-one-way'; -import moment from 'moment-timezone'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -const SCRATCH_PROPS = ['name', 'email', 'note']; - -export default class MemberController extends Controller { - @service ajax; - @service session; - @service dropdown; - @service feature; - @service ghostPaths; - @service membersStats; - @service membersCountCache; - @service modals; - @service notifications; - @service router; - @service labelsManager; - @service stateBridge; - @service store; - - queryParams = [ - {postAnalytics: 'post'}, - {backPath: 'back'} - ]; - - @tracked isLoading = false; - @tracked showImpersonateMemberModal = false; - @tracked modalLabel = null; - @tracked showLabelModal = false; - - _previousLabels = null; - _previousNewsletters = null; - - @tracked directlyFromAnalytics = false; - @tracked postAnalytics = null; - @tracked backPath = null; - - get fromAnalytics() { - if (!this.postAnalytics) { - return null; - } - return [this.postAnalytics]; - } - - get membersListPath() { - if (this.backPath?.startsWith('/members')) { - return this.backPath; - } - - if (this.postAnalytics) { - return `/members?post=${encodeURIComponent(this.postAnalytics)}`; - } - - return '/members'; - } - - get membersListUrl() { - return `#${this.membersListPath}`; - } - - constructor() { - super(...arguments); - this._availableLabels = this.store.peekAll('label'); - } - - // Computed properties ----------------------------------------------------- - - get member() { - return this.model; - } - - get memberTitle() { - if (this.member.isNew) { - return 'New member'; - } - - return this.member.name || this.member.email; - } - - set member(member) { - this.model = member; - } - - get dirtyAttributes() { - return this._hasDirtyAttributes(); - } - - get _labels() { - return this.member.get('labels').map(label => label.name); - } - - get _newsletters() { - return this.member.get('newsletters').map(newsletter => newsletter.id); - } - - get labelModalData() { - let label = this.modalLabel; - let labels = this.availableLabels; - - return { - label, - labels - }; - } - - get availableLabels() { - let labels = this._availableLabels - .filter(label => !label.isNew) - .filter(label => label.id !== null) - .sort((labelA, labelB) => labelA.name.localeCompare(labelB.name, undefined, {ignorePunctuation: true})); - let options = labels.toArray(); - - options.unshiftObject({name: 'All labels', slug: null}); - - return options; - } - - get scratchMember() { - let scratchMember = EmberObject.create({member: this.member}); - SCRATCH_PROPS.forEach(prop => defineProperty(scratchMember, prop, boundOneWay(`member.${prop}`))); - return scratchMember; - } - - get subscribedAt() { - let memberSince = moment(this.member.createdAtUTC).from(moment()); - let createdDate = moment(this.member.createdAtUTC).format('D MMM YYYY'); - return `${createdDate} (${memberSince})`; - } - - invalidateMembersCache() { - this.stateBridge.triggerEmberDataChange('update', 'member', this.member.id, null); - } - - invalidateMemberCommenting() { - this.invalidateMembersCache(); - this.stateBridge.triggerEmberDataChange('update', 'comment', this.member.id, null); - } - - // Actions ----------------------------------------------------------------- - - @action - setInitialRelationshipValues() { - this._previousLabels = this._labels; - this._previousNewsletters = this._newsletters; - } - - @action - toggleLabelModal() { - this.showLabelModal = !this.showLabelModal; - } - - @action - editLabel(label, e) { - if (e) { - e.preventDefault(); - e.stopPropagation(); - } - let modalLabel = this.availableLabels.findBy('slug', label); - this.modalLabel = modalLabel; - this.showLabelModal = !this.showLabelModal; - } - - @action - setProperty(propKey, value) { - this._saveMemberProperty(propKey, value); - } - - @action - confirmDeleteMember() { - this.modals.open(DeleteMemberModal, { - member: this.member, - afterDelete: () => { - this.membersStats.invalidate(); - this.invalidateMembersCache(); - this.membersCountCache.clear(); - this.router.transitionTo(this.membersListPath); - } - }); - } - - @action - confirmLogoutMember() { - this.modals.open(LogoutMemberModal, { - member: this.member, - afterLogout: () => { - this.invalidateMembersCache(); - } - }); - } - - @action - confirmDisableCommenting() { - this.modals.open(DisableCommentingModal, { - member: this.member, - afterDisable: () => { - this.invalidateMemberCommenting(); - this.fetchMemberTask.perform(this.member.id); - } - }); - } - - @action - async confirmEnableCommenting() { - this.dropdown.closeDropdowns(); - try { - const url = this.ghostPaths.url.api('members', this.member.id, 'commenting', 'enable'); - await this.ajax.post(url); - - this.invalidateMemberCommenting(); - - await this.fetchMemberTask.perform(this.member.id); - this.notifications.showNotification(`Commenting has been enabled for ${this.member.name || this.member.email}.`, {type: 'success'}); - } catch (e) { - this.notifications.showAPIError(e, {key: 'member.enable-commenting'}); - } - } - - @action - toggleImpersonateMemberModal() { - this.showImpersonateMemberModal = !this.showImpersonateMemberModal; - } - - @action - closeImpersonateMemberModal() { - this.showImpersonateMemberModal = false; - } - - @action - save() { - return this.saveTask.perform(); - } - - // Tasks ------------------------------------------------------------------- - - @task({drop: true}) - *saveTask() { - let {member, scratchMember} = this; - - // if Cmd+S is pressed before the field loses focus make sure we're - // saving the intended property values - let scratchProps = scratchMember.getProperties(SCRATCH_PROPS); - Object.assign(member, scratchProps); - - try { - const clearCountCache = member.isNew; // clear cache for adding new members so the count is updated without waiting for a refresh - - yield member.save(); - member.updateLabels(); - member.labels.forEach(label => this.labelsManager.addLabel(label)); - this.invalidateMembersCache(); - - this.setInitialRelationshipValues(); - - if (clearCountCache) { - this.membersCountCache.clear(); - } - - // replace 'member.new' route with 'member' route - this.replaceRoute('member', member); - - return member; - } catch (error) { - if (error === undefined) { - // Validation error - return; - } - - if (error.payload && error.payload.errors) { - for (const payloadError of error.payload.errors) { - if (payloadError.type === 'ValidationError' && payloadError.property && (payloadError.context || payloadError.message)) { - member.errors.add(payloadError.property, payloadError.context || payloadError.message); - member.hasValidated.pushObject(payloadError.property); - } - } - return; - } - - throw error; - } - } - - @task - *fetchMemberTask(memberId) { - this.isLoading = true; - - this.member = yield this.store.queryRecord('member', { - id: memberId, - include: 'tiers' - }); - - this.setInitialRelationshipValues(); - - this.isLoading = false; - } - - // Private ----------------------------------------------------------------- - - _saveMemberProperty(propKey, newValue) { - let currentValue = this.member[propKey]; - - if (newValue && typeof newValue === 'string') { - newValue = newValue.trim(); - } - - // avoid modifying empty values and triggering inadvertant unsaved changes modals - if (newValue !== false && !newValue && !currentValue) { - return; - } - - this.member[propKey] = newValue; - } - - _hasDirtyAttributes() { - let member = this.member; - - if (!member || member.isDeleted || member.isDeleting) { - return false; - } - - // member.labels is an array so hasDirtyAttributes doesn't pick up - // changes unless the array ref is changed. - // use sort() to sort of detect same item is re-added - let currentLabels = (this._labels.sort() || []).join(', '); - let previousLabels = (this._previousLabels.sort() || []).join(', '); - if (currentLabels !== previousLabels) { - return true; - } - - // member.newsletters is an array so hasDirtyAttributes doesn't pick up - // changes unless the array ref is changed - // use sort() to sort of detect same item is re-enabled - let currentNewsletters = (this._newsletters.sort() || []).join(', '); - let previousNewsletters = (this._previousNewsletters.sort() || []).join(', '); - if (currentNewsletters !== previousNewsletters) { - return true; - } - - // we've covered all the non-tracked cases we care about so fall - // back on Ember Data's default dirty attribute checks - let {hasDirtyAttributes} = member; - - return hasDirtyAttributes; - } -} diff --git a/apps/ember-admin/app/router.js b/apps/ember-admin/app/router.js index 25ff8234910..5059b89d940 100644 --- a/apps/ember-admin/app/router.js +++ b/apps/ember-admin/app/router.js @@ -37,24 +37,10 @@ Router.map(function () { this.route('tag.new', {path: '/tags/new'}); this.route('tag', {path: '/tags/:tag_slug'}); - this.route('explore', function () { - // actual Ember route, not rendered in iframe - this.route('connect'); - // iframe sub pages, used for categories - this.route('explore-sub', {path: '/*sub'}, function () { - // needed to allow search to work, as it uses URL - // params for search queries. They don't need to - // be visible, but may not be cut off. - this.route('explore-query', {path: '/*query'}); - }); - }); - this.route('migrate', function () { this.route('migrate', {path: '/*platform'}); }); - this.route('member.new', {path: '/members/new'}); - this.route('member', {path: '/members/:member_id'}); this.route('members-activity'); this.route('react-fallback', {path: '/*path'}); diff --git a/apps/ember-admin/app/routes/explore.js b/apps/ember-admin/app/routes/explore.js deleted file mode 100644 index 0b34b48e762..00000000000 --- a/apps/ember-admin/app/routes/explore.js +++ /dev/null @@ -1,10 +0,0 @@ -import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; -import {inject as service} from '@ember/service'; - -export default class ExploreRoute extends AuthenticatedRoute { - @service store; - - model() { - return this.store.findAll('integration'); - } -} diff --git a/apps/ember-admin/app/routes/explore/connect.js b/apps/ember-admin/app/routes/explore/connect.js deleted file mode 100644 index 6ab296e8b3d..00000000000 --- a/apps/ember-admin/app/routes/explore/connect.js +++ /dev/null @@ -1,10 +0,0 @@ -import ExploreRoute from './index'; - -export default class ExploreConnectRoute extends ExploreRoute { - controllerName = 'explore'; - - // Ensure to always close the iframe, as we're now on an Ember route - beforeModel() { - this.explore.toggleExploreWindow(false); - } -} diff --git a/apps/ember-admin/app/routes/explore/explore-sub.js b/apps/ember-admin/app/routes/explore/explore-sub.js deleted file mode 100644 index f504100aabe..00000000000 --- a/apps/ember-admin/app/routes/explore/explore-sub.js +++ /dev/null @@ -1,5 +0,0 @@ -import ExploreRoute from './index'; - -export default class ExploreSubRoute extends ExploreRoute { - controllerName = 'explore'; -} diff --git a/apps/ember-admin/app/routes/explore/index.js b/apps/ember-admin/app/routes/explore/index.js deleted file mode 100644 index 43a8e43135e..00000000000 --- a/apps/ember-admin/app/routes/explore/index.js +++ /dev/null @@ -1,80 +0,0 @@ -import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; - -export default class ExploreIndexRoute extends AuthenticatedRoute { - @service explore; - @service store; - @service router; - @service feature; - - beforeModel(transition) { - super.beforeModel(...arguments); - - // Usage of query param to ensure that sites can be submitted across - // older versions of Ghost where the `connect` part lives in the - // explore route directly. By using the query param, we avoid causing - // a 404 and handle the redirect here. - if (transition.to?.queryParams?.new === 'true') { - this.explore.isIframeTransition = false; - return this.router.transitionTo('explore.connect'); - } - - // Ensure the explore window is set to open - if (transition.to?.localName === 'index') { - this.explore.isIframeTransition = true; - this.explore.openExploreWindow(); - } - } - - model() { - return this.store.findAll('integration'); - } - - @action - willTransition(transition) { - let isExploreTransition = false; - - if (transition) { - let destinationUrl = (typeof transition.to === 'string') - ? transition.to - : (transition.intent - ? transition.intent.url - : ''); - - if (destinationUrl?.includes('/explore')) { - isExploreTransition = true; - this.explore.isIframeTransition = isExploreTransition; - - if (destinationUrl?.includes('/explore/submit')) { - // only show the submit page if the site is already submitted - // and redirect to the connect page if not. - if (Object.keys(this?.explore?.siteData).length >= 1) { - this.controllerFor('explore').submitExploreSite(); - } else { - transition.abort(); - return this.router.transitionTo('explore.connect'); - } - } else { - let path = destinationUrl.replace(/explore\//, ''); - path = path === '/' ? '/explore/' : path; - - if (destinationUrl?.includes('/explore/about')) { - window.open(`${this.explore.exploreUrl}about/`, '_blank').focus(); - path = '/explore/'; - } - // Send the updated route to the iframe - this.explore.sendRouteUpdate({path}); - } - } - } - - this.explore.toggleExploreWindow(isExploreTransition); - } - - buildRouteInfoMetadata() { - return { - titleToken: 'Explore' - }; - } -} diff --git a/apps/ember-admin/app/routes/member.js b/apps/ember-admin/app/routes/member.js deleted file mode 100644 index 145a98c4ff4..00000000000 --- a/apps/ember-admin/app/routes/member.js +++ /dev/null @@ -1,152 +0,0 @@ -import * as Sentry from '@sentry/ember'; -import ConfirmUnsavedChangesModal from '../components/modals/confirm-unsaved-changes'; -import MembersManagementRoute from './members-management'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; - -export default class MembersRoute extends MembersManagementRoute { - @service feature; - @service modals; - @service router; - @service('unsaved-changes') unsavedChanges; - - queryParams = { - postAnalytics: {refreshModel: false} - }; - - _requiresBackgroundRefresh = true; - _unregisterUnsavedChanges = null; - - constructor() { - super(...arguments); - this.router.on('routeWillChange', (transition) => { - this.closeImpersonateModal(transition); - }); - } - - beforeModel(transition) { - super.beforeModel(...arguments); - - // The outer React shell owns sibling URLs like /members/import. - // Ember's recognizer can still match this dynamic route for those - // paths and fire a bogus queryRecord that surfaces as an alert. - // Abort the transition for known React-owned siblings so the React - // shell keeps rendering the page. - const memberId = transition.to?.params?.member_id; - if (memberId === 'import') { - transition.abort(); - return; - } - - // React owns this URL when the flag is on. Aborting keeps the Ember - // subtree unrendered, so the `data-testid` and `data-test-link` - // attributes exist in only one tree and the queryRecord below doesn't - // fire for a screen nobody sees. - if (this.feature.memberDetailsReact === true) { - transition.abort(); - } - } - - model(params) { - this._requiresBackgroundRefresh = false; - - if (params.member_id) { - return this.store.queryRecord('member', {id: params.member_id, include: 'tiers'}); - } else { - return this.store.createRecord('member'); - } - } - - setupController(controller, member, transition) { - super.setupController(...arguments); - - controller.setInitialRelationshipValues(); - - if (this._requiresBackgroundRefresh) { - controller.fetchMemberTask.perform(member.id); - } - - controller.directlyFromAnalytics = false; - if (transition.from?.params?.path?.startsWith('posts/analytics')) { - controller.directlyFromAnalytics = true; - } - - this._registerUnsavedChanges(controller); - } - - resetController(controller, isExiting) { - super.resetController(...arguments); - - // Make sure we clear - if (isExiting) { - controller.set('backPath', null); - controller.set('directlyFromAnalytics', false); - } - - if (isExiting && controller.postAnalytics) { - controller.set('postAnalytics', null); - } - } - - deactivate() { - this._requiresBackgroundRefresh = true; - this._unregisterUnsavedChanges?.(); - this._unregisterUnsavedChanges = null; - } - - @action - save() { - this.controller.save(); - } - - @action - async willTransition(transition) { - return this.unsavedChanges.guardTransition(transition); - } - - closeImpersonateModal(transition) { - // If user navigates away with forward or back button, ensure returning to page - // hides modal - if (transition.from && transition.from.name === this.routeName && transition.targetName) { - let {controller} = this; - - controller.closeImpersonateMemberModal(transition); - } - } - - _registerUnsavedChanges(controller) { - this._unregisterUnsavedChanges?.(); - this._unregisterUnsavedChanges = this.unsavedChanges.register({ - isDirty: () => controller.dirtyAttributes, - confirmLeave: () => this._confirmUnsavedChanges(controller) - }); - } - - async _confirmUnsavedChanges(controller) { - if (controller.saveTask?.isRunning) { - try { - await controller.saveTask.last; - } catch (e) { - // ignore save errors — we'll check dirty state below - } - } - - if (!controller.dirtyAttributes) { - return true; - } - - Sentry.captureMessage('showing unsaved changes modal for members route'); - const shouldLeave = await this.modals.open(ConfirmUnsavedChangesModal); - - if (shouldLeave) { - controller.model.rollbackAttributes(); - return true; - } - - return false; - } - - titleToken() { - return this.controller.member.name; - } -} diff --git a/apps/ember-admin/app/routes/member/new.js b/apps/ember-admin/app/routes/member/new.js deleted file mode 100644 index 30c27fc4aa0..00000000000 --- a/apps/ember-admin/app/routes/member/new.js +++ /dev/null @@ -1,6 +0,0 @@ -import MemberRoute from '../member'; - -export default class NewMemberRoute extends MemberRoute { - controllerName = 'member'; - templateName = 'member'; -} diff --git a/apps/ember-admin/app/serializers/member.js b/apps/ember-admin/app/serializers/member.js index 08de9e1d9c9..5eb2b2082dc 100644 --- a/apps/ember-admin/app/serializers/member.js +++ b/apps/ember-admin/app/serializers/member.js @@ -18,9 +18,7 @@ export default class MemberSerializer extends ApplicationSerializer.extend(Embed delete json.status; delete json.last_seen_at; delete json.comped; - // Tiers are managed via direct API calls in gh-member-settings-form.js - // (removeComplimentaryTask) and modal-member-tier.js (addTier task), - // not through the normal member save flow + // Tiers are managed via direct API calls, not the member save flow delete json.tiers; // Normalize properties diff --git a/apps/ember-admin/app/services/explore.js b/apps/ember-admin/app/services/explore.js deleted file mode 100644 index 5efaa2b4628..00000000000 --- a/apps/ember-admin/app/services/explore.js +++ /dev/null @@ -1,136 +0,0 @@ -import Service, {inject as service} from '@ember/service'; -import {inject} from 'ghost-admin/decorators/inject'; -import {tracked} from '@glimmer/tracking'; - -export default class ExploreService extends Service { - @service router; - @service feature; - @service ghostPaths; - - @inject config; - - exploreUrl = 'https://ghost.org/explore/'; - exploreRouteRoot = '#/explore'; - submitRoute = 'submit'; - - @tracked exploreWindowOpen = false; - @tracked siteData = null; - @tracked isIframeTransition = false; - - get apiUrl() { - const origin = new URL(window.location.origin); - const subdir = this.ghostPaths.subdir; - // We want the API URL without protocol - let url = this.ghostPaths.url.join(origin.host, subdir); - - return url.replace(/\/$/, ''); - } - - get iframeURL() { - let url = this.exploreUrl; - - if (window.location.hash && window.location.hash.includes(this.exploreRouteRoot)) { - let destinationRoute = window.location.hash.replace(this.exploreRouteRoot, ''); - - // Connect is an Ember route, do not use it as iframe src - if (destinationRoute && !destinationRoute.includes('connect')) { - url += destinationRoute.replace(/^\//, ''); - } - } - - return url; - } - - constructor() { - super(...arguments); - - if (this.exploreUrl) { - window.addEventListener('message', (event) => { - if (event && event.data && event.data.route) { - this.handleRouteChangeInIframe(event.data.route); - } - }); - } - } - - handleRouteChangeInIframe(destinationRoute) { - if (this.exploreWindowOpen) { - let exploreRoute = this.exploreRouteRoot; - - if (destinationRoute.match(/^\/explore(\/.*)?/)) { - destinationRoute = destinationRoute.replace(/\/explore/, ''); - } - - if (destinationRoute !== '/') { - exploreRoute += destinationRoute; - } - - if (window.location.hash !== exploreRoute) { - window.history.replaceState(window.history.state, '', exploreRoute); - } - } - } - - // Sends a route update to a child route in the BMA, because we can't control - // navigating to it otherwise - sendRouteUpdate(route) { - this.getExploreIframe().contentWindow.postMessage({ - query: 'routeUpdate', - response: route - }, '*'); - } - - sendUIUpdate(data) { - this.getExploreIframe().contentWindow.postMessage({ - query: 'uiUpdate', - response: data - }, '*'); - } - - // Controls explore window modal visibility and sync of the URL visible in browser - // and the URL opened on the iframe. It is responsible to non user triggered iframe opening, - // for example: by entering "/explore" route in the URL or using history navigation (back and forward) - toggleExploreWindow(value) { - if (this.config.hostSettings?.forceUpgrade && value) { - // don't attempt to open Explore iframe when in Force Upgrade state - return; - } - - if (this.exploreWindowOpen && value) { - // don't attempt to open again - return; - } - this.exploreWindowOpen = value; - } - - openExploreWindow() { - if (this.config.hostSettings?.forceUpgrade) { - // don't attempt to open Explore iframe when in Force Upgrade state - return; - } - if (this.exploreWindowOpen) { - // don't attempt to open again - return; - } - - // Begin loading the iframe and setting the src if it's not already set - this.ensureIframeIsLoaded(); - - // Ensures correct iframe URL calculation when syncing iframe location - // in toggleExploreWindow - window.location.hash = '/explore'; - - this.router.transitionTo('/explore'); - this.toggleExploreWindow(true); - } - - ensureIframeIsLoaded() { - if (this.getExploreIframe() && !this.getExploreIframe()?.src) { - this.getExploreIframe().src = this.iframeURL; - } - } - - getExploreIframe() { - return document.getElementById('explore-frame'); - } -} diff --git a/apps/ember-admin/app/services/feature.js b/apps/ember-admin/app/services/feature.js index 9408422d489..cd81e8e5a6c 100644 --- a/apps/ember-admin/app/services/feature.js +++ b/apps/ember-admin/app/services/feature.js @@ -80,7 +80,6 @@ export default class FeatureService extends Service { @feature('importMemberTier') importMemberTier; @feature('adminUIRefresh') adminUIRefresh; @feature('editorExcerpt') editorExcerpt; - @feature('memberDetailsReact') memberDetailsReact; @feature('tagDetailsReact') tagDetailsReact; @feature('paywallImprovements') paywallImprovements; @feature('automations') automations; diff --git a/apps/ember-admin/app/styles/app-dark.css b/apps/ember-admin/app/styles/app-dark.css index 06548b309b2..234b8a84f93 100644 --- a/apps/ember-admin/app/styles/app-dark.css +++ b/apps/ember-admin/app/styles/app-dark.css @@ -60,7 +60,6 @@ @import "layouts/post-history.css"; @import "layouts/post-preview.css"; @import "layouts/tiers.css"; -@import "layouts/explore.css"; :root { /* Primary colours */ @@ -937,10 +936,6 @@ input, stroke: #fff; } -.gh-member-settings .gh-member-feed { - background: transparent; -} - .gh-member-feed-row { border-bottom: 1px solid var(--grey-900); } @@ -1058,24 +1053,6 @@ kbd { fill: var(--midgrey-d2); } -/* Explore */ - -.fullscreen-explore-container { - background: var(--main-bg-color); -} - -.explore-api { - color: var(--middarkgrey); -} - -.explore-permissions p { - color: #fff; -} - -.explore-permissions { - background: var(--whitegrey-l1); -} - /* Settings Links */ .kg-settings-link-url::before { diff --git a/apps/ember-admin/app/styles/app.css b/apps/ember-admin/app/styles/app.css index 4dab315b47f..597b9218e85 100644 --- a/apps/ember-admin/app/styles/app.css +++ b/apps/ember-admin/app/styles/app.css @@ -63,7 +63,6 @@ @import "layouts/post-history.css"; @import "layouts/post-preview.css"; @import "layouts/tiers.css"; -@import "layouts/explore.css"; @import "layouts/mentions.css"; @import "layouts/migrate.css"; diff --git a/apps/ember-admin/app/styles/components/dropdowns.css b/apps/ember-admin/app/styles/components/dropdowns.css index 6ff834449f4..404573c34db 100644 --- a/apps/ember-admin/app/styles/components/dropdowns.css +++ b/apps/ember-admin/app/styles/components/dropdowns.css @@ -285,42 +285,6 @@ margin-right: 0; } -.gh-member-label-input .dropdown-action-icon { - opacity: 0; - transition: opacity ease-in-out 0.15s; - padding: 4px; - margin-top: -2px; - margin-bottom: -2px; - margin-right: 4px; - border-radius: 3px; -} - -.gh-member-settings .gh-member-label-input .dropdown-action-icon { - margin-right: -8px; - padding: 4px 6px; - color: var(--midgrey); -} - -.gh-member-settings .gh-member-label-input .dropdown-action-icon:hover { - color: var(--darkgrey); -} - -.gh-member-label-input li:hover .dropdown-action-icon { - opacity: 1.0; -} - -.gh-member-label-input .dropdown-action-icon:hover { - background: var(--whitegrey-d1); -} - -.gh-member-label-input .dropdown-action-icon svg { - margin: 0; - height: 14px; - width: 14px; - line-height: 1em; - fill: none; -} - /** Post context menu */ diff --git a/apps/ember-admin/app/styles/layouts/explore.css b/apps/ember-admin/app/styles/layouts/explore.css deleted file mode 100644 index f016b981a95..00000000000 --- a/apps/ember-admin/app/styles/layouts/explore.css +++ /dev/null @@ -1,242 +0,0 @@ -.gh-explore { - position: absolute; - top: 0; - left: 0; - height: 100%; - width: 100%; - z-index: 9999; - background: var(--main-bg-color); -} - -.gh-explore-container { - position: relative; - height: 100%; - width: 100%; -} - -.gh-explore.closed { - display: none; -} - -.gh-explore .close { - position: absolute; - top: 19px; - right: 19px; - z-index: 9999; - margin: 0; - padding: 0; - width: 16px; - height: 16px; - border: none; -} - -.gh-explore .explore-frame { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - width: 100%; - height: 100%; - border: none; - transform: translate3d(0, 0, 0); -} - -.gh-explore-close { - width: calc(50vw - 200px) -} - -.gh-explore-close button { - stroke: var(--midgrey); - opacity: 0.6; - transition: all 0.2s ease-in-out; - top: 25px; -} - -/* Connect */ -.explore { - position: relative; -} - -.iframe-explore-container { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - width: 100%; - height: 100%; - border: none; - transform: translate3d(0, 0, 0); - height: 100vh; - background: linear-gradient(180deg, var(--white) 0%, #E1E1E1 100%); -} - -.fullscreen-explore-container { - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 10000; - height: 100vh; - background: linear-gradient(180deg, var(--white) 0%, #E1E1E1 100%); - overflow: hidden; -} - -.explore-close { - display: flex; - justify-content: flex-end; - align-items: center; - padding: 2rem; -} - -.explore-close a { - color: var(--middarkgrey); -} - -.explore-close a svg { - stroke: var(--middarkgrey); - width: 18px; - height: auto; -} - -.explore-close a svg path { - stroke-width: 1px; -} - -.explore-close a:hover svg { - stroke: var(--darkgrey); -} - -.explore-content { - display: flex; - align-items: center; - flex-direction: column; - min-height: 100%; - padding: 8vmin 2vmin 4vmin; -} - -.explore-header { - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: center; -} - -.explore-header h1 { - margin: 1.6rem 0 0; - font-size: 3.6rem; - text-align: center; - font-weight: 700; - letter-spacing: -.03em; - line-height: 40px; -} - -.explore-api { - margin-bottom: 50px; - color: rgba(0, 0, 0, 0.52); - font-size: 2.3rem; - font-weight: 400; - line-height: 30px; - text-align: center; - letter-spacing: -0.03em; -} - -.explore-permissions { - background: var(--white); - padding: 3rem 3.5rem; - max-width: 457px; - width: 100%; - border-radius: 6px; -} - -.explore-permissions svg path { - stroke: #86C600; -} - -.explore-permissions > div { - display: flex; - flex-direction: row; - align-items: baseline; -} - -.explore-permissions p { - color: rgba(0, 0, 0, 0.66); - margin: 0; - font-size: 1.9rem; - font-weight: 400; - letter-spacing: -0.03em; - line-height: 1.32; -} - -.explore-permissions div:not(:last-of-type) { - margin-bottom: 3rem; -} - -.explore-permissions > div span { - padding-right: 18px; -} - -.explore button { - margin-top: 4vmin; - max-width: 457px; - width: 100%; - height: 50px; - border-radius: 6px; -} - -.explore button span { - font-size: 1.7rem; - font-weight: 500; - color: var(--white); -} - -.explore button svg { - fill: var(--white); - margin-left: 0.1em; - height: 14px; -} - -@media (max-width: 800px) { - .explore-content { - padding: 2vmin; - } - .explore-header { - margin-top: 10vmin; - } - .explore-header svg { - width: 7rem; - } - .explore-header h1 { - font-size: 2.8rem; - } - .explore-api { - font-size: 1.8rem; - } - .explore-permissions { - padding: 2rem 2.5rem; - } - .explore-permissions p { - font-size: 1.6rem; - } -} - -@media (max-width: 500px) { - .explore-header h1 { - font-size: 2.4rem; - } - .explore-api { - font-size: 1.6rem; - margin-bottom: 20px; - } - .explore-permissions div:not(:last-of-type) { - margin-bottom: 2rem; - } - .explore-permissions > div span { - padding-right: 12px; - } - .explore-permissions > div span svg { - width: 1.8rem; - } -} diff --git a/apps/ember-admin/app/styles/layouts/members.css b/apps/ember-admin/app/styles/layouts/members.css index 89206ed0efd..e8fb9d615e8 100644 --- a/apps/ember-admin/app/styles/layouts/members.css +++ b/apps/ember-admin/app/styles/layouts/members.css @@ -92,10 +92,6 @@ label[for="member-description"] + p { margin: 0 0 4px; } -.gh-member-settings .gh-main-section.columns-3 { - grid-column-gap: 48px; -} - .gh-member-details { position: sticky; top: 0px; @@ -415,16 +411,6 @@ textarea.gh-member-details-textarea { } @media (max-width: 1160px) { - .gh-member-settings .gh-main-section { - display: flex; - flex-direction: column; - } - - .gh-member-settings .gh-main-section > div { - float: none; - width: 100%; - } - .gh-member-details { position: relative; top: unset; @@ -605,13 +591,6 @@ textarea.gh-member-details-textarea { border-radius: 3px; } -.gh-member-settings .gh-member-feed-no-data { - margin: 0; - padding: 24px 0 28px; - background: transparent; - box-shadow: none; -} - .gh-member-feed-row { display: flex; align-items: center; diff --git a/apps/ember-admin/app/templates/application.hbs b/apps/ember-admin/app/templates/application.hbs index aead6868001..0d474701667 100644 --- a/apps/ember-admin/app/templates/application.hbs +++ b/apps/ember-admin/app/templates/application.hbs @@ -33,8 +33,6 @@ {{#if this.showBilling}} {{/if}} - -
    diff --git a/apps/ember-admin/app/templates/explore/connect.hbs b/apps/ember-admin/app/templates/explore/connect.hbs deleted file mode 100644 index b7574489522..00000000000 --- a/apps/ember-admin/app/templates/explore/connect.hbs +++ /dev/null @@ -1,34 +0,0 @@ -
    - -
    - -
    - {{svg-jar "ghost-orb-pink" alt="Ghost" class="w25 v-mid"}} -

    Connect to Ghost Explore.

    -

    {{this.apiUrl}}

    -
    - -
    -
    - {{svg-jar "check-circle" class="w6 v-mid" alt="checkmark"}} -

    Allow read-only access to your site data to create a directory listing.

    -
    -
    - {{svg-jar "check-circle" class="w6 v-mid" alt="checkmark"}} -

    You’ll be able to choose what data is shown publicly or hidden.

    -
    -
    - {{svg-jar "check-circle" class="w6 v-mid" alt="checkmark"}} -

    Your site will be promoted across the entire Ghost ecosystem.

    -
    -
    - - -
    -
    \ No newline at end of file diff --git a/apps/ember-admin/app/templates/member.hbs b/apps/ember-admin/app/templates/member.hbs deleted file mode 100644 index b8d3a0e2f21..00000000000 --- a/apps/ember-admin/app/templates/member.hbs +++ /dev/null @@ -1,146 +0,0 @@ -
    - -
    - {{#if this.fromAnalytics}} -
    - - Posts - - {{svg-jar "arrow-right-small"}} - - Analytics - - - {{#unless this.directlyFromAnalytics}} - {{svg-jar "arrow-right-small"}} - - Members - - {{/unless}} - - {{svg-jar "arrow-right-small"}} - {{this.memberTitle}} -
    - {{else}} -
    - - Members - - {{svg-jar "arrow-right-small"}} - {{this.memberTitle}} -
    - {{/if}} -
    - -
    - {{#if this.session.user.canManageMembers}} - {{#unless this.member.isNew}} - - - - {{svg-jar "settings"}} - - - - -
  • - -
  • -
  • - -
  • -
  • - {{#if (not-eq this.member.canComment false)}} - - {{else}} - - {{/if}} -
  • -
  • - -
  • -
    -
    - {{/unless}} - {{/if}} - - -
    -
    - -
    -
    - - -
    -
    - -{{#if this.showImpersonateMemberModal}} - -{{/if}} - -{{#if this.showLabelModal}} - -{{/if}} diff --git a/apps/ember-admin/app/utils/subscription-data.js b/apps/ember-admin/app/utils/subscription-data.js deleted file mode 100644 index 77fd2327f27..00000000000 --- a/apps/ember-admin/app/utils/subscription-data.js +++ /dev/null @@ -1,225 +0,0 @@ -import moment from 'moment-timezone'; -import {getNonDecimal, getSymbol} from 'ghost-admin/utils/currency'; - -export function getSubscriptionData(sub) { - const data = { - ...sub, - attribution: { - ...sub.attribution, - referrerSource: sub.attribution?.referrer_source || 'Unknown', - referrerMedium: sub.attribution?.referrer_medium || '-' - }, - startDate: sub.start_date ? moment(sub.start_date).format('D MMM YYYY') : '-', - validUntil: validUntil(sub), - hasEnded: isCanceled(sub), - willEndSoon: isSetToCancel(sub), - cancellationReason: sub.cancellation_reason, - price: { - ...sub.price, - currencySymbol: getSymbol(sub.price.currency), - nonDecimalAmount: getNonDecimal(sub.price.amount) - }, - isComplimentary: isComplimentary(sub), - isGift: isGift(sub), - compExpiry: compExpiry(sub), - giftExpiry: giftExpiry(sub), - trialUntil: trialUntil(sub) - }; - - data.priceLabel = priceLabel(data); - data.validityDetails = validityDetails(data, !!data.priceLabel); - - const discount = getDiscountPrice(sub); - if (discount) { - data.hasActiveDiscount = true; - data.discountedPrice = discount.discountedPrice; - data.originalPrice = discount.originalPrice; - } - - return data; -} - -export function validUntil(sub) { - // If a subscription has been canceled immediately, don't render the end of validity date - // Reason: we don't store the exact cancelation date in the subscription object - if (sub.status === 'canceled' && !sub.cancel_at_period_end) { - return ''; - } - - // Otherwise, show the current period end date - if (sub.current_period_end) { - return moment(sub.current_period_end).format('D MMM YYYY'); - } - - return ''; -} - -export function isActive(sub) { - return ['active', 'trialing', 'past_due', 'unpaid'].includes(sub.status); -} - -export function isComplimentary(sub) { - const compedNickname = sub.plan?.nickname?.toLowerCase() === 'complimentary'; - - return !sub.id && compedNickname; -} - -export function isGift(sub) { - const giftNickname = sub.plan?.nickname?.toLowerCase() === 'gift subscription'; - - return !sub.id && giftNickname; -} - -export function isCanceled(sub) { - return sub.status === 'canceled'; -} - -export function isSetToCancel(sub) { - return sub.cancel_at_period_end && isActive(sub); -} - -export function compExpiry(sub) { - if (!isComplimentary(sub)) { - return undefined; - } - - if (sub.tier && sub.tier.expiry_at) { - return moment(sub.tier.expiry_at).utc().format('D MMM YYYY'); - } - - return undefined; -} - -export function giftExpiry(sub) { - if (!isGift(sub)) { - return undefined; - } - - if (sub.tier && sub.tier.expiry_at) { - return moment(sub.tier.expiry_at).utc().format('D MMM YYYY'); - } - - return undefined; -} - -export function trialUntil(sub) { - const offer = sub.offer; - const isTrial = !offer || offer?.type === 'trial'; - const isTrialActive = isTrial && sub.trial_end_at && moment(sub.trial_end_at).isAfter(new Date(), 'day'); - - if (isTrialActive) { - return moment(sub.trial_end_at).format('D MMM YYYY'); - } - - return undefined; -} - -export function validityDetails(data, separatorNeeded = false) { - const separator = separatorNeeded ? ' – ' : ''; - const space = data.validUntil ? ' ' : ''; - - if (data.isComplimentary) { - if (data.compExpiry) { - return `${separator}Expires ${data.compExpiry}`; - } else { - return ''; - } - } - - if (data.isGift) { - if (data.giftExpiry) { - return `${separator}Expires ${data.giftExpiry}`; - } else { - return ''; - } - } - - if (data.hasEnded) { - return `${separator}Ended${space}${data.validUntil}`; - } - - if (data.willEndSoon) { - return `${separator}Has access until${space}${data.validUntil}`; - } - - if (data.trialUntil) { - return `${separator}Ends ${data.trialUntil}`; - } - - return `${separator}Renews${space}${data.validUntil}`; -} - -export function priceLabel(data) { - if (data.trialUntil) { - return 'Free trial'; - } - - if (data.price.nickname && data.price.nickname.length > 0 && data.price.nickname !== 'Monthly' && data.price.nickname !== 'Yearly') { - return data.price.nickname; - } -} - -export function getOfferDisplayData(offer, sub = {}) { - const isRetention = offer.redemption_type === 'retention'; - const label = isRetention ? 'Retention offer' : 'Signup offer'; - - const isFreeMonths = offer.type === 'percent' && offer.amount === 100 && offer.duration === 'repeating'; - - let discount; - if (offer.type === 'trial') { - discount = `${offer.amount} days free`; - } else if (isFreeMonths) { - discount = `${offer.duration_in_months} ${offer.duration_in_months === 1 ? 'month' : 'months'} free`; - } else if (offer.type === 'fixed') { - discount = `${getSymbol(offer.currency)}${getNonDecimal(offer.amount)} off`; - } else { - discount = `${offer.amount}% off`; - } - - let detail; - if (isRetention) { - const discountEnd = offer.id && sub.next_payment?.discount?.offer_id === offer.id - ? sub.next_payment.discount.end - : null; - - if (discountEnd) { - detail = `${discount} until ${moment(discountEnd).format('MMM YYYY')}`; - } else if (isFreeMonths) { - // "N months free" is self-contained, no need to append "for N months" - detail = discount; - } else if (offer.duration === 'repeating' && offer.duration_in_months) { - detail = `${discount} for ${offer.duration_in_months} ${offer.duration_in_months === 1 ? 'month' : 'months'}`; - } else if (offer.duration === 'forever') { - detail = `${discount} forever`; - } else { - detail = discount; - } - } else { - detail = `${offer.name} (${discount})`; - } - - return {label, detail}; -} - -export function getDiscountPrice(sub) { - if (!sub.next_payment || !sub.next_payment.discount) { - return null; - } - - if (sub.next_payment.amount === sub.next_payment.original_amount) { - return null; - } - - return { - discountedPrice: { - currencySymbol: getSymbol(sub.next_payment.currency), - nonDecimalAmount: getNonDecimal(sub.next_payment.amount), - amount: sub.next_payment.amount - }, - originalPrice: { - currencySymbol: getSymbol(sub.next_payment.currency), - nonDecimalAmount: getNonDecimal(sub.next_payment.original_amount), - amount: sub.next_payment.original_amount - } - }; -} diff --git a/apps/ember-admin/tests/acceptance/members/details-test.js b/apps/ember-admin/tests/acceptance/members/details-test.js deleted file mode 100644 index 8c74c364aca..00000000000 --- a/apps/ember-admin/tests/acceptance/members/details-test.js +++ /dev/null @@ -1,621 +0,0 @@ -import {authenticateSession} from 'ember-simple-auth/test-support'; -import {click, currentURL, fillIn, find, findAll} from '@ember/test-helpers'; -import {enableLabsFlag} from '../../helpers/labs-flag'; -import {enableNewsletters} from '../../helpers/newsletters'; -import {enableStripe} from '../../helpers/stripe'; -import {expect} from 'chai'; -import {setupApplicationTest} from 'ember-mocha'; -import {setupMirage} from 'ember-cli-mirage/test-support'; -import {visit} from '../../helpers/visit'; - -describe('Acceptance: Member details', function () { - let hooks = setupApplicationTest(); - setupMirage(hooks); - - let clock; - let tier; - - function subscriptionSummaryText(tierId) { - const priceLabel = find(`[data-test-tier="${tierId}"] .gh-cp-membertier-pricelabel`)?.textContent?.trim() || ''; - const renewal = find(`[data-test-tier="${tierId}"] .gh-cp-membertier-renewal`)?.textContent?.trim() || ''; - - return [priceLabel, renewal] - .filter(Boolean) - .join(' ') - .replace(/–/g, '-') - .replace(/\s+/g, ' ') - .trim(); - } - - beforeEach(async function () { - this.server.loadFixtures('configs'); - this.server.loadFixtures('settings'); - enableLabsFlag(this.server, 'membersLastSeenFilter'); - enableLabsFlag(this.server, 'membersTimeFilters'); - - enableStripe(this.server); - enableNewsletters(this.server, true); - - // add a default tier that complimentary plans can be assigned to - tier = this.server.create('tier', { - id: '6213b3f6cb39ebdb03ebd810', - name: 'Supporter', - slug: 'supporter', - created_at: '2022-02-21T16:47:02.000Z', - updated_at: '2022-03-03T15:37:02.000Z', - description: null, - monthly_price_id: '6220df272fee0571b5dd0a0a', - yearly_price_id: '6220df272fee0571b5dd0a0b', - type: 'paid', - active: true, - welcome_page_url: '/' - }); - - let role = this.server.create('role', {name: 'Owner'}); - this.server.create('user', {roles: [role]}); - - await authenticateSession(); - }); - - afterEach(function () { - clock?.restore(); - }); - - it('has a known base-state', async function () { - const member = this.server.create('member', { - id: 1, - subscriptions: [ - this.server.create('subscription', { - id: 'sub_1KZGcmEGb07FFvyN9jwrwbKu', - customer: { - id: 'cus_LFmBWoSkB84lnr', - name: 'test', - email: 'test@ghost.org' - }, - plan: { - id: 'price_1KZGc6EGb07FFvyNkK3umKiX', - nickname: 'Monthly', - amount: 500, - interval: 'month', - currency: 'USD' - }, - status: 'canceled', - start_date: '2022-03-03T15:31:27.000Z', - default_payment_card_last4: '4242', - cancel_at_period_end: false, - cancellation_reason: null, - current_period_end: '2022-04-03T15:31:27.000Z', - price: { - id: 'price_1KZGc6EGb07FFvyNkK3umKiX', - price_id: '6220df272fee0571b5dd0a0a', - nickname: 'Monthly', - amount: 500, - interval: 'month', - type: 'recurring', - currency: 'USD', - tier: { - id: 'prod_LFmAAmCnnbzrvL', - name: 'Supporter', - tier_id: tier.id - } - }, - offer: null - }), - this.server.create('subscription', { - id: 'sub_1KZGi6EGb07FFvyNDjZq98g8', - tier, - customer: { - id: 'cus_LFmGicpX4BkQKH', - name: '123', - email: 'test@ghost.org' - }, - plan: { - id: 'price_1KZGc6EGb07FFvyNkK3umKiX', - nickname: 'Monthly', - amount: 500, - interval: 'month', - currency: 'USD' - }, - status: 'active', - start_date: '2022-03-03T15:36:58.000Z', - default_payment_card_last4: '4242', - cancel_at_period_end: false, - cancellation_reason: null, - current_period_end: '2022-04-03T15:36:58.000Z', - price: { - id: 'price_1KZGc6EGb07FFvyNkK3umKiX', - price_id: '6220df272fee0571b5dd0a0a', - nickname: 'Monthly', - amount: 500, - interval: 'month', - type: 'recurring', - currency: 'USD', - tier: { - id: 'prod_LFmAAmCnnbzrvL', - name: 'Supporter', - tier_id: tier.id - } - }, - offer: null - }) - ], - tiers: [ - tier - ] - }); - - await visit(`/members/${member.id}`); - - expect(currentURL()).to.equal(`/members/${member.id}`); - - expect(findAll('[data-test-subscription]').length, 'displays all member subscriptions') - .to.equal(2); - await click('[data-test-button="save"]'); - expect(find('[data-test-button="save"]')).to.not.contain.text('Retry'); - }); - - it('displays correctly one canceled subscription', async function () { - const member = this.server.create('member', { - id: 1, - subscriptions: [ - this.server.create('subscription', { - id: 'sub_1KZGcmEGb07FFvyN9jwrwbKu', - tier, - customer: { - id: 'cus_LFmBWoSkB84lnr', - name: 'test', - email: 'test@ghost.org' - }, - plan: { - id: 'price_1KZGc6EGb07FFvyNkK3umKiX', - nickname: 'Monthly', - amount: 500, - interval: 'month', - currency: 'USD' - }, - status: 'canceled', - start_date: '2022-03-03T15:31:27.000Z', - default_payment_card_last4: '4242', - cancel_at_period_end: false, - cancellation_reason: null, - current_period_end: '2022-04-03T15:31:27.000Z', - price: { - id: 'price_1KZGc6EGb07FFvyNkK3umKiX', - price_id: '6220df272fee0571b5dd0a0a', - nickname: 'Monthly', - amount: 500, - interval: 'month', - type: 'recurring', - currency: 'USD', - tier: { - id: 'prod_LFmAAmCnnbzrvL', - name: 'Supporter', - tier_id: '6213b3f6cb39ebdb03ebd810' - } - }, - offer: null - }) - ], - tiers: [] - }); - - await visit(`/members/${member.id}`); - - expect(currentURL()).to.equal(`/members/${member.id}`); - - expect(findAll('[data-test-subscription]').length, 'displays all member subscriptions') - .to.equal(1); - }); - - it('renders non-discounted subscription prices with trailing zeros', async function () { - const member = this.server.create('member', { - id: 1, - subscriptions: [ - this.server.create('subscription', { - id: 'sub_1KZGi6EGb07FFvyNDjZq98g8', - tier, - customer: { - id: 'cus_LFmGicpX4BkQKH', - name: '123', - email: 'test@ghost.org' - }, - plan: { - id: 'price_1KZGc6EGb07FFvyNkK3umKiX', - nickname: 'Monthly', - amount: 590, - interval: 'month', - currency: 'USD' - }, - status: 'active', - start_date: '2022-03-03T15:36:58.000Z', - default_payment_card_last4: '4242', - cancel_at_period_end: false, - cancellation_reason: null, - current_period_end: '2022-04-03T15:36:58.000Z', - price: { - id: 'price_1KZGc6EGb07FFvyNkK3umKiX', - price_id: '6220df272fee0571b5dd0a0a', - nickname: 'Monthly', - amount: 590, - interval: 'month', - type: 'recurring', - currency: 'USD', - tier: { - id: 'prod_LFmAAmCnnbzrvL', - name: 'Supporter', - tier_id: tier.id - } - }, - offer: null - }) - ], - tiers: [ - tier - ] - }); - - await visit(`/members/${member.id}`); - - const amountElement = find(`[data-test-tier="${tier.id}"] .gh-tier-card-price .amount`); - expect(amountElement.textContent.trim()).to.equal('5.90'); - }); - - it('can add and remove complimentary subscription', async function () { - const member = this.server.create('member', {name: 'Comp Member Test'}); - - await visit(`/members/${member.id}`); - - expect(findAll('[data-test-button="add-complimentary"]').length, '# of add complimentary buttons') - .to.equal(1); - - await click('[data-test-button="add-complimentary"]'); - expect(find('[data-test-modal="member-tier"]'), 'select tier modal').to.exist; - expect(find('[data-test-text="select-tier-desc"]')).to.contain.text('Comp Member Test'); - expect(find('[data-test-tier-option="6213b3f6cb39ebdb03ebd810"]')).to.have.exist; - expect(find('[data-test-tier-option="6213b3f6cb39ebdb03ebd810"]')).to.have.class('active'); - await click('[data-test-button="save-comp-tier"]'); - - expect(findAll('[data-test-subscription]').length, '# of subscription blocks - after add comped') - .to.equal(1); - - await click('[data-test-tier="6213b3f6cb39ebdb03ebd810"] [data-test-button="subscription-actions"]'); - await click('[data-test-tier="6213b3f6cb39ebdb03ebd810"] [data-test-button="remove-complimentary"]'); - - expect(findAll('[data-test-subscription]').length, '# of subscription blocks - after remove comped') - .to.equal(0); - }); - - it('can add complimentary subscription when member has canceled subscriptions', async function () { - const member = this.server.create('member', { - name: 'Comped for canceled sub test', - subscriptions: [ - this.server.create('subscription', { - // tier, // _Not_ included as `tier` when subscription is canceled - status: 'canceled', - price: { - id: 'price_1', - tier: { - id: 'prod_1', - tier_id: tier.id - } - } - }) - ] - }); - - await visit(`/members/${member.id}`); - - expect(findAll('[data-test-button="add-complimentary"]').length, '# of add complimentary buttons') - .to.equal(1); - - await click('[data-test-button="add-complimentary"]'); - await click('[data-test-button="save-comp-tier"]'); - - expect(findAll('[data-test-subscription]').length, '# of subscription blocks - after add comped') - .to.equal(2); - expect(findAll('[data-test-button="add-complimentary"]').length, '# of add complimentary buttons - after add comped') - .to.equal(0); - }); - - it('displays gift subscriptions with expiry text and without an action menu', async function () { - const giftTier = this.server.create('tier', { - id: 'gift-tier-1', - name: 'Gift tier', - slug: 'gift-tier', - created_at: '2022-02-21T16:47:02.000Z', - updated_at: '2022-03-03T15:37:02.000Z', - description: null, - monthly_price_id: 'gift-monthly-price', - yearly_price_id: 'gift-yearly-price', - type: 'paid', - active: true, - welcome_page_url: '/', - expiry_at: '2022-04-03T00:00:00.000Z' - }); - - const member = this.server.create('member', { - id: 1, - status: 'gift', - tiers: [giftTier], - subscriptions: [ - this.server.create('subscription', { - id: '', - tier: giftTier, - customer: { - id: '', - name: 'Gift Receiver', - email: 'gift@example.com' - }, - plan: { - id: '', - nickname: 'Gift subscription', - amount: 0, - interval: 'year', - currency: 'USD' - }, - status: 'active', - start_date: '2022-03-03T15:36:58.000Z', - default_payment_card_last4: '****', - cancel_at_period_end: false, - cancellation_reason: null, - current_period_end: '2022-04-03T15:36:58.000Z', - price: { - id: '', - price_id: '', - nickname: 'Gift subscription', - amount: 0, - interval: 'year', - type: 'recurring', - currency: 'USD', - tier: { - id: '', - tier_id: giftTier.id - } - }, - offer: null - }) - ] - }); - - await visit(`/members/${member.id}`); - - expect(currentURL()).to.equal(`/members/${member.id}`); - - const tierCard = find(`[data-test-tier="${giftTier.id}"]`); - - expect(subscriptionSummaryText(giftTier.id)).to.equal('Gift subscription - Expires 3 Apr 2022'); - expect(tierCard).to.contain.text('Gift subscription'); - expect(find(`[data-test-tier="${giftTier.id}"] [data-test-button="subscription-actions"]`)).to.not.exist; - }); - - it('displays comped subscriptions with expiry text and a complimentary action menu', async function () { - const compedTier = this.server.create('tier', { - id: 'comped-tier-1', - name: 'Comped tier', - slug: 'comped-tier', - created_at: '2022-02-21T16:47:02.000Z', - updated_at: '2022-03-03T15:37:02.000Z', - description: null, - monthly_price_id: 'comped-monthly-price', - yearly_price_id: 'comped-yearly-price', - type: 'paid', - active: true, - welcome_page_url: '/', - expiry_at: '2022-04-03T00:00:00.000Z' - }); - - const member = this.server.create('member', { - id: 1, - status: 'comped', - tiers: [compedTier], - subscriptions: [ - this.server.create('subscription', { - id: '', - tier: compedTier, - customer: { - id: '', - name: 'Comped Receiver', - email: 'comped@example.com' - }, - plan: { - id: '', - nickname: 'Complimentary', - amount: 0, - interval: 'year', - currency: 'USD' - }, - status: 'active', - start_date: '2022-03-03T15:36:58.000Z', - default_payment_card_last4: '****', - cancel_at_period_end: false, - cancellation_reason: null, - current_period_end: '2022-04-03T15:36:58.000Z', - price: { - id: '', - price_id: '', - nickname: 'Complimentary', - amount: 0, - interval: 'year', - type: 'recurring', - currency: 'USD', - tier: { - id: '', - tier_id: compedTier.id - } - }, - offer: null - }) - ] - }); - - await visit(`/members/${member.id}`); - - expect(currentURL()).to.equal(`/members/${member.id}`); - expect(subscriptionSummaryText(compedTier.id)).to.equal('Complimentary - Expires 3 Apr 2022'); - expect(find(`[data-test-tier="${compedTier.id}"] [data-test-button="subscription-actions"]`)).to.exist; - - await click(`[data-test-tier="${compedTier.id}"] [data-test-button="subscription-actions"]`); - expect(find('.tier-actions-menu')).to.contain.text('Remove complimentary subscription'); - }); - - it('displays paid subscriptions with Stripe actions', async function () { - const member = this.server.create('member', { - id: 1, - subscriptions: [ - this.server.create('subscription', { - id: 'sub_paid_1', - tier, - customer: { - id: 'cus_paid_1', - name: 'Paid Member', - email: 'paid@example.com' - }, - plan: { - id: 'price_paid_1', - nickname: 'Monthly', - amount: 500, - interval: 'month', - currency: 'USD' - }, - status: 'active', - start_date: '2022-03-03T15:36:58.000Z', - default_payment_card_last4: '4242', - cancel_at_period_end: false, - cancellation_reason: null, - current_period_end: '2022-04-03T15:36:58.000Z', - price: { - id: 'price_paid_1', - price_id: '6220df272fee0571b5dd0a0a', - nickname: 'Monthly', - amount: 500, - interval: 'month', - type: 'recurring', - currency: 'USD', - tier: { - id: 'prod_paid_1', - tier_id: tier.id - } - }, - offer: null - }) - ], - tiers: [tier] - }); - - await visit(`/members/${member.id}`); - - expect(currentURL()).to.equal(`/members/${member.id}`); - expect(subscriptionSummaryText(tier.id)).to.equal('Renews 3 Apr 2022'); - expect(find(`[data-test-tier="${tier.id}"] [data-test-button="subscription-actions"]`)).to.exist; - - await click(`[data-test-tier="${tier.id}"] [data-test-button="subscription-actions"]`); - expect(find('.tier-actions-menu')).to.contain.text('View Stripe customer'); - expect(find('.tier-actions-menu')).to.contain.text('View Stripe subscription'); - }); - - it('handles multiple tiers', async function () { - const tier2 = this.server.create('tier', { - name: 'Superfan', - slug: 'superfan', - created_at: '2022-02-21T16:47:02.000Z', - updated_at: '2022-03-03T15:37:02.000Z', - description: null, - monthly_price_id: '6220df272fee0571b5dd0a0a', - yearly_price_id: '6220df272fee0571b5dd0a0b', - type: 'paid', - active: true, - welcome_page_url: '/' - }); - - const member = this.server.create('member', {name: 'Multiple tier test'}); - - this.server.create('subscription', {member, tier, status: 'canceled', price: {id: '1', tier: {tier_id: tier.id}}}); - this.server.create('subscription', {member, tier, status: 'canceled', price: {id: '1', tier: {tier_id: tier.id}}}); - this.server.create('subscription', {member, tier: tier2, status: 'canceled', price: {id: '1', tier: {tier_id: tier2.id}}}); - - await visit(`/members/${member.id}`); - - // all subscriptions are shown - expect(findAll('[data-test-subscription]').length, '# of subscriptions shown').to.equal(3); - - // verify correct number of occurrences for each tier name - const supporterCount = findAll('[data-test-text="tier-name"]').filter(el => el.textContent.includes('Supporter')).length; - const superfanCount = findAll('[data-test-text="tier-name"]').filter(el => el.textContent.includes('Superfan')).length; - - expect(supporterCount, '# of Supporter tiers').to.equal(2); - expect(superfanCount, '# of Superfan tiers').to.equal(1); - - // can add complimentary - expect(findAll('[data-test-button="add-complimentary"]').length, '# of add-complimentary buttons').to.equal(1); - await click('[data-test-button="add-complimentary"]'); - await click(`[data-test-tier-option="${tier2.id}"]`); - await click('[data-test-button="save-comp-tier"]'); - - expect(findAll('[data-test-subscription]').length, '# of tiers after comp added').to.equal(4); - expect(findAll('[data-test-text="tier-name"]').filter(el => el.textContent.includes('Supporter')).length, '# of Supporter tiers after comp added').to.equal(2); - expect(findAll('[data-test-text="tier-name"]').filter(el => el.textContent.includes('Superfan')).length, '# of Superfan tiers after comp added').to.equal(2); - }); - - it('does not set comped status when saving member with stale tier data', async function () { - // Regression test: When a member's subscription was cancelled while the admin - // had the member page open, saving the member would incorrectly set status to - // 'comped' because stale tier data was sent in the request. - - // 1. Create a paid member with an active subscription - const member = this.server.create('member', { - name: 'Stale Tier Test', - status: 'paid', - tiers: [tier], - subscriptions: [ - this.server.create('subscription', { - tier, - status: 'active', - plan: { - id: 'price_1', - nickname: 'Monthly', - amount: 500, - interval: 'month', - currency: 'USD' - }, - price: { - id: 'price_1', - price_id: '6220df272fee0571b5dd0a0a', - nickname: 'Monthly', - amount: 500, - interval: 'month', - type: 'recurring', - currency: 'USD', - tier: { - id: 'prod_1', - tier_id: tier.id - } - } - }) - ] - }); - - // 2. Visit member page (loads tier data into client memory) - await visit(`/members/${member.id}`); - expect(currentURL()).to.equal(`/members/${member.id}`); - - // 3. Simulate backend cancellation - remove tier directly in mirage - // This mimics what happens when a subscription is cancelled via Stripe - // while the admin still has the member page open - member.update({ - tiers: [], - status: 'free' - }); - - // 4. Edit the member (change note) and save - // The client still has stale tier data in memory - await fillIn('[data-test-input="member-note"]', 'Testing stale tier data'); - await click('[data-test-button="save"]'); - - // 5. Verify save succeeded and member is still free, not comped - expect(find('[data-test-button="save"]')).to.not.contain.text('Retry'); - - const updatedMember = this.server.schema.members.find(member.id); - expect(updatedMember.status).to.equal('free'); - expect(updatedMember.tiers.length).to.equal(0); - }); -}); diff --git a/apps/ember-admin/tests/unit/controllers/member-test.js b/apps/ember-admin/tests/unit/controllers/member-test.js deleted file mode 100644 index e3da7b35329..00000000000 --- a/apps/ember-admin/tests/unit/controllers/member-test.js +++ /dev/null @@ -1,40 +0,0 @@ -import sinon from 'sinon'; -import {afterEach, beforeEach, describe, it} from 'mocha'; -import {expect} from 'chai'; -import {setupTest} from 'ember-mocha'; - -describe('Unit: Controller: member', function () { - setupTest(); - - let controller; - let triggerEmberDataChange; - - beforeEach(function () { - triggerEmberDataChange = sinon.spy(); - controller = this.owner.lookup('controller:member'); - Object.defineProperty(controller, 'stateBridge', { - configurable: true, - value: {triggerEmberDataChange} - }); - controller.member = {id: 'member-1'}; - }); - - afterEach(function () { - sinon.restore(); - }); - - it('invalidates the React members cache through the Ember bridge when member data changes', function () { - controller.invalidateMembersCache(); - - expect(triggerEmberDataChange.calledOnce).to.be.true; - expect(triggerEmberDataChange.calledWith('update', 'member', 'member-1', null)).to.be.true; - }); - - it('notifies the Ember bridge when member commenting changes', function () { - controller.invalidateMemberCommenting(); - - expect(triggerEmberDataChange.calledTwice).to.be.true; - expect(triggerEmberDataChange.firstCall.calledWith('update', 'member', 'member-1', null)).to.be.true; - expect(triggerEmberDataChange.secondCall.calledWith('update', 'comment', 'member-1', null)).to.be.true; - }); -}); diff --git a/apps/ember-admin/tests/unit/routes/explore-test.js b/apps/ember-admin/tests/unit/routes/explore-test.js deleted file mode 100644 index 9f1a37b9299..00000000000 --- a/apps/ember-admin/tests/unit/routes/explore-test.js +++ /dev/null @@ -1,12 +0,0 @@ -import {describe, it} from 'mocha'; -import {expect} from 'chai'; -import {setupTest} from 'ember-mocha'; - -describe('Unit | Route | explore', function () { - setupTest(); - - it('exists', function () { - let route = this.owner.lookup('route:explore.connect'); - expect(route).to.be.ok; - }); -}); diff --git a/apps/ember-admin/tests/unit/services/state-bridge-test.js b/apps/ember-admin/tests/unit/services/state-bridge-test.js index 837bc0ee456..1e2502f219e 100644 --- a/apps/ember-admin/tests/unit/services/state-bridge-test.js +++ b/apps/ember-admin/tests/unit/services/state-bridge-test.js @@ -65,10 +65,10 @@ describe('Unit: Service: state-bridge', function () { it('exposes the same strict Labs state used by Ember routes', function () { settings.settingsModel = {}; sinon.stub(feature, 'tagDetailsReact').get(() => true); - sinon.stub(feature, 'memberDetailsReact').get(() => 'true'); + sinon.stub(feature, 'adminUIRefresh').get(() => 'true'); expect(service.isFeatureEnabled('tagDetailsReact')).to.be.true; - expect(service.isFeatureEnabled('memberDetailsReact')).to.be.false; + expect(service.isFeatureEnabled('adminUIRefresh')).to.be.false; expect(service.isFeatureEnabled('missingFlag')).to.be.false; }); }); diff --git a/apps/ember-admin/tests/unit/utils/subscription-data-test.js b/apps/ember-admin/tests/unit/utils/subscription-data-test.js deleted file mode 100644 index 25c78a6e4ca..00000000000 --- a/apps/ember-admin/tests/unit/utils/subscription-data-test.js +++ /dev/null @@ -1,734 +0,0 @@ -import moment from 'moment-timezone'; -import {compExpiry, getDiscountPrice, getOfferDisplayData, getSubscriptionData, giftExpiry, isActive, isCanceled, isComplimentary, isGift, isSetToCancel, priceLabel, trialUntil, validUntil, validityDetails} from 'ghost-admin/utils/subscription-data'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; - -describe('Unit: Util: subscription-data', function () { - describe('validUntil', function () { - it('returns the end of the current billing period when the subscription is canceled at the end of the period', function () { - let sub = { - status: 'canceled', - cancel_at_period_end: true, - current_period_end: '2021-05-31' - }; - expect(validUntil(sub)).to.equal('31 May 2021'); - }); - - it('returns an empty string when the subscription is canceled immediately', function () { - let sub = { - status: 'canceled', - cancel_at_period_end: false, - current_period_end: '2021-05-31' - }; - expect(validUntil(sub)).to.equal(''); - }); - - it('returns the end of the current billing period when the subscription is active', function () { - let sub = { - status: 'active', - cancel_at_period_end: false, - current_period_end: '2021-05-31' - }; - expect(validUntil(sub)).to.equal('31 May 2021'); - }); - - it('returns the end of the current billing period when the subscription is in trial', function () { - let sub = { - status: 'trialing', - cancel_at_period_end: false, - current_period_end: '2021-05-31' - }; - expect(validUntil(sub)).to.equal('31 May 2021'); - }); - - it('returns the end of the current billing period when the subscription is past_due', function () { - let sub = { - status: 'past_due', - cancel_at_period_end: false, - current_period_end: '2021-05-31' - }; - expect(validUntil(sub)).to.equal('31 May 2021'); - }); - - it('returns the end of the current billing period when the subscription is unpaid', function () { - let sub = { - status: 'unpaid', - cancel_at_period_end: false, - current_period_end: '2021-05-31' - }; - expect(validUntil(sub)).to.equal('31 May 2021'); - }); - - // Extra data safety check, mainly for imported subscriptions - it('returns an empty string if the subcription is canceled immediately and has no current_period_start', function () { - let sub = { - status: 'canceled', - cancel_at_period_end: false - }; - expect(validUntil(sub)).to.equal(''); - }); - - // Extra data safety check, mainly for imported subscriptions - it('returns an empty string if the subscription has no current_period_end', function () { - let sub = { - status: 'active', - cancel_at_period_end: false - }; - expect(validUntil(sub)).to.equal(''); - }); - }); - - describe('isActive', function () { - it('returns true for active subscriptions', function () { - let sub = {status: 'active'}; - expect(isActive(sub)).to.be.true; - }); - - it('returns true for trialing subscriptions', function () { - let sub = {status: 'trialing'}; - expect(isActive(sub)).to.be.true; - }); - - it('returns true for past_due subscriptions', function () { - let sub = {status: 'past_due'}; - expect(isActive(sub)).to.be.true; - }); - - it('returns true for unpaid subscriptions', function () { - let sub = {status: 'unpaid'}; - expect(isActive(sub)).to.be.true; - }); - - it('returns false for canceled subscriptions', function () { - let sub = {status: 'canceled'}; - expect(isActive(sub)).to.be.false; - }); - }); - - describe('isComplimentary', function () { - it('returns true for complimentary subscriptions', function () { - let sub = {id: null, plan: {nickname: 'Complimentary'}}; - expect(isComplimentary(sub)).to.be.true; - }); - - it('returns false for paid subscriptions', function () { - let sub = {id: 'sub_123'}; - expect(isComplimentary(sub)).to.be.false; - }); - }); - - describe('isGift', function () { - it('returns true for gift subscriptions', function () { - let sub = {id: null, plan: {nickname: 'Gift subscription'}}; - expect(isGift(sub)).to.be.true; - }); - - it('returns false for complimentary subscriptions', function () { - let sub = {id: null, plan: {nickname: 'Complimentary'}}; - expect(isGift(sub)).to.be.false; - }); - }); - - describe('isCanceled', function () { - it('returns true for canceled subscriptions', function () { - let sub = {status: 'canceled'}; - expect(isCanceled(sub)).to.be.true; - }); - - it('returns false for active subscriptions', function () { - let sub = {status: 'active'}; - expect(isCanceled(sub)).to.be.false; - }); - }); - - describe('isSetToCancel', function () { - it('returns true for subscriptions set to cancel at the end of the period', function () { - let sub = {status: 'active', cancel_at_period_end: true}; - expect(isSetToCancel(sub)).to.be.true; - }); - - it('returns false for canceled subscriptions', function () { - let sub = {status: 'canceled', cancel_at_period_end: true}; - expect(isSetToCancel(sub)).to.be.false; - }); - }); - - describe('trialUntil', function () { - it('returns the trial end date for subscriptions in trial', function () { - let sub = {status: 'trialing', trial_end_at: '2222-05-31'}; - expect(trialUntil(sub)).to.equal('31 May 2222'); - }); - - it('returns the trial end date for trial offers', function () { - let sub = {status: 'active', trial_end_at: '2222-05-31', offer: {type: 'trial'}}; - expect(trialUntil(sub)).to.equal('31 May 2222'); - }); - - it('returns undefined for percent/100/repeating (free months) offers', function () { - let sub = {status: 'active', trial_end_at: '2222-05-31', offer: {type: 'percent', amount: 100, duration: 'repeating'}}; - expect(trialUntil(sub)).to.be.undefined; - }); - - it('returns undefined for subscriptions not in trial', function () { - let sub = {status: 'active'}; - expect(trialUntil(sub)).to.be.undefined; - }); - }); - - describe('compExpiry', function () { - it('returns the expiry date for complimentary subscriptions', function () { - let sub = { - id: null, - plan: {nickname: 'Complimentary'}, - tier: {expiry_at: moment.utc('2021-05-31').toISOString()} - }; - expect(compExpiry(sub)).to.equal('31 May 2021'); - }); - - it('returns undefined for gift subscriptions', function () { - let sub = { - id: null, - plan: {nickname: 'Gift subscription'}, - tier: {expiry_at: moment.utc('2021-05-31').toISOString()} - }; - expect(compExpiry(sub)).to.be.undefined; - }); - - it('returns undefined for paid subscriptions', function () { - let sub = {id: 'sub_123'}; - expect(compExpiry(sub)).to.be.undefined; - }); - }); - - describe('giftExpiry', function () { - it('returns the expiry date for gift subscriptions', function () { - let sub = { - id: null, - plan: {nickname: 'Gift subscription'}, - tier: {expiry_at: moment.utc('2021-05-31').toISOString()} - }; - expect(giftExpiry(sub)).to.equal('31 May 2021'); - }); - - it('returns undefined for complimentary subscriptions', function () { - let sub = { - id: null, - plan: {nickname: 'Complimentary'}, - tier: {expiry_at: moment.utc('2021-05-31').toISOString()} - }; - expect(giftExpiry(sub)).to.be.undefined; - }); - - it('returns undefined for paid subscriptions', function () { - let sub = {id: 'sub_123'}; - expect(giftExpiry(sub)).to.be.undefined; - }); - }); - - describe('priceLabel', function () { - it('returns "Free trial" for trial subscriptions', function () { - let data = {trialUntil: '31 May 2021'}; - expect(priceLabel(data)).to.equal('Free trial'); - }); - - it('returns nothing if the price nickname is the default "monthly" or "yearly"', function () { - let data = {price: {nickname: 'Monthly'}}; - expect(priceLabel(data)).to.be.undefined; - - data = {price: {nickname: 'Yearly'}}; - expect(priceLabel(data)).to.be.undefined; - }); - - it('returns the price nickname for non-default prices', function () { - let data = {price: {nickname: 'Custom'}}; - expect(priceLabel(data)).to.equal('Custom'); - }); - }); - - describe('validityDetails', function () { - it('returns "Expires {compExpiry}" for expired complimentary subscriptions', function () { - let data = { - isComplimentary: true, - compExpiry: '31 May 2021' - }; - expect(validityDetails(data)).to.equal('Expires 31 May 2021'); - }); - - it('returns "" for forever complimentary subscriptions', function () { - let data = { - isComplimentary: true, - compExpiry: undefined - }; - expect(validityDetails(data)).to.equal(''); - }); - - it('returns "Expires {giftExpiry}" for gift subscriptions', function () { - let data = { - isGift: true, - giftExpiry: '31 May 2021' - }; - expect(validityDetails(data)).to.equal('Expires 31 May 2021'); - }); - - it('returns "Ended {validUntil}" for canceled subscriptions', function () { - let data = { - hasEnded: true, - validUntil: '31 May 2021' - }; - expect(validityDetails(data)).to.equal('Ended 31 May 2021'); - }); - - it('returns "Has access until {validUntil}" for set to cancel subscriptions', function () { - let data = { - willEndSoon: true, - validUntil: '31 May 2021' - }; - expect(validityDetails(data)).to.equal('Has access until 31 May 2021'); - }); - - it('returns "Ends {validUntil}" for trial subscriptions', function () { - let data = { - trialUntil: '31 May 2021' - }; - expect(validityDetails(data)).to.equal('Ends 31 May 2021'); - }); - - it('returns "Renews {validUntil}" for active subscriptions', function () { - let data = { - validUntil: '31 May 2021' - }; - expect(validityDetails(data)).to.equal('Renews 31 May 2021'); - }); - }); - - describe('getSubscriptionData', function () { - it('returns the correct data for an active subscription', function () { - let sub = { - id: 'defined', - status: 'active', - cancel_at_period_end: false, - current_period_end: '2021-05-31', - trial_end_at: null, - tier: null, - price: { - currency: 'usd', - amount: 5000 - } - }; - let data = getSubscriptionData(sub); - - expect(data).to.include({ - isComplimentary: false, - isGift: false, - compExpiry: undefined, - giftExpiry: undefined, - hasEnded: false, - validUntil: '31 May 2021', - willEndSoon: false, - trialUntil: undefined, - priceLabel: undefined, - validityDetails: 'Renews 31 May 2021' - }); - }); - - it('returns the correct data for a trial subscription', function () { - let sub = { - id: 'defined', - status: 'trialing', - cancel_at_period_end: false, - current_period_end: '2222-05-31', - trial_end_at: '2222-05-31', - tier: null, - price: { - currency: 'usd', - amount: 5000 - } - }; - let data = getSubscriptionData(sub); - - expect(data).to.include({ - isComplimentary: false, - isGift: false, - compExpiry: undefined, - giftExpiry: undefined, - hasEnded: false, - validUntil: '31 May 2222', - willEndSoon: false, - trialUntil: '31 May 2222', - priceLabel: 'Free trial', - validityDetails: ' – Ends 31 May 2222' - }); - }); - - it('returns renews details for percent/100/repeating (free months) offers', function () { - let sub = { - id: 'defined', - status: 'active', - cancel_at_period_end: false, - current_period_end: '2222-05-31', - offer: { - type: 'percent', - amount: 100, - duration: 'repeating' - }, - tier: null, - price: { - currency: 'usd', - amount: 5000, - nickname: 'Free tier' - } - }; - let data = getSubscriptionData(sub); - - expect(data).to.include({ - isComplimentary: false, - isGift: false, - compExpiry: undefined, - giftExpiry: undefined, - hasEnded: false, - validUntil: '31 May 2222', - willEndSoon: false, - trialUntil: undefined, - priceLabel: 'Free tier', - validityDetails: ' – Renews 31 May 2222' - }); - }); - - it('returns the correct data for an immediately canceled subscription', function () { - let sub = { - id: 'defined', - status: 'canceled', - cancel_at_period_end: false, - current_period_end: '2021-05-31', - trial_end_at: null, - tier: null, - price: { - currency: 'usd', - amount: 5000 - } - }; - let data = getSubscriptionData(sub); - - expect(data).to.include({ - isComplimentary: false, - isGift: false, - compExpiry: undefined, - giftExpiry: undefined, - hasEnded: true, - validUntil: '', - willEndSoon: false, - trialUntil: undefined, - priceLabel: undefined, - validityDetails: 'Ended' - }); - }); - - it('returns the correct data for a subscription set to cancel at the end of the period', function () { - let sub = { - id: 'defined', - status: 'active', - cancel_at_period_end: true, - current_period_end: '2021-05-31', - trial_end_at: null, - tier: null, - price: { - currency: 'usd', - amount: 5000 - } - }; - let data = getSubscriptionData(sub); - - expect(data).to.include({ - isComplimentary: false, - isGift: false, - compExpiry: undefined, - giftExpiry: undefined, - hasEnded: false, - validUntil: '31 May 2021', - willEndSoon: true, - trialUntil: undefined, - priceLabel: undefined, - validityDetails: 'Has access until 31 May 2021' - }); - }); - - it('returns the correct data for a complimentary subscription active forever', function () { - let sub = { - id: null, - status: 'active', - cancel_at_period_end: false, - current_period_end: '2021-05-31', - trial_end_at: null, - plan: { - nickname: 'Complimentary' - }, - tier: { - expiry_at: null - }, - price: { - currency: 'usd', - amount: 0, - nickname: 'Complimentary' - } - }; - let data = getSubscriptionData(sub); - - expect(data).to.include({ - isComplimentary: true, - isGift: false, - compExpiry: undefined, - giftExpiry: undefined, - hasEnded: false, - validUntil: '31 May 2021', - willEndSoon: false, - trialUntil: undefined, - priceLabel: 'Complimentary', - validityDetails: '' - }); - }); - - it('returns the correct data for a complimentary subscription with an expiration date', function () { - let sub = { - id: null, - status: 'active', - cancel_at_period_end: false, - current_period_end: '2021-05-31', - trial_end_at: null, - plan: { - nickname: 'Complimentary' - }, - tier: { - expiry_at: moment.utc('2021-05-31').toISOString() - }, - price: { - currency: 'usd', - amount: 0, - nickname: 'Complimentary' - } - }; - let data = getSubscriptionData(sub); - - expect(data).to.include({ - isComplimentary: true, - isGift: false, - compExpiry: '31 May 2021', - giftExpiry: undefined, - hasEnded: false, - validUntil: '31 May 2021', - willEndSoon: false, - trialUntil: undefined, - priceLabel: 'Complimentary', - validityDetails: ' – Expires 31 May 2021' - }); - }); - - it('returns the correct data for a gift subscription with an expiration date', function () { - let sub = { - id: null, - status: 'active', - cancel_at_period_end: false, - current_period_end: '2021-05-31', - trial_end_at: null, - plan: { - nickname: 'Gift subscription' - }, - tier: { - expiry_at: moment.utc('2021-05-31').toISOString() - }, - price: { - currency: 'usd', - amount: 0, - nickname: 'Gift subscription' - } - }; - let data = getSubscriptionData(sub); - - expect(data).to.include({ - isComplimentary: false, - isGift: true, - compExpiry: undefined, - giftExpiry: '31 May 2021', - hasEnded: false, - validUntil: '31 May 2021', - willEndSoon: false, - trialUntil: undefined, - priceLabel: 'Gift subscription', - validityDetails: ' – Expires 31 May 2021' - }); - }); - - it('sets hasActiveDiscount with discounted and original prices', function () { - const sub = { - id: 'sub_1', - status: 'active', - cancel_at_period_end: false, - current_period_end: '2026-05-31', - price: {currency: 'usd', amount: 5000}, - next_payment: { - amount: 2500, - original_amount: 5000, - currency: 'usd', - discount: {offer_id: 'offer_1', end: '2026-09-01'} - } - }; - - const data = getSubscriptionData(sub); - - expect(data.hasActiveDiscount).to.be.true; - expect(data.discountedPrice).to.deep.equal({currencySymbol: '$', nonDecimalAmount: 25, amount: 2500}); - expect(data.originalPrice).to.deep.equal({currencySymbol: '$', nonDecimalAmount: 50, amount: 5000}); - }); - - it('does not set hasActiveDiscount when no discount', function () { - const sub = { - id: 'sub_1', - status: 'active', - cancel_at_period_end: false, - current_period_end: '2026-05-31', - price: {currency: 'usd', amount: 5000} - }; - - const data = getSubscriptionData(sub); - - expect(data.hasActiveDiscount).to.be.undefined; - }); - }); - - describe('getOfferDisplayData', function () { - const cases = [ - { - name: 'signup + percent', - offer: {redemption_type: 'signup', type: 'percent', name: 'Black Friday', amount: 30}, - expected: {label: 'Signup offer', detail: 'Black Friday (30% off)'} - }, - { - name: 'signup + fixed (USD)', - offer: {redemption_type: 'signup', type: 'fixed', name: 'Welcome Deal', amount: 500, currency: 'USD'}, - expected: {label: 'Signup offer', detail: 'Welcome Deal ($5 off)'} - }, - { - name: 'signup + fixed (EUR)', - offer: {redemption_type: 'signup', type: 'fixed', name: 'Euro Deal', amount: 1000, currency: 'EUR'}, - expected: {label: 'Signup offer', detail: 'Euro Deal (€10 off)'} - }, - { - name: 'signup + trial', - offer: {redemption_type: 'signup', type: 'trial', name: 'Try It', amount: 7}, - expected: {label: 'Signup offer', detail: 'Try It (7 days free)'} - }, - { - name: 'retention + percent + once', - offer: {id: 'offer_once_1', redemption_type: 'retention', type: 'percent', amount: 50, duration: 'once'}, - sub: {next_payment: {discount: {offer_id: 'offer_once_1', end: '2026-02-17T00:00:00.000Z'}}}, - expected: {label: 'Retention offer', detail: '50% off until Feb 2026'} - }, - { - name: 'retention + percent + repeating (no discount end)', - offer: {redemption_type: 'retention', type: 'percent', amount: 50, duration: 'repeating', duration_in_months: 3}, - expected: {label: 'Retention offer', detail: '50% off for 3 months'} - }, - { - name: 'retention + percent + repeating (1 month, no discount end)', - offer: {redemption_type: 'retention', type: 'percent', amount: 50, duration: 'repeating', duration_in_months: 1}, - expected: {label: 'Retention offer', detail: '50% off for 1 month'} - }, - { - name: 'retention + percent + repeating (with discount end)', - offer: {id: 'offer_1', redemption_type: 'retention', type: 'percent', amount: 50, duration: 'repeating', duration_in_months: 3}, - sub: {next_payment: {discount: {offer_id: 'offer_1', end: '2026-02-17T00:00:00.000Z'}}}, - expected: {label: 'Retention offer', detail: '50% off until Feb 2026'} - }, - { - name: 'retention + percent + forever', - offer: {redemption_type: 'retention', type: 'percent', amount: 25, duration: 'forever'}, - expected: {label: 'Retention offer', detail: '25% off forever'} - }, - { - name: 'retention + percent/100/repeating (free months, no discount end)', - offer: {redemption_type: 'retention', type: 'percent', amount: 100, duration: 'repeating', duration_in_months: 1}, - expected: {label: 'Retention offer', detail: '1 month free'} - }, - { - name: 'retention + percent/100/repeating (free months, with discount end)', - offer: {id: 'offer_2', redemption_type: 'retention', type: 'percent', amount: 100, duration: 'repeating', duration_in_months: 1}, - sub: {next_payment: {discount: {offer_id: 'offer_2', end: '2026-02-17T00:00:00.000Z'}}}, - expected: {label: 'Retention offer', detail: '1 month free until Feb 2026'} - }, - { - name: 'retention + discount end does not match offer id', - offer: {id: 'offer_1', redemption_type: 'retention', type: 'percent', amount: 50, duration: 'repeating', duration_in_months: 3}, - sub: {next_payment: {discount: {offer_id: 'offer_other', end: '2026-02-17T00:00:00.000Z'}}}, - expected: {label: 'Retention offer', detail: '50% off for 3 months'} - }, - { - name: 'missing redemption_type defaults to signup label', - offer: {type: 'percent', name: 'Legacy Offer', amount: 15}, - expected: {label: 'Signup offer', detail: 'Legacy Offer (15% off)'} - } - ]; - - cases.forEach(({name, offer, sub, expected}) => { - it(name, function () { - const result = getOfferDisplayData(offer, sub); - expect(result).to.deep.equal(expected); - }); - }); - }); - - describe('getDiscountPrice', function () { - it('returns null when there is no next_payment', function () { - expect(getDiscountPrice({price: {currency: 'usd', amount: 5000}})).to.be.null; - }); - - it('returns null when there is no discount on next_payment', function () { - expect(getDiscountPrice({ - price: {currency: 'usd', amount: 5000}, - next_payment: {amount: 5000, original_amount: 5000, currency: 'usd'} - })).to.be.null; - }); - - it('returns null when discounted amount equals original amount', function () { - expect(getDiscountPrice({ - price: {currency: 'usd', amount: 5000}, - next_payment: { - amount: 5000, - original_amount: 5000, - currency: 'usd', - discount: {offer_id: 'offer_1', end: '2026-09-01'} - } - })).to.be.null; - }); - - it('returns discounted and original prices for USD', function () { - const result = getDiscountPrice({ - price: {currency: 'usd', amount: 5000}, - next_payment: { - amount: 2500, - original_amount: 5000, - currency: 'usd', - discount: {offer_id: 'offer_1', end: '2026-09-01'} - } - }); - expect(result).to.deep.equal({ - discountedPrice: {currencySymbol: '$', nonDecimalAmount: 25, amount: 2500}, - originalPrice: {currencySymbol: '$', nonDecimalAmount: 50, amount: 5000} - }); - }); - - it('returns discounted and original prices for EUR', function () { - const result = getDiscountPrice({ - price: {currency: 'eur', amount: 10000}, - next_payment: { - amount: 7000, - original_amount: 10000, - currency: 'eur', - discount: {offer_id: 'offer_1', end: '2026-09-01'} - } - }); - expect(result).to.deep.equal({ - discountedPrice: {currencySymbol: '€', nonDecimalAmount: 70, amount: 7000}, - originalPrice: {currencySymbol: '€', nonDecimalAmount: 100, amount: 10000} - }); - }); - }); -}); diff --git a/apps/portal/LICENSE b/apps/portal/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/apps/portal/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/apps/portal/README.md b/apps/portal/README.md index fcc106fd7cb..828bf1d890a 100644 --- a/apps/portal/README.md +++ b/apps/portal/README.md @@ -97,4 +97,4 @@ If the CI doesn't clear JsDelivr cache to get the new version out instantly, you # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). diff --git a/apps/signup-form/LICENSE b/apps/signup-form/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/apps/signup-form/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/apps/signup-form/README.md b/apps/signup-form/README.md index bb2106ee9c5..1ad1b85455a 100644 --- a/apps/signup-form/README.md +++ b/apps/signup-form/README.md @@ -6,46 +6,37 @@ Embed a Ghost signup form on any site. ### Pre-requisites -- Run `pnpm` in Ghost monorepo root -- Run `pnpm` in this directory +- Run `pnpm setup` in the Ghost monorepo root -### Running via Ghost `pnpm dev` in root folder +### Running via Ghost from the monorepo root + +Start Ghost with the public-app watchers enabled: -Signup Form runs automatically when using Ghost's development command from the monorepo root: ```bash -pnpm dev +pnpm dev:public ``` -This starts all frontend apps (including Signup Form.) +This starts the standard development environment and the Signup Form watcher. ### Running the standalone demo page Run `pnpm dev:standalone` (in this package folder) to start the standalone development server with HMR for testing/developing the form in isolation. - This serves the demo page at http://localhost:6173 -`pnpm dev` on its own (in this package folder) only builds `umd/signup-form.min.js` and watches for changes — it does not bind a port. The UMD is served by Caddy at http://localhost:2368/ghost/assets/signup-form/signup-form.min.js when you run `pnpm dev` from the monorepo root. +`pnpm dev` on its own (in this package folder) only builds `umd/signup-form.min.js` and watches for changes — it does not bind a port. The UMD is served by Caddy at http://localhost:2368/ghost/assets/signup-form/signup-form.min.js when you run `pnpm dev:public` from the monorepo root. ### Using the UMD build during development Vite by default only supports HRM with an ESM output. But when loading a script on a site as a ESM module (`'); - giftServiceWrapper.service = { - getPreview: sinon.stub().resolves({ - tier: {id: 'tier_1', name: 'Premium'}, - cadence: 'month', - duration: 3 - }) - }; + giftService.getPreview.resolves({ + tier: {id: 'tier_1', name: 'Premium'}, + cadence: 'month', + duration: 3 + }); await controller.giftPreview(req, res); @@ -113,13 +121,11 @@ describe('Gift Preview Controller', function () { }); it('uses monthly cadence label', async function () { - giftServiceWrapper.service = { - getPreview: sinon.stub().resolves({ - tier: {id: 'tier_1', name: 'Premium'}, - cadence: 'month', - duration: 3 - }) - }; + giftService.getPreview.resolves({ + tier: {id: 'tier_1', name: 'Premium'}, + cadence: 'month', + duration: 3 + }); await controller.giftPreview(req, res); @@ -131,13 +137,11 @@ describe('Gift Preview Controller', function () { it('defaults site title to Ghost', async function () { settingsCache.get.withArgs('title').returns(null); - giftServiceWrapper.service = { - getPreview: sinon.stub().resolves({ - tier: {id: 'tier_1', name: 'Premium'}, - cadence: 'year', - duration: 1 - }) - }; + giftService.getPreview.resolves({ + tier: {id: 'tier_1', name: 'Premium'}, + cadence: 'year', + duration: 1 + }); await controller.giftPreview(req, res); @@ -149,13 +153,11 @@ describe('Gift Preview Controller', function () { describe('giftPreviewImage', function () { it('returns a PNG image for a valid gift', async function () { - giftServiceWrapper.service = { - getPreview: sinon.stub().resolves({ - tier: {id: 'tier_1', name: 'Gold'}, - cadence: 'year', - duration: 1 - }) - }; + giftService.getPreview.resolves({ + tier: {id: 'tier_1', name: 'Gold'}, + cadence: 'year', + duration: 1 + }); await controller.giftPreviewImage(req, res); diff --git a/ghost/core/test/unit/shared/config/loader.test.js b/ghost/core/test/unit/shared/config/loader.test.js index 2949d8bc149..669f93864bc 100644 --- a/ghost/core/test/unit/shared/config/loader.test.js +++ b/ghost/core/test/unit/shared/config/loader.test.js @@ -1,4 +1,6 @@ const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); const path = require('path'); const _ = require('lodash'); const configUtils = require('../../../utils/config-utils'); @@ -17,10 +19,18 @@ describe('Config Loader', function () { let originalArgv; let customConfig; let loader; + let tmpDir; + + function writeSecret(contents) { + const filePath = path.join(tmpDir, 'secret'); + fs.writeFileSync(filePath, contents); + return filePath; + } beforeEach(function () { originalEnv = _.clone(process.env); originalArgv = _.clone(process.argv); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ghost-loader-')); loader = require('../../../../core/shared/config/loader'); // getNodeEnv() reads process.env.NODE_ENV, so drive that directly process.env.NODE_ENV = 'testing'; @@ -35,6 +45,7 @@ describe('Config Loader', function () { afterEach(function () { process.env = originalEnv; process.argv = originalArgv; + fs.rmSync(tmpDir, {recursive: true, force: true}); sinon.restore(); }); @@ -61,6 +72,50 @@ describe('Config Loader', function () { assert.equal(customConfig.get('database:client'), 'stronger'); }); + it('secret file is stronger than file', function () { + process.env.logging__level_FILE = writeSecret('warn\n'); + + customConfig = loader.loadNconf({ + baseConfigPath: path.join(__dirname, '../../../utils/fixtures/config'), + customConfigPath: path.join(__dirname, '../../../utils/fixtures/config') + }); + + assert.equal(customConfig.get('logging:level'), 'warn'); + }); + + it('argv is stronger than a secret file', function () { + process.env.logging__level_FILE = writeSecret('warn\n'); + process.argv[2] = '--logging:level=stronger'; + + customConfig = loader.loadNconf({ + baseConfigPath: path.join(__dirname, '../../../utils/fixtures/config'), + customConfigPath: path.join(__dirname, '../../../utils/fixtures/config') + }); + + assert.equal(customConfig.get('logging:level'), 'stronger'); + }); + + it('does not leak the secret file path into config', function () { + process.env.database__connection__password_FILE = writeSecret('hunter2\n'); + + customConfig = loader.loadNconf({ + baseConfigPath: path.join(__dirname, '../../../utils/fixtures/config'), + customConfigPath: path.join(__dirname, '../../../utils/fixtures/config') + }); + + assert.equal(customConfig.get('database:connection:password_FILE'), undefined); + }); + + it('throws if a value and its secret file are both set', function () { + process.env.logging__level = 'warn'; + process.env.logging__level_FILE = writeSecret('error\n'); + + assert.throws(() => loader.loadNconf({ + baseConfigPath: path.join(__dirname, '../../../utils/fixtures/config'), + customConfigPath: path.join(__dirname, '../../../utils/fixtures/config') + }), /Cannot set both logging__level and logging__level_FILE/); + }); + it('argv or env is NOT stronger than overrides', function () { process.env.paths__corePath = 'try-to-override'; process.argv[2] = '--paths:corePath=try-to-override'; diff --git a/ghost/core/test/unit/shared/config/secrets.test.ts b/ghost/core/test/unit/shared/config/secrets.test.ts new file mode 100644 index 00000000000..0be4d19e569 --- /dev/null +++ b/ghost/core/test/unit/shared/config/secrets.test.ts @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {loadSecretsFromEnv} from '../../../../core/shared/config/secrets'; + +describe('Config Secrets', function () { + let tmpDir: string; + + function writeSecret(name: string, contents: string): string { + const filePath = path.join(tmpDir, name); + fs.writeFileSync(filePath, contents); + return filePath; + } + + beforeEach(function () { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ghost-secrets-')); + }); + + afterEach(function () { + fs.rmSync(tmpDir, {recursive: true, force: true}); + }); + + it('resolves a nested key from a file', function () { + const filePath = writeSecret('db', 'hunter2'); + + assert.deepEqual(loadSecretsFromEnv({database__connection__password_FILE: filePath}), { + database: {connection: {password: 'hunter2'}} + }); + }); + + it('matches the suffix case-insensitively', function () { + const filePath = writeSecret('db', 'hunter2'); + + assert.deepEqual(loadSecretsFromEnv({database__connection__password_file: filePath}), { + database: {connection: {password: 'hunter2'}} + }); + }); + + it('does not parse values', function () { + const filePath = writeSecret('db', '01234'); + + assert.deepEqual(loadSecretsFromEnv({database__connection__password_FILE: filePath}), { + database: {connection: {password: '01234'}} + }); + }); + + it('strips a single trailing newline but keeps other whitespace', function () { + const trailing = writeSecret('trailing', 'hunter2\n'); + const surrounding = writeSecret('surrounding', ' hunter2 '); + const multiple = writeSecret('multiple', 'hunter2\n\n'); + + assert.deepEqual(loadSecretsFromEnv({a__b_FILE: trailing}), {a: {b: 'hunter2'}}); + assert.deepEqual(loadSecretsFromEnv({a__b_FILE: surrounding}), {a: {b: ' hunter2 '}}); + assert.deepEqual(loadSecretsFromEnv({a__b_FILE: multiple}), {a: {b: 'hunter2\n'}}); + }); + + it('merges multiple secrets under a shared parent', function () { + const user = writeSecret('user', 'ghost'); + const pass = writeSecret('pass', 'hunter2'); + + assert.deepEqual(loadSecretsFromEnv({ + mail__options__auth__user_FILE: user, + mail__options__auth__pass_FILE: pass + }), { + mail: {options: {auth: {user: 'ghost', pass: 'hunter2'}}} + }); + }); + + it('ignores env vars that are not nested config keys', function () { + const filePath = writeSecret('ca', 'not-a-secret'); + + assert.deepEqual(loadSecretsFromEnv({ + SSL_CERT_FILE: filePath, + CURL_CA_BUNDLE: filePath, + _FILE: filePath, + database____password_FILE: filePath + }), {}); + }); + + it('ignores env vars with an empty value', function () { + assert.deepEqual(loadSecretsFromEnv({database__connection__password_FILE: ''}), {}); + }); + + it('keeps numeric key segments as plain objects', function () { + const filePath = writeSecret('token', 'abc'); + const store = loadSecretsFromEnv({adapters__0__token_FILE: filePath}); + + assert.deepEqual(store, {adapters: {0: {token: 'abc'}}}); + assert.equal(Array.isArray(store.adapters), false); + }); + + it('throws if the value and its file reference are both set', function () { + const filePath = writeSecret('db', 'hunter2'); + + assert.throws(() => loadSecretsFromEnv({ + database__connection__password: 'hunter2', + database__connection__password_FILE: filePath + }), /Cannot set both database__connection__password and database__connection__password_FILE/); + }); + + it('allows the same key set twice if both point at the same file', function () { + const filePath = writeSecret('db', 'hunter2'); + + assert.deepEqual(loadSecretsFromEnv({ + database__connection__password_FILE: filePath, + database__connection__password_file: filePath + }), { + database: {connection: {password: 'hunter2'}} + }); + }); + + it('throws if the same key is set twice pointing at different files', function () { + assert.throws(() => loadSecretsFromEnv({ + database__connection__password_FILE: writeSecret('one', 'hunter2'), + database__connection__password_file: writeSecret('two', 'hunter3') + }), /to different files/); + }); + + it('throws if the file cannot be read', function () { + const filePath = path.join(tmpDir, 'nope'); + + assert.throws(() => loadSecretsFromEnv({database__connection__password_FILE: filePath}), (err: Error) => { + assert.match(err.message, /Could not read the secret file referenced by database__connection__password_FILE/); + assert.equal((err.cause as NodeJS.ErrnoException).code, 'ENOENT'); + return true; + }); + }); + + it('supports a custom separator', function () { + const filePath = writeSecret('db', 'hunter2'); + + assert.deepEqual(loadSecretsFromEnv({'database.connection.password_FILE': filePath}, '.'), { + database: {connection: {password: 'hunter2'}} + }); + }); +}); diff --git a/ghost/core/test/utils/fixtures/default-settings.json b/ghost/core/test/utils/fixtures/default-settings.json index c897aa1f91c..e7a42714dce 100644 --- a/ghost/core/test/utils/fixtures/default-settings.json +++ b/ghost/core/test/utils/fixtures/default-settings.json @@ -60,6 +60,14 @@ "defaultValue": null, "type": "string" }, + "machine_payments_secret": { + "defaultValue": null, + "type": "string" + }, + "machine_payments_deposit_address": { + "defaultValue": null, + "type": "string" + }, "members_track_sources": { "defaultValue": "true", "validations": { @@ -194,6 +202,25 @@ "validations": {}, "type": "boolean" }, + "machine_payments_enabled": { + "defaultValue": "false", + "type": "boolean" + }, + "machine_payments_currency": { + "defaultValue": null, + "type": "string" + }, + "machine_payments_amount": { + "defaultValue": 100, + "validations": { + "isEmpty": false + }, + "type": "number" + }, + "machine_payments_stripe_profile_id": { + "defaultValue": null, + "type": "string" + }, "meta_title": { "defaultValue": null, "validations": { diff --git a/ghost/core/test/utils/fixtures/fixtures.json b/ghost/core/test/utils/fixtures/fixtures.json index f630c37c1d1..5515dda1618 100644 --- a/ghost/core/test/utils/fixtures/fixtures.json +++ b/ghost/core/test/utils/fixtures/fixtures.json @@ -100,10 +100,6 @@ "name": "Admin Integration", "description": "External Apps" }, - { - "name": "Ghost Explore Integration", - "description": "Internal Integration for the Ghost Explore directory" - }, { "name": "Self-Serve Migration Integration", "description": "Internal Integration for the Self-Serve migration tool" @@ -125,11 +121,6 @@ { "name": "Permission", "entries": [ - { - "name": "Read explore data", - "action_type": "read", - "object_type": "explore" - }, { "name": "Export database", "action_type": "exportContent", @@ -1068,13 +1059,6 @@ "type": "builtin", "api_keys": [{"type": "admin"}] }, - { - "slug": "ghost-explore", - "name": "Ghost Explore", - "description": "Built-in Ghost Explore integration", - "type": "core", - "api_keys": [{"type": "admin", "role": "Ghost Explore Integration"}] - }, { "slug": "self-serve-migration", "name": "Self-Serve Migration Integration", @@ -1156,7 +1140,6 @@ "authentication": "reset", "members_stripe_connect": "auth", "newsletter": "all", - "explore": "read", "comment": "all", "link": "all", "mention": "browse", @@ -1175,9 +1158,6 @@ "automation": "poll", "gift": "flushReminders" }, - "Ghost Explore Integration": { - "explore": "read" - }, "Self-Serve Migration Integration": { "db": "importContent", "content_import": "importContent", @@ -1210,7 +1190,6 @@ "product": ["browse", "read", "add", "edit"], "offer": ["browse", "read", "add", "edit"], "newsletter": ["browse", "read", "add", "edit"], - "explore": "read", "comment": "all", "link": "all", "mention": "browse", diff --git a/koenig/kg-card-factory/LICENSE b/koenig/kg-card-factory/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/koenig/kg-card-factory/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/koenig/kg-card-factory/README.md b/koenig/kg-card-factory/README.md index d0b96847bc6..2442adfcd3d 100644 --- a/koenig/kg-card-factory/README.md +++ b/koenig/kg-card-factory/README.md @@ -36,4 +36,4 @@ Follow the instructions for the top-level repo. # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). diff --git a/koenig/kg-clean-basic-html/LICENSE b/koenig/kg-clean-basic-html/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/koenig/kg-clean-basic-html/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/koenig/kg-clean-basic-html/README.md b/koenig/kg-clean-basic-html/README.md index 6ccde4d713b..6abdd59bbb8 100644 --- a/koenig/kg-clean-basic-html/README.md +++ b/koenig/kg-clean-basic-html/README.md @@ -36,4 +36,4 @@ Follow the instructions for the top-level repo. # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). diff --git a/koenig/kg-converters/LICENSE b/koenig/kg-converters/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/koenig/kg-converters/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/koenig/kg-converters/README.md b/koenig/kg-converters/README.md index 4fc28ad4543..1db8e67800c 100644 --- a/koenig/kg-converters/README.md +++ b/koenig/kg-converters/README.md @@ -32,4 +32,4 @@ Follow the instructions for the top-level repo. # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). \ No newline at end of file +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). diff --git a/koenig/kg-default-cards/LICENSE b/koenig/kg-default-cards/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/koenig/kg-default-cards/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/koenig/kg-default-cards/README.md b/koenig/kg-default-cards/README.md index 6a02521c5ae..d6d42c30430 100644 --- a/koenig/kg-default-cards/README.md +++ b/koenig/kg-default-cards/README.md @@ -36,4 +36,4 @@ Follow the instructions for the top-level repo. # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). diff --git a/koenig/kg-default-nodes/LICENSE b/koenig/kg-default-nodes/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/koenig/kg-default-nodes/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/koenig/kg-default-nodes/README.md b/koenig/kg-default-nodes/README.md index 5efaf612112..23e54a9f039 100644 --- a/koenig/kg-default-nodes/README.md +++ b/koenig/kg-default-nodes/README.md @@ -40,4 +40,4 @@ linking. Run `pnpm dev` in this package for a rebuild-on-change watcher. # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). \ No newline at end of file +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). diff --git a/koenig/kg-default-transforms/LICENSE b/koenig/kg-default-transforms/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/koenig/kg-default-transforms/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/koenig/kg-default-transforms/README.md b/koenig/kg-default-transforms/README.md index 9ff897a72bc..283461b9184 100644 --- a/koenig/kg-default-transforms/README.md +++ b/koenig/kg-default-transforms/README.md @@ -32,4 +32,4 @@ Follow the instructions for the top-level repo. # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). \ No newline at end of file +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). diff --git a/koenig/kg-html-to-lexical/LICENSE b/koenig/kg-html-to-lexical/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/koenig/kg-html-to-lexical/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/koenig/kg-html-to-lexical/README.md b/koenig/kg-html-to-lexical/README.md index fbda456a71a..ebac325d7ca 100644 --- a/koenig/kg-html-to-lexical/README.md +++ b/koenig/kg-html-to-lexical/README.md @@ -32,4 +32,4 @@ Follow the instructions for the top-level repo. # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). diff --git a/koenig/kg-lexical-html-renderer/LICENSE b/koenig/kg-lexical-html-renderer/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/koenig/kg-lexical-html-renderer/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/koenig/kg-lexical-html-renderer/README.md b/koenig/kg-lexical-html-renderer/README.md index ca3406cd0b7..66f203b0939 100644 --- a/koenig/kg-lexical-html-renderer/README.md +++ b/koenig/kg-lexical-html-renderer/README.md @@ -65,4 +65,4 @@ linking. Run `pnpm dev` in this package for a rebuild-on-change watcher. # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). diff --git a/koenig/kg-markdown-html-renderer/LICENSE b/koenig/kg-markdown-html-renderer/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/koenig/kg-markdown-html-renderer/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/koenig/kg-markdown-html-renderer/README.md b/koenig/kg-markdown-html-renderer/README.md index e560febd0d5..28e5f456b6e 100644 --- a/koenig/kg-markdown-html-renderer/README.md +++ b/koenig/kg-markdown-html-renderer/README.md @@ -36,4 +36,4 @@ Follow the instructions for the top-level repo. # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). diff --git a/koenig/kg-unsplash-selector/LICENSE b/koenig/kg-unsplash-selector/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/koenig/kg-unsplash-selector/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/koenig/kg-utils/LICENSE b/koenig/kg-utils/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/koenig/kg-utils/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/koenig/kg-utils/README.md b/koenig/kg-utils/README.md index d9aceb9d6d9..607b31521cb 100644 --- a/koenig/kg-utils/README.md +++ b/koenig/kg-utils/README.md @@ -36,4 +36,4 @@ Follow the instructions for the top-level repo. # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). \ No newline at end of file +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). diff --git a/koenig/koenig-lexical/LICENSE b/koenig/koenig-lexical/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/koenig/koenig-lexical/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/package.json b/package.json index 180d7a19ddd..38b41bf79c8 100644 --- a/package.json +++ b/package.json @@ -62,9 +62,10 @@ "docker:rebase": "git fetch ${GHOST_UPSTREAM:-origin} main && git rebase ${GHOST_UPSTREAM:-origin}/main && pnpm install && pnpm nx run-many -t build --projects=@tryghost/shade,@tryghost/admin-x-framework && docker compose -f compose.dev.yaml ${DEV_COMPOSE_FILES} up -d --build --force-recreate ghost-dev", "knip": "knip", "knip:fix": "knip --fix --allow-remove-files=false", - "lint": "pnpm nx run-many -t lint lint:boundaries && pnpm lint:docs", + "lint": "pnpm nx run-many -t lint lint:boundaries && pnpm lint:packages && pnpm lint:docs", "lint:agent-skills": "node scripts/check-agent-skill-links.js", "lint:boundaries": "depcruise ghost/core/core apps --config .dependency-cruiser.cjs", + "lint:packages": "node scripts/check-internal-packages.js", "lint:docs": "pnpm lint:agent-skills", "check": "pnpm lint && pnpm test", "test": "pnpm nx run-many -t test --exclude @tryghost/e2e --exclude ghost-admin", diff --git a/packages/README.md b/packages/README.md index d28314516a7..55fa831f442 100644 --- a/packages/README.md +++ b/packages/README.md @@ -14,6 +14,7 @@ New internal packages are private, TypeScript-only ESM libraries: - use an `@tryghost/` package name; - set `"version": "0.0.0"` and `"private": true`; +- set `"ghostPackage": {"goldenPath": "compliant"}`; - set `"type": "module"`; - keep authored code in `src/**/*.ts` and tests in `test/**/*.ts`; - compile production code to `build/` with `tsc`; @@ -23,6 +24,22 @@ Making a package public or independently versioned is a product and maintenance decision, not a packaging convenience. Establish its compatibility, release and support policy before removing `private` or adding publishing automation. +### Golden path status + +Every private package under `packages/` declares its lifecycle state in +`ghostPackage.goldenPath`: + +- `compliant` means the package is mechanically checked against this document; +- `migration` is temporary while a history-preserving import awaits a separate + modernization PR; +- `exempt` records an intentional long-term exception such as a test-only or + multi-runtime package. + +Both `migration` and `exempt` require a non-empty `ghostPackage.reason`. +Public, independently versioned packages do not declare this metadata because +this internal-only contract does not apply to them. Run `pnpm lint:packages` to +validate the status and all mechanically enforceable rules. + ## Package metadata Use the template's repository, author and license metadata. Point repository diff --git a/packages/_template/package.json b/packages/_template/package.json index b12829ea474..6f69858100f 100644 --- a/packages/_template/package.json +++ b/packages/_template/package.json @@ -3,6 +3,9 @@ "version": "0.0.0", "description": "{{DESCRIPTION}}", "private": true, + "ghostPackage": { + "goldenPath": "compliant" + }, "type": "module", "repository": { "type": "git", diff --git a/packages/adapters/cache-base/README.md b/packages/adapters/cache-base/README.md index 1e2bd22f8ce..8a9643a3f34 100644 --- a/packages/adapters/cache-base/README.md +++ b/packages/adapters/cache-base/README.md @@ -83,4 +83,4 @@ imports in `src/` must carry an explicit extension; write the real `.ts` one — # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage. +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage. diff --git a/packages/adapters/redirects-base/package.json b/packages/adapters/redirects-base/package.json index 05808e44e7c..881bea07d15 100644 --- a/packages/adapters/redirects-base/package.json +++ b/packages/adapters/redirects-base/package.json @@ -3,6 +3,9 @@ "version": "0.0.0", "description": "The adapter-base-redirects package for Ghost.", "private": true, + "ghostPackage": { + "goldenPath": "compliant" + }, "type": "module", "repository": { "type": "git", diff --git a/packages/adapters/route-settings-base/package.json b/packages/adapters/route-settings-base/package.json index 55121464f13..0e010e7cf17 100644 --- a/packages/adapters/route-settings-base/package.json +++ b/packages/adapters/route-settings-base/package.json @@ -3,6 +3,9 @@ "version": "0.0.0", "description": "The adapter-base-route-settings package for Ghost.", "private": true, + "ghostPackage": { + "goldenPath": "compliant" + }, "type": "module", "repository": { "type": "git", diff --git a/packages/adapters/scheduling-base/README.md b/packages/adapters/scheduling-base/README.md index 1a144f33de9..5591e1a1455 100644 --- a/packages/adapters/scheduling-base/README.md +++ b/packages/adapters/scheduling-base/README.md @@ -78,4 +78,4 @@ imports in `src/` must carry an explicit extension; write the real `.ts` one — # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage. +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage. diff --git a/packages/adapters/sso-base/README.md b/packages/adapters/sso-base/README.md index 6b9eaea879b..2ca44fb5a8a 100644 --- a/packages/adapters/sso-base/README.md +++ b/packages/adapters/sso-base/README.md @@ -82,4 +82,4 @@ imports in `src/` must carry an explicit extension; write the real `.ts` one — # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage. +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage. diff --git a/packages/adapters/storage-base/README.md b/packages/adapters/storage-base/README.md index b60f66ff6af..b4b1ebcfcf7 100644 --- a/packages/adapters/storage-base/README.md +++ b/packages/adapters/storage-base/README.md @@ -79,4 +79,4 @@ imports in `src/` must carry an explicit extension; write the real `.ts` one — # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage. +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage. diff --git a/packages/admin-api-schema/LICENSE b/packages/admin-api-schema/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/packages/admin-api-schema/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/admin-api-schema/README.md b/packages/admin-api-schema/README.md index 150d807d66d..833a4e41c8e 100644 --- a/packages/admin-api-schema/README.md +++ b/packages/admin-api-schema/README.md @@ -84,4 +84,4 @@ pnpm --filter @tryghost/admin-api-schema build # Copyright & License -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE). diff --git a/packages/admin-api-schema/package.json b/packages/admin-api-schema/package.json index 702d358faf9..e1445503d09 100644 --- a/packages/admin-api-schema/package.json +++ b/packages/admin-api-schema/package.json @@ -3,6 +3,9 @@ "version": "0.0.0", "description": "JSON schemas used to validate Ghost Admin API requests", "private": true, + "ghostPackage": { + "goldenPath": "compliant" + }, "type": "module", "repository": { "type": "git", diff --git a/packages/custom-field-types/package.json b/packages/custom-field-types/package.json index a59f854a616..53d480ae0c8 100644 --- a/packages/custom-field-types/package.json +++ b/packages/custom-field-types/package.json @@ -3,6 +3,9 @@ "version": "0.0.0", "description": "Shared catalog of member custom field types: storage routing and value validation, consumed by Ghost core and admin", "private": true, + "ghostPackage": { + "goldenPath": "compliant" + }, "type": "module", "repository": { "type": "git", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index f54c1fd0cfc..c2f25bf1b42 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -4,6 +4,10 @@ "repository": "https://github.com/TryGhost/Ghost/tree/main/packages/i18n", "author": "Ghost Foundation", "private": true, + "ghostPackage": { + "goldenPath": "exempt", + "reason": "Ships locale assets and dedicated CommonJS and ESM loaders for browser and server consumers." + }, "main": "index.js", "exports": { ".": { diff --git a/packages/nql-string/LICENSE b/packages/nql-string/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/packages/nql-string/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/nql-string/package.json b/packages/nql-string/package.json index 3c71ebe89e1..5f9973137d6 100644 --- a/packages/nql-string/package.json +++ b/packages/nql-string/package.json @@ -2,6 +2,9 @@ "name": "@tryghost/nql-string", "version": "0.0.0", "private": true, + "ghostPackage": { + "goldenPath": "compliant" + }, "repository": { "type": "git", "url": "git+https://github.com/TryGhost/Ghost.git", diff --git a/packages/parse-email-address/LICENSE b/packages/parse-email-address/LICENSE deleted file mode 100644 index efad547e8fc..00000000000 --- a/packages/parse-email-address/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2013-2026 Ghost Foundation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/parse-email-address/package.json b/packages/parse-email-address/package.json index 3d55dcfd46c..2b2a1608b07 100644 --- a/packages/parse-email-address/package.json +++ b/packages/parse-email-address/package.json @@ -2,6 +2,9 @@ "name": "@tryghost/parse-email-address", "version": "0.0.0", "private": true, + "ghostPackage": { + "goldenPath": "compliant" + }, "type": "module", "repository": { "type": "git", diff --git a/packages/testing/test-data/package.json b/packages/testing/test-data/package.json index 8dbd4841389..7fcc9fe23c4 100644 --- a/packages/testing/test-data/package.json +++ b/packages/testing/test-data/package.json @@ -2,6 +2,10 @@ "name": "@tryghost/test-data", "version": "0.0.0", "private": true, + "ghostPackage": { + "goldenPath": "exempt", + "reason": "Test-only source package consumed directly as TypeScript without a production build artifact." + }, "type": "module", "repository": "https://github.com/TryGhost/Ghost/tree/main/testing/test-data", "author": "Ghost Foundation", diff --git a/packages/testing/test-data/src/fixtures/data/settings.ts b/packages/testing/test-data/src/fixtures/data/settings.ts index 84f042e888a..dca7dbf101c 100644 --- a/packages/testing/test-data/src/fixtures/data/settings.ts +++ b/packages/testing/test-data/src/fixtures/data/settings.ts @@ -31,6 +31,10 @@ export const settingsDefaults: Record = { navigation: '[{"label":"Home","url":"/"},{"label":"About","url":"/about/"}]', secondary_navigation: '[{"label":"Sign up","url":"#/portal/"}]', llms_enabled: true, + machine_payments_enabled: false, + machine_payments_currency: null, + machine_payments_amount: 100, + machine_payments_stripe_profile_id: null, meta_title: null, meta_description: null, og_image: null, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e398c86af3..38b68513d89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -330,6 +330,9 @@ catalogs: mingo: specifier: 2.5.3 version: 2.5.3 + mppx: + specifier: 0.6.20 + version: 0.6.20 msw: specifier: 2.14.6 version: 2.14.6 @@ -2534,6 +2537,9 @@ importers: moment-timezone: specifier: 0.5.45 version: 0.5.45 + mppx: + specifier: 'catalog:' + version: 0.6.20(@typescript/typescript6@6.0.2)(express@4.22.2(supports-color@10.2.2))(hono@4.12.18)(viem@2.55.11(@typescript/typescript6@6.0.2)(zod@4.4.3)) multer: specifier: 2.2.0 version: 2.2.0 @@ -4098,6 +4104,9 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -4939,6 +4948,9 @@ packages: '@cacheable/utils@2.5.0': resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + '@cnakazawa/watch@1.0.4': resolution: {integrity: sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ==} engines: {node: '>=0.1.95'} @@ -6515,6 +6527,10 @@ packages: '@miragejs/pretender-node-polyfill@0.1.2': resolution: {integrity: sha512-M/BexG/p05C5lFfMunxo/QcgIJnMT2vDVCd00wNqK2ImZONIlEETZwWJu1QtLxtmYlSHlCFl3JNzp0tLe7OJ5g==} + '@modelcontextprotocol/server@2.0.0-alpha.4': + resolution: {integrity: sha512-/KEo3ZJ50HlagHp0lz2vPgfBZFtXHu6zTBXT9XqPc+4O9i4+IbBVWKq/a9yAfv/ifp0u3+mRo8SUk5kMKzhN8A==} + engines: {node: '>=20'} + '@mswjs/interceptors@0.41.9': resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} engines: {node: '>=18'} @@ -6531,6 +6547,18 @@ packages: '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==} + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.1': + resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -8009,6 +8037,19 @@ packages: cpu: [x64] os: [win32] + '@scalar/openapi-types@0.8.0': + resolution: {integrity: sha512-WmaxVSfvY5K/TwcG2B2TU1WOe1As1uc2s7myswtP6dBlcjU3hM08SApxv/jmyGaCE8t4gO5BBhmHY4pDUfmr2g==} + engines: {node: '>=22'} + + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + + '@scure/bip32@1.7.0': + resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + + '@scure/bip39@1.6.0': + resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -8970,6 +9011,9 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@toon-format/toon@2.3.1': + resolution: {integrity: sha512-sWqEMeAJGMda9qRrfPKrX3C4c33CO9SJuvJzzrcBEn5sbDB+k6pSLkDsfN0rVnLQwR97MV3sDgVbcxQNsq8xVw==} + '@tootallnate/once@3.0.1': resolution: {integrity: sha512-VyMVKRrpHTT8PnotUeV8L/mDaMwD5DaAKCFLP73zAqAtvF0FCqky+Ki7BYbFCYQmqFyTe9316Ed5zS70QUR9eg==} engines: {node: '>= 10'} @@ -10196,6 +10240,28 @@ packages: resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + abitype@1.2.3: + resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + + abitype@1.3.0: + resolution: {integrity: sha512-fk6Te+bojIFrMvMZrnOO+SxCB+RUksTGOzq/60ZRvs1L+BVzvi2bqt9L3W/17ZLdZsyM1FuYf65P5nlmoiH1Bg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -14309,6 +14375,9 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -15293,6 +15362,10 @@ packages: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} + hono@4.12.18: + resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} + engines: {node: '>=16.9.0'} + hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} @@ -15557,6 +15630,11 @@ packages: include-path-searcher@0.1.0: resolution: {integrity: sha512-KlpXnsZOrBGo4PPKqPFi3Ft6dcRyh8fTaqgzqDRi8jKAsngJEWWOxeFIWC8EfZtXKaZqlsNf9XRwcQ49DVgl/g==} + incur@0.4.26: + resolution: {integrity: sha512-63lwOc8+o1VeFkIW3sWWMrDSP5Dd1NsvucUWyxLUk+DoNRiNl7cj481nlLdlboXkq+tqYPBSlV/7XbylS2S4BA==} + engines: {node: '>=22'} + hasBin: true + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} @@ -16104,6 +16182,11 @@ packages: isostring@0.0.1: resolution: {integrity: sha512-wRcdJtXCe2LGtXnD14fXMkduWVdbeGkzBIKg8WcKeEOi6SIc+hRjYYw76WNx3v5FebhUWZrBTWB0NOl3/sagdQ==} + isows@1.0.7: + resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + peerDependencies: + ws: '*' + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -17772,6 +17855,25 @@ packages: resolution: {integrity: sha512-hdrFxZOycD/g6A6SoI2bB5NA/5NEqD0569+S47WZhPvm46sD50ZHdYaFmnua5lndde9rCHGjmfK7Z8BuCt/PcQ==} deprecated: This package is no longer supported. + mppx@0.6.20: + resolution: {integrity: sha512-RNXcY64QQVuDo3BqFEQAfp8ciF9O+DVIazDzZsf8vd9g4xqO1BRvTBLEcSO0WcEo6HcmQSL3Qsv3zgdM8vb3Eg==} + hasBin: true + peerDependencies: + '@modelcontextprotocol/sdk': '>=1.25.0' + elysia: '>=1' + express: '>=5' + hono: '>=4.12.18' + viem: '>=2.47.5' + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + elysia: + optional: true + express: + optional: true + hono: + optional: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -18395,6 +18497,22 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + ox@0.14.20: + resolution: {integrity: sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + ox@0.14.33: + resolution: {integrity: sha512-rooA/4o7bBof4Ge2VH/eovfNPb/AEEYyrNj03wggc55g5HZD8Pjs/OeWhttgjic3dDcqn0r29bDuvQEdTiUemQ==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + oxc-parser@0.127.0: resolution: {integrity: sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -21477,6 +21595,9 @@ packages: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} + tokenx@1.6.0: + resolution: {integrity: sha512-CKTjk345ajvBAUp5xUI9a5KKN0zU0lBueVHQbCskH1Hp6WkUKsPW2qGCYNs0pxNyfzxfo+IIjdt2W4sMbw/qBw==} + tooltip.js@1.3.3: resolution: {integrity: sha512-XWWuy/dBdF/F/YpRE955yqBZ4VdLfiTAUdOqoU+wJm6phJlMpEzl/iYHZ+qJswbeT9VG822bNfsETF9wzmoy5A==} deprecated: Tooltip.js is not supported anymore, please migrate to tippy.js @@ -22117,6 +22238,14 @@ packages: resolution: {integrity: sha512-qSXHIVmAVVKYk16x4keCwSqFuvF3D0K9KFuywWxd6ulpDfBHZ5QkGccMbT789IwSX9ykC88dL5kYGK3DZ59/ng==} engines: {node: '>=18.20'} + viem@2.55.11: + resolution: {integrity: sha512-RR5MwtdUnFfqw6ZGoFptizywyLOkLuhTL7UafoP3Irf2upXpANakQkLgGqY4H7A7+8JBxjUs6lElGF5zuGjMEw==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + vinyl-contents@2.0.0: resolution: {integrity: sha512-cHq6NnGyi2pZ7xwdHSW1v4Jfnho4TEGtxZHw01cmnc8+i7jgR6bRnED/LbrKan/Q7CvVLbnvA5OepnhbpjBZ5Q==} engines: {node: '>=10.13.0'} @@ -22714,6 +22843,8 @@ snapshots: '@adobe/css-tools@4.5.0': {} + '@adraffy/ens-normalize@1.11.1': {} + '@alloc/quick-lru@5.2.0': {} '@apm-js-collab/code-transformer-bundler-plugins@0.5.0': @@ -23879,6 +24010,8 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 + '@cfworker/json-schema@4.1.1': {} + '@cnakazawa/watch@1.0.4': dependencies: exec-sh: 0.3.6 @@ -25889,6 +26022,10 @@ snapshots: '@miragejs/pretender-node-polyfill@0.1.2': {} + '@modelcontextprotocol/server@2.0.0-alpha.4': + dependencies: + zod: 4.4.3 + '@mswjs/interceptors@0.41.9': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -25929,6 +26066,16 @@ snapshots: dependencies: eslint-scope: 5.1.1 + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + '@noble/hashes@1.8.0': {} '@nodelib/fs.scandir@2.1.5': @@ -27164,6 +27311,21 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@scalar/openapi-types@0.8.0': {} + + '@scure/base@1.2.6': {} + + '@scure/bip32@1.7.0': + dependencies: + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@scure/bip39@1.6.0': + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + '@sec-ant/readable-stream@0.4.1': {} '@secretlint/config-creator@13.0.2': @@ -28360,6 +28522,8 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@toon-format/toon@2.3.1': {} + '@tootallnate/once@3.0.1': {} '@tryghost/api-framework@3.3.5(supports-color@10.2.2)': @@ -30408,6 +30572,16 @@ snapshots: abbrev@5.0.0: {} + abitype@1.2.3(@typescript/typescript6@6.0.2)(zod@4.4.3): + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' + zod: 4.4.3 + + abitype@1.3.0(@typescript/typescript6@6.0.2)(zod@4.4.3): + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' + zod: 4.4.3 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -36470,6 +36644,8 @@ snapshots: eventemitter3@4.0.7: {} + eventemitter3@5.0.1: {} + eventemitter3@5.0.4: {} events-to-array@1.1.2: {} @@ -37900,6 +38076,9 @@ snapshots: dependencies: parse-passwd: 1.0.0 + hono@4.12.18: + optional: true + hookified@1.15.1: {} hookified@2.2.0: {} @@ -38192,6 +38371,16 @@ snapshots: include-path-searcher@0.1.0: {} + incur@0.4.26: + dependencies: + '@cfworker/json-schema': 4.1.1 + '@modelcontextprotocol/server': 2.0.0-alpha.4 + '@scalar/openapi-types': 0.8.0 + '@toon-format/toon': 2.3.1 + tokenx: 1.6.0 + yaml: 2.9.0 + zod: 4.4.3 + indent-string@4.0.0: {} index-to-position@1.2.0: {} @@ -38695,6 +38884,10 @@ snapshots: isostring@0.0.1: {} + isows@1.0.7(ws@8.21.0): + dependencies: + ws: 8.21.0 + istanbul-lib-coverage@3.2.2: {} istanbul-lib-instrument@5.2.1(supports-color@10.2.2): @@ -41112,6 +41305,18 @@ snapshots: rimraf: 2.7.1 run-queue: 1.0.3 + mppx@0.6.20(@typescript/typescript6@6.0.2)(express@4.22.2(supports-color@10.2.2))(hono@4.12.18)(viem@2.55.11(@typescript/typescript6@6.0.2)(zod@4.4.3)): + dependencies: + incur: 0.4.26 + ox: 0.14.20(@typescript/typescript6@6.0.2)(zod@4.4.3) + viem: 2.55.11(@typescript/typescript6@6.0.2)(zod@4.4.3) + zod: 4.4.3 + optionalDependencies: + express: 4.22.2(supports-color@10.2.2) + hono: 4.12.18 + transitivePeerDependencies: + - typescript + mrmime@2.0.1: {} ms@2.0.0: {} @@ -41970,6 +42175,36 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + ox@0.14.20(@typescript/typescript6@6.0.2)(zod@4.4.3): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.3.0(@typescript/typescript6@6.0.2)(zod@4.4.3) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' + transitivePeerDependencies: + - zod + + ox@0.14.33(@typescript/typescript6@6.0.2)(zod@4.4.3): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.3.0(@typescript/typescript6@6.0.2)(zod@4.4.3) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' + transitivePeerDependencies: + - zod + oxc-parser@0.127.0: dependencies: '@oxc-project/types': 0.127.0 @@ -45757,6 +45992,8 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + tokenx@1.6.0: {} + tooltip.js@1.3.3: dependencies: popper.js: 1.16.1 @@ -46422,6 +46659,23 @@ snapshots: video-extensions@2.0.0: {} + viem@2.55.11(@typescript/typescript6@6.0.2)(zod@4.4.3): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(@typescript/typescript6@6.0.2)(zod@4.4.3) + isows: 1.0.7(ws@8.21.0) + ox: 0.14.33(@typescript/typescript6@6.0.2)(zod@4.4.3) + ws: 8.21.0 + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + vinyl-contents@2.0.0: dependencies: bl: 5.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 587cd8d7c8c..6ad6ab1d3cf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -184,6 +184,7 @@ catalog: '@dnd-kit/utilities': 3.2.2 shell-quote: 1.10.0 unidecode: 1.1.0 + mppx: 0.6.20 '@octokit/core': 7.0.7 '@octokit/plugin-retry': 8.1.1 '@octokit/plugin-throttling': 11.0.5 diff --git a/scripts/check-internal-packages.js b/scripts/check-internal-packages.js new file mode 100644 index 00000000000..eb9bd2963ef --- /dev/null +++ b/scripts/check-internal-packages.js @@ -0,0 +1,348 @@ +import {execFile} from 'node:child_process'; +import {readdir, readFile, realpath, stat} from 'node:fs/promises'; +import path from 'node:path'; +import {promisify} from 'node:util'; + +import {ROOT_DIR} from './lib/constants.js'; +import {applyPackageTemplateTokens, isValidPackageName} from './lib/package-template.js'; + +const execFileAsync = promisify(execFile); + +const GOLDEN_PATH_STATUSES = new Set(['compliant', 'migration', 'exempt']); +// These expectations intentionally remain independent of packages/_template. +// Deriving them from the template would allow accidental template drift to +// redefine the contract and approve itself. +const REQUIRED_SCRIPTS = { + build: 'tsc', + 'test:unit': 'NODE_ENV=testing vitest run --coverage', + 'test:types': 'tsc --noEmit -p test/tsconfig.json', + test: "pnpm run '/^test:/'", + 'lint:code': 'eslint src/ --cache', + 'lint:test': 'eslint test/ --cache', + lint: "pnpm run '/^lint:/'" +}; +const REQUIRED_DEV_DEPENDENCIES = { + '@internal/cfg-eslint': 'workspace:*', + '@internal/cfg-typescript': 'workspace:*', + '@internal/cfg-vitest': 'workspace:*', + '@types/node': 'catalog:', + '@vitest/coverage-v8': 'catalog:', + '@typescript/native': 'catalog:', + eslint: 'catalog:', + typescript: 'catalog:', + vitest: 'catalog:' +}; + +async function exists(filePath) { + try { + await stat(filePath); + return true; + } catch { + return false; + } +} + +async function readJson(filePath) { + return JSON.parse(await readFile(filePath, 'utf8')); +} + +async function listWorkspacePackages(rootDirectory) { + const {stdout} = await execFileAsync('pnpm', [ + 'm', 'ls', '--depth', '-1', '--json' + ], { + cwd: rootDirectory, + maxBuffer: 10 * 1024 * 1024 + }); + + return JSON.parse(stdout); +} + +function sameArray(actual, expected) { + return Array.isArray(actual) && actual.length === expected.length && actual.every((value, index) => value === expected[index]); +} + +function addExactValueError(errors, manifestPath, actual, expected, field) { + if (actual !== expected) { + errors.push(`${manifestPath}: ${field} must be ${JSON.stringify(expected)}`); + } +} + +function usesStandardConfigFactory(config, moduleName, factoryName) { + const importPattern = new RegExp(`^\\s*import\\s*\\{\\s*${factoryName}\\s*\\}\\s*from\\s*['"]${moduleName}['"];?\\s*$`, 'm'); + const exportPattern = new RegExp(`^\\s*export\\s+default\\s+${factoryName}\\s*\\(`, 'm'); + return importPattern.test(config) && exportPattern.test(config); +} + +async function findJavaScriptFiles(directory) { + if (!await exists(directory)) { + return []; + } + + const files = []; + for (const entry of await readdir(directory, {withFileTypes: true})) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...await findJavaScriptFiles(entryPath)); + } else if (/\.(?:c|m)?jsx?$/.test(entry.name)) { + files.push(entryPath); + } + } + return files; +} + +async function validateConfigFiles(packageDirectory, manifestPath, errors) { + const tsconfigPath = path.join(packageDirectory, 'tsconfig.json'); + const testTsconfigPath = path.join(packageDirectory, 'test', 'tsconfig.json'); + const eslintPath = path.join(packageDirectory, 'eslint.config.mjs'); + const vitestPath = path.join(packageDirectory, 'vitest.config.ts'); + + for (const requiredPath of [tsconfigPath, testTsconfigPath, eslintPath, vitestPath]) { + if (!await exists(requiredPath)) { + errors.push(`${manifestPath}: missing ${path.relative(packageDirectory, requiredPath)}`); + } + } + + if (await exists(tsconfigPath)) { + try { + const config = await readJson(tsconfigPath); + addExactValueError(errors, manifestPath, config.extends, '@internal/cfg-typescript/esm.json', 'tsconfig.json extends'); + addExactValueError(errors, manifestPath, config.compilerOptions?.rootDir, 'src', 'tsconfig.json compilerOptions.rootDir'); + addExactValueError(errors, manifestPath, config.compilerOptions?.outDir, 'build', 'tsconfig.json compilerOptions.outDir'); + if (!sameArray(config.include, ['src/**/*'])) { + errors.push(`${manifestPath}: tsconfig.json include must be ["src/**/*"]`); + } + } catch (error) { + errors.push(`${manifestPath}: invalid tsconfig.json (${error.message})`); + } + } + + if (await exists(testTsconfigPath)) { + try { + const config = await readJson(testTsconfigPath); + addExactValueError(errors, manifestPath, config.extends, '../tsconfig.json', 'test/tsconfig.json extends'); + addExactValueError(errors, manifestPath, config.compilerOptions?.rootDir, '..', 'test/tsconfig.json compilerOptions.rootDir'); + addExactValueError(errors, manifestPath, config.compilerOptions?.noEmit, true, 'test/tsconfig.json compilerOptions.noEmit'); + if (!sameArray(config.include, ['../src/**/*', '**/*'])) { + errors.push(`${manifestPath}: test/tsconfig.json include must be ["../src/**/*", "**/*"]`); + } + } catch (error) { + errors.push(`${manifestPath}: invalid test/tsconfig.json (${error.message})`); + } + } + + if (await exists(eslintPath)) { + const config = await readFile(eslintPath, 'utf8'); + if (!usesStandardConfigFactory(config, '@internal/cfg-eslint', 'nodeLibConfig')) { + errors.push(`${manifestPath}: eslint.config.mjs must use nodeLibConfig from @internal/cfg-eslint`); + } + } + + if (await exists(vitestPath)) { + const config = await readFile(vitestPath, 'utf8'); + if (!usesStandardConfigFactory(config, '@internal/cfg-vitest', 'createVitestConfig')) { + errors.push(`${manifestPath}: vitest.config.ts must use createVitestConfig from @internal/cfg-vitest`); + } + } +} + +async function validateCompliantPackage({rootDirectory, packageDirectory, manifest, workspaceNames, packagePath: explicitPackagePath}) { + const manifestPath = path.relative(rootDirectory, path.join(packageDirectory, 'package.json')); + const packagePath = explicitPackagePath ?? path.relative(rootDirectory, packageDirectory).split(path.sep).join('/'); + const errors = []; + + if (typeof manifest.name !== 'string' || !manifest.name.startsWith('@tryghost/') || !isValidPackageName(manifest.name.slice('@tryghost/'.length))) { + errors.push(`${manifestPath}: name must use the @tryghost/ form`); + } + addExactValueError(errors, manifestPath, manifest.version, '0.0.0', 'version'); + addExactValueError(errors, manifestPath, manifest.private, true, 'private'); + addExactValueError(errors, manifestPath, manifest.type, 'module', 'type'); + addExactValueError(errors, manifestPath, manifest.author, 'Ghost Foundation', 'author'); + addExactValueError(errors, manifestPath, manifest.license, 'MIT', 'license'); + addExactValueError(errors, manifestPath, manifest.repository?.type, 'git', 'repository.type'); + addExactValueError(errors, manifestPath, manifest.repository?.url, 'git+https://github.com/TryGhost/Ghost.git', 'repository.url'); + addExactValueError(errors, manifestPath, manifest.repository?.directory, packagePath, 'repository.directory'); + + if (manifest.publishConfig !== undefined) { + errors.push(`${manifestPath}: compliant internal packages must not define publishConfig`); + } + if (!sameArray(manifest.files, ['build'])) { + errors.push(`${manifestPath}: files must be ["build"]`); + } + + const exports = manifest.exports; + if (!exports || typeof exports !== 'object' || Array.isArray(exports)) { + errors.push(`${manifestPath}: exports must define explicit entry points`); + } else { + for (const [entryPoint, conditions] of Object.entries(exports)) { + if (!conditions || typeof conditions !== 'object' || Array.isArray(conditions)) { + errors.push(`${manifestPath}: exports.${entryPoint} must be an object`); + continue; + } + + if (!sameArray(Object.keys(conditions), ['source', 'types', 'default'])) { + errors.push(`${manifestPath}: exports.${entryPoint} conditions must be source, types, default in that order`); + continue; + } + + for (const condition of ['source', 'types', 'default']) { + if (typeof conditions[condition] !== 'string') { + errors.push(`${manifestPath}: exports.${entryPoint}.${condition} must be a string`); + } + } + + const sourceMatch = typeof conditions.source === 'string' && /^\.\/src\/(.+)\.ts$/.exec(conditions.source); + if (!sourceMatch || sourceMatch[1].split('/').includes('..')) { + errors.push(`${manifestPath}: exports.${entryPoint}.source must point to ./src/.ts`); + continue; + } + + const sourceStem = sourceMatch[1]; + addExactValueError(errors, manifestPath, conditions.types, `./build/${sourceStem}.d.ts`, `exports.${entryPoint}.types`); + addExactValueError(errors, manifestPath, conditions.default, `./build/${sourceStem}.js`, `exports.${entryPoint}.default`); + if (!await exists(path.join(packageDirectory, conditions.source))) { + errors.push(`${manifestPath}: exports.${entryPoint}.source does not exist (${conditions.source})`); + } + } + + const rootExport = exports['.']; + if (!rootExport) { + errors.push(`${manifestPath}: exports must define the "." entry point`); + } else { + if (typeof rootExport.default === 'string') { + addExactValueError(errors, manifestPath, manifest.main, rootExport.default.replace(/^\.\//, ''), 'main'); + } + if (typeof rootExport.types === 'string') { + addExactValueError(errors, manifestPath, manifest.types, rootExport.types.replace(/^\.\//, ''), 'types'); + } + } + } + + for (const [script, expected] of Object.entries(REQUIRED_SCRIPTS)) { + addExactValueError(errors, manifestPath, manifest.scripts?.[script], expected, `scripts.${script}`); + } + for (const [dependency, expected] of Object.entries(REQUIRED_DEV_DEPENDENCIES)) { + addExactValueError(errors, manifestPath, manifest.devDependencies?.[dependency], expected, `devDependencies.${dependency}`); + } + if (!sameArray(manifest.nx?.targets?.build?.outputs, ['{projectRoot}/build'])) { + errors.push(`${manifestPath}: nx.targets.build.outputs must be ["{projectRoot}/build"]`); + } + + for (const section of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) { + for (const [dependency, version] of Object.entries(manifest[section] ?? {})) { + if (dependency !== manifest.name && workspaceNames.has(dependency) && version !== 'workspace:*') { + errors.push(`${manifestPath}: ${section}.${dependency} must use workspace:*`); + } + } + } + + await validateConfigFiles(packageDirectory, manifestPath, errors); + + for (const javascriptFile of await findJavaScriptFiles(path.join(packageDirectory, 'src'))) { + errors.push(`${manifestPath}: authored source must be TypeScript (${path.relative(packageDirectory, javascriptFile)})`); + } + for (const javascriptFile of await findJavaScriptFiles(path.join(packageDirectory, 'test'))) { + errors.push(`${manifestPath}: authored tests must be TypeScript (${path.relative(packageDirectory, javascriptFile)})`); + } + + return errors; +} + +export async function checkInternalPackages(rootDirectory) { + rootDirectory = await realpath(rootDirectory); + const packagesDirectory = path.join(rootDirectory, 'packages'); + let workspacePackages; + try { + workspacePackages = await listWorkspacePackages(rootDirectory); + } catch (error) { + const detail = error.stderr?.trim() || error.message; + return [`Unable to list pnpm workspace packages (${detail})`]; + } + const packageDirectories = workspacePackages + .map(workspace => workspace.path) + .filter(directory => directory.startsWith(`${packagesDirectory}${path.sep}`)) + .sort(); + const packages = []; + const errors = []; + + for (const packageDirectory of packageDirectories) { + const manifestPath = path.join(packageDirectory, 'package.json'); + try { + packages.push({packageDirectory, manifest: await readJson(manifestPath)}); + } catch (error) { + errors.push(`${path.relative(rootDirectory, manifestPath)}: invalid JSON (${error.message})`); + } + } + + const workspaceNames = new Set(); + for (const workspace of workspacePackages) { + if (workspace.name) { + workspaceNames.add(workspace.name); + } + } + + for (const pkg of packages) { + const manifestPath = path.relative(rootDirectory, path.join(pkg.packageDirectory, 'package.json')); + const status = pkg.manifest.ghostPackage?.goldenPath; + + if (pkg.manifest.private !== true) { + if (status !== undefined) { + errors.push(`${manifestPath}: ghostPackage.goldenPath is only valid for private internal packages`); + } + continue; + } + + if (!GOLDEN_PATH_STATUSES.has(status)) { + errors.push(`${manifestPath}: ghostPackage.goldenPath must be one of compliant, migration, exempt`); + continue; + } + + if (status === 'migration' || status === 'exempt') { + if (typeof pkg.manifest.ghostPackage.reason !== 'string' || pkg.manifest.ghostPackage.reason.trim().length === 0) { + errors.push(`${manifestPath}: ghostPackage.reason is required when goldenPath is ${status}`); + } + continue; + } + + errors.push(...await validateCompliantPackage({ + rootDirectory, + packageDirectory: pkg.packageDirectory, + manifest: pkg.manifest, + workspaceNames + })); + } + + const templateManifestPath = path.join(packagesDirectory, '_template', 'package.json'); + let templateManifest; + try { + const templateManifestSource = await readFile(templateManifestPath, 'utf8'); + templateManifest = JSON.parse(applyPackageTemplateTokens(templateManifestSource, { + name: 'template', + directory: 'packages/template', + description: 'Template package' + })); + } catch (error) { + errors.push(`packages/_template/package.json: unreadable or invalid JSON (${error.message})`); + } + + if (templateManifest) { + errors.push(...await validateCompliantPackage({ + rootDirectory, + packageDirectory: path.dirname(templateManifestPath), + packagePath: 'packages/template', + manifest: templateManifest, + workspaceNames + })); + } + + return errors; +} + +if (import.meta.main) { + const errors = await checkInternalPackages(ROOT_DIR); + if (errors.length > 0) { + console.error(`Internal package golden path check failed:\n\n${errors.join('\n')}`); + process.exitCode = 1; + } else { + console.log('All private internal packages have a valid golden path status.'); + } +} diff --git a/scripts/create-package.js b/scripts/create-package.js index bc3e8dcf5ac..19f1ec24291 100644 --- a/scripts/create-package.js +++ b/scripts/create-package.js @@ -5,6 +5,7 @@ import {existsSync} from 'node:fs'; import {join, dirname, relative, resolve, sep} from 'node:path'; import {ROOT_DIR} from './lib/constants.js'; +import {applyPackageTemplateTokens, isValidPackageName} from './lib/package-template.js'; const TEMPLATE_DIR = join(ROOT_DIR, 'packages', '_template'); const PACKAGES_DIR = join(ROOT_DIR, 'packages'); @@ -49,7 +50,7 @@ const name = positionals[0]; if (!name) { fail('Missing package name.'); } -if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) { +if (!isValidPackageName(name)) { fail(`Invalid package name "${name}". Use lowercase kebab-case, e.g. "email-utils" (no @scope — it becomes @tryghost/${name}).`); } @@ -81,10 +82,11 @@ async function walk(dir) { } function applyTokens(text) { - return text - .replaceAll('{{NAME}}', name) - .replaceAll('{{DIRECTORY}}', packageDir) - .replaceAll('{{DESCRIPTION}}', description); + return applyPackageTemplateTokens(text, { + name, + directory: packageDir, + description + }); } await mkdir(dirname(targetDir), {recursive: true}); @@ -108,4 +110,4 @@ for (const file of await walk(targetDir)) { // Clean any stray build artifacts the template dir may have accumulated. await rm(join(targetDir, 'build'), {recursive: true, force: true}); -process.stdout.write(`\n\x1b[32m✓ Created @tryghost/${name}\x1b[0m at ${packageDir} (ESM-only)\n\nNext steps:\n 1. pnpm install # link the new workspace member\n 2. pnpm --filter @tryghost/${name} test\n 3. Add real code in ${packageDir}/src/index.ts\n\n`); +process.stdout.write(`\n\x1b[32m✓ Created @tryghost/${name}\x1b[0m at ${packageDir} (ESM-only)\n\nNext steps:\n 1. pnpm install # link the new workspace member\n 2. cd ${packageDir} && pnpm test\n 3. Add real code in ${packageDir}/src/index.ts\n\n`); diff --git a/scripts/lib/package-template.js b/scripts/lib/package-template.js new file mode 100644 index 00000000000..a930b6f1e87 --- /dev/null +++ b/scripts/lib/package-template.js @@ -0,0 +1,12 @@ +const PACKAGE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export function isValidPackageName(name) { + return PACKAGE_NAME_PATTERN.test(name); +} + +export function applyPackageTemplateTokens(text, {name, directory, description}) { + return text + .replaceAll('{{NAME}}', name) + .replaceAll('{{DIRECTORY}}', directory) + .replaceAll('{{DESCRIPTION}}', description); +} diff --git a/scripts/test/check-internal-packages.test.js b/scripts/test/check-internal-packages.test.js new file mode 100644 index 00000000000..8da4c3a64a3 --- /dev/null +++ b/scripts/test/check-internal-packages.test.js @@ -0,0 +1,309 @@ +import assert from 'node:assert/strict'; +import {mkdir, mkdtemp, rm, writeFile} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {afterEach, test} from 'node:test'; + +import {checkInternalPackages} from '../check-internal-packages.js'; + +const temporaryDirectories = []; + +async function writeJson(filePath, value) { + await mkdir(path.dirname(filePath), {recursive: true}); + await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +async function createRepository() { + const rootDirectory = await mkdtemp(path.join(os.tmpdir(), 'ghost-internal-packages-')); + temporaryDirectories.push(rootDirectory); + await writeJson(path.join(rootDirectory, 'package.json'), { + name: 'test-workspace', + private: true + }); + await writeFile(path.join(rootDirectory, 'pnpm-workspace.yaml'), "packages:\n - 'packages/**'\n - '!packages/_template'\n - 'koenig/*'\n"); + const templateManifest = compliantManifest('packages/template'); + templateManifest.name = '@tryghost/{{NAME}}'; + templateManifest.description = '{{DESCRIPTION}}'; + templateManifest.repository.directory = '{{DIRECTORY}}'; + await createCompliantPackage(rootDirectory, 'packages/_template', templateManifest); + return rootDirectory; +} + +function compliantManifest(directory = 'packages/example') { + return { + name: '@tryghost/example', + version: '0.0.0', + private: true, + type: 'module', + repository: { + type: 'git', + url: 'git+https://github.com/TryGhost/Ghost.git', + directory + }, + author: 'Ghost Foundation', + license: 'MIT', + exports: { + '.': { + source: './src/index.ts', + types: './build/index.d.ts', + default: './build/index.js' + } + }, + main: 'build/index.js', + types: 'build/index.d.ts', + scripts: { + build: 'tsc', + 'test:unit': 'NODE_ENV=testing vitest run --coverage', + 'test:types': 'tsc --noEmit -p test/tsconfig.json', + test: "pnpm run '/^test:/'", + 'lint:code': 'eslint src/ --cache', + 'lint:test': 'eslint test/ --cache', + lint: "pnpm run '/^lint:/'" + }, + files: ['build'], + devDependencies: {...REQUIRED_DEV_DEPENDENCIES_FOR_TEST}, + nx: {targets: {build: {outputs: ['{projectRoot}/build']}}}, + ghostPackage: {goldenPath: 'compliant'} + }; +} + +const REQUIRED_DEV_DEPENDENCIES_FOR_TEST = { + '@internal/cfg-eslint': 'workspace:*', + '@internal/cfg-typescript': 'workspace:*', + '@internal/cfg-vitest': 'workspace:*', + '@types/node': 'catalog:', + '@vitest/coverage-v8': 'catalog:', + '@typescript/native': 'catalog:', + eslint: 'catalog:', + typescript: 'catalog:', + vitest: 'catalog:' +}; + +async function createCompliantPackage(rootDirectory, directory = 'packages/example', manifest = compliantManifest(directory)) { + const packageDirectory = path.join(rootDirectory, directory); + await writeJson(path.join(packageDirectory, 'package.json'), manifest); + await writeJson(path.join(packageDirectory, 'tsconfig.json'), { + extends: '@internal/cfg-typescript/esm.json', + compilerOptions: {rootDir: 'src', outDir: 'build'}, + include: ['src/**/*'] + }); + await writeJson(path.join(packageDirectory, 'test', 'tsconfig.json'), { + extends: '../tsconfig.json', + compilerOptions: {rootDir: '..', noEmit: true}, + include: ['../src/**/*', '**/*'] + }); + await mkdir(path.join(packageDirectory, 'src'), {recursive: true}); + await writeFile(path.join(packageDirectory, 'src', 'index.ts'), 'export const value = true;\n'); + await writeFile(path.join(packageDirectory, 'eslint.config.mjs'), "import {nodeLibConfig} from '@internal/cfg-eslint';\nexport default nodeLibConfig();\n"); + await writeFile(path.join(packageDirectory, 'vitest.config.ts'), "import {createVitestConfig} from '@internal/cfg-vitest';\nexport default createVitestConfig();\n"); + return packageDirectory; +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(directory => rm(directory, {recursive: true, force: true}))); +}); + +test('accepts a compliant nested private package', async () => { + const rootDirectory = await createRepository(); + await createCompliantPackage(rootDirectory, 'packages/adapters/example'); + + assert.deepEqual(await checkInternalPackages(rootDirectory), []); +}); + +test('reports workspace discovery failures through the checker interface', async () => { + const rootDirectory = await createRepository(); + await writeFile(path.join(rootDirectory, 'pnpm-workspace.yaml'), 'packages: [invalid\n'); + + assert.match( + (await checkInternalPackages(rootDirectory))[0], + /^Unable to list pnpm workspace packages/ + ); +}); + +test('requires every private package to declare its golden path status', async () => { + const rootDirectory = await createRepository(); + const packageDirectory = await createCompliantPackage(rootDirectory); + const manifest = compliantManifest(); + delete manifest.ghostPackage; + await writeJson(path.join(packageDirectory, 'package.json'), manifest); + + assert.deepEqual(await checkInternalPackages(rootDirectory), [ + 'packages/example/package.json: ghostPackage.goldenPath must be one of compliant, migration, exempt' + ]); +}); + +test('accepts documented migration and exempt states without applying the golden path', async () => { + const rootDirectory = await createRepository(); + await writeJson(path.join(rootDirectory, 'packages', 'migrating', 'package.json'), { + name: '@tryghost/migrating', + private: true, + ghostPackage: {goldenPath: 'migration', reason: 'Imported unchanged before a separate modernization PR.'} + }); + await writeJson(path.join(rootDirectory, 'packages', 'special', 'package.json'), { + name: '@tryghost/special', + private: true, + ghostPackage: {goldenPath: 'exempt', reason: 'This is a source-only test helper.'} + }); + + assert.deepEqual(await checkInternalPackages(rootDirectory), []); +}); + +test('requires migration and exempt states to explain the exception', async () => { + const rootDirectory = await createRepository(); + await writeJson(path.join(rootDirectory, 'packages', 'special', 'package.json'), { + name: '@tryghost/special', + private: true, + ghostPackage: {goldenPath: 'exempt'} + }); + + assert.deepEqual(await checkInternalPackages(rootDirectory), [ + 'packages/special/package.json: ghostPackage.reason is required when goldenPath is exempt' + ]); +}); + +test('reports manifest, export, config and source violations together', async () => { + const rootDirectory = await createRepository(); + const packageDirectory = await createCompliantPackage(rootDirectory); + const manifest = compliantManifest(); + manifest.version = '1.0.0'; + manifest.exports['.'] = { + types: './build/index.d.ts', + source: './src/index.ts', + default: './build/index.js' + }; + delete manifest.scripts.build; + await writeJson(path.join(packageDirectory, 'package.json'), manifest); + await writeFile(path.join(packageDirectory, 'src', 'legacy.js'), 'module.exports = {};\n'); + await writeFile(path.join(packageDirectory, 'test', 'legacy.test.js'), 'export {};\n'); + + const errors = await checkInternalPackages(rootDirectory); + assert.ok(errors.includes('packages/example/package.json: version must be "0.0.0"')); + assert.ok(errors.includes('packages/example/package.json: exports.. conditions must be source, types, default in that order')); + assert.ok(errors.includes('packages/example/package.json: scripts.build must be "tsc"')); + assert.ok(errors.includes('packages/example/package.json: authored source must be TypeScript (src/legacy.js)')); + assert.ok(errors.includes('packages/example/package.json: authored tests must be TypeScript (test/legacy.test.js)')); +}); + +test('rejects package names that are not kebab-case', async () => { + const rootDirectory = await createRepository(); + const packageDirectory = await createCompliantPackage(rootDirectory); + const manifest = compliantManifest(); + manifest.name = '@tryghost/example--'; + await writeJson(path.join(packageDirectory, 'package.json'), manifest); + + assert.ok((await checkInternalPackages(rootDirectory)).includes( + 'packages/example/package.json: name must use the @tryghost/ form' + )); +}); + +test('does not accept config factory names that appear only in comments', async () => { + const rootDirectory = await createRepository(); + const packageDirectory = await createCompliantPackage(rootDirectory); + await writeFile(path.join(packageDirectory, 'eslint.config.mjs'), "// import {nodeLibConfig} from '@internal/cfg-eslint';\n// export default nodeLibConfig();\nexport default [];\n"); + await writeFile(path.join(packageDirectory, 'vitest.config.ts'), "// import {createVitestConfig} from '@internal/cfg-vitest';\n// export default createVitestConfig();\nexport default {};\n"); + + const errors = await checkInternalPackages(rootDirectory); + assert.ok(errors.includes('packages/example/package.json: eslint.config.mjs must use nodeLibConfig from @internal/cfg-eslint')); + assert.ok(errors.includes('packages/example/package.json: vitest.config.ts must use createVitestConfig from @internal/cfg-vitest')); +}); + +test('reports non-string export conditions without terminating validation', async () => { + const rootDirectory = await createRepository(); + const packageDirectory = await createCompliantPackage(rootDirectory); + const manifest = compliantManifest(); + manifest.exports['.'] = {source: 42, types: false, default: {}}; + await writeJson(path.join(packageDirectory, 'package.json'), manifest); + + const errors = await checkInternalPackages(rootDirectory); + assert.ok(errors.includes('packages/example/package.json: exports...source must be a string')); + assert.ok(errors.includes('packages/example/package.json: exports...types must be a string')); + assert.ok(errors.includes('packages/example/package.json: exports...default must be a string')); +}); + +test('validates the complete package template contract', async () => { + const rootDirectory = await createRepository(); + const templatePath = path.join(rootDirectory, 'packages', '_template', 'package.json'); + const manifest = compliantManifest('packages/template'); + manifest.name = '@tryghost/{{NAME}}'; + manifest.repository.directory = '{{DIRECTORY}}'; + delete manifest.scripts.build; + await writeJson(templatePath, manifest); + + assert.ok((await checkInternalPackages(rootDirectory)).includes( + 'packages/_template/package.json: scripts.build must be "tsc"' + )); +}); + +test('reports an unreadable package template accurately', async () => { + const rootDirectory = await createRepository(); + await rm(path.join(rootDirectory, 'packages', '_template', 'package.json')); + + assert.match( + (await checkInternalPackages(rootDirectory))[0], + /^packages\/_template\/package\.json: unreadable or invalid JSON/ + ); +}); + +test('ignores public packages but rejects golden path metadata on them', async () => { + const rootDirectory = await createRepository(); + await writeJson(path.join(rootDirectory, 'packages', 'public', 'package.json'), { + name: '@tryghost/public', + version: '1.0.0' + }); + assert.deepEqual(await checkInternalPackages(rootDirectory), []); + + await writeJson(path.join(rootDirectory, 'packages', 'public', 'package.json'), { + name: '@tryghost/public', + version: '1.0.0', + ghostPackage: {goldenPath: 'compliant'} + }); + assert.deepEqual(await checkInternalPackages(rootDirectory), [ + 'packages/public/package.json: ghostPackage.goldenPath is only valid for private internal packages' + ]); +}); + +test('requires dependencies on packages in this workspace to use workspace:*', async () => { + const rootDirectory = await createRepository(); + const packageDirectory = await createCompliantPackage(rootDirectory); + const manifest = compliantManifest(); + manifest.dependencies = {'@tryghost/other': 'catalog:'}; + await writeJson(path.join(packageDirectory, 'package.json'), manifest); + await writeJson(path.join(rootDirectory, 'packages', 'other', 'package.json'), { + name: '@tryghost/other', + version: '1.0.0' + }); + + assert.deepEqual(await checkInternalPackages(rootDirectory), [ + 'packages/example/package.json: dependencies.@tryghost/other must use workspace:*' + ]); +}); + +test('recognizes workspace packages outside packages/', async () => { + const rootDirectory = await createRepository(); + const packageDirectory = await createCompliantPackage(rootDirectory); + const manifest = compliantManifest(); + manifest.dependencies = {'@tryghost/koenig-example': 'catalog:'}; + await writeJson(path.join(packageDirectory, 'package.json'), manifest); + await writeJson(path.join(rootDirectory, 'koenig', 'example', 'package.json'), { + name: '@tryghost/koenig-example' + }); + + assert.deepEqual(await checkInternalPackages(rootDirectory), [ + 'packages/example/package.json: dependencies.@tryghost/koenig-example must use workspace:*' + ]); +}); + +test('requires peer dependencies on workspace packages to use workspace:*', async () => { + const rootDirectory = await createRepository(); + const packageDirectory = await createCompliantPackage(rootDirectory); + const manifest = compliantManifest(); + manifest.peerDependencies = {'@tryghost/koenig-example': '>=1'}; + await writeJson(path.join(packageDirectory, 'package.json'), manifest); + await writeJson(path.join(rootDirectory, 'koenig', 'example', 'package.json'), { + name: '@tryghost/koenig-example' + }); + + assert.deepEqual(await checkInternalPackages(rootDirectory), [ + 'packages/example/package.json: peerDependencies.@tryghost/koenig-example must use workspace:*' + ]); +});