Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
e29a5d9
Fixed e2e dev mode failing to boot Ghost workers under pnpm 11
jonatansberg Jun 9, 2026
09b5682
Improved tag detail e2e suite to run against Ember and React implemen…
jonatansberg Jun 9, 2026
2b3e7a3
Added tagDetailsX private labs flag
jonatansberg Jun 9, 2026
c8406f6
Increased e2e Ghost boot health-check window for slow dev-mode boots
jonatansberg Jun 9, 2026
41837c6
Added accessible name to the admin sidebar navigation landmark
jonatansberg Jun 9, 2026
40abf82
Added React implementation of the tag detail screen behind tagDetailsX
jonatansberg Jun 9, 2026
3c79fba
Improved tag detail screen from code review and adversarial review fi…
jonatansberg Jun 9, 2026
3c9ce2c
Added shared posts/pages list e2e suite for the Ember-to-React migration
jonatansberg Jun 9, 2026
3b69967
Added postsListX private labs flag
jonatansberg Jun 9, 2026
68d4f11
Added React implementation of the posts/pages list behind postsListX
jonatansberg Jun 9, 2026
3fbb955
Added custom views and analytics columns to the React posts/pages list
jonatansberg Jun 9, 2026
361d071
Improved posts/pages list from code review and adversarial review fin…
jonatansberg Jun 10, 2026
f7c8130
Added memberDetailsX private labs flag
jonatansberg Jun 10, 2026
d658441
Added shared member detail and members-activity e2e suites
jonatansberg Jun 10, 2026
be27d3a
Added React member detail and members-activity screens behind memberD…
jonatansberg Jun 10, 2026
3d0de2f
Improved member detail and activity screens from review findings
jonatansberg Jun 10, 2026
78d824f
Added shared auth-screen e2e suites (signin, 2FA, password reset)
jonatansberg Jun 10, 2026
8e428eb
Changed Ghost dev mounts to match installed workspaces
jonatansberg Jun 10, 2026
8158fd4
Updated E2E dev containers to match Ghost dev mounts
jonatansberg Jun 10, 2026
93d4d89
Fixed stats build after posts API gained typed authors
jonatansberg Jun 10, 2026
a5248a9
Added React auth screens behind the authX labs flag
jonatansberg Jun 10, 2026
89717b1
Improved auth screens from slice-review findings
jonatansberg Jun 10, 2026
0a510a6
Added React post editor behind the editorX labs flag
jonatansberg Jun 10, 2026
d305ff8
Improved editor save semantics from slice-review findings
jonatansberg Jun 10, 2026
9b6c7d3
Added React long-tail screens behind restoreX/embedScreensX flags
jonatansberg Jun 10, 2026
7f95531
Improved visual parity of React admin screens with Ember
jonatansberg Jun 10, 2026
339cde8
Added missing editor sidebar sections and post list parity
jonatansberg Jun 10, 2026
0e7e93e
Added React post email debug screen and hardened the auth flows
jonatansberg Jun 10, 2026
2881c78
Improved embed screens and local revisions from final review findings
jonatansberg Jun 10, 2026
8fa1429
Fixed high-severity findings from the Ember-parity audit
jonatansberg Jun 11, 2026
83e5738
Added publish-flow recipients selector and canvas feature image
jonatansberg Jun 11, 2026
8ddf640
Fixed the publish flow disabling email on a stale member count
jonatansberg Jun 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
471 changes: 471 additions & 0 deletions DEVIATIONS.md

Large diffs are not rendered by default.

88 changes: 88 additions & 0 deletions apps/admin-x-framework/src/api/authentication.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {createMutation, createQuery} from '../utils/api/hooks';

// Types

export type SetupStatus = {
status: boolean;
title?: string;
name?: string;
email?: string;
};

export interface SetupStatusResponseType {
setup: SetupStatus[];
}

export interface InvitationStatusResponseType {
invitation: Array<{valid: boolean}>;
}

export interface PasswordResetResponseType {
password_reset: Array<{
message?: string;
// Only returned by legacy backends that have not yet started minting
// a verified session as part of the password reset itself
emailVerificationToken?: string;
}>;
}

// Helpers

/**
* Invite/reset tokens base64-encode `expiry|email|hash`. Returns the email
* embedded in the token, or null when the token cannot be decoded.
*/
export function getTokenEmail(token: string): string | null {
try {
return window.atob(token).split('|')[1] || null;
} catch {
return null;
}
}

// Requests

const setupStatusQuery = createQuery<SetupStatusResponseType>({
dataType: 'SetupStatusResponseType',
path: '/authentication/setup/'
});

export const getSetupStatus = (options: {enabled?: boolean} = {}) => setupStatusQuery({
defaultErrorHandler: false,
...options
});

const invitationStatusQuery = createQuery<InvitationStatusResponseType>({
dataType: 'InvitationStatusResponseType',
path: '/authentication/invitation/'
});

