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/25337-spec-book-team-add-member.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions .changeset/25337-team-add-member-binds-team.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
label: "チームを編集",
successMessage: "チームを更新しました"
},
add_team_member: {
label: "メンバーを追加",
successMessage: "チームメンバーを追加しました"
},
remove_team: {
label: "チームを削除",
confirmText: "このチームを削除しますか?メンバーはチームスコープのアクセスを失います。この操作は取り消せません。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
label: "编辑团队",
successMessage: "团队已更新"
},
add_team_member: {
label: "添加成员",
successMessage: "团队成员已添加"
},
remove_team: {
label: "删除团队",
confirmText: "要删除该团队吗?成员将失去所有团队范围内的访问权限。此操作无法撤销。",
Expand Down
Original file line number Diff line number Diff line change
@@ -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']);
});
});
25 changes: 25 additions & 0 deletions packages/platform-objects/src/identity/sys-team.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/platform-objects/src/platform-objects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/spec/src/kernel/public-auth-features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading