From c98d37307e9772d47e3f9a8c63f6519cd0afa872 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 23 Jul 2026 11:32:13 +0800 Subject: [PATCH 1/3] fix: align permission-gated UI actions --- apps/admin/src/api/index.ts | 3 +- .../permission-state.integration.test.tsx | 67 +++ apps/admin/src/auth/permission-store.ts | 43 +- apps/admin/src/components/DefaultRoute.tsx | 7 +- .../src/components/JinshujuMatchModal.tsx | 385 +++++++++++++----- apps/admin/src/components/PermissionRoute.tsx | 7 +- .../StudentProfileContent/index.tsx | 190 +++++---- apps/admin/src/hooks/usePermission.ts | 33 +- apps/admin/src/layouts/MainLayout.tsx | 10 +- apps/admin/src/pages/AiConfig/index.tsx | 24 +- .../Attendance/LessonAttendanceDetail.tsx | 4 + apps/admin/src/pages/Attendance/index.tsx | 32 +- .../src/pages/ClassroomRentals/index.tsx | 16 +- apps/admin/src/pages/Classrooms/index.tsx | 46 ++- apps/admin/src/pages/Exams/detail.tsx | 108 ++++- apps/admin/src/pages/Expenses/index.tsx | 100 ++--- .../src/pages/IntegrationConfig/index.tsx | 105 ++--- apps/admin/src/pages/Login/index.tsx | 9 +- apps/admin/src/pages/Occupancies/index.tsx | 219 ++++++---- apps/admin/src/pages/RoomVisual/index.tsx | 33 +- apps/admin/src/pages/Rooms/index.tsx | 239 +++++------ apps/admin/src/pages/Students/index.tsx | 262 +++++++----- apps/admin/src/pages/Teachers/index.tsx | 17 +- .../occupancies.controller.spec.ts | 26 ++ .../src/occupancies/occupancies.controller.ts | 4 +- apps/server/src/students/dto/student.dto.ts | 3 +- .../src/students/students.lifecycle.spec.ts | 27 +- apps/server/src/students/students.service.ts | 39 +- 28 files changed, 1340 insertions(+), 718 deletions(-) create mode 100644 apps/admin/src/auth/permission-state.integration.test.tsx create mode 100644 apps/server/src/occupancies/occupancies.controller.spec.ts diff --git a/apps/admin/src/api/index.ts b/apps/admin/src/api/index.ts index 20af776..cc2e4c1 100644 --- a/apps/admin/src/api/index.ts +++ b/apps/admin/src/api/index.ts @@ -1,4 +1,5 @@ import axios, { type AxiosRequestConfig } from 'axios'; +import { clearPermissions } from '../auth/permission-store'; const instance = axios.create({ baseURL: '/api', @@ -21,7 +22,7 @@ instance.interceptors.response.use( if (err.response?.status === 401 && !isLoginRequest) { localStorage.removeItem('token'); localStorage.removeItem('user'); - localStorage.removeItem('permissions'); + clearPermissions(); window.location.href = '/login'; } if (err.response?.status === 403) { diff --git a/apps/admin/src/auth/permission-state.integration.test.tsx b/apps/admin/src/auth/permission-state.integration.test.tsx new file mode 100644 index 0000000..bd93b98 --- /dev/null +++ b/apps/admin/src/auth/permission-state.integration.test.tsx @@ -0,0 +1,67 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import PermissionButton from '../components/PermissionButton'; +import { + beginPermissionVerification, + clearPermissions, + readPermissionState, + writePermissions, +} from './permission-store'; + +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; + +beforeAll(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +async function renderPermissionButton() { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render(编辑学生); + }); +} + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + container?.remove(); + root = null; + container = null; + clearPermissions(); +}); + +describe('permission state', () => { + it('ignores cached localStorage permissions until profile verification succeeds', async () => { + localStorage.setItem('permissions', JSON.stringify(['student:edit'])); + beginPermissionVerification(); + + expect(readPermissionState()).toEqual({ permissions: [], status: 'loading' }); + await renderPermissionButton(); + expect(container?.textContent).not.toContain('编辑学生'); + }); + + it('renders permission actions only after verified permissions are written', async () => { + beginPermissionVerification(); + await renderPermissionButton(); + expect(container?.textContent).not.toContain('编辑学生'); + + await act(async () => writePermissions(['student:edit'])); + expect(container?.textContent).toContain('编辑学生'); + }); + + it('fails closed after profile refresh failure', async () => { + writePermissions(['student:edit']); + beginPermissionVerification(); + clearPermissions('ready'); + + expect(readPermissionState()).toEqual({ permissions: [], status: 'ready' }); + await renderPermissionButton(); + expect(container?.textContent).not.toContain('编辑学生'); + expect(localStorage.getItem('permissions')).toBeNull(); + }); +}); diff --git a/apps/admin/src/auth/permission-store.ts b/apps/admin/src/auth/permission-store.ts index ecc0d8a..e20e888 100644 --- a/apps/admin/src/auth/permission-store.ts +++ b/apps/admin/src/auth/permission-store.ts @@ -1,17 +1,40 @@ export const PERMISSIONS_UPDATED_EVENT = 'permissions-updated'; +export type PermissionStatus = 'unknown' | 'loading' | 'ready'; + +export interface PermissionState { + permissions: string[]; + status: PermissionStatus; +} + +let permissionState: PermissionState = { permissions: [], status: 'unknown' }; + +function notifyPermissionStateChanged(): void { + window.dispatchEvent(new Event(PERMISSIONS_UPDATED_EVENT)); +} + +export function readPermissionState(): PermissionState { + return permissionState; +} + export function readPermissions(): string[] { - try { - const value = JSON.parse(localStorage.getItem('permissions') || '[]'); - return Array.isArray(value) - ? value.filter((item): item is string => typeof item === 'string') - : []; - } catch { - return []; - } + return permissionState.status === 'ready' ? permissionState.permissions : []; +} + +export function beginPermissionVerification(): void { + permissionState = { permissions: [], status: 'loading' }; + notifyPermissionStateChanged(); } export function writePermissions(permissions: string[]): void { - localStorage.setItem('permissions', JSON.stringify([...new Set(permissions)])); - window.dispatchEvent(new Event(PERMISSIONS_UPDATED_EVENT)); + const uniquePermissions = [...new Set(permissions)]; + localStorage.setItem('permissions', JSON.stringify(uniquePermissions)); + permissionState = { permissions: uniquePermissions, status: 'ready' }; + notifyPermissionStateChanged(); +} + +export function clearPermissions(status: PermissionStatus = 'unknown'): void { + localStorage.removeItem('permissions'); + permissionState = { permissions: [], status }; + notifyPermissionStateChanged(); } diff --git a/apps/admin/src/components/DefaultRoute.tsx b/apps/admin/src/components/DefaultRoute.tsx index 9366729..3c8da54 100644 --- a/apps/admin/src/components/DefaultRoute.tsx +++ b/apps/admin/src/components/DefaultRoute.tsx @@ -1,11 +1,14 @@ import React from 'react'; import { Navigate } from 'react-router-dom'; -import { Result } from 'antd'; +import { Result, Spin } from 'antd'; import { usePermission } from '../hooks/usePermission'; import { findRoleAwareLandingPath } from '../auth/menu-policy'; const DefaultRoute: React.FC = () => { - const { permissions } = usePermission(); + const { permissions, permissionsReady } = usePermission(); + if (!permissionsReady) { + return ; + } const roles = (() => { try { return JSON.parse(localStorage.getItem('user') || '{}').roles || []; diff --git a/apps/admin/src/components/JinshujuMatchModal.tsx b/apps/admin/src/components/JinshujuMatchModal.tsx index 1dddaa8..a65312d 100644 --- a/apps/admin/src/components/JinshujuMatchModal.tsx +++ b/apps/admin/src/components/JinshujuMatchModal.tsx @@ -11,6 +11,8 @@ import { } from '@ant-design/icons'; import api from '../api'; import { message } from '../ui/app-message'; +import { usePermission } from '../hooks/usePermission'; +import PermissionButton from './PermissionButton'; const { Text } = Typography; @@ -86,7 +88,12 @@ interface MatchSelectorProps { onChange: (d: MatchDecision) => void; } -const MatchSelector: React.FC = ({ entry, decision, studentOptions, onChange }) => { +const MatchSelector: React.FC = ({ + entry, + decision, + studentOptions, + onChange, +}) => { const action = decision?.action ?? 'skip'; if (action === 'match') { @@ -94,7 +101,9 @@ const MatchSelector: React.FC = ({ entry, decision, studentO const matchedStudent = studentOptions.find((s) => s.id === matchD.matchStudentId); return (
- }>已匹配 + }> + 已匹配 + {matchedStudent?.name ?? '未知'} {matchedStudent?.studentNo && ( @@ -103,7 +112,9 @@ const MatchSelector: React.FC = ({ entry, decision, studentO )} - +
); } @@ -112,30 +123,76 @@ const MatchSelector: React.FC = ({ entry, decision, studentO const createD = decision as { action: 'create'; createName: string; createPhone: string }; return (
- }>将新建 - onChange({ action: 'create', createName: e.target.value, createPhone: createD.createPhone })} /> - onChange({ action: 'create', createName: createD.createName, createPhone: e.target.value })} /> - + }> + 将新建 + + + onChange({ + action: 'create', + createName: e.target.value, + createPhone: createD.createPhone, + }) + } + /> + + onChange({ + action: 'create', + createName: createD.createName, + createPhone: e.target.value, + }) + } + /> +
); } return (
- + ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()) + } options={studentOptions.map((s) => ({ value: s.id, label: `${s.name}${s.phone ? ` (${s.phone})` : ''}${s.studentNo ? ` [${s.studentNo}]` : ''}`, }))} - onChange={(studentId: number) => onChange({ action: 'match', matchStudentId: studentId })} /> - - +
); }; @@ -151,13 +208,25 @@ interface RuleEditorProps { onCancel: () => void; } -const RuleEditor: React.FC = ({ rule, formToken, fields, onSave, onDelete, onCancel }) => { +const RuleEditor: React.FC = ({ + rule, + formToken, + fields, + onSave, + onDelete, + onCancel, +}) => { const [name, setName] = useState(rule?.name ?? ''); - const [mappings, setMappings] = useState>(rule?.mappings ?? { name: 'field_1', phone: 'field_2' }); + const [mappings, setMappings] = useState>( + rule?.mappings ?? { name: 'field_1', phone: 'field_2' }, + ); const [saving, setSaving] = useState(false); const handleSave = async () => { - if (!name.trim()) { message.warning('请输入规则名称'); return; } + if (!name.trim()) { + message.warning('请输入规则名称'); + return; + } setSaving(true); try { if (rule) { @@ -170,18 +239,31 @@ const RuleEditor: React.FC = ({ rule, formToken, fields, onSave } catch (e: unknown) { const err = e as { message?: string }; if (err?.message) message.error(err.message); - } finally { setSaving(false); } + } finally { + setSaving(false); + } }; return (
- setName(e.target.value)} - style={{ marginBottom: 12 }} /> - 选择金数据字段映射到学生资料 + setName(e.target.value)} + style={{ marginBottom: 12 }} + /> + + 选择金数据字段映射到学生资料 + {STUDENT_FIELDS.map((sf) => ( -
+
{sf.label} - + + ← + - - - - - - + + + + + + } - placeholder="用户名" - autoComplete="username" - /> + } placeholder="用户名" autoComplete="username" /> { + const { hasPermission } = usePermission(); + const canCheckIn = hasPermission('occupancy:checkin'); const [data, setData] = useState([]); const [students, setStudents] = useState([]); const [rooms, setRooms] = useState([]); @@ -93,7 +96,12 @@ const OccupanciesPage: React.FC = () => { [data, selectedRowKeys], ); const latestSelectedCheckInDate = useMemo( - () => selectedBatchRecords.map((item) => item.checkInDate).filter(Boolean).sort().at(-1), + () => + selectedBatchRecords + .map((item) => item.checkInDate) + .filter(Boolean) + .sort() + .at(-1), [selectedBatchRecords], ); const latestSelectedBillingStartDate = useMemo( @@ -106,7 +114,8 @@ const OccupanciesPage: React.FC = () => { [selectedBatchRecords], ); - const dateNotBefore = (start: string | Dayjs | null | undefined, messageText: string) => + const dateNotBefore = + (start: string | Dayjs | null | undefined, messageText: string) => (_: unknown, value?: Dayjs | null) => { if (!value || !start) return Promise.resolve(); const startDate = dayjs.isDayjs(start) ? start : dayjs(start); @@ -457,46 +466,77 @@ const OccupanciesPage: React.FC = () => { > 入住登记 - { - const formData = new FormData(); - formData.append('file', file); - const params = new URLSearchParams(); - if (autoDeposit) { - params.set('autoDeposit', 'true'); - params.set('depositAmount', String(depositAmount)); - } - try { - const res: any = await api.post( - `/occupancies/import?${params.toString()}`, - formData, - { headers: { 'Content-Type': 'multipart/form-data' } }, - ); - if (res.errors?.length > 0) { - Modal.warning({ - title: res.message, - content: res.errors.join('\n'), - width: 500, - }); - } else { - message.success(res.message); - } - onSuccess?.(res); - fetchData(); - } catch (e: any) { - message.error(e?.message || '导入失败'); - onError?.(e); - } - }} - > - - - - + {canCheckIn ? ( + <> + { + const formData = new FormData(); + formData.append('file', file); + const params = new URLSearchParams(); + if (autoDeposit) { + params.set('autoDeposit', 'true'); + params.set('depositAmount', String(depositAmount)); + } + try { + const res: any = await api.post( + `/occupancies/import?${params.toString()}`, + formData, + { headers: { 'Content-Type': 'multipart/form-data' } }, + ); + if (res.errors?.length > 0) { + Modal.warning({ + title: res.message, + content: res.errors.join('\n'), + width: 500, + }); + } else { + message.success(res.message); + } + onSuccess?.(res); + fetchData(); + } catch (e: any) { + message.error(e?.message || '导入失败'); + onError?.(e); + } + }} + > + + + + + + + 导入时自动收押金 + {autoDeposit && ( + + setDepositAmount(v || 500)} + style={{ width: 60 }} + /> + + 元 + + + )} + + + ) : null} } @@ -521,33 +561,6 @@ const OccupanciesPage: React.FC = () => { > 导出记录 - - - 导入时自动收押金 - {autoDeposit && ( - - setDepositAmount(v || 500)} - style={{ width: 60 }} - /> - - 元 - - - )} -
{selectedRowKeys.length > 0 && ( @@ -642,7 +655,11 @@ const OccupanciesPage: React.FC = () => { .filter((s: any) => s.status === 'active') .map((s: any) => { const activeOccupancy = activeOccupancyByStudentId.get(s.id); - const identifier = s.idNumber ? maskIdNumber(s.idNumber) : s.phone ? maskPhone(s.phone) : ''; + const identifier = s.idNumber + ? maskIdNumber(s.idNumber) + : s.phone + ? maskPhone(s.phone) + : ''; return { value: s.id, label: `${s.name} (${identifier})${activeOccupancy ? ` · 已入住${activeOccupancy.room?.roomNumber ? ` ${activeOccupancy.room.roomNumber}` : ''}` : ''}`, @@ -668,18 +685,25 @@ const OccupanciesPage: React.FC = () => { }))} /> - + ({ - validator: dateNotBefore(getFieldValue('checkInDate'), '计费起始日不能早于入住日期'), + validator: dateNotBefore( + getFieldValue('checkInDate'), + '计费起始日不能早于入住日期', + ), }), ]} > @@ -689,7 +713,11 @@ const OccupanciesPage: React.FC = () => { format="YYYY-MM-DD" /> - + ({ value: b.id, label: b.bedNumber, @@ -732,7 +762,9 @@ const OccupanciesPage: React.FC = () => { allowClear placeholder="可选分配柜子" loading={availableResourcesLoading} - disabled={!selectedCheckInRoomId || availableResourcesLoading || availableLockers.length === 0} + disabled={ + !selectedCheckInRoomId || availableResourcesLoading || availableLockers.length === 0 + } options={availableLockers.map((l) => ({ value: l.id, label: l.lockerNumber, @@ -792,12 +824,14 @@ const OccupanciesPage: React.FC = () => { ({ validator: dateNotBefore( - checkOutModal?.billingStartDate || checkOutModal?.checkInDate || getFieldValue('checkOutDate'), + checkOutModal?.billingStartDate || + checkOutModal?.checkInDate || + getFieldValue('checkOutDate'), '计费截止日不能早于计费起始日', ), }), @@ -948,7 +982,11 @@ const OccupanciesPage: React.FC = () => { - + {canViewOrganizations ? ( + + ) : null} - - ({ + value: organization.id, + label: organization.isHost + ? `${organization.name}(本机构)` + : organization.name, + }), + )} + /> + + ) : null} @@ -968,11 +1007,16 @@ const StudentsPage: React.FC = () => { - setJinshujuOpen(false)} - onApplied={() => { setJinshujuOpen(false); fetchData(); }} - /> + {hasPermission('sync:read') ? ( + setJinshujuOpen(false)} + onApplied={() => { + setJinshujuOpen(false); + fetchData(); + }} + /> + ) : null} = { const DEFAULT_PAGE_SIZE = 20; const TeachersPage: React.FC = () => { + const { hasPermission } = usePermission(); + const canEditTeachers = hasPermission('teacher:edit'); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [total, setTotal] = useState(0); @@ -190,7 +194,8 @@ const TeachersPage: React.FC = () => { key: 'actions', width: 100, render: (_: unknown, r: TeacherRow) => ( - + ), }, ], - [saveProfileCell], + [saveProfileCell, form], ); return ( @@ -263,8 +268,8 @@ const TeachersPage: React.FC = () => { /> setProfileModal(null)} okText="保存" confirmLoading={saving} diff --git a/apps/server/src/occupancies/occupancies.controller.spec.ts b/apps/server/src/occupancies/occupancies.controller.spec.ts new file mode 100644 index 0000000..605d00b --- /dev/null +++ b/apps/server/src/occupancies/occupancies.controller.spec.ts @@ -0,0 +1,26 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { OccupanciesController } from './occupancies.controller'; + +describe('OccupanciesController permissions', () => { + it('requires occupancy:delete for single and batch archive actions', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.remove)).toEqual([ + 'occupancy:delete', + ]); + expect( + Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.batchRemove), + ).toEqual(['occupancy:delete']); + }); + + it('keeps read-only endpoints on occupancy:view', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.findAll)).toEqual([ + 'occupancy:view', + ]); + expect( + Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.exportExcel), + ).toEqual(['occupancy:view']); + expect( + Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.downloadTemplate), + ).toEqual(['occupancy:view']); + }); +}); diff --git a/apps/server/src/occupancies/occupancies.controller.ts b/apps/server/src/occupancies/occupancies.controller.ts index b2746ee..dc1e0af 100644 --- a/apps/server/src/occupancies/occupancies.controller.ts +++ b/apps/server/src/occupancies/occupancies.controller.ts @@ -159,7 +159,7 @@ export class OccupanciesController { } @Delete(':id') - @RequirePermission('occupancy:view') + @RequirePermission('occupancy:delete') async remove(@Param('id') id: string, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(+id); @@ -177,7 +177,7 @@ export class OccupanciesController { } @Post('batch-delete') - @RequirePermission('occupancy:view') + @RequirePermission('occupancy:delete') async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRemove(body.ids || []); diff --git a/apps/server/src/students/dto/student.dto.ts b/apps/server/src/students/dto/student.dto.ts index 276e1c8..18d53c2 100644 --- a/apps/server/src/students/dto/student.dto.ts +++ b/apps/server/src/students/dto/student.dto.ts @@ -33,8 +33,9 @@ export class CreateStudentDto { @IsString() emergencyPhone?: string; + @IsOptional() @IsInt() - organizationId: number; + organizationId?: number; @IsOptional() @IsString() diff --git a/apps/server/src/students/students.lifecycle.spec.ts b/apps/server/src/students/students.lifecycle.spec.ts index fbe7b11..3a85d87 100644 --- a/apps/server/src/students/students.lifecycle.spec.ts +++ b/apps/server/src/students/students.lifecycle.spec.ts @@ -43,6 +43,21 @@ describe('StudentsService — archive lifecycle boundaries', () => { expect(repo.save).not.toHaveBeenCalled(); }); + it('defaults a new student to the active host organization when none is supplied', async () => { + const repo = { + create: jest.fn((value) => value), + save: jest.fn(async (value) => ({ ...value, id: 1 })), + }; + const organizationRepo = { + findOne: jest.fn().mockResolvedValue({ id: 7, isHost: true, status: 'active' }), + }; + + await expect(createService(repo, organizationRepo).create({ name: '张三' })).resolves.toEqual( + expect.objectContaining({ organizationId: 7 }), + ); + expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ organizationId: 7 })); + }); + it('returns not found for a missing student', async () => { const repo = { findOne: jest.fn().mockResolvedValue(null) }; await expect(createService(repo).findOne(404)).rejects.toBeInstanceOf(NotFoundException); @@ -50,11 +65,13 @@ describe('StudentsService — archive lifecycle boundaries', () => { it('builds export archive maps from profile and result rows', async () => { const profileRepo = { - find: jest.fn().mockResolvedValue([{ - studentId: 1, - targetCollege: '北京大学', - collegeSchool: '北京职业技术学院', - }]), + find: jest.fn().mockResolvedValue([ + { + studentId: 1, + targetCollege: '北京大学', + collegeSchool: '北京职业技术学院', + }, + ]), }; const resultRepo = { find: jest.fn().mockResolvedValue([{ studentId: 1, admissionStatus: 'pending' }]), diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index 26a2279..4deafab 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -164,8 +164,9 @@ export class StudentsService { } async create(dto: CreateStudentDto) { - await this.assertActiveOrganization(dto.organizationId); - return this.repo.save(this.repo.create(dto)); + const organizationId = dto.organizationId || (await this.getHostOrganizationId()); + await this.assertActiveOrganization(organizationId); + return this.repo.save(this.repo.create({ ...dto, organizationId })); } async update(id: number, dto: UpdateStudentDto) { @@ -314,7 +315,9 @@ export class StudentsService { }; } - private normalizeImportData(importData: StudentWorkbookImport | StudentImportRow[]): StudentWorkbookImport { + private normalizeImportData( + importData: StudentWorkbookImport | StudentImportRow[], + ): StudentWorkbookImport { if (Array.isArray(importData)) { return { students: importData, enrollments: [], examScores: [], learningRecords: [] }; } @@ -370,18 +373,24 @@ export class StudentsService { if (!phone) return imported; const enrollmentByClassName = new Map(); - for (const enrollmentRow of data.enrollments.filter((item) => this.normalizePhone(item.phone) === phone)) { + for (const enrollmentRow of data.enrollments.filter( + (item) => this.normalizePhone(item.phone) === phone, + )) { const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow); if (!enrollment) continue; if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment); imported++; } - for (const examRow of data.examScores.filter((item) => this.normalizePhone(item.phone) === phone)) { + for (const examRow of data.examScores.filter( + (item) => this.normalizePhone(item.phone) === phone, + )) { if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) { imported++; } } - for (const learningRow of data.learningRecords.filter((item) => this.normalizePhone(item.phone) === phone)) { + for (const learningRow of data.learningRecords.filter( + (item) => this.normalizePhone(item.phone) === phone, + )) { if (await this.upsertLearningRecordFromImport(studentId, learningRow)) { imported++; } @@ -390,7 +399,9 @@ export class StudentsService { } private async upsertProfileFromImport(studentId: number, row: StudentImportRow) { - const entity = (await this.profileRepo.findOne({ where: { studentId } })) || this.profileRepo.create({ studentId }); + const entity = + (await this.profileRepo.findOne({ where: { studentId } })) || + this.profileRepo.create({ studentId }); if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim(); if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim(); if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim(); @@ -403,9 +414,12 @@ export class StudentsService { } private async upsertResultFromImport(studentId: number, row: StudentImportRow) { - const entity = (await this.resultRepo.findOne({ where: { studentId } })) || this.resultRepo.create({ studentId }); + const entity = + (await this.resultRepo.findOne({ where: { studentId } })) || + this.resultRepo.create({ studentId }); if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore; - if (row.professionalFinalScore !== undefined) entity.professionalFinalScore = row.professionalFinalScore; + if (row.professionalFinalScore !== undefined) + entity.professionalFinalScore = row.professionalFinalScore; if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim(); if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim(); if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim(); @@ -623,10 +637,9 @@ export class StudentsService { // ---- Filters ---- if (query?.keyword) { - qb.andWhere( - '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', - { keyword: `%${query.keyword}%` }, - ); + qb.andWhere('(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', { + keyword: `%${query.keyword}%`, + }); } if (query?.organizationId) { qb.andWhere('student.organizationId = :orgId', { orgId: query.organizationId }); From a7a7af166765ab44e5e79fbdd2a6aaa95931b61b Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 23 Jul 2026 12:23:33 +0800 Subject: [PATCH 2/3] fix: close permission review gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: harden permission-gated UI — minimum-org endpoint, modal/Popconfirm fail-closed on revocation --- .../permission-state.integration.test.tsx | 6 +- .../src/components/JinshujuMatchModal.tsx | 27 ++- .../StudentProfileContent/index.tsx | 23 ++- apps/admin/src/hooks/useViewSensitive.ts | 28 ++- apps/admin/src/layouts/MainLayout.tsx | 66 +++++-- apps/admin/src/pages/Attendance/index.tsx | 4 +- apps/admin/src/pages/Exams/detail.tsx | 2 +- apps/admin/src/pages/Occupancies/index.tsx | 79 +++++---- apps/admin/src/pages/Rooms/index.tsx | 99 ++++++----- apps/admin/src/pages/Students/index.tsx | 161 +++++++++++------- .../organizations.controller.spec.ts | 23 +++ .../organizations/organizations.controller.ts | 6 + .../organizations.service.spec.ts | 17 ++ .../organizations/organizations.service.ts | 8 + apps/server/src/students/dto/student.dto.ts | 3 +- .../src/students/students.lifecycle.spec.ts | 27 +-- apps/server/src/students/students.service.ts | 39 ++--- 17 files changed, 391 insertions(+), 227 deletions(-) create mode 100644 apps/server/src/organizations/organizations.controller.spec.ts diff --git a/apps/admin/src/auth/permission-state.integration.test.tsx b/apps/admin/src/auth/permission-state.integration.test.tsx index bd93b98..04d7198 100644 --- a/apps/admin/src/auth/permission-state.integration.test.tsx +++ b/apps/admin/src/auth/permission-state.integration.test.tsx @@ -54,14 +54,12 @@ describe('permission state', () => { expect(container?.textContent).toContain('编辑学生'); }); - it('fails closed after profile refresh failure', async () => { + it('stays fail-closed while profile verification is retried after a failure', async () => { writePermissions(['student:edit']); beginPermissionVerification(); - clearPermissions('ready'); - expect(readPermissionState()).toEqual({ permissions: [], status: 'ready' }); + expect(readPermissionState()).toEqual({ permissions: [], status: 'loading' }); await renderPermissionButton(); expect(container?.textContent).not.toContain('编辑学生'); - expect(localStorage.getItem('permissions')).toBeNull(); }); }); diff --git a/apps/admin/src/components/JinshujuMatchModal.tsx b/apps/admin/src/components/JinshujuMatchModal.tsx index a65312d..108479f 100644 --- a/apps/admin/src/components/JinshujuMatchModal.tsx +++ b/apps/admin/src/components/JinshujuMatchModal.tsx @@ -312,8 +312,11 @@ interface MatchModalProps { } const JinshujuMatchModal: React.FC = ({ open, onClose, onApplied }) => { - const { hasPermission } = usePermission(); + const { hasPermission, hasAllPermissions, permissionsReady } = usePermission(); + const canReadSync = hasPermission('sync:read'); const canTriggerSync = hasPermission('sync:trigger'); + const canEnterModal = permissionsReady && hasAllPermissions('sync:read', 'sync:trigger'); + const canWriteRules = permissionsReady && canTriggerSync; const [step, setStep] = useState<'connection' | 'rule' | 'match' | 'applying'>('connection'); const [loading, setLoading] = useState(false); const [selectedRuleId, setSelectedRuleId] = useState(); @@ -334,8 +337,22 @@ const JinshujuMatchModal: React.FC = ({ open, onClose, onApplie // Load rules on open useEffect(() => { - if (open) loadRules(); - }, [open]); + if (open && canEnterModal) loadRules(); + }, [open, canEnterModal]); + + // Close and reset when permission is lost + const enteredRef = useRef(false); + useEffect(() => { + if (canEnterModal) { + enteredRef.current = true; + return; + } + if (enteredRef.current) { + enteredRef.current = false; + reset(); + onClose(); + } + }, [canEnterModal, onClose]); const loadRules = async () => { try { @@ -347,6 +364,7 @@ const JinshujuMatchModal: React.FC = ({ open, onClose, onApplie }; const handleConnectionNext = async () => { + if (!canTriggerSync) return; try { const values = await credForm.validateFields(); setLoading(true); @@ -366,6 +384,7 @@ const JinshujuMatchModal: React.FC = ({ open, onClose, onApplie }; const handlePreview = async () => { + if (!canTriggerSync) return; try { const values = await credForm.validateFields(); setLoading(true); @@ -689,7 +708,7 @@ const JinshujuMatchModal: React.FC = ({ open, onClose, onApplie return ( = ({ onClose, }) => { const { hasPermission, hasAnyPermission } = usePermission(); - const canViewOrganizations = hasPermission('organization:view'); - const canChooseOrganization = - canViewOrganizations && hasAnyPermission('student:create', 'student:edit'); + const canLoadOrganizations = hasAnyPermission( + 'organization:view', + 'student:create', + 'student:edit', + ); + const canChooseOrganization = hasAnyPermission('student:create', 'student:edit'); const [aggregateData, setAggregateData] = useState(null); const [organizations, setOrganizations] = useState>([]); const [loading, setLoading] = useState(false); @@ -1426,17 +1429,17 @@ const StudentProfileContent: React.FC = ({ }, [fetchData]); useEffect(() => { - if (!canViewOrganizations) { + if (!canLoadOrganizations) { setOrganizations([]); return; } api - .get('/organizations', { params: { includeArchived: 'false' } }) + .get('/organizations/options') .then((res: unknown) => { - setOrganizations(res as Array<{ id: number; name: string }>); + setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>); }) .catch(() => {}); - }, [canViewOrganizations]); + }, [canLoadOrganizations]); const handlePreviewReport = useCallback(async () => { try { @@ -1451,7 +1454,11 @@ const StudentProfileContent: React.FC = ({ } }, [studentId]); - const handleViewSensitive = useViewSensitive(studentId, '学生档案'); + const handleViewSensitive = useViewSensitive( + studentId, + '学生档案', + hasPermission('log:create'), + ); const tabItems = useMemo(() => { if (!aggregateData) return []; diff --git a/apps/admin/src/hooks/useViewSensitive.ts b/apps/admin/src/hooks/useViewSensitive.ts index 48cc70e..6a99021 100644 --- a/apps/admin/src/hooks/useViewSensitive.ts +++ b/apps/admin/src/hooks/useViewSensitive.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { Modal } from 'antd'; import api from '../api'; import { message } from '../ui/app-message'; @@ -9,16 +9,35 @@ import { message } from '../ui/app-message'; * * @param studentId - The student whose data is being viewed * @param module - Audit module label (e.g. '学生管理', '学生档案') + * @param canLog - Whether the current user has log:create; when false any + * already-open confirm modal is destroyed. */ -export function useViewSensitive(studentId: number, module: string) { +export function useViewSensitive(studentId: number, module: string, canLog: boolean) { + const canLogRef = useRef(canLog); + const modalRef = useRef | null>(null); + canLogRef.current = canLog; + + useEffect(() => { + if (!canLogRef.current && modalRef.current) { + modalRef.current.destroy(); + modalRef.current = null; + } + return () => { + modalRef.current?.destroy(); + modalRef.current = null; + }; + }, []); + return useCallback( (field: string, value: string) => { - Modal.confirm({ + if (!canLogRef.current) return; + modalRef.current = Modal.confirm({ title: '查看敏感信息', content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`, okText: '确认查看', cancelText: '取消', onOk: async () => { + if (!canLogRef.current) return; try { await api.post('/operation-logs/audit', { module, @@ -37,6 +56,9 @@ export function useViewSensitive(studentId: number, module: string) { okText: '关闭', }); }, + afterClose: () => { + modalRef.current = null; + }, }); }, [studentId, module], diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 3e37c69..15eadd3 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -86,25 +86,57 @@ const MainLayout: React.FC = () => { useEffect(() => { let cancelled = false; - beginPermissionVerification(); - api - .get<{ id: number; username: string; permissions: string[]; roles?: string[] }>( - '/auth/profile', - ) - .then((profile) => { - if (cancelled) return; - writePermissions(profile.permissions || []); - const cachedUser = JSON.parse(localStorage.getItem('user') || '{}'); - const nextUser = { ...cachedUser, ...profile }; - localStorage.setItem('user', JSON.stringify(nextUser)); - setUser(nextUser); - }) - .catch(() => { - if (!cancelled) clearPermissions('ready'); - // The API interceptor handles expired/invalid sessions. - }); + let retryTimer: number | undefined; + let verificationInFlight = false; + + const verifyPermissions = () => { + if (cancelled || verificationInFlight || !localStorage.getItem('token')) return; + if (retryTimer !== undefined) { + window.clearTimeout(retryTimer); + retryTimer = undefined; + } + verificationInFlight = true; + beginPermissionVerification(); + api + .get<{ id: number; username: string; permissions: string[]; roles?: string[] }>( + '/auth/profile', + ) + .then((profile) => { + if (cancelled) return; + verificationInFlight = false; + writePermissions(profile.permissions || []); + const cachedUser = JSON.parse(localStorage.getItem('user') || '{}'); + const nextUser = { ...cachedUser, ...profile }; + localStorage.setItem('user', JSON.stringify(nextUser)); + setUser(nextUser); + }) + .catch(() => { + verificationInFlight = false; + if (cancelled || !localStorage.getItem('token')) return; + retryTimer = window.setTimeout(verifyPermissions, 5_000); + }); + }; + + const handleStorage = (event: StorageEvent) => { + if (event.key !== 'token' && event.key !== 'permissions') return; + beginPermissionVerification(); + window.location.reload(); + }; + const handleOnline = () => verifyPermissions(); + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') verifyPermissions(); + }; + + verifyPermissions(); + window.addEventListener('storage', handleStorage); + window.addEventListener('online', handleOnline); + document.addEventListener('visibilitychange', handleVisibilityChange); return () => { cancelled = true; + if (retryTimer !== undefined) window.clearTimeout(retryTimer); + window.removeEventListener('storage', handleStorage); + window.removeEventListener('online', handleOnline); + document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, []); diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx index 3935bdd..98b4c83 100644 --- a/apps/admin/src/pages/Attendance/index.tsx +++ b/apps/admin/src/pages/Attendance/index.tsx @@ -1146,9 +1146,9 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => value={studentSearch} onChange={(event) => setStudentSearch(event.target.value)} /> - +
diff --git a/apps/admin/src/pages/Exams/detail.tsx b/apps/admin/src/pages/Exams/detail.tsx index fea4658..94ad8db 100644 --- a/apps/admin/src/pages/Exams/detail.tsx +++ b/apps/admin/src/pages/Exams/detail.tsx @@ -27,7 +27,7 @@ interface ExamDetail extends ExamItem { } const PhoneCell: React.FC<{ row: ScoreRow }> = ({ row }) => { - const reveal = useViewSensitive(row.studentId, '考试管理'); + const reveal = useViewSensitive(row.studentId, '考试管理', hasPermission('log:create')); const { hasPermission } = usePermission(); if (!row.phone) return <>-; return ( diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index ab85b1f..fc9bd7f 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { Table, Button, @@ -38,8 +38,11 @@ import { usePermission } from '../../hooks/usePermission'; const { RangePicker } = DatePicker; const OccupanciesPage: React.FC = () => { - const { hasPermission } = usePermission(); - const canCheckIn = hasPermission('occupancy:checkin'); + const { hasPermission, permissionsReady } = usePermission(); + const canCheckIn = permissionsReady && hasPermission('occupancy:checkin'); + const canCheckOut = permissionsReady && hasPermission('occupancy:checkout'); + const canTransfer = permissionsReady && hasPermission('occupancy:transfer'); + const canDelete = permissionsReady && hasPermission('occupancy:delete'); const [data, setData] = useState([]); const [students, setStudents] = useState([]); const [rooms, setRooms] = useState([]); @@ -69,6 +72,12 @@ const OccupanciesPage: React.FC = () => { const selectedCheckInRoomId = Form.useWatch('roomId', checkInForm); const selectedTransferRoomId = Form.useWatch('newRoomId', transferForm); + // Close modals when the user loses the required permission + useEffect(() => { if (!canCheckIn) { setCheckInModal(false); checkInForm.resetFields(); } }, [canCheckIn, checkInForm]); + useEffect(() => { if (!canCheckOut && checkOutModal) { setCheckOutModal(null); checkOutForm.resetFields(); } }, [canCheckOut, checkOutModal, checkOutForm]); + useEffect(() => { if (!canCheckOut) { setBatchCheckOutModal(false); batchCheckOutForm.resetFields(); } }, [canCheckOut, batchCheckOutForm]); + useEffect(() => { if (!canTransfer && transferModal) { setTransferModal(null); transferForm.resetFields(); } }, [canTransfer, transferModal, transferForm]); + const activeOccupancyByStudentId = useMemo(() => { const map = new Map(); data.forEach((item) => { @@ -372,27 +381,28 @@ const OccupanciesPage: React.FC = () => { ) : ( 已退宿 - { - try { - await api.delete(`/occupancies/${record.id}`); - message.success('归档成功'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '归档失败'); - } - }} - > - } + {canDelete ? ( + { + try { + await api.delete(`/occupancies/${record.id}`); + message.success('归档成功'); + fetchData(); + } catch (e: any) { + message.error(e?.message || '归档失败'); + } + }} > - 归档 - - + + + ) : null} ), }, @@ -584,15 +594,15 @@ const OccupanciesPage: React.FC = () => { > 批量退宿 - ) : ( + )} + {canDelete ? ( - } @@ -600,8 +610,9 @@ const OccupanciesPage: React.FC = () => { loading={batchLoading} > 批量归档 - + + ) : null} )} + + ) : null ) : ( <> { > 编辑 - handleArchive(r.id)}> - } - > - 归档 - - + {canDeleteRooms ? ( + handleArchive(r.id)}> + + + ) : null} )} @@ -638,23 +646,24 @@ const RoomsPage: React.FC = () => { - - } + {canDeleteRooms ? ( + - 批量归档 - - + + + ) : null} { { setModalOpen(false); setEditing(null); @@ -994,20 +1003,19 @@ const RoomsPage: React.FC = () => { > 编辑 - {r.status !== 'occupied' && ( + {r.status !== 'occupied' && canEditRooms && ( handleDeleteBed(r.id)} > - 归档 - + )} @@ -1147,20 +1155,19 @@ const RoomsPage: React.FC = () => { > 编辑 - {r.status !== 'occupied' && ( + {r.status !== 'occupied' && canEditRooms && ( handleDeleteLocker(r.id)} > - 归档 - + )} diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index d984946..3951a75 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, App, @@ -85,15 +85,24 @@ interface StudentFilterLookups { const StudentsPage: React.FC = () => { const { modal } = App.useApp(); - const { hasPermission, hasAnyPermission } = usePermission(); + const { hasPermission, hasAnyPermission, hasAllPermissions } = usePermission(); const canViewOrganizations = hasPermission('organization:view'); - const canChooseOrganization = - canViewOrganizations && hasAnyPermission('student:create', 'student:edit'); + const canLoadOrganizations = hasAnyPermission( + 'organization:view', + 'student:create', + 'student:edit', + ); + const canChooseOrganization = hasAnyPermission('student:create', 'student:edit'); + const canCreateStudent = hasPermission('student:create'); + const canEditStudent = hasPermission('student:edit'); + const canDeleteStudent = hasPermission('student:delete'); + const canSyncJinshuju = hasAllPermissions('sync:read', 'sync:trigger'); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [organizations, setOrganizations] = useState([]); const [editing, setEditing] = useState(null); + const canSaveStudent = editing ? canEditStudent : canCreateStudent; const [searchName, setSearchName] = useState(''); const [filterStatus, setFilterStatus] = useState(undefined); const [filterOrganizationId, setFilterOrganizationId] = useState(undefined); @@ -118,13 +127,40 @@ const StudentsPage: React.FC = () => { const [jinshujuOpen, setJinshujuOpen] = useState(false); + // Sensitive info modal — command-style; destroy when log:create is lost or comp unmounts. + // Close the student form modal when the user loses the required permission. + useEffect(() => { + if (!canSaveStudent && modalOpen) { + setModalOpen(false); + setEditing(null); + form.resetFields(); + } + }, [canSaveStudent, modalOpen, form]); + + // Close sensitive modal when log:create is lost (imperative ref already set above). + const logCreateRef = React.useRef(hasPermission('log:create')); + const sensitiveModalRef = React.useRef | null>(null); + logCreateRef.current = hasPermission('log:create'); + useEffect(() => { + if (!logCreateRef.current && sensitiveModalRef.current) { + sensitiveModalRef.current.destroy(); + sensitiveModalRef.current = null; + } + return () => { + sensitiveModalRef.current?.destroy(); + sensitiveModalRef.current = null; + }; + }, []); + const handleViewSensitive = (studentId: number, field: string, value: string) => { - modal.confirm({ + if (!logCreateRef.current) return; + sensitiveModalRef.current = modal.confirm({ title: '查看敏感信息', content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`, okText: '确认查看', cancelText: '取消', onOk: async () => { + if (!logCreateRef.current) return; try { await api.post('/operation-logs/audit', { module: '学生管理', @@ -142,6 +178,9 @@ const StudentsPage: React.FC = () => { message.error('审计日志记录失败,请稍后重试'); } }, + afterClose: () => { + sensitiveModalRef.current = null; + }, }); }; @@ -194,16 +233,25 @@ const StudentsPage: React.FC = () => { }, [fetchData]); useEffect(() => { + if (!canLoadOrganizations) { + setOrganizations([]); + setFilterOrganizationId(undefined); + return; + } if (canViewOrganizations) { api .get('/organizations', { params: { includeArchived: 'false' } }) .then((res: unknown) => { - setOrganizations(res as Array<{ id: number; name: string }>); + setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>); }) .catch(() => {}); } else { - setOrganizations([]); - setFilterOrganizationId(undefined); + api + .get('/organizations/options') + .then((res: unknown) => { + setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>); + }) + .catch(() => {}); } api .get('/students/filter-lookups') @@ -212,7 +260,7 @@ const StudentsPage: React.FC = () => { setTeacherOptions(res.teachers || []); }) .catch(() => {}); - }, [canViewOrganizations]); + }, [canLoadOrganizations]); const handleSave = async () => { const values = await form.validateFields(); setSaving(true); @@ -614,21 +662,18 @@ const StudentsPage: React.FC = () => { render: (_: any, record: any) => ( {record.status === 'archived' ? ( - handleRestore(record.id)} - okText="恢复" - cancelText="取消" - > - } - type="link" + canEditStudent ? ( + handleRestore(record.id)} + okText="恢复" + cancelText="取消" > - 恢复 - - + + + ) : null ) : ( <> { > 编辑 - handleArchive(record.id)} - okText="归档" - cancelText="取消" - > - handleArchive(record.id)} + okText="归档" + cancelText="取消" + > + + ) : null} )} @@ -765,23 +811,24 @@ const StudentsPage: React.FC = () => { - - } + {canDeleteStudent ? ( + - 批量归档 - - + + + ) : null} { ) : null} - } - onClick={() => setJinshujuOpen(true)} - > - 同步金数据 - + {canSyncJinshuju ? ( + + ) : null} } @@ -929,8 +974,8 @@ const StudentsPage: React.FC = () => { title={editing ? '编辑学生' : '添加学生'} className="student-form-modal" width={720} - open={modalOpen} - onOk={handleSave} + open={modalOpen && canSaveStudent} + onOk={canSaveStudent ? handleSave : undefined} onCancel={() => { setModalOpen(false); setEditing(null); @@ -1007,7 +1052,7 @@ const StudentsPage: React.FC = () => { - {hasPermission('sync:read') ? ( + {canSyncJinshuju ? ( setJinshujuOpen(false)} diff --git a/apps/server/src/organizations/organizations.controller.spec.ts b/apps/server/src/organizations/organizations.controller.spec.ts new file mode 100644 index 0000000..feb7131 --- /dev/null +++ b/apps/server/src/organizations/organizations.controller.spec.ts @@ -0,0 +1,23 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { OrganizationsController } from './organizations.controller'; + +describe('OrganizationsController permissions', () => { + it('allows student editors to use the options endpoint without full entity exposure', () => { + expect( + Reflect.getMetadata(PERMISSION_KEY, OrganizationsController.prototype.findOptions), + ).toEqual(['organization:view', 'student:create', 'student:edit']); + }); + + it('keeps the full entity list restricted to organization viewers only', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, OrganizationsController.prototype.findAll)).toEqual([ + 'organization:view', + ]); + }); + + it('keeps organization detail restricted to organization viewers', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, OrganizationsController.prototype.findOne)).toEqual([ + 'organization:view', + ]); + }); +}); diff --git a/apps/server/src/organizations/organizations.controller.ts b/apps/server/src/organizations/organizations.controller.ts index 8ab4ac5..6650420 100644 --- a/apps/server/src/organizations/organizations.controller.ts +++ b/apps/server/src/organizations/organizations.controller.ts @@ -25,6 +25,12 @@ export class OrganizationsController { private logService: OperationLogsService, ) {} + @Get('options') + @RequirePermission('organization:view', 'student:create', 'student:edit') + findOptions() { + return this.service.findOptions(); + } + @Get() @RequirePermission('organization:view') findAll( diff --git a/apps/server/src/organizations/organizations.service.spec.ts b/apps/server/src/organizations/organizations.service.spec.ts index 5588e8e..13f8040 100644 --- a/apps/server/src/organizations/organizations.service.spec.ts +++ b/apps/server/src/organizations/organizations.service.spec.ts @@ -27,4 +27,21 @@ describe('OrganizationsService — host organization rules', () => { await expect(service.remove(1)).rejects.toBeInstanceOf(BadRequestException); expect(repo.update).not.toHaveBeenCalled(); }); + + it('findOptions returns only id, name, isHost for active organizations', async () => { + const orgs = [ + { id: 1, name: '本机构', isHost: true }, + { id: 2, name: '分校', isHost: false }, + ]; + repo.find.mockResolvedValue(orgs as Organization[]); + + const result = await service.findOptions(); + + expect(repo.find).toHaveBeenCalledWith({ + select: ['id', 'name', 'isHost'], + where: { status: 'active' }, + order: { isHost: 'DESC', name: 'ASC' }, + }); + expect(result).toEqual(orgs); + }); }); diff --git a/apps/server/src/organizations/organizations.service.ts b/apps/server/src/organizations/organizations.service.ts index 655de27..f4128cb 100644 --- a/apps/server/src/organizations/organizations.service.ts +++ b/apps/server/src/organizations/organizations.service.ts @@ -30,6 +30,14 @@ export class OrganizationsService { return this.repo.find({ where, order: { isHost: 'DESC', name: 'ASC' } }); } + async findOptions() { + return this.repo.find({ + select: ['id', 'name', 'isHost'] as const, + where: { status: 'active' }, + order: { isHost: 'DESC' as const, name: 'ASC' as const }, + }); + } + async findOne(id: number) { const organization = await this.repo.findOne({ where: { id } }); if (!organization) throw new NotFoundException('机构不存在'); diff --git a/apps/server/src/students/dto/student.dto.ts b/apps/server/src/students/dto/student.dto.ts index 18d53c2..276e1c8 100644 --- a/apps/server/src/students/dto/student.dto.ts +++ b/apps/server/src/students/dto/student.dto.ts @@ -33,9 +33,8 @@ export class CreateStudentDto { @IsString() emergencyPhone?: string; - @IsOptional() @IsInt() - organizationId?: number; + organizationId: number; @IsOptional() @IsString() diff --git a/apps/server/src/students/students.lifecycle.spec.ts b/apps/server/src/students/students.lifecycle.spec.ts index 3a85d87..fbe7b11 100644 --- a/apps/server/src/students/students.lifecycle.spec.ts +++ b/apps/server/src/students/students.lifecycle.spec.ts @@ -43,21 +43,6 @@ describe('StudentsService — archive lifecycle boundaries', () => { expect(repo.save).not.toHaveBeenCalled(); }); - it('defaults a new student to the active host organization when none is supplied', async () => { - const repo = { - create: jest.fn((value) => value), - save: jest.fn(async (value) => ({ ...value, id: 1 })), - }; - const organizationRepo = { - findOne: jest.fn().mockResolvedValue({ id: 7, isHost: true, status: 'active' }), - }; - - await expect(createService(repo, organizationRepo).create({ name: '张三' })).resolves.toEqual( - expect.objectContaining({ organizationId: 7 }), - ); - expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ organizationId: 7 })); - }); - it('returns not found for a missing student', async () => { const repo = { findOne: jest.fn().mockResolvedValue(null) }; await expect(createService(repo).findOne(404)).rejects.toBeInstanceOf(NotFoundException); @@ -65,13 +50,11 @@ describe('StudentsService — archive lifecycle boundaries', () => { it('builds export archive maps from profile and result rows', async () => { const profileRepo = { - find: jest.fn().mockResolvedValue([ - { - studentId: 1, - targetCollege: '北京大学', - collegeSchool: '北京职业技术学院', - }, - ]), + find: jest.fn().mockResolvedValue([{ + studentId: 1, + targetCollege: '北京大学', + collegeSchool: '北京职业技术学院', + }]), }; const resultRepo = { find: jest.fn().mockResolvedValue([{ studentId: 1, admissionStatus: 'pending' }]), diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index 4deafab..26a2279 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -164,9 +164,8 @@ export class StudentsService { } async create(dto: CreateStudentDto) { - const organizationId = dto.organizationId || (await this.getHostOrganizationId()); - await this.assertActiveOrganization(organizationId); - return this.repo.save(this.repo.create({ ...dto, organizationId })); + await this.assertActiveOrganization(dto.organizationId); + return this.repo.save(this.repo.create(dto)); } async update(id: number, dto: UpdateStudentDto) { @@ -315,9 +314,7 @@ export class StudentsService { }; } - private normalizeImportData( - importData: StudentWorkbookImport | StudentImportRow[], - ): StudentWorkbookImport { + private normalizeImportData(importData: StudentWorkbookImport | StudentImportRow[]): StudentWorkbookImport { if (Array.isArray(importData)) { return { students: importData, enrollments: [], examScores: [], learningRecords: [] }; } @@ -373,24 +370,18 @@ export class StudentsService { if (!phone) return imported; const enrollmentByClassName = new Map(); - for (const enrollmentRow of data.enrollments.filter( - (item) => this.normalizePhone(item.phone) === phone, - )) { + for (const enrollmentRow of data.enrollments.filter((item) => this.normalizePhone(item.phone) === phone)) { const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow); if (!enrollment) continue; if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment); imported++; } - for (const examRow of data.examScores.filter( - (item) => this.normalizePhone(item.phone) === phone, - )) { + for (const examRow of data.examScores.filter((item) => this.normalizePhone(item.phone) === phone)) { if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) { imported++; } } - for (const learningRow of data.learningRecords.filter( - (item) => this.normalizePhone(item.phone) === phone, - )) { + for (const learningRow of data.learningRecords.filter((item) => this.normalizePhone(item.phone) === phone)) { if (await this.upsertLearningRecordFromImport(studentId, learningRow)) { imported++; } @@ -399,9 +390,7 @@ export class StudentsService { } private async upsertProfileFromImport(studentId: number, row: StudentImportRow) { - const entity = - (await this.profileRepo.findOne({ where: { studentId } })) || - this.profileRepo.create({ studentId }); + const entity = (await this.profileRepo.findOne({ where: { studentId } })) || this.profileRepo.create({ studentId }); if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim(); if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim(); if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim(); @@ -414,12 +403,9 @@ export class StudentsService { } private async upsertResultFromImport(studentId: number, row: StudentImportRow) { - const entity = - (await this.resultRepo.findOne({ where: { studentId } })) || - this.resultRepo.create({ studentId }); + const entity = (await this.resultRepo.findOne({ where: { studentId } })) || this.resultRepo.create({ studentId }); if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore; - if (row.professionalFinalScore !== undefined) - entity.professionalFinalScore = row.professionalFinalScore; + if (row.professionalFinalScore !== undefined) entity.professionalFinalScore = row.professionalFinalScore; if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim(); if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim(); if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim(); @@ -637,9 +623,10 @@ export class StudentsService { // ---- Filters ---- if (query?.keyword) { - qb.andWhere('(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', { - keyword: `%${query.keyword}%`, - }); + qb.andWhere( + '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', + { keyword: `%${query.keyword}%` }, + ); } if (query?.organizationId) { qb.andWhere('student.organizationId = :orgId', { orgId: query.organizationId }); From 446c6bcc9178564d847c6fb7208a970ae8e25340 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 23 Jul 2026 14:27:58 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20harden=20permission-gated=20UI=20?= =?UTF-8?q?=E2=80=94=20minimum-org=20endpoint,=20modal/Popconfirm=20fail-c?= =?UTF-8?q?losed=20on=20revocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/JinshujuMatchModal.tsx | 2 - apps/admin/src/pages/Exams/detail.tsx | 2 +- apps/admin/src/pages/Occupancies/index.tsx | 38 +++++++++---------- apps/admin/src/pages/Students/index.tsx | 2 +- 4 files changed, 21 insertions(+), 23 deletions(-) diff --git a/apps/admin/src/components/JinshujuMatchModal.tsx b/apps/admin/src/components/JinshujuMatchModal.tsx index 108479f..7ba297e 100644 --- a/apps/admin/src/components/JinshujuMatchModal.tsx +++ b/apps/admin/src/components/JinshujuMatchModal.tsx @@ -313,10 +313,8 @@ interface MatchModalProps { const JinshujuMatchModal: React.FC = ({ open, onClose, onApplied }) => { const { hasPermission, hasAllPermissions, permissionsReady } = usePermission(); - const canReadSync = hasPermission('sync:read'); const canTriggerSync = hasPermission('sync:trigger'); const canEnterModal = permissionsReady && hasAllPermissions('sync:read', 'sync:trigger'); - const canWriteRules = permissionsReady && canTriggerSync; const [step, setStep] = useState<'connection' | 'rule' | 'match' | 'applying'>('connection'); const [loading, setLoading] = useState(false); const [selectedRuleId, setSelectedRuleId] = useState(); diff --git a/apps/admin/src/pages/Exams/detail.tsx b/apps/admin/src/pages/Exams/detail.tsx index 94ad8db..17f6dc7 100644 --- a/apps/admin/src/pages/Exams/detail.tsx +++ b/apps/admin/src/pages/Exams/detail.tsx @@ -27,8 +27,8 @@ interface ExamDetail extends ExamItem { } const PhoneCell: React.FC<{ row: ScoreRow }> = ({ row }) => { - const reveal = useViewSensitive(row.studentId, '考试管理', hasPermission('log:create')); const { hasPermission } = usePermission(); + const reveal = useViewSensitive(row.studentId, '考试管理', hasPermission('log:create')); if (!row.phone) return <>-; return ( diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index fc9bd7f..e4acdea 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; +import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { Table, Button, @@ -594,25 +594,25 @@ const OccupanciesPage: React.FC = () => { > 批量退宿 - )} - {canDelete ? ( - - - - ) : null} + + + ) : null )}