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 ( = ({ 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(() => {}); }, [canLoadOrganizations]); @@ -1454,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/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 9959024..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, @@ -93,12 +93,16 @@ const StudentsPage: React.FC = () => { '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); @@ -123,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: '学生管理', @@ -147,6 +178,9 @@ const StudentsPage: React.FC = () => { message.error('审计日志记录失败,请稍后重试'); } }, + afterClose: () => { + sensitiveModalRef.current = null; + }, }); }; @@ -199,16 +233,25 @@ const StudentsPage: React.FC = () => { }, [fetchData]); useEffect(() => { - if (canLoadOrganizations) { + 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') @@ -619,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} )} @@ -770,23 +811,24 @@ const StudentsPage: React.FC = () => { - - } + {canDeleteStudent ? ( + - 批量归档 - - + + + ) : null} { title={editing ? '编辑学生' : '添加学生'} className="student-form-modal" width={720} - open={modalOpen} - onOk={handleSave} + open={modalOpen && canSaveStudent} + onOk={canSaveStudent ? handleSave : undefined} onCancel={() => { setModalOpen(false); setEditing(null); diff --git a/apps/server/src/organizations/organizations.controller.spec.ts b/apps/server/src/organizations/organizations.controller.spec.ts index 94ebfd2..feb7131 100644 --- a/apps/server/src/organizations/organizations.controller.spec.ts +++ b/apps/server/src/organizations/organizations.controller.spec.ts @@ -3,11 +3,15 @@ import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; import { OrganizationsController } from './organizations.controller'; describe('OrganizationsController permissions', () => { - it('allows student editors to list organization options', () => { + 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', - 'student:create', - 'student:edit', ]); }); diff --git a/apps/server/src/organizations/organizations.controller.ts b/apps/server/src/organizations/organizations.controller.ts index cd439f7..6650420 100644 --- a/apps/server/src/organizations/organizations.controller.ts +++ b/apps/server/src/organizations/organizations.controller.ts @@ -25,8 +25,14 @@ export class OrganizationsController { private logService: OperationLogsService, ) {} - @Get() + @Get('options') @RequirePermission('organization:view', 'student:create', 'student:edit') + findOptions() { + return this.service.findOptions(); + } + + @Get() + @RequirePermission('organization:view') findAll( @Query('includeArchived') includeArchived?: string, @Query('scope') scope?: 'all' | 'host' | 'external', 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('机构不存在');