Skip to content

Conversation

@rob-ghost
Copy link
Contributor

@rob-ghost rob-ghost commented Dec 10, 2025

ref https://linear.app/ghost/issue/BER-3127

Summary

What is Force Upgrade mode?

Force Upgrade is a Ghost(Pro) billing feature that locks users out of most of the admin when a subscription action is required (e.g., payment failed, plan expired). In this mode, users are redirected to the /pro billing page, with only settings and sign-out remaining accessible.

The Bug

The force upgrade protection was implemented in Ember's routing layer, which intercepts route transitions and redirects to /pro. However, as we've migrated routes to React (Analytics, Network/ActivityPub, Tags), these routes bypassed the Ember protection entirely - React was unaware of the force upgrade state, so these pages would render normally when they should have been blocked.

The Fix

We chose a declarative approach using React Router's handle metadata:

  1. React reads forceUpgrade from the API config - The hostSettings.forceUpgrade flag is set by the server and available via the config endpoint
  2. React derives the effective state - If config says forceUpgrade: true but subscription status is active, billing was just completed (server hasn't restarted yet), so we clear the lock locally. This mirrors Ember's logic.
  3. A ForceUpgradeGuard wraps all React routes - This guard checks the derived forceUpgrade state and route metadata
  4. Routes opt-in to being allowed - Routes set handle: { allowInForceUpgrade: true } to bypass the guard (used for settings and the Ember fallback which handles /pro)

This approach mirrors how Ember handles it (checking route transitions) but uses React Router's native patterns.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 10, 2025

Walkthrough

This PR implements a site-wide force upgrade feature for Ghost Admin. Changes include: adding a forceUpgrade boolean property to hostSettings in the config type, creating a useForceUpgrade() hook to derive upgrade state from server config and subscription status, introducing a ForceUpgradeGuard component to enforce route access control during upgrades, updating routes to support per-route opt-out via allowInForceUpgrade flag, updating Ember integration to trigger subscription changes via stateBridge, extending E2E test infrastructure with fixture and navigation helpers, and adding a test suite for force upgrade behavior.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • apps/admin/src/routes.tsx: Route structure change with ForceUpgradeGuard wrapping and per-route allowance flags requires careful validation that routing logic remains correct and no routes are unintentionally blocked
  • apps/admin/src/ember-bridge/force-upgrade-guard.tsx: Guard logic that matches routes against allowed patterns and determines redirect behavior needs verification of edge cases
  • apps/admin/src/ember-bridge/ember-bridge.tsx: useForceUpgrade hook logic that combines config and subscription status, plus SubscriptionState interface changes making fields optional (potential null safety implications)
  • ghost/admin/app/routes/application.js: Ember-React integration point using stateBridge to propagate subscription state; verify stateBridge trigger timing and data flow
  • e2e/tests/admin/sidebar/force-upgrade.test.ts: New test suite mocking billing iframe scenarios; validate test coverage and mock setup completeness

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely summarizes the main fix: addressing force upgrade mode not blocking React shell routes, which is the core issue resolved by this changeset.
Description check ✅ Passed The description provides comprehensive context about force upgrade mode, explains the bug clearly, and documents the declarative React Router-based solution implemented across multiple files.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch force-upgrade-react-shell

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@rob-ghost rob-ghost force-pushed the force-upgrade-react-shell branch 3 times, most recently from eff6f0c to 78cb936 Compare December 11, 2025 12:16
ref https://linear.app/ghost/issue/BER-3127

When Ghost is in forceUpgrade mode (requires billing action), routes
handled by React (Analytics, Network, Tags) were bypassing the
protection and rendering instead of redirecting to the billing page.

This adds a declarative route protection system using React Router's
handle property. A ForceUpgradeGuard component wraps all routes and
redirects to /pro when forceUpgrade is active, unless the route has
handle.allowInForceUpgrade set to true.
@rob-ghost rob-ghost force-pushed the force-upgrade-react-shell branch from 78cb936 to 735ef58 Compare December 11, 2025 13:44
@rob-ghost rob-ghost marked this pull request as ready for review December 11, 2025 14:02
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
e2e/tests/admin/sidebar/force-upgrade.test.ts (2)

77-82: Consider using a semantic locator for the billing iframe.

Per coding guidelines, prefer semantic locators over CSS selectors. The iframe has a title attribute, so you could use getByTitle:

-const billingIframe = page.locator('iframe[title="Billing"]');
+const billingIframe = page.getByTitle('Billing');

This aligns with the guideline to prefer getByRole, getByLabel, getByText over test IDs and CSS selectors.


115-151: Good coverage of allowed routes with clear test boundaries.

Each test covers a single scenario as required by the guidelines. The sign-out test properly validates the end state (redirect to signin page).

Minor observation: The Settings tests (lines 116-135) don't explicitly verify that the billing iframe is not visible after navigation. Consider adding a negative assertion for completeness:

// Settings should be allowed - verify billing iframe is NOT visible
await expect(page.getByTitle('Billing')).not.toBeVisible();
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2546ddd and 735ef58.

📒 Files selected for processing (12)
  • apps/admin-x-framework/src/api/config.ts (1 hunks)
  • apps/admin/src/ember-bridge/ember-bridge.tsx (3 hunks)
  • apps/admin/src/ember-bridge/force-upgrade-guard.tsx (1 hunks)
  • apps/admin/src/ember-bridge/index.ts (1 hunks)
  • apps/admin/src/routes.tsx (1 hunks)
  • e2e/helpers/pages/admin/sidebar/sidebar-page.ts (1 hunks)
  • e2e/helpers/playwright/fixture.ts (1 hunks)
  • e2e/helpers/playwright/flows/login.ts (1 hunks)
  • e2e/tests/admin/sidebar/force-upgrade.test.ts (1 hunks)
  • e2e/tests/admin/sidebar/navigation.test.ts (1 hunks)
  • ghost/admin/app/routes/application.js (2 hunks)
  • ghost/admin/app/services/state-bridge.js (1 hunks)
🧰 Additional context used
📓 Path-based instructions (4)
e2e/**/*.{ts,tsx}

📄 CodeRabbit inference engine (e2e/AGENTS.md)

Prefer less comments and give things clear names

Files:

  • e2e/helpers/playwright/flows/login.ts
  • e2e/tests/admin/sidebar/force-upgrade.test.ts
  • e2e/tests/admin/sidebar/navigation.test.ts
  • e2e/helpers/pages/admin/sidebar/sidebar-page.ts
  • e2e/helpers/playwright/fixture.ts
e2e/**/*.{ts,js}

📄 CodeRabbit inference engine (AGENTS.md)

E2E tests should use Playwright with Docker container isolation

Files:

  • e2e/helpers/playwright/flows/login.ts
  • e2e/tests/admin/sidebar/force-upgrade.test.ts
  • e2e/tests/admin/sidebar/navigation.test.ts
  • e2e/helpers/pages/admin/sidebar/sidebar-page.ts
  • e2e/helpers/playwright/fixture.ts
e2e/**/*.test.ts

📄 CodeRabbit inference engine (e2e/AGENTS.md)

e2e/**/*.test.ts: Always follow ADRs in ../adr/ folder (ADR-0001: AAA pattern, ADR-0002: Page Objects)
Never use CSS/XPath selectors - only use semantic locators or data-testid
Test suite names should follow the format 'Ghost Admin - Feature' or 'Ghost Public - Feature'
Test names should be lowercase and follow the format 'what is tested - expected outcome'
Each test should represent one scenario only - never mix multiple scenarios in a single test
Follow the AAA (Arrange, Act, Assert) pattern in test structure
Prefer semantic locators (getByRole, getByLabel, getByText) over test IDs and never use CSS selectors, XPath, nth-child, or class names
Use getByTestId() only when semantic locators are unavailable, and suggest adding data-testid to Ghost codebase when needed
Use factory pattern for all test data creation instead of hard-coded data or direct database manipulation
Never use hard-coded waits like waitForTimeout()
Never use networkidle in waits
Use Playwright's auto-waiting capabilities and run tests multiple times to ensure stability
Use test.only() for debugging single tests
Manual login should not be used - authentication is automatic via fixture

Files:

  • e2e/tests/admin/sidebar/force-upgrade.test.ts
  • e2e/tests/admin/sidebar/navigation.test.ts
e2e/helpers/pages/**/*.ts

📄 CodeRabbit inference engine (e2e/AGENTS.md)

e2e/helpers/pages/**/*.ts: Page Objects should be located in helpers/pages/ directory
Expose locators as public readonly in Page Objects when used with assertions
Page Object methods should use semantic names (e.g., login() instead of clickLoginButton())
Use waitFor() for guards in Page Objects, never use expect() in page objects

Files:

  • e2e/helpers/pages/admin/sidebar/sidebar-page.ts
🧠 Learnings (22)
📓 Common learnings
Learnt from: sagzy
Repo: TryGhost/Ghost PR: 24673
File: ghost/i18n/lib/i18n.js:34-35
Timestamp: 2025-11-24T11:12:15.712Z
Learning: In the Ghost i18n package (ghost/i18n/lib/i18n.js), changing existing locale codes requires backwards compatibility handling for users who have already configured those locales. Such changes should be done in a separate PR with migration logic rather than included in feature PRs.
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-25T14:28:50.351Z
Learning: Applies to ghost/i18n/locales/en/ghost.json : Add UI translations to `ghost/i18n/locales/en/ghost.json` for Admin UI features
Learnt from: kevinansfield
Repo: TryGhost/Ghost PR: 23770
File: apps/admin-x-settings/src/components/settings/email/newsletters/NewsletterDetailModalLabs.tsx:435-450
Timestamp: 2025-06-10T11:07:10.800Z
Learning: In Ghost's newsletter customization features, when promoting a feature from alpha to beta status, the feature flag guards are updated to make the feature available under both the `emailCustomization` (beta) and `emailCustomizationAlpha` (alpha) flags. This is done by either removing conditional guards entirely when the component is already behind both flags, or by updating conditionals to check for either flag.
📚 Learning: 2025-11-24T17:29:43.865Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: e2e/AGENTS.md:0-0
Timestamp: 2025-11-24T17:29:43.865Z
Learning: Applies to e2e/**/*.test.ts : Use Playwright's auto-waiting capabilities and run tests multiple times to ensure stability

Applied to files:

  • e2e/helpers/playwright/flows/login.ts
📚 Learning: 2025-11-24T17:29:43.865Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: e2e/AGENTS.md:0-0
Timestamp: 2025-11-24T17:29:43.865Z
Learning: Applies to e2e/helpers/pages/**/*.ts : Use `waitFor()` for guards in Page Objects, never use `expect()` in page objects

Applied to files:

  • e2e/helpers/playwright/flows/login.ts
📚 Learning: 2025-11-24T17:29:43.865Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: e2e/AGENTS.md:0-0
Timestamp: 2025-11-24T17:29:43.865Z
Learning: Applies to e2e/**/*.test.ts : Never use hard-coded waits like `waitForTimeout()`

Applied to files:

  • e2e/helpers/playwright/flows/login.ts
📚 Learning: 2025-11-24T17:29:43.865Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: e2e/AGENTS.md:0-0
Timestamp: 2025-11-24T17:29:43.865Z
Learning: Applies to e2e/helpers/pages/**/*.ts : Page Object methods should use semantic names (e.g., `login()` instead of `clickLoginButton()`)

Applied to files:

  • e2e/helpers/playwright/flows/login.ts
📚 Learning: 2025-11-24T17:29:43.865Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: e2e/AGENTS.md:0-0
Timestamp: 2025-11-24T17:29:43.865Z
Learning: Applies to e2e/**/*.test.ts : Manual login should not be used - authentication is automatic via fixture

Applied to files:

  • e2e/helpers/playwright/flows/login.ts
📚 Learning: 2025-11-25T14:28:50.351Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-25T14:28:50.351Z
Learning: Ember admin uses `AdminXComponent` to dynamically import React apps with Suspense and error boundaries

Applied to files:

  • apps/admin/src/ember-bridge/force-upgrade-guard.tsx
  • apps/admin/src/ember-bridge/ember-bridge.tsx
  • apps/admin/src/ember-bridge/index.ts
📚 Learning: 2025-11-26T11:05:59.314Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: apps/shade/AGENTS.md:0-0
Timestamp: 2025-11-26T11:05:59.314Z
Learning: Applies to apps/shade/src/components/**/*.{ts,tsx} : Use CVA (Class Variance Authority) for component variants when useful instead of heavy prop configuration

Applied to files:

  • apps/admin/src/ember-bridge/force-upgrade-guard.tsx
📚 Learning: 2025-10-30T17:13:26.190Z
Learnt from: sam-lord
Repo: TryGhost/Ghost PR: 25303
File: ghost/core/core/server/services/email-service/BatchSendingService.js:19-19
Timestamp: 2025-10-30T17:13:26.190Z
Learning: In ghost/core/core/server/services/email-service/BatchSendingService.js and similar files in the Ghost codebase, prefer using `{...options}` spread syntax without explicit guards like `...(options || {})` when spreading potentially undefined objects, as the maintainer prefers cleaner syntax over defensive patterns when the behavior is safe.

Applied to files:

  • ghost/admin/app/services/state-bridge.js
📚 Learning: 2025-11-24T17:29:43.865Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: e2e/AGENTS.md:0-0
Timestamp: 2025-11-24T17:29:43.865Z
Learning: Applies to e2e/**/*.test.ts : Test suite names should follow the format 'Ghost Admin - Feature' or 'Ghost Public - Feature'

Applied to files:

  • e2e/tests/admin/sidebar/force-upgrade.test.ts
📚 Learning: 2025-03-13T09:00:20.205Z
Learnt from: mike182uk
Repo: TryGhost/Ghost PR: 22471
File: apps/admin-x-activitypub/src/utils/pending-activity.ts:13-71
Timestamp: 2025-03-13T09:00:20.205Z
Learning: The pending activity utilities in the Ghost ActivityPub module are covered by tests in the file `apps/admin-x-activitypub/test/unit/utils/pending-activity.ts`.

Applied to files:

  • e2e/tests/admin/sidebar/force-upgrade.test.ts
  • e2e/tests/admin/sidebar/navigation.test.ts
📚 Learning: 2025-11-25T14:28:50.351Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-25T14:28:50.351Z
Learning: Run `yarn test:integration` for integration tests in ghost/core

Applied to files:

  • e2e/tests/admin/sidebar/force-upgrade.test.ts
📚 Learning: 2025-11-10T17:07:54.169Z
Learnt from: kevinansfield
Repo: TryGhost/Ghost PR: 25377
File: apps/admin/src/ember-bridge/EmberBridge.test.tsx:146-174
Timestamp: 2025-11-10T17:07:54.169Z
Learning: In apps/admin/src/ember-bridge/EmberBridge.tsx, the useEmberAuthSync hook invalidates all React Query caches whenever an emberAuthChange event is emitted, regardless of the isAuthenticated field value in the event payload. The isAuthenticated field exists in the event type but is not currently used by the handler.

Applied to files:

  • apps/admin/src/ember-bridge/ember-bridge.tsx
  • apps/admin/src/ember-bridge/index.ts
📚 Learning: 2025-11-24T17:29:43.865Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: e2e/AGENTS.md:0-0
Timestamp: 2025-11-24T17:29:43.865Z
Learning: Applies to e2e/**/*.test.ts : Use factory pattern for all test data creation instead of hard-coded data or direct database manipulation

Applied to files:

  • e2e/tests/admin/sidebar/navigation.test.ts
📚 Learning: 2025-11-05T16:42:12.989Z
Learnt from: rob-ghost
Repo: TryGhost/Ghost PR: 25356
File: apps/admin/test-utils/fixtures/query-client.tsx:17-35
Timestamp: 2025-11-05T16:42:12.989Z
Learning: In apps/admin/test-utils/fixtures/query-client.tsx, the createTestQueryClient function is intentionally duplicated from admin-x-framework to reduce external dependencies in the admin app's test utilities.

Applied to files:

  • e2e/tests/admin/sidebar/navigation.test.ts
📚 Learning: 2025-03-13T09:00:20.205Z
Learnt from: mike182uk
Repo: TryGhost/Ghost PR: 22471
File: apps/admin-x-activitypub/src/utils/pending-activity.ts:13-71
Timestamp: 2025-03-13T09:00:20.205Z
Learning: The pending activity utilities in Ghost's ActivityPub module are thoroughly tested in `apps/admin-x-activitypub/test/unit/utils/pending-activity.ts`, which covers `generatePendingActivityId`, `isPendingActivity`, and `generatePendingActivity` functions.

Applied to files:

  • e2e/tests/admin/sidebar/navigation.test.ts
📚 Learning: 2025-11-24T17:29:43.865Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: e2e/AGENTS.md:0-0
Timestamp: 2025-11-24T17:29:43.865Z
Learning: Applies to e2e/**/*.test.ts : Prefer semantic locators (`getByRole`, `getByLabel`, `getByText`) over test IDs and never use CSS selectors, XPath, nth-child, or class names

Applied to files:

  • e2e/helpers/pages/admin/sidebar/sidebar-page.ts
📚 Learning: 2025-11-24T17:29:43.865Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: e2e/AGENTS.md:0-0
Timestamp: 2025-11-24T17:29:43.865Z
Learning: Applies to e2e/helpers/pages/**/*.ts : Expose locators as `public readonly` in Page Objects when used with assertions

Applied to files:

  • e2e/helpers/pages/admin/sidebar/sidebar-page.ts
📚 Learning: 2025-11-24T17:29:43.865Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: e2e/AGENTS.md:0-0
Timestamp: 2025-11-24T17:29:43.865Z
Learning: Applies to e2e/**/*.test.ts : Use `getByTestId()` only when semantic locators are unavailable, and suggest adding `data-testid` to Ghost codebase when needed

Applied to files:

  • e2e/helpers/pages/admin/sidebar/sidebar-page.ts
📚 Learning: 2025-11-03T12:33:31.093Z
Learnt from: kevinansfield
Repo: TryGhost/Ghost PR: 25320
File: apps/admin/src/layout/app-sidebar/NavHeader.tsx:10-10
Timestamp: 2025-11-03T12:33:31.093Z
Learning: The Ghost admin apps (apps/admin/**) do not use SSR, so accessing browser APIs like `navigator`, `window`, or `document` at module load time is safe and does not require typeof guards.

Applied to files:

  • e2e/helpers/pages/admin/sidebar/sidebar-page.ts
📚 Learning: 2025-12-09T12:37:30.664Z
Learnt from: kevinansfield
Repo: TryGhost/Ghost PR: 25501
File: apps/shade/src/hooks/use-mobile.tsx:5-10
Timestamp: 2025-12-09T12:37:30.664Z
Learning: The Ghost admin apps/shade/** components do not use SSR, so accessing browser APIs like `navigator`, `window`, or `document` at module load time is safe and does not require typeof guards.

Applied to files:

  • e2e/helpers/pages/admin/sidebar/sidebar-page.ts
📚 Learning: 2025-12-01T08:42:35.320Z
Learnt from: jonatansberg
Repo: TryGhost/Ghost PR: 25552
File: e2e/helpers/environment/service-managers/dev-ghost-manager.ts:210-247
Timestamp: 2025-12-01T08:42:35.320Z
Learning: In e2e/helpers/environment/service-managers/dev-ghost-manager.ts, the hardcoded volume name 'ghost-dev_shared-config' at line 231 is intentional. E2E tests run under the 'ghost-dev-e2e' project namespace but deliberately mount the shared-config volume from the main 'ghost-dev' project to access Tinybird credentials created by yarn dev:forward. This is cross-project volume sharing by design.

Applied to files:

  • e2e/helpers/playwright/fixture.ts
🧬 Code graph analysis (2)
e2e/helpers/playwright/flows/login.ts (1)
e2e/helpers/pages/admin/login-page.ts (1)
  • LoginPage (4-63)
e2e/tests/admin/sidebar/force-upgrade.test.ts (1)
e2e/helpers/pages/admin/sidebar/sidebar-page.ts (2)
  • NAV_ITEMS (17-25)
  • SidebarPage (35-108)
🔇 Additional comments (16)
e2e/helpers/playwright/flows/login.ts (2)

1-1: LGTM: Simplified import.

Removing the AnalyticsOverviewPage import is correct since the function no longer waits for the analytics page header.


8-11: Consider using a more specific selector for the sidebar navigation.

The getByRole('navigation') selector will match the first visible <nav> element on the page. Since the sidebar component has multiple navigation elements (desktop, mobile, and collapsed states), this could match the wrong one depending on render order and viewport size. Use page.locator('[data-sidebar="sidebar"]') to specifically target the sidebar, or combine the role selector with additional filtering to ensure you're waiting for the correct navigation element.

Likely an incorrect or invalid review comment.

apps/admin/src/ember-bridge/force-upgrade-guard.tsx (1)

29-44: LGTM! Clean declarative guard implementation.

The guard correctly leverages React Router's useMatches() to inspect route metadata and redirects appropriately. The replace prop on Navigate is a good choice to prevent the blocked route from appearing in browser history.

e2e/helpers/playwright/fixture.ts (1)

22-22: LGTM! Config property follows established conventions.

The hostSettings__forceUpgrade property correctly uses the double-underscore convention for nested configuration, consistent with existing properties like hostSettings__billing__enabled.

apps/admin-x-framework/src/api/config.ts (1)

35-35: LGTM! Type definition correctly extends the config surface.

The optional forceUpgrade boolean is well-placed within hostSettings and maintains backward compatibility.

ghost/admin/app/services/state-bridge.js (1)

204-206: LGTM! Simplified data passing.

Removing the unnecessary object spread aligns with the codebase preference for cleaner syntax. The data structure is now created by the caller, which is appropriate.

apps/admin/src/ember-bridge/index.ts (1)

5-8: LGTM! Clean barrel exports.

The new exports follow the established pattern of the file, properly separating component/hook exports from type exports.

e2e/tests/admin/sidebar/navigation.test.ts (1)

1-1: LGTM! Good consolidation of navigation data.

Importing NAV_ITEMS from the centralized location eliminates duplication and establishes a single source of truth for navigation test data.

e2e/helpers/pages/admin/sidebar/sidebar-page.ts (2)

4-25: LGTM! Well-structured navigation data model.

The NavItem interface and NAV_ITEMS constant provide a clean, typed data structure for navigation testing. The RegExp patterns appropriately handle URL variations, and the role definitions enable role-based test scenarios.


44-64: LGTM! Locators follow Page Object best practices.

The new locators correctly use semantic selectors (getByRole) as per coding guidelines. The .or() pattern for upgradeNowLink is a pragmatic solution for handling both React and Ember implementations during the migration period.

ghost/admin/app/routes/application.js (1)

237-241: LGTM! Proper initialization of React subscription state.

The subscription change notification is well-placed after the billing window logic, ensuring React has the subscription data needed to derive the forceUpgrade state. The empty object fallback is appropriate—the SubscriptionState interface defines all subscription properties as optional (status?, isActiveTrial?, trial_end?), so the fallback safely handles cases where this.billing.subscription is undefined, and the React code uses optional chaining to access subscription?.status.

apps/admin/src/ember-bridge/ember-bridge.tsx (2)

45-51: LGTM! Making fields optional accommodates varying subscription states.

The optional fields allow the interface to handle partial subscription data from different sources (Ember bridge events, initial state).


298-316: Consider the initial loading state behavior.

When subscriptionStatus is null (before Ember emits the first subscriptionChange event), the hook returns true if configForceUpgrade is truthy. This is likely the desired behavior—default to blocking access until subscription status is confirmed—but verify this doesn't cause a brief flash of the billing iframe on normal loads.

The hook logic correctly mirrors the Ember behavior described in the PR objectives.

e2e/tests/admin/sidebar/force-upgrade.test.ts (1)

84-102: Well-structured parameterized tests following AAA pattern.

The tests properly iterate over NAV_ITEMS using the page object pattern, follow the AAA structure (Arrange: goto, Act: click nav, Assert: expect iframe visible), and test names are lowercase. Good use of the shared fixture configuration.

apps/admin/src/routes.tsx (2)

22-80: Clean implementation of the force upgrade guard pattern.

The route structure correctly:

  1. Wraps all routes under ForceUpgradeGuard as a layout route
  2. Opts-in specific routes (settings/*, catch-all for Ember) to bypass protection
  3. Leaves React-migrated routes (Analytics, Network/ActivityPub, Tags list) blocked by default

The use of handle metadata with allowInForceUpgrade is an idiomatic React Router pattern for route-level configuration.


34-37: Verify the tag detail route should be allowed during force upgrade.

The /tags/:tagSlug route has allowInForceUpgrade: true, but the comment suggests this is for Ember delegation purposes rather than intentional force-upgrade access. Should users be able to access tag detail pages during force upgrade mode, or should this route be blocked?

Based on PR objectives, only Settings and /pro should be accessible. If tag details should be blocked, remove the handle property.

Copy link
Member

@jonatansberg jonatansberg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this looks good. I added a few comments on some minor things that we might want to look at before merging.

Comment on lines +47 to +49
isActiveTrial?: boolean;
trial_end?: string | null;
status?: string;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Are these all individually optional, or is it actually the full object that should be optional?

const configForceUpgrade = config?.config?.hostSettings?.forceUpgrade;

// If config doesn't have forceUpgrade, we're not in force upgrade mode
if (!configForceUpgrade) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thought: Maybe redundant, but would it make sense to also check if billing is enabled?

{
path: `settings/*`,
element: <Settings />,
handle: { allowInForceUpgrade: true } as RouteHandle,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we fix the RouteObject rather than assert here?

@@ -0,0 +1,152 @@
import {NAV_ITEMS, SidebarPage} from '@/helpers/pages';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: shouldn't this be a billing test rather than a sidebar test?

return billingIframe;
}

test.describe('sidebar navigation shows billing iframe', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this meant to test the redirect? It's not the sidebar that shows the iframe, no?

Comment on lines +239 to +241
this.stateBridge.triggerSubscriptionChange({
subscription: this.billing.subscription || {}
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non blocking question: Is this needed? I assumed that iframe would load way after the bridge has been initialized, but maybe that's a naive assumption.

// Check if any matched route allows access in force upgrade mode
const isAllowed = matches.some((match) => {
const handle = match.handle as RouteHandle | undefined;
return handle?.allowInForceUpgrade === true;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice use of handles! 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants