diff --git a/.changeset/24273-transfer-ownership-honest.md b/.changeset/24273-transfer-ownership-honest.md new file mode 100644 index 00000000000..918ea1a8f06 --- /dev/null +++ b/.changeset/24273-transfer-ownership-honest.md @@ -0,0 +1,19 @@ +--- +'@objectstack/platform-objects': patch +--- + +fix(platform-objects): `sys_member`'s `transfer_ownership` row action says the selected member becomes an owner and the caller keeps their role + +The action sends better-auth's `POST /organization/update-member-role` with `role: 'owner'`. That endpoint writes the selected member's role and nothing else, so the caller stays an owner and the organization ends with two. The action's confirm used to promise "You will be demoted to admin and lose owner-only privileges"; nobody was demoted. + +The copy now says what happens, in all four locales: + +- label: FROM "Transfer Ownership" TO "Make Owner"; +- confirm: the selected member becomes an owner with every owner privilege, the caller stays an owner, and to step down the caller uses Change Role on their own membership; +- success message: FROM "Ownership transferred" TO "Member is now an owner". + +To hand ownership over, run Make Owner on the new owner's row, then Change Role on your own row to `admin`. better-auth refuses that second step only while you are the organization's last owner (`YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER`). + +The action's `name`, `target`, request body, parameters, `requiresMembershipReach` and `variant` are unchanged, so a page or test that names `transfer_ownership` keeps working. A test that expected the caller to be `admin` after the action expected a demotion the endpoint never performed: it now reads `owner`, and reads `admin` only after the caller's own Change Role. + +Clause-②: no (provisional: copy only; if the measurement forces another shape, the dev reports before building it; owes the seat's review of record at `TIER_DEFAULT` before the queue) diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 7a0942cb959..785d046f9a3 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -688,9 +688,9 @@ export const enObjects: NonNullable = { successMessage: "Member removed" }, transfer_ownership: { - label: "Transfer Ownership", - confirmText: "Transfer ownership of this organization to the selected member? You will be demoted to admin and lose owner-only privileges.", - successMessage: "Ownership transferred" + label: "Make Owner", + confirmText: "Make the selected member an owner of this organization? They get every owner privilege, including deleting the organization. You stay an owner too; to step down, use Change Role on your own membership.", + successMessage: "Member is now an owner" } } }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index bc65351b76c..0e2107e022a 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -688,9 +688,9 @@ export const esESObjects: NonNullable = { successMessage: "Miembro eliminado" }, transfer_ownership: { - label: "Transferir propiedad", - confirmText: "¿Transferir la propiedad de esta organización al miembro seleccionado? Pasarás a ser administrador y perderás los privilegios exclusivos del propietario.", - successMessage: "Propiedad transferida" + label: "Hacer propietario", + confirmText: "¿Hacer propietario de esta organización al miembro seleccionado? Obtendrá todos los privilegios de propietario, incluido eliminar la organización. Tú sigues siendo propietario; para dejar de serlo, usa «Cambiar rol» en tu propia membresía.", + successMessage: "El miembro ahora es propietario" } } }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index f41fb422c2b..78b3569f559 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -688,9 +688,9 @@ export const jaJPObjects: NonNullable = { successMessage: "メンバーを削除しました" }, transfer_ownership: { - label: "所有権の移譲", - confirmText: "この組織の所有権を選択したメンバーに移譲しますか?あなたは管理者に降格され、所有者のみの権限を失います。", - successMessage: "所有権を移譲しました" + label: "所有者にする", + confirmText: "選択したメンバーをこの組織の所有者にしますか?このメンバーには、組織の削除を含む所有者のすべての権限が付与されます。あなたも引き続き所有者です。退く場合は、自分のメンバーシップで「ロールを変更」を使用してください。", + successMessage: "メンバーが所有者になりました" } } }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 2ee763c06f0..6776e74cf58 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -688,9 +688,9 @@ export const zhCNObjects: NonNullable = { successMessage: "成员已移除" }, transfer_ownership: { - label: "转移所有权", - confirmText: "确定要将该组织的所有权转移给所选成员吗?您将被降级为管理员,并失去仅所有者拥有的权限。", - successMessage: "已转移所有权" + label: "设为所有者", + confirmText: "确定要将所选成员设为该组织的所有者吗?对方将获得所有者的全部权限,包括删除该组织。您仍是所有者;如需卸任,请在您自己的成员记录上使用“更改角色”。", + successMessage: "该成员现已成为所有者" } } }, diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index 8429dce5b16..c3487c977c2 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -227,15 +227,22 @@ export const SysMember = ObjectSchema.create({ successMessage: 'Member removed', refreshAfter: true, }, - // Transfer ownership is modeled as `update-member-role` with role=owner - // (better-auth's organization plugin auto-demotes the previous owner - // to admin). Kept as a separate action so the row menu can present a - // distinct destructive-style affordance with the right confirm copy — - // mixing it into `update_member_role` would hide the ownership-handoff - // semantics behind a generic role dropdown. + // Make Owner is `update-member-role` with role=owner, and better-auth's + // `updateMemberRole` writes the TARGET's role and nothing else: the caller + // stays an owner, so the organization ends with two. The copy promises + // exactly that. Stepping down is the caller's own Change Role + // (`update_member_role`) on their own row, which the confirm names; the + // vendor refuses it only while the caller is the last owner + // (`YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER`). Measured on a + // booted stack: `member-transfer-ownership-step-down.dogfood.test.ts`. + // Kept as a separate action so the row menu can present a distinct + // danger-variant affordance with its own confirm — granting the creator + // role hidden in `update_member_role`'s generic role dropdown would be + // easy to miss. The `name` stays `transfer_ownership`: it is the action's + // id and its MEMBERSHIP_REACH row's key, which consumers name. { name: 'transfer_ownership', - label: 'Transfer Ownership', + label: 'Make Owner', icon: 'crown', variant: 'danger', mode: 'custom', @@ -256,8 +263,8 @@ export const SysMember = ObjectSchema.create({ visible: "has(record.role) && record.role != 'owner'", requiresFeature: 'organization', requiresMembershipReach: 'transfer_ownership', - confirmText: 'Transfer ownership of this organization to the selected member? You will be demoted to admin and lose owner-only privileges.', - successMessage: 'Ownership transferred', + confirmText: 'Make the selected member an owner of this organization? They get every owner privilege, including deleting the organization. You stay an owner too; to step down, use Change Role on your own membership.', + successMessage: 'Member is now an owner', refreshAfter: true, }, ], diff --git a/packages/platform-objects/src/identity/transfer-ownership-action-copy.test.ts b/packages/platform-objects/src/identity/transfer-ownership-action-copy.test.ts new file mode 100644 index 00000000000..2e3e56c0c1b --- /dev/null +++ b/packages/platform-objects/src/identity/transfer-ownership-action-copy.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `sys_member.transfer_ownership`'s copy says what its door does. +// +// The action sends better-auth's `update-member-role` with `role: 'owner'`, and +// that endpoint writes the TARGET's role and nothing else: the caller stays an +// owner (measured on a booted stack by +// `packages/qa/dogfood/test/member-transfer-ownership-step-down.dogfood.test.ts`). +// The copy used to promise the caller "You will be demoted to admin"; nothing +// demoted anyone, and the organization ended with two owners. So the copy may +// promise neither a demotion nor a transfer, and the confirm must name the step +// that really steps the caller down — Change Role (`update_member_role`) on +// their own row — by that action's own label, in every locale. +// +// The translated bundles are pinned as well: merge mode keeps every existing +// translated value, so a revised English source leaves the old promise served +// in zh-CN / ja-JP / es-ES while `check:i18n` reads "in sync". +import { describe, expect, it } from 'vitest'; +import { enObjects } from '../apps/translations/en.objects.generated.js'; +import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js'; +import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js'; +import { esESObjects } from '../apps/translations/es-ES.objects.generated.js'; +import { SysMember } from './sys-member.object.js'; + +interface CopyAction { + name?: string; + label?: string; + confirmText?: string; + successMessage?: string; + target?: string; + bodyExtra?: Record; + recordIdParam?: string; + variant?: string; + visible?: unknown; +} + +function declared(name: string): CopyAction { + const found = ((SysMember as { actions?: CopyAction[] }).actions ?? []).find((a) => a.name === name); + expect(found, `${name} is declared`).toBeDefined(); + return found as CopyAction; +} + +const bundled = (bundle: unknown, name: string): CopyAction | undefined => + (bundle as Record }>)?.sys_member?._actions?.[name]; + +const LOCALES = [ + ['en', enObjects], + ['zh-CN', zhCNObjects], + ['ja-JP', jaJPObjects], + ['es-ES', esESObjects], +] as const; + +describe('sys_member.transfer_ownership — declared copy', () => { + const action = declared('transfer_ownership'); + + it('keeps the request and the identity consumers name', () => { + expect(action.target).toBe('/api/v1/auth/organization/update-member-role'); + expect(action.bodyExtra).toEqual({ role: 'owner' }); + expect(action.recordIdParam).toBe('memberId'); + expect(action.variant).toBe('danger'); + // `requiresMembershipReach: 'transfer_ownership'`, lowered: the owner alone. + expect(String((action.visible as { source?: string })?.source)).toContain("'org_owner' in current_user.positions"); + }); + + it('promises neither a demotion nor a transfer', () => { + for (const text of [action.label, action.confirmText, action.successMessage]) { + expect(text).not.toMatch(/demot|transfer/i); + } + expect(action.confirmText).toContain('You stay an owner'); + }); + + it('names the step-down by the Change Role action\'s own label', () => { + expect(action.confirmText).toContain(declared('update_member_role').label); + }); +}); + +describe('sys_member.transfer_ownership — every locale bundle', () => { + it.each(LOCALES)('%s names its own Change Role label in the confirm', (_locale, bundle) => { + const copy = bundled(bundle, 'transfer_ownership'); + const changeRole = bundled(bundle, 'update_member_role')?.label; + expect(changeRole, 'update_member_role is translated').toBeTruthy(); + expect(copy?.confirmText).toContain(changeRole); + }); + + it('en is the declared copy', () => { + const copy = bundled(enObjects, 'transfer_ownership'); + const action = declared('transfer_ownership'); + expect(copy).toEqual({ label: action.label, confirmText: action.confirmText, successMessage: action.successMessage }); + }); +}); diff --git a/packages/qa/dogfood/test/member-transfer-ownership-step-down.dogfood.test.ts b/packages/qa/dogfood/test/member-transfer-ownership-step-down.dogfood.test.ts new file mode 100644 index 00000000000..58ecbeb1a29 --- /dev/null +++ b/packages/qa/dogfood/test/member-transfer-ownership-step-down.dogfood.test.ts @@ -0,0 +1,197 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_member.transfer_ownership` says what its door does — measured on a real + * boot, on the wire, by reading both `sys_member` rows after every write. + * + * The action sends better-auth's `POST /organization/update-member-role` with + * `{ memberId: TARGET, role: 'owner' }`. That endpoint writes the TARGET's role + * and nothing else: the caller stays an owner, so the organization ends with + * two. The action used to promise the caller "You will be demoted to admin"; + * nothing demoted anyone. Its copy now says the selected member becomes an + * owner and the caller keeps their role, and names the step that really steps + * the caller down: Change Role (`update_member_role`) on their own row. + * + * ## What this measures, in one organization beside a second one + * + * 1. the SERVED copy of `transfer_ownership` (`GET /meta/object/sys_member`): + * no transfer, no demotion promised; the step-down named; + * 2. the action's own request, as an owner, on a plain member: the target + * becomes an owner and the caller is STILL an owner — both rows read; + * 3. the step-down the copy names: the served `update_member_role` predicate + * offers it on the caller's OWN row, and the same door, sent by the caller + * for their own row with `role: 'admin'`, steps them down — both rows read; + * 4. the vendor's last-owner guard: the new sole owner cannot step down the + * same way, refused with the vendor's own code, and its row stays `owner`. + * + * The second organization holds both principals with other grades; its rows + * are read last and are unchanged, so every write above is the organization's + * own and not a per-user one. + * + * Harness note: `bootStack` boots the production `single` shape (ADR-0131 D3); + * the multi-organization runtime (`@objectstack/organizations`) is not a + * dependency of this suite, so its gates skip here (`enterprise-organizations.ts`). + * The two organizations are rows written in system context, the shape + * `org-admin-affordance-reach.dogfood.test.ts` uses — the only writer + * better-auth-managed tables accept (ADR-0092). The door under test is the + * vendor's own `update-member-role` either way. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { celEngine } from '@objectstack/formula'; +import { bootShowcase } from './showcase-boot.js'; + +const SYSTEM_CTX = { isSystem: true }; + +type ServedAction = { + name: string; + label?: string; + confirmText?: string; + successMessage?: string; + target?: string; + bodyExtra?: Record; + visible?: unknown; +}; + +async function findRows(ql: any, object: string, where: any, limit = 50): Promise { + const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : (rows?.records ?? []); +} + +/** The sign-up reconciler's membership row lands after the signup transaction. */ +async function waitForMembership(ql: any, userId: string): Promise { + for (let i = 0; i < 40; i++) { + const rows = await findRows(ql, 'sys_member', { user_id: userId }, 5); + if (rows.length > 0) return rows[0]; + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`no sys_member row appeared for ${userId}`); +} + +describe('sys_member.transfer_ownership makes the target an owner; the caller steps down through Change Role', () => { + let stack: VerifyStack; + let ql: any; + let orgId: string; + let otherOrgId: string; + const owner = {} as { token: string; userId: string; rowId: string }; + const target = {} as { token: string; userId: string; rowId: string }; + let served: Map; + let features: Record; + + /** Both principals' roles in an organization, read from storage. */ + const rolesIn = async (organizationId: string) => { + const rows = await findRows(ql, 'sys_member', { organization_id: organizationId }, 50); + const roleOf = (userId: string) => rows.find((r) => String(r.user_id) === userId)?.role; + return { owner: roleOf(owner.userId), target: roleOf(target.userId) }; + }; + + /** The action's request as the console sends it: the row id under `recordIdParam`, plus `bodyExtra`. */ + const updateMemberRole = (token: string, memberId: string, role: string) => + stack.apiAs(token, 'POST', '/auth/organization/update-member-role', { memberId, role, organizationId: orgId }); + + beforeAll(async () => { + stack = await bootShowcase({}); + await stack.signIn(); // the seeded dev admin: boots the identity plane + ql = await stack.kernel.getServiceAsync('objectql'); + + // Two signed-up principals, neither a platform admin, so the verdicts + // below are the membership grade's alone. + for (const [who, email] of [[owner, 'transfer.owner@example.com'], [target, 'transfer.target@example.com']] as const) { + who.token = await stack.signUp(email, 'Transfer!Pass123', email); + const [user] = await findRows(ql, 'sys_user', { email }, 1); + who.userId = String(user.id); + await waitForMembership(ql, who.userId); + } + + const org = await ql.insert('sys_organization', { name: 'Transfer Org', slug: 'transfer-org' }, { context: SYSTEM_CTX }); + orgId = String(org.id); + const other = await ql.insert('sys_organization', { name: 'Transfer Other Org', slug: 'transfer-other-org' }, { context: SYSTEM_CTX }); + otherOrgId = String(other.id); + + owner.rowId = String((await ql.insert('sys_member', { user_id: owner.userId, organization_id: orgId, role: 'owner' }, { context: SYSTEM_CTX })).id); + target.rowId = String((await ql.insert('sys_member', { user_id: target.userId, organization_id: orgId, role: 'member' }, { context: SYSTEM_CTX })).id); + // The other organization: the same two people, the grades reversed. + await ql.insert('sys_member', { user_id: owner.userId, organization_id: otherOrgId, role: 'member' }, { context: SYSTEM_CTX }); + await ql.insert('sys_member', { user_id: target.userId, organization_id: otherOrgId, role: 'owner' }, { context: SYSTEM_CTX }); + // The session's grade (`positions`) is the ACTIVE organization's, as in the + // console after the organization switcher: activate the one under test. + for (const who of [owner, target]) { + const res = await stack.apiAs(who.token, 'POST', '/auth/organization/set-active', { organizationId: orgId }); + expect(res.status, `activates the organization: ${await res.clone().text()}`).toBe(200); + } + + const meta = await stack.apiAs(owner.token, 'GET', '/meta/object/sys_member'); + expect(meta.status).toBe(200); + const body = (await meta.json()) as { item?: { actions?: ServedAction[] } }; + served = new Map((body.item?.actions ?? []).map((a) => [a.name, a])); + + const config = await stack.apiAs(owner.token, 'GET', '/auth/config'); + expect(config.status).toBe(200); + features = ((await config.json()) as { data: { features: Record } }).data.features; + }, 240_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('the served copy promises what the door does: the target becomes an owner, the caller keeps their role', () => { + const action = served.get('transfer_ownership'); + expect(action, 'transfer_ownership is served').toBeDefined(); + // The request is unchanged: better-auth's update-member-role, role owner. + expect(action!.target).toBe('/api/v1/auth/organization/update-member-role'); + expect(action!.bodyExtra).toEqual({ role: 'owner' }); + + const copy = [action!.label, action!.confirmText, action!.successMessage].join(' | '); + // No demotion and no transfer is promised anywhere in the copy. + expect(copy).not.toMatch(/demot/i); + expect(copy).not.toMatch(/transfer/i); + expect(action!.label).toBe('Make Owner'); + // The confirm names the outcome and the real step-down, by the step-down's own label. + expect(action!.confirmText).toContain('You stay an owner'); + expect(action!.confirmText).toContain(served.get('update_member_role')?.label ?? 'update_member_role is not served'); + }); + + it('the action, as an owner, makes the target an owner and leaves the caller an owner', async () => { + expect(await rolesIn(orgId)).toEqual({ owner: 'owner', target: 'member' }); + + const res = await updateMemberRole(owner.token, target.rowId, 'owner'); + expect(res.status, await res.clone().text()).toBe(200); + + // Both rows: two owners. The caller was not demoted. + expect(await rolesIn(orgId)).toEqual({ owner: 'owner', target: 'owner' }); + }, 60_000); + + it('Change Role is offered on the caller\'s own row, and steps the previous owner down to admin', async () => { + const changeRole = served.get('update_member_role'); + expect(changeRole?.target).toBe('/api/v1/auth/organization/update-member-role'); + + // The console's verdict for the caller on their OWN row, from the served + // predicate and the caller's served session. + const session = await stack.apiAs(owner.token, 'GET', '/auth/get-session'); + expect(session.status).toBe(200); + const user = ((await session.json()) as { user: Record }).user; + expect(user.positions as string[], 'the caller is an owner on the session face').toContain('org_owner'); + const [ownRow] = await findRows(ql, 'sys_member', { id: owner.rowId }, 1); + const verdict = celEngine.evaluate(changeRole!.visible as never, { record: ownRow, user: user as never, extra: { features } }); + if (!verdict.ok) throw new Error(`update_member_role faulted: ${JSON.stringify(verdict)}`); + expect(verdict.value, 'Change Role is offered on the caller\'s own row').toBe(true); + + const res = await updateMemberRole(owner.token, owner.rowId, 'admin'); + expect(res.status, await res.clone().text()).toBe(200); + + // Both rows: the previous owner is an admin, the new owner is the owner. + expect(await rolesIn(orgId)).toEqual({ owner: 'admin', target: 'owner' }); + }, 60_000); + + it('the vendor keeps one owner: the sole owner cannot step down the same way', async () => { + const res = await updateMemberRole(target.token, target.rowId, 'admin'); + expect(res.status).toBe(400); + expect(((await res.json()) as { code?: string }).code).toBe('YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER'); + expect(await rolesIn(orgId)).toEqual({ owner: 'admin', target: 'owner' }); + }, 60_000); + + it('the other organization\'s rows are untouched', async () => { + expect(await rolesIn(otherOrgId)).toEqual({ owner: 'member', target: 'owner' }); + }); +});