Skip to content

Commit d2fd3c5

Browse files
elrrrrrrrclaude
andauthored
feat: add team member role for team-level permission control (#1023)
## Summary Cherry-pick from #1021 (`feat/user-team-api` branch). - Add `role` field (`owner` / `member`) to `TeamMember`, enabling team-level permission control - Team creator is auto-added as team owner; team write operations now require **team owner**, org owner, or admin - Add private API `GET /-/team/:org/:team/member` returning `[{user, role}]` - Add private API `PATCH /-/team/:org/:team/member/:username` for updating member role - Keep npm compatible endpoints unchanged - Update docs (Chinese + English) - SQL migration: `4.32.0.sql` adds `role` column to `team_members` ## Test plan - [x] Run `npm run test:local test/port/controller/TeamController/index.test.ts` - [x] Run full test suite - [x] Verify npm CLI commands still work unchanged 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Team members can now have assignable roles (owner or member) controlling team permissions * New API endpoints to view team member roles and update member permissions * Team creators automatically receive team owner status upon team creation * **Documentation** * Added comprehensive guide to organization, team, and package permission models with API endpoint reference <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 407d659 commit d2fd3c5

13 files changed

Lines changed: 1651 additions & 184 deletions

File tree

app/core/entity/TeamMember.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ interface TeamMemberData extends EntityData {
55
teamMemberId: string;
66
teamId: string;
77
userId: string;
8+
role: string;
89
}
910

1011
export type CreateTeamMemberData = Omit<EasyData<TeamMemberData, 'teamMemberId'>, 'id'>;
@@ -13,12 +14,14 @@ export class TeamMember extends Entity {
1314
teamMemberId: string;
1415
teamId: string;
1516
userId: string;
17+
role: string;
1618

1719
constructor(data: TeamMemberData) {
1820
super(data);
1921
this.teamMemberId = data.teamMemberId;
2022
this.teamId = data.teamId;
2123
this.userId = data.userId;
24+
this.role = data.role || 'member';
2225
}
2326

2427
static create(data: CreateTeamMemberData): TeamMember {

app/core/service/OrgService.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export class OrgService extends AbstractService {
5050
const teamMember = TeamMember.create({
5151
teamId: developersTeam.teamId,
5252
userId: cmd.creatorUserId,
53+
role: 'owner',
5354
});
5455
await this.orgRepository.createOrgCascade(org, developersTeam, ownerMember, teamMember);
5556

@@ -110,6 +111,7 @@ export class OrgService extends AbstractService {
110111
const teamMember = TeamMember.create({
111112
teamId: developersTeam.teamId,
112113
userId,
114+
role: 'member',
113115
});
114116
await this.teamRepository.addMember(teamMember);
115117
}

app/core/service/TeamService.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export class TeamService extends AbstractService {
1919
@Inject()
2020
private readonly teamRepository: TeamRepository;
2121

22-
async createTeam(orgId: string, name: string, description?: string): Promise<Team> {
22+
async createTeam(orgId: string, name: string, description?: string, creatorUserId?: string): Promise<Team> {
2323
const existing = await this.teamRepository.findTeam(orgId, name);
2424
if (existing) {
2525
throw new ForbiddenError(`Team "${name}" already exists`);
@@ -31,7 +31,20 @@ export class TeamService extends AbstractService {
3131
description,
3232
});
3333
await this.teamRepository.saveTeam(team);
34-
this.logger.info('[TeamService:createTeam] teamId: %s, orgId: %s, name: %s', team.teamId, orgId, name);
34+
35+
// Auto-add creator as team owner
36+
if (creatorUserId) {
37+
const member = TeamMember.create({ teamId: team.teamId, userId: creatorUserId, role: 'owner' });
38+
await this.teamRepository.addMember(member);
39+
}
40+
41+
this.logger.info(
42+
'[TeamService:createTeam] teamId: %s, orgId: %s, name: %s, creator: %s',
43+
team.teamId,
44+
orgId,
45+
name,
46+
creatorUserId,
47+
);
3548
return team;
3649
}
3750

@@ -48,7 +61,7 @@ export class TeamService extends AbstractService {
4861
this.logger.info('[TeamService:removeTeam] teamId: %s', teamId);
4962
}
5063

51-
async addMember(teamId: string, userId: string): Promise<TeamMember> {
64+
async addMember(teamId: string, userId: string, role: 'owner' | 'member' = 'member'): Promise<TeamMember> {
5265
const team = await this.teamRepository.findTeamByTeamId(teamId);
5366
if (!team) {
5467
throw new NotFoundError('Team not found');
@@ -66,12 +79,17 @@ export class TeamService extends AbstractService {
6679

6780
const existing = await this.teamRepository.findMember(teamId, userId);
6881
if (existing) {
82+
// Update role if changed
83+
if (existing.role !== role) {
84+
existing.role = role;
85+
await this.teamRepository.addMember(existing);
86+
}
6987
return existing;
7088
}
7189

72-
const member = TeamMember.create({ teamId, userId });
90+
const member = TeamMember.create({ teamId, userId, role });
7391
await this.teamRepository.addMember(member);
74-
this.logger.info('[TeamService:addMember] teamId: %s, userId: %s', teamId, userId);
92+
this.logger.info('[TeamService:addMember] teamId: %s, userId: %s, role: %s', teamId, userId, role);
7593
return member;
7694
}
7795

app/port/controller/OrgController.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ export class OrgController extends AbstractController {
167167
if (!targetUser) {
168168
throw new NotFoundError(`User "${username}" not found`);
169169
}
170-
const teams = await this.teamRepository.listTeamsByUserIdAndOrgId(targetUser.userId, org.orgId);
171-
return teams.map((t) => ({ name: t.name, description: t.description }));
170+
const teamResults = await this.teamRepository.listTeamsByUserIdAndOrgId(targetUser.userId, org.orgId);
171+
return teamResults.map((t) => ({ name: t.team.name, description: t.team.description, role: t.role }));
172172
}
173173
}

app/port/controller/TeamController.ts

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Context, HTTPBody, HTTPContext, HTTPController, HTTPMethod, HTTPMethodEnum, HTTPParam, Inject } from 'egg';
2-
import { NotFoundError, UnprocessableEntityError } from 'egg/errors';
2+
import { ForbiddenError, NotFoundError, UnprocessableEntityError } from 'egg/errors';
33

44
import { getScopeAndName } from '../../common/PackageUtil.ts';
55
import type { OrgService } from '../../core/service/OrgService.ts';
66
import type { TeamService } from '../../core/service/TeamService.ts';
7+
import type { OrgRepository } from '../../repository/OrgRepository.ts';
78
import type { TeamRepository } from '../../repository/TeamRepository.ts';
89
import { AbstractController } from './AbstractController.ts';
910

@@ -15,6 +16,9 @@ export class TeamController extends AbstractController {
1516
@Inject()
1617
private readonly teamService: TeamService;
1718

19+
@Inject()
20+
private readonly orgRepository: OrgRepository;
21+
1822
@Inject()
1923
private readonly teamRepository: TeamRepository;
2024

@@ -50,12 +54,44 @@ export class TeamController extends AbstractController {
5054
}
5155

5256
private async requireTeamWriteAccess(ctx: Context, orgName: string, teamName: string) {
53-
const { org, authorizedUser } = await this.requireOrgWriteAccess(ctx, orgName);
57+
const authorizedUser = await this.userRoleManager.requiredAuthorizedUser(ctx, 'setting');
58+
const isAdmin = await this.userRoleManager.isAdmin(ctx);
59+
60+
let org;
61+
if (this.isAllowScopeOrg(orgName)) {
62+
org = await this.orgService.ensureOrgForScope(`@${orgName}`);
63+
} else {
64+
org = await this.orgService.findOrgByName(orgName);
65+
if (!org) {
66+
throw new NotFoundError(`Org "${orgName}" not found`);
67+
}
68+
}
69+
5470
const team = await this.teamRepository.findTeam(org.orgId, teamName);
5571
if (!team) {
5672
throw new NotFoundError(`Team "${teamName}" not found`);
5773
}
58-
return { org, team, authorizedUser };
74+
75+
// Admin always has access
76+
if (isAdmin) {
77+
return { org, team, authorizedUser };
78+
}
79+
80+
// Org owner has access
81+
if (!this.isAllowScopeOrg(orgName)) {
82+
const orgMember = await this.orgRepository.findMember(org.orgId, authorizedUser.userId);
83+
if (orgMember && orgMember.role === 'owner') {
84+
return { org, team, authorizedUser };
85+
}
86+
}
87+
88+
// Team owner has access
89+
const teamMember = await this.teamRepository.findMember(team.teamId, authorizedUser.userId);
90+
if (teamMember && teamMember.role === 'owner') {
91+
return { org, team, authorizedUser };
92+
}
93+
94+
throw new ForbiddenError('Only team owner or admin can perform this action');
5995
}
6096

6197
// --- Team CRUD ---
@@ -70,12 +106,12 @@ export class TeamController extends AbstractController {
70106
@HTTPParam() orgName: string,
71107
@HTTPBody() body: { name: string; description?: string },
72108
) {
73-
const { org } = await this.requireOrgWriteAccess(ctx, orgName);
109+
const { org, authorizedUser } = await this.requireOrgWriteAccess(ctx, orgName);
74110

75111
if (!body.name) {
76112
throw new UnprocessableEntityError('name is required');
77113
}
78-
await this.teamService.createTeam(org.orgId, body.name, body.description);
114+
await this.teamService.createTeam(org.orgId, body.name, body.description, authorizedUser.userId);
79115
return { ok: true };
80116
}
81117

@@ -131,6 +167,7 @@ export class TeamController extends AbstractController {
131167
// --- Team Members (npm uses "user") ---
132168

133169
// npm team ls @scope:team → GET /-/team/:orgName/:teamName/user
170+
// npm compatible: returns string array ["user1", "user2"]
134171
@HTTPMethod({
135172
path: '/-/team/:orgName/:teamName/user',
136173
method: HTTPMethodEnum.GET,
@@ -150,6 +187,64 @@ export class TeamController extends AbstractController {
150187
return users.map((u) => u.displayName);
151188
}
152189

190+
// Private API: GET /-/team/:orgName/:teamName/member
191+
// Returns [{user, role}] with team member role info
192+
@HTTPMethod({
193+
path: '/-/team/:orgName/:teamName/member',
194+
method: HTTPMethodEnum.GET,
195+
})
196+
async listTeamMembersWithRole(
197+
@HTTPContext() ctx: Context,
198+
@HTTPParam() orgName: string,
199+
@HTTPParam() teamName: string,
200+
) {
201+
await this.userRoleManager.requiredAuthorizedUser(ctx, 'read');
202+
const org = await this.findOrg(orgName);
203+
if (!org) {
204+
throw new NotFoundError(`Org "${orgName}" not found`);
205+
}
206+
const team = await this.teamRepository.findTeam(org.orgId, teamName);
207+
if (!team) {
208+
throw new NotFoundError(`Team "${teamName}" not found`);
209+
}
210+
const members = await this.teamService.listMembers(team.teamId);
211+
const users = await this.userRepository.findUsersByUserIds(members.map((m) => m.userId));
212+
const userMap = new Map(users.map((u) => [u.userId, u]));
213+
return members.map((m) => ({
214+
user: userMap.get(m.userId)?.displayName ?? '',
215+
role: m.role,
216+
}));
217+
}
218+
219+
// Private API: PATCH /-/team/:orgName/:teamName/member/:username
220+
// Update team member role
221+
@HTTPMethod({
222+
path: '/-/team/:orgName/:teamName/member/:username',
223+
method: HTTPMethodEnum.PATCH,
224+
})
225+
async updateTeamMemberRole(
226+
@HTTPContext() ctx: Context,
227+
@HTTPParam() orgName: string,
228+
@HTTPParam() teamName: string,
229+
@HTTPParam() username: string,
230+
@HTTPBody() body: { role: string },
231+
) {
232+
const { team } = await this.requireTeamWriteAccess(ctx, orgName, teamName);
233+
if (!body.role || (body.role !== 'owner' && body.role !== 'member')) {
234+
throw new UnprocessableEntityError('role is required and must be "owner" or "member"');
235+
}
236+
const targetUser = await this.userRepository.findUserByName(username);
237+
if (!targetUser) {
238+
throw new NotFoundError(`User "${username}" not found`);
239+
}
240+
const member = await this.teamRepository.findMember(team.teamId, targetUser.userId);
241+
if (!member) {
242+
throw new NotFoundError(`User "${username}" is not a member of this team`);
243+
}
244+
await this.teamService.addMember(team.teamId, targetUser.userId, body.role as 'owner' | 'member');
245+
return { ok: true };
246+
}
247+
153248
// npm team add <user> @scope:team → PUT /-/team/:orgName/:teamName/user
154249
@HTTPMethod({
155250
path: '/-/team/:orgName/:teamName/user',

app/repository/TeamRepository.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,16 +68,19 @@ export class TeamRepository extends AbstractRepository {
6868
return models.map((model) => ModelConvertor.convertModelToEntity(model, Team));
6969
}
7070

71-
async listTeamsByUserIdAndOrgId(userId: string, orgId: string): Promise<Team[]> {
71+
async listTeamsByUserIdAndOrgId(userId: string, orgId: string): Promise<{ team: Team; role: string }[]> {
7272
const orgTeams = await this.Team.find({ orgId });
7373
if (orgTeams.length === 0) return [];
7474
const orgTeamIds = orgTeams.map((t) => t.teamId);
7575
const memberModels = await this.TeamMember.find({ userId, teamId: { $in: orgTeamIds } });
7676
if (memberModels.length === 0) return [];
77-
const memberTeamIds = new Set(memberModels.map((m) => m.teamId));
77+
const memberRoleMap = new Map(memberModels.map((m) => [m.teamId, m.role || 'member']));
7878
return orgTeams
79-
.filter((t) => memberTeamIds.has(t.teamId))
80-
.map((model) => ModelConvertor.convertModelToEntity(model, Team));
79+
.filter((t) => memberRoleMap.has(t.teamId))
80+
.map((model) => ({
81+
team: ModelConvertor.convertModelToEntity(model, Team),
82+
role: memberRoleMap.get(model.teamId) || 'member',
83+
}));
8184
}
8285

8386
// --- TeamMember ---
@@ -90,6 +93,10 @@ export class TeamRepository extends AbstractRepository {
9093

9194
async addMember(member: TeamMember): Promise<void> {
9295
if (member.id) {
96+
const model = await this.TeamMember.findOne({ id: member.id });
97+
if (model) {
98+
await ModelConvertor.saveEntityToModel(member, model);
99+
}
93100
return;
94101
}
95102
await ModelConvertor.convertEntityToModel(member, this.TeamMember);

app/repository/model/TeamMember.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,7 @@ export class TeamMember extends Bone {
2424

2525
@Attribute(DataTypes.STRING(24))
2626
userId: string;
27+
28+
@Attribute(DataTypes.STRING(20))
29+
role: string;
2730
}

0 commit comments

Comments
 (0)