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
19 changes: 19 additions & 0 deletions .changeset/24273-transfer-ownership-honest.md
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -688,9 +688,9 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
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"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -688,9 +688,9 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
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"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -688,9 +688,9 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
successMessage: "メンバーを削除しました"
},
transfer_ownership: {
label: "所有権の移譲",
confirmText: "この組織の所有権を選択したメンバーに移譲しますか?あなたは管理者に降格され、所有者のみの権限を失います。",
successMessage: "所有権を移譲しました"
label: "所有者にする",
confirmText: "選択したメンバーをこの組織の所有者にしますか?このメンバーには、組織の削除を含む所有者のすべての権限が付与されます。あなたも引き続き所有者です。退く場合は、自分のメンバーシップで「ロールを変更」を使用してください。",
successMessage: "メンバーが所有者になりました"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -688,9 +688,9 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
successMessage: "成员已移除"
},
transfer_ownership: {
label: "转移所有权",
confirmText: "确定要将该组织的所有权转移给所选成员吗?您将被降级为管理员,并失去仅所有者拥有的权限。",
successMessage: "已转移所有权"
label: "设为所有者",
confirmText: "确定要将所选成员设为该组织的所有者吗?对方将获得所有者的全部权限,包括删除该组织。您仍是所有者;如需卸任,请在您自己的成员记录上使用“更改角色”。",
successMessage: "该成员现已成为所有者"
}
}
},
Expand Down
25 changes: 16 additions & 9 deletions packages/platform-objects/src/identity/sys-member.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
},
],
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, { _actions?: Record<string, CopyAction> }>)?.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 });
});
});
Loading
Loading