diff --git a/frontend/src/api/users.test.ts b/frontend/src/api/users.test.ts new file mode 100644 index 0000000..7c11fc3 --- /dev/null +++ b/frontend/src/api/users.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { usersApi } from './users'; +import { BizError } from './errors'; + +describe('usersApi', () => { + it('list returns PageResult with records', async () => { + const result = await usersApi.list(); + expect(result.total).toBeGreaterThan(0); + expect(result.records[0].username).toBeDefined(); + expect(result.page).toBe(1); + }); + + it('list with queryField=username queryValue=ali returns filtered results', async () => { + const result = await usersApi.list({ queryField: 'username', queryValue: 'ali' }); + expect(result.total).toBe(1); + expect(result.records[0].username).toBe('alice'); + }); + + it('get returns UserDetail', async () => { + const detail = await usersApi.get(1); + expect(detail.userId).toBe(1); + expect(detail.permissionCategoryIds).toEqual([1, 2]); + }); + + it('get unknown userId throws BizError 40401', async () => { + await expect(usersApi.get(99999)).rejects.toMatchObject({ code: 40401 }); + }); + + it('create returns CreateUserVo on success', async () => { + const vo = await usersApi.create({ + username: 'newbie', + userCode: 'U010', + userType: 'NORMAL', + language: 'zh-CN', + canEditDocument: false, + }); + expect(vo.userId).toBe(42); + expect(vo.username).toBe('newbie'); + }); + + it('create with duplicate username throws BizError 40901', async () => { + await expect( + usersApi.create({ + username: 'dup', + userCode: 'U011', + userType: 'NORMAL', + language: 'zh-CN', + canEditDocument: false, + }), + ).rejects.toMatchObject({ code: 40901 }); + }); + + it('update returns UserDetail with patched fields', async () => { + const detail = await usersApi.update(1, { userCode: 'U_NEW' }); + expect(detail.userCode).toBe('U_NEW'); + }); +}); diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 0000000..b56ff2a --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,82 @@ +import { apiClient } from './client'; + +export interface UserListItem { + userId: number; + username: string; + employeeName?: string | null; + userCode: string; + departmentName?: string | null; + userType: 'NORMAL' | 'SUPER_ADMIN'; + language: string; + isDeleted: boolean; + lastLoginDate?: string | null; + createdBy?: string | null; + createdDate?: string | null; +} + +export interface UserDetail extends UserListItem { + employeeId?: number | null; + permissionCategoryIds: number[]; + updatedBy?: string | null; + updatedDate?: string | null; +} + +export interface UsersListQuery { + page?: number; + size?: number; + sortField?: 'tCreateDate' | 'tLastLoginDate' | 'sUsername' | 'sUserCode'; + sortOrder?: 'asc' | 'desc'; + queryField?: string; + matchMode?: 'contains' | 'notContains' | 'equals'; + queryValue?: string; + userType?: 'NORMAL' | 'SUPER_ADMIN'; + isDeleted?: boolean; +} + +export interface PageResult { + records: T[]; + total: number; + page: number; + size: number; +} + +export interface CreateUserReq { + username: string; + userCode: string; + userType: 'NORMAL' | 'SUPER_ADMIN'; + language: 'zh-CN' | 'en-US' | 'zh-TW'; + canEditDocument: boolean; + employeeId?: number; + permissionCategoryIds?: number[]; +} + +export interface UpdateUserReq { + userCode?: string; + userType?: 'NORMAL' | 'SUPER_ADMIN'; + language?: 'zh-CN' | 'en-US' | 'zh-TW'; + canEditDocument?: boolean; + employeeId?: number; + isDeleted?: boolean; + permissionCategoryIds?: number[]; +} + +export interface CreateUserVo { + userId: number; + username: string; + userCode: string; +} + +export const usersApi = { + async list(query: UsersListQuery = {}): Promise> { + return await apiClient.get>('/users', { params: query }); + }, + async get(userId: number): Promise { + return await apiClient.get(`/users/${userId}`); + }, + async create(req: CreateUserReq): Promise { + return await apiClient.post('/users', req); + }, + async update(userId: number, req: UpdateUserReq): Promise { + return await apiClient.put(`/users/${userId}`, req); + }, +}; diff --git a/frontend/src/router/RequireSuperAdmin.test.tsx b/frontend/src/router/RequireSuperAdmin.test.tsx new file mode 100644 index 0000000..e1d1eed --- /dev/null +++ b/frontend/src/router/RequireSuperAdmin.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import authReducer, { setSession } from '../store/slices/authSlice'; +import RequireSuperAdmin from './RequireSuperAdmin'; + +function makeStore(opts: { token?: string; userType?: 'NORMAL' | 'SUPER_ADMIN' } = {}) { + const store = configureStore({ reducer: { auth: authReducer } }); + if (opts.token) { + store.dispatch( + setSession({ + accessToken: opts.token, + userInfo: { + userId: 1, + username: 'alice', + userType: opts.userType ?? 'NORMAL', + language: 'zh-CN', + companyCode: 'HQ', + }, + }), + ); + } + return store; +} + +function renderRoutes(store: ReturnType, entry: string) { + return render( + + + + +
ADMIN
+ + } + /> + LOGIN} /> +
+
+
, + ); +} + +describe('RequireSuperAdmin', () => { + it('no token → redirects to /login', () => { + renderRoutes(makeStore(), '/users'); + expect(screen.getByTestId('login')).toBeInTheDocument(); + }); + + it('NORMAL user token → shows 403 Result', () => { + renderRoutes(makeStore({ token: 'jwt', userType: 'NORMAL' }), '/users'); + expect(screen.getByTestId('forbidden-result')).toBeInTheDocument(); + expect(screen.queryByTestId('admin-only')).toBeNull(); + }); + + it('SUPER_ADMIN token → renders children', () => { + renderRoutes(makeStore({ token: 'jwt', userType: 'SUPER_ADMIN' }), '/users'); + expect(screen.getByTestId('admin-only')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/router/RequireSuperAdmin.tsx b/frontend/src/router/RequireSuperAdmin.tsx new file mode 100644 index 0000000..ba63e51 --- /dev/null +++ b/frontend/src/router/RequireSuperAdmin.tsx @@ -0,0 +1,22 @@ +import { Navigate, useLocation } from 'react-router-dom'; +import { Result } from 'antd'; +import { useAppSelector } from '../store/hooks'; +import { selectIsAuthenticated, selectUserInfo } from '../store/slices/authSlice'; + +export default function RequireSuperAdmin({ children }: { children: React.ReactNode }) { + const isAuth = useAppSelector(selectIsAuthenticated); + const userInfo = useAppSelector(selectUserInfo); + const location = useLocation(); + + if (!isAuth) { + return ; + } + if (userInfo?.userType !== 'SUPER_ADMIN') { + return ( +
+ +
+ ); + } + return <>{children}; +} diff --git a/frontend/src/test-utils/msw-handlers.ts b/frontend/src/test-utils/msw-handlers.ts index 20bd633..b7c3de0 100644 --- a/frontend/src/test-utils/msw-handlers.ts +++ b/frontend/src/test-utils/msw-handlers.ts @@ -64,4 +64,172 @@ export const handlers = [ await delay(50); return HttpResponse.error(); }), + + // === REQ-USR-002/003/004: users CRUD === + http.get(`${BASE}/users`, ({ request }) => { + const url = new URL(request.url); + const queryField = url.searchParams.get('queryField'); + const queryValue = url.searchParams.get('queryValue'); + const page = Number(url.searchParams.get('page') ?? 1); + const size = Number(url.searchParams.get('size') ?? 20); + const allUsers = [ + { + userId: 1, + username: 'alice', + employeeName: '张三', + userCode: 'U001', + departmentName: '技术部', + userType: 'NORMAL', + language: 'zh-CN', + isDeleted: false, + lastLoginDate: '2026-05-15T08:00:00', + createdBy: 'admin', + createdDate: '2026-05-10T00:00:00', + }, + { + userId: 2, + username: 'admin', + employeeName: null, + userCode: 'U000', + departmentName: null, + userType: 'SUPER_ADMIN', + language: 'zh-CN', + isDeleted: false, + lastLoginDate: null, + createdBy: 'system', + createdDate: '2026-05-01T00:00:00', + }, + { + userId: 3, + username: 'bob_deleted', + employeeName: null, + userCode: 'U002', + departmentName: null, + userType: 'NORMAL', + language: 'zh-CN', + isDeleted: true, + lastLoginDate: null, + createdBy: 'admin', + createdDate: '2026-05-05T00:00:00', + }, + ]; + let filtered = allUsers; + if (queryField === 'username' && queryValue) { + filtered = allUsers.filter((u) => u.username.includes(queryValue)); + } + const start = (page - 1) * size; + const records = filtered.slice(start, start + size); + return HttpResponse.json({ + code: 200, + message: '操作成功', + data: { records, total: filtered.length, page, size }, + timestamp: Date.now(), + }); + }), + + http.get(`${BASE}/users/:userId`, ({ params }) => { + const userId = Number(params.userId); + if (userId === 99999) { + return HttpResponse.json( + { code: 40401, message: '用户不存在', data: null, timestamp: Date.now() }, + { status: 404 }, + ); + } + return HttpResponse.json({ + code: 200, + message: '操作成功', + data: { + userId, + username: 'alice', + employeeName: '张三', + userCode: 'U001', + departmentName: '技术部', + userType: 'NORMAL', + language: 'zh-CN', + isDeleted: false, + lastLoginDate: '2026-05-15T08:00:00', + employeeId: 1, + permissionCategoryIds: [1, 2], + createdBy: 'admin', + createdDate: '2026-05-10T00:00:00', + updatedBy: 'admin', + updatedDate: '2026-05-14T00:00:00', + }, + timestamp: Date.now(), + }); + }), + + http.post(`${BASE}/users`, async ({ request }) => { + const body = (await request.json()) as { username: string; userCode: string; userType: string }; + if (body.username === 'dup') { + return HttpResponse.json( + { code: 40901, message: '用户名已存在', data: null, timestamp: Date.now() }, + { status: 409 }, + ); + } + if (body.userCode === 'dup-code') { + return HttpResponse.json( + { code: 40902, message: '用户号已被占用', data: null, timestamp: Date.now() }, + { status: 409 }, + ); + } + if (!['NORMAL', 'SUPER_ADMIN'].includes(body.userType)) { + return HttpResponse.json( + { code: 40001, message: 'userType 不在白名单', data: null, timestamp: Date.now() }, + { status: 400 }, + ); + } + return HttpResponse.json( + { + code: 200, + message: '操作成功', + data: { userId: 42, username: body.username, userCode: body.userCode }, + timestamp: Date.now(), + }, + { status: 201 }, + ); + }), + + http.put(`${BASE}/users/:userId`, async ({ params, request }) => { + const userId = Number(params.userId); + const body = (await request.json()) as { isDeleted?: boolean; userCode?: string }; + if (userId === 99999) { + return HttpResponse.json( + { code: 40401, message: '用户不存在', data: null, timestamp: Date.now() }, + { status: 404 }, + ); + } + if (body.isDeleted === true && userId === 2) { + return HttpResponse.json( + { code: 40302, message: '不允许停用当前登录用户自己', data: null, timestamp: Date.now() }, + { status: 403 }, + ); + } + if (body.userCode === 'dup-code') { + return HttpResponse.json( + { code: 40902, message: '用户号已被占用', data: null, timestamp: Date.now() }, + { status: 409 }, + ); + } + return HttpResponse.json({ + code: 200, + message: '操作成功', + data: { + userId, + username: 'alice', + employeeName: '张三', + userCode: body.userCode ?? 'U001', + departmentName: '技术部', + userType: 'NORMAL', + language: 'zh-CN', + isDeleted: body.isDeleted ?? false, + lastLoginDate: '2026-05-15T08:00:00', + employeeId: 1, + permissionCategoryIds: [1, 2], + updatedBy: 'admin', + updatedDate: '2026-05-15T09:00:00', + }, + timestamp: Date.now(), + }); + }), ];