fix(auth): 放行主管用户管理入口

This commit is contained in:
WuYongJie 2026-07-06 21:51:59 +08:00
parent 50b8f61a69
commit cec4b449ad
6 changed files with 112 additions and 10 deletions

View File

@ -481,7 +481,9 @@ export async function getUserInfo(userId: string): Promise<UserInfo | null> {
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),
}
}

View File

@ -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',

View File

@ -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<WorkspacePermissionUser, 'role'> | 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)
}

View File

@ -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,

View File

@ -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']),
}),
)
})

View File

@ -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<AuthUserDTO> {
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<LoginResult> {
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,