feat(intake): add node permissions and accessory library controls

This commit is contained in:
WuYongJie 2026-05-23 13:15:15 +08:00
parent 320670fa92
commit 78a036ca2d
22 changed files with 3564 additions and 230 deletions

View File

@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS "IntakeNodePermissionOverride" (
"id" TEXT NOT NULL,
"targetKey" TEXT NOT NULL,
"subjectKey" TEXT NOT NULL,
"action" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdBy" TEXT,
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IntakeNodePermissionOverride_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "IntakeNodePermissionOverride_targetKey_subjectKey_action_key"
ON "IntakeNodePermissionOverride"("targetKey", "subjectKey", "action");
CREATE INDEX IF NOT EXISTS "IntakeNodePermissionOverride_targetKey_idx"
ON "IntakeNodePermissionOverride"("targetKey");
CREATE INDEX IF NOT EXISTS "IntakeNodePermissionOverride_subjectKey_idx"
ON "IntakeNodePermissionOverride"("subjectKey");

View File

@ -1563,6 +1563,24 @@ model ThemeConfig {
@@index([startDate, endDate])
}
model IntakeNodePermissionOverride {
id String @id @default(cuid())
targetKey String
subjectKey String
action String
enabled Boolean @default(true)
createdBy String?
updatedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([targetKey, subjectKey, action])
@@index([targetKey])
@@index([subjectKey])
}
// ============================================
// 五金件库(标准五金件复用系统)
// ============================================

View File

@ -0,0 +1,46 @@
import { NextRequest } from "next/server";
import { AppError } from "@/server/core/errors";
import { handleRequest, jsonError, jsonSuccess } from "@/server/core/response";
import { intakeNodePermissionSettingsService } from "@/server/services/intake-node-permission-settings.service";
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const settings = await intakeNodePermissionSettingsService.getSettings();
return jsonSuccess(settings, 200, ctx.requestId);
},
{ requireAuth: true },
);
}
export async function PUT(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const rawBody = await request.json().catch(() => null);
try {
const settings =
await intakeNodePermissionSettingsService.updateSettings(
rawBody,
ctx.user,
);
return jsonSuccess(settings, 200, ctx.requestId);
} catch (error) {
if (error instanceof AppError) {
return jsonError(
error.code,
error.message,
error.statusCode,
ctx.requestId,
);
}
throw error;
}
},
{ requireAuth: true },
);
}

File diff suppressed because it is too large Load Diff

View File

