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

@@ -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)}