fix: close permission review gaps

fix: harden permission-gated UI — minimum-org endpoint, modal/Popconfirm fail-closed on revocation
This commit is contained in:
2026-07-23 12:23:33 +08:00
parent c98d37307e
commit a7a7af1667
17 changed files with 391 additions and 227 deletions

View File

@@ -1146,9 +1146,9 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
value={studentSearch}
onChange={(event) => setStudentSearch(event.target.value)}
/>
<Button icon={<ExportOutlined />} onClick={handleExport}>
<PermissionButton permission="attendance:export" icon={<ExportOutlined />} onClick={handleExport}>
</Button>
</PermissionButton>
</div>
</header>
<div className="student-legend">

View File

@@ -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 (

View File

@@ -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<any[]>([]);
const [students, setStudents] = useState<any[]>([]);
const [rooms, setRooms] = useState<any[]>([]);
@@ -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<number, any>();
data.forEach((item) => {
@@ -372,27 +381,28 @@ const OccupanciesPage: React.FC = () => {
) : (
<Space>
<Tag>退宿</Tag>
<Popconfirm
title="确定归档此记录?"
onConfirm={async () => {
try {
await api.delete(`/occupancies/${record.id}`);
message.success('归档成功');
fetchData();
} catch (e: any) {
message.error(e?.message || '归档失败');
}
}}
>
<PermissionButton
permission="occupancy:delete"
size="small"
danger
icon={<InboxOutlined />}
{canDelete ? (
<Popconfirm
title="确定归档此记录?"
onConfirm={async () => {
try {
await api.delete(`/occupancies/${record.id}`);
message.success('归档成功');
fetchData();
} catch (e: any) {
message.error(e?.message || '归档失败');
}
}}
>
</PermissionButton>
</Popconfirm>
<Button
size="small"
danger
icon={<InboxOutlined />}
>
</Button>
</Popconfirm>
) : null}
</Space>
),
},
@@ -584,15 +594,15 @@ const OccupanciesPage: React.FC = () => {
>
退宿
</PermissionButton>
) : (
</>)}
{canDelete ? (
<Popconfirm
title={`确定归档选中的 ${selectedRowKeys.length} 条入住记录?在住记录会自动跳过`}
onConfirm={handleBatchDelete}
okText="归档"
cancelText="取消"
>
<PermissionButton
permission="occupancy:delete"
<Button
danger
size="small"
icon={<InboxOutlined />}
@@ -600,8 +610,9 @@ const OccupanciesPage: React.FC = () => {
loading={batchLoading}
>
</PermissionButton>
</Button>
</Popconfirm>
) : null}
)}
<Button size="small" onClick={() => setSelectedRowKeys([])} style={{ marginLeft: 8 }}>
取消选择
@@ -629,8 +640,8 @@ const OccupanciesPage: React.FC = () => {
/>
<Modal
title="入住登记"
open={checkInModal}
onOk={handleCheckIn}
open={checkInModal && canCheckIn}
onOk={canCheckIn ? handleCheckIn : undefined}
onCancel={() => {
setCheckInModal(false);
setAvailableBeds([]);
@@ -804,8 +815,8 @@ const OccupanciesPage: React.FC = () => {
{/* 退宿弹窗 */}
<Modal
title={`退宿 - ${checkOutModal?.student?.name}`}
open={!!checkOutModal}
onOk={handleCheckOut}
open={!!checkOutModal && canCheckOut}
onOk={canCheckOut ? handleCheckOut : undefined}
onCancel={() => setCheckOutModal(null)}
okText="确认退宿"
confirmLoading={saving}
@@ -861,8 +872,8 @@ const OccupanciesPage: React.FC = () => {
{/* 批量退宿弹窗 */}
<Modal
title={`批量退宿(${selectedRowKeys.length} 人)`}
open={batchCheckOutModal}
onOk={handleBatchCheckOut}
open={batchCheckOutModal && canCheckOut}
onOk={canCheckOut ? handleBatchCheckOut : undefined}
onCancel={() => setBatchCheckOutModal(false)}
okText="确认批量退宿"
width={500}
@@ -938,7 +949,7 @@ const OccupanciesPage: React.FC = () => {
{/* 换房弹窗 */}
<Modal
title={`换房 - ${transferModal?.student?.name}`}
open={!!transferModal}
open={!!transferModal && canTransfer}
onOk={handleTransfer}
onCancel={() => {
setTransferModal(null);

View File

@@ -85,12 +85,15 @@ function parseRoomNumber(input: string) {
}
const RoomsPage: React.FC = () => {
const { hasPermission } = usePermission();
const canEditRooms = hasPermission('room:edit');
const { hasPermission, permissionsReady } = usePermission();
const canEditRooms = permissionsReady && hasPermission('room:edit');
const canCreateRooms = permissionsReady && hasPermission('room:create');
const canDeleteRooms = permissionsReady && hasPermission('room:delete');
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const canSaveRoom = editing ? canEditRooms : canCreateRooms;
const [showArchived, setShowArchived] = useState(false);
const [archivedCount, setArchivedCount] = useState(0);
const [searchText, setSearchText] = useState('');
@@ -114,6 +117,9 @@ const RoomsPage: React.FC = () => {
const [savingLocker, setSavingLocker] = useState(false);
const [batchLoading, setBatchLoading] = useState(false);
// Close modals when the required permission is lost
useEffect(() => { if (!canSaveRoom && modalOpen) { setModalOpen(false); setEditing(null); form.resetFields(); } }, [canSaveRoom, modalOpen, form]);
const handleBatchDelete = async () => {
setBatchLoading(true);
try {
@@ -522,16 +528,17 @@ const RoomsPage: React.FC = () => {
return (
<Space>
{r.status === 'archived' ? (
<Popconfirm title="确定恢复此宿舍?" onConfirm={() => handleRestore(r.id)}>
<PermissionButton
permission="room:edit"
size="small"
icon={<UndoOutlined />}
type="link"
>
</PermissionButton>
</Popconfirm>
canEditRooms ? (
<Popconfirm title="确定恢复此宿舍?" onConfirm={() => handleRestore(r.id)}>
<Button
size="small"
icon={<UndoOutlined />}
type="link"
>
</Button>
</Popconfirm>
) : null
) : (
<>
<PermissionButton
@@ -559,15 +566,16 @@ const RoomsPage: React.FC = () => {
>
</PermissionButton>
<Popconfirm title="确定归档?" onConfirm={() => handleArchive(r.id)}>
<PermissionButton
permission="room:delete"
size="small"
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
{canDeleteRooms ? (
<Popconfirm title="确定归档?" onConfirm={() => handleArchive(r.id)}>
<Button
size="small"
icon={<InboxOutlined />}
>
</Button>
</Popconfirm>
) : null}
</>
)}
</Space>
@@ -638,23 +646,24 @@ const RoomsPage: React.FC = () => {
</Button>
</Space>
<Space wrap className="responsive-toolbar__group">
<Popconfirm
title={`确定批量归档选中的 ${selectedRowKeys.length} 间宿舍?(有在住人员的会跳过)`}
onConfirm={handleBatchDelete}
okText="归档"
cancelText="取消"
disabled={selectedRowKeys.length === 0}
>
<PermissionButton
permission="room:delete"
danger
icon={<InboxOutlined />}
{canDeleteRooms ? (
<Popconfirm
title={`确定批量归档选中的 ${selectedRowKeys.length} 间宿舍?(有在住人员的会跳过)`}
onConfirm={handleBatchDelete}
okText="归档"
cancelText="取消"
disabled={selectedRowKeys.length === 0}
loading={batchLoading}
>
</PermissionButton>
</Popconfirm>
<Button
danger
icon={<InboxOutlined />}
disabled={selectedRowKeys.length === 0}
loading={batchLoading}
>
</Button>
</Popconfirm>
) : null}
<PermissionButton
permission="room:create"
type="primary"
@@ -732,8 +741,8 @@ const RoomsPage: React.FC = () => {
<Modal
title={editing ? '编辑宿舍' : '添加宿舍'}
open={modalOpen}
onOk={handleSave}
open={modalOpen && canSaveRoom}
onOk={canSaveRoom ? handleSave : undefined}
onCancel={() => {
setModalOpen(false);
setEditing(null);
@@ -994,20 +1003,19 @@ const RoomsPage: React.FC = () => {
>
</PermissionButton>
{r.status !== 'occupied' && (
{r.status !== 'occupied' && canEditRooms && (
<Popconfirm
title="确定归档?"
onConfirm={() => handleDeleteBed(r.id)}
>
<PermissionButton
permission="room:edit"
<Button
size="small"
type="link"
danger
disabled={drawerRoom?.status === 'archived'}
>
</PermissionButton>
</Button>
</Popconfirm>
)}
</Space>
@@ -1147,20 +1155,19 @@ const RoomsPage: React.FC = () => {
>
</PermissionButton>
{r.status !== 'occupied' && (
{r.status !== 'occupied' && canEditRooms && (
<Popconfirm
title="确定归档?"
onConfirm={() => handleDeleteLocker(r.id)}
>
<PermissionButton
permission="room:edit"
<Button
size="small"
type="link"
danger
disabled={drawerRoom?.status === 'archived'}
>
</PermissionButton>
</Button>
</Popconfirm>
)}
</Space>

View File

@@ -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<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [organizations, setOrganizations] = useState<any[]>([]);
const [editing, setEditing] = useState<any>(null);
const canSaveStudent = editing ? canEditStudent : canCreateStudent;
const [searchName, setSearchName] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [filterOrganizationId, setFilterOrganizationId] = useState<number | undefined>(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<ReturnType<typeof modal.confirm> | 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<StudentFilterLookups>('/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) => (
<Space>
{record.status === 'archived' ? (
<Popconfirm
title="确定恢复此学生?恢复后将重新出现在学生列表中。"
onConfirm={() => handleRestore(record.id)}
okText="恢复"
cancelText="取消"
>
<PermissionButton
permission="student:edit"
size="small"
icon={<UndoOutlined />}
type="link"
canEditStudent ? (
<Popconfirm
title="确定恢复此学生?恢复后将重新出现在学生列表中。"
onConfirm={() => handleRestore(record.id)}
okText="恢复"
cancelText="取消"
>
</PermissionButton>
</Popconfirm>
<Button size="small" icon={<UndoOutlined />} type="link">
</Button>
</Popconfirm>
) : null
) : (
<>
<PermissionButton
@@ -650,20 +695,21 @@ const StudentsPage: React.FC = () => {
>
</PermissionButton>
<Popconfirm
title="归档后不会删除数据,可随时恢复。确定归档?"
onConfirm={() => handleArchive(record.id)}
okText="归档"
cancelText="取消"
>
<PermissionButton
permission="student:delete"
size="small"
{canDeleteStudent ? (
<Popconfirm
title="归档后不会删除数据,可随时恢复。确定归档?"
onConfirm={() => handleArchive(record.id)}
okText="归档"
cancelText="取消"
>
<Button
size="small"
icon={<InboxOutlined />}
>
</PermissionButton>
</Button>
</Popconfirm>
) : null}
</>
)}
</Space>
@@ -765,23 +811,24 @@ const StudentsPage: React.FC = () => {
</Button>
</Space>
<Space wrap className="responsive-toolbar__group">
<Popconfirm
title={`确定批量归档选中的 ${selectedRowKeys.length} 名学生?(数据保留,可恢复)`}
onConfirm={handleBatchDelete}
okText="归档"
cancelText="取消"
disabled={selectedRowKeys.length === 0}
>
<PermissionButton
permission="student:delete"
danger
icon={<InboxOutlined />}
{canDeleteStudent ? (
<Popconfirm
title={`确定批量归档选中的 ${selectedRowKeys.length} 名学生?(数据保留,可恢复)`}
onConfirm={handleBatchDelete}
okText="归档"
cancelText="取消"
disabled={selectedRowKeys.length === 0}
loading={batchLoading}
>
</PermissionButton>
</Popconfirm>
<Button
danger
icon={<InboxOutlined />}
disabled={selectedRowKeys.length === 0}
loading={batchLoading}
>
</Button>
</Popconfirm>
) : null}
<PermissionButton
permission="student:create"
type="primary"
@@ -814,13 +861,11 @@ const StudentsPage: React.FC = () => {
</Upload>
</>
) : null}
<PermissionButton
permission="sync:read"
icon={<CloudUploadOutlined />}
onClick={() => setJinshujuOpen(true)}
>
</PermissionButton>
{canSyncJinshuju ? (
<Button icon={<CloudUploadOutlined />} onClick={() => setJinshujuOpen(true)}>
</Button>
) : null}
<PermissionButton
permission="student:view"
icon={<DownloadOutlined />}
@@ -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 = () => {
</Form>
</Modal>
{hasPermission('sync:read') ? (
{canSyncJinshuju ? (
<JinshujuMatchModal
open={jinshujuOpen}
onClose={() => setJinshujuOpen(false)}