Skip to content

Commit b0a1df0

Browse files
feat(platform-objects): sys_organization declares a record_header invite action; leave_organization folds to the overflow and names what is lost (#24216)
Fixes #24016 #24021's copy half rides here (ruling 6228318638 item 2); that card's record-scope key is not added. Clause-②: no ## What `sys_organization` declares the invite action ruling 6228318638 (letter B′ on #24034, item 1) names, so an organization record page's `page:header` can list it. A `page:header` resolves action ids against its own object only. - **`invite_user` at `record_header`**: `type: 'api'`, `target: '/api/v1/auth/organization/invite-member'`, `recordIdParam: 'organizationId'`, `requiresFeature: 'organization'`, `requiresMembershipReach: 'invite_member'`, `variant: 'primary'`, `order: -1`. Its params are `email` and `role`, both required, with the role picker on `INVITATION_ROLE_OPTIONS`, as `sys_invitation.invite_user` declares them. Its description and success message match the other three mirrors (`sys_user`, `sys_invitation`, `sys_member`). - **`leave_organization`** keeps `variant: 'danger'` and gains `order: 1`. Its `confirmText` now names what is lost: "Leave this organization? You will lose access to its environments and members." The zh-CN, ja-JP and es-ES bundles carry translated text. - No new spec key. ## Measurements behind the choices - **H1, the params.** `email` and `role` are not fields of `sys_organization`. A field-backed param resolves against the action's own object unless `objectOverride` names another. So both params carry `objectOverride: 'sys_invitation'`, the device `sys_member`'s mirror already uses for `email`. Apart from that the shape is `sys_invitation`'s (required, same options). The existing "every field-backed param resolves to a real field" pin now covers all four mirrors. - **H2, the gates.** Both keys are declared on `ActionSchema` (`requiresFeature`, `requiresMembershipReach` in `packages/spec/src/ui/action.zod.ts`) and are already used by all three mirrors. The lowered `visible` matches theirs byte for byte; that is pinned. - **H3, the order.** I read objectui `9753811`'s `page:header` renderer (`packages/components/src/renderers/layout/containers.tsx`). It stable-sorts by `order` (unset = 0) and promotes the first non-destructive `variant: 'primary'` action. Then the first `maxVisible` authored actions (default 3) go inline and the rest fold into the `⋯`, with destructive ones in its last band. With only the invite added, the row was invite, set_active, **leave**, and `change_slug` folded. So `leave_organization` stayed inline as the third button. For it to "fall to the overflow" it needs a declared position: `order: 1` sorts it last, so the inline row is invite, set_active, change_slug and leave folds. This reuses the spec's existing `order`, so no new key. - **H4/H5, the bundles.** `en.objects.generated.ts` is rewritten from source on every extract. The translated locales keep their values in merge mode, so the new leaves were seeded in English by `pnpm i18n:extract`. I set them to the sibling `sys_invitation.invite_user` translations and translated the new `leave_organization.confirmText`, then re-ran the extract. It is idempotent and drops the seeded source-hash entries. `pnpm check:i18n`: all 10 packages in sync. ## Outside the claim's file surface `packages/spec/src/kernel/public-auth-features.ts` gains one row: `sys_organization.actions.invite_user` in `PUBLIC_AUTH_FEATURES.organization.gatedInputs`. This is a ledger entry, not a key. `platform-objects/src/feature-gate-guard.test.ts` requires every `features.*` gate in the objects to be booked there, and it went red without the row. The ruled `requiresFeature: 'organization'` cannot land without it. `pnpm --filter @objectstack/spec check:generated` reports all 14 artifacts up to date, and the changeset adds a `@objectstack/spec` patch. ## Tests - New `sys-organization-invite-action.test.ts` pins: - the record-header placement: location, endpoint, `recordIdParam`; - the invite is the only primary action and sorts first; - `leave_organization` stays `danger` and sorts past the inline budget. - `invite-entry-toolbar.test.ts` and `invite-user-action-copy.test.ts` now include `sys_organization` as a fourth mirror for endpoint, gates, inputs, param resolvability, copy, translated bundles and served metadata. The list-toolbar location pin stays on the three toolbar mirrors. - Ablation: deleting `leave_organization`'s `order: 1` through `scripts/ablation-replace.mjs` landed (anchor 1 to 0, blob `919503b9` to `70b55e52`). The test "sorts last, past the inline budget once the invite leads" went red (1 failed, 4 passed). The file was restored to the HEAD blob and `git diff HEAD` was empty. - `pnpm --filter @objectstack/platform-objects test`: 74 files, 1162 tests passed. `typecheck`: exit 0. `src/kernel/public-auth-features.test.ts`: 24 passed. All on `9aa48a7e5`, after merging `origin/main`, which brought #24200 and #24204; the bundles regenerated byte-identical. - Local gates on `9aa48a7e5`: `node scripts/pm/dispatch-gates.mjs --commands` derives 86 commands. All 86 exited 0, and `--ran` reports "86 run, 0 NOT-MEASURED (a DERIVED zero)". ## Acceptance notes - `leave_organization` folds only when the header has more than 3 visible authored actions. That is the case for an admin in multi-org: invite, set_active, change_slug, leave. A plain member does not see the invite, so leave stays inline as a red outline button. It is never the primary (objectui#12764). Moving it to the overflow unconditionally would take `component: 'action:menu'`. I did not do that: the ruling speaks in terms of order, and cloud#2910 overrides order and variant per its own posture (ruling item 5). - The invite is gated on the `organization` capability, as the ruling says, while its record-header siblings use `multiOrgEnabled`. In single-org mode the invite is the only header action. --- _Generated by [Claude Code](https://claude.ai/code/session_01Eey1448sFJ5Z95gbXZh2tG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b036ac5 commit b0a1df0

10 files changed

Lines changed: 276 additions & 34 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
`sys_organization` now declares an `invite_user` action at `record_header`, so an organization record page can offer "Invite User" as its primary header button. It calls `/api/v1/auth/organization/invite-member` with the record's id as `organizationId`, asks for `email` and `role` (the role picker offers only the roles the caller may grant), and is gated like the other three invite mirrors: the `organization` capability and the owner / admin / delegated_admin grades. Its `order: -1` puts it first in the header.
7+
8+
`leave_organization` keeps `variant: 'danger'` and now declares `order: 1`, so it sorts last among the header actions and folds into the `⋯` overflow menu instead of taking the third inline slot. Its confirmation now names what is lost: the organization's environments and members (en, zh-CN, ja-JP and es-ES).
9+
10+
`@objectstack/spec` books the new action in `PUBLIC_AUTH_FEATURES.organization.gatedInputs`, beside the other three invite mirrors.
11+
12+
Clause-②: no

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,21 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
569569
}
570570
},
571571
_actions: {
572+
invite_user: {
573+
label: "Invite User",
574+
description: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation.",
575+
successMessage: "Invitation sent to ${result.email}",
576+
params: {
577+
role: {
578+
options: {
579+
owner: "Owner",
580+
admin: "Admin",
581+
delegated_admin: "Delegated Admin",
582+
member: "Member"
583+
}
584+
}
585+
}
586+
},
572587
create_organization: {
573588
label: "Create Organization",
574589
successMessage: "Organization created"
@@ -586,15 +601,15 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
586601
label: "Set Active",
587602
successMessage: "Active organization switched"
588603
},
589-
leave_organization: {
590-
label: "Leave Organization",
591-
confirmText: "Leave this organization? You will lose access to all of its resources.",
592-
successMessage: "You have left the organization"
593-
},
594604
change_slug: {
595605
label: "Change Slug",
596606
description: "Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?",
597607
successMessage: "Organization slug changed"
608+
},
609+
leave_organization: {
610+
label: "Leave Organization",
611+
confirmText: "Leave this organization? You will lose access to its environments and members.",
612+
successMessage: "You have left the organization"
598613
}
599614
}
600615
},

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,21 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
569569
}
570570
},
571571
_actions: {
572+
invite_user: {
573+
label: "Invitar usuario",
574+
description: "Invita a alguien por su dirección de correo electrónico. Se une a esta organización con el rol elegido cuando acepta la invitación.",
575+
successMessage: "Invitación enviada a ${result.email}",
576+
params: {
577+
role: {
578+
options: {
579+
owner: "Propietario",
580+
admin: "Administrador",
581+
delegated_admin: "Administrador delegado",
582+
member: "Miembro"
583+
}
584+
}
585+
}
586+
},
572587
create_organization: {
573588
label: "Crear organización",
574589
successMessage: "Organización creada"
@@ -586,15 +601,15 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
586601
label: "Establecer como activa",
587602
successMessage: "Organización activa cambiada"
588603
},
589-
leave_organization: {
590-
label: "Abandonar organización",
591-
confirmText: "¿Abandonar esta organización? Perderá el acceso a todos sus recursos.",
592-
successMessage: "Ha abandonado la organización"
593-
},
594604
change_slug: {
595605
label: "Cambiar slug",
596606
description: "Renombrar el slug reescribe todos los subdominios de la plataforma para esta organización y reserva el slug anterior durante 90 días. ¿Continuar?",
597607
successMessage: "Slug de la organización cambiado"
608+
},
609+
leave_organization: {
610+
label: "Abandonar organización",
611+
confirmText: "¿Abandonar esta organización? Perderá el acceso a sus entornos y miembros.",
612+
successMessage: "Ha abandonado la organización"
598613
}
599614
}
600615
},

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,21 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
569569
}
570570
},
571571
_actions: {
572+
invite_user: {
573+
label: "ユーザーを招待",
574+
description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。",
575+
successMessage: "${result.email} に招待を送信しました",
576+
params: {
577+
role: {
578+
options: {
579+
owner: "所有者",
580+
admin: "管理者",
581+
delegated_admin: "委任管理者",
582+
member: "メンバー"
583+
}
584+
}
585+
}
586+
},
572587
create_organization: {
573588
label: "組織を作成",
574589
successMessage: "組織を作成しました"
@@ -586,15 +601,15 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
586601
label: "アクティブに設定",
587602
successMessage: "アクティブ組織を切り替えました"
588603
},
589-
leave_organization: {
590-
label: "組織から退出",
591-
confirmText: "この組織から退出しますか?すべてのリソースへのアクセスを失います。",
592-
successMessage: "組織から退出しました"
593-
},
594604
change_slug: {
595605
label: "スラッグを変更",
596606
description: "スラッグを変更すると、この組織のすべてのプラットフォームサブドメインが書き換えられ、旧スラッグは 90 日間確保されます。続行しますか?",
597607
successMessage: "組織のスラッグを変更しました"
608+
},
609+
leave_organization: {
610+
label: "組織から退出",
611+
confirmText: "この組織から退出しますか?この組織の環境とメンバーへのアクセスを失います。",
612+
successMessage: "組織から退出しました"
598613
}
599614
}
600615
},

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,21 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
569569
}
570570
},
571571
_actions: {
572+
invite_user: {
573+
label: "邀请用户",
574+
description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。",
575+
successMessage: "已向 ${result.email} 发送邀请",
576+
params: {
577+
role: {
578+
options: {
579+
owner: "所有者",
580+
admin: "管理员",
581+
delegated_admin: "受托管理员",
582+
member: "成员"
583+
}
584+
}
585+
}
586+
},
572587
create_organization: {
573588
label: "创建组织",
574589
successMessage: "组织已创建"
@@ -586,15 +601,15 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
586601
label: "设为当前",
587602
successMessage: "当前组织已切换"
588603
},
589-
leave_organization: {
590-
label: "退出组织",
591-
confirmText: "要退出该组织吗?退出后你将失去对其所有资源的访问权限。",
592-
successMessage: "你已退出该组织"
593-
},
594604
change_slug: {
595605
label: "修改标识符",
596606
description: "重命名标识符会重写该组织所有平台子域名,旧标识符将保留占用 90 天。继续吗?",
597607
successMessage: "组织标识符已修改"
608+
},
609+
leave_organization: {
610+
label: "退出组织",
611+
confirmText: "要退出该组织吗?退出后你将失去对其环境和成员的访问权限。",
612+
successMessage: "你已退出该组织"
598613
}
599614
}
600615
},

