Skip to content

Commit 3445251

Browse files
authored
feat: add team member role for team-level permission control (#1021)
1 parent 3fda2f7 commit 3445251

File tree

13 files changed

+1514
-126
lines changed

13 files changed

+1514
-126
lines changed

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
@@ -53,6 +53,7 @@ export class OrgService extends AbstractService {
5353
const teamMember = TeamMember.create({
5454
teamId: developersTeam.teamId,
5555
userId: cmd.creatorUserId,
56+
role: 'owner',
5657
});
5758
await this.orgRepository.createOrgCascade(org, developersTeam, ownerMember, teamMember);
5859

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

app/core/service/TeamService.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export class TeamService extends AbstractService {
2323
private readonly teamRepository: TeamRepository;
2424

2525

26-
async createTeam(orgId: string, name: string, description?: string): Promise<Team> {
26+
async createTeam(orgId: string, name: string, description?: string, creatorUserId?: string): Promise<Team> {
2727
const existing = await this.teamRepository.findTeam(orgId, name);
2828
if (existing) {
2929
throw new ForbiddenError(`Team "${name}" already exists`);
@@ -35,7 +35,14 @@ export class TeamService extends AbstractService {
3535
description,
3636
});
3737
await this.teamRepository.saveTeam(team);
38-
this.logger.info('[TeamService:createTeam] teamId: %s, orgId: %s, name: %s', team.teamId, orgId, name);
38+
39+
// Auto-add creator as team owner
40+
if (creatorUserId) {
41+
const member = TeamMember.create({ teamId: team.teamId, userId: creatorUserId, role: 'owner' });
42+
await this.teamRepository.addMember(member);
43+
}
44+
45+
this.logger.info('[TeamService:createTeam] teamId: %s, orgId: %s, name: %s, creator: %s', team.teamId, orgId, name, creatorUserId);
3946
return team;
4047
}
4148

@@ -52,7 +59,7 @@ export class TeamService extends AbstractService {
5259
this.logger.info('[TeamService:removeTeam] teamId: %s', teamId);
5360
}
5461

55-
async addMember(teamId: string, userId: string): Promise<TeamMember> {
62+
async addMember(teamId: string, userId: string, role: 'owner' | 'member' = 'member'): Promise<TeamMember> {
5663
const team = await this.teamRepository.findTeamByTeamId(teamId);
5764
if (!team) {
5865
throw new NotFoundError('Team not found');
@@ -70,12 +77,17 @@ export class TeamService extends AbstractService {
7077

7178
const existing = await this.teamRepository.findMember(teamId, userId);
7279
if (existing) {
80+
// Update role if changed
81+
if (existing.role !== role) {
82+
existing.role = role;
83+
await this.teamRepository.addMember(existing);
84+
}
7385
return existing;
7486
}
7587

76-
const member = TeamMember.create({ teamId, userId });
88+
const member = TeamMember.create({ teamId, userId, role });
7789
await this.teamRepository.addMember(member);
78-
this.logger.info('[TeamService:addMember] teamId: %s, userId: %s', teamId, userId);
90+
this.logger.info('[TeamService:addMember] teamId: %s, userId: %s, role: %s', teamId, userId, role);
7991
return member;
8092
}
8193

app/port/controller/OrgController.ts

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

app/port/controller/TeamController.ts

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ import {
88
HTTPParam,
99
Inject,
1010
} from '@eggjs/tegg';
11-
import { NotFoundError, UnprocessableEntityError } from 'egg-errors';
11+
import { NotFoundError, ForbiddenError, UnprocessableEntityError } from 'egg-errors';
1212
import { AbstractController } from './AbstractController';
1313
import { OrgService } from '../../core/service/OrgService';
1414
import { TeamService } from '../../core/service/TeamService';
15+
import { OrgRepository } from '../../repository/OrgRepository';
1516
import { TeamRepository } from '../../repository/TeamRepository';
1617
import { getScopeAndName } from '../../common/PackageUtil';
1718

@@ -23,6 +24,9 @@ export class TeamController extends AbstractController {
2324
@Inject()
2425
private readonly teamService: TeamService;
2526

27+
@Inject()
28+
private readonly orgRepository: OrgRepository;
29+
2630
@Inject()
2731
private readonly teamRepository: TeamRepository;
2832

@@ -58,12 +62,44 @@ export class TeamController extends AbstractController {
5862
}
5963

6064
private async requireTeamWriteAccess(ctx: EggContext, orgName: string, teamName: string) {
61-
const { org, authorizedUser } = await this.requireOrgWriteAccess(ctx, orgName);
65+
const authorizedUser = await this.userRoleManager.requiredAuthorizedUser(ctx, 'setting');
66+
const isAdmin = await this.userRoleManager.isAdmin(ctx);
67+
68+
let org;
69+
if (this.isAllowScopeOrg(orgName)) {
70+
org = await this.orgService.ensureOrgForScope(`@${orgName}`);
71+
} else {
72+
org = await this.orgService.findOrgByName(orgName);
73+
if (!org) {
74+
throw new NotFoundError(`Org "${orgName}" not found`);
75+
}
76+
}
77+
6278
const team = await this.teamRepository.findTeam(org.orgId, teamName);
6379
if (!team) {
6480
throw new NotFoundError(`Team "${teamName}" not found`);
6581
}
66-
return { org, team, authorizedUser };
82+
83+
// Admin always has access
84+
if (isAdmin) {
85+
return { org, team, authorizedUser };
86+
}
87+
88+
// Org owner has access
89+
if (!this.isAllowScopeOrg(orgName)) {
90+
const orgMember = await this.orgRepository.findMember(org.orgId, authorizedUser.userId);
91+
if (orgMember && orgMember.role === 'owner') {
92+
return { org, team, authorizedUser };
93+
}
94+
}
95+
96+
// Team owner has access
97+
const teamMember = await this.teamRepository.findMember(team.teamId, authorizedUser.userId);
98+
if (teamMember && teamMember.role === 'owner') {
99+
return { org, team, authorizedUser };
100+
}
101+
102+
throw new ForbiddenError('Only team owner or admin can perform this action');
67103
}
68104

69105
// --- Team CRUD ---
@@ -75,12 +111,12 @@ export class TeamController extends AbstractController {
75111
})
76112
async createTeam(@Context() ctx: EggContext, @HTTPParam() orgName: string,
77113
@HTTPBody() body: { name: string; description?: string }) {
78-
const { org } = await this.requireOrgWriteAccess(ctx, orgName);
114+
const { org, authorizedUser } = await this.requireOrgWriteAccess(ctx, orgName);
79115

80116
if (!body.name) {
81117
throw new UnprocessableEntityError('name is required');
82118
}
83-
await this.teamService.createTeam(org.orgId, body.name, body.description);
119+
await this.teamService.createTeam(org.orgId, body.name, body.description, authorizedUser.userId);
84120
return { ok: true };
85121
}
86122

@@ -138,6 +174,7 @@ export class TeamController extends AbstractController {
138174
// --- Team Members (npm uses "user") ---
139175

140176
// npm team ls @scope:team → GET /-/team/:orgName/:teamName/user
177+
// npm compatible: returns string array ["user1", "user2"]
141178
@HTTPMethod({
142179
path: '/-/team/:orgName/:teamName/user',
143180
method: HTTPMethodEnum.GET,
@@ -158,6 +195,57 @@ export class TeamController extends AbstractController {
158195
return users.map(u => u.displayName);
159196
}
160197

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

app/repository/TeamRepository.ts

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

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

8285
// --- TeamMember ---
@@ -89,6 +92,10 @@ export class TeamRepository extends AbstractRepository {
8992

9093
async addMember(member: TeamMember): Promise<void> {
9194
if (member.id) {
95+
const model = await this.TeamMember.findOne({ id: member.id });
96+
if (model) {
97+
await ModelConvertor.saveEntityToModel(member, model);
98+
}
9299
return;
93100
}
94101
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
@@ -23,4 +23,7 @@ export class TeamMember extends Bone {
2323

2424
@Attribute(DataTypes.STRING(24))
2525
userId: string;
26+
27+
@Attribute(DataTypes.STRING(20))
28+
role: string;
2629
}

0 commit comments

Comments
 (0)