feat(users): refine permission management
This commit is contained in:
parent
6178d09e31
commit
c0aab147bf
@ -9,7 +9,7 @@ export async function GET(request: NextRequest) {
|
||||
request,
|
||||
async (ctx) => {
|
||||
try {
|
||||
bomCostingAccessService.assertCanView(ctx.user)
|
||||
await bomCostingAccessService.assertCanView(ctx.user)
|
||||
const [materials, sync] = await Promise.all([
|
||||
bomCostingScenarioService.listMaterials(),
|
||||
bomCostingScenarioService.getSyncFreshness(),
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import { canViewBomCostingWorkbench } from '@/lib/bom-costing-permissions'
|
||||
import { bomCostingAccessService } from '@/server/services/bom-costing/bom-costing-access.service'
|
||||
import { bomCostingService } from '@/server/services/bom-costing/bom-costing.service'
|
||||
import BomCostingWorkbenchClient from './BomCostingWorkbenchClient'
|
||||
|
||||
@ -10,7 +10,7 @@ export const metadata = {
|
||||
|
||||
export default async function BomCostingPage() {
|
||||
const { authUser } = await requireSession('/login')
|
||||
if (!canViewBomCostingWorkbench(authUser)) {
|
||||
if (!(await bomCostingAccessService.canView(authUser))) {
|
||||
redirect('/intake')
|
||||
}
|
||||
|
||||
|
||||
@ -91,6 +91,7 @@ function resolveIntakeFormalWorkspaceAccess(
|
||||
overrides,
|
||||
targetKey,
|
||||
resolveIntakeNodePermissionSubjectKeys(user),
|
||||
user.id,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -202,9 +202,10 @@ export function useNonIntegratedPermissionState({
|
||||
nodePermissionOverrides,
|
||||
targetKey,
|
||||
currentPermissionSubjectKeys,
|
||||
currentPermissionUser.id,
|
||||
);
|
||||
},
|
||||
[currentPermissionSubjectKeys, nodePermissionOverrides],
|
||||
[currentPermissionSubjectKeys, currentPermissionUser.id, nodePermissionOverrides],
|
||||
);
|
||||
const activeNodePermissionAccess = getNodePermissionAccessForCategory(
|
||||
activeDocumentWorkspaceCategory,
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronLeft, ChevronRight, type LucideIcon } from 'lucide-react'
|
||||
|
||||
export type PermissionLevelNavigationItem = {
|
||||
key: string
|
||||
label: string
|
||||
icon?: LucideIcon
|
||||
active?: boolean
|
||||
hasChildren?: boolean
|
||||
}
|
||||
|
||||
export function PermissionLevelNavigation({
|
||||
title,
|
||||
items,
|
||||
backLabel,
|
||||
onBack,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string
|
||||
items: PermissionLevelNavigationItem[]
|
||||
backLabel?: string
|
||||
onBack?: () => void
|
||||
onSelect: (key: string) => void
|
||||
}) {
|
||||
return (
|
||||
<aside className="flex min-h-0 flex-col border-r border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)]">
|
||||
<div className="shrink-0 border-b border-[var(--obs-shell-border)] px-3 py-3">
|
||||
{onBack ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="mb-2 inline-flex items-center gap-1 text-[10px] font-bold text-[var(--obs-shell-muted)] transition hover:text-[var(--obs-shell-title)]"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={1.8} />
|
||||
{backLabel ?? '返回上一级'}
|
||||
</button>
|
||||
) : null}
|
||||
<p className="text-[12px] font-black text-[var(--obs-shell-title)]">{title}</p>
|
||||
</div>
|
||||
<nav className="min-h-0 flex-1 space-y-0.5 overflow-y-auto p-2" aria-label={title}>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => onSelect(item.key)}
|
||||
className={`flex w-full items-center gap-2 rounded-[6px] px-2 py-2 text-left text-[11px] font-bold transition ${
|
||||
item.active
|
||||
? 'bg-[var(--obs-shell-muted-bg)] text-[var(--obs-shell-title)]'
|
||||
: 'text-[var(--obs-shell-muted)] hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]'
|
||||
}`}
|
||||
>
|
||||
{Icon ? <Icon className="h-3.5 w-3.5 shrink-0" strokeWidth={1.8} /> : null}
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
{item.hasChildren ? <ChevronRight className="h-3.5 w-3.5 shrink-0" strokeWidth={1.8} /> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { ProductDevelopmentNodePermissionWorkspace } from './ProductDevelopmentNodePermissionWorkspace'
|
||||
|
||||
@ -17,7 +17,7 @@ describe('ProductDevelopmentNodePermissionWorkspace', () => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('loads the existing permission matrix and saves changes through the same API', async () => {
|
||||
it('keeps only the active navigation level visible and saves changes through the same API', async () => {
|
||||
const actor = userEvent.setup()
|
||||
const fetchMock = jest
|
||||
.fn()
|
||||
@ -33,13 +33,39 @@ describe('ProductDevelopmentNodePermissionWorkspace', () => {
|
||||
render(<ProductDevelopmentNodePermissionWorkspace />)
|
||||
|
||||
expect(await screen.findByText('已读取当前权限配置')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '基础信息' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('navigation', { name: '产品资料' })).not.toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: /展开/ }))
|
||||
await actor.click(screen.getByRole('button', { name: '产品资料' }))
|
||||
expect(screen.getByRole('navigation', { name: '产品资料' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('选择二层页面;产品开发明细可进入节点权限')).not.toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '产品开发明细' }))
|
||||
expect(screen.queryByRole('navigation', { name: '产品资料' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: '产品开发明细节点' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('选择需要配置的三级节点')).not.toBeInTheDocument()
|
||||
|
||||
expect(screen.getAllByText('采购')).toHaveLength(1)
|
||||
expect(screen.getByText('采购-跟单')).toBeInTheDocument()
|
||||
expect(screen.getByText('采购-质检')).toBeInTheDocument()
|
||||
expect(screen.getByText('仓库')).toBeInTheDocument()
|
||||
const designRow = screen.getByText('设计').closest('tr')
|
||||
expect(designRow).not.toBeNull()
|
||||
await waitFor(() => expect(within(designRow!).getByRole('button', { name: /人/ })).toBeEnabled())
|
||||
await actor.click(within(designRow!).getByRole('button', { name: /人/ }))
|
||||
expect(designRow).toHaveClass('sticky', 'top-[33px]')
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '设计卖点' }))
|
||||
expect(screen.getByRole('navigation', { name: '产品开发明细节点' })).toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '返回产品资料' }))
|
||||
expect(screen.getByRole('navigation', { name: '产品资料' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('navigation', { name: '产品开发明细节点' })).not.toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '首页概览' }))
|
||||
await actor.click(screen.getByRole('button', { name: '开通供应商可见' }))
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
|
||||
const [, saveOptions] = fetchMock.mock.calls[1] as [string, RequestInit]
|
||||
await waitFor(() => expect(fetchMock.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(true))
|
||||
const [, saveOptions] = fetchMock.mock.calls.find(([, options]) => options?.method === 'PUT') as [string, RequestInit]
|
||||
expect(saveOptions.method).toBe('PUT')
|
||||
expect(JSON.parse(String(saveOptions.body))).toEqual({
|
||||
overrides: {
|
||||
|
||||
@ -30,10 +30,13 @@ import {
|
||||
sanitizeIntakeNodePermissionOverrides,
|
||||
type IntakeNodePermissionAction,
|
||||
type IntakeNodePermissionOverrideMap,
|
||||
type IntakeNodePermissionSubject,
|
||||
type IntakeNodePermissionPrincipalKey,
|
||||
type IntakeNodePermissionSubjectKey,
|
||||
type IntakeNodePermissionTargetKey,
|
||||
} from '@/lib/intake-node-permission-settings'
|
||||
import { ProductDevelopmentPermissionTable } from './ProductDevelopmentPermissionTable'
|
||||
import { PermissionLevelNavigation } from './PermissionLevelNavigation'
|
||||
import { usePermissionManagedUsers } from './usePermissionManagedUsers'
|
||||
|
||||
const TOP_ENTRY_ICONS: Partial<Record<IntakeNodePermissionTargetKey, LucideIcon>> = {
|
||||
homeDashboard: Home,
|
||||
@ -41,6 +44,8 @@ const TOP_ENTRY_ICONS: Partial<Record<IntakeNodePermissionTargetKey, LucideIcon>
|
||||
assistedAssembly: Layers,
|
||||
draftGroups: PackageOpen,
|
||||
formalProducts: ArrowDownToLine,
|
||||
bomCostingWorkbench: Calculator,
|
||||
oaNewProductBoard: ClipboardList,
|
||||
warehouseManage: Warehouse,
|
||||
warehouseQuality: ClipboardCheck,
|
||||
aiRenderWorkbench: Sparkles,
|
||||
@ -61,35 +66,14 @@ const FORMAL_WORKSPACE_ICONS: Partial<Record<IntakeNodePermissionTargetKey, Luci
|
||||
brandFiling: ClipboardCheck,
|
||||
}
|
||||
|
||||
function TargetTab({
|
||||
active,
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`inline-flex h-9 shrink-0 items-center gap-2 rounded-[5px] px-3 text-[12px] font-bold transition ${
|
||||
active
|
||||
? 'bg-[#2d3335] text-[#ffffff]'
|
||||
: 'text-[var(--obs-shell-muted)] hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-[15px] w-[15px] shrink-0" strokeWidth={1.8} />
|
||||
<span className="whitespace-nowrap">{label}</span>
|
||||
</button>
|
||||
)
|
||||
function isProductMaterialBranch(targetKey: IntakeNodePermissionTargetKey) {
|
||||
return FORMAL_WORKSPACE_PERMISSION_TARGETS.some((target) => target.key === targetKey) ||
|
||||
PRODUCT_DEVELOPMENT_PERMISSION_TARGETS.some((target) => target.key === targetKey)
|
||||
}
|
||||
|
||||
export function ProductDevelopmentNodePermissionWorkspace() {
|
||||
const [activeTargetKey, setActiveTargetKey] = useState(TOP_ENTRY_PERMISSION_TARGETS[0]!.key)
|
||||
const [navigationLevel, setNavigationLevel] = useState<'secondary' | 'tertiary' | null>(null)
|
||||
const [overrides, setOverrides] = useState<IntakeNodePermissionOverrideMap>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@ -98,6 +82,21 @@ export function ProductDevelopmentNodePermissionWorkspace() {
|
||||
ALL_NODE_PERMISSION_TARGETS.find((target) => target.key === activeTargetKey) ??
|
||||
ALL_NODE_PERMISSION_TARGETS[0]!
|
||||
const disabled = saving || !loaded
|
||||
const managedUsers = usePermissionManagedUsers()
|
||||
|
||||
function selectTopTarget(targetKey: IntakeNodePermissionTargetKey) {
|
||||
setActiveTargetKey(targetKey)
|
||||
setNavigationLevel(targetKey === 'formalProducts' ? 'secondary' : null)
|
||||
}
|
||||
|
||||
function selectFormalTarget(targetKey: IntakeNodePermissionTargetKey) {
|
||||
setActiveTargetKey(targetKey)
|
||||
if (targetKey === 'productDevelopmentList') setNavigationLevel('tertiary')
|
||||
}
|
||||
|
||||
function selectDevelopmentNode(targetKey: IntakeNodePermissionTargetKey) {
|
||||
setActiveTargetKey(targetKey)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@ -160,13 +159,20 @@ export function ProductDevelopmentNodePermissionWorkspace() {
|
||||
}
|
||||
|
||||
function toggleOverride(
|
||||
subjectKey: IntakeNodePermissionSubject['key'],
|
||||
principalKey: IntakeNodePermissionPrincipalKey,
|
||||
action: IntakeNodePermissionAction,
|
||||
subjectKeys: readonly IntakeNodePermissionSubjectKey[],
|
||||
userId?: string,
|
||||
) {
|
||||
if (disabled) return
|
||||
const access = resolveIntakeNodePermissionAccess(overrides, activeTarget.key, [subjectKey])
|
||||
const access = resolveIntakeNodePermissionAccess(
|
||||
overrides,
|
||||
activeTarget.key,
|
||||
subjectKeys,
|
||||
userId,
|
||||
)
|
||||
const getKey = (targetAction: IntakeNodePermissionAction) =>
|
||||
buildIntakeNodePermissionOverrideKey(activeTarget.key, subjectKey, targetAction)
|
||||
buildIntakeNodePermissionOverrideKey(activeTarget.key, principalKey, targetAction)
|
||||
const nextValue = !access[action]
|
||||
const next = { ...overrides, [getKey(action)]: nextValue }
|
||||
|
||||
@ -181,72 +187,105 @@ export function ProductDevelopmentNodePermissionWorkspace() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-full min-h-0 grid-cols-[minmax(0,1fr)_208px] overflow-hidden bg-[var(--obs-shell-content-bg)]">
|
||||
<section className="flex min-h-0 min-w-0 flex-col">
|
||||
<div className="flex h-11 shrink-0 items-center justify-between gap-4 border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-header-bg)] px-4">
|
||||
<div>
|
||||
<p className="text-[12px] font-black text-[var(--obs-shell-title)]">产品开发节点开放权限</p>
|
||||
<p className="text-[10px] text-[var(--obs-shell-muted)]">按业务对象配置可见、下载和操作范围</p>
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-[var(--obs-shell-muted)]">{status}</span>
|
||||
<div className={`grid h-full min-h-0 overflow-hidden bg-[var(--obs-shell-content-bg)] ${
|
||||
navigationLevel ? 'grid-cols-[76px_190px_minmax(0,1fr)]' : 'grid-cols-[76px_minmax(0,1fr)]'
|
||||
}`}>
|
||||
<aside className="flex min-h-0 flex-col items-center gap-1 overflow-y-auto border-r border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] px-2 py-3">
|
||||
<div className="mb-1 flex h-8 w-8 items-center justify-center rounded-[8px] bg-[var(--obs-shell-title)] text-white">
|
||||
<ShieldCheck className="h-4 w-4" strokeWidth={1.8} />
|
||||
</div>
|
||||
{TOP_ENTRY_PERMISSION_TARGETS.map((target) => {
|
||||
const Icon = TOP_ENTRY_ICONS[target.key] ?? Home
|
||||
const isProductMaterials = target.key === 'formalProducts'
|
||||
const active = target.key === activeTarget.key ||
|
||||
(isProductMaterials && isProductMaterialBranch(activeTarget.key))
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--obs-shell-border)] bg-white px-3 py-2">
|
||||
{TOP_ENTRY_PERMISSION_TARGETS.map((target) => (
|
||||
<TargetTab
|
||||
key={target.key}
|
||||
active={target.key === activeTarget.key}
|
||||
icon={TOP_ENTRY_ICONS[target.key] ?? Home}
|
||||
label={target.label}
|
||||
onClick={() => setActiveTargetKey(target.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--obs-shell-border)] bg-white px-3 py-2">
|
||||
{FORMAL_WORKSPACE_PERMISSION_TARGETS.map((target) => (
|
||||
<TargetTab
|
||||
key={target.key}
|
||||
active={target.key === activeTarget.key}
|
||||
icon={FORMAL_WORKSPACE_ICONS[target.key] ?? ClipboardList}
|
||||
label={target.label}
|
||||
onClick={() => setActiveTargetKey(target.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ProductDevelopmentPermissionTable
|
||||
target={activeTarget}
|
||||
overrides={overrides}
|
||||
disabled={disabled}
|
||||
onToggle={toggleOverride}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<aside className="flex min-h-0 flex-col border-l border-[var(--obs-shell-border)] bg-white">
|
||||
<div className="border-b border-[var(--obs-shell-border)] px-3 py-3">
|
||||
<p className="text-[12px] font-black text-[var(--obs-shell-title)]">产品开发明细节点</p>
|
||||
<p className="mt-0.5 text-[10px] text-[var(--obs-shell-muted)]">选择需要配置的流程节点</p>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
|
||||
{PRODUCT_DEVELOPMENT_PERMISSION_TARGETS.map((target) => {
|
||||
const active = target.key === activeTarget.key
|
||||
return (
|
||||
return (
|
||||
<div key={target.key} className="w-full">
|
||||
<button
|
||||
key={target.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTargetKey(target.key)}
|
||||
className={`relative min-h-9 rounded-[5px] px-3 text-left text-[12px] font-bold transition ${
|
||||
onClick={() => selectTopTarget(target.key)}
|
||||
className={`flex w-full flex-col items-center gap-1 rounded-[8px] px-1 py-2 text-center transition ${
|
||||
active
|
||||
? 'bg-[#2d3335] text-[#ffffff]'
|
||||
? 'bg-[var(--obs-shell-muted-bg)] text-[var(--obs-shell-title)]'
|
||||
: 'text-[var(--obs-shell-muted)] hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]'
|
||||
}`}
|
||||
aria-label={target.label}
|
||||
>
|
||||
{target.label}
|
||||
<Icon className="h-[18px] w-[18px]" strokeWidth={1.8} />
|
||||
<span className="max-w-[62px] text-[9px] font-bold leading-3">{target.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</aside>
|
||||
|
||||
{navigationLevel === 'secondary' ? (
|
||||
<PermissionLevelNavigation
|
||||
title="产品资料"
|
||||
items={FORMAL_WORKSPACE_PERMISSION_TARGETS.map((target) => ({
|
||||
key: target.key,
|
||||
label: target.label,
|
||||
icon: FORMAL_WORKSPACE_ICONS[target.key] ?? ClipboardList,
|
||||
active: target.key === activeTarget.key,
|
||||
hasChildren: target.key === 'productDevelopmentList',
|
||||
}))}
|
||||
onSelect={(key) => selectFormalTarget(key as IntakeNodePermissionTargetKey)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{navigationLevel === 'tertiary' ? (
|
||||
<PermissionLevelNavigation
|
||||
title="产品开发明细节点"
|
||||
backLabel="返回产品资料"
|
||||
onBack={() => {
|
||||
setActiveTargetKey('productDevelopmentList')
|
||||
setNavigationLevel('secondary')
|
||||
}}
|
||||
items={PRODUCT_DEVELOPMENT_PERMISSION_TARGETS.map((target) => ({
|
||||
key: target.key,
|
||||
label: target.label,
|
||||
active: target.key === activeTarget.key,
|
||||
}))}
|
||||
onSelect={(key) => selectDevelopmentNode(key as IntakeNodePermissionTargetKey)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<section className="flex min-h-0 min-w-0 flex-col">
|
||||
<header className="flex min-h-14 shrink-0 items-center justify-between gap-4 border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-header-bg)] px-5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-black text-[var(--obs-shell-title)]">产品开发节点开放权限</p>
|
||||
<p className="mt-0.5 truncate text-[10px] text-[var(--obs-shell-muted)]">
|
||||
左侧一级入口保持显示;当前二级或三级菜单固定在旁边,便于连续配置
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-[11px] font-medium text-[var(--obs-shell-muted)]">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[#7a9d91]" />
|
||||
<span>{status}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col p-3">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between rounded-[8px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[12px] font-black text-[var(--obs-shell-title)]">{activeTarget.label}</p>
|
||||
<p className="mt-0.5 text-[10px] text-[var(--obs-shell-muted)]">当前配置对象 · 业务岗位可单独设置例外权限</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-[5px] bg-[var(--obs-shell-muted-bg)] px-2 py-1 text-[10px] font-bold text-[var(--obs-shell-muted)]">
|
||||
可见 / 下载 / 操作
|
||||
</span>
|
||||
</div>
|
||||
<ProductDevelopmentPermissionTable
|
||||
target={activeTarget}
|
||||
overrides={overrides}
|
||||
disabled={disabled}
|
||||
users={managedUsers.users}
|
||||
usersLoading={managedUsers.loading}
|
||||
usersError={managedUsers.error}
|
||||
onToggle={toggleOverride}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,18 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Check, X } from 'lucide-react'
|
||||
import { Check, ChevronDown, Users, X } from 'lucide-react'
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import {
|
||||
CORE_NODE_PERMISSION_SUBJECTS,
|
||||
NODE_PERMISSION_STATE_LABEL,
|
||||
ORDINARY_NODE_PERMISSION_SUBJECTS,
|
||||
resolveIntakeNodePermissionAccess,
|
||||
resolveIntakeNodePermissionSubjectKeys,
|
||||
type IntakeNodePermissionAction,
|
||||
type IntakeNodePermissionOverrideMap,
|
||||
type IntakeNodePermissionPrincipalKey,
|
||||
type IntakeNodePermissionState,
|
||||
type IntakeNodePermissionSubject,
|
||||
type IntakeNodePermissionSubjectKey,
|
||||
type IntakeNodePermissionTarget,
|
||||
} from '@/lib/intake-node-permission-settings'
|
||||
import type { PermissionManagedUser } from './usePermissionManagedUsers'
|
||||
|
||||
type PermissionSubjectDisplay = {
|
||||
subject: IntakeNodePermissionSubject
|
||||
label: string
|
||||
nested?: boolean
|
||||
}
|
||||
|
||||
const ALL_PERMISSION_SUBJECTS = [
|
||||
...CORE_NODE_PERMISSION_SUBJECTS,
|
||||
...ORDINARY_NODE_PERMISSION_SUBJECTS,
|
||||
]
|
||||
|
||||
const SUBJECT_LABELS: Partial<Record<IntakeNodePermissionSubjectKey, string>> = {
|
||||
design: '设计',
|
||||
planning: '企划',
|
||||
brandCenter: '策划',
|
||||
purchase: '采购',
|
||||
merchandiser: '采购-跟单',
|
||||
quality: '采购-质检',
|
||||
operation: '运营',
|
||||
warehouse: '仓库',
|
||||
customerService: '客服',
|
||||
management: '经管',
|
||||
viewer: '普通用户',
|
||||
supplier: '供应商',
|
||||
externalIntake: '外部人员',
|
||||
}
|
||||
|
||||
function createSubjectDisplay(
|
||||
key: IntakeNodePermissionSubjectKey,
|
||||
nested = false,
|
||||
): PermissionSubjectDisplay {
|
||||
const subject = ALL_PERMISSION_SUBJECTS.find((item) => item.key === key)
|
||||
if (!subject) throw new Error(`未找到权限业务类型:${key}`)
|
||||
return { subject, label: SUBJECT_LABELS[key] ?? subject.name, nested }
|
||||
}
|
||||
|
||||
const PRIMARY_PERMISSION_SUBJECTS = [
|
||||
createSubjectDisplay('design'),
|
||||
createSubjectDisplay('planning'),
|
||||
createSubjectDisplay('brandCenter'),
|
||||
]
|
||||
|
||||
const PURCHASE_PERMISSION_SUBJECTS = [
|
||||
createSubjectDisplay('purchase'),
|
||||
createSubjectDisplay('merchandiser'),
|
||||
createSubjectDisplay('quality'),
|
||||
]
|
||||
|
||||
const OTHER_PERMISSION_SUBJECTS = [
|
||||
createSubjectDisplay('operation'),
|
||||
createSubjectDisplay('warehouse'),
|
||||
createSubjectDisplay('customerService'),
|
||||
createSubjectDisplay('management'),
|
||||
createSubjectDisplay('viewer'),
|
||||
createSubjectDisplay('supplier'),
|
||||
createSubjectDisplay('externalIntake'),
|
||||
]
|
||||
|
||||
const VISIBLE_PERMISSION_SUBJECTS = [
|
||||
...PRIMARY_PERMISSION_SUBJECTS,
|
||||
...PURCHASE_PERMISSION_SUBJECTS,
|
||||
...OTHER_PERMISSION_SUBJECTS,
|
||||
]
|
||||
|
||||
function getStateClassName(state: IntakeNodePermissionState) {
|
||||
if (state === 'edit') return 'bg-[#e8f0ee] text-[#334548]'
|
||||
@ -55,19 +123,86 @@ export function ProductDevelopmentPermissionTable({
|
||||
target,
|
||||
overrides,
|
||||
disabled,
|
||||
users,
|
||||
usersLoading,
|
||||
usersError,
|
||||
onToggle,
|
||||
}: {
|
||||
target: IntakeNodePermissionTarget
|
||||
overrides: IntakeNodePermissionOverrideMap
|
||||
disabled: boolean
|
||||
users: PermissionManagedUser[]
|
||||
usersLoading: boolean
|
||||
usersError: string | null
|
||||
onToggle: (
|
||||
subjectKey: IntakeNodePermissionSubject['key'],
|
||||
principalKey: IntakeNodePermissionPrincipalKey,
|
||||
action: IntakeNodePermissionAction,
|
||||
subjectKeys: readonly IntakeNodePermissionSubjectKey[],
|
||||
userId?: string,
|
||||
) => void
|
||||
}) {
|
||||
const [ordinaryExpanded, setOrdinaryExpanded] = useState(false)
|
||||
const [expandedSubjects, setExpandedSubjects] = useState<Set<string>>(new Set())
|
||||
const usersBySubject = useMemo(() => {
|
||||
const grouped = new Map<string, PermissionManagedUser[]>()
|
||||
VISIBLE_PERMISSION_SUBJECTS.forEach(({ subject }) => {
|
||||
grouped.set(
|
||||
subject.key,
|
||||
users.filter((user) =>
|
||||
resolveIntakeNodePermissionSubjectKeys(user).includes(subject.key),
|
||||
),
|
||||
)
|
||||
})
|
||||
return grouped
|
||||
}, [users])
|
||||
|
||||
function renderSubjectRow(subject: IntakeNodePermissionSubject) {
|
||||
function toggleExpanded(subjectKey: string) {
|
||||
setExpandedSubjects((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(subjectKey)) next.delete(subjectKey)
|
||||
else next.add(subjectKey)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function renderUserRow(subject: IntakeNodePermissionSubject, user: PermissionManagedUser) {
|
||||
const userSubjectKeys = resolveIntakeNodePermissionSubjectKeys(user)
|
||||
const access = resolveIntakeNodePermissionAccess(
|
||||
overrides,
|
||||
target.key,
|
||||
userSubjectKeys,
|
||||
user.id,
|
||||
)
|
||||
return (
|
||||
<tr key={`${subject.key}-${user.id}`} className="border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-surface-bg)] last:border-b-0">
|
||||
<td className="py-2 pl-10 pr-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--obs-shell-muted-bg)] text-[10px] font-black text-[var(--obs-shell-muted)]">
|
||||
{user.name.slice(0, 1)}
|
||||
</span>
|
||||
<div className="min-w-0 truncate text-[11px] font-bold text-[var(--obs-shell-title)]">{user.name}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[10px] text-[var(--obs-shell-muted)]">个人例外优先于业务类型</td>
|
||||
{(['view', 'download', 'edit'] as IntakeNodePermissionAction[]).map((action) => (
|
||||
<td key={action} className="px-3 py-2 text-center">
|
||||
<PermissionToggle
|
||||
checked={access[action]}
|
||||
disabled={disabled}
|
||||
label={`${user.name}${action === 'view' ? '可见' : action === 'download' ? '下载' : '操作'}`}
|
||||
onChange={() => onToggle(`user@${user.id}`, action, userSubjectKeys, user.id)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="px-3 py-2 text-[10px] text-[var(--obs-shell-muted)]">
|
||||
{access.edit ? '可操作' : access.download ? '可查看、下载' : access.view ? '仅查看' : '未开放'}
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function renderSubjectRow(display: PermissionSubjectDisplay) {
|
||||
const { subject, label, nested } = display
|
||||
const defaultState = target.defaults[subject.key] ?? 'hidden'
|
||||
const access = resolveIntakeNodePermissionAccess(overrides, target.key, [subject.key])
|
||||
const effectiveState: IntakeNodePermissionState = access.edit
|
||||
@ -76,11 +211,16 @@ export function ProductDevelopmentPermissionTable({
|
||||
? 'readonly'
|
||||
: 'hidden'
|
||||
|
||||
const subjectUsers = usersBySubject.get(subject.key) ?? []
|
||||
const expanded = expandedSubjects.has(subject.key)
|
||||
|
||||
return (
|
||||
<tr key={subject.key} className="border-b border-[var(--obs-shell-border)] last:border-b-0">
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="text-[12px] font-bold text-[var(--obs-shell-title)]">{subject.name}</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--obs-shell-muted)]">{subject.department}</div>
|
||||
<Fragment key={subject.key}>
|
||||
<tr className={`border-b border-[var(--obs-shell-border)] last:border-b-0 ${
|
||||
expanded ? 'sticky top-[33px] z-[6] bg-[var(--obs-shell-panel-bg)]' : ''
|
||||
}`}>
|
||||
<td className={`py-2.5 pr-3 ${nested ? 'pl-8' : 'pl-3'}`}>
|
||||
<div className="text-[12px] font-bold text-[var(--obs-shell-title)]">{label}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
@ -96,24 +236,24 @@ export function ProductDevelopmentPermissionTable({
|
||||
<PermissionToggle
|
||||
checked={access.view}
|
||||
disabled={disabled}
|
||||
label={access.view ? `关闭${subject.name}可见` : `开通${subject.name}可见`}
|
||||
onChange={() => onToggle(subject.key, 'view')}
|
||||
label={access.view ? `关闭${label}可见` : `开通${label}可见`}
|
||||
onChange={() => onToggle(subject.key, 'view', [subject.key])}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<PermissionToggle
|
||||
checked={access.download}
|
||||
disabled={disabled}
|
||||
label={access.download ? `关闭${subject.name}下载` : `开通${subject.name}下载`}
|
||||
onChange={() => onToggle(subject.key, 'download')}
|
||||
label={access.download ? `关闭${label}下载` : `开通${label}下载`}
|
||||
onChange={() => onToggle(subject.key, 'download', [subject.key])}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<PermissionToggle
|
||||
checked={access.edit}
|
||||
disabled={disabled}
|
||||
label={access.edit ? `关闭${subject.name}操作` : `开通${subject.name}操作`}
|
||||
onChange={() => onToggle(subject.key, 'edit')}
|
||||
label={access.edit ? `关闭${label}操作` : `开通${label}操作`}
|
||||
onChange={() => onToggle(subject.key, 'edit', [subject.key])}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[11px] text-[var(--obs-shell-muted)]">
|
||||
@ -125,41 +265,50 @@ export function ProductDevelopmentPermissionTable({
|
||||
: '当前仅可查看。'
|
||||
: '当前已关闭。'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(subject.key)}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-[5px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] px-2 text-[10px] font-bold text-[var(--obs-shell-muted)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<Users className="h-3.5 w-3.5" strokeWidth={1.8} />
|
||||
<span>{usersLoading ? '读取中' : `${subjectUsers.length} 人`}</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition ${expanded ? 'rotate-180' : ''}`} strokeWidth={1.8} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded ? (
|
||||
usersError ? (
|
||||
<tr><td colSpan={7} className="bg-[var(--obs-shell-surface-bg)] px-10 py-3 text-[11px] text-red-600">{usersError}</td></tr>
|
||||
) : subjectUsers.length > 0 ? (
|
||||
subjectUsers.map((user) => renderUserRow(subject, user))
|
||||
) : (
|
||||
<tr><td colSpan={7} className="bg-[var(--obs-shell-surface-bg)] px-10 py-3 text-[11px] text-[var(--obs-shell-muted)]">该业务类型暂无运行中账号</td></tr>
|
||||
)
|
||||
) : null}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto border-y border-[var(--obs-shell-border)] bg-white">
|
||||
<table className="w-full min-w-[920px] border-collapse text-xs">
|
||||
<table className="w-full min-w-[1040px] border-collapse text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-[#f7f8f8] text-[var(--obs-shell-muted)]">
|
||||
<tr>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">对象</th>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">业务类型</th>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">权限状态</th>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-center font-bold">可见</th>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-center font-bold">下载</th>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-center font-bold">操作</th>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">说明</th>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-right font-bold">人员</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-[var(--obs-shell-border)] bg-[#fafbfb]">
|
||||
<td colSpan={6} className="px-3 py-2 text-[11px] font-bold text-[var(--obs-shell-muted)]">核心对象</td>
|
||||
</tr>
|
||||
{CORE_NODE_PERMISSION_SUBJECTS.map(renderSubjectRow)}
|
||||
<tr className="border-b border-[var(--obs-shell-border)] bg-[#fafbfb]">
|
||||
<td colSpan={6} className="px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOrdinaryExpanded((current) => !current)}
|
||||
className="flex w-full items-center justify-between text-left text-[11px] font-bold text-[var(--obs-shell-muted)]"
|
||||
>
|
||||
<span>普通业务</span>
|
||||
<span>{ordinaryExpanded ? '收起' : '展开'}</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{ordinaryExpanded ? ORDINARY_NODE_PERMISSION_SUBJECTS.map(renderSubjectRow) : null}
|
||||
{PRIMARY_PERMISSION_SUBJECTS.map(renderSubjectRow)}
|
||||
{PURCHASE_PERMISSION_SUBJECTS.map(renderSubjectRow)}
|
||||
{OTHER_PERMISSION_SUBJECTS.map(renderSubjectRow)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { SupplyChainPermissionWorkspace } from './SupplyChainPermissionWorkspace'
|
||||
|
||||
@ -27,13 +27,36 @@ describe('SupplyChainPermissionWorkspace', () => {
|
||||
|
||||
render(<SupplyChainPermissionWorkspace />)
|
||||
expect(await screen.findByText('已读取当前权限配置')).toBeInTheDocument()
|
||||
expect(screen.queryByText('供应商')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('外部录入员')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: '供应链入口' })).toBeInTheDocument()
|
||||
expect(screen.getByText('供应商')).toBeInTheDocument()
|
||||
expect(screen.getByText('外部人员')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('固定排除')).toHaveLength(2)
|
||||
expect(screen.queryByText('供应链中心入口与工作台总览')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByText('采购')).toHaveLength(1)
|
||||
expect(screen.getByText('采购-跟单')).toBeInTheDocument()
|
||||
expect(screen.getByText('采购-质检')).toBeInTheDocument()
|
||||
expect(screen.getByText('仓库')).toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '开通设计师可见' }))
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
|
||||
expect(JSON.parse(String((fetchMock.mock.calls[1] as [string, RequestInit])[1].body))).toEqual({
|
||||
overrides: { 'workspace:design:view': true },
|
||||
const table = screen.getByRole('table')
|
||||
expect(table.parentElement).toHaveClass('min-h-0', 'flex-1', 'overflow-auto')
|
||||
expect(table.parentElement?.parentElement).toHaveClass('overflow-hidden')
|
||||
|
||||
const designRow = screen.getByText('设计').closest('tr')
|
||||
expect(designRow).not.toBeNull()
|
||||
await waitFor(() => expect(within(designRow!).getByRole('button', { name: /人/ })).toBeEnabled())
|
||||
await actor.click(within(designRow!).getByRole('button', { name: /人/ }))
|
||||
expect(designRow).toHaveClass('sticky', 'top-[33px]')
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '数据库' }))
|
||||
expect(screen.getByRole('navigation', { name: '数据库' })).toBeInTheDocument()
|
||||
await actor.click(screen.getByRole('button', { name: '产品全量档案' }))
|
||||
expect(screen.getByRole('navigation', { name: '数据库' })).toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '开通设计可见' }))
|
||||
await waitFor(() => expect(fetchMock.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(true))
|
||||
const [, saveOptions] = fetchMock.mock.calls.find(([, options]) => options?.method === 'PUT') as [string, RequestInit]
|
||||
expect(JSON.parse(String(saveOptions.body))).toEqual({
|
||||
overrides: { 'productArchive:design:view': true },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, ClipboardList, Database, LayoutDashboard, PackagePlus, ShoppingCart, Warehouse, X } from 'lucide-react'
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
import { Check, ChevronDown, ClipboardList, Database, LayoutDashboard, PackagePlus, ShoppingCart, Users, Warehouse, X } from 'lucide-react'
|
||||
import {
|
||||
SUPPLY_CHAIN_PERMISSION_ACTION_LABELS,
|
||||
SUPPLY_CHAIN_PERMISSION_CORE_SUBJECTS,
|
||||
@ -9,12 +9,16 @@ import {
|
||||
SUPPLY_CHAIN_PERMISSION_TARGETS,
|
||||
buildSupplyChainPermissionOverrideKey,
|
||||
resolveSupplyChainPermissionAccess,
|
||||
resolveSupplyChainPermissionSubjectKeys,
|
||||
sanitizeSupplyChainPermissionOverrides,
|
||||
type SupplyChainPermissionAction,
|
||||
type SupplyChainPermissionOverrideMap,
|
||||
type SupplyChainPermissionPrincipalKey,
|
||||
type SupplyChainPermissionSubject,
|
||||
type SupplyChainPermissionTargetKey,
|
||||
} from '@/lib/supply-chain-permission-settings'
|
||||
import { usePermissionManagedUsers, type PermissionManagedUser } from './usePermissionManagedUsers'
|
||||
import { PermissionLevelNavigation } from './PermissionLevelNavigation'
|
||||
|
||||
const TARGET_ICONS: Record<SupplyChainPermissionTargetKey, typeof LayoutDashboard> = {
|
||||
workspace: LayoutDashboard,
|
||||
@ -30,6 +34,104 @@ const TARGET_ICONS: Record<SupplyChainPermissionTargetKey, typeof LayoutDashboar
|
||||
operationLogs: ClipboardList,
|
||||
}
|
||||
|
||||
const SUPPLY_PERMISSION_GROUPS = [
|
||||
{
|
||||
key: 'entry',
|
||||
label: '供应链入口',
|
||||
description: '供应链中心入口与工作台总览',
|
||||
icon: LayoutDashboard,
|
||||
targets: SUPPLY_CHAIN_PERMISSION_TARGETS.filter((target) => ['workspace', 'dashboard'].includes(target.key)),
|
||||
},
|
||||
{
|
||||
key: 'database',
|
||||
label: '数据库',
|
||||
description: '产品档案、类目和仓库配置',
|
||||
icon: Database,
|
||||
targets: SUPPLY_CHAIN_PERMISSION_TARGETS.filter((target) => ['productArchive', 'categoryConfig', 'warehouseConfig'].includes(target.key)),
|
||||
},
|
||||
{
|
||||
key: 'new-products',
|
||||
label: '新品开发',
|
||||
description: '新品开发总表与打样进度明细',
|
||||
icon: PackagePlus,
|
||||
targets: SUPPLY_CHAIN_PERMISSION_TARGETS.filter((target) => ['newProductOverview', 'newProductUnifiedTable', 'newProductSampleProgress'].includes(target.key)),
|
||||
},
|
||||
{
|
||||
key: 'purchase',
|
||||
label: '采购协作',
|
||||
description: '日常事项与产品变价',
|
||||
icon: ShoppingCart,
|
||||
targets: SUPPLY_CHAIN_PERMISSION_TARGETS.filter((target) => ['purchaseDailyAffairs', 'purchasePriceChange'].includes(target.key)),
|
||||
},
|
||||
{
|
||||
key: 'audit',
|
||||
label: '审计记录',
|
||||
description: '供应链操作日志',
|
||||
icon: ClipboardList,
|
||||
targets: SUPPLY_CHAIN_PERMISSION_TARGETS.filter((target) => target.key === 'operationLogs'),
|
||||
},
|
||||
] as const
|
||||
|
||||
type SupplyPermissionSubjectDisplay = {
|
||||
subject: SupplyChainPermissionSubject
|
||||
label: string
|
||||
nested?: boolean
|
||||
}
|
||||
|
||||
const ALL_SUPPLY_PERMISSION_SUBJECTS = [
|
||||
...SUPPLY_CHAIN_PERMISSION_CORE_SUBJECTS,
|
||||
...SUPPLY_CHAIN_PERMISSION_ORDINARY_SUBJECTS,
|
||||
]
|
||||
|
||||
const SUPPLY_SUBJECT_LABELS: Partial<Record<SupplyChainPermissionSubject['key'], string>> = {
|
||||
design: '设计',
|
||||
planning: '企划',
|
||||
brandCenter: '策划',
|
||||
purchase: '采购',
|
||||
merchandiser: '采购-跟单',
|
||||
quality: '采购-质检',
|
||||
operation: '运营',
|
||||
warehouse: '仓库',
|
||||
customerService: '客服',
|
||||
management: '经管',
|
||||
viewer: '普通用户',
|
||||
}
|
||||
|
||||
function createSupplySubjectDisplay(
|
||||
key: SupplyChainPermissionSubject['key'],
|
||||
nested = false,
|
||||
): SupplyPermissionSubjectDisplay {
|
||||
const subject = ALL_SUPPLY_PERMISSION_SUBJECTS.find((item) => item.key === key)
|
||||
if (!subject) throw new Error(`未找到供应链权限业务类型:${key}`)
|
||||
return { subject, label: SUPPLY_SUBJECT_LABELS[key] ?? subject.name, nested }
|
||||
}
|
||||
|
||||
const PRIMARY_SUPPLY_PERMISSION_SUBJECTS = [
|
||||
createSupplySubjectDisplay('design'),
|
||||
createSupplySubjectDisplay('planning'),
|
||||
createSupplySubjectDisplay('brandCenter'),
|
||||
]
|
||||
|
||||
const PURCHASE_SUPPLY_PERMISSION_SUBJECTS = [
|
||||
createSupplySubjectDisplay('purchase'),
|
||||
createSupplySubjectDisplay('merchandiser'),
|
||||
createSupplySubjectDisplay('quality'),
|
||||
]
|
||||
|
||||
const OTHER_SUPPLY_PERMISSION_SUBJECTS = [
|
||||
createSupplySubjectDisplay('operation'),
|
||||
createSupplySubjectDisplay('warehouse'),
|
||||
createSupplySubjectDisplay('customerService'),
|
||||
createSupplySubjectDisplay('management'),
|
||||
createSupplySubjectDisplay('viewer'),
|
||||
]
|
||||
|
||||
const VISIBLE_SUPPLY_PERMISSION_SUBJECTS = [
|
||||
...PRIMARY_SUPPLY_PERMISSION_SUBJECTS,
|
||||
...PURCHASE_SUPPLY_PERMISSION_SUBJECTS,
|
||||
...OTHER_SUPPLY_PERMISSION_SUBJECTS,
|
||||
]
|
||||
|
||||
function Toggle({
|
||||
checked,
|
||||
disabled,
|
||||
@ -64,11 +166,28 @@ function Toggle({
|
||||
export function SupplyChainPermissionWorkspace() {
|
||||
const [activeTargetKey, setActiveTargetKey] = useState<SupplyChainPermissionTargetKey>('workspace')
|
||||
const [overrides, setOverrides] = useState<SupplyChainPermissionOverrideMap>({})
|
||||
const [expandedSubjects, setExpandedSubjects] = useState<Set<string>>(new Set())
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [status, setStatus] = useState('正在读取权限配置...')
|
||||
const activeTarget = SUPPLY_CHAIN_PERMISSION_TARGETS.find((target) => target.key === activeTargetKey) ?? SUPPLY_CHAIN_PERMISSION_TARGETS[0]!
|
||||
const activeGroup = SUPPLY_PERMISSION_GROUPS.find((group) =>
|
||||
group.targets.some((target) => target.key === activeTarget.key),
|
||||
) ?? SUPPLY_PERMISSION_GROUPS[0]
|
||||
const disabled = !loaded || saving
|
||||
const managedUsers = usePermissionManagedUsers()
|
||||
const usersBySubject = useMemo(() => {
|
||||
const grouped = new Map<string, PermissionManagedUser[]>()
|
||||
VISIBLE_SUPPLY_PERMISSION_SUBJECTS.forEach(({ subject }) => {
|
||||
grouped.set(
|
||||
subject.key,
|
||||
managedUsers.users.filter((user) =>
|
||||
resolveSupplyChainPermissionSubjectKeys(user).includes(subject.key),
|
||||
),
|
||||
)
|
||||
})
|
||||
return grouped
|
||||
}, [managedUsers.users])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@ -128,11 +247,21 @@ export function SupplyChainPermissionWorkspace() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(subject: SupplyChainPermissionSubject, action: SupplyChainPermissionAction) {
|
||||
function toggle(
|
||||
principalKey: SupplyChainPermissionPrincipalKey,
|
||||
subjectKeys: readonly SupplyChainPermissionSubject['key'][],
|
||||
action: SupplyChainPermissionAction,
|
||||
userId?: string,
|
||||
) {
|
||||
if (disabled) return
|
||||
const access = resolveSupplyChainPermissionAccess(overrides, activeTarget.key, [subject.key])
|
||||
const access = resolveSupplyChainPermissionAccess(
|
||||
overrides,
|
||||
activeTarget.key,
|
||||
subjectKeys,
|
||||
userId,
|
||||
)
|
||||
const key = (targetAction: SupplyChainPermissionAction) =>
|
||||
buildSupplyChainPermissionOverrideKey(activeTarget.key, subject.key, targetAction)
|
||||
buildSupplyChainPermissionOverrideKey(activeTarget.key, principalKey, targetAction)
|
||||
const nextValue = !access[action]
|
||||
const next = { ...overrides, [key(action)]: nextValue }
|
||||
if (action === 'view' && !nextValue) {
|
||||
@ -145,14 +274,63 @@ export function SupplyChainPermissionWorkspace() {
|
||||
void persist(next)
|
||||
}
|
||||
|
||||
function renderSubject(subject: SupplyChainPermissionSubject) {
|
||||
function toggleExpanded(subjectKey: string) {
|
||||
setExpandedSubjects((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(subjectKey)) next.delete(subjectKey)
|
||||
else next.add(subjectKey)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function renderUser(subject: SupplyChainPermissionSubject, user: PermissionManagedUser) {
|
||||
const userSubjectKeys = resolveSupplyChainPermissionSubjectKeys(user)
|
||||
const access = resolveSupplyChainPermissionAccess(
|
||||
overrides,
|
||||
activeTarget.key,
|
||||
userSubjectKeys,
|
||||
user.id,
|
||||
)
|
||||
return (
|
||||
<tr key={`${subject.key}-${user.id}`} className="border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-surface-bg)] last:border-b-0">
|
||||
<td className="py-2 pl-10 pr-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--obs-shell-muted-bg)] text-[10px] font-black text-[var(--obs-shell-muted)]">{user.name.slice(0, 1)}</span>
|
||||
<p className="min-w-0 truncate text-[11px] font-bold text-[var(--obs-shell-title)]">{user.name}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[10px] text-[var(--obs-shell-muted)]">个人例外优先于业务类型</td>
|
||||
{(['view', 'download', 'edit'] as SupplyChainPermissionAction[]).map((action) => (
|
||||
<td key={action} className="px-3 py-2 text-center">
|
||||
<Toggle
|
||||
checked={access[action]}
|
||||
disabled={disabled}
|
||||
label={`${user.name}${SUPPLY_CHAIN_PERMISSION_ACTION_LABELS[action]}`}
|
||||
onClick={() => toggle(`user@${user.id}`, userSubjectKeys, action, user.id)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="px-3 py-2 text-[10px] text-[var(--obs-shell-muted)]">
|
||||
{access.edit ? '可操作' : access.download ? '可查看、下载' : access.view ? '仅查看' : '未开放'}
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function renderSubject(display: SupplyPermissionSubjectDisplay) {
|
||||
const { subject, label, nested } = display
|
||||
const access = resolveSupplyChainPermissionAccess(overrides, activeTarget.key, [subject.key])
|
||||
const state = access.edit ? '可操作' : access.view ? '仅查看' : '未开放'
|
||||
const subjectUsers = usersBySubject.get(subject.key) ?? []
|
||||
const expanded = expandedSubjects.has(subject.key)
|
||||
return (
|
||||
<tr key={subject.key} className="border-b border-[var(--obs-shell-border)] last:border-b-0">
|
||||
<td className="px-3 py-2.5">
|
||||
<p className="text-[12px] font-bold text-[var(--obs-shell-title)]">{subject.name}</p>
|
||||
<p className="mt-0.5 text-[11px] text-[var(--obs-shell-muted)]">{subject.department}</p>
|
||||
<Fragment key={subject.key}>
|
||||
<tr className={`border-b border-[var(--obs-shell-border)] last:border-b-0 ${
|
||||
expanded ? 'sticky top-[33px] z-[6] bg-[var(--obs-shell-panel-bg)]' : ''
|
||||
}`}>
|
||||
<td className={`py-2.5 pr-3 ${nested ? 'pl-8' : 'pl-3'}`}>
|
||||
<p className="text-[12px] font-bold text-[var(--obs-shell-title)]">{label}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className={`inline-flex h-6 items-center rounded-[4px] px-2 text-[11px] font-bold ${
|
||||
@ -164,73 +342,138 @@ export function SupplyChainPermissionWorkspace() {
|
||||
<Toggle
|
||||
checked={access[action]}
|
||||
disabled={disabled}
|
||||
label={access[action] ? `关闭${subject.name}${SUPPLY_CHAIN_PERMISSION_ACTION_LABELS[action]}` : `开通${subject.name}${SUPPLY_CHAIN_PERMISSION_ACTION_LABELS[action]}`}
|
||||
onClick={() => toggle(subject, action)}
|
||||
label={access[action] ? `关闭${label}${SUPPLY_CHAIN_PERMISSION_ACTION_LABELS[action]}` : `开通${label}${SUPPLY_CHAIN_PERMISSION_ACTION_LABELS[action]}`}
|
||||
onClick={() => toggle(subject.key, [subject.key], action)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="px-3 py-2.5 text-[11px] text-[var(--obs-shell-muted)]">
|
||||
{access.edit ? '已开通查看和操作。' : access.download ? '已开通查看和下载。' : access.view ? '当前仅可查看。' : '默认不开放。'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(subject.key)}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-[5px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] px-2 text-[10px] font-bold text-[var(--obs-shell-muted)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<Users className="h-3.5 w-3.5" strokeWidth={1.8} />
|
||||
<span>{managedUsers.loading ? '读取中' : `${subjectUsers.length} 人`}</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition ${expanded ? 'rotate-180' : ''}`} strokeWidth={1.8} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded ? (
|
||||
managedUsers.error ? (
|
||||
<tr><td colSpan={7} className="bg-[var(--obs-shell-surface-bg)] px-10 py-3 text-[11px] text-red-600">{managedUsers.error}</td></tr>
|
||||
) : subjectUsers.length > 0 ? (
|
||||
subjectUsers.map((user) => renderUser(subject, user))
|
||||
) : (
|
||||
<tr><td colSpan={7} className="bg-[var(--obs-shell-surface-bg)] px-10 py-3 text-[11px] text-[var(--obs-shell-muted)]">该业务类型暂无运行中账号</td></tr>
|
||||
)
|
||||
) : null}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
function renderExcludedSubject(label: string) {
|
||||
return (
|
||||
<tr key={label} className="border-b border-[var(--obs-shell-border)] last:border-b-0">
|
||||
<td className="px-3 py-2.5 text-[12px] font-bold text-[var(--obs-shell-title)]">{label}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className="inline-flex h-6 items-center rounded-[4px] bg-[#f4f5f5] px-2 text-[11px] font-bold text-[#9aa2a4]">固定排除</span>
|
||||
</td>
|
||||
{(['view', 'download', 'edit'] as SupplyChainPermissionAction[]).map((action) => (
|
||||
<td key={action} className="px-3 py-2.5 text-center">
|
||||
<Toggle checked={false} disabled label={`${label}固定排除`} onClick={() => undefined} />
|
||||
</td>
|
||||
))}
|
||||
<td className="px-3 py-2.5 text-[11px] text-[var(--obs-shell-muted)]">不进入供应链中心。</td>
|
||||
<td className="px-3 py-2.5 text-right text-[11px] text-[var(--obs-shell-subtle)]">—</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-full min-h-0 grid-cols-[216px_minmax(0,1fr)] overflow-hidden bg-[var(--obs-shell-content-bg)]">
|
||||
<aside className="flex min-h-0 flex-col border-r border-[var(--obs-shell-border)] bg-white">
|
||||
<div className="border-b border-[var(--obs-shell-border)] px-3 py-3">
|
||||
<p className="text-[12px] font-black text-[var(--obs-shell-title)]">供应链开发权限</p>
|
||||
<p className="mt-0.5 text-[10px] text-[var(--obs-shell-muted)]">供应商和外部录入员固定排除</p>
|
||||
<div className="grid h-full min-h-0 grid-cols-[76px_190px_minmax(0,1fr)] overflow-hidden bg-[var(--obs-shell-content-bg)]">
|
||||
<aside className="flex min-h-0 flex-col items-center gap-2 overflow-y-auto border-r border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] px-2 py-3">
|
||||
<div className="mb-1 flex h-8 w-8 items-center justify-center rounded-[8px] bg-[var(--obs-shell-title)] text-white">
|
||||
<PackagePlus className="h-4 w-4" strokeWidth={1.8} />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
|
||||
{SUPPLY_CHAIN_PERMISSION_TARGETS.map((target) => {
|
||||
const Icon = TARGET_ICONS[target.key]
|
||||
const active = target.key === activeTarget.key
|
||||
return (
|
||||
{SUPPLY_PERMISSION_GROUPS.map((group) => {
|
||||
const GroupIcon = group.icon
|
||||
const hasActiveTarget = group.targets.some((target) => target.key === activeTarget.key)
|
||||
return (
|
||||
<div key={group.key} className="w-full">
|
||||
<button
|
||||
key={target.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTargetKey(target.key)}
|
||||
className={`flex min-h-9 w-full items-center gap-2 rounded-[5px] px-3 text-left text-[12px] font-bold transition ${
|
||||
active ? 'bg-[#2d3335] text-white' : 'text-[var(--obs-shell-muted)] hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]'
|
||||
onClick={() => setActiveTargetKey(group.targets[0]!.key)}
|
||||
className={`flex w-full flex-col items-center gap-1 rounded-[8px] px-1 py-2 text-center transition ${
|
||||
hasActiveTarget
|
||||
? 'bg-[var(--obs-shell-muted-bg)] text-[var(--obs-shell-title)]'
|
||||
: 'text-[var(--obs-shell-muted)] hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]'
|
||||
}`}
|
||||
aria-label={group.label}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" strokeWidth={1.8} />
|
||||
<span className="min-w-0 truncate">{target.label}</span>
|
||||
<GroupIcon className="h-5 w-5" strokeWidth={1.8} />
|
||||
<span className="whitespace-nowrap text-[10px] font-bold">{group.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</aside>
|
||||
|
||||
<PermissionLevelNavigation
|
||||
title={activeGroup.label}
|
||||
items={activeGroup.targets.map((target) => ({
|
||||
key: target.key,
|
||||
label: target.label,
|
||||
icon: TARGET_ICONS[target.key],
|
||||
active: target.key === activeTarget.key,
|
||||
}))}
|
||||
onSelect={(key) => setActiveTargetKey(key as SupplyChainPermissionTargetKey)}
|
||||
/>
|
||||
|
||||
<section className="flex min-h-0 min-w-0 flex-col">
|
||||
<header className="flex min-h-12 shrink-0 items-center justify-between gap-4 border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-header-bg)] px-4">
|
||||
<header className="flex min-h-14 shrink-0 items-center justify-between gap-4 border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-header-bg)] px-5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] font-black text-[var(--obs-shell-title)]">{activeTarget.label}</p>
|
||||
<p className="truncate text-[10px] text-[var(--obs-shell-muted)]">{activeTarget.description}</p>
|
||||
<p className="text-[13px] font-black text-[var(--obs-shell-title)]">供应链开发权限</p>
|
||||
<p className="mt-0.5 truncate text-[10px] text-[var(--obs-shell-muted)]">供应商和外部录入员固定排除;当前模块页面固定显示在左侧二级栏</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-[11px] font-medium text-[var(--obs-shell-muted)]">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[#7a9d91]" />
|
||||
<span>{status}</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] font-medium text-[var(--obs-shell-muted)]">{status}</span>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-white">
|
||||
<table className="w-full min-w-[820px] border-collapse text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-[#f7f8f8] text-[var(--obs-shell-muted)]">
|
||||
<tr>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">业务类型</th>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">当前状态</th>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-center font-bold">可见</th>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-center font-bold">下载</th>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-center font-bold">操作</th>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-[var(--obs-shell-border)] bg-[#fafbfb]"><td colSpan={6} className="px-3 py-2 text-[11px] font-bold text-[var(--obs-shell-muted)]">核心业务类型</td></tr>
|
||||
{SUPPLY_CHAIN_PERMISSION_CORE_SUBJECTS.map(renderSubject)}
|
||||
<tr className="border-b border-[var(--obs-shell-border)] bg-[#fafbfb]"><td colSpan={6} className="px-3 py-2 text-[11px] font-bold text-[var(--obs-shell-muted)]">其他业务类型</td></tr>
|
||||
{SUPPLY_CHAIN_PERMISSION_ORDINARY_SUBJECTS.map(renderSubject)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden p-3">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between rounded-[8px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[12px] font-black text-[var(--obs-shell-title)]">{activeTarget.label}</p>
|
||||
<p className="mt-0.5 text-[10px] text-[var(--obs-shell-muted)]">{activeTarget.description} · 业务岗位可单独设置例外权限</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-[5px] bg-[var(--obs-shell-muted-bg)] px-2 py-1 text-[10px] font-bold text-[var(--obs-shell-muted)]">可见 / 下载 / 操作</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-[8px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)]">
|
||||
<table className="w-full min-w-[1040px] border-collapse text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-[var(--obs-shell-surface-bg)] text-[var(--obs-shell-muted)]">
|
||||
<tr>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">业务类型</th>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">当前状态</th>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-center font-bold">可见</th>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-center font-bold">下载</th>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-center font-bold">操作</th>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">说明</th>
|
||||
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-right font-bold">人员</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PRIMARY_SUPPLY_PERMISSION_SUBJECTS.map(renderSubject)}
|
||||
{PURCHASE_SUPPLY_PERMISSION_SUBJECTS.map(renderSubject)}
|
||||
{OTHER_SUPPLY_PERMISSION_SUBJECTS.map(renderSubject)}
|
||||
{['供应商', '外部人员'].map(renderExcludedSubject)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,114 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { getApiErrorMessage } from '@/lib/utils'
|
||||
|
||||
export type PermissionManagedUser = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
status: string
|
||||
businessRoles?: string[]
|
||||
department?: { id?: string; name?: string; fullPath?: string } | null
|
||||
departmentPaths?: string[]
|
||||
}
|
||||
|
||||
type OrganizationDepartment = {
|
||||
id: string
|
||||
name: string
|
||||
fullPath: string
|
||||
users?: PermissionManagedUser[]
|
||||
teams?: Array<{ users?: PermissionManagedUser[] }>
|
||||
children?: OrganizationDepartment[]
|
||||
}
|
||||
|
||||
type OrganizationTreePayload = {
|
||||
departments?: OrganizationDepartment[]
|
||||
unassignedUsers?: PermissionManagedUser[]
|
||||
}
|
||||
|
||||
function attachDepartment(
|
||||
user: PermissionManagedUser,
|
||||
department: OrganizationDepartment,
|
||||
): PermissionManagedUser {
|
||||
return {
|
||||
...user,
|
||||
department: user.department ?? {
|
||||
id: department.id,
|
||||
name: department.name,
|
||||
fullPath: department.fullPath,
|
||||
},
|
||||
departmentPaths: Array.from(new Set([
|
||||
...(user.departmentPaths ?? []),
|
||||
department.fullPath,
|
||||
].filter(Boolean))),
|
||||
}
|
||||
}
|
||||
|
||||
function flattenDepartmentUsers(departments: OrganizationDepartment[]) {
|
||||
const users: PermissionManagedUser[] = []
|
||||
|
||||
function visit(department: OrganizationDepartment) {
|
||||
department.users?.forEach((user) => users.push(attachDepartment(user, department)))
|
||||
department.teams?.forEach((team) => {
|
||||
team.users?.forEach((user) => users.push(attachDepartment(user, department)))
|
||||
})
|
||||
department.children?.forEach(visit)
|
||||
}
|
||||
|
||||
departments.forEach(visit)
|
||||
return users
|
||||
}
|
||||
|
||||
export function usePermissionManagedUsers() {
|
||||
const [users, setUsers] = useState<PermissionManagedUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const response = await fetch('/api/users/organization-tree?status=ACTIVE', {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
})
|
||||
const payload = await response.json().catch(() => null) as {
|
||||
success?: boolean
|
||||
data?: OrganizationTreePayload
|
||||
error?: unknown
|
||||
} | null
|
||||
if (!response.ok || !payload?.data) {
|
||||
throw new Error(getApiErrorMessage(payload?.error, '人员列表读取失败'))
|
||||
}
|
||||
if (!active) return
|
||||
|
||||
const merged = [
|
||||
...flattenDepartmentUsers(payload.data.departments ?? []),
|
||||
...(payload.data.unassignedUsers ?? []),
|
||||
]
|
||||
const uniqueUsers = Array.from(
|
||||
new Map(merged.map((user) => [user.id, user])).values(),
|
||||
).sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
|
||||
setUsers(uniqueUsers)
|
||||
} catch (loadError) {
|
||||
if (active) {
|
||||
setUsers([])
|
||||
setError(loadError instanceof Error ? loadError.message : '人员列表读取失败')
|
||||
}
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return useMemo(() => ({ users, loading, error }), [error, loading, users])
|
||||
}
|
||||
@ -20,6 +20,7 @@ export interface AiRenderWorkbenchAccess {
|
||||
}
|
||||
|
||||
export type AiRenderWorkbenchPermissionUser = ScopedRoleUser & {
|
||||
id?: string | null
|
||||
supplierCode?: string | null
|
||||
}
|
||||
|
||||
@ -52,6 +53,7 @@ export function getAiRenderWorkbenchAccess(
|
||||
overrides,
|
||||
'aiRenderWorkbench',
|
||||
resolveIntakeNodePermissionSubjectKeys(user),
|
||||
user.id,
|
||||
)
|
||||
if (!nodeAccess.view && !nodeAccess.download && !nodeAccess.edit) {
|
||||
return {
|
||||
|
||||
@ -14,6 +14,7 @@ describe('BOM 核算权限', () => {
|
||||
const user = {
|
||||
role: 'USER',
|
||||
businessRoles: [businessRole],
|
||||
workspaceAllowedPathPrefixes: [BOM_COSTING_WORKBENCH_PATH],
|
||||
}
|
||||
expect(canViewBomCostingWorkbench(user)).toBe(true)
|
||||
expect(canAccessWorkspacePath(BOM_COSTING_WORKBENCH_PATH, user)).toBe(true)
|
||||
@ -28,7 +29,6 @@ describe('BOM 核算权限', () => {
|
||||
canAccessWorkspacePath(BOM_COSTING_WORKBENCH_PATH, {
|
||||
role: 'USER',
|
||||
businessRoles: ['STRATEGY'],
|
||||
workspaceAllowedPathPrefixes: [BOM_COSTING_WORKBENCH_PATH],
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
@ -19,6 +19,7 @@ export interface InstallationDemoWorkbenchAccess {
|
||||
}
|
||||
|
||||
export type InstallationDemoWorkbenchPermissionUser = ScopedRoleUser & {
|
||||
id?: string | null
|
||||
supplierCode?: string | null
|
||||
}
|
||||
|
||||
@ -77,6 +78,7 @@ export function getInstallationDemoWorkbenchAccess(
|
||||
overrides,
|
||||
'installationDemoWorkbench',
|
||||
resolveIntakeNodePermissionSubjectKeys(user),
|
||||
user.id,
|
||||
)
|
||||
if (nodeAccess.edit) {
|
||||
return {
|
||||
|
||||
@ -358,4 +358,53 @@ describe('intake node permission settings', () => {
|
||||
edit: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('applies personal overrides after business type permissions', () => {
|
||||
const userId = 'user-123'
|
||||
const subjectKeys = ['purchase'] as const
|
||||
const overrides = sanitizeIntakeNodePermissionOverrides({
|
||||
[buildIntakeNodePermissionOverrideKey('bomCostingWorkbench', 'purchase', 'view')]: true,
|
||||
[buildIntakeNodePermissionOverrideKey('bomCostingWorkbench', `user@${userId}`, 'view')]: false,
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveIntakeNodePermissionAccess(
|
||||
overrides,
|
||||
'bomCostingWorkbench',
|
||||
subjectKeys,
|
||||
userId,
|
||||
),
|
||||
).toMatchObject({ view: false, download: false, edit: false })
|
||||
expect(
|
||||
resolveIntakeNodePermissionAccess(
|
||||
overrides,
|
||||
'bomCostingWorkbench',
|
||||
subjectKeys,
|
||||
'another-user',
|
||||
),
|
||||
).toMatchObject({ view: true })
|
||||
})
|
||||
|
||||
it('lets a personal edit override open a closed business type', () => {
|
||||
const userId = 'user-456'
|
||||
const overrides = sanitizeIntakeNodePermissionOverrides({
|
||||
[buildIntakeNodePermissionOverrideKey('warehouseManage', `user@${userId}`, 'edit')]: true,
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveIntakeNodePermissionAccess(overrides, 'warehouseManage', ['viewer'], userId),
|
||||
).toMatchObject({ view: true, edit: true })
|
||||
})
|
||||
|
||||
it('accepts only valid personal permission principal keys', () => {
|
||||
expect(
|
||||
sanitizeIntakeNodePermissionOverrides({
|
||||
'warehouseManage:user@valid-user_1:view': true,
|
||||
'warehouseManage:user@invalid:user:view': true,
|
||||
'warehouseManage:user@invalid user:view': true,
|
||||
}),
|
||||
).toEqual({
|
||||
'warehouseManage:user@valid-user_1:view': true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -36,6 +36,8 @@ export type IntakeNodePermissionTargetKey =
|
||||
| "assistedAssembly"
|
||||
| "draftGroups"
|
||||
| "formalProducts"
|
||||
| "bomCostingWorkbench"
|
||||
| "oaNewProductBoard"
|
||||
| "warehouseManage"
|
||||
| "warehouseQuality"
|
||||
| "aiRenderWorkbench"
|
||||
@ -84,6 +86,9 @@ export type IntakeNodePermissionTarget = {
|
||||
};
|
||||
|
||||
export type IntakeNodePermissionOverrideMap = Record<string, boolean>;
|
||||
export type IntakeNodePermissionPrincipalKey =
|
||||
| IntakeNodePermissionSubjectKey
|
||||
| `user@${string}`;
|
||||
|
||||
const ALL_SUBJECT_KEYS: IntakeNodePermissionSubjectKey[] = [
|
||||
"supplier",
|
||||
@ -110,6 +115,8 @@ const ALL_TARGET_KEYS: IntakeNodePermissionTargetKey[] = [
|
||||
"assistedAssembly",
|
||||
"draftGroups",
|
||||
"formalProducts",
|
||||
"bomCostingWorkbench",
|
||||
"oaNewProductBoard",
|
||||
"warehouseManage",
|
||||
"warehouseQuality",
|
||||
"aiRenderWorkbench",
|
||||
@ -396,6 +403,26 @@ export const TOP_ENTRY_PERMISSION_TARGETS: IntakeNodePermissionTarget[] = [
|
||||
externalIntake: "edit",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "bomCostingWorkbench",
|
||||
label: "BOM核算",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_HIDDEN,
|
||||
design: "readonly",
|
||||
planning: "readonly",
|
||||
purchase: "readonly",
|
||||
operation: "readonly",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "oaNewProductBoard",
|
||||
label: "OA 新品流程看板",
|
||||
defaults: {
|
||||
...NODE_PERMISSION_FORMAL_PRODUCTS_DEFAULT,
|
||||
customerService: "readonly",
|
||||
externalIntake: "edit",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "warehouseManage",
|
||||
label: "仓库管理",
|
||||
@ -438,6 +465,8 @@ const TOP_ENTRY_PATH_PREFIXES: Partial<
|
||||
assistedAssembly: ["/assisted-assembly"],
|
||||
draftGroups: ["/draft-groups"],
|
||||
formalProducts: ["/intake"],
|
||||
bomCostingWorkbench: ["/bom-costing"],
|
||||
oaNewProductBoard: ["/intake/oa-board"],
|
||||
warehouseManage: ["/warehouse"],
|
||||
warehouseQuality: ["/warehouse-quality"],
|
||||
aiRenderWorkbench: ["/ai-render-workbench"],
|
||||
@ -893,22 +922,31 @@ export function getIntakeNodePermissionTargetKeyByWorkspaceCategory(
|
||||
|
||||
export function buildIntakeNodePermissionOverrideKey(
|
||||
targetKey: IntakeNodePermissionTargetKey,
|
||||
subjectKey: IntakeNodePermissionSubjectKey,
|
||||
subjectKey: IntakeNodePermissionPrincipalKey,
|
||||
action: IntakeNodePermissionAction,
|
||||
) {
|
||||
return `${targetKey}:${subjectKey}:${action}`;
|
||||
}
|
||||
|
||||
export function buildIntakeNodePermissionUserKey(userId: string) {
|
||||
return `user@${userId.trim()}` as const;
|
||||
}
|
||||
|
||||
function isIntakeNodePermissionPrincipalKey(value: string) {
|
||||
if (SUBJECT_KEY_SET.has(value)) return true;
|
||||
return /^user@[A-Za-z0-9_-]+$/.test(value);
|
||||
}
|
||||
|
||||
export function parseIntakeNodePermissionOverrideKey(key: string) {
|
||||
const [targetKey, subjectKey, action, ...rest] = key.split(":");
|
||||
if (rest.length > 0) return null;
|
||||
if (!targetKey || !TARGET_KEY_SET.has(targetKey)) return null;
|
||||
if (!subjectKey || !SUBJECT_KEY_SET.has(subjectKey)) return null;
|
||||
if (!subjectKey || !isIntakeNodePermissionPrincipalKey(subjectKey)) return null;
|
||||
if (!action || !ACTION_SET.has(action)) return null;
|
||||
|
||||
return {
|
||||
targetKey: targetKey as IntakeNodePermissionTargetKey,
|
||||
subjectKey: subjectKey as IntakeNodePermissionSubjectKey,
|
||||
subjectKey: subjectKey as IntakeNodePermissionPrincipalKey,
|
||||
action: action as IntakeNodePermissionAction,
|
||||
};
|
||||
}
|
||||
@ -1071,6 +1109,7 @@ export function resolveIntakeNodePermissionAccess(
|
||||
overrides: IntakeNodePermissionOverrideMap,
|
||||
targetKey: IntakeNodePermissionTargetKey | string | null | undefined,
|
||||
subjectKeys: readonly IntakeNodePermissionSubjectKey[],
|
||||
userId?: string | null,
|
||||
) {
|
||||
const target =
|
||||
targetKey && TARGET_KEY_SET.has(targetKey)
|
||||
@ -1170,6 +1209,32 @@ export function resolveIntakeNodePermissionAccess(
|
||||
overrideEdit ||= subjectEditOverride === true;
|
||||
}
|
||||
|
||||
const normalizedUserId = userId?.trim();
|
||||
if (normalizedUserId) {
|
||||
const userKey = buildIntakeNodePermissionUserKey(normalizedUserId);
|
||||
const personalView = overrides[
|
||||
buildIntakeNodePermissionOverrideKey(target.key, userKey, "view")
|
||||
];
|
||||
const personalDownload = overrides[
|
||||
buildIntakeNodePermissionOverrideKey(target.key, userKey, "download")
|
||||
];
|
||||
const personalEdit = overrides[
|
||||
buildIntakeNodePermissionOverrideKey(target.key, userKey, "edit")
|
||||
];
|
||||
|
||||
if (personalView !== undefined) view = personalView;
|
||||
if (personalDownload !== undefined) download = personalDownload;
|
||||
if (personalEdit !== undefined) edit = personalEdit;
|
||||
if (edit || download) view = true;
|
||||
if (!view) {
|
||||
download = false;
|
||||
edit = false;
|
||||
}
|
||||
overrideView ||= personalView === true;
|
||||
overrideDownload ||= personalDownload === true;
|
||||
overrideEdit ||= personalEdit === true;
|
||||
}
|
||||
|
||||
return {
|
||||
defaultState,
|
||||
defaultView,
|
||||
@ -1201,6 +1266,7 @@ export function resolveIntakeTopEntryAllowedPathPrefixes(
|
||||
overrides,
|
||||
target.key,
|
||||
subjectKeys,
|
||||
user.id,
|
||||
);
|
||||
if (!access.view && !access.download && !access.edit) {
|
||||
return;
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
isSupplyChainExcludedUser,
|
||||
resolveSupplyChainPermissionAccess,
|
||||
resolveSupplyChainPermissionSubjectKeys,
|
||||
sanitizeSupplyChainPermissionOverrides,
|
||||
} from './supply-chain-permission-settings'
|
||||
|
||||
describe('supply chain permission settings', () => {
|
||||
@ -109,4 +110,42 @@ describe('supply chain permission settings', () => {
|
||||
getSupplyChainPermissionTargetForPath('/supply-chain/new-products/sample-progress'),
|
||||
).toBe('newProductSampleProgress')
|
||||
})
|
||||
|
||||
it('applies personal overrides after business type permissions', () => {
|
||||
const userId = 'user-123'
|
||||
const overrides = {
|
||||
[buildSupplyChainPermissionOverrideKey('workspace', 'purchase', 'view')]: true,
|
||||
[buildSupplyChainPermissionOverrideKey('workspace', `user@${userId}`, 'view')]: false,
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveSupplyChainPermissionAccess(overrides, 'workspace', ['purchase'], userId),
|
||||
).toEqual({ view: false, download: false, edit: false })
|
||||
expect(
|
||||
resolveSupplyChainPermissionAccess(overrides, 'workspace', ['purchase'], 'another-user'),
|
||||
).toEqual({ view: true, download: false, edit: false })
|
||||
})
|
||||
|
||||
it('lets a personal edit override open a closed business type', () => {
|
||||
const userId = 'user-456'
|
||||
const overrides = {
|
||||
[buildSupplyChainPermissionOverrideKey('productArchive', `user@${userId}`, 'edit')]: true,
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveSupplyChainPermissionAccess(overrides, 'productArchive', ['viewer'], userId),
|
||||
).toEqual({ view: true, download: false, edit: true })
|
||||
})
|
||||
|
||||
it('accepts only valid personal permission principal keys', () => {
|
||||
expect(
|
||||
sanitizeSupplyChainPermissionOverrides({
|
||||
'workspace:user@valid-user_1:view': true,
|
||||
'workspace:user@invalid:user:view': true,
|
||||
'workspace:user@invalid user:view': true,
|
||||
}),
|
||||
).toEqual({
|
||||
'workspace:user@valid-user_1:view': true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -40,8 +40,12 @@ export type SupplyChainPermissionTarget = {
|
||||
}
|
||||
|
||||
export type SupplyChainPermissionOverrideMap = Record<string, boolean>
|
||||
export type SupplyChainPermissionPrincipalKey =
|
||||
| SupplyChainPermissionSubjectKey
|
||||
| `user@${string}`
|
||||
|
||||
export type SupplyChainPermissionUser = {
|
||||
id?: string | null
|
||||
role?: string | null
|
||||
businessRoles?: readonly string[] | null
|
||||
supplierCode?: string | null
|
||||
@ -157,19 +161,30 @@ export function getSupplyChainRoleDefaultAccess(user: SupplyChainPermissionUser)
|
||||
|
||||
export function buildSupplyChainPermissionOverrideKey(
|
||||
targetKey: SupplyChainPermissionTargetKey,
|
||||
subjectKey: SupplyChainPermissionSubjectKey,
|
||||
subjectKey: SupplyChainPermissionPrincipalKey,
|
||||
action: SupplyChainPermissionAction,
|
||||
) {
|
||||
return `${targetKey}:${subjectKey}:${action}`
|
||||
}
|
||||
|
||||
export function buildSupplyChainPermissionUserKey(userId: string) {
|
||||
return `user@${userId.trim()}` as const
|
||||
}
|
||||
|
||||
function isSupplyChainPermissionPrincipalKey(value: string) {
|
||||
if (SUPPLY_CHAIN_PERMISSION_SUBJECT_KEYS.has(value as SupplyChainPermissionSubjectKey)) {
|
||||
return true
|
||||
}
|
||||
return /^user@[A-Za-z0-9_-]+$/.test(value)
|
||||
}
|
||||
|
||||
export function parseSupplyChainPermissionOverrideKey(value: string) {
|
||||
const [targetKey, subjectKey, action, ...rest] = value.split(':')
|
||||
if (rest.length > 0) return null
|
||||
if (!targetKey || !SUPPLY_CHAIN_PERMISSION_TARGET_KEY_SET.has(targetKey as SupplyChainPermissionTargetKey)) {
|
||||
return null
|
||||
}
|
||||
if (!subjectKey || !SUPPLY_CHAIN_PERMISSION_SUBJECT_KEYS.has(subjectKey as SupplyChainPermissionSubjectKey)) {
|
||||
if (!subjectKey || !isSupplyChainPermissionPrincipalKey(subjectKey)) {
|
||||
return null
|
||||
}
|
||||
if (!action || !SUPPLY_CHAIN_PERMISSION_ACTION_SET.has(action as SupplyChainPermissionAction)) {
|
||||
@ -178,7 +193,7 @@ export function parseSupplyChainPermissionOverrideKey(value: string) {
|
||||
|
||||
return {
|
||||
targetKey: targetKey as SupplyChainPermissionTargetKey,
|
||||
subjectKey: subjectKey as SupplyChainPermissionSubjectKey,
|
||||
subjectKey: subjectKey as SupplyChainPermissionPrincipalKey,
|
||||
action: action as SupplyChainPermissionAction,
|
||||
}
|
||||
}
|
||||
@ -212,6 +227,7 @@ export function resolveSupplyChainPermissionAccess(
|
||||
overrides: SupplyChainPermissionOverrideMap,
|
||||
targetKey: SupplyChainPermissionTargetKey,
|
||||
subjectKeys: readonly SupplyChainPermissionSubjectKey[],
|
||||
userId?: string | null,
|
||||
) {
|
||||
let view = false
|
||||
let download = false
|
||||
@ -233,6 +249,28 @@ export function resolveSupplyChainPermissionAccess(
|
||||
edit ||= subjectEdit
|
||||
})
|
||||
|
||||
const normalizedUserId = userId?.trim()
|
||||
if (normalizedUserId) {
|
||||
const userKey = buildSupplyChainPermissionUserKey(normalizedUserId)
|
||||
const personalView = overrides[
|
||||
buildSupplyChainPermissionOverrideKey(targetKey, userKey, 'view')
|
||||
]
|
||||
const personalDownload = overrides[
|
||||
buildSupplyChainPermissionOverrideKey(targetKey, userKey, 'download')
|
||||
]
|
||||
const personalEdit = overrides[
|
||||
buildSupplyChainPermissionOverrideKey(targetKey, userKey, 'edit')
|
||||
]
|
||||
if (personalView !== undefined) view = personalView
|
||||
if (personalDownload !== undefined) download = personalDownload
|
||||
if (personalEdit !== undefined) edit = personalEdit
|
||||
if (edit || download) view = true
|
||||
if (!view) {
|
||||
download = false
|
||||
edit = false
|
||||
}
|
||||
}
|
||||
|
||||
return { view, download, edit }
|
||||
}
|
||||
|
||||
|
||||
@ -4,10 +4,7 @@ import {
|
||||
INSTALLATION_DEMO_WORKBENCH_PATH,
|
||||
canViewInstallationDemoWorkbench,
|
||||
} from './installation-demo-workbench-permissions'
|
||||
import {
|
||||
BOM_COSTING_WORKBENCH_PATH,
|
||||
canViewBomCostingWorkbench,
|
||||
} from './bom-costing-permissions'
|
||||
import { BOM_COSTING_WORKBENCH_PATH } from './bom-costing-permissions'
|
||||
import {
|
||||
isBomCostingOnlyUser,
|
||||
isExternalIntakeUser,
|
||||
@ -103,11 +100,15 @@ function isPathAllowedByPrefixes(
|
||||
pathname: string,
|
||||
prefixes: readonly string[] | null | undefined,
|
||||
) {
|
||||
const requiresDedicatedOaPermission =
|
||||
pathname === '/intake/oa-board' || pathname.startsWith('/intake/oa-board/')
|
||||
return Boolean(
|
||||
prefixes?.some((prefix) =>
|
||||
prefix === '/'
|
||||
? pathname === '/'
|
||||
: pathname === prefix || pathname.startsWith(`${prefix}/`),
|
||||
: requiresDedicatedOaPermission && prefix === '/intake'
|
||||
? false
|
||||
: pathname === prefix || pathname.startsWith(`${prefix}/`),
|
||||
),
|
||||
)
|
||||
}
|
||||
@ -146,7 +147,12 @@ export function canAccessWorkspacePath(
|
||||
pathname === BOM_COSTING_WORKBENCH_PATH ||
|
||||
pathname.startsWith(`${BOM_COSTING_WORKBENCH_PATH}/`)
|
||||
) {
|
||||
return canViewBomCostingWorkbench(user)
|
||||
return Boolean(
|
||||
user &&
|
||||
(isBomCostingOnlyUser(user) ||
|
||||
hasFullWorkspaceAccess(user) ||
|
||||
isPathAllowedByPrefixes(pathname, user.workspaceAllowedPathPrefixes)),
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@ -25,10 +25,7 @@ import {
|
||||
getInstallationDemoWorkbenchAccess,
|
||||
INSTALLATION_DEMO_WORKBENCH_PATH,
|
||||
} from '@/lib/installation-demo-workbench-permissions'
|
||||
import {
|
||||
BOM_COSTING_WORKBENCH_PATH,
|
||||
canViewBomCostingWorkbench,
|
||||
} from '@/lib/bom-costing-permissions'
|
||||
import { BOM_COSTING_WORKBENCH_PATH } from '@/lib/bom-costing-permissions'
|
||||
import { isExternalIntakeUser } from '@/lib/non-integrated-external-entry-permissions'
|
||||
import { isSupplierAccount } from '@/lib/supplier-account'
|
||||
import { resolveIntakeTopEntryAllowedPathPrefixes } from '@/lib/intake-node-permission-settings'
|
||||
@ -123,6 +120,7 @@ function isEmail(identifier: string): boolean {
|
||||
}
|
||||
|
||||
type WorkspaceTokenPermissionUser = {
|
||||
id?: string | null
|
||||
role?: string | null
|
||||
businessRoles?: readonly string[] | null
|
||||
loginSource?: string | null
|
||||
@ -155,9 +153,6 @@ async function buildWorkspaceTokenPermissions(user: WorkspaceTokenPermissionUser
|
||||
if (getInstallationDemoWorkbenchAccess(user, overrides).canView) {
|
||||
allowedPathPrefixes.push(INSTALLATION_DEMO_WORKBENCH_PATH)
|
||||
}
|
||||
if (canViewBomCostingWorkbench(user)) {
|
||||
allowedPathPrefixes.push(BOM_COSTING_WORKBENCH_PATH)
|
||||
}
|
||||
}
|
||||
if (canAccessUserManagement(user)) {
|
||||
allowedPathPrefixes.push(USER_MANAGEMENT_PATH)
|
||||
@ -178,6 +173,7 @@ async function buildAuthUserDTO(
|
||||
const managedTeamId =
|
||||
user.managedTeam?.id ?? (user.role === 'SUPERVISOR' ? user.teamId : null)
|
||||
const workspacePermissions = await buildWorkspaceTokenPermissions({
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
businessRoles: user.businessRoles,
|
||||
loginSource: user.loginSource,
|
||||
@ -354,6 +350,7 @@ class AuthService {
|
||||
}
|
||||
|
||||
const workspacePermissions = await buildWorkspaceTokenPermissions({
|
||||
id: effectiveUser.id,
|
||||
role: effectiveUser.role,
|
||||
businessRoles: effectiveUser.businessRoles,
|
||||
loginSource: effectiveUser.loginSource,
|
||||
@ -469,6 +466,7 @@ class AuthService {
|
||||
|
||||
const departmentPaths = await getUserDepartmentPathsById(user.id)
|
||||
const workspacePermissions = await buildWorkspaceTokenPermissions({
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
businessRoles: user.businessRoles,
|
||||
departmentPaths,
|
||||
@ -551,6 +549,7 @@ class AuthService {
|
||||
const managedTeamId =
|
||||
user.managedTeam?.id ?? (user.role === 'SUPERVISOR' ? user.teamId : null)
|
||||
const workspacePermissions = await buildWorkspaceTokenPermissions({
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
businessRoles: user.businessRoles,
|
||||
loginSource: user.loginSource,
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
import { intakeNodePermissionSettingsService } from '../intake-node-permission-settings.service'
|
||||
import { bomCostingAccessService } from './bom-costing-access.service'
|
||||
|
||||
jest.mock('../intake-node-permission-settings.service', () => ({
|
||||
intakeNodePermissionSettingsService: {
|
||||
getSettings: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockedSettingsService = jest.mocked(intakeNodePermissionSettingsService)
|
||||
|
||||
function buildUser(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
name: '测试用户',
|
||||
role: 'USER',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: ['PURCHASING'],
|
||||
departmentPaths: ['江苏简尚品牌管理有限公司 > 供应链中心 > 采购部'],
|
||||
...overrides,
|
||||
} as never
|
||||
}
|
||||
|
||||
describe('BomCostingAccessService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockedSettingsService.getSettings.mockResolvedValue({ overrides: {} })
|
||||
})
|
||||
|
||||
it('uses a personal override after the business type permission', async () => {
|
||||
mockedSettingsService.getSettings.mockResolvedValue({
|
||||
overrides: {
|
||||
'bomCostingWorkbench:purchase:view': true,
|
||||
'bomCostingWorkbench:user@user-1:view': false,
|
||||
},
|
||||
})
|
||||
|
||||
await expect(bomCostingAccessService.canView(buildUser())).resolves.toBe(false)
|
||||
await expect(
|
||||
bomCostingAccessService.canView(buildUser({ id: 'user-2' })),
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('keeps administrators and supervisors open without reading overrides', async () => {
|
||||
await expect(
|
||||
bomCostingAccessService.canView(buildUser({ role: 'ADMIN' })),
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
bomCostingAccessService.canView(buildUser({ role: 'SUPERVISOR' })),
|
||||
).resolves.toBe(true)
|
||||
expect(mockedSettingsService.getSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -1,12 +1,37 @@
|
||||
import 'server-only'
|
||||
|
||||
import { canViewBomCostingWorkbench } from '@/lib/bom-costing-permissions'
|
||||
import {
|
||||
resolveIntakeNodePermissionAccess,
|
||||
resolveIntakeNodePermissionSubjectKeys,
|
||||
} from '@/lib/intake-node-permission-settings'
|
||||
import {
|
||||
isBomCostingOnlyUser,
|
||||
isExternalIntakeUser,
|
||||
} from '@/lib/non-integrated-external-entry-permissions'
|
||||
import { isSupplierAccount } from '@/lib/supplier-account'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { intakeNodePermissionSettingsService } from '../intake-node-permission-settings.service'
|
||||
|
||||
const FULL_ACCESS_ROLES = new Set(['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'])
|
||||
|
||||
export class BomCostingAccessService {
|
||||
assertCanView(user: AuthUser) {
|
||||
if (!canViewBomCostingWorkbench(user)) {
|
||||
async canView(user: AuthUser) {
|
||||
if (isBomCostingOnlyUser(user)) return true
|
||||
if (isSupplierAccount(user) || isExternalIntakeUser(user)) return false
|
||||
if (FULL_ACCESS_ROLES.has(user.role)) return true
|
||||
|
||||
const { overrides } = await intakeNodePermissionSettingsService.getSettings()
|
||||
return resolveIntakeNodePermissionAccess(
|
||||
overrides,
|
||||
'bomCostingWorkbench',
|
||||
resolveIntakeNodePermissionSubjectKeys(user),
|
||||
user.id,
|
||||
).view
|
||||
}
|
||||
|
||||
async assertCanView(user: AuthUser) {
|
||||
if (!(await this.canView(user))) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号不能访问 BOM 核算')
|
||||
}
|
||||
}
|
||||
|
||||
@ -491,12 +491,12 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
|
||||
|
||||
export class BomCostingService {
|
||||
async getCapabilities(user: AuthUser) {
|
||||
bomCostingAccessService.assertCanView(user)
|
||||
await bomCostingAccessService.assertCanView(user)
|
||||
return getBomCostingCapabilities()
|
||||
}
|
||||
|
||||
async listCases(user: AuthUser) {
|
||||
bomCostingAccessService.assertCanView(user)
|
||||
await bomCostingAccessService.assertCanView(user)
|
||||
const records = await bomCostingRepository.listCases({
|
||||
createdById: bomCostingAccessService.canViewAllCases(user) ? undefined : user.id,
|
||||
limit: 50,
|
||||
@ -571,7 +571,7 @@ export class BomCostingService {
|
||||
}
|
||||
|
||||
async getCase(caseId: string, user: AuthUser) {
|
||||
bomCostingAccessService.assertCanView(user)
|
||||
await bomCostingAccessService.assertCanView(user)
|
||||
const record = await bomCostingRepository.findCaseById({
|
||||
id: caseId,
|
||||
createdById: bomCostingAccessService.canViewAllCases(user) ? undefined : user.id,
|
||||
@ -609,7 +609,7 @@ export class BomCostingService {
|
||||
}
|
||||
|
||||
async createCase(sourceUrl: string, user: AuthUser) {
|
||||
bomCostingAccessService.assertCanView(user)
|
||||
await bomCostingAccessService.assertCanView(user)
|
||||
const parsedUrl = parseSafeCompetitorUrl(sourceUrl)
|
||||
const provider = getCompetitorAnalysisProvider()
|
||||
if (!provider) {
|
||||
@ -1011,7 +1011,7 @@ export class BomCostingService {
|
||||
input: UpdateBomCostingLineInput,
|
||||
user: AuthUser,
|
||||
) {
|
||||
bomCostingAccessService.assertCanView(user)
|
||||
await bomCostingAccessService.assertCanView(user)
|
||||
const line = await bomCostingRepository.findLineForUpdate(lineId)
|
||||
if (
|
||||
!line ||
|
||||
@ -1247,7 +1247,7 @@ export class BomCostingService {
|
||||
lineId: string,
|
||||
user: AuthUser,
|
||||
) {
|
||||
bomCostingAccessService.assertCanView(user)
|
||||
await bomCostingAccessService.assertCanView(user)
|
||||
const line = await bomCostingRepository.findLineForUpdate(lineId)
|
||||
if (
|
||||
!line ||
|
||||
|
||||
@ -484,6 +484,7 @@ class IntakeNonIntegratedScopeService {
|
||||
overrides,
|
||||
targetKey,
|
||||
subjectKeys,
|
||||
user.id,
|
||||
)
|
||||
|
||||
if (!nodePermissionAccess.edit) {
|
||||
@ -542,6 +543,7 @@ class IntakeNonIntegratedScopeService {
|
||||
overrides,
|
||||
targetKey,
|
||||
subjectKeys,
|
||||
user.id,
|
||||
)
|
||||
|
||||
if (!nodePermissionAccess.download) {
|
||||
@ -575,6 +577,7 @@ class IntakeNonIntegratedScopeService {
|
||||
overrides,
|
||||
targetKey,
|
||||
subjectKeys,
|
||||
user.id,
|
||||
)
|
||||
|
||||
if (!nodePermissionAccess.view) {
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
sanitizeSupplyChainPermissionOverrides,
|
||||
type SupplyChainPermissionAction,
|
||||
type SupplyChainPermissionOverrideMap,
|
||||
type SupplyChainPermissionSubjectKey,
|
||||
type SupplyChainPermissionPrincipalKey,
|
||||
type SupplyChainPermissionTargetKey,
|
||||
type SupplyChainPermissionUser,
|
||||
} from '@/lib/supply-chain-permission-settings'
|
||||
@ -50,7 +50,7 @@ class SupplyChainPermissionSettingsService {
|
||||
rows.forEach((row) => {
|
||||
const key = buildSupplyChainPermissionOverrideKey(
|
||||
row.targetKey as SupplyChainPermissionTargetKey,
|
||||
row.subjectKey as SupplyChainPermissionSubjectKey,
|
||||
row.subjectKey as SupplyChainPermissionPrincipalKey,
|
||||
row.action as SupplyChainPermissionAction,
|
||||
)
|
||||
if (parseSupplyChainPermissionOverrideKey(key)) overrides[key] = row.enabled
|
||||
@ -85,6 +85,7 @@ class SupplyChainPermissionSettingsService {
|
||||
await this.readOverrides(),
|
||||
targetKey,
|
||||
resolveSupplyChainPermissionSubjectKeys(user),
|
||||
user.id,
|
||||
)
|
||||
} catch (error) {
|
||||
if (isMissingPermissionTable(error)) {
|
||||
@ -128,7 +129,12 @@ class SupplyChainPermissionSettingsService {
|
||||
'operationLogs',
|
||||
]
|
||||
return targetKeys.filter((targetKey) =>
|
||||
resolveSupplyChainPermissionAccess(overrides, targetKey, subjectKeys).view,
|
||||
resolveSupplyChainPermissionAccess(
|
||||
overrides,
|
||||
targetKey,
|
||||
subjectKeys,
|
||||
user.id,
|
||||
).view,
|
||||
)
|
||||
} catch (error) {
|
||||
if (isMissingPermissionTable(error)) return []
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user