Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-bom-recalc-rootfix-20260805
This commit is contained in:
commit
cc01261d96
@ -0,0 +1 @@
|
||||
ALTER TYPE "BusinessAccountType" ADD VALUE IF NOT EXISTS 'MANAGEMENT';
|
||||
@ -2000,6 +2000,7 @@ enum BusinessAccountType {
|
||||
PURCHASING
|
||||
OPERATIONS
|
||||
WAREHOUSE
|
||||
MANAGEMENT
|
||||
SUPPLIER
|
||||
EXTERNAL_INTAKE
|
||||
}
|
||||
|
||||
@ -23,6 +23,10 @@ export async function GET(request: NextRequest) {
|
||||
const data = await supplyChainProductArchiveDashboardService.getDashboard(parsed.data)
|
||||
return jsonSuccess(data, 200, ctx.requestId)
|
||||
},
|
||||
{ requireAuth: true, allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] },
|
||||
{
|
||||
requireAuth: true,
|
||||
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'],
|
||||
allowedBusinessRoles: ['MANAGEMENT'],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -78,6 +78,7 @@ export async function GET(request: NextRequest) {
|
||||
{
|
||||
requireAuth: true,
|
||||
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'],
|
||||
allowedBusinessRoles: ['MANAGEMENT'],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -2242,9 +2242,16 @@ function DetailTableRow({
|
||||
onPreviewImage: (image: StyleImagePreview) => void
|
||||
}) {
|
||||
const timingFields = buildTimingDetailFields(item)
|
||||
const overtimeStages = getOvertimeStages(item)
|
||||
const isOvertime = item.overallTimeoutFlag || overtimeStages.length > 0
|
||||
const timeoutFollowupSummary = getTimeoutFollowupSummary(overtimeStages, timeoutFollowups)
|
||||
const currentOvertimeStages = getOvertimeStages(item)
|
||||
const historicalOvertimeStages = getHistoricalOvertimeStages(item)
|
||||
const followupDisplayStages = getFollowupDisplayStages(
|
||||
currentOvertimeStages,
|
||||
historicalOvertimeStages,
|
||||
timeoutFollowups,
|
||||
)
|
||||
const isOvertime =
|
||||
item.overallTimeoutFlag || currentOvertimeStages.length > 0 || historicalOvertimeStages.length > 0
|
||||
const timeoutFollowupSummary = getTimeoutFollowupSummary(followupDisplayStages, timeoutFollowups)
|
||||
const competitorLink = formatCompetitorLinkSummary(item.formSummary.competitorLink)
|
||||
const primaryStyleImage = item.styleImages[0]
|
||||
const openingCode = formatCellText(item.formSummary.openingCode || item.openingCode)
|
||||
|
||||
@ -17,6 +17,7 @@ export const BUSINESS_ACCOUNT_TYPE_OPTIONS: Array<{
|
||||
{ value: 'PURCHASING', label: '采购' },
|
||||
{ value: 'OPERATIONS', label: '运营' },
|
||||
{ value: 'WAREHOUSE', label: '仓库' },
|
||||
{ value: 'MANAGEMENT', label: '经管' },
|
||||
{ value: 'SUPPLIER', label: '供应商' },
|
||||
{ value: EXTERNAL_INTAKE_BUSINESS_ROLE, label: '外部录入员' },
|
||||
]
|
||||
@ -30,6 +31,7 @@ export const BUSINESS_ACCOUNT_TYPE_LABELS: Record<AppBusinessAccountType, string
|
||||
PURCHASING: '采购',
|
||||
OPERATIONS: '运营',
|
||||
WAREHOUSE: '仓库',
|
||||
MANAGEMENT: '经管',
|
||||
SUPPLIER: '供应商',
|
||||
EXTERNAL_INTAKE: '外部录入员',
|
||||
}
|
||||
|
||||
@ -89,6 +89,21 @@ describe('intake node permission settings', () => {
|
||||
],
|
||||
}),
|
||||
).toEqual(['operation'])
|
||||
|
||||
expect(
|
||||
resolveIntakeNodePermissionSubjectKeys({
|
||||
role: 'USER',
|
||||
businessRoles: [],
|
||||
departmentPaths: ['江苏简尚品牌管理有限公司 > 经营管理中心 > 数据分析组'],
|
||||
}),
|
||||
).toEqual(['management'])
|
||||
|
||||
expect(
|
||||
resolveIntakeNodePermissionSubjectKeys({
|
||||
role: 'USER',
|
||||
businessRoles: ['MANAGEMENT'],
|
||||
}),
|
||||
).toEqual(['management'])
|
||||
})
|
||||
|
||||
it('uses the same matrix for default view, download, and admin-opened overrides', () => {
|
||||
@ -180,6 +195,20 @@ describe('intake node permission settings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps management hidden until an administrator opens an action', () => {
|
||||
const managementKeys = ['management'] as const
|
||||
expect(
|
||||
resolveIntakeNodePermissionAccess({}, 'productData', managementKeys),
|
||||
).toMatchObject({ view: false, download: false, edit: false })
|
||||
|
||||
const overrides = {
|
||||
[buildIntakeNodePermissionOverrideKey('productData', 'management', 'view')]: true,
|
||||
}
|
||||
expect(
|
||||
resolveIntakeNodePermissionAccess(overrides, 'productData', managementKeys),
|
||||
).toMatchObject({ view: true, download: false, edit: false })
|
||||
})
|
||||
|
||||
it('keeps external intake users on their own product development detail lane', () => {
|
||||
const subjectKeys = resolveIntakeNodePermissionSubjectKeys({
|
||||
role: 'USER',
|
||||
|
||||
@ -26,6 +26,7 @@ export type IntakeNodePermissionSubjectKey =
|
||||
| "warehouse"
|
||||
| "warehouseQuality"
|
||||
| "merchandiser"
|
||||
| "management"
|
||||
| "externalIntake"
|
||||
| "viewer";
|
||||
|
||||
@ -98,6 +99,7 @@ const ALL_SUBJECT_KEYS: IntakeNodePermissionSubjectKey[] = [
|
||||
"warehouse",
|
||||
"warehouseQuality",
|
||||
"merchandiser",
|
||||
"management",
|
||||
"externalIntake",
|
||||
"viewer",
|
||||
];
|
||||
@ -170,6 +172,7 @@ const NODE_PERMISSION_HIDDEN: Record<
|
||||
warehouse: "hidden",
|
||||
warehouseQuality: "hidden",
|
||||
merchandiser: "hidden",
|
||||
management: "hidden",
|
||||
externalIntake: "hidden",
|
||||
viewer: "hidden",
|
||||
};
|
||||
@ -191,6 +194,7 @@ const NODE_PERMISSION_READONLY: Record<
|
||||
warehouse: "readonly",
|
||||
warehouseQuality: "readonly",
|
||||
merchandiser: "readonly",
|
||||
management: "hidden",
|
||||
externalIntake: "hidden",
|
||||
viewer: "readonly",
|
||||
};
|
||||
@ -345,6 +349,11 @@ export const ORDINARY_NODE_PERMISSION_SUBJECTS: IntakeNodePermissionSubject[] =
|
||||
name: "跟单组",
|
||||
department: "供应链中心-品牌采购部-跟单组",
|
||||
},
|
||||
{
|
||||
key: "management",
|
||||
name: "经管",
|
||||
department: "经营管理中心",
|
||||
},
|
||||
{
|
||||
key: "externalIntake",
|
||||
name: "外部录入员",
|
||||
@ -845,6 +854,10 @@ function isOperationDepartmentPath(path: string) {
|
||||
return /运营/.test(path);
|
||||
}
|
||||
|
||||
function isManagementDepartmentPath(path: string) {
|
||||
return /经营管理中心/.test(path);
|
||||
}
|
||||
|
||||
function isCustomerServiceDepartmentPath(path: string) {
|
||||
return /客服中心/.test(path);
|
||||
}
|
||||
@ -980,6 +993,10 @@ export function resolveIntakeNodePermissionSubjectKeys(
|
||||
pushUniqueSubject(keys, "operation");
|
||||
}
|
||||
|
||||
if (hasDepartmentPath(user, isManagementDepartmentPath)) {
|
||||
pushUniqueSubject(keys, "management");
|
||||
}
|
||||
|
||||
if (hasDepartmentPath(user, isCustomerServiceDepartmentPath)) {
|
||||
pushUniqueSubject(keys, "customerService");
|
||||
}
|
||||
@ -1008,6 +1025,10 @@ export function resolveIntakeNodePermissionSubjectKeys(
|
||||
pushUniqueSubject(keys, "customerService");
|
||||
}
|
||||
|
||||
if (hasBusinessRoleFallback(user, "MANAGEMENT")) {
|
||||
pushUniqueSubject(keys, "management");
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
keys.push("viewer");
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ export const RELEASE_NOTE_BUSINESS_ROLE_OPTIONS = [
|
||||
{ value: 'PURCHASING', label: '采购' },
|
||||
{ value: 'OPERATIONS', label: '运营' },
|
||||
{ value: 'WAREHOUSE', label: '仓库' },
|
||||
{ value: 'MANAGEMENT', label: '经管' },
|
||||
{ value: 'SUPPLIER', label: '供应商' },
|
||||
{ value: 'EXTERNAL_INTAKE', label: '外部录入' },
|
||||
] as const
|
||||
|
||||
@ -18,6 +18,21 @@ describe('workspace route permissions', () => {
|
||||
expect(canAccessWorkspacePath('/supply-chain', { role: 'ADMIN', businessRoles: [] })).toBe(true)
|
||||
})
|
||||
|
||||
it('allows management users into supply chain only', () => {
|
||||
const managementUser = {
|
||||
role: 'USER',
|
||||
businessRoles: ['MANAGEMENT'],
|
||||
}
|
||||
|
||||
expect(getWorkspaceAccessLevel(managementUser)).toBe('management')
|
||||
expect(canAccessSupplyChainWorkspace(managementUser)).toBe(true)
|
||||
expect(canAccessWorkspacePath('/supply-chain', managementUser)).toBe(true)
|
||||
expect(canAccessWorkspacePath('/supply-chain/dashboard', managementUser)).toBe(true)
|
||||
expect(canAccessWorkspacePath('/asset-center', managementUser)).toBe(false)
|
||||
expect(canAccessWorkspacePath('/warehouse', managementUser)).toBe(false)
|
||||
expect(canAccessWorkspacePath('/users', managementUser)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps space visual users in formal products only even if their business role is design', () => {
|
||||
const spaceVisualUser = {
|
||||
role: 'USER',
|
||||
@ -195,6 +210,12 @@ describe('workspace route permissions', () => {
|
||||
expect(
|
||||
canAccessWorkspacePathByAccessLevel('/supply-chain/dashboard', 'full', [], 'USER'),
|
||||
).toBe(false)
|
||||
expect(
|
||||
canAccessWorkspacePathByAccessLevel('/supply-chain/dashboard', 'management', [], 'USER'),
|
||||
).toBe(true)
|
||||
expect(
|
||||
canAccessWorkspacePathByAccessLevel('/supply-chain/dashboard', 'formal-products-only', [], 'USER'),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('redirects special accounts to product data and blocked internal users to the portal', () => {
|
||||
|
||||
@ -25,7 +25,11 @@ const USER_MANAGEMENT_ALLOWED_ROLES: AllowedRole[] = [
|
||||
'SUPERVISOR_TEAM',
|
||||
]
|
||||
|
||||
export type WorkspaceAccessLevel = 'full' | 'formal-products-only' | 'supplier-barcode-print-only'
|
||||
export type WorkspaceAccessLevel =
|
||||
| 'full'
|
||||
| 'management'
|
||||
| 'formal-products-only'
|
||||
| 'supplier-barcode-print-only'
|
||||
|
||||
export interface WorkspacePermissionUser {
|
||||
role?: string | null
|
||||
@ -63,8 +67,8 @@ export function canAccessSupplyChainWorkspace(
|
||||
) {
|
||||
return Boolean(
|
||||
canAccessHomePortal(user) &&
|
||||
user?.role &&
|
||||
SUPPLY_CHAIN_ALLOWED_ROLES.has(user.role),
|
||||
((user?.role && SUPPLY_CHAIN_ALLOWED_ROLES.has(user.role)) ||
|
||||
user?.businessRoles?.includes('MANAGEMENT')),
|
||||
)
|
||||
}
|
||||
|
||||
@ -77,7 +81,10 @@ export function getWorkspaceAccessLevel(
|
||||
): WorkspaceAccessLevel {
|
||||
if (isSupplierAccount(user)) return 'supplier-barcode-print-only'
|
||||
if (isExternalIntakeUser(user)) return 'formal-products-only'
|
||||
return hasFullWorkspaceAccess(user) ? 'full' : 'formal-products-only'
|
||||
if (hasFullWorkspaceAccess(user)) return 'full'
|
||||
return user?.businessRoles?.includes('MANAGEMENT')
|
||||
? 'management'
|
||||
: 'formal-products-only'
|
||||
}
|
||||
|
||||
export function isRestrictedWorkspacePathAllowed(pathname: string) {
|
||||
@ -174,7 +181,7 @@ export function canAccessWorkspacePathByAccessLevel(
|
||||
return accessLevel !== 'supplier-barcode-print-only'
|
||||
}
|
||||
if (isSupplyChainPath(pathname)) {
|
||||
return Boolean(role && SUPPLY_CHAIN_ALLOWED_ROLES.has(role))
|
||||
return accessLevel === 'management' || Boolean(role && SUPPLY_CHAIN_ALLOWED_ROLES.has(role))
|
||||
}
|
||||
if (!accessLevel || accessLevel === 'full') return true
|
||||
if (accessLevel === 'supplier-barcode-print-only') {
|
||||
|
||||
@ -1,15 +1,31 @@
|
||||
export {}
|
||||
|
||||
const mockResolveObjectStorageUrlForClientAccess = jest.fn()
|
||||
const mockAuthenticateRequest = jest.fn()
|
||||
|
||||
jest.mock('@/lib/storage', () => ({
|
||||
resolveObjectStorageUrlForClientAccess: (...args: unknown[]) =>
|
||||
mockResolveObjectStorageUrlForClientAccess(...args),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/auth-middleware', () => ({
|
||||
authenticateRequest: (...args: unknown[]) => mockAuthenticateRequest(...args),
|
||||
}))
|
||||
|
||||
jest.mock('next/server', () => ({
|
||||
NextResponse: {
|
||||
json: (body: unknown, init?: { status?: number }) => ({
|
||||
status: init?.status ?? 200,
|
||||
json: async () => body,
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
import type { NextRequest } from 'next/server'
|
||||
import {
|
||||
isSupplierAllowedApiRequest,
|
||||
handleRequest,
|
||||
jsonSuccess,
|
||||
success,
|
||||
successWithPagination,
|
||||
} from './response'
|
||||
@ -140,3 +156,65 @@ describe('supplier request boundary', () => {
|
||||
expect(isSupplierAllowedApiRequest(createRequest(path, method))).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('request business-role authorization', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('allows a management user through an explicitly allowed read scope', async () => {
|
||||
mockAuthenticateRequest.mockResolvedValue({
|
||||
success: true,
|
||||
userInfo: {
|
||||
id: 'management-user',
|
||||
email: 'management@example.com',
|
||||
name: 'Management User',
|
||||
role: 'USER',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: ['MANAGEMENT'],
|
||||
},
|
||||
})
|
||||
|
||||
const response = await handleRequest(
|
||||
createRequest('/api/supply-chain/product-archive', 'GET'),
|
||||
async (ctx) => jsonSuccess({ userId: ctx.user.id }, 200, ctx.requestId),
|
||||
{
|
||||
requireAuth: true,
|
||||
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'],
|
||||
allowedBusinessRoles: ['MANAGEMENT'],
|
||||
},
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { userId: 'management-user' },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an ordinary user out of a management-only read scope', async () => {
|
||||
mockAuthenticateRequest.mockResolvedValue({
|
||||
success: true,
|
||||
userInfo: {
|
||||
id: 'ordinary-user',
|
||||
email: 'ordinary@example.com',
|
||||
name: 'Ordinary User',
|
||||
role: 'USER',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: ['GENERAL'],
|
||||
},
|
||||
})
|
||||
|
||||
const response = await handleRequest(
|
||||
createRequest('/api/supply-chain/product-archive', 'GET'),
|
||||
async (ctx) => jsonSuccess({ userId: ctx.user.id }, 200, ctx.requestId),
|
||||
{
|
||||
requireAuth: true,
|
||||
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'],
|
||||
allowedBusinessRoles: ['MANAGEMENT'],
|
||||
},
|
||||
)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
@ -10,7 +10,8 @@ import type { ApiResponse, PaginationMeta, ResponseMeta, AuthUser, RequestContex
|
||||
import { AppError, ErrorCode, ERROR_MESSAGES, isAppError } from './errors'
|
||||
import { HTTP_STATUS, ENV } from './constants'
|
||||
import type { AllowedRole } from '@/lib/role-scope'
|
||||
import { canCreateContent, canOperateDesignWorkspace, hasAllowedRole } from '@/lib/role-scope'
|
||||
import { canCreateContent, canOperateDesignWorkspace, hasAllowedRole, hasBusinessRole } from '@/lib/role-scope'
|
||||
import type { BusinessAccountType } from '@prisma/client'
|
||||
import { resolveObjectStorageUrlForClientAccess } from '@/lib/storage'
|
||||
import { isSupplierAccount } from '@/lib/supplier-account'
|
||||
|
||||
@ -195,6 +196,8 @@ interface HandleRequestOptions {
|
||||
requireAuth?: boolean
|
||||
/** 允许的角色 */
|
||||
allowedRoles?: AllowedRole[]
|
||||
/** 允许的业务类型 */
|
||||
allowedBusinessRoles?: BusinessAccountType[]
|
||||
/** 是否需要内部业务写入权限 */
|
||||
requireInternalContentWrite?: boolean
|
||||
/** 是否需要设计工作区操作权限 */
|
||||
@ -251,7 +254,13 @@ export async function handleRequest<T>(
|
||||
}
|
||||
|
||||
// 角色检查
|
||||
if (options.allowedRoles && !hasAllowedRole(user, options.allowedRoles)) {
|
||||
const hasRoleScope = Boolean(options.allowedRoles?.length)
|
||||
const hasBusinessRoleScope = Boolean(options.allowedBusinessRoles?.length)
|
||||
const isAllowedByRole = hasRoleScope && hasAllowedRole(user, options.allowedRoles!)
|
||||
const isAllowedByBusinessRole = Boolean(
|
||||
options.allowedBusinessRoles?.some((businessRole) => hasBusinessRole(user!, businessRole)),
|
||||
)
|
||||
if ((hasRoleScope || hasBusinessRoleScope) && !isAllowedByRole && !isAllowedByBusinessRole) {
|
||||
return jsonError(
|
||||
ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
'没有权限执行此操作',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user