‎packages/platform-objects/src/identity/invite-entry-toolbar.test.ts‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import { describe, expect, it } from 'vitest';
4040
import { SysInvitation } from './sys-invitation.object.js';
4141
import { SysMember } from './sys-member.object.js';
42+
import { SysOrganization } from './sys-organization.object.js';
4243
import { SysUser } from './sys-user.object.js';
4344

4445
const INVITE_ENDPOINT = '/api/v1/auth/organization/invite-member';
@@ -48,6 +49,7 @@ const OBJECTS_BY_NAME: Record<string, unknown> = {
4849
sys_user: SysUser,
4950
sys_member: SysMember,
5051
sys_invitation: SysInvitation,
52+
sys_organization: SysOrganization,
5153
};
5254

5355
interface AnyAction {
@@ -82,6 +84,18 @@ const MIRRORS: Array<[string, unknown]> = [
8284
['sys_member', SysMember],
8385
];
8486

87+
/**
88+
* Every declaration site: the three toolbar mirrors plus sys_organization's
89+
* `record_header` copy (ruling 6228318638, B′ on #24034), which a
90+
* `page:header` can name because it lives on the page's own object. Its
91+
* placement is pinned in `sys-organization-invite-action.test.ts`; what the
92+
* four must share — endpoint, gates, inputs — is pinned here.
93+
*/
94+
const ALL_MIRRORS: Array<[string, unknown]> = [
95+
...MIRRORS,
96+
['sys_organization', SysOrganization],
97+
];
98+
8599
describe('invite_user — reachable from the default Members tab (#11544)', () => {
86100
it('is declared on sys_member, in the toolbar the Members tab renders', () => {
87101
// The regression itself: the whole defect was this action's ABSENCE from
@@ -137,35 +151,38 @@ describe('invite_user — reachable from the default Members tab (#11544)', () =
137151
});
138152
});
139153

140-
describe('invite_user — the three mirrors agree (#11544)', () => {
141-
it.each(MIRRORS)('%s dispatches the same endpoint from the same location', (_name, object) => {
154+
describe('invite_user — the mirrors agree (#11544)', () => {
155+
it.each(ALL_MIRRORS)('%s dispatches the same endpoint', (_name, object) => {
142156
const invite = action(object, 'invite_user');
143157
expect(invite.type).toBe('api');
144158
expect(invite.target).toBe(INVITE_ENDPOINT);
145-
expect(invite.locations).toContain('list_toolbar');
146159
});
147160

148-
it.each(MIRRORS)('%s carries the same lowered `organization` capability gate', (_name, object) => {
161+
it.each(MIRRORS)('%s offers it from the list toolbar', (_name, object) => {
162+
expect(action(object, 'invite_user').locations).toContain('list_toolbar');
163+
});
164+
165+
it.each(ALL_MIRRORS)('%s carries the same lowered `organization` capability gate', (_name, object) => {
149166
// `requiresFeature: 'organization'` is authoring sugar — it is lowered to a
150167
// CEL predicate at ObjectSchema.create time and the sugar key does not
151168
// survive (pinned in platform-objects.test.ts), so the gate is read from
152169
// its lowered form. A mirror that lost the gate would render a button that
153170
// 404s wherever the org capability is off.
154171
// The grade gate composes AHEAD of it (`requiresMembershipReach:
155-
// 'invite_member'`), so the three mirrors also agree on WHO is offered
172+
// 'invite_member'`), so the mirrors also agree on WHO is offered
156173
// the button: owner, admin and delegated_admin — never a plain member.
157174
expect(action(object, 'invite_user').visible?.source).toBe(
158175
"('org_owner' in current_user.positions || 'org_admin' in current_user.positions"
159176
+ " || 'delegated_admin' in current_user.positions) && features.organization != false",
160177
);
161178
});
162179

163-
it.each(MIRRORS)('%s asks for the same two inputs, email and role', (_name, object) => {
180+
it.each(ALL_MIRRORS)('%s asks for the same two inputs, email and role', (_name, object) => {
164181
const keys = (action(object, 'invite_user').params ?? []).map((p) => p.name ?? p.field);
165182
expect(keys).toEqual(['email', 'role']);
166183
});
167184

168-
it.each(MIRRORS)('%s requires both of them', (_name, object) => {
185+
it.each(ALL_MIRRORS)('%s requires both of them', (_name, object) => {
169186
// The endpoint has no default for either; an optional param here is a
170187
// dialog that submits an incomplete body and answers with a server error.
171188
for (const p of action(object, 'invite_user').params ?? []) {
@@ -175,11 +192,11 @@ describe('invite_user — the three mirrors agree (#11544)', () => {
175192
});
176193

177194
describe('invite_user — every field-backed param resolves to a real field (#11544)', () => {
178-
// THE load-bearing pin. Stated over all three mirrors rather than over the
195+
// THE load-bearing pin. Stated over every mirror rather than over the
179196
// one that was wrong, because the defect is a property of the DECLARATION
180197
// SHAPE, not of sys_member: any future mirror of any action that copies a
181198
// `{ field }` param onto an object that lacks that field lands here.
182-
it.each(MIRRORS)('%s', (name, object) => {
199+
it.each(ALL_MIRRORS)('%s', (name, object) => {
183200
const params = action(object, 'invite_user').params ?? [];
184201
expect(params.length).toBeGreaterThan(0);
185202
for (const p of params) {

‎packages/platform-objects/src/identity/invite-user-action-copy.test.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
//
33
// `invite_user`'s user-facing copy — the parameter dialog's subtitle and the
4-
// success toast — on all three declaration sites (sys_user, sys_invitation,
5-
// sys_member).
4+
// success toast — on all four declaration sites (sys_user, sys_invitation,
5+
// sys_member, sys_organization).
66
//
77
// What the console does with each key, measured on the pinned objectui:
88
//
@@ -29,6 +29,7 @@ import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js';
2929
import { esESObjects } from '../apps/translations/es-ES.objects.generated.js';
3030
import { SysInvitation } from './sys-invitation.object.js';
3131
import { SysMember } from './sys-member.object.js';
32+
import { SysOrganization } from './sys-organization.object.js';
3233
import { SysUser } from './sys-user.object.js';
3334

3435
interface CopyAction {
@@ -44,6 +45,7 @@ const MIRRORS: Array<[string, unknown]> = [
4445
['sys_user', SysUser],
4546
['sys_invitation', SysInvitation],
4647
['sys_member', SysMember],
48+
['sys_organization', SysOrganization],
4749
];
4850

4951
const TRANSLATED = [
@@ -75,8 +77,8 @@ describe('invite_user — declared copy', () => {
7577
expect(invite(object).successMessage).toContain(INVITEE);
7678
});
7779

78-
it('the three mirrors say the same thing', () => {
79-
// One action, three declaration sites: compared to EACH OTHER, so a mirror
80+
it('the mirrors say the same thing', () => {
81+
// One action, four declaration sites: compared to EACH OTHER, so a mirror
8082
// edited alone goes red without any literal copied into this file.
8183
const [first, ...rest] = MIRRORS.map(([, object]) => invite(object));
8284
for (const other of rest) {

0 commit comments

Comments
 (0)