feat: 接入真实登录与成员权限
- auth/members/messages: 新增超级管理员登录、成员权限和密码消息流程 - agent-config/agent: 支持服务端保存 Agent 配置并取消未配置 mock 成功结果 - projects/deploy-runs/settings: 按当前用户权限保护真实接口 - prisma: 新增用户、项目权限和平台消息表结构
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { z } from 'zod';
|
||||
|
||||
const permissionLevelSchema = z.enum(['none', 'read', 'build']);
|
||||
|
||||
export const projectPermissionSchema = z.object({
|
||||
projectKey: z.string().trim().min(1).max(80),
|
||||
level: permissionLevelSchema,
|
||||
});
|
||||
|
||||
export const createMemberSchema = z.object({
|
||||
account: z.string().trim().min(1).max(80),
|
||||
displayName: z.string().trim().min(1).max(120),
|
||||
permissions: z.array(projectPermissionSchema).optional().default([]),
|
||||
});
|
||||
|
||||
export type CreateMemberDtoInput = z.infer<typeof createMemberSchema>;
|
||||
|
||||
export class ProjectPermissionDto {
|
||||
@ApiProperty({ example: 'devops-platform-api', description: '项目标识' })
|
||||
projectKey!: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: ['none', 'read', 'build'],
|
||||
description: '项目权限:none 无权限,read 只读,build 构建',
|
||||
})
|
||||
level!: 'none' | 'read' | 'build';
|
||||
}
|
||||
|
||||
export class CreateMemberDto {
|
||||
@ApiProperty({ example: 'member01', description: '成员账号' })
|
||||
account!: string;
|
||||
|
||||
@ApiProperty({ example: '成员一', description: '成员名称' })
|
||||
displayName!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: [ProjectPermissionDto],
|
||||
required: false,
|
||||
description: '成员项目权限,未传项目默认为无权限',
|
||||
})
|
||||
permissions?: ProjectPermissionDto[];
|
||||
}
|
||||
|
||||
export const updateMemberPermissionsSchema = z.object({
|
||||
permissions: z.array(projectPermissionSchema).default([]),
|
||||
});
|
||||
|
||||
export type UpdateMemberPermissionsInput = z.infer<
|
||||
typeof updateMemberPermissionsSchema
|
||||
>;
|
||||
|
||||
export class UpdateMemberPermissionsDto {
|
||||
@ApiProperty({ type: [ProjectPermissionDto], description: '完整项目权限列表' })
|
||||
permissions!: ProjectPermissionDto[];
|
||||
}
|
||||
|
||||
export const updateMemberStatusSchema = z.object({
|
||||
status: z.enum(['active', 'disabled']),
|
||||
});
|
||||
|
||||
export type UpdateMemberStatusInput = z.infer<typeof updateMemberStatusSchema>;
|
||||
|
||||
export class UpdateMemberStatusDto {
|
||||
@ApiProperty({
|
||||
enum: ['active', 'disabled'],
|
||||
description: '成员状态:active 启用,disabled 禁用',
|
||||
})
|
||||
status!: 'active' | 'disabled';
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { AuthRole, AuthStatus, ProjectPermissionLevel } from '../auth/auth.types';
|
||||
|
||||
export type MemberProjectPermissionSummary = {
|
||||
projectId: string;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
level: ProjectPermissionLevel;
|
||||
levelName: string;
|
||||
};
|
||||
|
||||
export type MemberSummary = {
|
||||
id: string;
|
||||
account: string;
|
||||
displayName: string;
|
||||
role: AuthRole;
|
||||
roleName: string;
|
||||
status: AuthStatus;
|
||||
statusName: string;
|
||||
mustChangePassword: boolean;
|
||||
hasPendingPasswordResetRequest: boolean;
|
||||
lastLoginAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
projectPermissions: MemberProjectPermissionSummary[];
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBody,
|
||||
ApiCreatedResponse,
|
||||
ApiOkResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { AdminOnlyMessage } from '../auth/admin-only-message.decorator';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
import { AuthenticatedUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { PasswordChangeGuard } from '../auth/password-change.guard';
|
||||
import { SuperAdminGuard } from '../auth/super-admin.guard';
|
||||
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||
import {
|
||||
CreateMemberDto,
|
||||
CreateMemberDtoInput,
|
||||
UpdateMemberPermissionsDto,
|
||||
UpdateMemberPermissionsInput,
|
||||
UpdateMemberStatusDto,
|
||||
UpdateMemberStatusInput,
|
||||
createMemberSchema,
|
||||
updateMemberPermissionsSchema,
|
||||
updateMemberStatusSchema,
|
||||
} from './member.dto';
|
||||
import { MemberSummary } from './member.types';
|
||||
import { MembersService } from './members.service';
|
||||
|
||||
@ApiTags('成员管理')
|
||||
@Controller('members')
|
||||
@UseGuards(AuthGuard, PasswordChangeGuard, SuperAdminGuard)
|
||||
@AdminOnlyMessage('无权限访问成员管理')
|
||||
export class MembersController {
|
||||
constructor(private readonly membersService: MembersService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponse({ description: '查询成员列表和项目权限。' })
|
||||
async listMembers(): Promise<MemberSummary[]> {
|
||||
return this.membersService.listMembers();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiBody({ type: CreateMemberDto })
|
||||
@ApiCreatedResponse({ description: '创建普通成员,初始密码固定为 111111。' })
|
||||
async createMember(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body(new ZodValidationPipe(createMemberSchema))
|
||||
body: CreateMemberDtoInput,
|
||||
): Promise<MemberSummary> {
|
||||
return this.membersService.createMember(body, user);
|
||||
}
|
||||
|
||||
@Patch(':id/permissions')
|
||||
@ApiBody({ type: UpdateMemberPermissionsDto })
|
||||
@ApiOkResponse({ description: '更新普通成员的项目权限。' })
|
||||
async updatePermissions(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body(new ZodValidationPipe(updateMemberPermissionsSchema))
|
||||
body: UpdateMemberPermissionsInput,
|
||||
): Promise<MemberSummary> {
|
||||
return this.membersService.updatePermissions(id, body, user);
|
||||
}
|
||||
|
||||
@Post(':id/reset-password')
|
||||
@ApiOkResponse({
|
||||
description: '将普通成员密码重置为 111111,并要求下次登录改密。',
|
||||
})
|
||||
async resetPassword(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
): Promise<MemberSummary> {
|
||||
return this.membersService.resetPassword(id, user);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@ApiBody({ type: UpdateMemberStatusDto })
|
||||
@ApiOkResponse({ description: '启用或禁用普通成员账号。' })
|
||||
async updateStatus(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body(new ZodValidationPipe(updateMemberStatusSchema))
|
||||
body: UpdateMemberStatusInput,
|
||||
): Promise<MemberSummary> {
|
||||
return this.membersService.updateStatus(id, body, user);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOkResponse({ description: '删除普通成员账号。' })
|
||||
async deleteMember(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
): Promise<{ message: string }> {
|
||||
return this.membersService.deleteMember(id, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { MessagesModule } from '../messages/messages.module';
|
||||
import { ProjectsModule } from '../projects/projects.module';
|
||||
import { MembersController } from './members.controller';
|
||||
import { MembersService } from './members.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, AuthModule, MessagesModule, ProjectsModule],
|
||||
controllers: [MembersController],
|
||||
providers: [MembersService],
|
||||
exports: [MembersService],
|
||||
})
|
||||
export class MembersModule {}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { MEMBER_INITIAL_PASSWORD, SUPER_ADMIN_SEED } from '../auth/auth.constants';
|
||||
import {
|
||||
AuthenticatedUser,
|
||||
ProjectPermissionInput,
|
||||
ProjectPermissionLevel,
|
||||
UserRecord,
|
||||
} from '../auth/auth.types';
|
||||
import { PasswordService } from '../auth/password.service';
|
||||
import { UserRepository } from '../auth/user.repository';
|
||||
import { AppError } from '../common/errors/app-error';
|
||||
import { MessagesService } from '../messages/messages.service';
|
||||
import { ProjectRepository } from '../projects/project.repository';
|
||||
import {
|
||||
CreateMemberDtoInput,
|
||||
UpdateMemberPermissionsInput,
|
||||
UpdateMemberStatusInput,
|
||||
} from './member.dto';
|
||||
import {
|
||||
MemberProjectPermissionSummary,
|
||||
MemberSummary,
|
||||
} from './member.types';
|
||||
|
||||
@Injectable()
|
||||
export class MembersService {
|
||||
constructor(
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly passwordService: PasswordService,
|
||||
private readonly messagesService: MessagesService,
|
||||
private readonly projectRepository: ProjectRepository,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
async listMembers(): Promise<MemberSummary[]> {
|
||||
const users = await this.userRepository.listUsers();
|
||||
|
||||
return Promise.all(users.map((user) => this.toMemberSummary(user)));
|
||||
}
|
||||
|
||||
async createMember(
|
||||
input: CreateMemberDtoInput,
|
||||
operator: AuthenticatedUser,
|
||||
): Promise<MemberSummary> {
|
||||
const account = input.account.trim();
|
||||
|
||||
if (account === SUPER_ADMIN_SEED.account) {
|
||||
throw new AppError(
|
||||
'CONFLICT',
|
||||
'该账号为内置超级管理员账号,不能创建为普通成员',
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.userRepository.findByAccount(account);
|
||||
|
||||
if (existing) {
|
||||
throw new AppError('CONFLICT', '成员账号已存在', 409, { account });
|
||||
}
|
||||
|
||||
const passwordHash = await this.passwordService.hashPassword(
|
||||
MEMBER_INITIAL_PASSWORD,
|
||||
);
|
||||
const user = await this.userRepository.createUser({
|
||||
account,
|
||||
displayName: input.displayName,
|
||||
passwordHash,
|
||||
role: 'member',
|
||||
mustChangePassword: true,
|
||||
createdById: operator.id,
|
||||
});
|
||||
const updatedUser = await this.userRepository.replaceProjectPermissions(
|
||||
user.id,
|
||||
input.permissions ?? [],
|
||||
);
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'MEMBER_CREATED',
|
||||
resourceType: 'user',
|
||||
resourceId: updatedUser.id,
|
||||
actorId: operator.id,
|
||||
actorName: operator.account,
|
||||
after: {
|
||||
account: updatedUser.account,
|
||||
permissions: this.safePermissionDigest(input.permissions ?? []),
|
||||
mustChangePassword: true,
|
||||
},
|
||||
});
|
||||
|
||||
return this.toMemberSummary(updatedUser);
|
||||
}
|
||||
|
||||
async updatePermissions(
|
||||
memberId: string,
|
||||
input: UpdateMemberPermissionsInput,
|
||||
operator: AuthenticatedUser,
|
||||
): Promise<MemberSummary> {
|
||||
const user = await this.requireMutableMember(memberId);
|
||||
const updatedUser = await this.userRepository.replaceProjectPermissions(
|
||||
user.id,
|
||||
input.permissions,
|
||||
);
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'MEMBER_PERMISSIONS_UPDATED',
|
||||
resourceType: 'user',
|
||||
resourceId: updatedUser.id,
|
||||
actorId: operator.id,
|
||||
actorName: operator.account,
|
||||
before: {
|
||||
permissions: this.safePermissionDigest(user.projectPermissions),
|
||||
},
|
||||
after: {
|
||||
permissions: this.safePermissionDigest(input.permissions),
|
||||
},
|
||||
});
|
||||
|
||||
return this.toMemberSummary(updatedUser);
|
||||
}
|
||||
|
||||
async resetPassword(
|
||||
memberId: string,
|
||||
operator: AuthenticatedUser,
|
||||
): Promise<MemberSummary> {
|
||||
const user = await this.requireMutableMember(memberId);
|
||||
const passwordHash = await this.passwordService.hashPassword(
|
||||
MEMBER_INITIAL_PASSWORD,
|
||||
);
|
||||
const updatedUser = await this.userRepository.updatePassword(
|
||||
user.id,
|
||||
passwordHash,
|
||||
true,
|
||||
);
|
||||
|
||||
await this.messagesService.markPasswordResetHandledForRequester(
|
||||
user.id,
|
||||
operator,
|
||||
);
|
||||
await this.auditService.record({
|
||||
action: 'MEMBER_PASSWORD_RESET',
|
||||
resourceType: 'user',
|
||||
resourceId: user.id,
|
||||
actorId: operator.id,
|
||||
actorName: operator.account,
|
||||
after: {
|
||||
resetToInitialPassword: true,
|
||||
mustChangePassword: true,
|
||||
},
|
||||
});
|
||||
|
||||
return this.toMemberSummary(updatedUser);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
memberId: string,
|
||||
input: UpdateMemberStatusInput,
|
||||
operator: AuthenticatedUser,
|
||||
): Promise<MemberSummary> {
|
||||
const user = await this.requireMutableMember(memberId);
|
||||
const updatedUser = await this.userRepository.setStatus(
|
||||
user.id,
|
||||
input.status,
|
||||
);
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'MEMBER_STATUS_UPDATED',
|
||||
resourceType: 'user',
|
||||
resourceId: user.id,
|
||||
actorId: operator.id,
|
||||
actorName: operator.account,
|
||||
before: {
|
||||
status: user.status,
|
||||
},
|
||||
after: {
|
||||
status: updatedUser.status,
|
||||
},
|
||||
});
|
||||
|
||||
return this.toMemberSummary(updatedUser);
|
||||
}
|
||||
|
||||
async deleteMember(
|
||||
memberId: string,
|
||||
operator: AuthenticatedUser,
|
||||
): Promise<{ message: string }> {
|
||||
const user = await this.requireMutableMember(memberId);
|
||||
await this.userRepository.deleteUser(user.id);
|
||||
await this.auditService.record({
|
||||
action: 'MEMBER_DELETED',
|
||||
resourceType: 'user',
|
||||
resourceId: user.id,
|
||||
actorId: operator.id,
|
||||
actorName: operator.account,
|
||||
before: {
|
||||
account: user.account,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
message: '成员已删除',
|
||||
};
|
||||
}
|
||||
|
||||
private async requireMutableMember(memberId: string): Promise<UserRecord> {
|
||||
const user = await this.userRepository.findById(memberId);
|
||||
|
||||
if (!user) {
|
||||
throw new AppError('RESOURCE_NOT_FOUND', '成员不存在', 404, { memberId });
|
||||
}
|
||||
|
||||
if (user.role === 'super_admin') {
|
||||
throw new AppError(
|
||||
'FORBIDDEN',
|
||||
'超级管理员不可删除、不可禁用、不可降级、不可重置为普通成员初始密码',
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private async toMemberSummary(user: UserRecord): Promise<MemberSummary> {
|
||||
return {
|
||||
id: user.id,
|
||||
account: user.account,
|
||||
displayName: user.displayName,
|
||||
role: user.role,
|
||||
roleName: user.role === 'super_admin' ? '超级管理员' : '普通成员',
|
||||
status: user.status,
|
||||
statusName: user.status === 'active' ? '启用' : '禁用',
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
hasPendingPasswordResetRequest:
|
||||
await this.messagesService.hasPendingPasswordResetRequest(user.id),
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
projectPermissions: await this.resolveProjectPermissions(user),
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveProjectPermissions(
|
||||
user: UserRecord,
|
||||
): Promise<MemberProjectPermissionSummary[]> {
|
||||
const projects = await this.projectRepository.findMany();
|
||||
const permissionByProject = new Map(
|
||||
user.projectPermissions.map((permission) => [
|
||||
permission.projectKey,
|
||||
permission.level,
|
||||
]),
|
||||
);
|
||||
|
||||
return projects.map((project) => {
|
||||
const level =
|
||||
user.role === 'super_admin'
|
||||
? 'build'
|
||||
: (permissionByProject.get(project.key) ?? 'none');
|
||||
|
||||
return {
|
||||
projectId: project.id,
|
||||
projectKey: project.key,
|
||||
projectName: project.name,
|
||||
level,
|
||||
levelName: this.permissionLevelName(level),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private permissionLevelName(level: ProjectPermissionLevel): string {
|
||||
if (level === 'build') {
|
||||
return '构建权限';
|
||||
}
|
||||
|
||||
if (level === 'read') {
|
||||
return '只读权限';
|
||||
}
|
||||
|
||||
return '无权限';
|
||||
}
|
||||
|
||||
private safePermissionDigest(
|
||||
permissions: Array<ProjectPermissionInput | { projectKey: string; level: string }>,
|
||||
): Array<{ projectKey: string; level: string }> {
|
||||
return permissions.map((permission) => ({
|
||||
projectKey: permission.projectKey,
|
||||
level: permission.level,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user