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..04d7198 --- /dev/null +++ b/apps/admin/src/auth/permission-state.integration.test.tsx @@ -0,0 +1,65 @@ +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('stays fail-closed while profile verification is retried after a failure', async () => { + writePermissions(['student:edit']); + beginPermissionVerification(); + + expect(readPermissionState()).toEqual({ permissions: [], status: 'loading' }); + await renderPermissionButton(); + expect(container?.textContent).not.toContain('编辑学生'); + }); +}); 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..7ba297e 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, 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([]); @@ -66,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) => { @@ -93,7 +105,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 +123,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); @@ -363,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} ), }, @@ -457,46 +476,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 +571,6 @@ const OccupanciesPage: React.FC = () => { > 导出记录 - - - 导入时自动收押金 - {autoDeposit && ( - - setDepositAmount(v || 500)} - style={{ width: 60 }} - /> - - 元 - - - )} -
{selectedRowKeys.length > 0 && ( @@ -572,23 +595,24 @@ const OccupanciesPage: React.FC = () => { 批量退宿 ) : ( - - } - style={{ marginLeft: 12 }} - loading={batchLoading} + canDelete ? ( + - 批量归档 - - + + + ) : null )} + ), }, ], - [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/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('机构不存在');