From fd4bf56e980397c40fbec3617859bf3e6b2d97f7 Mon Sep 17 00:00:00 2001 From: John O'Nolan Date: Mon, 18 May 2026 12:32:57 -0700 Subject: [PATCH 1/4] Add account migration settings UI Adds the ActivityPub preferences page for creating and removing account aliases used by Mastodon-to-Ghost follower migration.\n\nIncludes the client API/query hooks, settings route, empty/error/loading behavior, and acceptance coverage for creating aliases, existing alias ordering, and load failures. --- apps/activitypub/package.json | 2 +- apps/activitypub/src/api/activitypub.test.ts | 184 ++++++++++++++++ apps/activitypub/src/api/activitypub.ts | 54 +++++ .../src/hooks/use-activity-pub-queries.ts | 46 ++++ apps/activitypub/src/routes.tsx | 7 + .../components/mastodon-migration.tsx | 201 ++++++++++++++++++ .../views/preferences/components/settings.tsx | 20 +- .../test/acceptance/preferences.test.ts | 163 ++++++++++++++ 8 files changed, 672 insertions(+), 5 deletions(-) create mode 100644 apps/activitypub/src/views/preferences/components/mastodon-migration.tsx create mode 100644 apps/activitypub/test/acceptance/preferences.test.ts diff --git a/apps/activitypub/package.json b/apps/activitypub/package.json index fa29036eae1..a8d79f8f961 100644 --- a/apps/activitypub/package.json +++ b/apps/activitypub/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/activitypub", - "version": "3.1.23", + "version": "3.1.25", "license": "MIT", "repository": { "type": "git", diff --git a/apps/activitypub/src/api/activitypub.test.ts b/apps/activitypub/src/api/activitypub.test.ts index 2e141c81338..6aece885616 100644 --- a/apps/activitypub/src/api/activitypub.test.ts +++ b/apps/activitypub/src/api/activitypub.test.ts @@ -1597,6 +1597,190 @@ describe('ActivityPubAPI', function () { }); }); + describe('accountAliases', function () { + test('It fetches account aliases', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/v1/account/aliases`]: { + response: JSONResponse({ + destination: { + handle: '@index@example.com', + apId: 'https://example.com/.ghost/activitypub/users/index' + }, + aliases: [{ + apId: 'https://mastodon.social/users/old' + }] + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const result = await api.getAccountAliases(); + + expect(result).toEqual({ + destination: { + handle: '@index@example.com', + apId: 'https://example.com/.ghost/activitypub/users/index' + }, + aliases: [{ + apId: 'https://mastodon.social/users/old' + }] + }); + }); + + test('It adds an account alias', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/v1/account/aliases`]: { + async assert(_resource, init) { + expect(init?.method).toEqual('POST'); + expect(init?.body).toEqual('{"sourceHandle":"@old@mastodon.social"}'); + }, + response: JSONResponse({ + destination: { + handle: '@index@example.com', + apId: 'https://example.com/.ghost/activitypub/users/index' + }, + aliases: [{ + apId: 'https://mastodon.social/users/old' + }] + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const result = await api.addAccountAlias('@old@mastodon.social'); + + expect(result.aliases).toEqual([{ + apId: 'https://mastodon.social/users/old' + }]); + }); + + test('It returns an empty alias response when adding an account alias has no response body', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/v1/account/aliases`]: { + response: new Response(null, {status: 204}) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const result = await api.addAccountAlias('@old@mastodon.social'); + + expect(result).toEqual({ + destination: { + handle: '', + apId: '' + }, + aliases: [] + }); + }); + + test('It removes an account alias', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/v1/account/aliases`]: { + async assert(_resource, init) { + expect(init?.method).toEqual('DELETE'); + expect(init?.body).toEqual('{"actorUri":"https://mastodon.social/users/old"}'); + }, + response: JSONResponse({ + destination: { + handle: '@index@example.com', + apId: 'https://example.com/.ghost/activitypub/users/index' + }, + aliases: [] + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const result = await api.removeAccountAlias('https://mastodon.social/users/old'); + + expect(result.aliases).toEqual([]); + }); + + test('It returns an empty alias response when removing an account alias has no response body', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/v1/account/aliases`]: { + response: new Response(null, {status: 204}) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const result = await api.removeAccountAlias('https://mastodon.social/users/old'); + + expect(result).toEqual({ + destination: { + handle: '', + apId: '' + }, + aliases: [] + }); + }); + }); + describe('enableBluesky', function () { test('It enables bluesky', async function () { const fakeFetch = Fetch({ diff --git a/apps/activitypub/src/api/activitypub.ts b/apps/activitypub/src/api/activitypub.ts index 14021e29128..9ae55eab48b 100644 --- a/apps/activitypub/src/api/activitypub.ts +++ b/apps/activitypub/src/api/activitypub.ts @@ -102,6 +102,39 @@ export type AccountFollowsType = 'following' | 'followers'; type GetAccountResponse = Account +export interface AccountAlias { + apId: string; +} + +export interface AccountAliasesResponse { + destination: { + handle: string; + apId: string; + }; + aliases: AccountAlias[]; +} + +function emptyAccountAliasesResponse(): AccountAliasesResponse { + return { + destination: { + handle: '', + apId: '' + }, + aliases: [] + }; +} + +function parseAccountAliasesResponse(json: object | null): AccountAliasesResponse { + if (json === null || !('destination' in json)) { + return emptyAccountAliasesResponse(); + } + + return { + destination: json.destination as AccountAliasesResponse['destination'], + aliases: 'aliases' in json && Array.isArray(json.aliases) ? json.aliases as AccountAlias[] : [] + }; +} + export type FollowAccount = Pick & {isFollowing: true}; export interface GetAccountFollowsResponse { @@ -484,6 +517,27 @@ export class ActivityPubAPI { }; } + async getAccountAliases(): Promise { + const url = new URL('.ghost/activitypub/v1/account/aliases', this.apiUrl); + const json = await this.fetchJSON(url); + + return parseAccountAliasesResponse(json); + } + + async addAccountAlias(sourceHandle: string): Promise { + const url = new URL('.ghost/activitypub/v1/account/aliases', this.apiUrl); + const json = await this.fetchJSON(url, 'POST', {sourceHandle}); + + return parseAccountAliasesResponse(json); + } + + async removeAccountAlias(actorUri: string): Promise { + const url = new URL('.ghost/activitypub/v1/account/aliases', this.apiUrl); + const json = await this.fetchJSON(url, 'DELETE', {actorUri}); + + return parseAccountAliasesResponse(json); + } + async getFeed(next?: string): Promise { return this.getPaginatedPosts('.ghost/activitypub/v1/feed/notes', next); } diff --git a/apps/activitypub/src/hooks/use-activity-pub-queries.ts b/apps/activitypub/src/hooks/use-activity-pub-queries.ts index d5c1d6c6b96..29139dbf7e4 100644 --- a/apps/activitypub/src/hooks/use-activity-pub-queries.ts +++ b/apps/activitypub/src/hooks/use-activity-pub-queries.ts @@ -1,5 +1,6 @@ import { type Account, + type AccountAliasesResponse, type AccountFollowsType, type AccountSearchResult, ActivityPubAPI, @@ -74,6 +75,7 @@ const QUERY_KEYS = { return ['profile_posts', profileHandle]; }, account: (handle: string) => ['account', handle], + accountAliases: (handle: string) => ['account_aliases', handle], accountFollows: (handle: string, type: AccountFollowsType) => ['account_follows', handle, type], searchResults: (query: string) => ['search_results', query], suggestedProfiles: (handle: string, limit: number) => ['suggested_profiles', handle, limit], @@ -1654,6 +1656,50 @@ export function useAccountFollowsForUser(profileHandle: string, type: AccountFol }); } +export function useAccountAliasesForUser(handle: string) { + return useQuery({ + queryKey: QUERY_KEYS.accountAliases(handle), + async queryFn() { + const siteUrl = await getSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + + return api.getAccountAliases(); + } + }); +} + +export function useAddAccountAliasMutationForUser(handle: string) { + const queryClient = useQueryClient(); + + return useMutation({ + async mutationFn(sourceHandle: string) { + const siteUrl = await getSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + + return api.addAccountAlias(sourceHandle); + }, + onSuccess(response: AccountAliasesResponse) { + queryClient.setQueryData(QUERY_KEYS.accountAliases(handle), response); + } + }); +} + +export function useRemoveAccountAliasMutationForUser(handle: string) { + const queryClient = useQueryClient(); + + return useMutation({ + async mutationFn(actorUri: string) { + const siteUrl = await getSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + + return api.removeAccountAlias(actorUri); + }, + onSuccess(response: AccountAliasesResponse) { + queryClient.setQueryData(QUERY_KEYS.accountAliases(handle), response); + } + }); +} + export function useFeedForUser(options: {enabled: boolean}) { const queryKey = QUERY_KEYS.feed; const queryClient = useQueryClient(); diff --git a/apps/activitypub/src/routes.tsx b/apps/activitypub/src/routes.tsx index 3ee76ceff17..3e6603d0bb1 100644 --- a/apps/activitypub/src/routes.tsx +++ b/apps/activitypub/src/routes.tsx @@ -3,6 +3,7 @@ import AppError from '@components/layout/error'; import {Navigate, Outlet, RouteObject, lazyComponent} from '@tryghost/admin-x-framework'; const basePath = import.meta.env.VITE_TEST ? '' : 'activitypub'; +const accountMigrationPath = 'preferences/move'; export type CustomRouteObject = RouteObject & { pageTitle?: string; @@ -106,6 +107,12 @@ export const routes: CustomRouteObject[] = [ lazy: lazyComponent(() => import('./views/preferences/components/bluesky-sharing')), showBackButton: true }, + { + path: accountMigrationPath, + lazy: lazyComponent(() => import('./views/preferences/components/mastodon-migration')), + pageTitle: 'Account migration', + showBackButton: true + }, { path: 'welcome', lazy: lazyComponent(() => import('./components/layout/onboarding')), diff --git a/apps/activitypub/src/views/preferences/components/mastodon-migration.tsx b/apps/activitypub/src/views/preferences/components/mastodon-migration.tsx new file mode 100644 index 00000000000..80bcd12f902 --- /dev/null +++ b/apps/activitypub/src/views/preferences/components/mastodon-migration.tsx @@ -0,0 +1,201 @@ +import Layout from '@src/components/layout'; +import React, {useState} from 'react'; +import {Button, Field, FieldDescription, FieldLabel, Input, LoadingIndicator, Skeleton} from '@tryghost/shade/components'; +import {H2} from '@tryghost/shade/primitives'; +import {LucideIcon} from '@tryghost/shade/utils'; +import {useAccountAliasesForUser, useAddAccountAliasMutationForUser, useRemoveAccountAliasMutationForUser} from '@hooks/use-activity-pub-queries'; + +const HANDLE_REGEX = /^@?[^@\s]+@[^@\s]+$/; + +function normalizeHandle(handle: string) { + const trimmedHandle = handle.trim(); + + return trimmedHandle.startsWith('@') ? trimmedHandle : `@${trimmedHandle}`; +} + +function getAliasDisplayHandle(actorUri: string) { + try { + const url = new URL(actorUri); + const mastodonUserMatch = url.pathname.match(/^\/users\/([^/]+)\/?$/); + + if (mastodonUserMatch) { + return `${decodeURIComponent(mastodonUserMatch[1])}@${url.hostname}`; + } + + return `${url.hostname}${url.pathname}`; + } catch { + return actorUri; + } +} + +function getAliasErrorMessage(error: unknown) { + if ( + typeof error === 'object' && + error !== null && + 'statusCode' in error && + typeof error.statusCode === 'number' + ) { + if (error.statusCode === 400) { + return 'Enter a valid handle, like old@mastodon.social.'; + } + + if (error.statusCode === 404) { + return 'Could not find that profile. Check the handle and try again.'; + } + } + + return 'Something went wrong, please try again.'; +} + +const MastodonMigration: React.FC = () => { + const { + data: aliasData, + isError: hasAliasLoadError, + isLoading: isLoadingAliases, + refetch: refetchAliases + } = useAccountAliasesForUser('index'); + const addAliasMutation = useAddAccountAliasMutationForUser('index'); + const removeAliasMutation = useRemoveAccountAliasMutationForUser('index'); + const [sourceHandle, setSourceHandle] = useState(''); + const [handleError, setHandleError] = useState(null); + const [aliasActionError, setAliasActionError] = useState(null); + const [removingAlias, setRemovingAlias] = useState(null); + + const aliases = [...(aliasData?.aliases ?? [])].reverse(); + const showAliasesSection = isLoadingAliases || hasAliasLoadError || aliases.length > 0; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedHandle = sourceHandle.trim(); + + if (!HANDLE_REGEX.test(trimmedHandle)) { + setHandleError('Enter a valid handle, like old@mastodon.social.'); + return; + } + + setHandleError(null); + setAliasActionError(null); + + try { + const normalizedHandle = normalizeHandle(trimmedHandle); + await addAliasMutation.mutateAsync(normalizedHandle); + setSourceHandle(''); + } catch (error) { + setHandleError(getAliasErrorMessage(error)); + } + }; + + const handleRemoveAlias = async (actorUri: string) => { + setAliasActionError(null); + setRemovingAlias(actorUri); + + try { + await removeAliasMutation.mutateAsync(actorUri); + } catch { + setAliasActionError('Could not remove migration profile.'); + } finally { + setRemovingAlias(null); + } + }; + + return ( + +
+
+

