diff --git a/.changeset/24016-org-invite-action.md b/.changeset/24016-org-invite-action.md new file mode 100644 index 00000000000..e21bfe8b689 --- /dev/null +++ b/.changeset/24016-org-invite-action.md @@ -0,0 +1,12 @@ +--- +'@objectstack/platform-objects': patch +'@objectstack/spec': patch +--- + +`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. + +`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). + +`@objectstack/spec` books the new action in `PUBLIC_AUTH_FEATURES.organization.gatedInputs`, beside the other three invite mirrors. + +Clause-②: no 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 d9d24be3ddb..866ea2b5aae 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -569,6 +569,21 @@ export const enObjects: NonNullable = { } }, _actions: { + invite_user: { + label: "Invite User", + description: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation.", + successMessage: "Invitation sent to ${result.email}", + params: { + role: { + options: { + owner: "Owner", + admin: "Admin", + delegated_admin: "Delegated Admin", + member: "Member" + } + } + } + }, create_organization: { label: "Create Organization", successMessage: "Organization created" @@ -586,15 +601,15 @@ export const enObjects: NonNullable = { label: "Set Active", successMessage: "Active organization switched" }, - leave_organization: { - label: "Leave Organization", - confirmText: "Leave this organization? You will lose access to all of its resources.", - successMessage: "You have left the organization" - }, change_slug: { label: "Change Slug", description: "Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?", successMessage: "Organization slug changed" + }, + leave_organization: { + label: "Leave Organization", + confirmText: "Leave this organization? You will lose access to its environments and members.", + successMessage: "You have left the organization" } } }, 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 30dba97c22b..d7b292334b0 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 @@ -569,6 +569,21 @@ export const esESObjects: NonNullable = { } }, _actions: { + invite_user: { + label: "Invitar usuario", + 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.", + successMessage: "Invitación enviada a ${result.email}", + params: { + role: { + options: { + owner: "Propietario", + admin: "Administrador", + delegated_admin: "Administrador delegado", + member: "Miembro" + } + } + } + }, create_organization: { label: "Crear organización", successMessage: "Organización creada" @@ -586,15 +601,15 @@ export const esESObjects: NonNullable = { label: "Establecer como activa", successMessage: "Organización activa cambiada" }, - leave_organization: { - label: "Abandonar organización", - confirmText: "¿Abandonar esta organización? Perderá el acceso a todos sus recursos.", - successMessage: "Ha abandonado la organización" - }, change_slug: { label: "Cambiar slug", 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?", successMessage: "Slug de la organización cambiado" + }, + leave_organization: { + label: "Abandonar organización", + confirmText: "¿Abandonar esta organización? Perderá el acceso a sus entornos y miembros.", + successMessage: "Ha abandonado la organización" } } }, 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 b04c2b015d7..6200a908799 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 @@ -569,6 +569,21 @@ export const jaJPObjects: NonNullable = { } }, _actions: { + invite_user: { + label: "ユーザーを招待", + description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。", + successMessage: "${result.email} に招待を送信しました", + params: { + role: { + options: { + owner: "所有者", + admin: "管理者", + delegated_admin: "委任管理者", + member: "メンバー" + } + } + } + }, create_organization: { label: "組織を作成", successMessage: "組織を作成しました" @@ -586,15 +601,15 @@ export const jaJPObjects: NonNullable = { label: "アクティブに設定", successMessage: "アクティブ組織を切り替えました" }, - leave_organization: { - label: "組織から退出", - confirmText: "この組織から退出しますか?すべてのリソースへのアクセスを失います。", - successMessage: "組織から退出しました" - }, change_slug: { label: "スラッグを変更", description: "スラッグを変更すると、この組織のすべてのプラットフォームサブドメインが書き換えられ、旧スラッグは 90 日間確保されます。続行しますか?", successMessage: "組織のスラッグを変更しました" + }, + leave_organization: { + 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 0e45cfb4327..34af894436b 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 @@ -569,6 +569,21 @@ export const zhCNObjects: NonNullable = { } }, _actions: { + invite_user: { + label: "邀请用户", + description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。", + successMessage: "已向 ${result.email} 发送邀请", + params: { + role: { + options: { + owner: "所有者", + admin: "管理员", + delegated_admin: "受托管理员", + member: "成员" + } + } + } + }, create_organization: { label: "创建组织", successMessage: "组织已创建" @@ -586,15 +601,15 @@ export const zhCNObjects: NonNullable = { label: "设为当前", successMessage: "当前组织已切换" }, - leave_organization: { - label: "退出组织", - confirmText: "要退出该组织吗?退出后你将失去对其所有资源的访问权限。", - successMessage: "你已退出该组织" - }, change_slug: { label: "修改标识符", description: "重命名标识符会重写该组织所有平台子域名,旧标识符将保留占用 90 天。继续吗?", successMessage: "组织标识符已修改" + }, + leave_organization: { + label: "退出组织", + confirmText: "要退出该组织吗?退出后你将失去对其环境和成员的访问权限。", + successMessage: "你已退出该组织" } } }, diff --git a/packages/platform-objects/src/identity/invite-entry-toolbar.test.ts b/packages/platform-objects/src/identity/invite-entry-toolbar.test.ts index c9abdd72d70..1a017411343 100644 --- a/packages/platform-objects/src/identity/invite-entry-toolbar.test.ts +++ b/packages/platform-objects/src/identity/invite-entry-toolbar.test.ts @@ -39,6 +39,7 @@ import { describe, expect, it } from 'vitest'; import { SysInvitation } from './sys-invitation.object.js'; import { SysMember } from './sys-member.object.js'; +import { SysOrganization } from './sys-organization.object.js'; import { SysUser } from './sys-user.object.js'; const INVITE_ENDPOINT = '/api/v1/auth/organization/invite-member'; @@ -48,6 +49,7 @@ const OBJECTS_BY_NAME: Record = { sys_user: SysUser, sys_member: SysMember, sys_invitation: SysInvitation, + sys_organization: SysOrganization, }; interface AnyAction { @@ -82,6 +84,18 @@ const MIRRORS: Array<[string, unknown]> = [ ['sys_member', SysMember], ]; +/** + * Every declaration site: the three toolbar mirrors plus sys_organization's + * `record_header` copy (ruling 6228318638, B′ on #24034), which a + * `page:header` can name because it lives on the page's own object. Its + * placement is pinned in `sys-organization-invite-action.test.ts`; what the + * four must share — endpoint, gates, inputs — is pinned here. + */ +const ALL_MIRRORS: Array<[string, unknown]> = [ + ...MIRRORS, + ['sys_organization', SysOrganization], +]; + describe('invite_user — reachable from the default Members tab (#11544)', () => { it('is declared on sys_member, in the toolbar the Members tab renders', () => { // The regression itself: the whole defect was this action's ABSENCE from @@ -137,22 +151,25 @@ describe('invite_user — reachable from the default Members tab (#11544)', () = }); }); -describe('invite_user — the three mirrors agree (#11544)', () => { - it.each(MIRRORS)('%s dispatches the same endpoint from the same location', (_name, object) => { +describe('invite_user — the mirrors agree (#11544)', () => { + it.each(ALL_MIRRORS)('%s dispatches the same endpoint', (_name, object) => { const invite = action(object, 'invite_user'); expect(invite.type).toBe('api'); expect(invite.target).toBe(INVITE_ENDPOINT); - expect(invite.locations).toContain('list_toolbar'); }); - it.each(MIRRORS)('%s carries the same lowered `organization` capability gate', (_name, object) => { + it.each(MIRRORS)('%s offers it from the list toolbar', (_name, object) => { + expect(action(object, 'invite_user').locations).toContain('list_toolbar'); + }); + + it.each(ALL_MIRRORS)('%s carries the same lowered `organization` capability gate', (_name, object) => { // `requiresFeature: 'organization'` is authoring sugar — it is lowered to a // CEL predicate at ObjectSchema.create time and the sugar key does not // survive (pinned in platform-objects.test.ts), so the gate is read from // its lowered form. A mirror that lost the gate would render a button that // 404s wherever the org capability is off. // The grade gate composes AHEAD of it (`requiresMembershipReach: - // 'invite_member'`), so the three mirrors also agree on WHO is offered + // 'invite_member'`), so the mirrors also agree on WHO is offered // the button: owner, admin and delegated_admin — never a plain member. expect(action(object, 'invite_user').visible?.source).toBe( "('org_owner' in current_user.positions || 'org_admin' in current_user.positions" @@ -160,12 +177,12 @@ describe('invite_user — the three mirrors agree (#11544)', () => { ); }); - it.each(MIRRORS)('%s asks for the same two inputs, email and role', (_name, object) => { + it.each(ALL_MIRRORS)('%s asks for the same two inputs, email and role', (_name, object) => { const keys = (action(object, 'invite_user').params ?? []).map((p) => p.name ?? p.field); expect(keys).toEqual(['email', 'role']); }); - it.each(MIRRORS)('%s requires both of them', (_name, object) => { + it.each(ALL_MIRRORS)('%s requires both of them', (_name, object) => { // The endpoint has no default for either; an optional param here is a // dialog that submits an incomplete body and answers with a server error. for (const p of action(object, 'invite_user').params ?? []) { @@ -175,11 +192,11 @@ describe('invite_user — the three mirrors agree (#11544)', () => { }); describe('invite_user — every field-backed param resolves to a real field (#11544)', () => { - // THE load-bearing pin. Stated over all three mirrors rather than over the + // THE load-bearing pin. Stated over every mirror rather than over the // one that was wrong, because the defect is a property of the DECLARATION // SHAPE, not of sys_member: any future mirror of any action that copies a // `{ field }` param onto an object that lacks that field lands here. - it.each(MIRRORS)('%s', (name, object) => { + it.each(ALL_MIRRORS)('%s', (name, object) => { const params = action(object, 'invite_user').params ?? []; expect(params.length).toBeGreaterThan(0); for (const p of params) { diff --git a/packages/platform-objects/src/identity/invite-user-action-copy.test.ts b/packages/platform-objects/src/identity/invite-user-action-copy.test.ts index f1d8b8a9ca1..47cd105ac73 100644 --- a/packages/platform-objects/src/identity/invite-user-action-copy.test.ts +++ b/packages/platform-objects/src/identity/invite-user-action-copy.test.ts @@ -1,8 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // `invite_user`'s user-facing copy — the parameter dialog's subtitle and the -// success toast — on all three declaration sites (sys_user, sys_invitation, -// sys_member). +// success toast — on all four declaration sites (sys_user, sys_invitation, +// sys_member, sys_organization). // // What the console does with each key, measured on the pinned objectui: // @@ -29,6 +29,7 @@ import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js'; import { esESObjects } from '../apps/translations/es-ES.objects.generated.js'; import { SysInvitation } from './sys-invitation.object.js'; import { SysMember } from './sys-member.object.js'; +import { SysOrganization } from './sys-organization.object.js'; import { SysUser } from './sys-user.object.js'; interface CopyAction { @@ -44,6 +45,7 @@ const MIRRORS: Array<[string, unknown]> = [ ['sys_user', SysUser], ['sys_invitation', SysInvitation], ['sys_member', SysMember], + ['sys_organization', SysOrganization], ]; const TRANSLATED = [ @@ -75,8 +77,8 @@ describe('invite_user — declared copy', () => { expect(invite(object).successMessage).toContain(INVITEE); }); - it('the three mirrors say the same thing', () => { - // One action, three declaration sites: compared to EACH OTHER, so a mirror + it('the mirrors say the same thing', () => { + // One action, four declaration sites: compared to EACH OTHER, so a mirror // edited alone goes red without any literal copied into this file. const [first, ...rest] = MIRRORS.map(([, object]) => invite(object)); for (const other of rest) { diff --git a/packages/platform-objects/src/identity/sys-organization-invite-action.test.ts b/packages/platform-objects/src/identity/sys-organization-invite-action.test.ts new file mode 100644 index 00000000000..b35d7efa765 --- /dev/null +++ b/packages/platform-objects/src/identity/sys-organization-invite-action.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// sys_organization's record-header invite (ruling 6228318638, letter B′ on +// #24034, items 1 and 2). +// +// A `page:header`'s `actions` are ids resolved against the page object's OWN +// actions only, so an organization record page could not offer 邀请成员 while +// the invite lived on sys_user / sys_invitation / sys_member alone. The +// endpoint, gates and inputs the four mirrors share are pinned in +// `invite-entry-toolbar.test.ts`, the copy in `invite-user-action-copy.test.ts`; +// this file pins what only this copy has — where it sits in the header. +// +// How the console places record-header actions, measured on objectui `9753811` +// (`packages/components/src/renderers/layout/containers.tsx`, page:header): +// a STABLE sort by `order` (unset = 0), the first non-destructive +// `variant: 'primary'` action promoted to the one filled button, the first +// `maxVisible` (default 3) authored actions inline, the rest folded into the +// `⋯`, destructive ones in its last band. So "invite first, leave in the +// overflow" is decided by the two declared `order` values: with `leave` left +// at 0 it kept the third inline slot by registration order. +import { describe, expect, it } from 'vitest'; +import { SysOrganization } from './sys-organization.object.js'; + +interface HeaderAction { + name?: string; + variant?: string; + order?: number; + type?: string; + target?: string; + recordIdParam?: string; + locations?: string[]; +} + +const actions = (): HeaderAction[] => + ((SysOrganization as { actions?: HeaderAction[] }).actions) ?? []; + +function action(name: string): HeaderAction { + const found = actions().find((a) => a.name === name); + expect(found, `${name} is declared`).toBeDefined(); + return found as HeaderAction; +} + +/** The object's `record_header` actions, in the order the stable `order` sort gives them. */ +function headerOrder(): string[] { + return actions() + .filter((a) => (a.locations ?? []).includes('record_header')) + .map((a, i) => ({ a, i })) + .sort((x, y) => (x.a.order ?? 0) - (y.a.order ?? 0) || x.i - y.i) + .map(({ a }) => a.name as string); +} + +/** objectui page:header's default desktop inline budget. */ +const MAX_VISIBLE = 3; + +describe('sys_organization.invite_user — the record header (#24016)', () => { + it('is a record_header api action on the invite endpoint, bound to THIS organization', () => { + const invite = action('invite_user'); + expect(invite.locations).toContain('record_header'); + expect(invite.type).toBe('api'); + expect(invite.target).toBe('/api/v1/auth/organization/invite-member'); + // The row id rides as `organizationId`: without it better-auth falls back + // to the caller's ACTIVE org, so an admin on another org's page would + // invite into the wrong organization. + expect(invite.recordIdParam).toBe('organizationId'); + }); + + it('is the one primary action in the header', () => { + const primaries = actions() + .filter((a) => (a.locations ?? []).includes('record_header')) + .filter((a) => a.variant === 'primary') + .map((a) => a.name); + expect(primaries).toEqual(['invite_user']); + }); + + it('sorts first by its declared order', () => { + expect(action('invite_user').order).toBeLessThan(0); + expect(headerOrder()[0]).toBe('invite_user'); + }); +}); + +describe('sys_organization.leave_organization — out of the primary slot (#24016)', () => { + it('stays destructive', () => { + expect(action('leave_organization').variant).toBe('danger'); + }); + + it('sorts last, past the inline budget once the invite leads', () => { + const order = headerOrder(); + expect(order.length).toBeGreaterThan(MAX_VISIBLE); + expect(order[order.length - 1]).toBe('leave_organization'); + expect(order.indexOf('leave_organization')).toBeGreaterThanOrEqual(MAX_VISIBLE); + }); +}); diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index a06aa948895..919503b9313 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { INVITATION_ROLE_OPTIONS } from '@objectstack/spec/identity'; /** * sys_organization — System Organization Object @@ -137,6 +138,52 @@ export const SysOrganization = ObjectSchema.create({ successMessage: 'Active organization switched', refreshAfter: true, }, + { + // FOURTH mirror of `invite_user` (sys_user, sys_invitation, sys_member + // carry the other three — keep all four consistent), here because a + // `page:header`'s `actions` are ids resolved against the page object's + // OWN actions only (spec `component.zod.ts`, page:header), so the + // organization record page cannot offer an invite from a mirror on + // another object. Ruling 6228318638 (B′ on #24034): declared at + // `record_header`, primary, first by `order`. + // + // The record IS the organization, so its id rides as `organizationId` + // instead of better-auth's active-org fallback — an admin on another + // org's page invites into that org, not into whichever one is active. + name: 'invite_user', + label: 'Invite User', + // Same wording as the sys_user / sys_invitation / sys_member mirrors. + description: + 'Invite someone by email address. They join this organization with the chosen role when they accept the invitation.', + icon: 'user-plus', + variant: 'primary', + // Lower than every sibling (unset = 0), so the stable `order` sort puts + // it first in the record header. + order: -1, + locations: ['record_header'], + type: 'api', + target: '/api/v1/auth/organization/invite-member', + recordIdParam: 'organizationId', + // Same gates as the other three mirrors: the org CAPABILITY (not + // multi-org, ADR-0093 D9) and the grades that reach the endpoint + // (ADR-0108 D1) — owner, admin and delegated_admin. + requiresFeature: 'organization', + requiresMembershipReach: 'invite_member', + // `${result.email}`: the invitee's address from the invitation row + // invite-member answers (see sys_user's mirror). + successMessage: 'Invitation sent to ${result.email}', + refreshAfter: true, + params: [ + // ⚠️ Neither `email` nor `role` is a field of sys_organization, so + // both params name their owner via `objectOverride` — an unresolvable + // field-backed param degrades to an unlabelled text input with + // nothing red anywhere (see sys_member's mirror). Otherwise the shape + // is sys_invitation's: both required, and the role picker offers only + // what this caller may grant ([ADR-0105 D8] `INVITATION_ROLE_OPTIONS`). + { field: 'email', objectOverride: 'sys_invitation', required: true }, + { field: 'role', objectOverride: 'sys_invitation', required: true, options: [...INVITATION_ROLE_OPTIONS] }, + ], + }, { // Current user leaves the org. Distinct from `delete_organization` // (admin-only, destroys the org) — `leave` only removes the caller's @@ -145,13 +192,21 @@ export const SysOrganization = ObjectSchema.create({ label: 'Leave Organization', icon: 'log-out', variant: 'danger', + // Higher than every sibling (unset = 0), so it sorts LAST: with + // `invite_user` leading, the header's three inline slots go to the + // invite, `set_active_organization` and `change_slug`, and this one + // folds into the `⋯` overflow's destructive band (objectui's + // page:header split, default `maxVisible` 3). Without it, registration + // order kept it inline as the third button. + order: 1, mode: 'custom', locations: ['list_item', 'record_header'], type: 'api', target: '/api/v1/auth/organization/leave', recordIdParam: 'organizationId', requiresFeature: 'multiOrgEnabled', - confirmText: 'Leave this organization? You will lose access to all of its resources.', + // Names what is lost (#24021's copy half, ruling 6228318638 item 2). + confirmText: 'Leave this organization? You will lose access to its environments and members.', successMessage: 'You have left the organization', refreshAfter: true, }, diff --git a/packages/spec/src/kernel/public-auth-features.ts b/packages/spec/src/kernel/public-auth-features.ts index a299e181e1e..f99b1fb90ce 100644 --- a/packages/spec/src/kernel/public-auth-features.ts +++ b/packages/spec/src/kernel/public-auth-features.ts @@ -121,6 +121,10 @@ export const PUBLIC_AUTH_FEATURES = { 'sys_team.actions.remove_team', 'sys_team_member.actions.add_team_member', 'sys_team_member.actions.remove_team_member', + // Fourth mirror of the email-invite entry, at the organization record + // page's `record_header` — a `page:header` resolves ids against its own + // object's actions only (ruling 6228318638, B′ on #24034). + 'sys_organization.actions.invite_user', ], notes: 'Org CAPABILITY gate, not multi-org (cloud ADR-0081 D1).', },