diff --git a/apps/admin/src/pages/Classes/teacher-candidate.integration.test.ts b/apps/admin/src/pages/Classes/teacher-candidate.integration.test.ts index 9f561d4..ebfe45b 100644 --- a/apps/admin/src/pages/Classes/teacher-candidate.integration.test.ts +++ b/apps/admin/src/pages/Classes/teacher-candidate.integration.test.ts @@ -10,7 +10,6 @@ const baseUser = (overrides: Partial = {}): TeacherCandida id: 1, username: 'teacher', name: '测试老师', - isActive: true, isArchived: false, studentStatus: null, roles: [{ code: 'teacher', name: '任课老师' }], @@ -29,13 +28,12 @@ describe('class teacher candidates', () => { 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 = [ baseUser({ id: 1, studentStatus: 'active' }), - baseUser({ id: 2, isActive: false }), - baseUser({ id: 3, isArchived: true }), + baseUser({ id: 2, isArchived: true }), baseUser({ - id: 4, + id: 3, roles: [{ code: 'super_admin', name: '超级管理员' }], }), ]; diff --git a/apps/admin/src/pages/Classes/teacher-candidate.ts b/apps/admin/src/pages/Classes/teacher-candidate.ts index 823c72f..cae2d8c 100644 --- a/apps/admin/src/pages/Classes/teacher-candidate.ts +++ b/apps/admin/src/pages/Classes/teacher-candidate.ts @@ -7,7 +7,6 @@ export interface TeacherCandidateUser { id: number; username: string; name?: string | null; - isActive: boolean; isArchived: boolean; studentStatus?: string | null; roles?: TeacherCandidateRole[]; @@ -18,7 +17,7 @@ const isSuperAdminRole = (role: TeacherCandidateRole) => role.code === 'super_admin' || role.name === '超级管理员' || role.name === '超管'; 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; return !(user.roles || []).some(isSuperAdminRole); }; diff --git a/apps/admin/src/pages/Teachers/index.tsx b/apps/admin/src/pages/Teachers/index.tsx index add59f7..7fc003c 100644 --- a/apps/admin/src/pages/Teachers/index.tsx +++ b/apps/admin/src/pages/Teachers/index.tsx @@ -12,7 +12,6 @@ interface TeacherRow { id: number; username: string; name: string; - isActive: boolean; profile: { subjects?: string[]; joinedAt?: string; qualifications?: string } | null; lastLoginAt: string; roles: { code: string; name: string }[]; @@ -175,13 +174,6 @@ const TeachersPage: React.FC = () => { ), }, - { - title: '状态', - dataIndex: 'isActive', - key: 'status', - width: 90, - render: (v: boolean) => {v ? '在职' : '停用'}, - }, { title: '最后登录', dataIndex: 'lastLoginAt', diff --git a/apps/admin/src/pages/Users/index.tsx b/apps/admin/src/pages/Users/index.tsx index d633aac..edcf68d 100644 --- a/apps/admin/src/pages/Users/index.tsx +++ b/apps/admin/src/pages/Users/index.tsx @@ -87,7 +87,6 @@ const UsersPage: React.FC = () => { form.setFieldsValue({ username: record.username, name: record.name, - isActive: record.isActive, roleIds: record.roles?.map((r: any) => r.id) || [], }); setModalOpen(true); @@ -101,7 +100,6 @@ const UsersPage: React.FC = () => { await api.put(`/rbac/users/${editing.id}`, { username: values.username, name: values.name, - isActive: values.isActive, roleIds: values.roleIds || [], }); message.success('更新成功'); @@ -223,26 +221,6 @@ const UsersPage: React.FC = () => { ), }, - { - title: '状态', - dataIndex: 'isActive', - width: 80, - render: (v: boolean, r: any) => ( - saveCell(r, 'isActive', String(next) === 'true')} - > - {v ? '启用' : '禁用'} - - ), - }, { title: '最后登录', dataIndex: 'lastLoginAt', @@ -350,7 +328,7 @@ const UsersPage: React.FC = () => { dataSource={data} rowKey="id" loading={loading} - scroll={{ x: 1250 }} + scroll={{ x: 1150 }} pagination={false} /> @@ -382,11 +360,6 @@ const UsersPage: React.FC = () => { - {editing && ( - - - - )} { ).rejects.toThrow('账号已失效'); 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' })); + }); }); diff --git a/apps/server/src/auth/auth.service.ts b/apps/server/src/auth/auth.service.ts index 3de488d..b3dd0a0 100644 --- a/apps/server/src/auth/auth.service.ts +++ b/apps/server/src/auth/auth.service.ts @@ -38,7 +38,7 @@ export class AuthService { this.recordFailedAttempt(attemptKey); throw new UnauthorizedException('用户名或密码错误'); } - if (!user.isActive || user.isArchived) { + if (user.isArchived) { throw new UnauthorizedException('账号已失效,请联系管理员'); } const valid = await bcrypt.compare(dto.password, user.passwordHash); diff --git a/apps/server/src/auth/strategies/jwt.strategy.spec.ts b/apps/server/src/auth/strategies/jwt.strategy.spec.ts index 1443ab0..74da847 100644 --- a/apps/server/src/auth/strategies/jwt.strategy.spec.ts +++ b/apps/server/src/auth/strategies/jwt.strategy.spec.ts @@ -50,16 +50,32 @@ describe('JwtStrategy', () => { ); }); - it.each([ - [{ id: 7, isActive: false, isArchived: false, roles: [] }], - [{ id: 7, isActive: true, isArchived: true, roles: [] }], - [null], - ])('rejects disabled, archived, or deleted users', async (user) => { - const userRepo = { findOne: jest.fn().mockResolvedValue(user) }; + it.each([[{ id: 7, isArchived: true, roles: [] }], [null]])( + 'rejects archived or deleted users', + async (user) => { + const userRepo = { findOne: jest.fn().mockResolvedValue(user) }; + const strategy = new JwtStrategy(config as never, userRepo as never); + + await expect(strategy.validate({ sub: 7, username: 'teacher' })).rejects.toBeInstanceOf( + 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' })).rejects.toBeInstanceOf( - UnauthorizedException, + await expect(strategy.validate({ sub: 7, username: 'teacher' })).resolves.toEqual( + expect.objectContaining({ id: 7, username: 'teacher' }), ); }); }); diff --git a/apps/server/src/auth/strategies/jwt.strategy.ts b/apps/server/src/auth/strategies/jwt.strategy.ts index ca967a1..0e9ab64 100644 --- a/apps/server/src/auth/strategies/jwt.strategy.ts +++ b/apps/server/src/auth/strategies/jwt.strategy.ts @@ -37,7 +37,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { where: { id: payload.sub }, relations: ['roles', 'roles.permissions'], }); - if (!user || !user.isActive || user.isArchived) { + if (!user || user.isArchived) { throw new UnauthorizedException('账号已失效,请重新登录'); } diff --git a/apps/server/src/rbac/dto/rbac.dto.ts b/apps/server/src/rbac/dto/rbac.dto.ts index c75029e..5229106 100644 --- a/apps/server/src/rbac/dto/rbac.dto.ts +++ b/apps/server/src/rbac/dto/rbac.dto.ts @@ -1,7 +1,6 @@ import { ArrayUnique, IsArray, - IsBoolean, IsInt, IsOptional, IsString, @@ -70,10 +69,6 @@ export class UpdateUserDto { @IsString() name?: string; - @IsOptional() - @IsBoolean() - isActive?: boolean; - @IsOptional() @IsArray() @ArrayUnique() diff --git a/apps/server/src/rbac/rbac.boundary.spec.ts b/apps/server/src/rbac/rbac.boundary.spec.ts index 843e819..6e8067b 100644 --- a/apps/server/src/rbac/rbac.boundary.spec.ts +++ b/apps/server/src/rbac/rbac.boundary.spec.ts @@ -92,4 +92,10 @@ describe('RBAC DTO id arrays', () => { ])('rejects invalid, duplicate, or non-positive ids for %p', async (metatype, value) => { 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' }); + }); }); diff --git a/apps/server/src/rbac/rbac.seed.spec.ts b/apps/server/src/rbac/rbac.seed.spec.ts index 9f0669c..42fef73 100644 --- a/apps/server/src/rbac/rbac.seed.spec.ts +++ b/apps/server/src/rbac/rbac.seed.spec.ts @@ -35,7 +35,12 @@ describe('RbacService seedData', () => { save: jest.fn(async (value: any) => value), 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( permRepo as never, @@ -101,7 +106,12 @@ describe('RbacService seedData', () => { save: jest.fn(async (value) => value), 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( permRepo as never, @@ -184,6 +194,7 @@ describe('RbacService legacy role consolidation', () => { remove: jest.fn(async (value) => value), }; const userRepo = { + update: jest.fn(async () => ({ affected: 0 })), count: jest.fn(async () => 1), findOne: jest.fn(async () => user), save: jest.fn(async (value) => value), diff --git a/apps/server/src/rbac/rbac.service.ts b/apps/server/src/rbac/rbac.service.ts index 88ace4a..638f301 100644 --- a/apps/server/src/rbac/rbac.service.ts +++ b/apps/server/src/rbac/rbac.service.ts @@ -296,6 +296,13 @@ export class RbacService { } async seedData(): Promise { + const restoredLegacyUsers = await this.userRepo.update({ isActive: false }, { isActive: true }); + if (restoredLegacyUsers.affected) { + this.logger.log( + `已恢复 ${restoredLegacyUsers.affected} 个旧版禁用账号,账号状态现统一由归档管理`, + ); + } + // Step 1: 幂等插入所有权限点(先查后插,兼容 SQLite/MySQL) for (const p of PRESET_PERMISSIONS) { const exists = await this.permRepo.findOne({ where: { code: p.code } }); @@ -568,7 +575,6 @@ export class RbacService { id: u.id, username: u.username, name: u.name, - isActive: u.isActive, isArchived: u.isArchived, studentStatus: statusMap.get(u.id) || null, lastLoginAt: u.lastLoginAt, @@ -597,7 +603,7 @@ export class RbacService { async updateUser( 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'] }); if (!user) throw new Error('用户不存在'); @@ -607,7 +613,6 @@ export class RbacService { user.username = dto.username; } if (dto.name !== undefined) user.name = dto.name; - if (dto.isActive !== undefined) user.isActive = dto.isActive; if (dto.roleIds !== undefined) { user.roles = dto.roleIds.length > 0 ? await this.resolveRoles(dto.roleIds) : []; } @@ -634,7 +639,7 @@ export class RbacService { async restoreUser(id: number) { const user = await this.userRepo.findOne({ where: { id } }); if (!user) throw new Error('用户不存在'); - await this.userRepo.update(id, { isArchived: false }); + await this.userRepo.update(id, { isArchived: false, isActive: true }); return { message: '用户已恢复' }; } @@ -766,7 +771,8 @@ export class RbacService { .where('(role.code IN (:...roleCodes) OR role.name IN (:...roleNames))', { roleCodes: teacherRoleCodes, roleNames: teacherRoleNames, - }); + }) + .andWhere('u.isArchived = :isArchived', { isArchived: false }); if (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, username: u.username, name: u.name, - isActive: u.isActive, profile: u.profile, lastLoginAt: u.lastLoginAt, roles: u.roles || [], diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index 26a2279..30c1ed5 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -140,7 +140,7 @@ export class StudentsService { : []; const teacherMap = new Map(); 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, { id: assignment.userId, name: assignment.user.name || assignment.user.username,