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
12 changes: 12 additions & 0 deletions .changeset/24016-org-invite-action.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,21 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
}
},
_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"
Expand All @@ -586,15 +601,15 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
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"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,21 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
}
},
_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"
Expand All @@ -586,15 +601,15 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
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"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,21 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
}
},
_actions: {
invite_user: {
label: "ユーザーを招待",
description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。",
successMessage: "${result.email} に招待を送信しました",
params: {
role: {
options: {
owner: "所有者",
admin: "管理者",
delegated_admin: "委任管理者",
member: "メンバー"
}
}
}
},
create_organization: {
label: "組織を作成",
successMessage: "組織を作成しました"
Expand All @@ -586,15 +601,15 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
label: "アクティブに設定",
successMessage: "アクティブ組織を切り替えました"
},
leave_organization: {
label: "組織から退出",
confirmText: "この組織から退出しますか?すべてのリソースへのアクセスを失います。",
successMessage: "組織から退出しました"
},
change_slug: {
label: "スラッグを変更",
description: "スラッグを変更すると、この組織のすべてのプラットフォームサブドメインが書き換えられ、旧スラッグは 90 日間確保されます。続行しますか?",
successMessage: "組織のスラッグを変更しました"
},
leave_organization: {
label: "組織から退出",
confirmText: "この組織から退出しますか?この組織の環境とメンバーへのアクセスを失います。",
successMessage: "組織から退出しました"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,21 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
}
},
_actions: {
invite_user: {
label: "邀请用户",
description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。",
successMessage: "已向 ${result.email} 发送邀请",
params: {
role: {
options: {
owner: "所有者",
admin: "管理员",
delegated_admin: "受托管理员",
member: "成员"
}
}
}
},
create_organization: {
label: "创建组织",
successMessage: "组织已创建"
Expand All @@ -586,15 +601,15 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
label: "设为当前",
successMessage: "当前组织已切换"
},
leave_organization: {
label: "退出组织",
confirmText: "要退出该组织吗?退出后你将失去对其所有资源的访问权限。",
successMessage: "你已退出该组织"
},
change_slug: {
label: "修改标识符",
description: "重命名标识符会重写该组织所有平台子域名,旧标识符将保留占用 90 天。继续吗?",
successMessage: "组织标识符已修改"
},
leave_organization: {
label: "退出组织",
confirmText: "要退出该组织吗?退出后你将失去对其环境和成员的访问权限。",
successMessage: "你已退出该组织"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -48,6 +49,7 @@ const OBJECTS_BY_NAME: Record<string, unknown> = {
sys_user: SysUser,
sys_member: SysMember,
sys_invitation: SysInvitation,
sys_organization: SysOrganization,
};

interface AnyAction {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -137,35 +151,38 @@ 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"
+ " || 'delegated_admin' in current_user.positions) && features.organization != false",
);
});

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 ?? []) {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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:
//
Expand All @@ -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 {
Expand All @@ -44,6 +45,7 @@ const MIRRORS: Array<[string, unknown]> = [
['sys_user', SysUser],
['sys_invitation', SysInvitation],
['sys_member', SysMember],
['sys_organization', SysOrganization],
];

const TRANSLATED = [
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading