diff --git a/graphql/account.test.ts b/graphql/account.test.ts index 8e5a47f7..2c9dc8ef 100644 --- a/graphql/account.test.ts +++ b/graphql/account.test.ts @@ -1676,6 +1676,31 @@ test("addAccountMigrationAlias returns typed errors", async () => { "NotAuthorizedError", ); + // A well-formed but nonexistent account id is also rejected with + // NotAuthorizedError, so the error does not leak whether the id exists. + const missing = await execute({ + schema, + document: addAccountMigrationAliasMutation, + variableValues: { + input: { + accountId: encodeGlobalID( + "Account", + "00000000-0000-4000-8000-000000000000", + ), + actor: "@old@remote.example", + }, + }, + contextValue: makeUserContext(tx, owner.account), + onError: "NO_PROPAGATE", + }); + assert.equal(missing.errors, undefined); + assert.equal( + (toPlainJson(missing.data) as { + addAccountMigrationAlias?: { __typename?: string }; + }).addAccountMigrationAlias?.__typename, + "NotAuthorizedError", + ); + const invalid = await execute({ schema, document: addAccountMigrationAliasMutation, @@ -1778,6 +1803,159 @@ test("removeAccountMigrationAlias removes one alias idempotently", async () => { }); }); +test("account migration aliases are manageable by organization admins", async () => { + await withRollback(async (tx) => { + const admin = await insertAccountWithActor(tx, { + username: "orgmigadmin", + name: "Org Migration Admin", + email: "orgmigadmin@example.com", + }); + const member = await insertAccountWithActor(tx, { + username: "orgmigmember", + name: "Org Migration Member", + email: "orgmigmember@example.com", + }); + const outsider = await insertAccountWithActor(tx, { + username: "orgmigoutsider", + name: "Org Migration Outsider", + email: "orgmigoutsider@example.com", + }); + const organization = await insertAccountWithActor(tx, { + username: "orgmigration", + name: "Org Migration", + email: "orgmigration@example.com", + kind: "organization", + type: "Organization", + }); + await tx.insert(organizationMembershipTable).values([ + { + organizationAccountId: organization.account.id, + memberAccountId: admin.account.id, + role: "admin", + invitedById: admin.account.id, + accepted: new Date("2026-04-15T00:00:00.000Z"), + }, + { + organizationAccountId: organization.account.id, + memberAccountId: member.account.id, + role: "member", + invitedById: admin.account.id, + accepted: new Date("2026-04-15T00:00:00.000Z"), + }, + ]); + await insertRemoteActor(tx, { + username: "orgoldalias", + name: "Org Old Alias", + host: "old.example", + iri: "https://old.example/users/orgoldalias", + url: "https://old.example/@orgoldalias", + }); + + const fedCtx = createFedCtx(tx); + fedCtx.getActor = (identifier: string) => + Promise.resolve( + new vocab.Organization({ id: fedCtx.getActorUri(identifier) }), + ); + const sentActivities: unknown[][] = []; + fedCtx.sendActivity = ((...args: unknown[]) => { + sentActivities.push(args); + return Promise.resolve(undefined); + }) as typeof fedCtx.sendActivity; + + // An accepted admin can add an alias to the organization account. + const added = await execute({ + schema, + document: addAccountMigrationAliasMutation, + variableValues: { + input: { + accountId: encodeGlobalID("Account", organization.account.id), + actor: "@orgoldalias@old.example", + }, + }, + contextValue: makeUserContext(tx, admin.account, { fedCtx }), + onError: "NO_PROPAGATE", + }); + assert.equal(added.errors, undefined); + assert.deepEqual(toPlainJson(added.data), { + addAccountMigrationAlias: { + __typename: "AddAccountMigrationAliasPayload", + account: { + actor: { + aliases: ["https://old.example/users/orgoldalias"], + }, + }, + }, + }); + + // ... and remove it again. + const removed = await execute({ + schema, + document: removeAccountMigrationAliasMutation, + variableValues: { + input: { + accountId: encodeGlobalID("Account", organization.account.id), + alias: "https://old.example/users/orgoldalias", + }, + }, + contextValue: makeUserContext(tx, admin.account, { fedCtx }), + onError: "NO_PROPAGATE", + }); + assert.equal(removed.errors, undefined); + assert.deepEqual(toPlainJson(removed.data), { + removeAccountMigrationAlias: { + __typename: "RemoveAccountMigrationAliasPayload", + account: { + actor: { + aliases: [], + }, + }, + }, + }); + + // A non-admin member cannot manage the organization's migration aliases. + const byMember = await execute({ + schema, + document: addAccountMigrationAliasMutation, + variableValues: { + input: { + accountId: encodeGlobalID("Account", organization.account.id), + actor: "@orgoldalias@old.example", + }, + }, + contextValue: makeUserContext(tx, member.account, { fedCtx }), + onError: "NO_PROPAGATE", + }); + assert.equal(byMember.errors, undefined); + assert.equal( + (toPlainJson(byMember.data) as { + addAccountMigrationAlias?: { __typename?: string }; + }).addAccountMigrationAlias?.__typename, + "NotAuthorizedError", + ); + + // Neither can an account that is not a member at all. + const byOutsider = await execute({ + schema, + document: addAccountMigrationAliasMutation, + variableValues: { + input: { + accountId: encodeGlobalID("Account", organization.account.id), + actor: "@orgoldalias@old.example", + }, + }, + contextValue: makeUserContext(tx, outsider.account, { fedCtx }), + onError: "NO_PROPAGATE", + }); + assert.equal(byOutsider.errors, undefined); + assert.equal( + (toPlainJson(byOutsider.data) as { + addAccountMigrationAlias?: { __typename?: string }; + }).addAccountMigrationAlias?.__typename, + "NotAuthorizedError", + ); + }); +}); + test("deleteAccount deletes the viewer account and session", async () => { await withRollback(async (tx) => { const { kv } = createTestKv(); diff --git a/graphql/account.ts b/graphql/account.ts index e6813b61..4c0294d7 100644 --- a/graphql/account.ts +++ b/graphql/account.ts @@ -218,12 +218,25 @@ async function getAuthorizedMigrationAccount( throw new NotAuthenticatedError(); } if (!validateUuid(accountId)) throw new InvalidInputError("accountId"); - if (session.accountId !== accountId) throw new NotAuthorizedError(); const account = await ctx.db.query.accountTable.findFirst({ where: { id: accountId }, with: { actor: true }, }); - if (account?.actor == null) throw new InvalidInputError("accountId"); + // Treat a nonexistent account the same as an unauthorized one so the error + // does not leak whether the id exists (the input description promises that + // any account id the viewer cannot manage returns `NotAuthorizedError`). + if ( + account == null || + !(await viewerCanManageAccountSettings( + ctx, + account.id, + account.kind, + session.accountId, + )) + ) { + throw new NotAuthorizedError(); + } + if (account.actor == null) throw new InvalidInputError("accountId"); return account; } @@ -1445,17 +1458,21 @@ builder.relayMutationField( "addAccountMigrationAlias", { description: - "Add a previous account actor as an ActivityPub migration alias for " + - "the authenticated viewer's local account. The stored value is the " + - "resolved actor IRI, which is then advertised as `alsoKnownAs` on " + - "the local actor document so a later remote `Move` can be validated.", + "Add a previous account actor as an ActivityPub migration alias for a " + + "local account the authenticated viewer can manage: their own personal " + + "account, or an `ORGANIZATION` account they are an accepted `admin` of. " + + "The stored value is the resolved actor IRI, which is then advertised " + + "as `alsoKnownAs` on the local actor document so a later remote `Move` " + + "can be validated.", inputFields: (t) => ({ accountId: t.globalID({ for: Account, required: true, description: - "The `Account` global id owned by the authenticated viewer. " + - "Passing another account id returns `NotAuthorizedError`.", + "The `Account` global id to add the alias to. The authenticated " + + "viewer must be able to manage that account: its personal holder, " + + "or an accepted `admin` of an `ORGANIZATION` account. Any other " + + "account id returns `NotAuthorizedError`.", }), actor: t.string({ required: true, @@ -1506,17 +1523,19 @@ builder.relayMutationField( "removeAccountMigrationAlias", { description: - "Remove one ActivityPub migration alias from the authenticated " + - "viewer's local account. Removing an alias only changes the new " + - "account's `alsoKnownAs` list; it does not send or retract a remote " + - "`Move` activity.", + "Remove one ActivityPub migration alias from a local account the " + + "authenticated viewer can manage: their own personal account, or an " + + "`ORGANIZATION` account they are an accepted `admin` of. Removing an " + + "alias only changes the account's `alsoKnownAs` list; it does not send " + + "or retract a remote `Move` activity.", inputFields: (t) => ({ accountId: t.globalID({ for: Account, required: true, - description: - "The `Account` global id owned by the authenticated viewer. " + - "Passing another account id returns `NotAuthorizedError`.", + description: "The `Account` global id to remove the alias from. The " + + "authenticated viewer must be able to manage that account: its " + + "personal holder, or an accepted `admin` of an `ORGANIZATION` " + + "account. Any other account id returns `NotAuthorizedError`.", }), alias: t.field({ type: "URL", diff --git a/graphql/schema.graphql b/graphql/schema.graphql index f3b7b05a..0e64651a 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -872,11 +872,11 @@ type ActorViewerInteractionsConnectionEdge { } """ -Add a previous account actor as an ActivityPub migration alias for the authenticated viewer's local account. The stored value is the resolved actor IRI, which is then advertised as `alsoKnownAs` on the local actor document so a later remote `Move` can be validated. +Add a previous account actor as an ActivityPub migration alias for a local account the authenticated viewer can manage: their own personal account, or an `ORGANIZATION` account they are an accepted `admin` of. The stored value is the resolved actor IRI, which is then advertised as `alsoKnownAs` on the local actor document so a later remote `Move` can be validated. """ input AddAccountMigrationAliasInput { """ - The `Account` global id owned by the authenticated viewer. Passing another account id returns `NotAuthorizedError`. + The `Account` global id to add the alias to. The authenticated viewer must be able to manage that account: its personal holder, or an accepted `admin` of an `ORGANIZATION` account. Any other account id returns `NotAuthorizedError`. """ accountId: ID! @@ -5287,11 +5287,11 @@ type RelaySubscription implements Node { } """ -Remove one ActivityPub migration alias from the authenticated viewer's local account. Removing an alias only changes the new account's `alsoKnownAs` list; it does not send or retract a remote `Move` activity. +Remove one ActivityPub migration alias from a local account the authenticated viewer can manage: their own personal account, or an `ORGANIZATION` account they are an accepted `admin` of. Removing an alias only changes the account's `alsoKnownAs` list; it does not send or retract a remote `Move` activity. """ input RemoveAccountMigrationAliasInput { """ - The `Account` global id owned by the authenticated viewer. Passing another account id returns `NotAuthorizedError`. + The `Account` global id to remove the alias from. The authenticated viewer must be able to manage that account: its personal holder, or an accepted `admin` of an `ORGANIZATION` account. Any other account id returns `NotAuthorizedError`. """ accountId: ID! diff --git a/web-next/src/locales/en-US/messages.po b/web-next/src/locales/en-US/messages.po index 1d49af97..e724b09e 100644 --- a/web-next/src/locales/en-US/messages.po +++ b/web-next/src/locales/en-US/messages.po @@ -5459,10 +5459,10 @@ msgstr "You can only view your own bookmarks" msgid "You can only view your own drafts" msgstr "You can only view your own drafts" -#: src/routes/(root)/[handle]/settings/account.tsx:1670 -#: src/routes/(root)/[handle]/settings/account.tsx:1721 -msgid "You can prepare migration only for your own account." -msgstr "You can prepare migration only for your own account." +#: src/routes/(root)/[handle]/settings/account.tsx:1676 +#: src/routes/(root)/[handle]/settings/account.tsx:1727 +msgid "You can prepare migration only for accounts you can manage." +msgstr "You can prepare migration only for accounts you can manage." #: src/routes/(root)/[handle]/settings/account.tsx:979 msgid "You do not belong to any organizations yet." diff --git a/web-next/src/locales/ja-JP/messages.po b/web-next/src/locales/ja-JP/messages.po index 699bb6a2..2dce5d9b 100644 --- a/web-next/src/locales/ja-JP/messages.po +++ b/web-next/src/locales/ja-JP/messages.po @@ -5455,10 +5455,10 @@ msgstr "自分のブックマークのみ表示できます" msgid "You can only view your own drafts" msgstr "自分の下書きのみ表示できます" -#: src/routes/(root)/[handle]/settings/account.tsx:1670 -#: src/routes/(root)/[handle]/settings/account.tsx:1721 -msgid "You can prepare migration only for your own account." -msgstr "自分のアカウントでのみ移行を準備できます。" +#: src/routes/(root)/[handle]/settings/account.tsx:1676 +#: src/routes/(root)/[handle]/settings/account.tsx:1727 +msgid "You can prepare migration only for accounts you can manage." +msgstr "管理できるアカウントでのみ移行を準備できます。" #: src/routes/(root)/[handle]/settings/account.tsx:979 msgid "You do not belong to any organizations yet." diff --git a/web-next/src/locales/ko-KR/messages.po b/web-next/src/locales/ko-KR/messages.po index 9e726bea..2438f01b 100644 --- a/web-next/src/locales/ko-KR/messages.po +++ b/web-next/src/locales/ko-KR/messages.po @@ -5455,10 +5455,10 @@ msgstr "자신의 북마크만 볼 수 있습니다" msgid "You can only view your own drafts" msgstr "자신의 초고만 볼 수 있습니다" -#: src/routes/(root)/[handle]/settings/account.tsx:1670 -#: src/routes/(root)/[handle]/settings/account.tsx:1721 -msgid "You can prepare migration only for your own account." -msgstr "자신의 계정에서만 이전을 준비할 수 있습니다." +#: src/routes/(root)/[handle]/settings/account.tsx:1676 +#: src/routes/(root)/[handle]/settings/account.tsx:1727 +msgid "You can prepare migration only for accounts you can manage." +msgstr "관리할 수 있는 계정에서만 이전을 준비할 수 있습니다." #: src/routes/(root)/[handle]/settings/account.tsx:979 msgid "You do not belong to any organizations yet." diff --git a/web-next/src/locales/zh-CN/messages.po b/web-next/src/locales/zh-CN/messages.po index cd941203..49cc7274 100644 --- a/web-next/src/locales/zh-CN/messages.po +++ b/web-next/src/locales/zh-CN/messages.po @@ -5455,10 +5455,10 @@ msgstr "您只能查看自己的收藏" msgid "You can only view your own drafts" msgstr "您只能查看自己的草稿" -#: src/routes/(root)/[handle]/settings/account.tsx:1670 -#: src/routes/(root)/[handle]/settings/account.tsx:1721 -msgid "You can prepare migration only for your own account." -msgstr "你只能为自己的账户准备迁移。" +#: src/routes/(root)/[handle]/settings/account.tsx:1676 +#: src/routes/(root)/[handle]/settings/account.tsx:1727 +msgid "You can prepare migration only for accounts you can manage." +msgstr "你只能为你能管理的账户准备迁移。" #: src/routes/(root)/[handle]/settings/account.tsx:979 msgid "You do not belong to any organizations yet." diff --git a/web-next/src/locales/zh-TW/messages.po b/web-next/src/locales/zh-TW/messages.po index 10784ee8..fef9257b 100644 --- a/web-next/src/locales/zh-TW/messages.po +++ b/web-next/src/locales/zh-TW/messages.po @@ -5455,10 +5455,10 @@ msgstr "您只能檢視自己的收藏" msgid "You can only view your own drafts" msgstr "您只能檢視自己的草稿" -#: src/routes/(root)/[handle]/settings/account.tsx:1670 -#: src/routes/(root)/[handle]/settings/account.tsx:1721 -msgid "You can prepare migration only for your own account." -msgstr "你只能為自己的帳戶準備遷移。" +#: src/routes/(root)/[handle]/settings/account.tsx:1676 +#: src/routes/(root)/[handle]/settings/account.tsx:1727 +msgid "You can prepare migration only for accounts you can manage." +msgstr "你只能為你能管理的帳戶準備遷移。" #: src/routes/(root)/[handle]/settings/account.tsx:979 msgid "You do not belong to any organizations yet." diff --git a/web-next/src/routes/(root)/[handle]/settings/account.tsx b/web-next/src/routes/(root)/[handle]/settings/account.tsx index 4f0728ef..703fc4d8 100644 --- a/web-next/src/routes/(root)/[handle]/settings/account.tsx +++ b/web-next/src/routes/(root)/[handle]/settings/account.tsx @@ -538,22 +538,7 @@ export default function AccountSettingsPage() { account={account} viewer={viewer} /> - - - - {t`Account migration`} - - - {t`Prepare this account as the destination for a Mastodon-style move.`} - - - - - - + {t`Delete account`} @@ -577,6 +562,7 @@ export default function AccountSettingsPage() { account={account} viewerId={data.viewer?.id ?? null} /> + {t`Delete organization`} @@ -1609,6 +1595,30 @@ function OrganizationMemberManagementCard(props: { ); } +interface AccountMigrationCardProps { + account: AccountPageAccount; +} + +function AccountMigrationCard(props: AccountMigrationCardProps) { + const { t } = useLingui(); + return ( + + + {t`Account migration`} + + {t`Prepare this account as the destination for a Mastodon-style move.`} + + + + + + + ); +} + interface AccountMigrationAliasesFormProps { aliases: readonly string[]; id: string; @@ -1667,7 +1677,7 @@ function AccountMigrationAliasesForm( if (result?.__typename === "NotAuthorizedError") { showMutationError( t`Cannot update this account`, - t`You can prepare migration only for your own account.`, + t`You can prepare migration only for accounts you can manage.`, ); return; } @@ -1718,7 +1728,7 @@ function AccountMigrationAliasesForm( if (result?.__typename === "NotAuthorizedError") { showMutationError( t`Cannot update this account`, - t`You can prepare migration only for your own account.`, + t`You can prepare migration only for accounts you can manage.`, ); return; }