From cec4b449ad893a1a5674245c2a94c9719fdfe892 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 6 Jul 2026 21:51:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E6=94=BE=E8=A1=8C=E4=B8=BB?= =?UTF-8?q?=E7=AE=A1=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- furniture-3d-system/src/lib/permissions.ts | 6 ++- .../lib/workspace-route-permissions.test.ts | 25 +++++++++++ .../src/lib/workspace-route-permissions.ts | 32 ++++++++++++++- furniture-3d-system/src/proxy.ts | 4 ++ .../services/__tests__/auth.service.test.ts | 14 ++++--- .../src/server/services/auth.service.ts | 41 ++++++++++++++++++- 6 files changed, 112 insertions(+), 10 deletions(-) diff --git a/furniture-3d-system/src/lib/permissions.ts b/furniture-3d-system/src/lib/permissions.ts index b8c3b1a5..278d7058 100644 --- a/furniture-3d-system/src/lib/permissions.ts +++ b/furniture-3d-system/src/lib/permissions.ts @@ -481,7 +481,9 @@ export async function getUserInfo(userId: string): Promise { departmentPaths, departmentId: user.departmentId || undefined, teamId: user.teamId || undefined, - managedDepartmentId: user.managedDepartment?.id, - managedTeamId: user.managedTeam?.id, + managedDepartmentId: + user.managedDepartment?.id ?? (user.role === 'SUPERVISOR' ? user.departmentId || undefined : undefined), + managedTeamId: + user.managedTeam?.id ?? (user.role === 'SUPERVISOR' ? user.teamId || undefined : undefined), } } diff --git a/furniture-3d-system/src/lib/workspace-route-permissions.test.ts b/furniture-3d-system/src/lib/workspace-route-permissions.test.ts index 4fa2411c..035f178c 100644 --- a/furniture-3d-system/src/lib/workspace-route-permissions.test.ts +++ b/furniture-3d-system/src/lib/workspace-route-permissions.test.ts @@ -1,4 +1,5 @@ import { + canPassUserManagementProxy, canAccessWorkspacePath, canAccessWorkspacePathByAccessLevel, getWorkspaceAccessLevel, @@ -83,6 +84,30 @@ describe('workspace route permissions', () => { expect(canAccessWorkspacePath('/warehouse', purchasingUser)).toBe(true) expect(canAccessWorkspacePath('/warehouse/receiving', purchasingUser)).toBe(true) }) + + it('allows scoped supervisors to open user management without full workspace access', () => { + const operationsSupervisor = { + role: 'SUPERVISOR', + businessRoles: [], + departmentId: 'dept-operation-b', + department: { + name: '运营中心B', + fullPath: '江苏简尚品牌管理有限公司 > 运营中心B', + }, + } + + expect(hasFullWorkspaceAccess(operationsSupervisor)).toBe(false) + expect(getWorkspaceAccessLevel(operationsSupervisor)).toBe('formal-products-only') + expect(canAccessWorkspacePath('/users', operationsSupervisor)).toBe(true) + expect(canAccessWorkspacePath('/users/organization-tree', operationsSupervisor)).toBe(true) + expect(canAccessWorkspacePath('/asset-center', operationsSupervisor)).toBe(false) + }) + + it('lets existing supervisor tokens pass the proxy for user management pages', () => { + expect(canPassUserManagementProxy({ role: 'SUPERVISOR' })).toBe(true) + expect(canPassUserManagementProxy({ role: 'USER' })).toBe(false) + }) + it('keeps supplier accounts limited to the barcode print workspace', () => { const supplierUser = { role: 'USER', diff --git a/furniture-3d-system/src/lib/workspace-route-permissions.ts b/furniture-3d-system/src/lib/workspace-route-permissions.ts index cbde9d03..76577d12 100644 --- a/furniture-3d-system/src/lib/workspace-route-permissions.ts +++ b/furniture-3d-system/src/lib/workspace-route-permissions.ts @@ -1,9 +1,16 @@ -import { canOperateDesignWorkspace } from './role-scope' +import { canOperateDesignWorkspace, hasAllowedRole, type AllowedRole } from './role-scope' import { AI_RENDER_WORKBENCH_PATH, canViewAiRenderWorkbench } from './ai-render-workbench-permissions' import { isSupplierAccount } from './supplier-account' export const FORMAL_PRODUCTS_HOME_PATH = '/intake' export const SUPPLIER_BARCODE_PRINT_PATH = '/intake' +export const USER_MANAGEMENT_PATH = '/users' +const USER_MANAGEMENT_ALLOWED_ROLES: AllowedRole[] = [ + 'SUPER_ADMIN', + 'ADMIN', + 'SUPERVISOR_DEPARTMENT', + 'SUPERVISOR_TEAM', +] export type WorkspaceAccessLevel = 'full' | 'formal-products-only' | 'supplier-barcode-print-only' @@ -13,6 +20,10 @@ export interface WorkspacePermissionUser { supplierCode?: string | null department?: { name?: string | null; fullPath?: string | null } | null departmentPaths?: readonly string[] | null + departmentId?: string | null + teamId?: string | null + managedDepartmentId?: string | null + managedTeamId?: string | null workspaceAllowedPathPrefixes?: readonly string[] | null } @@ -54,12 +65,31 @@ function isPathAllowedByPrefixes( ) } +export function isUserManagementPath(pathname: string) { + return pathname === USER_MANAGEMENT_PATH || pathname.startsWith(`${USER_MANAGEMENT_PATH}/`) +} + +export function canAccessUserManagement(user: WorkspacePermissionUser | null | undefined) { + return Boolean(user && hasAllowedRole(user, USER_MANAGEMENT_ALLOWED_ROLES)) +} + +export function canPassUserManagementProxy( + user: Pick | null | undefined, +) { + if (!user?.role) return false + if (user.role === 'SUPER_ADMIN' || user.role === 'ADMIN') return true + // Proxy only has the JWT payload and may be seeing an older token. The page + // and API fetch the current database user before showing or returning data. + return user.role === 'SUPERVISOR' +} + export function canAccessWorkspacePath( pathname: string, user: WorkspacePermissionUser | null | undefined, ) { const accessLevel = getWorkspaceAccessLevel(user) if (accessLevel === 'full') return true + if (isUserManagementPath(pathname) && canAccessUserManagement(user)) return true if (pathname === AI_RENDER_WORKBENCH_PATH || pathname.startsWith(`${AI_RENDER_WORKBENCH_PATH}/`)) { return canViewAiRenderWorkbench(user) } diff --git a/furniture-3d-system/src/proxy.ts b/furniture-3d-system/src/proxy.ts index 7fea3c22..0635ad85 100644 --- a/furniture-3d-system/src/proxy.ts +++ b/furniture-3d-system/src/proxy.ts @@ -12,6 +12,8 @@ import { validateToken } from '@/lib/token-validator' import { FORMAL_PRODUCTS_HOME_PATH, canAccessWorkspacePathByAccessLevel, + canPassUserManagementProxy, + isUserManagementPath, type WorkspaceAccessLevel, } from '@/lib/workspace-route-permissions' @@ -67,6 +69,7 @@ export function isPublicPath(rawPathname: string): boolean { interface WorkspaceTokenPayload { exp?: number + role?: string workspaceAccess?: WorkspaceAccessLevel workspaceAllowedPathPrefixes?: string[] } @@ -108,6 +111,7 @@ export async function proxy(request: NextRequest) { } if ( + !(isUserManagementPath(pathname) && canPassUserManagementProxy(payload)) && !canAccessWorkspacePathByAccessLevel( pathname, payload.workspaceAccess, diff --git a/furniture-3d-system/src/server/services/__tests__/auth.service.test.ts b/furniture-3d-system/src/server/services/__tests__/auth.service.test.ts index e308cfcd..e80c02b0 100644 --- a/furniture-3d-system/src/server/services/__tests__/auth.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/auth.service.test.ts @@ -40,6 +40,11 @@ jest.mock('../user-department-paths.service', () => ({ })) jest.mock('@/lib/workspace-route-permissions', () => ({ + USER_MANAGEMENT_PATH: '/users', + canAccessUserManagement: (user: { role?: string; departmentId?: string | null; managedDepartmentId?: string | null }) => + user?.role === 'SUPER_ADMIN' || + user?.role === 'ADMIN' || + (user?.role === 'SUPERVISOR' && Boolean(user.departmentId ?? user.managedDepartmentId)), getWorkspaceAccessLevel: (...args: unknown[]) => mockGetWorkspaceAccessLevel(...args), })) @@ -139,15 +144,12 @@ describe('authService employee auto activation', () => { name: '运营中心B', fullPath: '江苏简尚品牌管理有限公司 > 运营中心B', }, - managedDepartment: { - id: 'dept-operation-b', - name: '运营中心B', - fullPath: '江苏简尚品牌管理有限公司 > 运营中心B', - }, + managedDepartment: null, }) mockAuthRepository.findByIdentifier.mockResolvedValue(activeSupervisor as never) mockAuthRepository.getEmployeeAutoActivationContext.mockResolvedValue(null as never) mockAuthRepository.findByIdWithRelations.mockResolvedValue(activeSupervisor as never) + mockGetWorkspaceAccessLevel.mockReturnValue('formal-products-only' as never) const result = await authService.login({ identifier: '苏适', @@ -158,9 +160,11 @@ describe('authService employee auto activation', () => { expect(result.user.teamId).toBeNull() expect(result.user.managedDepartmentId).toBe('dept-operation-b') expect(result.user.managedTeamId).toBeNull() + expect(result.user.workspaceAllowedPathPrefixes).toContain('/users') expect(mockGenerateToken).toHaveBeenCalledWith( expect.objectContaining({ departmentId: 'dept-operation-b', + workspaceAllowedPathPrefixes: expect.arrayContaining(['/users']), }), ) }) diff --git a/furniture-3d-system/src/server/services/auth.service.ts b/furniture-3d-system/src/server/services/auth.service.ts index eef82a2f..6be2bc74 100644 --- a/furniture-3d-system/src/server/services/auth.service.ts +++ b/furniture-3d-system/src/server/services/auth.service.ts @@ -16,6 +16,8 @@ import type { BusinessAccountType, Prisma, Role, UserStatus } from '@prisma/clie import { authRepository, type AuthUserWithRelations } from '../repositories/auth.repository' import { getUserDepartmentPathsById } from './user-department-paths.service' import { + USER_MANAGEMENT_PATH, + canAccessUserManagement, getWorkspaceAccessLevel, type WorkspaceAccessLevel, } from '@/lib/workspace-route-permissions' @@ -118,6 +120,10 @@ type WorkspaceTokenPermissionUser = { businessRoles?: readonly string[] | null department?: { name?: string | null; fullPath?: string | null } | null departmentPaths?: readonly string[] | null + departmentId?: string | null + teamId?: string | null + managedDepartmentId?: string | null + managedTeamId?: string | null } async function buildWorkspaceTokenPermissions(user: WorkspaceTokenPermissionUser) { @@ -134,6 +140,9 @@ async function buildWorkspaceTokenPermissions(user: WorkspaceTokenPermissionUser if (!isExternalIntakeUser(user) && !isSupplierAccount(user)) { allowedPathPrefixes.push(AI_RENDER_WORKBENCH_PATH) } + if (canAccessUserManagement(user)) { + allowedPathPrefixes.push(USER_MANAGEMENT_PATH) + } return { workspaceAccess, workspaceAllowedPathPrefixes: Array.from(new Set(allowedPathPrefixes)), @@ -145,11 +154,19 @@ async function buildAuthUserDTO( options: { activationRequired?: boolean } = {}, ): Promise { const departmentPaths = await getUserDepartmentPathsById(user.id) + const managedDepartmentId = + user.managedDepartment?.id ?? (user.role === 'SUPERVISOR' ? user.departmentId : null) + const managedTeamId = + user.managedTeam?.id ?? (user.role === 'SUPERVISOR' ? user.teamId : null) const workspacePermissions = await buildWorkspaceTokenPermissions({ role: user.role, businessRoles: user.businessRoles, department: user.department, departmentPaths, + departmentId: user.departmentId, + teamId: user.teamId, + managedDepartmentId, + managedTeamId, }) return { @@ -171,9 +188,9 @@ async function buildAuthUserDTO( team: user.team, teamId: user.teamId, managedDepartment: user.managedDepartment, - managedDepartmentId: user.managedDepartment?.id ?? null, + managedDepartmentId, managedTeam: user.managedTeam, - managedTeamId: user.managedTeam?.id ?? null, + managedTeamId, avatar: user.avatar, lastLoginAt: user.lastLoginAt, ...workspacePermissions, @@ -321,6 +338,14 @@ class AuthService { businessRoles: effectiveUser.businessRoles, department: effectiveUser.department, departmentPaths: await getUserDepartmentPathsById(effectiveUser.id), + departmentId: effectiveUser.departmentId, + teamId: effectiveUser.teamId, + managedDepartmentId: + effectiveUser.managedDepartment?.id ?? + (effectiveUser.role === 'SUPERVISOR' ? effectiveUser.departmentId : null), + managedTeamId: + effectiveUser.managedTeam?.id ?? + (effectiveUser.role === 'SUPERVISOR' ? effectiveUser.teamId : null), }) const token = generateToken({ userId: effectiveUser.id, @@ -426,6 +451,10 @@ class AuthService { role: user.role, businessRoles: user.businessRoles, departmentPaths, + departmentId: user.departmentId, + teamId: user.teamId, + managedDepartmentId: user.role === 'SUPERVISOR' ? user.departmentId : null, + managedTeamId: user.role === 'SUPERVISOR' ? user.teamId : null, }) return generateToken({ @@ -496,11 +525,19 @@ class AuthService { private async toLoginResult(user: AuthUserWithRelations): Promise { const departmentPaths = await getUserDepartmentPathsById(user.id) + const managedDepartmentId = + user.managedDepartment?.id ?? (user.role === 'SUPERVISOR' ? user.departmentId : null) + const managedTeamId = + user.managedTeam?.id ?? (user.role === 'SUPERVISOR' ? user.teamId : null) const workspacePermissions = await buildWorkspaceTokenPermissions({ role: user.role, businessRoles: user.businessRoles, department: user.department, departmentPaths, + departmentId: user.departmentId, + teamId: user.teamId, + managedDepartmentId, + managedTeamId, }) const token = generateToken({ userId: user.id,