fix: align permission navigation and page access

This commit is contained in:
2026-07-11 14:55:49 +08:00
parent 1e1c476bc3
commit b6fca99390
27 changed files with 434 additions and 49 deletions

View File

@@ -32,6 +32,12 @@ export class DepositsController {
@InjectRepository(Student) private studentRepo: Repository<Student>,
) {}
@Get('student-lookups')
@RequirePermission('deposit:create')
getStudentLookups() {
return this.service.getStudentLookups();
}
@Get()
@RequirePermission('deposit:view')
findAll(@Query('studentId') studentId?: string, @Query('status') status?: string) {

View File

@@ -0,0 +1,15 @@
import { DepositsService } from './deposits.service';
describe('DepositsService permission-scoped lookups', () => {
it('returns only minimal student fields needed by deposit forms', async () => {
const studentRepo = {
find: jest.fn().mockResolvedValue([{ id: 2, name: '张三', studentNo: 'S2' }]),
};
const service = new DepositsService({} as never, {} as never, studentRepo as never);
await expect(service.getStudentLookups()).resolves.toEqual([
{ id: 2, name: '张三', studentNo: 'S2' },
]);
expect(studentRepo.find).toHaveBeenCalledWith(expect.objectContaining({ select: ['id', 'name', 'studentNo'] }));
});
});

View File

@@ -18,6 +18,14 @@ export class DepositsService {
private studentRepo: Repository<Student>,
) {}
async getStudentLookups() {
return this.studentRepo.find({
select: ['id', 'name', 'studentNo'],
where: { status: 'active' },
order: { name: 'ASC' },
});
}
async findAll(query?: { studentId?: number; status?: string }) {
const qb = this.repo
.createQueryBuilder('d')

View File

@@ -72,6 +72,12 @@ export class ExpensesController {
private logService: OperationLogsService,
) {}
@Get('lookups')
@RequirePermission('expense:create', 'expense:edit')
getFormLookups() {
return this.service.getFormLookups();
}
@Post('room')
@RequirePermission('expense:create')
async createRoomExpense(@Body() dto: CreateRoomExpenseDto, @Request() req: any) {

View File

@@ -0,0 +1,25 @@
import { ExpensesService } from './expenses.service';
describe('ExpensesService permission-scoped lookups', () => {
it('returns only minimal room and student fields needed by expense forms', async () => {
const roomRepo = {
find: jest.fn().mockResolvedValue([{ id: 1, roomNumber: '101', building: 'A' }]),
};
const studentRepo = {
find: jest.fn().mockResolvedValue([{ id: 2, name: '张三', studentNo: 'S2' }]),
};
const service = new ExpensesService(
{} as never,
{} as never,
roomRepo as never,
studentRepo as never,
);
await expect(service.getFormLookups()).resolves.toEqual({
rooms: [{ id: 1, roomNumber: '101', building: 'A' }],
students: [{ id: 2, name: '张三', studentNo: 'S2' }],
});
expect(roomRepo.find).toHaveBeenCalledWith(expect.objectContaining({ select: ['id', 'roomNumber', 'building'] }));
expect(studentRepo.find).toHaveBeenCalledWith(expect.objectContaining({ select: ['id', 'name', 'studentNo'] }));
});
});

View File

@@ -22,6 +22,21 @@ export class ExpensesService {
@InjectRepository(Student) private studentRepo: Repository<Student>,
) {}
async getFormLookups() {
const [rooms, students] = await Promise.all([
this.roomRepo.find({
select: ['id', 'roomNumber', 'building'],
order: { building: 'ASC', roomNumber: 'ASC' },
}),
this.studentRepo.find({
select: ['id', 'name', 'studentNo'],
where: { status: 'active' },
order: { name: 'ASC' },
}),
]);
return { rooms, students };
}
// 宿舍费用
async createRoomExpense(dto: CreateRoomExpenseDto, userId?: number) {
const room = await this.roomRepo.findOne({ where: { id: dto.roomId } });

View File

@@ -30,7 +30,7 @@ export class IntegrationConfigController {
/** 保存配置 */
@Post()
@RequirePermission('integration:read')
@RequirePermission('integration:trigger')
async saveConfig(@Body() body: SaveConfigRequest) {
await this.service.saveConfig(body);
return { success: true, message: '配置已保存' };

View File

@@ -1,12 +1,17 @@
import { PRESET_ROLES } from './rbac.service';
function permissionsFor(roleCode: string): { groups: string[]; extras: string[] } {
const role = PRESET_ROLES.find((item) => item.code === roleCode);
if (!role) throw new Error(`missing role ${roleCode}`);
return { groups: role.permissionGroups, extras: role.extraPermissions ?? [] };
}
describe('preset role permissions', () => {
it('gives teachers explicit workspace permissions without class/schedule delete privileges', () => {
const teacher = PRESET_ROLES.find((role) => role.code === 'teacher');
const teacher = permissionsFor('teacher');
expect(teacher).toBeDefined();
expect(teacher?.permissionGroups).toEqual(['notification', 'profile']);
expect(teacher?.extraPermissions).toEqual(
expect(teacher.groups).toEqual(['notification', 'profile']);
expect(teacher.extras).toEqual(
expect.arrayContaining([
'student:view',
'class:view',
@@ -16,8 +21,18 @@ describe('preset role permissions', () => {
'attendance:export',
]),
);
expect(teacher?.extraPermissions).not.toEqual(
expect(teacher.extras).not.toEqual(
expect.arrayContaining(['class:delete', 'schedule:delete']),
);
});
it('gives institution heads every read permission required by the classroom rental pages', () => {
const role = permissionsFor('institution_head');
expect(role.groups).toEqual(expect.arrayContaining(['classroom', 'rental', 'organization']));
});
it('keeps roles without dashboard access off the dashboard', () => {
expect(permissionsFor('teacher').groups).not.toContain('dashboard');
expect(permissionsFor('institution_head').groups).not.toContain('dashboard');
});
});

View File

@@ -56,6 +56,16 @@ export class SchedulesController {
);
}
@Get('lookups')
@RequirePermission('schedule:view')
async getLookups(@Request() req: { user: RequestUser }) {
const classIds = await this.service.getAccessibleClassIds(
req.user.id,
this.canManageAllSchedules(req),
);
return this.service.getLookups(classIds);
}
@Get()
@RequirePermission('schedule:view')
async findAll(@Query() query: QueryScheduleDto, @Request() req: { user: RequestUser }) {

View File

@@ -0,0 +1,31 @@
import { SchedulesService } from './schedules.service';
describe('SchedulesService permission-scoped lookups', () => {
it('returns scoped classes and minimal classrooms for schedule viewers', async () => {
const classRepo = {
find: jest.fn().mockResolvedValue([{ id: 3, name: '三班', code: 'C3' }]),
};
const scheduleRepo = {
createQueryBuilder: jest.fn().mockReturnValue({
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
distinct: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue([{ classroomId: 5, classroomName: '教室5', classroomBuilding: 'A' }]),
}),
};
const service = new SchedulesService(
scheduleRepo as never,
classRepo as never,
{} as never,
{} as never,
);
await expect(service.getLookups([3])).resolves.toEqual({
classes: [{ id: 3, name: '三班', code: 'C3' }],
classrooms: [{ id: 5, name: '教室5', building: 'A' }],
});
});
});

View File

@@ -5,7 +5,7 @@ import {
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { ClassSchedule, Class, ClassroomRental, ClassTeacher } from '../entities';
import {
@@ -33,6 +33,41 @@ export class SchedulesService {
return [...new Set(assignments.map((assignment) => assignment.classId))];
}
async getLookups(accessibleClassIds?: number[]) {
const classes = accessibleClassIds
? accessibleClassIds.length > 0
? await this.classRepo.find({
where: { id: In(accessibleClassIds) },
select: ['id', 'name', 'code'],
order: { name: 'ASC' },
})
: []
: await this.classRepo.find({
select: ['id', 'name', 'code'],
order: { name: 'ASC' },
});
const classroomRows = await this.scheduleRepo
.createQueryBuilder('schedule')
.select('classroom.id', 'classroomId')
.addSelect('classroom.name', 'classroomName')
.addSelect('classroom.building', 'classroomBuilding')
.innerJoin('schedule.classroom', 'classroom')
.distinct(true)
.orderBy('classroom.building', 'ASC')
.addOrderBy('classroom.name', 'ASC')
.getRawMany();
return {
classes,
classrooms: classroomRows.map((row) => ({
id: Number(row.classroomId),
name: String(row.classroomName ?? ''),
building: String(row.classroomBuilding ?? ''),
})),
};
}
async findAll(query: QueryScheduleDto, accessibleClassIds?: number[]) {
const qb = this.scheduleRepo.createQueryBuilder('cs');