fix: close permission review gaps
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1401,9 +1401,12 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
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<StudentProfileAggregate | null>(null);
|
||||
const [organizations, setOrganizations] = useState<Array<{ id: number; name: string }>>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -1426,7 +1429,7 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canViewOrganizations) {
|
||||
if (!canLoadOrganizations) {
|
||||
setOrganizations([]);
|
||||
return;
|
||||
}
|
||||
@@ -1436,7 +1439,7 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
setOrganizations(res as Array<{ id: number; name: string }>);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [canViewOrganizations]);
|
||||
}, [canLoadOrganizations]);
|
||||
|
||||
const handlePreviewReport = useCallback(async () => {
|
||||
try {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -85,10 +85,15 @@ 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 canSyncJinshuju = hasAllPermissions('sync:read', 'sync:trigger');
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -194,7 +199,7 @@ const StudentsPage: React.FC = () => {
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canViewOrganizations) {
|
||||
if (canLoadOrganizations) {
|
||||
api
|
||||
.get('/organizations', { params: { includeArchived: 'false' } })
|
||||
.then((res: unknown) => {
|
||||
@@ -212,7 +217,7 @@ const StudentsPage: React.FC = () => {
|
||||
setTeacherOptions(res.teachers || []);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [canViewOrganizations]);
|
||||
}, [canLoadOrganizations]);
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
@@ -814,13 +819,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 />}
|
||||
@@ -1007,7 +1010,7 @@ const StudentsPage: React.FC = () => {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{hasPermission('sync:read') ? (
|
||||
{canSyncJinshuju ? (
|
||||
<JinshujuMatchModal
|
||||
open={jinshujuOpen}
|
||||
onClose={() => setJinshujuOpen(false)}
|
||||
|
||||
Reference in New Issue
Block a user