Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
71 changes: 71 additions & 0 deletions apps/admin-x-framework/src/api/gift-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {createMutation, createQueryWithId} from '../utils/api/hooks';

// A gift link as the admin API emits it. Usage counts (visits/views) are NOT
// stored on the link — they come from the analytics pipeline (see the
// gift-link usage hook in the posts app), so the resource is just the token.
export type GiftLink = {
token: string;
created_at: string;
};

export type GiftLinksResponseType = {
gift_links: GiftLink[];
};

export type RemoveAllGiftLinksResponseType = {
meta: {
count: number;
};
};

// Gift links hang off a post or a page; both map to the same id-keyed,
// type-agnostic controller server-side, but we address the canonical route so
// the resource stays explicit.
export type GiftLinkResource = 'posts' | 'pages';

export type GiftLinkMutationPayload = {
id: string;
resource?: GiftLinkResource;
};

const dataType = 'GiftLinksResponseType';

const giftLinkPath = ({id, resource = 'posts'}: GiftLinkMutationPayload) => `/${resource}/${id}/gift_links/`;

// GET /posts/:id/gift_links/ — read the active link for a post without minting
// one (empty when none exists yet). Read-only, so it's safe to fire on render
// (e.g. the analytics gift-link card). Posts only; the analytics screen never
// reads pages.
export const useReadGiftLink = createQueryWithId<GiftLinksResponseType>({
dataType,
path: id => `/posts/${id}/gift_links/`
});

// PUT /:resource/:id/gift_links/ — idempotently ensure (create-or-get) the
// active link, so opening the UI always has a URL to show.
export const useEnsureGiftLink = createMutation<GiftLinksResponseType, GiftLinkMutationPayload>({
method: 'PUT',
path: giftLinkPath,
invalidateQueries: {
dataType
}
});

// POST /:resource/:id/gift_links/ — mint a fresh link, invalidating the old
// token. Surfaced in the UI as "reset", but the backend controller is `create`.
export const useCreateGiftLink = createMutation<GiftLinksResponseType, GiftLinkMutationPayload>({
method: 'POST',
path: giftLinkPath,
invalidateQueries: {
dataType
}
});

// PUT /gift_links/remove_all/ — site-wide kill switch (Owner/Admin), danger zone.
export const useRemoveAllGiftLinks = createMutation<RemoveAllGiftLinksResponseType, null>({
method: 'PUT',
path: () => '/gift_links/remove_all/',
invalidateQueries: {
dataType
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,20 @@ import {useDeleteAllContent} from '@tryghost/admin-x-framework/api/db';
import {useGlobalData} from '../../providers/global-data-provider';
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
import {useQueryClient} from '@tryghost/admin-x-framework';
import {useRemoveAllGiftLinks} from '@tryghost/admin-x-framework/api/gift-links';
import {useResetAuth} from '@tryghost/admin-x-framework/api/security';

const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {mutateAsync: deleteAllContent} = useDeleteAllContent();
const {mutateAsync: resetAuth} = useResetAuth();
const {mutateAsync: removeAllGiftLinks} = useRemoveAllGiftLinks();
const client = useQueryClient();
const handleError = useHandleError();
const {config} = useGlobalData();
const {totalUsers} = useStaffUsers();

const resetAuthEnabled = Boolean(config?.labs?.dangerZoneResetAuth);
const giftLinksEnabled = Boolean(config?.labs?.giftLinks);

const resetAuthStaffSentence = totalUsers === 1
? 'You will be signed out and must reset your password before signing back in.'
Expand Down Expand Up @@ -83,6 +86,29 @@ const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => {
});
};

const handleRemoveAllGiftLinks = () => {
NiceModal.show(ConfirmationModal, {
title: 'Reset all gift links?',
prompt: 'This immediately invalidates every active gift link across your site. Anyone holding one will lose access. New gift links can still be created afterwards.',
okLabel: 'Reset all gift links',
okRunningLabel: 'Resetting...',
okColor: 'red',
onOk: async (modal) => {
try {
const response = await removeAllGiftLinks(null);
const count = response?.meta?.count ?? 0;
showToast({
title: `Reset ${count} gift ${count === 1 ? 'link' : 'links'}.`,
type: 'success'
});
modal?.remove();
} catch (e) {
handleError(e);
}
}
});
};

return (
<TopLevelGroup
customHeader={
Expand All @@ -109,6 +135,15 @@ const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => {
title='Reset all authentication'
/>
)}
{giftLinksEnabled && (
<ListItem
action={<Button aria-label='Reset all gift links' color='red' label='Reset' onClick={handleRemoveAllGiftLinks} />}
bgOnHover={false}
detail='Invalidate every active gift link across your site. Anyone holding one will lose access.'
testId='reset-all-gift-links'
title='Reset all gift links'
/>
)}
</div>
</TopLevelGroup>
);
Expand Down
25 changes: 25 additions & 0 deletions apps/admin-x-settings/test/acceptance/advanced/dangerzone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,29 @@ test.describe('DangerZone', async () => {

expect(lastApiRequests.resetAuth).toBeTruthy();
});

test('Reset all gift links', async ({page}) => {
toggleLabsFlag('giftLinks', true);

const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
removeAllGiftLinks: {
method: 'PUT',
path: '/gift_links/remove_all/',
response: {meta: {count: 3}}
}
}});

