Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

5 changes: 5 additions & 0 deletions .changeset/changelogs/@tryghost!kg-default-nodes@2.2.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## 2.2.2

### Patch Changes

- Removed html-minifier from runtime dependencies; it is only used by the package's tests
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## 7.2.5

### Patch Changes

- Updated markdown-it-image-lazy-loading to 2.1.0, which uses image-size 2
9 changes: 9 additions & 0 deletions .changeset/changelogs/@tryghost!koenig-lexical@1.11.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## 1.11.0

### Minor Changes

- Added an embedPreviewUrl card config option that previews embed cards in a renderer served from a separate origin

### Patch Changes

- Updated dependencies
13 changes: 13 additions & 0 deletions .changeset/ledger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@
dir: koenig/kg-default-nodes
intents:
- khaki-poets-clap
"@tryghost/kg-default-nodes@2.2.2":
dir: koenig/kg-default-nodes
intents:
- proud-tigers-notice
"@tryghost/kg-default-transforms@1.3.4":
dir: koenig/kg-default-transforms
intents:
Expand All @@ -116,6 +120,10 @@
intents:
- common-squids-argue
- four-rings-relate
"@tryghost/kg-markdown-html-renderer@7.2.5":
dir: koenig/kg-markdown-html-renderer
intents:
- slow-bats-grin
"@tryghost/kg-unsplash-selector@0.4.4":
dir: koenig/kg-unsplash-selector
intents:
Expand All @@ -137,6 +145,11 @@
dir: koenig/koenig-lexical
intents:
- koenig-style-export
"@tryghost/koenig-lexical@1.11.0":
dir: koenig/koenig-lexical
intents:
- little-foxes-rule
- weak-suns-enjoy
"@tryghost/koenig-lexical@1.9.0":
dir: koenig/koenig-lexical
intents:
Expand Down
19 changes: 0 additions & 19 deletions .changeset/short-roses-fetch.md

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/sweet-eggs-joke.md

This file was deleted.

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

// Types

export interface SetupStatusResponseType {
setup: Array<{
status: boolean;
// Prefill values from Ghost's config, only present before setup
title?: string;
name?: string;
email?: string;
}>;
}

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

export interface PasswordResetResponseType {
password_reset: Array<{ message: string }>;
}

export interface CompletePasswordResetPayload {
token: string;
newPassword: string;
ne2Password: string;
}

export interface AcceptInvitationPayload {
token: string;
name: string;
email: string;
password: string;
}

export interface CompleteSetupPayload {
name: string;
email: string;
password: string;
blogTitle: string;
}

// Every write here consumes something single-use (a reset token, an invite,
// the unset-up site), so a retried request after a lost response would fail
// against its own first attempt. None of these endpoints needs a session, so
// their 401s are answers rather than an expired session.
const authenticationRequestOptions = { retry: false, sessionExpiryRedirect: false } as const;

// Requests

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

/** Whether a sent, unaccepted invitation exists for the email; the server does not check expiry here. */
export const useInvitationStatus = createQuery<InvitationStatusResponseType>({
dataType: 'InvitationStatusResponseType',
path: '/authentication/invitation/',
});

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

// On success the server also signs the user in with an already verified session.
export const useCompletePasswordReset = createMutation<
PasswordResetResponseType,
CompletePasswordResetPayload
>({
method: 'PUT',
path: () => '/authentication/password_reset/',
body: (payload) => ({ password_reset: [payload] }),
...authenticationRequestOptions,
});

// Creates the account without signing in; `email` is ignored by current servers
// (the invite's own address is used) but required by older ones.
export const useAcceptInvitation = createMutation<unknown, AcceptInvitationPayload>({
method: 'POST',
path: () => '/authentication/invitation/',
body: (payload) => ({ invitation: [payload] }),
...authenticationRequestOptions,
});

