Merge pull request '修复管理员班级可见范围、 修复班级归档查询参数转换、 移除账号启用状态统一使用归档' (#48) from xiongyuxing/gongxue-base:main into main
Reviewed-on: #48
This commit is contained in:
@@ -10,7 +10,6 @@ const baseUser = (overrides: Partial<TeacherCandidateUser> = {}): TeacherCandida
|
|||||||
id: 1,
|
id: 1,
|
||||||
username: 'teacher',
|
username: 'teacher',
|
||||||
name: '测试老师',
|
name: '测试老师',
|
||||||
isActive: true,
|
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
studentStatus: null,
|
studentStatus: null,
|
||||||
roles: [{ code: 'teacher', name: '任课老师' }],
|
roles: [{ code: 'teacher', name: '任课老师' }],
|
||||||
@@ -29,13 +28,12 @@ describe('class teacher candidates', () => {
|
|||||||
expect(isTeacherCandidate(baseUser({ studentStatus: 'staff' }))).toBe(true);
|
expect(isTeacherCandidate(baseUser({ studentStatus: 'staff' }))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('excludes active students, disabled, archived, and super-admin accounts', () => {
|
it('excludes active students, archived, and super-admin accounts', () => {
|
||||||
const users = [
|
const users = [
|
||||||
baseUser({ id: 1, studentStatus: 'active' }),
|
baseUser({ id: 1, studentStatus: 'active' }),
|
||||||
baseUser({ id: 2, isActive: false }),
|
baseUser({ id: 2, isArchived: true }),
|
||||||
baseUser({ id: 3, isArchived: true }),
|
|
||||||
baseUser({
|
baseUser({
|
||||||
id: 4,
|
id: 3,
|
||||||
roles: [{ code: 'super_admin', name: '超级管理员' }],
|
roles: [{ code: 'super_admin', name: '超级管理员' }],
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export interface TeacherCandidateUser {
|
|||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
isActive: boolean;
|
|
||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
studentStatus?: string | null;
|
studentStatus?: string | null;
|
||||||
roles?: TeacherCandidateRole[];
|
roles?: TeacherCandidateRole[];
|
||||||
@@ -18,7 +17,7 @@ const isSuperAdminRole = (role: TeacherCandidateRole) =>
|
|||||||
role.code === 'super_admin' || role.name === '超级管理员' || role.name === '超管';
|
role.code === 'super_admin' || role.name === '超级管理员' || role.name === '超管';
|
||||||
|
|
||||||
export const isTeacherCandidate = (user: TeacherCandidateUser) => {
|
export const isTeacherCandidate = (user: TeacherCandidateUser) => {
|
||||||
if (!user.isActive || user.isArchived) return false;
|
if (user.isArchived) return false;
|
||||||
if (user.studentStatus && user.studentStatus !== 'staff') return false;
|
if (user.studentStatus && user.studentStatus !== 'staff') return false;
|
||||||
return !(user.roles || []).some(isSuperAdminRole);
|
return !(user.roles || []).some(isSuperAdminRole);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ interface TeacherRow {
|
|||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
name: string;
|
name: string;
|
||||||
isActive: boolean;
|
|
||||||
profile: { subjects?: string[]; joinedAt?: string; qualifications?: string } | null;
|
profile: { subjects?: string[]; joinedAt?: string; qualifications?: string } | null;
|
||||||
lastLoginAt: string;
|
lastLoginAt: string;
|
||||||
roles: { code: string; name: string }[];
|
roles: { code: string; name: string }[];
|
||||||
@@ -175,13 +174,6 @@ const TeachersPage: React.FC = () => {
|
|||||||
</EditableCell>
|
</EditableCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'isActive',
|
|
||||||
key: 'status',
|
|
||||||
width: 90,
|
|
||||||
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '在职' : '停用'}</Tag>,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '最后登录',
|
title: '最后登录',
|
||||||
dataIndex: 'lastLoginAt',
|
dataIndex: 'lastLoginAt',
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ const UsersPage: React.FC = () => {
|
|||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
username: record.username,
|
username: record.username,
|
||||||
name: record.name,
|
name: record.name,
|
||||||
isActive: record.isActive,
|
|
||||||
roleIds: record.roles?.map((r: any) => r.id) || [],
|
roleIds: record.roles?.map((r: any) => r.id) || [],
|
||||||
});
|
});
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
@@ -101,7 +100,6 @@ const UsersPage: React.FC = () => {
|
|||||||
await api.put(`/rbac/users/${editing.id}`, {
|
await api.put(`/rbac/users/${editing.id}`, {
|
||||||
username: values.username,
|
username: values.username,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
isActive: values.isActive,
|
|
||||||
roleIds: values.roleIds || [],
|
roleIds: values.roleIds || [],
|
||||||
});
|
});
|
||||||
message.success('更新成功');
|
message.success('更新成功');
|
||||||
@@ -223,26 +221,6 @@ const UsersPage: React.FC = () => {
|
|||||||
</EditableCell>
|
</EditableCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'isActive',
|
|
||||||
width: 80,
|
|
||||||
render: (v: boolean, r: any) => (
|
|
||||||
<EditableCell
|
|
||||||
value={String(v)}
|
|
||||||
editor="select"
|
|
||||||
options={[
|
|
||||||
{ value: 'true', label: '启用' },
|
|
||||||
{ value: 'false', label: '禁用' },
|
|
||||||
]}
|
|
||||||
permission="user:edit"
|
|
||||||
disabled={r.isArchived}
|
|
||||||
onSave={(next) => saveCell(r, 'isActive', String(next) === 'true')}
|
|
||||||
>
|
|
||||||
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>
|
|
||||||
</EditableCell>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '最后登录',
|
title: '最后登录',
|
||||||
dataIndex: 'lastLoginAt',
|
dataIndex: 'lastLoginAt',
|
||||||
@@ -350,7 +328,7 @@ const UsersPage: React.FC = () => {
|
|||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
scroll={{ x: 1250 }}
|
scroll={{ x: 1150 }}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -382,11 +360,6 @@ const UsersPage: React.FC = () => {
|
|||||||
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
|
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{editing && (
|
|
||||||
<Form.Item name="isActive" label="状态" valuePropName="checked">
|
|
||||||
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="roleIds"
|
name="roleIds"
|
||||||
label="角色分配"
|
label="角色分配"
|
||||||
|
|||||||
@@ -47,4 +47,28 @@ describe('AuthService — authentication boundaries', () => {
|
|||||||
).rejects.toThrow('账号已失效');
|
).rejects.toThrow('账号已失效');
|
||||||
expect(userRepo.save).not.toHaveBeenCalled();
|
expect(userRepo.save).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('allows a legacy disabled user because archive is the only account status', async () => {
|
||||||
|
const userRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({
|
||||||
|
id: 3,
|
||||||
|
username: 'legacy-disabled',
|
||||||
|
name: '旧账号',
|
||||||
|
passwordHash: await bcrypt.hash('secret', 4),
|
||||||
|
isActive: false,
|
||||||
|
isArchived: false,
|
||||||
|
roles: [],
|
||||||
|
}),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
const service = new AuthService(
|
||||||
|
userRepo as never,
|
||||||
|
{ sign: jest.fn().mockReturnValue('token') } as never,
|
||||||
|
{ getUserPermissions: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.login({ username: 'legacy-disabled', password: 'secret' }, '192.0.2.11'),
|
||||||
|
).resolves.toEqual(expect.objectContaining({ access_token: 'token' }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export class AuthService {
|
|||||||
this.recordFailedAttempt(attemptKey);
|
this.recordFailedAttempt(attemptKey);
|
||||||
throw new UnauthorizedException('用户名或密码错误');
|
throw new UnauthorizedException('用户名或密码错误');
|
||||||
}
|
}
|
||||||
if (!user.isActive || user.isArchived) {
|
if (user.isArchived) {
|
||||||
throw new UnauthorizedException('账号已失效,请联系管理员');
|
throw new UnauthorizedException('账号已失效,请联系管理员');
|
||||||
}
|
}
|
||||||
const valid = await bcrypt.compare(dto.password, user.passwordHash);
|
const valid = await bcrypt.compare(dto.password, user.passwordHash);
|
||||||
|
|||||||
@@ -50,16 +50,32 @@ describe('JwtStrategy', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([[{ id: 7, isArchived: true, roles: [] }], [null]])(
|
||||||
[{ id: 7, isActive: false, isArchived: false, roles: [] }],
|
'rejects archived or deleted users',
|
||||||
[{ id: 7, isActive: true, isArchived: true, roles: [] }],
|
async (user) => {
|
||||||
[null],
|
|
||||||
])('rejects disabled, archived, or deleted users', async (user) => {
|
|
||||||
const userRepo = { findOne: jest.fn().mockResolvedValue(user) };
|
const userRepo = { findOne: jest.fn().mockResolvedValue(user) };
|
||||||
const strategy = new JwtStrategy(config as never, userRepo as never);
|
const strategy = new JwtStrategy(config as never, userRepo as never);
|
||||||
|
|
||||||
await expect(strategy.validate({ sub: 7, username: 'teacher' })).rejects.toBeInstanceOf(
|
await expect(strategy.validate({ sub: 7, username: 'teacher' })).rejects.toBeInstanceOf(
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('accepts a legacy disabled user when the account is not archived', async () => {
|
||||||
|
const userRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({
|
||||||
|
id: 7,
|
||||||
|
username: 'teacher',
|
||||||
|
isActive: false,
|
||||||
|
isArchived: false,
|
||||||
|
roles: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const strategy = new JwtStrategy(config as never, userRepo as never);
|
||||||
|
|
||||||
|
await expect(strategy.validate({ sub: 7, username: 'teacher' })).resolves.toEqual(
|
||||||
|
expect.objectContaining({ id: 7, username: 'teacher' }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
where: { id: payload.sub },
|
where: { id: payload.sub },
|
||||||
relations: ['roles', 'roles.permissions'],
|
relations: ['roles', 'roles.permissions'],
|
||||||
});
|
});
|
||||||
if (!user || !user.isActive || user.isArchived) {
|
if (!user || user.isArchived) {
|
||||||
throw new UnauthorizedException('账号已失效,请重新登录');
|
throw new UnauthorizedException('账号已失效,请重新登录');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
116
apps/server/src/classes/classes.controller.spec.ts
Normal file
116
apps/server/src/classes/classes.controller.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { ClassesController } from './classes.controller';
|
||||||
|
import { ClassesService } from './classes.service';
|
||||||
|
import { QueryClassDto } from './dto/class.dto';
|
||||||
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { CaslAction, SubjectName } from '../authorization';
|
||||||
|
|
||||||
|
describe('ClassesController - class data scope', () => {
|
||||||
|
const service = {
|
||||||
|
getAccessibleClassIds: jest.fn(),
|
||||||
|
assertClassAccess: jest.fn(),
|
||||||
|
findAll: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
getSchedule: jest.fn(),
|
||||||
|
getAttendanceSummary: jest.fn(),
|
||||||
|
getStudents: jest.fn(),
|
||||||
|
getTeachers: jest.fn(),
|
||||||
|
};
|
||||||
|
const authzService = { can: jest.fn() };
|
||||||
|
const logService = {};
|
||||||
|
const notificationsService = {};
|
||||||
|
let controller: ClassesController;
|
||||||
|
|
||||||
|
const request = (permissions: string[] = [], isSuperAdmin = false) => ({
|
||||||
|
user: {
|
||||||
|
id: 21,
|
||||||
|
username: 'user',
|
||||||
|
permissions,
|
||||||
|
isSuperAdmin,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
authzService.can.mockImplementation((req, action, subject) => {
|
||||||
|
if (subject !== SubjectName.Class) return false;
|
||||||
|
if (req.user.isSuperAdmin && action === CaslAction.Manage) return true;
|
||||||
|
if (action === CaslAction.Create) return req.user.permissions.includes('class:create');
|
||||||
|
if (action === CaslAction.Update) return req.user.permissions.includes('class:edit');
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
service.getAccessibleClassIds.mockResolvedValue(undefined);
|
||||||
|
service.assertClassAccess.mockResolvedValue(undefined);
|
||||||
|
service.findAll.mockResolvedValue([]);
|
||||||
|
service.findOne.mockResolvedValue({ id: 8 });
|
||||||
|
service.getSchedule.mockResolvedValue([]);
|
||||||
|
service.getAttendanceSummary.mockResolvedValue({});
|
||||||
|
service.getStudents.mockResolvedValue([]);
|
||||||
|
service.getTeachers.mockResolvedValue([]);
|
||||||
|
controller = new ClassesController(
|
||||||
|
service as unknown as ClassesService,
|
||||||
|
logService as unknown as OperationLogsService,
|
||||||
|
notificationsService as unknown as NotificationsService,
|
||||||
|
authzService as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['class creator', request(['class:view', 'class:create'])],
|
||||||
|
['class editor', request(['class:view', 'class:edit'])],
|
||||||
|
['super admin', request([], true)],
|
||||||
|
])('allows %s to list all classes', async (_label, req) => {
|
||||||
|
await controller.findAll({}, req);
|
||||||
|
|
||||||
|
expect(service.getAccessibleClassIds).toHaveBeenCalledWith(21, true);
|
||||||
|
expect(service.findAll).toHaveBeenCalledWith({}, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps view-only users scoped to their assigned classes', async () => {
|
||||||
|
service.getAccessibleClassIds.mockResolvedValue([8]);
|
||||||
|
|
||||||
|
await controller.findAll({}, request(['class:view']));
|
||||||
|
|
||||||
|
expect(service.getAccessibleClassIds).toHaveBeenCalledWith(21, false);
|
||||||
|
expect(service.findAll).toHaveBeenCalledWith({}, [8]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a class creator to read an unassigned class through every detail endpoint', async () => {
|
||||||
|
const req = request(['class:view', 'class:create']);
|
||||||
|
|
||||||
|
await controller.findOne('8', req);
|
||||||
|
await controller.getSchedule('8', {}, req);
|
||||||
|
await controller.getAttendanceSummary('8', {}, req);
|
||||||
|
await controller.getStudents('8', req);
|
||||||
|
await controller.getTeachers('8', req);
|
||||||
|
|
||||||
|
expect(service.assertClassAccess).toHaveBeenCalledTimes(5);
|
||||||
|
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a view-only user to be assigned before reading class details', async () => {
|
||||||
|
await controller.findOne('8', request(['class:view']));
|
||||||
|
|
||||||
|
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('QueryClassDto - query transformation', () => {
|
||||||
|
const pipe = new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true });
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['false', false],
|
||||||
|
['0', false],
|
||||||
|
['true', true],
|
||||||
|
['1', true],
|
||||||
|
])('transforms isArchived=%s to %s', async (input, expected) => {
|
||||||
|
await expect(
|
||||||
|
pipe.transform(
|
||||||
|
{ isArchived: input },
|
||||||
|
{ type: 'query', metatype: QueryClassDto, data: undefined },
|
||||||
|
),
|
||||||
|
).resolves.toEqual({ isArchived: expected });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
UsePipes,
|
||||||
|
ValidationPipe,
|
||||||
Request,
|
Request,
|
||||||
Res,
|
Res,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -45,6 +47,7 @@ const teacherRoleLabels: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
|
||||||
@Controller('classes')
|
@Controller('classes')
|
||||||
export class ClassesController {
|
export class ClassesController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -54,12 +57,20 @@ export class ClassesController {
|
|||||||
private readonly authz: AuthorizationService,
|
private readonly authz: AuthorizationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private assertReadAccess(req: AuthenticatedRequest, classId: number) {
|
private canManageAllClasses(req: AuthenticatedRequest): boolean {
|
||||||
// Legacy: Manage (super_admin) or Update (class:edit) grants broad class access
|
return (
|
||||||
const canManageAll =
|
|
||||||
this.authz.can(req, CaslAction.Manage, SubjectName.Class) ||
|
this.authz.can(req, CaslAction.Manage, SubjectName.Class) ||
|
||||||
this.authz.can(req, CaslAction.Update, SubjectName.Class);
|
this.authz.can(req, CaslAction.Create, SubjectName.Class) ||
|
||||||
return this.service.assertClassAccess(req.user.id, classId, canManageAll);
|
this.authz.can(req, CaslAction.Update, SubjectName.Class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertReadAccess(req: AuthenticatedRequest, classId: number) {
|
||||||
|
return this.service.assertClassAccess(
|
||||||
|
req.user.id,
|
||||||
|
classId,
|
||||||
|
this.canManageAllClasses(req),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -67,8 +78,7 @@ export class ClassesController {
|
|||||||
async findAll(@Query() query: QueryClassDto, @Request() req: AuthenticatedRequest) {
|
async findAll(@Query() query: QueryClassDto, @Request() req: AuthenticatedRequest) {
|
||||||
const classIds = await this.service.getAccessibleClassIds(
|
const classIds = await this.service.getAccessibleClassIds(
|
||||||
req.user.id,
|
req.user.id,
|
||||||
this.authz.can(req, CaslAction.Manage, SubjectName.Class) ||
|
this.canManageAllClasses(req),
|
||||||
this.authz.can(req, CaslAction.Update, SubjectName.Class),
|
|
||||||
);
|
);
|
||||||
return this.service.findAll(query, classIds);
|
return this.service.findAll(query, classIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ArrayNotEmpty,
|
ArrayNotEmpty,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
Min,
|
Min,
|
||||||
|
IsBoolean,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Type, Transform } from 'class-transformer';
|
import { Type, Transform } from 'class-transformer';
|
||||||
import { ClassType, ClassStatus, TeacherRoleType } from '../../entities';
|
import { ClassType, ClassStatus, TeacherRoleType } from '../../entities';
|
||||||
@@ -160,6 +161,7 @@ export class QueryClassDto {
|
|||||||
if (value === 'false' || value === '0') return false;
|
if (value === 'false' || value === '0') return false;
|
||||||
return value;
|
return value;
|
||||||
})
|
})
|
||||||
|
@IsBoolean()
|
||||||
isArchived?: boolean;
|
isArchived?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ArrayUnique,
|
ArrayUnique,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
|
||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@@ -70,10 +69,6 @@ export class UpdateUserDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
isActive?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ArrayUnique()
|
@ArrayUnique()
|
||||||
|
|||||||
@@ -92,4 +92,10 @@ describe('RBAC DTO id arrays', () => {
|
|||||||
])('rejects invalid, duplicate, or non-positive ids for %p', async (metatype, value) => {
|
])('rejects invalid, duplicate, or non-positive ids for %p', async (metatype, value) => {
|
||||||
await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined();
|
await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('strips the retired isActive field from account updates', async () => {
|
||||||
|
await expect(
|
||||||
|
pipe.transform({ name: 'Alice', isActive: false }, { type: 'body', metatype: UpdateUserDto }),
|
||||||
|
).resolves.toEqual({ name: 'Alice' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,7 +35,12 @@ describe('RbacService seedData', () => {
|
|||||||
save: jest.fn(async (value: any) => value),
|
save: jest.fn(async (value: any) => value),
|
||||||
find: jest.fn(async () => [systemAdminRole]),
|
find: jest.fn(async () => [systemAdminRole]),
|
||||||
};
|
};
|
||||||
const userRepo = { count: jest.fn(async () => 1), create: jest.fn(), save: jest.fn() };
|
const userRepo = {
|
||||||
|
update: jest.fn(async () => ({ affected: 0 })),
|
||||||
|
count: jest.fn(async () => 1),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const service = new RbacService(
|
const service = new RbacService(
|
||||||
permRepo as never,
|
permRepo as never,
|
||||||
@@ -101,7 +106,12 @@ describe('RbacService seedData', () => {
|
|||||||
save: jest.fn(async (value) => value),
|
save: jest.fn(async (value) => value),
|
||||||
find: jest.fn(async () => [teacherRole]),
|
find: jest.fn(async () => [teacherRole]),
|
||||||
};
|
};
|
||||||
const userRepo = { count: jest.fn(async () => 1), create: jest.fn(), save: jest.fn() };
|
const userRepo = {
|
||||||
|
update: jest.fn(async () => ({ affected: 0 })),
|
||||||
|
count: jest.fn(async () => 1),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const service = new RbacService(
|
const service = new RbacService(
|
||||||
permRepo as never,
|
permRepo as never,
|
||||||
@@ -184,6 +194,7 @@ describe('RbacService legacy role consolidation', () => {
|
|||||||
remove: jest.fn(async (value) => value),
|
remove: jest.fn(async (value) => value),
|
||||||
};
|
};
|
||||||
const userRepo = {
|
const userRepo = {
|
||||||
|
update: jest.fn(async () => ({ affected: 0 })),
|
||||||
count: jest.fn(async () => 1),
|
count: jest.fn(async () => 1),
|
||||||
findOne: jest.fn(async () => user),
|
findOne: jest.fn(async () => user),
|
||||||
save: jest.fn(async (value) => value),
|
save: jest.fn(async (value) => value),
|
||||||
|
|||||||
@@ -296,6 +296,13 @@ export class RbacService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async seedData(): Promise<void> {
|
async seedData(): Promise<void> {
|
||||||
|
const restoredLegacyUsers = await this.userRepo.update({ isActive: false }, { isActive: true });
|
||||||
|
if (restoredLegacyUsers.affected) {
|
||||||
|
this.logger.log(
|
||||||
|
`已恢复 ${restoredLegacyUsers.affected} 个旧版禁用账号,账号状态现统一由归档管理`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Step 1: 幂等插入所有权限点(先查后插,兼容 SQLite/MySQL)
|
// Step 1: 幂等插入所有权限点(先查后插,兼容 SQLite/MySQL)
|
||||||
for (const p of PRESET_PERMISSIONS) {
|
for (const p of PRESET_PERMISSIONS) {
|
||||||
const exists = await this.permRepo.findOne({ where: { code: p.code } });
|
const exists = await this.permRepo.findOne({ where: { code: p.code } });
|
||||||
@@ -568,7 +575,6 @@ export class RbacService {
|
|||||||
id: u.id,
|
id: u.id,
|
||||||
username: u.username,
|
username: u.username,
|
||||||
name: u.name,
|
name: u.name,
|
||||||
isActive: u.isActive,
|
|
||||||
isArchived: u.isArchived,
|
isArchived: u.isArchived,
|
||||||
studentStatus: statusMap.get(u.id) || null,
|
studentStatus: statusMap.get(u.id) || null,
|
||||||
lastLoginAt: u.lastLoginAt,
|
lastLoginAt: u.lastLoginAt,
|
||||||
@@ -597,7 +603,7 @@ export class RbacService {
|
|||||||
|
|
||||||
async updateUser(
|
async updateUser(
|
||||||
id: number,
|
id: number,
|
||||||
dto: { username?: string; name?: string; isActive?: boolean; roleIds?: number[] },
|
dto: { username?: string; name?: string; roleIds?: number[] },
|
||||||
) {
|
) {
|
||||||
const user = await this.userRepo.findOne({ where: { id }, relations: ['roles'] });
|
const user = await this.userRepo.findOne({ where: { id }, relations: ['roles'] });
|
||||||
if (!user) throw new Error('用户不存在');
|
if (!user) throw new Error('用户不存在');
|
||||||
@@ -607,7 +613,6 @@ export class RbacService {
|
|||||||
user.username = dto.username;
|
user.username = dto.username;
|
||||||
}
|
}
|
||||||
if (dto.name !== undefined) user.name = dto.name;
|
if (dto.name !== undefined) user.name = dto.name;
|
||||||
if (dto.isActive !== undefined) user.isActive = dto.isActive;
|
|
||||||
if (dto.roleIds !== undefined) {
|
if (dto.roleIds !== undefined) {
|
||||||
user.roles = dto.roleIds.length > 0 ? await this.resolveRoles(dto.roleIds) : [];
|
user.roles = dto.roleIds.length > 0 ? await this.resolveRoles(dto.roleIds) : [];
|
||||||
}
|
}
|
||||||
@@ -634,7 +639,7 @@ export class RbacService {
|
|||||||
async restoreUser(id: number) {
|
async restoreUser(id: number) {
|
||||||
const user = await this.userRepo.findOne({ where: { id } });
|
const user = await this.userRepo.findOne({ where: { id } });
|
||||||
if (!user) throw new Error('用户不存在');
|
if (!user) throw new Error('用户不存在');
|
||||||
await this.userRepo.update(id, { isArchived: false });
|
await this.userRepo.update(id, { isArchived: false, isActive: true });
|
||||||
return { message: '用户已恢复' };
|
return { message: '用户已恢复' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,7 +771,8 @@ export class RbacService {
|
|||||||
.where('(role.code IN (:...roleCodes) OR role.name IN (:...roleNames))', {
|
.where('(role.code IN (:...roleCodes) OR role.name IN (:...roleNames))', {
|
||||||
roleCodes: teacherRoleCodes,
|
roleCodes: teacherRoleCodes,
|
||||||
roleNames: teacherRoleNames,
|
roleNames: teacherRoleNames,
|
||||||
});
|
})
|
||||||
|
.andWhere('u.isArchived = :isArchived', { isArchived: false });
|
||||||
|
|
||||||
if (query?.search) {
|
if (query?.search) {
|
||||||
qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${query.search}%` });
|
qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${query.search}%` });
|
||||||
@@ -795,7 +801,6 @@ export class RbacService {
|
|||||||
id: u.id,
|
id: u.id,
|
||||||
username: u.username,
|
username: u.username,
|
||||||
name: u.name,
|
name: u.name,
|
||||||
isActive: u.isActive,
|
|
||||||
profile: u.profile,
|
profile: u.profile,
|
||||||
lastLoginAt: u.lastLoginAt,
|
lastLoginAt: u.lastLoginAt,
|
||||||
roles: u.roles || [],
|
roles: u.roles || [],
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export class StudentsService {
|
|||||||
: [];
|
: [];
|
||||||
const teacherMap = new Map<number, { id: number; name: string; username: string }>();
|
const teacherMap = new Map<number, { id: number; name: string; username: string }>();
|
||||||
for (const assignment of assignments) {
|
for (const assignment of assignments) {
|
||||||
if (!assignment.user || !assignment.user.isActive || assignment.user.isArchived) continue;
|
if (!assignment.user || assignment.user.isArchived) continue;
|
||||||
teacherMap.set(assignment.userId, {
|
teacherMap.set(assignment.userId, {
|
||||||
id: assignment.userId,
|
id: assignment.userId,
|
||||||
name: assignment.user.name || assignment.user.username,
|
name: assignment.user.name || assignment.user.username,
|
||||||
|
|||||||
Reference in New Issue
Block a user