fix(auth): 放行主管用户管理入口
This commit is contained in:
parent
50b8f61a69
commit
cec4b449ad
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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']),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user