await page.goto('/');

const dangerZone = page.getByTestId('dangerzone');
await dangerZone.getByTestId('reset-all-gift-links').getByRole('button', {name: 'Reset'}).click();

const modal = page.getByTestId('confirmation-modal');
await modal.getByRole('button', {name: 'Reset all gift links'}).click();

await expect(page.getByTestId('toast-success')).toContainText('Reset 3 gift links');

expect(lastApiRequests.removeAllGiftLinks).toBeTruthy();
});
});
18 changes: 18 additions & 0 deletions apps/admin/src/ember-bridge/ember-bridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type StateBridgeEventMap = {
subscriptionChange: SubscriptionState;
sidebarVisibilityChange: SidebarVisibilityChangeEvent;
routeChange: RouteChangeEvent;
openGiftLinkModal: OpenGiftLinkModalEvent;
}

export interface StateBridge {
Expand Down Expand Up @@ -59,6 +60,11 @@ export interface RouteChangeEvent {
queryParams: Record<string, unknown>;
}

export interface OpenGiftLinkModalEvent {
id: string;
resource: 'posts' | 'pages';
}

export type EmberRouting = Pick<StateBridge, 'getRouteUrl' | 'isRouteActive'>;

/**
Expand Down Expand Up @@ -208,6 +214,18 @@ export function useSubscriptionStatus() {
return subscriptionStatus;
}

/**
* Subscribes to Ember's request to open the (React-owned) gift-link modal.
*
* Ember surfaces — the posts/pages list context menu — fire `openGiftLinkModal`
* over the bridge instead of rendering their own modal. The consumer owns the
* modal's open/close state and just reacts to each request. Returns an
* unsubscribe function.
*/
export function subscribeOpenGiftLinkModal(handler: (event: OpenGiftLinkModalEvent) => void): () => void {
return onEmberStateBridgeEvent('openGiftLinkModal', handler);
}

// External store for sidebar visibility state
function subscribeSidebarVisibility(callback: () => void): () => void {
return onEmberStateBridgeEvent('sidebarVisibilityChange', callback);
Expand Down
4 changes: 2 additions & 2 deletions apps/admin/src/ember-bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ export { EmberProvider } from "./ember-provider";
export { useEmberContext } from "./ember-context";
export { EmberFallback } from "./ember-fallback";
export { ForceUpgradeGuard } from "./force-upgrade-guard";
export { useEmberAuthSync, useEmberDataSync, useSidebarVisibility, useSubscriptionStatus, useEmberRouting, useForceUpgrade } from "./ember-bridge";
export type { EmberDataChangeEvent, EmberRouting } from "./ember-bridge";
export { useEmberAuthSync, useEmberDataSync, useSidebarVisibility, useSubscriptionStatus, useEmberRouting, useForceUpgrade, subscribeOpenGiftLinkModal } from "./ember-bridge";
export type { EmberDataChangeEvent, EmberRouting, OpenGiftLinkModalEvent } from "./ember-bridge";
export type { RouteHandle } from "./force-upgrade-guard";
56 changes: 56 additions & 0 deletions apps/admin/src/gift-link-modal-host.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Suspense, lazy, useEffect, useState } from "react";
import { EmberFallback, subscribeOpenGiftLinkModal } from "./ember-bridge";
import type { OpenGiftLinkModalEvent } from "./ember-bridge";

// The gift-link modal is React-owned but triggered from the Ember posts/pages
// list. It's only needed once someone opens it, so lazy-load it rather than
// pulling the posts bundle into every list view.
const GiftLinkModal = lazy(() => import("@tryghost/posts/gift-link-modal"));

/**
* Bridges the Ember posts/pages context menu to the React gift-link modal.
*
* Subscribes to the bridge on mount and owns the modal's open/close state:
* each request opens the modal for the named post/page (reopening the same one
* just re-fires the event), and closing flips `open` while keeping the target
* so the modal can animate out.
*/
function GiftLinkModalHost() {
const [target, setTarget] = useState<OpenGiftLinkModalEvent | null>(null);
const [open, setOpen] = useState(false);

useEffect(() => subscribeOpenGiftLinkModal((event) => {
setTarget(event);
setOpen(true);
}), []);

if (!target) {
return null;
}

return (
<Suspense fallback={null}>
<GiftLinkModal
key={`${target.resource}:${target.id}`}
open={open}
postId={target.id}
resource={target.resource}
onOpenChange={setOpen}
/>
</Suspense>
);
}

/**
* Route element for the Ember-backed posts and pages lists. Delegates the page
* itself to Ember (EmberFallback) while keeping the React gift-link modal host
* mounted so the list's context menu can open it.
*/
export function EmberListWithGiftLinks() {
return (
<>
<EmberFallback />
<GiftLinkModalHost />
</>
);
}
9 changes: 7 additions & 2 deletions apps/admin/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import MyProfileRedirect from "./my-profile-redirect";
// Ember
import { EmberFallback, ForceUpgradeGuard } from "./ember-bridge";
import type { RouteHandle } from "./ember-bridge";
import { EmberListWithGiftLinks } from "./gift-link-modal-host";
import { MembersRoute } from "./members-route";
import { OnboardingRedirect } from "./onboarding/onboarding-redirect";

Expand All @@ -31,11 +32,11 @@ const EMBER_ROUTES: string[] = [
"/signup/*",
"/reset/*",
"/pro/*",
"/posts",
// "/posts" and "/pages" are Ember-rendered but get the gift-link modal host
// layered on top — see giftLinkEmberRoutes below.
"/posts/analytics/:postId/mentions",
"/posts/analytics/:postId/debug",
"/restore",
"/pages",
"/editor/*",
"/tags/new",
"/explore/*",
Expand Down Expand Up @@ -134,6 +135,10 @@ export const routes: RouteObject[] = [
lazy: lazyComponent(() => import("./settings/settings")),
handle: { allowInForceUpgrade: true } satisfies RouteHandle,
},
// The posts and pages lists stay in Ember, but mount the React
// gift-link modal host alongside so their context menu can open it.
{path: "/posts", Component: EmberListWithGiftLinks, handle: emberFallbackHandle},
{path: "/pages", Component: EmberListWithGiftLinks, handle: emberFallbackHandle},
// Ember-handled routes
...emberFallbackRoutes,
{
Expand Down
3 changes: 2 additions & 1 deletion apps/posts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"require": "./dist/posts.umd.cjs"
},
"./api": "./src/api.ts",
"./members": "./src/views/members/members.tsx"
"./members": "./src/views/members/members.tsx",
"./gift-link-modal": "./src/views/PostAnalytics/modals/gift-link-modal.tsx"
},
"private": true,
"scripts": {
Expand Down
21 changes: 21 additions & 0 deletions apps/posts/src/hooks/use-can-manage-gift-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {isAdminUser, isAuthorUser, isEditorUser, isOwnerUser} from '@tryghost/admin-x-framework/api/users';
import {useCurrentUser} from '@tryghost/admin-x-framework/api/current-user';
import {useGlobalData} from '@src/providers/post-analytics-context';
import {useMemo} from 'react';

// Whether the current user can manage a gift link for this post. Mirrors
// canCopyGiftLink in the Ember app/utils/gift-link.js: requires the labs flag,
// a published gated (non-public) post, and a managing role.
export const useCanManageGiftLink = (post?: {status?: string; visibility?: string}) => {
const {data: globalData} = useGlobalData();
const {data: currentUser} = useCurrentUser();

return useMemo(() => {
if (!globalData?.labs?.giftLinks || !post || !currentUser) {
return false;
}
const eligible = post.status === 'published' && Boolean(post.visibility) && post.visibility !== 'public';
const canManage = isOwnerUser(currentUser) || isAdminUser(currentUser) || isEditorUser(currentUser) || isAuthorUser(currentUser);
return eligible && canManage;
}, [globalData?.labs?.giftLinks, post, currentUser]);
};
57 changes: 57 additions & 0 deletions apps/posts/src/hooks/use-gift-link-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {StatsConfig, useTinybirdQuery} from '@tryghost/admin-x-framework';
import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config';
import {useMemo} from 'react';

export interface GiftLinkUsage {
visits: number;
views: number;
}

interface GiftLinkVisitsRow {
gift: string;
visits: number | string;
views: number | string;
}

// Reads gift-link usage from the web-analytics pipeline (the same Tinybird
// mechanism every other analytics surface uses), keyed on the link token.
//
// Usage tracking is best-effort and entirely optional: analytics may be turned
// off (no statsConfig), the per-link pipe may not be deployed yet, or the query
// may error. In every one of those cases `usage` is `undefined` and the caller
// simply hides the count — the rest of the gift-link UI keeps working.
export const useGiftLinkUsage = ({postUuid, token, enabled = true}: {
postUuid?: string;
token?: string;
enabled?: boolean;
}) => {
const {data: configData} = useBrowseConfig();
const statsConfig = configData?.config?.stats as StatsConfig | undefined;

const params = useMemo(() => ({
site_uuid: statsConfig?.id || '',
post_uuid: postUuid || ''
}), [statsConfig?.id, postUuid]);

const {data, loading, error} = useTinybirdQuery({
endpoint: 'api_gift_link_visits',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Add the Tinybird pipe before querying it

With analytics enabled, this hook asks Tinybird for api_gift_link_visits, but repo-wide rg "api_gift_link_visits" ghost/core/core/server/data/tinybird finds no endpoint datafile, while getStatEndpointUrl resolves endpoint names to /v0/pipes/<name>.json. As a result every gift-link card/modal calls a non-existent pipe and the visitor count stays hidden/ even when visits exist; add the Tinybird endpoint (and any versioned variants required by statsConfig.version) or gate this query until it exists.

Useful? React with 👍 / 👎.

statsConfig: statsConfig || {id: ''},
params,
enabled: enabled && Boolean(statsConfig?.id) && Boolean(postUuid)
});

const usage = useMemo<GiftLinkUsage | undefined>(() => {
// No token yet, the query is disabled/loading, or it errored → no data
// to show. Distinct from "ran and found zero", which yields {visits: 0}.
if (!token || error || !Array.isArray(data)) {
return undefined;
}
const row = (data as unknown as GiftLinkVisitsRow[]).find(r => r.gift === token);
return {
visits: Number(row?.visits) || 0,
views: Number(row?.views) || 0
};
}, [data, token, error]);

return {usage, loading};
};
Loading
Loading