Account migration

+
+ +
+

+ You can move your followers from another social web account (eg. Mastodon) to this one by creating an account alias. This action is harmless and reversible. The account migration is initiated from the old account. +

+
+ +
+ + + Old account handle + + + Specify the username@domain of the account you want to move from + +
+ setSourceHandle(event.target.value)} + /> + +
+ {handleError && ( +

+ {handleError} +

+ )} + {aliasActionError && ( +

+ {aliasActionError} +

+ )} +
+
+ + {showAliasesSection && ( +
+
+ +
Account aliases
+
+
+ + {isLoadingAliases ? ( +
+ +
+ ) : hasAliasLoadError ? ( +
+ Could not load account aliases. + +
+ ) : ( +
+ {aliases.map(alias => ( +
+
{getAliasDisplayHandle(alias.apId)}
+ +
+ ))} +
+ )} +
+ )} +
+
+ ); +}; + +export default MastodonMigration; diff --git a/apps/activitypub/src/views/preferences/components/settings.tsx b/apps/activitypub/src/views/preferences/components/settings.tsx index ad1687a4eef..258d9a6883c 100644 --- a/apps/activitypub/src/views/preferences/components/settings.tsx +++ b/apps/activitypub/src/views/preferences/components/settings.tsx @@ -5,6 +5,7 @@ import {Button, Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger} import {H4} from '@tryghost/shade/primitives'; import {Link} from '@tryghost/admin-x-framework'; import {LucideIcon, cn} from '@tryghost/shade/utils'; +import {useAppBasePath} from '@src/hooks/use-app-base-path'; import {useNavigateWithBasePath} from '@src/hooks/use-navigate-with-base-path'; interface SettingsProps { @@ -21,14 +22,14 @@ const Settings: React.FC = ({account, className = ''}) => { - Account + Profile Edit your profile information and account details - + e.preventDefault()}> @@ -57,6 +58,15 @@ const Settings: React.FC = ({account, className = ''}) => { + + + Account migration + Move another social web account to this one + + + + + @@ -120,15 +130,17 @@ interface SettingItemProps { } const SettingItem: React.FC = ({children, className = '', withHover = false, to, href, onClick}) => { + const basePath = useAppBasePath(); const baseClasses = 'flex items-center justify-between py-3 gap-4'; const hoverClasses = withHover ? 'relative cursor-pointer before:absolute before:inset-x-[-16px] before:inset-y-[-1px] before:rounded-md before:bg-gray-50 before:opacity-0 before:transition-opacity before:will-change-[opacity] hover:z-10 hover:cursor-pointer hover:border-b-transparent hover:before:opacity-100 dark:before:bg-gray-950' : ''; const itemClasses = cn(baseClasses, hoverClasses, className); + const fullPath = to && to.startsWith('/') ? `${basePath}${to}` : to; - if (to) { + if (fullPath) { return ( {children} diff --git a/apps/activitypub/test/acceptance/preferences.test.ts b/apps/activitypub/test/acceptance/preferences.test.ts new file mode 100644 index 00000000000..e274869334a --- /dev/null +++ b/apps/activitypub/test/acceptance/preferences.test.ts @@ -0,0 +1,163 @@ +import {expect, test} from '@playwright/test'; +import {mockApi} from '@tryghost/admin-x-framework/test/acceptance'; +import {mockInitialApiRequests} from '../utils/initial-api-requests'; + +const account = { + id: 'alice', + handle: '@alice@fake.host', + name: 'Alice', + url: 'https://fake.host/@alice', + avatarUrl: 'https://fake.host/avatars/alice.jpg', + followingCount: 5, + followerCount: 10, + likedCount: 3 +}; + +test.describe('Preferences', async () => { + test.beforeEach(async ({page}) => { + await mockInitialApiRequests(page); + }); + + test('I can add an old Mastodon handle for follower migration', async ({page}) => { + const {lastApiRequests} = await mockApi({page, requests: { + getMyAccount: { + method: 'GET', + path: '/v1/account/me', + response: account + }, + getAliases: { + method: 'GET', + path: '/v1/account/aliases', + response: { + destination: { + handle: '@alice@fake.host', + apId: 'https://fake.host/.ghost/activitypub/users/index' + }, + aliases: [] + } + }, + addAlias: { + method: 'POST', + path: '/v1/account/aliases', + response: { + destination: { + handle: '@alice@fake.host', + apId: 'https://fake.host/.ghost/activitypub/users/index' + }, + aliases: [{ + apId: 'https://mastodon.social/users/old' + }] + } + } + }, options: {useActivityPub: true}}); + + await page.goto('#/preferences'); + + await expect(page.getByRole('link', {name: /Account migration/})).toBeVisible(); + await page.getByRole('link', {name: /Account migration/}).click(); + + await expect(page.getByRole('heading', {name: 'Account migration'})).toBeVisible(); + await expect.poll(() => lastApiRequests.getAliases).toBeTruthy(); + await expect(page.getByTestId('account-migration-aliases')).toHaveCount(0); + await page.getByLabel('Old account handle').fill('old@mastodon.social'); + await page.getByRole('button', {name: 'Create alias'}).click(); + + await expect.poll(() => lastApiRequests.addAlias).toBeTruthy(); + expect(lastApiRequests.addAlias?.body).toMatchObject({ + sourceHandle: '@old@mastodon.social' + }); + await expect(page.getByTestId('account-migration-aliases')).toContainText('old@mastodon.social'); + }); + + test('I can see existing aliases newest first', async ({page}) => { + await mockApi({page, requests: { + getMyAccount: { + method: 'GET', + path: '/v1/account/me', + response: account + }, + getAliases: { + method: 'GET', + path: '/v1/account/aliases', + response: { + destination: { + handle: '@alice@fake.host', + apId: 'https://fake.host/.ghost/activitypub/users/index' + }, + aliases: [ + {apId: 'https://mastodon.social/users/old'}, + {apId: 'https://mastodon.social/users/new'} + ] + } + } + }, options: {useActivityPub: true}}); + + await page.goto('#/preferences/move'); + + const aliases = page.getByTestId('account-migration-aliases'); + await expect(aliases).toContainText('new@mastodon.social'); + await expect(aliases).toContainText('old@mastodon.social'); + + const aliasText = await aliases.textContent(); + const newAliasIndex = aliasText?.indexOf('new@mastodon.social') ?? -1; + const oldAliasIndex = aliasText?.indexOf('old@mastodon.social') ?? -1; + + expect(newAliasIndex).toBeGreaterThanOrEqual(0); + expect(oldAliasIndex).toBeGreaterThanOrEqual(0); + expect(newAliasIndex).toBeLessThan(oldAliasIndex); + }); + + test('I see an alias action error when unlinking fails', async ({page}) => { + await mockApi({page, requests: { + getMyAccount: { + method: 'GET', + path: '/v1/account/me', + response: account + }, + getAliases: { + method: 'GET', + path: '/v1/account/aliases', + response: { + destination: { + handle: '@alice@fake.host', + apId: 'https://fake.host/.ghost/activitypub/users/index' + }, + aliases: [{apId: 'https://mastodon.social/users/old'}] + } + }, + removeAlias: { + method: 'DELETE', + path: '/v1/account/aliases', + response: {}, + responseStatus: 500 + } + }, options: {useActivityPub: true}}); + + await page.goto('#/preferences/move'); + await page.getByRole('button', {name: 'Unlink'}).click(); + + await expect(page.getByText('Could not remove migration profile.')).toBeVisible(); + await expect(page.getByLabel('Old account handle')).not.toHaveAttribute('aria-invalid', 'true'); + }); + + test('I see an error when account aliases cannot be loaded', async ({page}) => { + await mockApi({page, requests: { + getMyAccount: { + method: 'GET', + path: '/v1/account/me', + response: account + }, + getAliases: { + method: 'GET', + path: '/v1/account/aliases', + response: {}, + responseStatus: 500 + } + }, options: {useActivityPub: true}}); + + await page.goto('#/preferences/move'); + + await expect(page.getByTestId('account-migration-aliases')).toContainText('Could not load account aliases.'); + await expect(page.getByRole('button', {name: 'Retry'})).toBeVisible(); + }); +}); From 39e62a0177073621fc65a7ac999114ea64a507d3 Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Tue, 19 May 2026 14:12:35 +0100 Subject: [PATCH 2/4] Update paths --- apps/activitypub/src/api/activitypub.test.ts | 10 +++++----- apps/activitypub/src/api/activitypub.ts | 6 +++--- apps/activitypub/test/acceptance/preferences.test.ts | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/activitypub/src/api/activitypub.test.ts b/apps/activitypub/src/api/activitypub.test.ts index 6aece885616..eab482dcb63 100644 --- a/apps/activitypub/src/api/activitypub.test.ts +++ b/apps/activitypub/src/api/activitypub.test.ts @@ -1607,7 +1607,7 @@ describe('ActivityPubAPI', function () { }] }) }, - [`https://activitypub.api/.ghost/activitypub/v1/account/aliases`]: { + [`https://activitypub.api/.ghost/activitypub/v1/aliases`]: { response: JSONResponse({ destination: { handle: '@index@example.com', @@ -1649,7 +1649,7 @@ describe('ActivityPubAPI', function () { }] }) }, - [`https://activitypub.api/.ghost/activitypub/v1/account/aliases`]: { + [`https://activitypub.api/.ghost/activitypub/v1/aliases`]: { async assert(_resource, init) { expect(init?.method).toEqual('POST'); expect(init?.body).toEqual('{"sourceHandle":"@old@mastodon.social"}'); @@ -1689,7 +1689,7 @@ describe('ActivityPubAPI', function () { }] }) }, - [`https://activitypub.api/.ghost/activitypub/v1/account/aliases`]: { + [`https://activitypub.api/.ghost/activitypub/v1/aliases`]: { response: new Response(null, {status: 204}) } }); @@ -1721,7 +1721,7 @@ describe('ActivityPubAPI', function () { }] }) }, - [`https://activitypub.api/.ghost/activitypub/v1/account/aliases`]: { + [`https://activitypub.api/.ghost/activitypub/v1/aliases`]: { async assert(_resource, init) { expect(init?.method).toEqual('DELETE'); expect(init?.body).toEqual('{"actorUri":"https://mastodon.social/users/old"}'); @@ -1757,7 +1757,7 @@ describe('ActivityPubAPI', function () { }] }) }, - [`https://activitypub.api/.ghost/activitypub/v1/account/aliases`]: { + [`https://activitypub.api/.ghost/activitypub/v1/aliases`]: { response: new Response(null, {status: 204}) } }); diff --git a/apps/activitypub/src/api/activitypub.ts b/apps/activitypub/src/api/activitypub.ts index 9ae55eab48b..504eb502a3d 100644 --- a/apps/activitypub/src/api/activitypub.ts +++ b/apps/activitypub/src/api/activitypub.ts @@ -518,21 +518,21 @@ export class ActivityPubAPI { } async getAccountAliases(): Promise { - const url = new URL('.ghost/activitypub/v1/account/aliases', this.apiUrl); + const url = new URL('.ghost/activitypub/v1/aliases', this.apiUrl); const json = await this.fetchJSON(url); return parseAccountAliasesResponse(json); } async addAccountAlias(sourceHandle: string): Promise { - const url = new URL('.ghost/activitypub/v1/account/aliases', this.apiUrl); + const url = new URL('.ghost/activitypub/v1/aliases', this.apiUrl); const json = await this.fetchJSON(url, 'POST', {sourceHandle}); return parseAccountAliasesResponse(json); } async removeAccountAlias(actorUri: string): Promise { - const url = new URL('.ghost/activitypub/v1/account/aliases', this.apiUrl); + const url = new URL('.ghost/activitypub/v1/aliases', this.apiUrl); const json = await this.fetchJSON(url, 'DELETE', {actorUri}); return parseAccountAliasesResponse(json); diff --git a/apps/activitypub/test/acceptance/preferences.test.ts b/apps/activitypub/test/acceptance/preferences.test.ts index e274869334a..24e5c112c40 100644 --- a/apps/activitypub/test/acceptance/preferences.test.ts +++ b/apps/activitypub/test/acceptance/preferences.test.ts @@ -27,7 +27,7 @@ test.describe('Preferences', async () => { }, getAliases: { method: 'GET', - path: '/v1/account/aliases', + path: '/v1/aliases', response: { destination: { handle: '@alice@fake.host', @@ -38,7 +38,7 @@ test.describe('Preferences', async () => { }, addAlias: { method: 'POST', - path: '/v1/account/aliases', + path: '/v1/aliases', response: { destination: { handle: '@alice@fake.host', @@ -78,7 +78,7 @@ test.describe('Preferences', async () => { }, getAliases: { method: 'GET', - path: '/v1/account/aliases', + path: '/v1/aliases', response: { destination: { handle: '@alice@fake.host', @@ -116,7 +116,7 @@ test.describe('Preferences', async () => { }, getAliases: { method: 'GET', - path: '/v1/account/aliases', + path: '/v1/aliases', response: { destination: { handle: '@alice@fake.host', @@ -127,7 +127,7 @@ test.describe('Preferences', async () => { }, removeAlias: { method: 'DELETE', - path: '/v1/account/aliases', + path: '/v1/aliases', response: {}, responseStatus: 500 } @@ -149,7 +149,7 @@ test.describe('Preferences', async () => { }, getAliases: { method: 'GET', - path: '/v1/account/aliases', + path: '/v1/aliases', response: {}, responseStatus: 500 } From b5bb874b0f6cda5c8253c94e95d85f9f92f045e0 Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Tue, 19 May 2026 14:20:44 +0100 Subject: [PATCH 3/4] Fix field error --- .../preferences/components/mastodon-migration.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/activitypub/src/views/preferences/components/mastodon-migration.tsx b/apps/activitypub/src/views/preferences/components/mastodon-migration.tsx index 80bcd12f902..41f58cb0d1c 100644 --- a/apps/activitypub/src/views/preferences/components/mastodon-migration.tsx +++ b/apps/activitypub/src/views/preferences/components/mastodon-migration.tsx @@ -1,6 +1,6 @@ import Layout from '@src/components/layout'; import React, {useState} from 'react'; -import {Button, Field, FieldDescription, FieldLabel, Input, LoadingIndicator, Skeleton} from '@tryghost/shade/components'; +import {Button, Field, FieldDescription, FieldError, FieldLabel, Input, LoadingIndicator, Skeleton} from '@tryghost/shade/components'; import {H2} from '@tryghost/shade/primitives'; import {LucideIcon} from '@tryghost/shade/utils'; import {useAccountAliasesForUser, useAddAccountAliasMutationForUser, useRemoveAccountAliasMutationForUser} from '@hooks/use-activity-pub-queries'; @@ -113,11 +113,11 @@ const MastodonMigration: React.FC = () => {
- + Old account handle - + Specify the username@domain of the account you want to move from
@@ -143,14 +143,14 @@ const MastodonMigration: React.FC = () => {
{handleError && ( -

+ {handleError} -

+ )} {aliasActionError && ( -

+ {aliasActionError} -

+ )}
From 859f3e60f20822a2eb42cbf718d81a8b4e47d4e2 Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Tue, 19 May 2026 14:25:05 +0100 Subject: [PATCH 4/4] mastodon-migration.tsx -> account-migration.tsx --- apps/activitypub/src/routes.tsx | 2 +- .../{mastodon-migration.tsx => account-migration.tsx} | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename apps/activitypub/src/views/preferences/components/{mastodon-migration.tsx => account-migration.tsx} (99%) diff --git a/apps/activitypub/src/routes.tsx b/apps/activitypub/src/routes.tsx index 3e6603d0bb1..cef3dca4348 100644 --- a/apps/activitypub/src/routes.tsx +++ b/apps/activitypub/src/routes.tsx @@ -109,7 +109,7 @@ export const routes: CustomRouteObject[] = [ }, { path: accountMigrationPath, - lazy: lazyComponent(() => import('./views/preferences/components/mastodon-migration')), + lazy: lazyComponent(() => import('./views/preferences/components/account-migration')), pageTitle: 'Account migration', showBackButton: true }, diff --git a/apps/activitypub/src/views/preferences/components/mastodon-migration.tsx b/apps/activitypub/src/views/preferences/components/account-migration.tsx similarity index 99% rename from apps/activitypub/src/views/preferences/components/mastodon-migration.tsx rename to apps/activitypub/src/views/preferences/components/account-migration.tsx index 41f58cb0d1c..0b05931776a 100644 --- a/apps/activitypub/src/views/preferences/components/mastodon-migration.tsx +++ b/apps/activitypub/src/views/preferences/components/account-migration.tsx @@ -47,7 +47,7 @@ function getAliasErrorMessage(error: unknown) { return 'Something went wrong, please try again.'; } -const MastodonMigration: React.FC = () => { +const AccountMigration: React.FC = () => { const { data: aliasData, isError: hasAliasLoadError, @@ -198,4 +198,4 @@ const MastodonMigration: React.FC = () => { ); }; -export default MastodonMigration; +export default AccountMigration;