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;
}