diff --git a/.changeset/25337-spec-book-team-add-member.md b/.changeset/25337-spec-book-team-add-member.md new file mode 100644 index 00000000000..f89baca4c99 --- /dev/null +++ b/.changeset/25337-spec-book-team-add-member.md @@ -0,0 +1,12 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): book the team-side Add Member action under the `organization` feature gate + +Clause-②: no +Metadata-protocol: none + +`PUBLIC_AUTH_FEATURES.organization.gatedInputs` now lists `sys_team.actions.add_team_member`, right after `sys_team.actions.remove_team`. `@objectstack/platform-objects` declares that action on `sys_team` (Add Member opened from a team, which binds the team) with `requiresFeature: 'organization'`, and the booking keeps the two sides agreeing: every action gated on a feature is listed under it, and every listed input carries the gate. The membership-list copy, `sys_team_member.actions.add_team_member`, keeps its own row unchanged. + +What to do: nothing. No key, value or predicate changes; the list gains one entry. diff --git a/.changeset/25337-team-add-member-binds-team.md b/.changeset/25337-team-add-member-binds-team.md new file mode 100644 index 00000000000..613eb84832e --- /dev/null +++ b/.changeset/25337-team-add-member-binds-team.md @@ -0,0 +1,10 @@ +--- +'@objectstack/platform-objects': patch +--- + +feat: Add Member opened from a team now binds that team, so the dialog asks only for the user + +Clause-②: no +Metadata-protocol: none + +In Setup → Teams, Add Member lived only on the team-membership list's toolbar. No team is in hand there, so the dialog asked the administrator to pick the team again. `sys_team` now also declares `add_team_member`, on the team row (`list_item`) and the team record header (`record_header`). It calls the same `POST /api/v1/auth/organization/add-team-member` endpoint behind the same gates (the `organization` feature and the `add_team_member` membership grade). The team's id rides as `teamId` from the row it is opened on, and the dialog asks only for the user. The membership-list copy is unchanged and still asks for both. 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 bfbce4b4e01..70a6366ae41 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -869,6 +869,10 @@ export const enObjects: NonNullable = { label: "Edit Team", successMessage: "Team updated" }, + add_team_member: { + label: "Add Member", + successMessage: "Team member added" + }, remove_team: { label: "Delete Team", confirmText: "Delete this team? Members will lose any team-scoped access. This cannot be undone.", 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 49f8eb07ed0..3ce4ab55363 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 @@ -869,6 +869,10 @@ export const esESObjects: NonNullable = { label: "Editar equipo", successMessage: "Equipo actualizado" }, + add_team_member: { + label: "Añadir miembro", + successMessage: "Miembro del equipo añadido" + }, remove_team: { label: "Eliminar equipo", confirmText: "¿Eliminar este equipo? Los miembros perderán cualquier acceso con ámbito de equipo. Esta acción no se puede deshacer.", 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 46616a89ce1..fe06b427cc2 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 @@ -869,6 +869,10 @@ export const jaJPObjects: NonNullable = { label: "チームを編集", successMessage: "チームを更新しました" }, + add_team_member: { + label: "メンバーを追加", + successMessage: "チームメンバーを追加しました" + }, remove_team: { label: "チームを削除", confirmText: "このチームを削除しますか?メンバーはチームスコープのアクセスを失います。この操作は取り消せません。", 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 b8febdf3fd8..942d1696125 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 @@ -869,6 +869,10 @@ export const zhCNObjects: NonNullable = { label: "编辑团队", successMessage: "团队已更新" }, + add_team_member: { + label: "添加成员", + successMessage: "团队成员已添加" + }, remove_team: { label: "删除团队", confirmText: "要删除该团队吗?成员将失去所有团队范围内的访问权限。此操作无法撤销。", diff --git a/packages/platform-objects/src/identity/sys-team-add-member-action.test.ts b/packages/platform-objects/src/identity/sys-team-add-member-action.test.ts new file mode 100644 index 00000000000..2b265b15a9e --- /dev/null +++ b/packages/platform-objects/src/identity/sys-team-add-member-action.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Add Member opened from a team carries that team. +// +// `sys_team_member.add_team_member` sits on the membership list's toolbar, +// where no team is in hand, so its dialog asks for the team as well as the +// user. The team-side copy on `sys_team` binds the team from the row it is +// opened on (`recordIdParam: 'teamId'`), so the dialog asks for the user +// alone. Both copies call the same endpoint behind the same gates; this file +// pins that the two agree on everything except where the team comes from. +import { describe, expect, it } from 'vitest'; +import { SysTeam } from './sys-team.object.js'; +import { SysTeamMember } from './sys-team-member.object.js'; + +interface ParamLike { + name?: string; + field?: string; + objectOverride?: string; + required?: boolean; + defaultFromRow?: boolean; +} + +interface ActionLike { + name?: string; + type?: string; + target?: string; + recordIdParam?: string; + locations?: string[]; + visible?: { source?: string }; + params?: ParamLike[]; +} + +function action(object: { actions?: unknown[] }, name: string): ActionLike { + const found = ((object.actions ?? []) as ActionLike[]).find((a) => a.name === name); + expect(found, `${name} is declared`).toBeDefined(); + return found as ActionLike; +} + +describe('sys_team.add_team_member — Add Member opened from a team', () => { + const fromTeam = action(SysTeam, 'add_team_member'); + const fromList = action(SysTeamMember, 'add_team_member'); + + it('is offered on the team row and the team record header', () => { + expect(fromTeam.locations).toEqual(['list_item', 'record_header']); + }); + + it('binds the team from the row it is opened on, as teamId', () => { + expect(fromTeam.recordIdParam).toBe('teamId'); + // The team is not asked for: no dialog param submits teamId. + expect((fromTeam.params ?? []).map((p) => p.name)).not.toContain('teamId'); + }); + + it('asks only for the user, through the membership row\'s sys_user lookup', () => { + expect(fromTeam.params).toEqual([ + { name: 'userId', field: 'user_id', objectOverride: 'sys_team_member', required: true }, + ]); + }); + + it('calls the same endpoint behind the same gate as the membership-list copy', () => { + expect(fromTeam.type).toBe('api'); + expect(fromTeam.target).toBe('/api/v1/auth/organization/add-team-member'); + expect(fromTeam.target).toBe(fromList.target); + expect(fromTeam.visible?.source).toBeDefined(); + expect(fromTeam.visible?.source).toBe(fromList.visible?.source); + }); + + it('leaves the membership-list copy asking for the team, where none is in hand', () => { + expect(fromList.locations).toEqual(['list_toolbar']); + expect(fromList.recordIdParam).toBeUndefined(); + expect((fromList.params ?? []).map((p) => p.name)).toEqual(['teamId', 'userId']); + }); +}); diff --git a/packages/platform-objects/src/identity/sys-team.object.ts b/packages/platform-objects/src/identity/sys-team.object.ts index 3a095490964..24b6d67fdd6 100644 --- a/packages/platform-objects/src/identity/sys-team.object.ts +++ b/packages/platform-objects/src/identity/sys-team.object.ts @@ -78,6 +78,31 @@ export const SysTeam = ObjectSchema.create({ { field: 'name', required: true, defaultFromRow: true }, ], }, + { + // Better-auth: `organization/add-team-member { teamId, userId }` — the + // team-side mirror of `sys_team_member.add_team_member`. That one sits on + // the membership list's toolbar, where no team is in hand, so it asks for + // one. Opened from a team, the record IS the team: its id rides as + // `teamId` (the `sys_organization.invite_user` shape), and the dialog + // asks only for the user. + name: 'add_team_member', + label: 'Add Member', + icon: 'user-plus', + locations: ['list_item', 'record_header'], + type: 'api', + target: '/api/v1/auth/organization/add-team-member', + recordIdParam: 'teamId', + // Same gates as the membership-list copy. + requiresFeature: 'organization', + requiresMembershipReach: 'add_team_member', + successMessage: 'Team member added', + refreshAfter: true, + params: [ + // `user_id` is not a field of sys_team, so the param names its owner + // through `objectOverride` — the sys_user lookup on the membership row. + { name: 'userId', field: 'user_id', objectOverride: 'sys_team_member', required: true }, + ], + }, { // Better-auth: `organization/remove-team { teamId, organizationId? }`. // organizationId defaults to the caller's active org when omitted. diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index b60f2e1ba07..f037b3b813c 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -731,6 +731,7 @@ describe('feature-gate lowering matrix (#2874)', () => { ['SysTeam', SysTeam, 'create_team', `${ADMINS} && ${ORG}`], ['SysTeam', SysTeam, 'update_team', `${ADMINS} && ${ORG}`], ['SysTeam', SysTeam, 'remove_team', `${ADMINS} && ${ORG}`], + ['SysTeam', SysTeam, 'add_team_member', `${ADMINS} && ${ORG}`], ['SysTeamMember', SysTeamMember, 'add_team_member', `${ADMINS} && ${ORG}`], ['SysTeamMember', SysTeamMember, 'remove_team_member', `${ADMINS} && ${ORG}`], // #2874 P2b — audit gates: capability-dependent actions that previously diff --git a/packages/spec/src/kernel/public-auth-features.ts b/packages/spec/src/kernel/public-auth-features.ts index 5b8ba535bc5..bc905d9641b 100644 --- a/packages/spec/src/kernel/public-auth-features.ts +++ b/packages/spec/src/kernel/public-auth-features.ts @@ -123,6 +123,7 @@ export const PUBLIC_AUTH_FEATURES = { 'sys_team.actions.create_team', 'sys_team.actions.update_team', 'sys_team.actions.remove_team', + 'sys_team.actions.add_team_member', '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