@ -9,44 +9,141 @@ describe('alphabet library panels', () => {
window.localStorage.clear()
})
it('adds color and material fields to the accessory library only', async () => {
afterEach(() => {
jest.restoreAllMocks()
})
it('uses the structured self-tapping screw library for accessories', async () => {
const actor = userEvent.setup()
render(<AccessoryLibraryPanel />)
await actor.click(screen.getByRole('button', { name: '新增当前字母' }))
expect(screen.getByText('A 自攻螺丝')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '新增' })).not.toBeInTheDocument()
const dialog = await screen.findByRole('dialog', { name: '新增配件' })
await actor.click(screen.getByRole('button', { name: '新增 A 组' }))
expect(within(dialog).getByLabelText('颜色')).toBeInTheDocument()
expect(within(dialog).getByLabelText('材质')).toBeInTheDocument()
const dialog = await screen.findByRole('dialog', { name: '新增自攻螺丝' })
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
'M4*16 十字 沉头 尖尾 自攻螺丝',
)
fireEvent.change(within(dialog).getByLabelText(/配件名称/), {
target: { value: '六角螺丝' },
})
fireEvent.change(within(dialog).getByLabelText('颜色'), {
target: { value: '黑色' },
})
fireEvent.change(within(dialog).getByLabelText('材质'), {
target: { value: '铁' },
fireEvent.change(within(dialog).getByLabelText('长度'), {
target: { value: '20mm' },
})
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
'M4*20 十字 沉头 尖尾 自攻螺丝',
)
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
await waitFor(() => {
expect(screen.getByText('黑色')).toBeInTheDocument()
expect(screen.getByText('铁')).toBeInTheDocument()
expect(
screen.getByText('M4*20 十字 沉头 尖尾 自攻螺丝'),
).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
expect(savedItems[0]).toMatchObject({
name: '六角螺丝',
color: '黑色',
material: '铁',
letter: 'A',
code: 'A0',
name: 'M4*20 十字 沉头 尖尾 自攻螺丝',
headType: '沉头',
drive: '十字',
tail: '尖尾',
material: '1022A碳钢',
tooth: '木牙',
diameter: 'M4',
length: '20mm',
color: '银色',
})
})
it('lets every accessory letter group be selected even before it is configured', async () => {
const actor = userEvent.setup()
render(<AccessoryLibraryPanel />)
await actor.click(screen.getByRole('button', { name: 'B 0 条' }))
expect(screen.getByText('B 组配件')).toBeInTheDocument()
expect(screen.getByText('B 组暂未配置配件分类')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '新增 A 组' })).not.toBeInTheDocument()
await actor.click(screen.getByRole('button', { name: 'A 自攻螺丝 0 条' }))
expect(screen.getByText('A 组配件 · 自攻螺丝')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '新增 A 组' })).toBeInTheDocument()
})
it('opens copy-new mode and blocks exact duplicate screw configs', async () => {
const actor = userEvent.setup()
render(<AccessoryLibraryPanel />)
await actor.click(screen.getByRole('button', { name: '新增 A 组' }))
let dialog = await screen.findByRole('dialog', { name: '新增自攻螺丝' })
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
await waitFor(() => {
expect(
screen.getByText('M4*16 十字 沉头 尖尾 自攻螺丝'),
).toBeInTheDocument()
})
await actor.click(screen.getByRole('button', { name: '复制' }))
dialog = await screen.findByRole('dialog', { name: '复制新建:A0' })
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
expect(await within(dialog).findByText(/已存在相同配置/)).toBeInTheDocument()
fireEvent.change(within(dialog).getByLabelText('长度'), {
target: { value: '25mm' },
})
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
await waitFor(() => {
expect(
screen.getByText('M4*25 十字 沉头 尖尾 自攻螺丝'),
).toBeInTheDocument()
})
})
it('shows delete only when accessory library deletion is allowed', async () => {
const actor = userEvent.setup()
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true)
const { rerender } = render(<AccessoryLibraryPanel />)
await actor.click(screen.getByRole('button', { name: '新增 A 组' }))
let dialog = await screen.findByRole('dialog', { name: '新增自攻螺丝' })
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
expect(screen.queryByRole('button', { name: '删除' })).not.toBeInTheDocument()
rerender(<AccessoryLibraryPanel canDeleteItems />)
const deleteButton = await screen.findByRole('button', { name: '删除' })
await actor.click(deleteButton)
await waitFor(() => {
expect(
screen.queryByText('M4*16 十字 沉头 尖尾 自攻螺丝'),
).not.toBeInTheDocument()
})
expect(confirmSpy).toHaveBeenCalledWith(
expect.stringContaining('确认删除配件'),
)
expect(
JSON.parse(window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]'),
).toEqual([])
})
it('keeps the packaging material library on its original fields', async () => {
const actor = userEvent.setup()

View File

@ -13,15 +13,22 @@ import {
useState,
} from "react";
import {
ArrowDownToLine,
Boxes,
Calculator,
Check,
ClipboardCheck,
ClipboardList,
Copy,
Database,
Download,
FileSpreadsheet,
Home,
KeyRound,
Layers,
Layers3,
Package,
PackageOpen,
Palette,
Pencil,
Plus,
@ -29,6 +36,7 @@ import {
ShieldCheck,
Trash2,
Upload,
Warehouse,
X as XIcon,
type LucideIcon,
} from "lucide-react";
@ -90,6 +98,20 @@ import {
isIntakeNonIntegratedQualityGroupUser,
type IntakeNonIntegratedPermissionUser,
} from "@/lib/non-integrated-permissions";
import {
ALL_NODE_PERMISSION_TARGETS,
CORE_NODE_PERMISSION_SUBJECTS,
NODE_PERMISSION_STATE_LABEL,
ORDINARY_NODE_PERMISSION_SUBJECTS,
PRODUCT_DEVELOPMENT_PERMISSION_TARGETS,
TOP_ENTRY_PERMISSION_TARGETS,
buildIntakeNodePermissionOverrideKey,
sanitizeIntakeNodePermissionOverrides,
type IntakeNodePermissionOverrideMap,
type IntakeNodePermissionState,
type IntakeNodePermissionSubject,
type IntakeNodePermissionTargetKey,
} from "@/lib/intake-node-permission-settings";
import {
getNextProductPatentFeeDueDate,
listProductPatentRecords,
@ -193,6 +215,10 @@ function canManageSpuRuleLibrary(role: string): boolean {
return role === "SUPER_ADMIN" || role === "ADMIN";
}
function canManageNodePermissionSettings(role: string): boolean {
return role === "SUPER_ADMIN" || role === "ADMIN";
}
function canAccessDesignControlledProductData(
user: IntakeProductPermissionUser,
): boolean {
@ -241,6 +267,7 @@ type IntakeSection =
| "rawMaterialLibrary"
| "referenceEstimator"
| "patents"
| "adminNodePermission"
| null;
type IntakeEntryTab = "integrated" | "nonIntegrated" | "stock";
type ProductDevelopmentKind = NonNullable<
@ -276,6 +303,7 @@ const FORMAL_SIDEBAR_GROUP_ICONS = {
referenceEstimator: Calculator,
patents: ShieldCheck,
spuRuleLibrary: Database,
nodePermission: KeyRound,
} satisfies Record<string, LucideIcon>;
function FormalSidebarIcon({
@ -287,6 +315,410 @@ function FormalSidebarIcon({
}) {
return <Icon aria-hidden="true" className={className} />;
}
const TOP_ENTRY_PERMISSION_ICON_BY_KEY: Partial<
Record<IntakeNodePermissionTargetKey, LucideIcon>
> = {
homeDashboard: Home,
assetCenter: Boxes,
assistedAssembly: Layers,
draftGroups: PackageOpen,
formalProducts: ArrowDownToLine,
warehouseManage: Warehouse,
warehouseQuality: ClipboardCheck,
};
function getNodePermissionStateClass(state: IntakeNodePermissionState) {
if (state === "edit") return "bg-apple-blue/10 text-apple-blue";
if (state === "readonly") {
return "bg-apple-bg-secondary text-apple-label-secondary";
}
return "bg-zinc-100 text-zinc-400";
}
function getNodePermissionTargetButtonClass(isActive: boolean) {
return `relative flex min-h-[34px] items-center rounded-apple-lg border px-3 py-2 text-left transition ${
isActive
? "border-zinc-950 bg-zinc-950 text-white hover:border-zinc-800 hover:bg-zinc-800"
: "border-transparent bg-apple-bg-primary text-apple-label-primary hover:bg-apple-bg-secondary/40"
}`;
}
function getTopEntryPermissionButtonClass(isActive: boolean) {
return `inline-flex h-9 shrink-0 items-center gap-2 rounded-[6px] border px-3 text-[12px] font-apple-semibold transition ${
isActive
? "border-transparent bg-black/[0.04] text-[#2d3335] shadow-sm"
: "border-transparent text-[#5a6062]/75 hover:bg-black/[0.025] hover:text-[#2d3335]"
}`;
}
function NodePermissionToggle({
checked,
disabled,
label,
onChange,
}: {
checked: boolean;
disabled?: boolean;
label: string;
onChange: () => void;
}) {
return (
<button
type="button"
aria-pressed={checked}
disabled={disabled}
onClick={onChange}
className={`inline-flex h-6 w-11 items-center rounded-full border px-0.5 transition ${
checked
? "justify-end border-zinc-950 bg-zinc-950"
: "justify-start border-apple-separator bg-apple-bg-secondary"
} ${disabled ? "cursor-not-allowed opacity-40" : ""}`}
title={label}
>
<span className="h-5 w-5 rounded-full bg-white shadow-apple-xs" />
</button>
);
}
function AdminNodePermissionWorkspace() {
const [activeTargetKey, setActiveTargetKey] = useState(
TOP_ENTRY_PERMISSION_TARGETS[0]!.key,
);
const [overrides, setOverrides] =
useState<IntakeNodePermissionOverrideMap>({});
const [permissionSettingsStatus, setPermissionSettingsStatus] =
useState("正在读取权限配置...");
const [ordinaryExpanded, setOrdinaryExpanded] = useState(false);
const activeTarget =
ALL_NODE_PERMISSION_TARGETS.find((target) => target.key === activeTargetKey) ??
ALL_NODE_PERMISSION_TARGETS[0]!;
function getOverrideKey(
subjectKey: IntakeNodePermissionSubject["key"],
action: "view" | "download" | "edit",
) {
return buildIntakeNodePermissionOverrideKey(
activeTarget.key,
subjectKey,
action,
);
}
async function persistOverrides(nextOverrides: IntakeNodePermissionOverrideMap) {
setPermissionSettingsStatus("正在保存...");
try {
const response = await fetch("/api/intake/node-permission-settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ overrides: nextOverrides }),
});
const payload = (await response.json().catch(() => null)) as {
success?: boolean;
data?: { overrides?: unknown };
error?: { message?: string };
} | null;
if (!response.ok || !payload?.success) {
throw new Error(payload?.error?.message || "权限配置保存失败");
}
setOverrides(sanitizeIntakeNodePermissionOverrides(payload.data?.overrides));
setPermissionSettingsStatus("已保存,刷新后仍会生效");
} catch (error) {
setPermissionSettingsStatus(
error instanceof Error ? error.message : "权限配置保存失败",
);
}
}
function toggleOverride(
subjectKey: IntakeNodePermissionSubject["key"],
action: "view" | "download" | "edit",
) {
const key = getOverrideKey(subjectKey, action);
setOverrides((current) => {
const next = { ...current };
if (next[key]) {
delete next[key];
if (action === "view") {
delete next[getOverrideKey(subjectKey, "download")];
delete next[getOverrideKey(subjectKey, "edit")];
}
} else {
next[key] = true;
if (action === "download" || action === "edit") {
next[getOverrideKey(subjectKey, "view")] = true;
}
}
void persistOverrides(next);
return next;
});
}
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const response = await fetch("/api/intake/node-permission-settings", {
cache: "no-store",
credentials: "same-origin",
});
const payload = (await response.json().catch(() => null)) as {
success?: boolean;
data?: { overrides?: unknown };
error?: { message?: string };
} | null;
if (cancelled) return;
if (!response.ok || !payload?.success) {
throw new Error(payload?.error?.message || "权限配置读取失败");
}
setOverrides(sanitizeIntakeNodePermissionOverrides(payload.data?.overrides));
setPermissionSettingsStatus("已读取当前权限配置");
} catch (error) {
if (!cancelled) {
setPermissionSettingsStatus(
error instanceof Error ? error.message : "权限配置读取失败",
);
}
}
})();
return () => {
cancelled = true;
};
}, []);
function renderSubjectRow(subject: IntakeNodePermissionSubject) {
const state = activeTarget.defaults[subject.key] ?? "hidden";
const visibleOverrideOn = Boolean(overrides[getOverrideKey(subject.key, "view")]);
const downloadOverrideOn = Boolean(
overrides[getOverrideKey(subject.key, "download")],
);
const editOverrideOn = Boolean(overrides[getOverrideKey(subject.key, "edit")]);
const visibleOn =
state !== "hidden" ||
visibleOverrideOn;
const defaultDownloadOn = Boolean(activeTarget.downloads?.[subject.key]);
const downloadOn =
defaultDownloadOn ||
downloadOverrideOn;
const editOn = state === "edit" || editOverrideOn;
const effectiveState: IntakeNodePermissionState = editOn
? "edit"
: visibleOn
? "readonly"
: "hidden";
const openedActions = [
visibleOverrideOn ? "可见" : null,
downloadOverrideOn ? "下载" : null,
editOverrideOn ? "操作" : null,
].filter(Boolean);
return (
<tr key={subject.key} className="border-b border-apple-separator">
<td className="px-3 py-2">
<div className="font-apple-semibold text-apple-label-primary">
{subject.name}
</div>
<div className="mt-0.5 text-[11px] text-apple-label-tertiary">
{subject.department}
</div>
</td>
<td className="px-3 py-2">
<div className="flex flex-wrap items-center gap-1.5">
<span
className={`inline-flex h-6 items-center rounded-apple-md px-2 text-[11px] font-apple-semibold ${getNodePermissionStateClass(
state,
)}`}
>
默认:{NODE_PERMISSION_STATE_LABEL[state]}
</span>
<span
className={`inline-flex h-6 items-center rounded-apple-md px-2 text-[11px] font-apple-semibold ${getNodePermissionStateClass(
effectiveState,
)}`}
>
生效:{NODE_PERMISSION_STATE_LABEL[effectiveState]}
</span>
{openedActions.length > 0 ? (
<span className="inline-flex h-6 items-center rounded-apple-md bg-zinc-950 px-2 text-[11px] font-apple-semibold text-white">
已开通:{openedActions.join("、")}
</span>
) : null}
</div>
</td>
<td className="px-3 py-2 text-center">
<NodePermissionToggle
checked={visibleOn}
disabled={state !== "hidden"}
label="开放可见"
onChange={() => toggleOverride(subject.key, "view")}
/>
</td>
<td className="px-3 py-2 text-center">
<NodePermissionToggle
checked={downloadOn}
disabled={!visibleOn || defaultDownloadOn}
label="开放下载"
onChange={() => toggleOverride(subject.key, "download")}
/>
</td>
<td className="px-3 py-2 text-center">
<NodePermissionToggle
checked={editOn}
disabled={state === "edit" || !visibleOn}
label="开放操作"
onChange={() => toggleOverride(subject.key, "edit")}
/>
</td>
<td className="px-3 py-2 text-apple-label-secondary">
{openedActions.length > 0
? `管理员已额外开通:${openedActions.join("、")}。`
: state === "edit"
? "默认已有操作权,无需开放。"
: state === "readonly"
? "默认可看不可改,可开放下载或操作。"
: "默认隐藏,可先打开可见,再开放下载或操作。"}
</td>
</tr>
);
}
return (
<div className="grid h-full min-h-0 grid-cols-[minmax(0,1fr)_184px] overflow-hidden rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/40">
<section className="min-h-0 overflow-auto p-3">
<div className="flex min-h-full flex-col gap-3 rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-3 shadow-apple-xs">
<div className="flex shrink-0 items-center justify-between gap-3">
<div className="text-[11px] text-apple-label-secondary">
{permissionSettingsStatus}
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5 overflow-x-auto rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/35 px-2 py-1.5">
{TOP_ENTRY_PERMISSION_TARGETS.map((target) => {
const isActive = target.key === activeTarget.key;
const Icon = TOP_ENTRY_PERMISSION_ICON_BY_KEY[target.key] ?? Home;
return (
<button
key={target.key}
type="button"
onClick={() => setActiveTargetKey(target.key)}
className={getTopEntryPermissionButtonClass(isActive)}
>
<Icon
aria-hidden="true"
className="h-[15px] w-[15px] shrink-0"
strokeWidth={1.8}
/>
<span className="whitespace-nowrap">{target.label}</span>
</button>
);
})}
</div>
<div className="min-h-0 flex-1 overflow-auto rounded-apple-lg border border-apple-separator">
<table className="w-full min-w-[920px] border-collapse text-xs">
<thead className="bg-apple-bg-secondary text-apple-label-secondary">
<tr>
<th className="border-b border-apple-separator px-3 py-2 text-left font-apple-semibold">
对象
</th>
<th className="border-b border-apple-separator px-3 py-2 text-left font-apple-semibold">
权限状态
</th>
<th className="border-b border-apple-separator px-3 py-2 text-center font-apple-semibold">
可见
</th>
<th className="border-b border-apple-separator px-3 py-2 text-center font-apple-semibold">
下载
</th>
<th className="border-b border-apple-separator px-3 py-2 text-center font-apple-semibold">
操作
</th>
<th className="border-b border-apple-separator px-3 py-2 text-left font-apple-semibold">
说明
</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-apple-separator bg-apple-bg-secondary/60">
<td
colSpan={6}
className="px-3 py-2 text-[11px] font-apple-semibold text-apple-label-secondary"
>
核心对象
</td>
</tr>
{CORE_NODE_PERMISSION_SUBJECTS.map(renderSubjectRow)}
<tr className="border-b border-apple-separator bg-apple-bg-secondary/60">
<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-apple-semibold text-apple-label-secondary"
>
<span>普通业务</span>
<span>{ordinaryExpanded ? "收起" : "展开"}</span>
</button>
</td>
</tr>
{ordinaryExpanded
? ORDINARY_NODE_PERMISSION_SUBJECTS.map(renderSubjectRow)
: null}
</tbody>
</table>
</div>
</div>
</section>
<aside className="flex min-h-0 w-full shrink-0 border-l border-apple-separator p-3">
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary shadow-apple-xs">
<div className="border-b border-apple-separator px-3 py-2">
<div className="text-[12px] font-apple-semibold text-apple-label-primary">
权限范围
</div>
<div className="mt-0.5 text-[10px] text-apple-label-secondary">
点击查看权限
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-2 pr-1.5">
<div className="px-1 pt-1 text-[10px] font-apple-semibold text-apple-label-tertiary">
产品开发明细节点
</div>
{PRODUCT_DEVELOPMENT_PERMISSION_TARGETS.map((target) => {
const isActive = target.key === activeTarget.key;
return (
<button
key={target.key}
type="button"
onClick={() => setActiveTargetKey(target.key)}
className={getNodePermissionTargetButtonClass(isActive)}
>
{isActive ? (
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-1.5 left-0 top-1.5 w-[3px] rounded-full bg-zinc-950"
/>
) : null}
<p className="truncate text-[13px] font-apple-medium leading-5">
{target.label}
</p>
</button>
);
})}
</div>
</div>
</aside>
</div>
);
}
type IntakeWorkspaceTab =
| {
id: string;
@ -304,6 +736,11 @@ type IntakeWorkspaceTab =
kind: "spuRuleLibrary";
label: string;
}
| {
id: string;
kind: "adminNodePermission";
label: string;
}
| {
id: string;
kind: "productPatentLedger";
@ -368,6 +805,10 @@ type PersistedIntakeWorkspaceTab =
id: string;
kind: "spuRuleLibrary";
}
| {
id: string;
kind: "adminNodePermission";
}
| {
id: string;
kind: "productPatentLedger";
@ -1234,6 +1675,9 @@ function restoreIntakeWorkspaceTab(
if (tab.kind === "spuRuleLibrary") {
return { id: tab.id, kind: tab.kind, label: "SPU规则库" };
}
if (tab.kind === "adminNodePermission") {
return { id: tab.id, kind: tab.kind, label: "节点权限开放" };
}
if (tab.kind === "productPatentLedger") {
return {
id: tab.id,
@ -1363,6 +1807,7 @@ function readInitialIntakeWorkspaceState(params: {
function isDesignControlledWorkspaceTab(tab: IntakeWorkspaceTab) {
if (tab.kind === "newNonIntegratedProduct") return true;
if (tab.kind === "bomLibrary") return true;
if (tab.kind === "adminNodePermission") return true;
if (tab.kind === "productPatentDashboard") return true;
return tab.kind === "productPatentLedger" && !tab.target;
}
@ -1407,7 +1852,13 @@ function persistIntakeWorkspaceState(params: {
}
function restrictSectionForBusinessUser(section: IntakeSection): IntakeSection {
if (section === "bom" || section === "patents") return "pending";
if (
section === "bom" ||
section === "patents" ||
section === "adminNodePermission"
) {
return "pending";
}
return section;
}
@ -1422,6 +1873,7 @@ function getWorkspaceTabInitialSection(tab: IntakeWorkspaceTab | null) {
return "pending";
}
if (tab.kind === "progressDashboard") return "progress";
if (tab.kind === "adminNodePermission") return "adminNodePermission";
if (tab.kind === "productPatentLedger") return "patents";
if (tab.kind === "productPatentDashboard") return "patents";
if (tab.kind === "bomLibrary") return "bom";
@ -1444,6 +1896,7 @@ function getWorkspaceTabPageKey(tab: IntakeWorkspaceTab) {
if (tab.kind === "pendingEntry") return `pending-entry:${tab.entryTab}`;
if (tab.kind === "progressDashboard") return "product-progress:dashboard";
if (tab.kind === "spuRuleLibrary") return "spu-rule-library";
if (tab.kind === "adminNodePermission") return "node-permission";
if (tab.kind === "productPatentDashboard") return "product-patent:dashboard";
if (tab.kind === "productPatentLedger") {
const target = tab.target;
@ -5804,7 +6257,9 @@ function BomDetail({
return (
<div className="flex h-full flex-col rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-4 shadow-apple-xs">
<div className="flex-1 overflow-y-auto">
<AccessoryLibraryPanel />
<AccessoryLibraryPanel
canDeleteItems={user.role === "SUPER_ADMIN" || user.role === "ADMIN"}
/>
</div>
</div>
);
@ -8810,7 +9265,9 @@ function FormalIntakeWorkbench({
onOpenProductPatentLedger,
onOpenProductPatentDashboard,
onOpenSpuRuleLibraryWorkspace,
onOpenAdminNodePermissionWorkspace,
showDesignControlledProductData,
showNodePermissionSettings,
}: {
activeSection: IntakeSection;
onToggle: (section: Exclude<IntakeSection, null>) => void;
@ -8826,7 +9283,9 @@ function FormalIntakeWorkbench({
onOpenProductPatentLedger: () => void;
onOpenProductPatentDashboard: () => void;
onOpenSpuRuleLibraryWorkspace?: () => void;
onOpenAdminNodePermissionWorkspace?: () => void;
showDesignControlledProductData: boolean;
showNodePermissionSettings: boolean;
}) {
const menuRef = useRef<HTMLDivElement>(null);
const pendingEntryOptions: { key: IntakeEntryTab; label: string }[] = [
@ -8854,7 +9313,11 @@ function FormalIntakeWorkbench({
className={FORMAL_SIDEBAR_SECTION_ICON_CLASS}
/>
}
expanded={activeSection === "pending" || activeSection === "progress"}
expanded={
activeSection === "pending" ||
activeSection === "progress" ||
activeSection === "adminNodePermission"
}
onToggle={() => onToggle("pending")}
>
<div className="space-y-2">
@ -8894,6 +9357,20 @@ function FormalIntakeWorkbench({
>
<span>{getPendingEntryTabLabel("stock")}</span>
</button>
{showNodePermissionSettings && onOpenAdminNodePermissionWorkspace ? (
<button
type="button"
onClick={onOpenAdminNodePermissionWorkspace}
className={`flex w-full items-center gap-2 rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
activeSection === "adminNodePermission"
? "border-zinc-950 bg-zinc-950 text-white"
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary"
}`}
>
<FormalSidebarIcon icon={FORMAL_SIDEBAR_GROUP_ICONS.nodePermission} />
<span>节点权限开放</span>
</button>
) : null}
</div>
</AccordionSection>
@ -9423,6 +9900,8 @@ export default function IntakeClient(props: IntakeClientProps) {
} else if (tab.kind === "spuRuleLibrary") {
setActiveSection("pending");
setPendingEntryTab("nonIntegrated");
} else if (tab.kind === "adminNodePermission") {
setActiveSection("adminNodePermission");
} else if (tab.kind === "productPatentLedger") {
setActiveSection("patents");
} else if (tab.kind === "productPatentDashboard") {
@ -9508,6 +9987,20 @@ export default function IntakeClient(props: IntakeClientProps) {
openWorkspaceTab(nextTab);
}
function openAdminNodePermissionWorkspace() {
if (!canManageNodePermissionSettings(props.user.role)) {
return;
}
const tabId = createWorkspaceTabId("node-permission", "dashboard");
const nextTab: IntakeWorkspaceTab = {
id: tabId,
kind: "adminNodePermission",
label: "节点权限开放",
};
openWorkspaceTab(nextTab);
}
function openProductWorkspace(
product: NonIntegratedProductRecord,
mode: WorkspaceProductMode,
@ -9822,6 +10315,12 @@ export default function IntakeClient(props: IntakeClientProps) {
);
} else if (activeWorkspaceTab?.kind === "spuRuleLibrary") {
activeContent = <SpuRuleLibraryWorkspace />;
} else if (activeWorkspaceTab?.kind === "adminNodePermission") {
activeContent = canManageNodePermissionSettings(props.user.role) ? (
<AdminNodePermissionWorkspace />
) : (
<EmptyDetail />
);
} else if (activeWorkspaceTab?.kind === "bomLibrary") {
activeContent = (
<BomDetail
@ -10109,9 +10608,17 @@ export default function IntakeClient(props: IntakeClientProps) {
? openSpuRuleLibraryWorkspace
: undefined
}
onOpenAdminNodePermissionWorkspace={
canManageNodePermissionSettings(props.user.role)
? openAdminNodePermissionWorkspace
: undefined
}
showDesignControlledProductData={
canUseDesignControlledProductData
}
showNodePermissionSettings={canManageNodePermissionSettings(
props.user.role,
)}
/>
)
}
@ -10274,7 +10781,15 @@ export default function IntakeClient(props: IntakeClientProps) {
? openSpuRuleLibraryWorkspace
: undefined
}
onOpenAdminNodePermissionWorkspace={
canManageNodePermissionSettings(props.user.role)
? openAdminNodePermissionWorkspace
: undefined
}
showDesignControlledProductData={canUseDesignControlledProductData}
showNodePermissionSettings={canManageNodePermissionSettings(
props.user.role,
)}
/>
)
}

View File

@ -3525,8 +3525,8 @@ describe("NonIntegratedDetailClient", () => {
createProductRecord({
id: "DNZ0001-purchase-download-restricted",
spu: "SZ0230",
purchaser: "其他采购",
purchaseOwner: "其他采购",
purchaser: "采购同事",
purchaseOwner: "采购同事",
skuItems: [
createSkuItem({
id: "sku-purchase-download-restricted-1",
@ -4686,7 +4686,7 @@ describe("NonIntegratedDetailClient", () => {
});
});
it("lets purchasing users edit only purchasing document nodes", async () => {
it("keeps purchasing users readonly on pricing-owned measurement nodes", async () => {
const actor = userEvent.setup();
const sampleMeasurementCategory = "样包测量";
@ -4735,16 +4735,7 @@ describe("NonIntegratedDetailClient", () => {
const sampleLengthInput = screen.getByTestId(
"sample-measurement-lengthCm-sku-purchasing-permission-1",
);
expect(sampleLengthInput).not.toHaveAttribute("readonly");
await actor.type(sampleLengthInput, "120");
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(
"SZ0230-purchasing-permission",
);
expect(savedProduct?.sampleMeasurementRows?.[0]?.lengthCm).toBe("120");
});
expect(sampleLengthInput).toHaveAttribute("readonly");
});
it("lets supply chain pricing and quality groups maintain their own nodes", async () => {
@ -4885,7 +4876,7 @@ describe("NonIntegratedDetailClient", () => {
});
});
it("lets planning users edit opening reference, planning layout, and product limit nodes", async () => {
it("lets product planning users edit opening reference and product limit but not planning layout", async () => {
const actor = userEvent.setup();
const openingReferenceCategory = "开品参考";
const planningLayoutCategory = "企划布局";
@ -4939,12 +4930,9 @@ describe("NonIntegratedDetailClient", () => {
await actor.click(
screen.getByTestId(`document-workspace-trigger-${planningLayoutCategory}`),
);
expect(
screen.getByTestId("planning-layout-styles-draft-sku-planning-permission-1"),
).not.toHaveAttribute("readonly");
expect(
screen.getByTestId(`document-workspace-confirm-${planningLayoutCategory}`),
).not.toBeDisabled();
).toBeDisabled();
await actor.click(
screen.getByTestId(`document-workspace-trigger-${productLimitCategory}`),

View File

@ -271,6 +271,13 @@ import {
isIntakeNonIntegratedSupplyChainUser,
type IntakeNonIntegratedPermissionUser,
} from "@/lib/non-integrated-permissions";
import {
getIntakeNodePermissionTargetKeyByWorkspaceCategory,
resolveIntakeNodePermissionAccess,
resolveIntakeNodePermissionSubjectKeys,
sanitizeIntakeNodePermissionOverrides,
type IntakeNodePermissionOverrideMap,
} from "@/lib/intake-node-permission-settings";
type BasicFieldType = "text" | "textarea" | "select";
type SkuTabKey = "componentSkuInfo" | "skuInfo" | "fileUpload";
@ -1042,76 +1049,6 @@ const PRODUCT_LIMIT_WORKSPACE_CATEGORY: DocumentWorkspaceCategory =
"产品限价";
const PLANNING_LAYOUT_WORKSPACE_CATEGORY: DocumentWorkspaceCategory =
NON_INTEGRATED_PLANNING_LAYOUT_WORKSPACE_CATEGORY;
const PURCHASE_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES =
new Set<DocumentWorkspaceCategory>([
"开品参考",
"设计卖点",
PLANNING_LAYOUT_WORKSPACE_CATEGORY,
PATENT_APPLICATION_WORKSPACE_CATEGORY,
"产品实拍图",
INSTALLATION_WORKSPACE_CATEGORY,
PACKAGING_WORKSPACE_CATEGORY,
PRODUCT_LIMIT_WORKSPACE_CATEGORY,
"工厂质检",
DETECTION_WORKSPACE_CATEGORY,
]);
function isPurchaseHiddenDocumentWorkspaceCategory(
category: DocumentWorkspaceCategory,
options?: { canViewFactoryQuality?: boolean },
) {
if (category === "工厂质检" && options?.canViewFactoryQuality) {
return false;
}
return PURCHASE_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES.has(category);
}
const PLANNING_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES =
new Set<DocumentWorkspaceCategory>([
PRODUCT_DATA_WORKSPACE_CATEGORY,
PATENT_APPLICATION_WORKSPACE_CATEGORY,
PACKAGING_WORKSPACE_CATEGORY,
PRODUCT_FACTORY_WORKSPACE_CATEGORY,
SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY,
FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY,
"工厂质检",
DETECTION_WORKSPACE_CATEGORY,
]);
const PLANNING_DOWNLOADABLE_DOCUMENT_WORKSPACE_CATEGORIES =
new Set<DocumentWorkspaceCategory>(["开品参考", "设计卖点"]);
const PLAIN_VIEWER_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES =
new Set<DocumentWorkspaceCategory>([
PRODUCT_DATA_WORKSPACE_CATEGORY,
REVIEW_WORKSPACE_CATEGORY,
PATENT_APPLICATION_WORKSPACE_CATEGORY,
PACKAGING_WORKSPACE_CATEGORY,
PRODUCT_FACTORY_WORKSPACE_CATEGORY,
SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY,
FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY,
"工厂质检",
]);
function isPlanningHiddenDocumentWorkspaceCategory(
category: DocumentWorkspaceCategory,
) {
return PLANNING_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES.has(category);
}
function isPlainViewerHiddenDocumentWorkspaceCategory(
category: DocumentWorkspaceCategory,
) {
return PLAIN_VIEWER_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES.has(category);
}
function canPlanningDownloadDocumentWorkspaceCategory(
category: DocumentWorkspaceCategory,
) {
return PLANNING_DOWNLOADABLE_DOCUMENT_WORKSPACE_CATEGORIES.has(category);
}
function getUniqueNonEmptyStrings(
values: ReadonlyArray<string | null | undefined>,
) {
@ -10337,6 +10274,8 @@ export default function NonIntegratedDetailClient({
useState<UploadProgressState | null>(null);
const [downloadProgress, setDownloadProgress] =
useState<DownloadProgressState | null>(null);
const [nodePermissionOverrides, setNodePermissionOverrides] =
useState<IntakeNodePermissionOverrideMap>({});
const [projectStatus, setProjectStatus] =
useState<NonIntegratedProjectStatus>(initialProjectStatus);
const [projectReview, setProjectReview] = useState<NonIntegratedProjectReview>(
@ -11108,6 +11047,38 @@ export default function NonIntegratedDetailClient({
currentUserRole,
],
);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const response = await fetch("/api/intake/node-permission-settings", {
cache: "no-store",
credentials: "same-origin",
});
const payload = (await response.json().catch(() => null)) as {
success?: boolean;
data?: { overrides?: unknown };
} | null;
if (cancelled) return;
if (response.ok && payload?.success) {
setNodePermissionOverrides(
sanitizeIntakeNodePermissionOverrides(payload.data?.overrides),
);
}
} catch {
if (!cancelled) {
setNodePermissionOverrides({});
}
}
})();
return () => {
cancelled = true;
};
}, []);
const currentPermissionProduct = useMemo(
() => ({
createdBy: existingProduct?.createdBy,
@ -11163,21 +11134,51 @@ export default function NonIntegratedDetailClient({
!canCurrentUserEditAnyConfiguredWorkspace &&
!isCurrentUserPurchaseDownloadRestricted &&
!isCurrentUserPlanningContentRestricted;
const isCurrentUserWorkspaceDownloadRestricted =
isCurrentUserPurchaseDownloadRestricted ||
isCurrentUserPlanningContentRestricted;
const currentPermissionSubjectKeys = useMemo(
() => resolveIntakeNodePermissionSubjectKeys(currentPermissionUser),
[currentPermissionUser],
);
const getNodePermissionAccessForCategory = useCallback(
(category: DocumentWorkspaceCategory | string) => {
const targetKey =
getIntakeNodePermissionTargetKeyByWorkspaceCategory(category);
return resolveIntakeNodePermissionAccess(
nodePermissionOverrides,
targetKey,
currentPermissionSubjectKeys,
);
},
[currentPermissionSubjectKeys, nodePermissionOverrides],
);
const activeNodePermissionAccess = getNodePermissionAccessForCategory(
activeDocumentWorkspaceCategory,
);
const isCurrentUserPurchaseOwner = isCurrentUserNonIntegratedPurchaseOwner(
currentPermissionProduct,
currentPermissionUser,
);
const isCurrentUserMerchandiser =
currentPermissionSubjectKeys.includes("merchandiser");
const canPurchaseDownloadActiveWorkspace =
isCurrentUserSupplyChainPurchaseDepartment &&
(activeDocumentWorkspaceCategory === PRODUCT_DATA_WORKSPACE_CATEGORY ||
activeDocumentWorkspaceCategory === REVIEW_WORKSPACE_CATEGORY);
const canPlanningDownloadActiveWorkspace =
isIntakeNonIntegratedBrandCenterPlanningUser(currentPermissionUser) &&
canPlanningDownloadDocumentWorkspaceCategory(activeDocumentWorkspaceCategory);
activeDocumentWorkspaceCategory === PRODUCT_DATA_WORKSPACE_CATEGORY
? isCurrentUserPurchaseOwner || isCurrentUserMerchandiser
: activeDocumentWorkspaceCategory === REVIEW_WORKSPACE_CATEGORY
? isCurrentUserSupplyChainPurchaseDepartment ||
isCurrentUserSupplyChainPricingGroup ||
isCurrentUserSupplyChainQualityGroup ||
isCurrentUserMerchandiser
: activeDocumentWorkspaceCategory === PRODUCT_FACTORY_WORKSPACE_CATEGORY
? isCurrentUserPurchaseOwner
: true;
const canUseDefaultDownloadForActiveWorkspace =
activeNodePermissionAccess.defaultDownload &&
(!isCurrentUserPurchaseDownloadRestricted ||
canPurchaseDownloadActiveWorkspace);
const canDownloadWorkspaceFiles =
!isCurrentUserPlainReadOnlyViewer &&
(!isCurrentUserWorkspaceDownloadRestricted ||
canPurchaseDownloadActiveWorkspace ||
canPlanningDownloadActiveWorkspace);
activeNodePermissionAccess.override.download ||
!hasCurrentUserContext ||
isCurrentUserFullDetailEditor ||
canUseDefaultDownloadForActiveWorkspace;
const isDocumentWorkspaceCategoryVisibleForCurrentUser = useCallback(
(category: DocumentWorkspaceCategory) => {
if (linkEntryView) {
@ -11191,29 +11192,14 @@ export default function NonIntegratedDetailClient({
return false;
}
if (
isCurrentUserPurchaseDownloadRestricted &&
isPurchaseHiddenDocumentWorkspaceCategory(category, {
canViewFactoryQuality: isCurrentUserSupplyChainQualityGroup,
})
) {
return false;
}
if (
isCurrentUserPlanningContentRestricted &&
isPlanningHiddenDocumentWorkspaceCategory(category)
) {
return false;
}
return true;
if (!hasCurrentUserContext || isCurrentUserFullDetailEditor) return true;
return getNodePermissionAccessForCategory(category).view;
},
[
isCurrentUserPlanningContentRestricted,
getNodePermissionAccessForCategory,
hasCurrentUserContext,
isCurrentUserFullDetailEditor,
isCurrentUserPlainReadOnlyViewer,
isCurrentUserPurchaseDownloadRestricted,
isCurrentUserSupplyChainQualityGroup,
linkEntryView,
],
);
@ -11246,6 +11232,8 @@ export default function NonIntegratedDetailClient({
documentWorkspaceView === "detail"
? NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY
: activeDocumentWorkspaceCategory;
const activeWorkspaceNodePermissionAccess =
getNodePermissionAccessForCategory(activePermissionWorkspaceCategory);
const canEditCurrentProduct =
mode === "create"
? !hasCurrentUserContext ||
@ -11254,9 +11242,10 @@ export default function NonIntegratedDetailClient({
canEditNonIntegratedProductIdentity(
currentPermissionProduct,
currentPermissionUser,
);
);
const canEditActiveWorkspace =
!hasCurrentUserContext ||
activeWorkspaceNodePermissionAccess.override.edit ||
(mode === "create"
? canEditCurrentProduct
: canEditNonIntegratedWorkspace(
@ -13018,7 +13007,10 @@ export default function NonIntegratedDetailClient({
const canDownloadProductFactoryContract =
!hasCurrentUserContext ||
isCurrentUserAdmin ||
isIntakeNonIntegratedSupplyChainUser(currentPermissionUser);
activeWorkspaceNodePermissionAccess.override.download ||
(getNodePermissionAccessForCategory(PRODUCT_FACTORY_WORKSPACE_CATEGORY)
.defaultDownload &&
isCurrentUserPurchaseOwner);
const skuStyleKeys = useMemo(() => {
const nextKeys = new Set<string>();

View File

@ -140,7 +140,6 @@ export function getNonIntegratedDocumentWorkspaceCategoryLabel(
if (category === "评审资料") return "样品评审";
if (category === "企划布局") return "空间布局";
if (category === "工厂质检") return "首批质检";
if (category === "检测报告") return "仓库抽检";
return category;
}

View File

@ -8,8 +8,18 @@ export type AccessoryLibraryItem = {
letter: string
code: string
name: string
headType?: string
drive?: string
tail?: string
color?: string
material?: string
finish?: string
tooth?: string
diameter?: string
length?: string
usages?: string[]
preDrill?: string
environments?: string[]
price: string
imageUrl: string
imageName: string
@ -73,8 +83,24 @@ export function readAccessoryLibraryItems() {
typeof item.letter === 'string' &&
typeof item.code === 'string' &&
typeof item.name === 'string' &&
(item.headType === undefined || typeof item.headType === 'string') &&
(item.drive === undefined || typeof item.drive === 'string') &&
(item.tail === undefined || typeof item.tail === 'string') &&
(item.color === undefined || typeof item.color === 'string') &&
(item.material === undefined || typeof item.material === 'string') &&
(item.finish === undefined || typeof item.finish === 'string') &&
(item.tooth === undefined || typeof item.tooth === 'string') &&
(item.diameter === undefined || typeof item.diameter === 'string') &&
(item.length === undefined || typeof item.length === 'string') &&
(item.usages === undefined ||
(Array.isArray(item.usages) &&
item.usages.every((value: unknown) => typeof value === 'string'))) &&
(item.preDrill === undefined || typeof item.preDrill === 'string') &&
(item.environments === undefined ||
(Array.isArray(item.environments) &&
item.environments.every(
(value: unknown) => typeof value === 'string',
))) &&
typeof item.price === 'string' &&
typeof item.imageUrl === 'string' &&
typeof item.imageName === 'string' &&

View File

@ -0,0 +1,78 @@
import {
buildIntakeNodePermissionOverrideKey,
resolveIntakeNodePermissionAccess,
resolveIntakeNodePermissionSubjectKeys,
} from './intake-node-permission-settings'
describe('intake node permission settings', () => {
it('resolves DingTalk department paths into the configured permission subjects', () => {
expect(
resolveIntakeNodePermissionSubjectKeys({
role: 'USER',
businessRoles: [],
departmentPaths: [
'江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部',
],
}),
).toEqual(['brandCenter', 'space'])
expect(
resolveIntakeNodePermissionSubjectKeys({
role: 'USER',
businessRoles: [],
departmentPaths: [
'江苏简尚品牌管理有限公司 > 供应链中心 > 品牌 采购部 > 跟单组',
],
}),
).toEqual(['merchandiser'])
})
it('uses the same matrix for default view, download, and admin-opened overrides', () => {
const spaceKeys = ['brandCenter', 'space'] as const
expect(
resolveIntakeNodePermissionAccess({}, 'planningLayout', spaceKeys),
).toMatchObject({
defaultState: 'edit',
defaultView: true,
view: true,
edit: true,
})
expect(
resolveIntakeNodePermissionAccess({}, 'homeDashboard', spaceKeys),
).toMatchObject({
defaultState: 'hidden',
defaultView: false,
view: false,
edit: false,
})
expect(
resolveIntakeNodePermissionAccess({}, 'productData', spaceKeys),
).toMatchObject({
defaultState: 'hidden',
defaultView: false,
view: false,
download: false,
})
const overrides = {
[buildIntakeNodePermissionOverrideKey(
'productData',
'space',
'view',
)]: true,
[buildIntakeNodePermissionOverrideKey(
'productData',
'space',
'download',
)]: true,
}
expect(
resolveIntakeNodePermissionAccess(overrides, 'productData', spaceKeys),
).toMatchObject({
defaultState: 'hidden',
view: true,
download: true,
edit: false,
})
})
})

View File

@ -0,0 +1,893 @@
import {
NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY,
NON_INTEGRATED_PLANNING_LAYOUT_WORKSPACE_CATEGORY,
type NonIntegratedWorkflowNodeCategory,
} from "@/app/intake/non-integrated/nonIntegratedStorage";
import type {
IntakeNonIntegratedPermissionUser,
} from "@/lib/non-integrated-permissions";
export type IntakeNodePermissionState = "edit" | "readonly" | "hidden";
export type IntakeNodePermissionAction = "view" | "download" | "edit";
export type IntakeNodePermissionSubjectKey =
| "design"
| "brandCenter"
| "planning"
| "space"
| "purchase"
| "pricing"
| "quality"
| "operation"
| "warehouse"
| "warehouseQuality"
| "merchandiser"
| "viewer";
export type IntakeNodePermissionTargetKey =
| "homeDashboard"
| "assetCenter"
| "assistedAssembly"
| "draftGroups"
| "formalProducts"
| "warehouseManage"
| "warehouseQuality"
| "openingReference"
| "designSellingPoint"
| "projectReview"
| "productData"
| "review"
| "basicInfo"
| "planningLayout"
| "patent"
| "photo"
| "installation"
| "packaging"
| "factory"
| "measurement"
| "finishedSampleShipment"
| "pricing"
| "limit"
| "quality"
| "warehouseSampling";
export type IntakeNodePermissionSubject = {
key: IntakeNodePermissionSubjectKey;
name: string;
department: string;
};
export type IntakeNodePermissionTarget = {
key: IntakeNodePermissionTargetKey;
label: string;
defaults: Record<IntakeNodePermissionSubjectKey, IntakeNodePermissionState>;
downloads?: Partial<Record<IntakeNodePermissionSubjectKey, boolean>>;
};
export type IntakeNodePermissionOverrideMap = Record<string, boolean>;
const ALL_SUBJECT_KEYS: IntakeNodePermissionSubjectKey[] = [
"design",
"brandCenter",
"planning",
"space",
"purchase",
"pricing",
"quality",
"operation",
"warehouse",
"warehouseQuality",
"merchandiser",
"viewer",
];
const ALL_TARGET_KEYS: IntakeNodePermissionTargetKey[] = [
"homeDashboard",
"assetCenter",
"assistedAssembly",
"draftGroups",
"formalProducts",
"warehouseManage",
"warehouseQuality",
"openingReference",
"designSellingPoint",
"projectReview",
"productData",
"review",
"basicInfo",
"planningLayout",
"patent",
"photo",
"installation",
"packaging",
"factory",
"measurement",
"finishedSampleShipment",
"pricing",
"limit",
"quality",
"warehouseSampling",
];
const ALL_ACTIONS: IntakeNodePermissionAction[] = [
"view",
"download",
"edit",
];
const SUBJECT_KEY_SET = new Set<string>(ALL_SUBJECT_KEYS);
const TARGET_KEY_SET = new Set<string>(ALL_TARGET_KEYS);
const ACTION_SET = new Set<string>(ALL_ACTIONS);
const NODE_PERMISSION_HIDDEN: Record<
IntakeNodePermissionSubjectKey,
IntakeNodePermissionState
> = {
design: "hidden",
brandCenter: "hidden",
planning: "hidden",
space: "hidden",
purchase: "hidden",
pricing: "hidden",
quality: "hidden",
operation: "hidden",
warehouse: "hidden",
warehouseQuality: "hidden",
merchandiser: "hidden",
viewer: "hidden",
};
const NODE_PERMISSION_READONLY: Record<
IntakeNodePermissionSubjectKey,
IntakeNodePermissionState
> = {
design: "readonly",
brandCenter: "readonly",
planning: "readonly",
space: "readonly",
purchase: "readonly",
pricing: "readonly",
quality: "readonly",
operation: "readonly",
warehouse: "readonly",
warehouseQuality: "readonly",
merchandiser: "readonly",
viewer: "readonly",
};
const NODE_PERMISSION_DESIGN_ENTRY_ONLY: Record<
IntakeNodePermissionSubjectKey,
IntakeNodePermissionState
> = {
...NODE_PERMISSION_HIDDEN,
design: "edit",
};
const NODE_PERMISSION_FORMAL_PRODUCTS_DEFAULT: Record<
IntakeNodePermissionSubjectKey,
IntakeNodePermissionState
> = {
...NODE_PERMISSION_READONLY,
design: "edit",
};
const NODE_PERMISSION_DESIGN_DOWNLOADS: Partial<
Record<IntakeNodePermissionSubjectKey, boolean>
> = {
design: true,
};
const NODE_PERMISSION_PURCHASE_DOWNLOADS: Partial<
Record<IntakeNodePermissionSubjectKey, boolean>
> = {
purchase: true,
merchandiser: true,
};
const NODE_PERMISSION_BRAND_READONLY: Record<
IntakeNodePermissionSubjectKey,
IntakeNodePermissionState
> = {
...NODE_PERMISSION_HIDDEN,
design: "readonly",
brandCenter: "readonly",
planning: "readonly",
space: "readonly",
operation: "readonly",
viewer: "readonly",
};
const NODE_PERMISSION_PLAIN_VISIBLE: Record<
IntakeNodePermissionSubjectKey,
IntakeNodePermissionState
> = {
...NODE_PERMISSION_READONLY,
design: "readonly",
purchase: "readonly",
pricing: "readonly",
quality: "readonly",
merchandiser: "readonly",
};
const NODE_PERMISSION_SUPPLY_VISIBLE: Record<
IntakeNodePermissionSubjectKey,
IntakeNodePermissionState
> = {
...NODE_PERMISSION_HIDDEN,
design: "readonly",
purchase: "readonly",
pricing: "readonly",
quality: "readonly",
merchandiser: "readonly",
};
export const CORE_NODE_PERMISSION_SUBJECTS: IntakeNodePermissionSubject[] = [
{
key: "design",
name: "设计师",
department: "产品研发中心-外观 / 结构",
},
{
key: "brandCenter",
name: "品牌中心",
department: "品牌中心-事业部 / 策划 / 企划",
},
{
key: "planning",
name: "商品企划",
department: "品牌中心-商品企划部",
},
{
key: "space",
name: "空间视觉",
department: "品牌中心-空间视觉部",
},
{
key: "purchase",
name: "采购负责人",
department: "供应链中心-品牌采购部",
},
{
key: "pricing",
name: "核价组",
department: "供应链中心-核价组",
},
{
key: "quality",
name: "质检组",
department: "供应链中心-品牌采购部-质检组",
},
];
export const ORDINARY_NODE_PERMISSION_SUBJECTS: IntakeNodePermissionSubject[] = [
{ key: "operation", name: "运营", department: "运营中心" },
{ key: "warehouse", name: "仓库管理", department: "供应链中心-仓储" },
{
key: "warehouseQuality",
name: "仓库质检",
department: "供应链中心-仓库质检",
},
{
key: "merchandiser",
name: "跟单组",
department: "供应链中心-品牌采购部-跟单组",
},
{
key: "viewer",
name: "其他业务用户",
department: "除已配置部门外的普通账号",
},
];
export const TOP_ENTRY_PERMISSION_TARGETS: IntakeNodePermissionTarget[] = [
{
key: "homeDashboard",
label: "首页概览",
defaults: NODE_PERMISSION_DESIGN_ENTRY_ONLY,
},
{
key: "assetCenter",
label: "资产中心",
defaults: NODE_PERMISSION_DESIGN_ENTRY_ONLY,
},
{
key: "assistedAssembly",
label: "辅助装配",
defaults: NODE_PERMISSION_DESIGN_ENTRY_ONLY,
},
{
key: "draftGroups",
label: "草稿组",
defaults: NODE_PERMISSION_DESIGN_ENTRY_ONLY,
},
{
key: "formalProducts",
label: "正式产品",
defaults: NODE_PERMISSION_FORMAL_PRODUCTS_DEFAULT,
},
{
key: "warehouseManage",
label: "仓库管理",
defaults: {
...NODE_PERMISSION_HIDDEN,
design: "readonly",
warehouse: "readonly",
},
},
{
key: "warehouseQuality",
label: "仓库质检",
defaults: {
...NODE_PERMISSION_HIDDEN,
design: "readonly",
warehouseQuality: "readonly",
quality: "readonly",
},
},
];
const TOP_ENTRY_PATH_PREFIXES: Partial<
Record<IntakeNodePermissionTargetKey, string[]>
> = {
homeDashboard: ["/"],
assetCenter: ["/asset-center"],
assistedAssembly: ["/assisted-assembly"],
draftGroups: ["/draft-groups"],
formalProducts: ["/intake"],
warehouseManage: ["/warehouse"],
warehouseQuality: ["/warehouse-quality"],
};
export const PRODUCT_DEVELOPMENT_PERMISSION_TARGETS: IntakeNodePermissionTarget[] = [
{
key: "openingReference",
label: "开品参考",
defaults: {
...NODE_PERMISSION_BRAND_READONLY,
design: "edit",
planning: "edit",
},
},
{
key: "designSellingPoint",
label: "设计卖点",
defaults: {
...NODE_PERMISSION_BRAND_READONLY,
design: "edit",
},
},
{
key: "projectReview",
label: "立项评审",
defaults: {
...NODE_PERMISSION_BRAND_READONLY,
design: "edit",
planning: "readonly",
purchase: "readonly",
merchandiser: "readonly",
},
},
{
key: "productData",
label: "产品资料",
defaults: {
...NODE_PERMISSION_HIDDEN,
design: "edit",
purchase: "readonly",
pricing: "readonly",
quality: "readonly",
merchandiser: "readonly",
},
downloads: {
...NODE_PERMISSION_DESIGN_DOWNLOADS,
...NODE_PERMISSION_PURCHASE_DOWNLOADS,
},
},
{
key: "review",
label: "样品评审",
defaults: {
...NODE_PERMISSION_HIDDEN,
design: "edit",
brandCenter: "readonly",
planning: "readonly",
space: "readonly",
purchase: "readonly",
pricing: "readonly",
quality: "readonly",
merchandiser: "readonly",
},
downloads: {
...NODE_PERMISSION_DESIGN_DOWNLOADS,
...NODE_PERMISSION_PURCHASE_DOWNLOADS,
pricing: true,
quality: true,
},
},
{
key: "basicInfo",
label: "基础信息",
defaults: {
...NODE_PERMISSION_PLAIN_VISIBLE,
design: "edit",
},
},
{
key: "planningLayout",
label: "空间布局",
defaults: {
...NODE_PERMISSION_BRAND_READONLY,
space: "edit",
},
},
{
key: "patent",
label: "专利申请",
defaults: {
...NODE_PERMISSION_HIDDEN,
design: "edit",
},
},
{
key: "photo",
label: "产品实拍",
defaults: {
...NODE_PERMISSION_BRAND_READONLY,
design: "edit",
operation: "readonly",
viewer: "readonly",
},
downloads: NODE_PERMISSION_DESIGN_DOWNLOADS,
},
{
key: "installation",
label: "安装方式",
defaults: {
...NODE_PERMISSION_BRAND_READONLY,
design: "edit",
operation: "readonly",
viewer: "readonly",
},
},
{
key: "packaging",
label: "包装细节",
defaults: {
...NODE_PERMISSION_HIDDEN,
design: "edit",
},
downloads: NODE_PERMISSION_DESIGN_DOWNLOADS,
},
{
key: "factory",
label: "产品定厂",
defaults: {
...NODE_PERMISSION_SUPPLY_VISIBLE,
purchase: "edit",
},
downloads: {
purchase: true,
},
},
{
key: "measurement",
label: "样包测量",
defaults: {
...NODE_PERMISSION_SUPPLY_VISIBLE,
pricing: "edit",
},
},
{
key: "finishedSampleShipment",
label: "成品寄样",
defaults: {
...NODE_PERMISSION_SUPPLY_VISIBLE,
purchase: "edit",
},
},
{
key: "pricing",
label: "产品核价",
defaults: {
...NODE_PERMISSION_SUPPLY_VISIBLE,
pricing: "edit",
},
},
{
key: "limit",
label: "产品限价",
defaults: {
...NODE_PERMISSION_BRAND_READONLY,
planning: "edit",
operation: "readonly",
},
},
{
key: "quality",
label: "首批质检",
defaults: {
...NODE_PERMISSION_HIDDEN,
design: "readonly",
quality: "edit",
},
},
{
key: "warehouseSampling",
label: "检测报告",
defaults: {
...NODE_PERMISSION_HIDDEN,
design: "readonly",
warehouseQuality: "edit",
quality: "readonly",
operation: "readonly",
viewer: "readonly",
},
},
];
export const ALL_NODE_PERMISSION_TARGETS = [
...TOP_ENTRY_PERMISSION_TARGETS,
...PRODUCT_DEVELOPMENT_PERMISSION_TARGETS,
] satisfies IntakeNodePermissionTarget[];
export const NODE_PERMISSION_STATE_LABEL: Record<
IntakeNodePermissionState,
string
> = {
edit: "可操作",
readonly: "只读不可编辑",
hidden: "隐藏",
};
const DOCUMENT_CATEGORY_TARGET_KEY_MAP: Partial<
Record<string, IntakeNodePermissionTargetKey>
> = {
开品参考: "openingReference",
设计卖点: "designSellingPoint",
立项评审: "projectReview",
产品模型: "productData",
产品资料: "productData",
评审资料: "review",
样品评审: "review",
[NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY]: "basicInfo",
[NON_INTEGRATED_PLANNING_LAYOUT_WORKSPACE_CATEGORY]: "planningLayout",
空间布局: "planningLayout",
专利申请: "patent",
产品实拍图: "photo",
产品实拍: "photo",
安装方式: "installation",
包装细节: "packaging",
产品定厂: "factory",
样包测量: "measurement",
成品寄样: "finishedSampleShipment",
产品核价: "pricing",
产品限价: "limit",
工厂质检: "quality",
首批质检: "quality",
检测报告: "warehouseSampling",
};
function normalizeText(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function normalizeDepartmentPath(value: unknown) {
return normalizeText(value).replace(/\s+/g, "");
}
function getDepartmentPathValues(user: IntakeNonIntegratedPermissionUser) {
return [
user.department?.fullPath,
user.department?.name,
...(user.departmentPaths ?? []),
]
.map(normalizeDepartmentPath)
.filter(Boolean);
}
function hasDepartmentPath(
user: IntakeNonIntegratedPermissionUser,
predicate: (path: string) => boolean,
) {
return getDepartmentPathValues(user).some(predicate);
}
function hasDepartmentScope(user: IntakeNonIntegratedPermissionUser) {
return getDepartmentPathValues(user).length > 0;
}
function hasBusinessRoleFallback(
user: IntakeNonIntegratedPermissionUser,
role: string,
) {
return !hasDepartmentScope(user) && Boolean(user.businessRoles?.includes(role));
}
function isProductResearchDepartmentPath(path: string) {
return /产品研发部/.test(path);
}
function isBrandPlanningDepartmentPath(path: string) {
return /品牌中心/.test(path) && /商品企划部/.test(path);
}
function isSpaceVisualDepartmentPath(path: string) {
return /品牌中心/.test(path) && /空间视觉部/.test(path);
}
function isBrandCenterDepartmentPath(path: string) {
return /品牌中心/.test(path);
}
function isPricingGroupDepartmentPath(path: string) {
return /供应链中心/.test(path) && /核价组/.test(path);
}
function isQualityGroupDepartmentPath(path: string) {
return /供应链中心/.test(path) && /质检组/.test(path);
}
function isMerchandiserDepartmentPath(path: string) {
return /供应链中心/.test(path) && /跟单组/.test(path);
}
function isSupplyChainPurchaseDepartmentPath(path: string) {
return (
/供应链中心/.test(path) &&
/采购部/.test(path) &&
!/核价组|质检组|跟单组/.test(path)
);
}
function isOperationDepartmentPath(path: string) {
return /运营/.test(path);
}
function isWarehouseQualityDepartmentPath(path: string) {
return /仓库质检|仓储质检|质检/.test(path) && /仓|仓储/.test(path);
}
function isWarehouseDepartmentPath(path: string) {
return /仓库|仓储/.test(path);
}
function pushUniqueSubject(
keys: IntakeNodePermissionSubjectKey[],
key: IntakeNodePermissionSubjectKey,
) {
if (!keys.includes(key)) keys.push(key);
}
export function getIntakeNodePermissionTargetByKey(
key: IntakeNodePermissionTargetKey | string,
) {
return ALL_NODE_PERMISSION_TARGETS.find((target) => target.key === key) ?? null;
}
export function getIntakeNodePermissionTargetKeyByWorkspaceCategory(
category: NonIntegratedWorkflowNodeCategory | string | null | undefined,
) {
const normalizedCategory = normalizeText(category);
if (!normalizedCategory) return null;
return DOCUMENT_CATEGORY_TARGET_KEY_MAP[normalizedCategory] ?? null;
}
export function buildIntakeNodePermissionOverrideKey(
targetKey: IntakeNodePermissionTargetKey,
subjectKey: IntakeNodePermissionSubjectKey,
action: IntakeNodePermissionAction,
) {
return `${targetKey}:${subjectKey}:${action}`;
}
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 (!action || !ACTION_SET.has(action)) return null;
return {
targetKey: targetKey as IntakeNodePermissionTargetKey,
subjectKey: subjectKey as IntakeNodePermissionSubjectKey,
action: action as IntakeNodePermissionAction,
};
}
export function sanitizeIntakeNodePermissionOverrides(
value: unknown,
): IntakeNodePermissionOverrideMap {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
const nextOverrides: IntakeNodePermissionOverrideMap = {};
Object.entries(value as Record<string, unknown>).forEach(([key, enabled]) => {
if (enabled !== true) return;
if (!parseIntakeNodePermissionOverrideKey(key)) return;
nextOverrides[key] = true;
});
return nextOverrides;
}
export function resolveIntakeNodePermissionSubjectKeys(
user: IntakeNonIntegratedPermissionUser,
) {
const keys: IntakeNodePermissionSubjectKey[] = [];
if (hasDepartmentPath(user, isProductResearchDepartmentPath)) {
pushUniqueSubject(keys, "design");
}
if (hasDepartmentPath(user, isBrandCenterDepartmentPath)) {
pushUniqueSubject(keys, "brandCenter");
}
if (hasDepartmentPath(user, isBrandPlanningDepartmentPath)) {
pushUniqueSubject(keys, "planning");
}
if (hasDepartmentPath(user, isSpaceVisualDepartmentPath)) {
pushUniqueSubject(keys, "space");
}
if (hasDepartmentPath(user, isPricingGroupDepartmentPath)) {
pushUniqueSubject(keys, "pricing");
}
if (hasDepartmentPath(user, isQualityGroupDepartmentPath)) {
pushUniqueSubject(keys, "quality");
}
if (hasDepartmentPath(user, isMerchandiserDepartmentPath)) {
pushUniqueSubject(keys, "merchandiser");
}
if (hasDepartmentPath(user, isSupplyChainPurchaseDepartmentPath)) {
pushUniqueSubject(keys, "purchase");
}
if (hasDepartmentPath(user, isWarehouseQualityDepartmentPath)) {
pushUniqueSubject(keys, "warehouseQuality");
}
if (hasDepartmentPath(user, isWarehouseDepartmentPath)) {
pushUniqueSubject(keys, "warehouse");
}
if (hasDepartmentPath(user, isOperationDepartmentPath)) {
pushUniqueSubject(keys, "operation");
}
if (hasBusinessRoleFallback(user, "DESIGN")) {
pushUniqueSubject(keys, "design");
}
if (hasBusinessRoleFallback(user, "PLANNING")) {
pushUniqueSubject(keys, "planning");
}
if (hasBusinessRoleFallback(user, "PURCHASING")) {
pushUniqueSubject(keys, "purchase");
}
if (hasBusinessRoleFallback(user, "WAREHOUSE")) {
pushUniqueSubject(keys, "warehouse");
}
if (keys.length === 0) {
keys.push("viewer");
}
return keys;
}
export function getIntakeNodePermissionOverrideState(
overrides: IntakeNodePermissionOverrideMap,
targetKey: IntakeNodePermissionTargetKey | string | null | undefined,
subjectKeys: readonly IntakeNodePermissionSubjectKey[],
) {
if (!targetKey || !TARGET_KEY_SET.has(targetKey)) {
return { view: false, download: false, edit: false };
}
const resolvedTargetKey = targetKey as IntakeNodePermissionTargetKey;
const hasAction = (action: IntakeNodePermissionAction) =>
subjectKeys.some((subjectKey) =>
Boolean(
overrides[
buildIntakeNodePermissionOverrideKey(
resolvedTargetKey,
subjectKey,
action,
)
],
),
);
const edit = hasAction("edit");
const download = hasAction("download");
const view = edit || download || hasAction("view");
return { view, download, edit };
}
export function resolveIntakeNodePermissionAccess(
overrides: IntakeNodePermissionOverrideMap,
targetKey: IntakeNodePermissionTargetKey | string | null | undefined,
subjectKeys: readonly IntakeNodePermissionSubjectKey[],
) {
const target =
targetKey && TARGET_KEY_SET.has(targetKey)
? getIntakeNodePermissionTargetByKey(targetKey)
: null;
const overrideState = getIntakeNodePermissionOverrideState(
overrides,
targetKey,
subjectKeys,
);
if (!target) {
return {
defaultState: "hidden" as IntakeNodePermissionState,
defaultView: false,
defaultDownload: false,
view: overrideState.view,
download: overrideState.download,
edit: overrideState.edit,
override: overrideState,
};
}
const hasDefaultEdit = subjectKeys.some(
(subjectKey) => target.defaults[subjectKey] === "edit",
);
const hasDefaultReadonly = subjectKeys.some(
(subjectKey) => target.defaults[subjectKey] === "readonly",
);
const defaultState: IntakeNodePermissionState = hasDefaultEdit
? "edit"
: hasDefaultReadonly
? "readonly"
: "hidden";
const defaultView = defaultState !== "hidden";
const defaultDownload = subjectKeys.some((subjectKey) =>
Boolean(target.downloads?.[subjectKey]),
);
return {
defaultState,
defaultView,
defaultDownload,
view: defaultView || overrideState.view,
download: defaultDownload || overrideState.download,
edit: defaultState === "edit" || overrideState.edit,
override: overrideState,
};
}
export function resolveIntakeTopEntryAllowedPathPrefixes(
user: IntakeNonIntegratedPermissionUser,
overrides: IntakeNodePermissionOverrideMap,
) {
const subjectKeys = resolveIntakeNodePermissionSubjectKeys(user);
const prefixes = new Set<string>();
TOP_ENTRY_PERMISSION_TARGETS.forEach((target) => {
const access = resolveIntakeNodePermissionAccess(
overrides,
target.key,
subjectKeys,
);
if (!access.view && !access.download && !access.edit) {
return;
}
TOP_ENTRY_PATH_PREFIXES[target.key]?.forEach((prefix) => {
prefixes.add(prefix);
});
});
return Array.from(prefixes);
}

View File

@ -41,6 +41,7 @@ export interface TokenPayload {
departmentId?: string;
teamId?: string;
workspaceAccess?: WorkspaceAccessLevel;
workspaceAllowedPathPrefixes?: string[];
exp?: number; // JWT 过期时间(Unix 时间戳)
iat?: number; // JWT 签发时间(Unix 时间戳)
}

View File

@ -73,7 +73,7 @@ describe('non-integrated intake permissions', () => {
expect(canEditNonIntegratedWorkspace(baseProduct, planningOwner, '开品参考')).toBe(true)
expect(canEditNonIntegratedWorkspace(baseProduct, planningOwner, '产品限价')).toBe(true)
expect(canEditNonIntegratedWorkspace(baseProduct, otherPlanner, '开品参考')).toBe(false)
expect(canEditNonIntegratedWorkspace(baseProduct, brandCampaignUser, '成品寄样')).toBe(true)
expect(canEditNonIntegratedWorkspace(baseProduct, brandCampaignUser, '成品寄样')).toBe(false)
expect(canEditAnyNonIntegratedWorkspace(baseProduct, brandCampaignUser)).toBe(false)
})
@ -93,7 +93,7 @@ describe('non-integrated intake permissions', () => {
expect(canEditNonIntegratedWorkspace(baseProduct, user, '产品限价')).toBe(false)
})
it('allows planning business users to maintain planning layout even when they are not assigned as space owner', () => {
it('does not let unrelated brand planning users maintain planning layout', () => {
const user = {
id: 'planning-id',
name: '希文',
@ -102,8 +102,8 @@ describe('non-integrated intake permissions', () => {
departmentPaths: ['江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部'],
}
expect(canEditNonIntegratedWorkspace(baseProduct, user, '企划布局')).toBe(true)
expect(canEditNonIntegratedWorkspace(baseProduct, user, '空间布局')).toBe(true)
expect(canEditNonIntegratedWorkspace(baseProduct, user, '企划布局')).toBe(false)
expect(canEditNonIntegratedWorkspace(baseProduct, user, '空间布局')).toBe(false)
expect(canEditNonIntegratedWorkspace(baseProduct, user, '样包测量')).toBe(false)
})
@ -139,6 +139,9 @@ describe('non-integrated intake permissions', () => {
}
expect(canEditNonIntegratedWorkspace(baseProduct, purchaser, '产品定厂')).toBe(true)
expect(canEditNonIntegratedWorkspace(baseProduct, purchaser, '样包测量')).toBe(false)
expect(canEditNonIntegratedWorkspace(baseProduct, purchaser, '产品核价')).toBe(false)
expect(canEditNonIntegratedWorkspace(baseProduct, purchaser, '成品寄样')).toBe(true)
expect(canEditNonIntegratedWorkspace(baseProduct, otherPurchaser, '产品定厂')).toBe(false)
expect(canEditNonIntegratedWorkspace(baseProduct, pricingUser, '样包测量')).toBe(true)
expect(canEditNonIntegratedWorkspace(baseProduct, pricingUser, '产品核价')).toBe(true)

View File

@ -83,14 +83,10 @@ const SPACE_OWNER_WORKSPACE_CATEGORIES = new Set<string>([
const PURCHASE_OWNER_WORKSPACE_CATEGORIES = new Set<string>([
PRODUCT_FACTORY_WORKSPACE_CATEGORY,
SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY,
FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY,
PRODUCT_PRICING_WORKSPACE_CATEGORY,
])
const OPEN_LINK_EDIT_WORKSPACE_CATEGORIES = new Set<string>([
FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY,
])
const OPEN_LINK_EDIT_WORKSPACE_CATEGORIES = new Set<string>()
const PRICING_GROUP_WORKSPACE_CATEGORIES = new Set<string>([
SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY,
@ -354,15 +350,6 @@ export function isCurrentUserNonIntegratedSpaceOwner(
)
}
export function isIntakeNonIntegratedPlanningLayoutEditor(
user: IntakeNonIntegratedPermissionUser,
) {
return (
hasDepartmentPath(user, isSpaceVisualDepartmentPath) ||
hasBusinessRoleFallback(user, 'PLANNING')
)
}
export function isCurrentUserNonIntegratedPurchaseOwner(
product: IntakeNonIntegratedPermissionProduct,
user: IntakeNonIntegratedPermissionUser,
@ -403,8 +390,7 @@ export function canEditNonIntegratedWorkspace(
if (
SPACE_OWNER_WORKSPACE_CATEGORIES.has(normalizedCategory) &&
(isCurrentUserNonIntegratedSpaceOwner(product, user) ||
isIntakeNonIntegratedPlanningLayoutEditor(user))
isCurrentUserNonIntegratedSpaceOwner(product, user)
) {
return true
}

View File

@ -4,38 +4,45 @@ import {
hasFullWorkspaceAccess,
} from './workspace-route-permissions'
describe('workspace-route-permissions', () => {
it('管理员拥有完整工作台权限', () => {
describe('workspace route permissions', () => {
it('keeps admins as full workspace users', () => {
expect(hasFullWorkspaceAccess({ role: 'ADMIN', businessRoles: [] })).toBe(true)
expect(getWorkspaceAccessLevel({ role: 'SUPER_ADMIN', businessRoles: [] })).toBe('full')
})
it('产品研发设计拥有完整工作台权限', () => {
const user = {
it('keeps space visual users in formal products only even if their business role is design', () => {
const spaceVisualUser = {
role: 'USER',
businessRoles: ['DESIGN'],
departmentPaths: ['江苏简尚品牌管理有限公司 > 产品研发部 > 基本盘 > 桌类组'],
departmentPaths: [
'江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部',
],
}
expect(hasFullWorkspaceAccess(user)).toBe(true)
expect(canAccessWorkspacePath('/asset-center', user)).toBe(true)
expect(hasFullWorkspaceAccess(spaceVisualUser)).toBe(false)
expect(getWorkspaceAccessLevel(spaceVisualUser)).toBe('formal-products-only')
expect(canAccessWorkspacePath('/', spaceVisualUser)).toBe(false)
expect(canAccessWorkspacePath('/asset-center', spaceVisualUser)).toBe(false)
expect(canAccessWorkspacePath('/assisted-assembly', spaceVisualUser)).toBe(false)
expect(canAccessWorkspacePath('/intake', spaceVisualUser)).toBe(true)
})
it('空间视觉设计只保留正式产品工作区入口', () => {
const user = {
it('keeps product research designers as full workspace users', () => {
const productDesigner = {
role: 'USER',
businessRoles: ['DESIGN'],
departmentPaths: ['江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部'],
departmentPaths: [
'江苏简尚品牌管理有限公司 > 产品研发部 > 外观设计组',
],
}
expect(hasFullWorkspaceAccess(user)).toBe(false)
expect(getWorkspaceAccessLevel(user)).toBe('formal-products-only')
expect(canAccessWorkspacePath('/intake', user)).toBe(true)
expect(canAccessWorkspacePath('/asset-center', user)).toBe(false)
expect(canAccessWorkspacePath('/assisted-assembly', user)).toBe(false)
expect(hasFullWorkspaceAccess(productDesigner)).toBe(true)
expect(getWorkspaceAccessLevel(productDesigner)).toBe('full')
expect(canAccessWorkspacePath('/', productDesigner)).toBe(true)
expect(canAccessWorkspacePath('/asset-center', productDesigner)).toBe(true)
})
it('供应链和普通用户不能进入正式产品以外的工作台', () => {
it('keeps supply chain users outside non-formal workspaces unless explicitly opened', () => {
const purchasingUser = {
role: 'USER',
businessRoles: ['PURCHASING'],
@ -46,4 +53,16 @@ describe('workspace-route-permissions', () => {
expect(canAccessWorkspacePath('/intake/non-integrated/product-1', purchasingUser)).toBe(true)
expect(canAccessWorkspacePath('/warehouse', purchasingUser)).toBe(false)
})
it('allows admin-opened workspace prefixes for restricted users', () => {
const purchasingUser = {
role: 'USER',
businessRoles: ['PURCHASING'],
departmentPaths: ['江苏简尚品牌管理有限公司 > 供应链中心 > 基本盘 采购部'],
workspaceAllowedPathPrefixes: ['/warehouse'],
}
expect(canAccessWorkspacePath('/warehouse', purchasingUser)).toBe(true)
expect(canAccessWorkspacePath('/warehouse/receiving', purchasingUser)).toBe(true)
})
})

View File

@ -9,6 +9,7 @@ export interface WorkspacePermissionUser {
businessRoles?: readonly string[] | null
department?: { name?: string | null; fullPath?: string | null } | null
departmentPaths?: readonly string[] | null
workspaceAllowedPathPrefixes?: readonly string[] | null
}
const RESTRICTED_ALLOWED_PATH_PREFIXES = [
@ -34,19 +35,37 @@ export function isRestrictedWorkspacePathAllowed(pathname: string) {
)
}
function isPathAllowedByPrefixes(
pathname: string,
prefixes: readonly string[] | null | undefined,
) {
return Boolean(
prefixes?.some((prefix) =>
prefix === '/'
? pathname === '/'
: pathname === prefix || pathname.startsWith(`${prefix}/`),
),
)
}
export function canAccessWorkspacePath(
pathname: string,
user: WorkspacePermissionUser | null | undefined,
) {
if (getWorkspaceAccessLevel(user) === 'full') return true
if (isPathAllowedByPrefixes(pathname, user?.workspaceAllowedPathPrefixes)) {
return true
}
return isRestrictedWorkspacePathAllowed(pathname)
}
export function canAccessWorkspacePathByAccessLevel(
pathname: string,
accessLevel: WorkspaceAccessLevel | null | undefined,
allowedPathPrefixes?: readonly string[] | null,
) {
if (!accessLevel || accessLevel === 'full') return true
if (isPathAllowedByPrefixes(pathname, allowedPathPrefixes)) return true
return isRestrictedWorkspacePathAllowed(pathname)
}

View File

@ -45,6 +45,7 @@ export function isPublicPath(pathname: string): boolean {
interface WorkspaceTokenPayload {
exp?: number
workspaceAccess?: WorkspaceAccessLevel
workspaceAllowedPathPrefixes?: string[]
}
export async function proxy(request: NextRequest) {
@ -69,7 +70,13 @@ export async function proxy(request: NextRequest) {
return response
}
if (!canAccessWorkspacePathByAccessLevel(pathname, payload.workspaceAccess)) {
if (
!canAccessWorkspacePathByAccessLevel(
pathname,
payload.workspaceAccess,
payload.workspaceAllowedPathPrefixes,
)
) {
return NextResponse.redirect(new URL(FORMAL_PRODUCTS_HOME_PATH, request.url))
}

View File

@ -19,6 +19,8 @@ import {
getWorkspaceAccessLevel,
type WorkspaceAccessLevel,
} from '@/lib/workspace-route-permissions'
import { resolveIntakeTopEntryAllowedPathPrefixes } from '@/lib/intake-node-permission-settings'
import { intakeNodePermissionSettingsService } from './intake-node-permission-settings.service'
// ============ 类型定义 ============
@ -51,6 +53,8 @@ export interface FirstLoginUpdateResult {
departmentId: string | null
teamId: string | null
isFirstLogin: false
workspaceAccess?: WorkspaceAccessLevel
workspaceAllowedPathPrefixes?: string[]
}
}
@ -84,6 +88,8 @@ export interface AuthUserDTO {
managedTeam: { id: string; name: string } | null
avatar: string | null
lastLoginAt: Date | null
workspaceAccess?: WorkspaceAccessLevel
workspaceAllowedPathPrefixes?: string[]
}
export interface TokenPayload {
@ -96,6 +102,7 @@ export interface TokenPayload {
departmentId?: string
teamId?: string
workspaceAccess?: WorkspaceAccessLevel
workspaceAllowedPathPrefixes?: string[]
}
// ============ 辅助函数 ============
@ -107,6 +114,29 @@ function isEmail(identifier: string): boolean {
return EMAIL_REGEX.test(identifier)
}
type WorkspaceTokenPermissionUser = {
role?: string | null
businessRoles?: readonly string[] | null
department?: { name?: string | null; fullPath?: string | null } | null
departmentPaths?: readonly string[] | null
}
async function buildWorkspaceTokenPermissions(user: WorkspaceTokenPermissionUser) {
const workspaceAccess = getWorkspaceAccessLevel(user)
if (workspaceAccess === 'full') {
return { workspaceAccess }
}
const { overrides } = await intakeNodePermissionSettingsService.getSettings()
return {
workspaceAccess,
workspaceAllowedPathPrefixes: resolveIntakeTopEntryAllowedPathPrefixes(
user,
overrides,
),
}
}
// ============ 服务类 ============
class AuthService {
@ -234,6 +264,11 @@ class AuthService {
})
const departmentPaths = await getUserDepartmentPathsById(updatedUser.id)
const workspacePermissions = await buildWorkspaceTokenPermissions({
role: updatedUser.role,
businessRoles: updatedUser.businessRoles,
departmentPaths,
})
const token = generateToken({
userId: updatedUser.id,
email: updatedUser.email,
@ -243,11 +278,7 @@ class AuthService {
isFirstLogin: false,
departmentId: updatedUser.departmentId || undefined,
teamId: updatedUser.teamId || undefined,
workspaceAccess: getWorkspaceAccessLevel({
role: updatedUser.role,
businessRoles: updatedUser.businessRoles,
departmentPaths,
}),
...workspacePermissions,
})
return {
@ -262,6 +293,7 @@ class AuthService {
departmentId: updatedUser.departmentId,
teamId: updatedUser.teamId,
isFirstLogin: false,
...workspacePermissions,
},
}
}
@ -312,6 +344,12 @@ class AuthService {
}
const departmentPaths = await getUserDepartmentPathsById(user.id)
const workspacePermissions = await buildWorkspaceTokenPermissions({
role: user.role,
businessRoles: user.businessRoles,
department: user.department,
departmentPaths,
})
return {
authenticated: true,
@ -333,6 +371,7 @@ class AuthService {
managedTeam: user.managedTeam,
avatar: user.avatar,
lastLoginAt: user.lastLoginAt,
...workspacePermissions,
},
}
}
@ -368,6 +407,11 @@ class AuthService {
}
const departmentPaths = await getUserDepartmentPathsById(user.id)
const workspacePermissions = await buildWorkspaceTokenPermissions({
role: user.role,
businessRoles: user.businessRoles,
departmentPaths,
})
return generateToken({
userId: user.id,
@ -378,11 +422,7 @@ class AuthService {
isFirstLogin: user.isFirstLogin,
departmentId: user.departmentId || undefined,
teamId: user.teamId || undefined,
workspaceAccess: getWorkspaceAccessLevel({
role: user.role,
businessRoles: user.businessRoles,
departmentPaths,
}),
...workspacePermissions,
})
}
@ -419,6 +459,12 @@ class AuthService {
private async toLoginResult(user: AuthUserWithRelations): Promise<LoginResult> {
const departmentPaths = await getUserDepartmentPathsById(user.id)
const workspacePermissions = await buildWorkspaceTokenPermissions({
role: user.role,
businessRoles: user.businessRoles,
department: user.department,
departmentPaths,
})
const token = generateToken({
userId: user.id,
email: user.email,
@ -428,12 +474,7 @@ class AuthService {
isFirstLogin: user.isFirstLogin,
departmentId: user.departmentId || undefined,
teamId: user.teamId || undefined,
workspaceAccess: getWorkspaceAccessLevel({
role: user.role,
businessRoles: user.businessRoles,
department: user.department,
departmentPaths,
}),
...workspacePermissions,
})
return {
@ -456,6 +497,7 @@ class AuthService {
managedTeam: user.managedTeam,
avatar: user.avatar,
lastLoginAt: user.lastLoginAt,
...workspacePermissions,
},
isFirstLogin: user.isFirstLogin,
}

View File

@ -0,0 +1,168 @@
import { randomUUID } from "crypto";
import { prisma } from "@/lib/prisma";
import {
parseIntakeNodePermissionOverrideKey,
sanitizeIntakeNodePermissionOverrides,
type IntakeNodePermissionOverrideMap,
} from "@/lib/intake-node-permission-settings";
import { AppError, ErrorCode } from "@/server/core/errors";
import type { AuthUser } from "@/server/core/types";
type IntakeNodePermissionOverrideRow = {
targetKey: string;
subjectKey: string;
action: string;
};
const ADMIN_ROLES = new Set(["ADMIN", "SUPER_ADMIN"]);
const INTAKE_NODE_PERMISSION_SETTINGS_LOCK_KEY = 2026052311;
function assertCanManageSettings(user: AuthUser) {
if (ADMIN_ROLES.has(user.role)) return;
throw new AppError(
ErrorCode.AUTH_PERMISSION_DENIED,
"只有管理员可以维护正式产品节点权限",
);
}
function buildOverrideKey(row: IntakeNodePermissionOverrideRow) {
return `${row.targetKey}:${row.subjectKey}:${row.action}`;
}
class IntakeNodePermissionSettingsService {
private ensureTablePromise: Promise<void> | null = null;
private async ensureTable() {
if (!this.ensureTablePromise) {
this.ensureTablePromise = (async () => {
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "IntakeNodePermissionOverride" (
"id" TEXT NOT NULL,
"targetKey" TEXT NOT NULL,
"subjectKey" TEXT NOT NULL,
"action" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdBy" TEXT,
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IntakeNodePermissionOverride_pkey" PRIMARY KEY ("id")
)
`);
await prisma.$executeRawUnsafe(`
CREATE UNIQUE INDEX IF NOT EXISTS "IntakeNodePermissionOverride_targetKey_subjectKey_action_key"
ON "IntakeNodePermissionOverride"("targetKey", "subjectKey", "action")
`);
await prisma.$executeRawUnsafe(`
CREATE INDEX IF NOT EXISTS "IntakeNodePermissionOverride_targetKey_idx"
ON "IntakeNodePermissionOverride"("targetKey")
`);
await prisma.$executeRawUnsafe(`
CREATE INDEX IF NOT EXISTS "IntakeNodePermissionOverride_subjectKey_idx"
ON "IntakeNodePermissionOverride"("subjectKey")
`);
})();
}
await this.ensureTablePromise;
}
async getSettings() {
await this.ensureTable();
const rows = await prisma.$queryRaw<IntakeNodePermissionOverrideRow[]>`
SELECT "targetKey", "subjectKey", "action"
FROM "IntakeNodePermissionOverride"
WHERE "enabled" = true
ORDER BY "targetKey" ASC, "subjectKey" ASC, "action" ASC
`;
const overrides: IntakeNodePermissionOverrideMap = {};
rows.forEach((row) => {
const key = buildOverrideKey(row);
if (parseIntakeNodePermissionOverrideKey(key)) {
overrides[key] = true;
}
});
return { overrides };
}
async updateSettings(input: unknown, user: AuthUser) {
assertCanManageSettings(user);
await this.ensureTable();
const inputRecord =
input && typeof input === "object" && !Array.isArray(input)
? (input as { overrides?: unknown })
: {};
const overrides = sanitizeIntakeNodePermissionOverrides(
inputRecord.overrides,
);
const entries = Array.from(
new Map(
Object.keys(overrides)
.map(parseIntakeNodePermissionOverrideKey)
.filter(
(
entry,
): entry is NonNullable<
ReturnType<typeof parseIntakeNodePermissionOverrideKey>
> => Boolean(entry),
)
.map((entry) => [
`${entry.targetKey}:${entry.subjectKey}:${entry.action}`,
entry,
]),
).values(),
);
await prisma.$transaction(async (tx) => {
await tx.$queryRaw`
SELECT pg_advisory_xact_lock(${INTAKE_NODE_PERMISSION_SETTINGS_LOCK_KEY})
`;
await tx.$executeRaw`
DELETE FROM "IntakeNodePermissionOverride"
`;
for (const entry of entries) {
const id = randomUUID();
await tx.$executeRaw`
INSERT INTO "IntakeNodePermissionOverride" (
"id",
"targetKey",
"subjectKey",
"action",
"enabled",
"createdBy",
"updatedBy",
"createdAt",
"updatedAt"
)
VALUES (
${id},
${entry.targetKey},
${entry.subjectKey},
${entry.action},
true,
${user.id},
${user.id},
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
ON CONFLICT ("targetKey", "subjectKey", "action")
DO UPDATE SET
"enabled" = true,
"updatedBy" = ${user.id},
"updatedAt" = CURRENT_TIMESTAMP
`;
}
});
return { overrides };
}
}
export const intakeNodePermissionSettingsService =
new IntakeNodePermissionSettingsService();

View File

@ -590,7 +590,7 @@ function getChangedWorkspaceCategories(
return categories
}
function assertCanWriteProductChanges(
async function assertCanWriteProductChanges(
record: IntakeNonIntegratedRecord,
user: AuthUser,
nextProduct: SharedNonIntegratedProductRecord,
@ -603,20 +603,20 @@ function assertCanWriteProductChanges(
})
if (!existingProduct) {
intakeNonIntegratedScopeService.assertCanWriteRecord(record, user)
await intakeNonIntegratedScopeService.assertCanWriteRecord(record, user)
return
}
const changedCategories = getChangedWorkspaceCategories(existingProduct, nextProduct)
if (changedCategories.size === 0) {
intakeNonIntegratedScopeService.assertCanWriteRecord(record, user)
await intakeNonIntegratedScopeService.assertCanWriteRecord(record, user)
return
}
changedCategories.forEach((category) => {
intakeNonIntegratedScopeService.assertCanWriteRecord(record, user, category)
})
for (const category of changedCategories) {
await intakeNonIntegratedScopeService.assertCanWriteRecord(record, user, category)
}
}
function normalizePresentationPreview(
@ -1541,7 +1541,7 @@ class IntakeNonIntegratedProductService {
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '非集成产品不存在')
}
intakeNonIntegratedScopeService.assertCanWriteRecord(existingRecord, user, category)
await intakeNonIntegratedScopeService.assertCanWriteRecord(existingRecord, user, category)
}
async save(productInput: unknown, user: AuthUser) {
@ -1608,7 +1608,7 @@ class IntakeNonIntegratedProductService {
try {
if (existingRecord) {
assertCanWriteProductChanges(existingRecord, user, persistedProduct)
await assertCanWriteProductChanges(existingRecord, user, persistedProduct)
await this.repository.update(existingRecord.id, {
spu: normalizedSpu,
@ -1703,7 +1703,7 @@ class IntakeNonIntegratedProductService {
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '非集成产品不存在')
}
intakeNonIntegratedScopeService.assertCanWriteRecord(existingRecord, user, input.category)
await intakeNonIntegratedScopeService.assertCanWriteRecord(existingRecord, user, input.category)
const payload = buildHydratedProductRecord(existingRecord.payload, {
createdAt: existingRecord.createdAt,
@ -1824,7 +1824,7 @@ class IntakeNonIntegratedProductService {
user: AuthUser,
) {
const { record, product, file } = await this.resolveUploadFileForPreview(productId, input, user)
intakeNonIntegratedScopeService.assertCanWriteRecord(record, user, file.category)
await intakeNonIntegratedScopeService.assertCanWriteRecord(record, user, file.category)
const nextUploadFiles = product.uploadFiles.map((file) =>
(input.fileId ? file.id === input.fileId : file.dataUrl === input.fileUrl)

View File

@ -8,6 +8,12 @@ import {
canEditNonIntegratedWorkspace,
type IntakeNonIntegratedPermissionProduct,
} from '@/lib/non-integrated-permissions'
import {
getIntakeNodePermissionOverrideState,
getIntakeNodePermissionTargetKeyByWorkspaceCategory,
resolveIntakeNodePermissionSubjectKeys,
} from '@/lib/intake-node-permission-settings'
import { intakeNodePermissionSettingsService } from './intake-node-permission-settings.service'
type IntakeNonIntegratedPermissionTarget = Pick<
IntakeNonIntegratedProduct,
@ -156,12 +162,37 @@ class IntakeNonIntegratedScopeService {
)
}
assertCanWriteRecord(
async canWriteWorkspaceRecordWithSettings(
record: IntakeNonIntegratedPermissionTarget,
user: AuthUser,
category?: string | null,
): void {
): Promise<boolean> {
if (this.canWriteWorkspaceRecord(record, user, category)) {
return true
}
const targetKey = getIntakeNodePermissionTargetKeyByWorkspaceCategory(category)
if (!targetKey) {
return false
}
const { overrides } = await intakeNodePermissionSettingsService.getSettings()
const subjectKeys = resolveIntakeNodePermissionSubjectKeys(user)
const overrideState = getIntakeNodePermissionOverrideState(
overrides,
targetKey,
subjectKeys,
)
return overrideState.edit
}
async assertCanWriteRecord(
record: IntakeNonIntegratedPermissionTarget,
user: AuthUser,
category?: string | null,
): Promise<void> {
if (await this.canWriteWorkspaceRecordWithSettings(record, user, category)) {
return
}