export const getInvitationValidity = (email: string, options: {enabled?: boolean} = {}) => invitationStatusQuery({
searchParams: {email},
defaultErrorHandler: false,
...options
});

export const useRequestPasswordReset = createMutation<unknown, {email: string}>({
method: 'POST',
path: () => '/authentication/password_reset/',
body: ({email}) => ({password_reset: [{email}]})
});

export const useResetPassword = createMutation<PasswordResetResponseType, {newPassword: string; ne2Password: string; token: string}>({
method: 'PUT',
path: () => '/authentication/password_reset/',
body: data => ({password_reset: [data]})
});

export const useAcceptInvitation = createMutation<unknown, {name: string; email: string; password: string; token: string}>({
method: 'POST',
path: () => '/authentication/invitation/',
body: data => ({invitation: [data]})
});

export const useCompleteSetup = createMutation<unknown, {name: string; email: string; password: string; blogTitle: string}>({
method: 'POST',
path: () => '/authentication/setup/',
body: data => ({setup: [data]})
});
3 changes: 3 additions & 0 deletions apps/admin-x-framework/src/api/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export type Config = {
enableDeveloperExperiments: boolean;
database: string;
blogUrl?: string;
// true when bulk email (Mailgun) is configured via Ghost config
// (ghost/core public-config); settings-based Mailgun config is separate
mailgunIsConfigured?: boolean;
labs: Record<string, boolean>;
stripeDirect: boolean;
mail: string;
Expand Down
23 changes: 22 additions & 1 deletion apps/admin-x-framework/src/api/current-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,28 @@ export const useCurrentUser = () => {
const result = useQuery({
queryKey: currentUserQueryKey,
queryFn: () => fetchApi<UsersResponseType>(currentUserUrl),
select: data => data.users[0]
select: data => data.users[0],
// Signed-out state: every createQuery subscribes to this query via
// usePermission, so a screen full of queries mounting against the
// errored (403) bootstrap query must not retrigger it — the default
// retryOnMount refetch caused an infinite mount/refetch/unmount loop
// on the React auth screens. Auth flows do a full reload after login,
// which refetches this naturally.
retryOnMount: false,
// 4xx (signed out / forbidden) is definitive: no retry, the auth
// screens should show immediately (and the loop fix above relies on
// the query settling). Anything else (network drop, 5xx, a dev
// server under load) MUST retry — this query decides authenticated
// vs signed-out for the whole shell, and classifying a transient
// failure as signed-out strands a logged-in user on the signin
// screen with no recovery.
retry: (failureCount, error) => {
const status = (error as {response?: {status?: number}})?.response?.status;
if (status && status >= 400 && status < 500) {
return false;
}
return failureCount < 3;
}
});

useEffect(() => {
Expand Down
218 changes: 218 additions & 0 deletions apps/admin-x-framework/src/api/editor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import {Query} from '@tanstack/react-query';
import {createMutation, createQueryWithId} from '../utils/api/hooks';
import {Email} from './posts';

export type EditorResource = 'posts' | 'pages';

// Mirrors ALL_POST_INCLUDES in ghost/admin/app/adapters/post.js — the editor
// needs to explicitly request post_revisions which means specifying every
// other include option too. The pages endpoint silently drops the includes it
// doesn't support (email, newsletter, count.clicks), matching Ember's
// behavior of sending the same list for both resources.
export const EDITOR_POST_INCLUDES = [
'tags',
'authors',
'authors.roles',
'email',
'tiers',
'newsletter',
'count.clicks',
'post_revisions',
'post_revisions.author'
].join(',');

export const EDITOR_POST_FORMATS = 'mobiledoc,lexical';

const editorSearchParams = {
formats: EDITOR_POST_FORMATS,
include: EDITOR_POST_INCLUDES
};

// Field names match the snake_case casing of the Admin API response

export type FullPostRole = {
id: string;
name: string;
};

export type FullPostAuthor = {
id: string;
name: string;
slug: string;
email?: string;
profile_image?: string | null;
roles?: FullPostRole[];
};

export type FullPostTag = {
id: string;
name: string;
slug: string;
visibility?: 'public' | 'internal';
};

export type FullPostTier = {
id: string;
name: string;
slug?: string;
};

export type FullPostNewsletter = {
id: string;
name?: string;
slug?: string;
status?: string;
};

export type PostRevision = {
id: string;
post_id: string;
lexical: string | null;
title: string | null;
feature_image: string | null;
feature_image_alt: string | null;
feature_image_caption: string | null;
post_status: string | null;
reason: string | null;
created_at: string;
author?: FullPostAuthor;
};

export type FullPost = {
id: string;
uuid: string;
title: string;
slug: string;
url?: string;
lexical: string | null;
mobiledoc: string | null;
status: string;
visibility: string;
tiers?: FullPostTier[];
excerpt?: string | null;
custom_excerpt: string | null;
feature_image: string | null;
feature_image_alt: string | null;
feature_image_caption: string | null;
featured: boolean;
published_at: string | null;
updated_at: string;
created_at: string;
custom_template: string | null;
canonical_url: string | null;
codeinjection_head: string | null;
codeinjection_foot: string | null;
og_image: string | null;
og_title: string | null;
og_description: string | null;
twitter_image: string | null;
twitter_title: string | null;
twitter_description: string | null;
meta_title: string | null;
meta_description: string | null;
email_only?: boolean;
email_segment?: string | null;
email_subject?: string | null;
newsletter?: FullPostNewsletter | null;
email?: Email | null;
tags?: FullPostTag[];
authors?: FullPostAuthor[];
post_revisions?: PostRevision[];
count?: {
clicks?: number;
positive_feedback?: number;
negative_feedback?: number;
};
show_title_and_feature_image?: boolean;
};

export interface EditorPostsResponseType {
posts: FullPost[];
}

export interface EditorPagesResponseType {
pages: FullPost[];
}

// Mutations are resource-parameterized (posts|pages), so the response carries
// either key depending on the resource that was saved
export interface EditorResourceResponseType {
posts?: FullPost[];
pages?: FullPost[];
}

// The full-post editor queries share the list dataTypes so that editor data
// stays in sync with the posts/pages list caches (and the Ember state bridge
// mapping for `post`)
export const getEditorPost = createQueryWithId<EditorPostsResponseType>({
dataType: 'PostsResponseType',
path: id => `/posts/${id}/`,
defaultSearchParams: editorSearchParams
});

export const getEditorPage = createQueryWithId<EditorPagesResponseType>({
dataType: 'PagesResponseType',
path: id => `/pages/${id}/`,
defaultSearchParams: editorSearchParams
});

// Resource-parameterized mutations need to invalidate both list caches, same
// as the bulk operations in posts.ts (createMutation's `dataType` invalidation
// is static, so we use the filters form)
const invalidatePostsAndPages = {
filters: {
predicate: (query: Query) => ['PostsResponseType', 'PagesResponseType'].includes(query.queryKey[0] as string)
}
};

export interface AddEditorPostPayload {
post: Partial<FullPost>;
resource?: EditorResource;
}

export const useAddEditorPost = createMutation<EditorResourceResponseType, AddEditorPostPayload>({
method: 'POST',
path: ({resource = 'posts'}) => `/${resource}/`,
defaultSearchParams: editorSearchParams,
body: ({post, resource = 'posts'}) => ({[resource]: [post]}),
invalidateQueries: invalidatePostsAndPages
});

export interface EditEditorPostPayload {
id: string;
// updated_at is required for the API's update collision detection — a
// stale value results in a 409 UpdateCollisionError
post: Partial<FullPost> & {updated_at: string};
resource?: EditorResource;
// Asks the API to convert a mobiledoc post to lexical as part of the
// save (mirrors Ember's adapterOptions.convertToLexical, which appends
// the same query param in ghost/admin/app/adapters/post.js)
convertToLexical?: boolean;
// Email sending params for publish saves, mirroring Ember's
// adapterOptions.newsletter/emailSegment (ghost/admin/app/adapters/post.js):
// `newsletter` is the newsletter slug; `emailSegment` is the recipient
// NQL filter. emailSegment is only sent alongside a newsletter, and the
// everyone-filter is collapsed to 'all' exactly like the Ember adapter.
newsletter?: string;
emailSegment?: string;
}

export const useEditEditorPost = createMutation<EditorResourceResponseType, EditEditorPostPayload>({
method: 'PUT',
path: ({id, resource = 'posts'}) => `/${resource}/${id}/`,
searchParams: ({convertToLexical, newsletter, emailSegment}) => {
const params: Record<string, string> = {...editorSearchParams};
if (convertToLexical) {
params.convert_to_lexical = '1';
}
if (newsletter) {
params.newsletter = newsletter;
if (emailSegment) {
params.email_segment = emailSegment === 'status:free,status:-free' ? 'all' : emailSegment;
}
}
return params;
},
body: ({post, resource = 'posts'}) => ({[resource]: [post]}),
invalidateQueries: invalidatePostsAndPages
});
20 changes: 20 additions & 0 deletions apps/admin-x-framework/src/api/email-previews.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {createQueryWithId} from '../utils/api/hooks';

export type EmailPreview = {
html?: string | null;
plaintext?: string | null;
subject?: string | null;
};

export interface EmailPreviewResponseType {
email_previews: EmailPreview[];
}

// GET /email_previews/posts/:id/ — the same Admin API endpoint Ember's editor
// preview modal uses (ghost/admin/app/components/editor/modals/preview/email.js).
// Accepts `memberSegment` (NQL filter, e.g. 'status:free') and `newsletter`
// (newsletter slug) search params.
export const getPostEmailPreview = createQueryWithId<EmailPreviewResponseType>({
dataType: 'EmailPreviewResponseType',
path: id => `/email_previews/posts/${id}/`
});
Loading
Loading