Skip to content
Merged
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
80 changes: 80 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,80 @@
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 => giftLinkPath({id})
});

// A post/page has at most one active gift link, so narrow the read above to that
// single link and surface its token directly — callers shouldn't have to reach
// into the response array.
export const useActiveGiftLink = (id: string, options?: Parameters<typeof useReadGiftLink>[1]) => {
const result = useReadGiftLink(id, options);
const giftLink = result.data?.gift_links?.[0];
return {...result, giftLink, token: giftLink?.token};
};

// 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
}
});
2 changes: 2 additions & 0 deletions apps/admin-x-framework/src/api/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export type Page = {
url: string;
status?: string;
published_at?: string;
visibility?: string;
uuid?: string;
};

export interface PagesResponseType {
Expand Down
5 changes: 5 additions & 0 deletions apps/admin-x-framework/src/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,11 @@ export function canManageTags(user: HasRoles) {
return isOwnerUser(user) || isAdminUser(user) || isEditorUser(user);
}

export function canManageGiftLinks(user: HasRoles) {
// Owner, Admin or Editor can manage gift links
return isOwnerUser(user) || isAdminUser(user) || isEditorUser(user);
Comment thread
jonatansberg marked this conversation as resolved.
}

export function hasAdminAccess(user: HasRoles) {
return isOwnerUser(user) || isAdminUser(user);
}
Expand Down
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.',

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@weylandswart some wordsmithing required here?

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 && (
Comment thread
jonatansberg marked this conversation as resolved.
<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.'

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@weylandswart same here

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 @@ -61,6 +62,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 @@ -210,6 +216,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 entry
* so the modal can animate out.
*/
function GiftLinkModalHost() {
const [entry, setEntry] = useState<OpenGiftLinkModalEvent | null>(null);
const [open, setOpen] = useState(false);

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

if (!entry) {
return null;
}

return (
<Suspense fallback={null}>
<GiftLinkModal
key={`${entry.resource}:${entry.id}`}
open={open}
postId={entry.id}
resource={entry.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 />
</>
);
}
5 changes: 3 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,9 @@ const EMBER_ROUTES: string[] = [
"/signup/*",
"/reset/*",
"/pro/*",
"/posts",
"/posts/analytics/:postId/mentions",
"/posts/analytics/:postId/debug",
"/restore",
"/pages",
"/editor/*",
"/tags/new",
"/explore/*",
Expand Down Expand Up @@ -134,6 +133,8 @@ export const routes: RouteObject[] = [
lazy: lazyComponent(() => import("./settings/settings")),
handle: { allowInForceUpgrade: true } satisfies RouteHandle,
},
{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
19 changes: 19 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,19 @@
import {canManageGiftLinks} 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.
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';
return eligible && canManageGiftLinks(currentUser);
}, [globalData?.labs?.giftLinks, post, currentUser]);
};
Loading
Loading