// Creates the owner account without signing in.
export const useCompleteSetup = createMutation<unknown, CompleteSetupPayload>({
method: 'POST',
path: () => '/authentication/setup/',
body: (payload) => ({ setup: [payload] }),
...authenticationRequestOptions,
});
2 changes: 1 addition & 1 deletion apps/admin-x-framework/src/api/automated-emails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const useAddAutomatedEmail = createMutation<
export const useEditAutomatedEmail = createMutation<AutomatedEmailsResponseType, AutomatedEmail>({
method: 'PUT',
path: (automatedEmail) => `/automated_emails/${automatedEmail.id}/`,
body: (automatedEmail) => ({ automated_emails: [automatedEmail] }),
body: ({ slug: _slug, ...automatedEmail }) => ({ automated_emails: [automatedEmail] }),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
Expand Down
3 changes: 3 additions & 0 deletions apps/admin-x-framework/src/api/current-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export const useCurrentUser = ({ requestOptions }: CurrentUserOptions = {}) => {
queryKey: currentUserQueryKey,
queryFn: () => fetchApi<UsersResponseType>(currentUserUrl, requestOptions),
select: (data) => data.users[0],
// Every query hook reads the current user for permissions, so each new
// screen would otherwise re-ask a signed-out server; signing in reloads.
retryOnMount: false,
});

useEffect(() => {
Expand Down
14 changes: 14 additions & 0 deletions apps/admin-x-framework/src/api/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,38 @@ export interface SessionVerification {
token: string;
}

// Each call is single-use on the server (a new session, a rotated or consumed
// code), so a retried request after a lost response would undo the first one.
const sessionRequestOptions = { retry: false } as const;

// The server replies 201 Created with only the status text ("Created") as a text/plain body.
export const useAddSession = createMutation<string, SessionCredentials>({
method: 'POST',
path: () => '/session/',
body: (credentials) => credentials,
...sessionRequestOptions,
});

// The server replies 200 OK with only the status text ("OK") as a text/plain body; a wrong code is a bare 401.
export const useVerifySession = createMutation<string, SessionVerification>({
method: 'PUT',
path: () => '/session/verify/',
body: ({ token }) => ({ token }),
...sessionRequestOptions,
});

// Emails a fresh sign-in code (invalidating the previous one); the server replies 200 "OK" as text/plain.
export const useSendSessionVerification = createMutation<string, null>({
method: 'POST',
path: () => '/session/verify/',
...sessionRequestOptions,
});

// The server replies 204 No Content on sign-out, so the mutation resolves with no data.
export const useDeleteSession = createMutation<void, null>({
method: 'DELETE',
path: () => '/session/',
...sessionRequestOptions,
});

const twoFactorRequiredCodes = ['2FA_TOKEN_REQUIRED', '2FA_NEW_DEVICE_DETECTED'];
Expand Down
2 changes: 2 additions & 0 deletions apps/admin-x-framework/src/api/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export type SiteData = {
locale: string;
version: string;
site_uuid: string;
/** Whether Admin serves its React auth screens; absent on servers before the flag existed. */
authReact?: boolean;
};

export interface SiteResponseType {
Expand Down
1 change: 1 addition & 0 deletions apps/admin-x-framework/src/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './utils/helpers';
export { isAuthPath } from './utils/auth-paths';
export { apiUrl } from './utils/api/fetch-api';
1 change: 1 addition & 0 deletions apps/admin-x-framework/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type {
} from './hooks/use-form';
export { default as useHandleError } from './hooks/use-handle-error';
export { useFeatureFlag } from './hooks/use-feature-flag';
export { useFeatureFlagOverrides } from './providers/feature-flag-overrides-context';
export { useHostLimits } from './hooks/use-host-limits';
export type { HostLimits } from './hooks/use-host-limits';
export { useLimiter } from './hooks/use-limiter';
Expand Down
6 changes: 3 additions & 3 deletions apps/admin-x-framework/src/hooks/use-handle-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ const useHandleError = () => {
// but still clear lingering toasts that would block clicks the same way.
toast.dismiss();
} else if (error instanceof SessionExpiredError) {
// A redirecting request unloads the page, so a toast would only flash;
// one that opted out of the redirect reports the expiry itself.
toast.dismiss();
// Either the page is reloading to signin or nobody is signed in yet, so
// there is nothing to report; toasts the signin flow shows must survive.
return;
} else if (error instanceof APIError) {
showErrorToast(getErrorMessage(error, error.message));
} else {
Expand Down
16 changes: 12 additions & 4 deletions apps/admin-x-framework/src/utils/api/fetch-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
TimeoutError,
UnauthorizedError,
} from '../errors';
import { isAuthPath } from '../auth-paths';
import { getGhostPaths } from '../helpers';
import handleResponse, { ResponseType } from './handle-response';

Expand Down Expand Up @@ -60,16 +61,19 @@ const xhrToFetchResponse = (xhr: Readonly<XMLHttpRequest>): Response =>

const GHOST_API_REQUEST = /\/ghost\/api\//;
const SESSION_API_REQUEST = /\/ghost\/api\/admin\/session([/?#]|$)/;
const UNAUTHENTICATED_ADMIN_ROUTE = /^#\/(?:reset|setup|signin|signup)(?:[/?]|$)/;
const CURRENT_USER_REQUEST = /\/ghost\/api\/admin\/users\/me\/([?#]|$)/;

// A session can only expire once this page load has seen it work; failures
// before that are the signed-out state, which the signin flow handles.
let sessionConfirmed = false;
let sessionExpiryHandled = false;

const isUnauthenticatedAdminRoute = (adminRoot: string) => {
return (
window.location.pathname === adminRoot &&
(!window.location.hash ||
window.location.hash === '#/' ||
UNAUTHENTICATED_ADMIN_ROUTE.test(window.location.hash))
isAuthPath(window.location.hash.slice(1)))
);
};

Expand All @@ -85,7 +89,7 @@ const isSessionExpiry = (endpoint: string | URL) => {
const redirectOnSessionExpiry = () => {
const { adminRoot } = getGhostPaths();

if (!sessionExpiryHandled && !isUnauthenticatedAdminRoute(adminRoot)) {
if (sessionConfirmed && !sessionExpiryHandled && !isUnauthenticatedAdminRoute(adminRoot)) {
sessionExpiryHandled = true;
window.location.replace(adminRoot);
}
Expand Down Expand Up @@ -232,7 +236,11 @@ export const useFetchApi = () => {
try {
const response = await fetchFn(endpoint, requestInit);
// Awaited so response errors reject inside the try/catch
return (await handleResponse(response, { responseType })) as ResponseData;
const data = (await handleResponse(response, { responseType })) as ResponseData;
if (CURRENT_USER_REQUEST.test(endpoint.toString())) {
sessionConfirmed = true;
}
return data;
} catch (error) {
retryingMs = Date.now() - startTime;

Expand Down
16 changes: 16 additions & 0 deletions apps/admin-x-framework/src/utils/auth-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Admin routes that serve the signed-out and session flows. `/setup/onboarding`
// is a signed-in screen, so only the bare `/setup` counts.
const AUTH_PATH_PATTERNS = [
/^\/signin\/?$/,
/^\/signin\/verify\/?$/,
/^\/signout\/?$/,
/^\/signup\/[^/]+\/?$/,
/^\/reset\/[^/]+\/?$/,
/^\/setup\/?$/,
];

/** Whether an Admin route path (optionally with a query string) is an authentication screen. */
export function isAuthPath(path: string): boolean {
const [pathname] = path.split('?');
return AUTH_PATH_PATTERNS.some((pattern) => pattern.test(pathname));
}
Loading
Loading