diff --git a/.impeccable/design.json b/.impeccable/design.json index 7643b97..517b917 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -460,11 +460,17 @@ "type": "navigation", "description": "Department Shell 内的 232px 单项二级栏与真实公司组织架构工作面;不渲染数据来源分组或折叠层。", "projection": "
组织架构

沿用真实人员层级、归属明细与管理员权限。

" + }, + { + "name": "Standalone Material Library Shell", + "type": "navigation", + "description": "品牌中心范围外普通用户使用的中性素材库顶栏;只保留页面定位和退出登录,不提供品牌中心入口。", + "projection": "
素材资产库
" } ], "narrative": { "northStar": "深色导航轨数据工作台", - "overview": "品牌中心是一套固定导航、快速切换与逐层下钻的内部运营界面。深海军蓝导航轨提供稳定坐标,白色操作顶栏承载全局控制,冷灰画布上以矩形白色工作面板组织真实数据、示例状态与待接入框架。\n\n它保留品牌中心自己的五个职责域、22 个小项入口,以及素材库、自动化工作流、人员架构与数据口径四项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。", + "overview": "品牌中心是一套固定导航、快速切换与逐层下钻的内部运营界面。深海军蓝导航轨提供稳定坐标,白色操作顶栏承载全局控制,冷灰画布上以矩形白色工作面板组织真实数据、示例状态与待接入框架。\n\n它保留品牌中心自己的五个职责域、22 个小项入口,以及素材库、自动化工作流、人员架构与数据口径四项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。品牌中心架构外的普通用户只进入素材资产库,使用无侧栏、无返回品牌中心入口的独立工作壳。", "keyCharacteristics": [ "272px 固定深色导航轨,桌面可折叠为 80px。", "72px 白色顶栏与 `#f2f4f7` 冷灰画布组成稳定后台骨架;账户入口固定在侧栏底部。", @@ -473,7 +479,8 @@ "移动端导航是完整的模态抽屉;内容状态始终诚实标注。", "自动化工作流在 Department Shell 内使用 232px 二级业务栏与同源真实操作面,移动端改为顶部两列菜单。", "数据口径在 Department Shell 内使用 232px 二级栏直接显示“公司组织架构”,右侧复用真实组织树和人员明细,不增加折叠层。", - "人员架构在 Department Shell 内直接复用真实组织工作面,并将组织树根节点限定为“品牌中心”。" + "人员架构在 Department Shell 内直接复用真实组织工作面,并将组织树根节点限定为“品牌中心”。", + "素材资产库按权威组织归属选择外壳:品牌中心成员保留 Department Shell,外部普通用户只显示 64px 中性顶栏、素材工作面与退出入口。" ], "rules": [ { @@ -516,6 +523,11 @@ "body": "嵌入业务的根路径返回必须跳出 iframe 到顶层浏览上下文,禁止在操作面内再次嵌入品牌中心首页。", "section": "layout" }, + { + "name": "The Organization Gate Rule", + "body": "品牌中心归属由权威人员绑定与精确组织路径决定;范围外普通用户只能进入素材资产库,品牌中心、组织、管理、考核与过稿工作面均须服务端拦截,视觉隐藏与路由门禁必须同时成立。", + "section": "layout" + }, { "name": "The Border-First Rule", "body": "默认先用 `#e2e8f0` 细边框建立结构;不要用超过 20px 的主表面圆角与大阴影制造漂浮岛。", @@ -523,14 +535,15 @@ } ], "dos": [ - "**Do** 让根路径直接进入核心指标,并在框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell。", + "**Do** 让品牌中心成员从根路径直接进入核心指标,并在框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell;范围外普通用户在素材库使用独立工作壳。", "**Do** 保持 5 个职责域、22 个小项、18 个框架入口,以及素材库、自动化工作流、人员架构和数据口径四项已接入业务的产品真相。", "**Do** 明确区分真实数据、示例值、公式待接入与空数据。", "**Do** 在桌面与 390px 移动宽度验证导航、焦点和横向溢出。", "**Do** 让自动化工作流的菜单、`?workflow=` 和右侧真实业务操作面始终同步。", "**Do** 让嵌入业务的根路径返回以 `target=\"_top\"` 跳出 iframe。", "**Do** 让数据口径直接显示“公司组织架构”并沿用主线管理员权限。", - "**Do** 让人员架构只显示“品牌中心”节点、下级组织与对应人员,并沿用主线管理员权限。" + "**Do** 让人员架构只显示“品牌中心”节点、下级组织与对应人员,并沿用主线管理员权限。", + "**Do** 让品牌中心归属声明、服务端重定向和素材库外壳保持一致,并在桌面与移动端验证没有返回品牌中心入口。" ], "donts": [ "**Don't** 恢复旧版漂浮岛视觉、门户式白色英雄卡或超过 20px 的主表面圆角。", @@ -542,7 +555,8 @@ "**Don't** 用入口卡片、复制页面或假数据替代 `/performance-survey` 与 `/work-review` 的真实操作面。", "**Don't** 让 `href=\"/\"` 在 iframe 内打开并嵌套品牌中心首页。", "**Don't** 在数据口径二级栏增加“数据来源”标题、折叠层或占位说明卡。", - "**Don't** 让人员架构回退到公司全量组织树,或复制一套脱离主线数据源的人员列表。" + "**Don't** 让人员架构回退到公司全量组织树,或复制一套脱离主线数据源的人员列表。", + "**Don't** 仅隐藏品牌中心侧栏却继续允许范围外用户通过 `/` 或 `/workspaces/*` 直接进入工作台。" ] } } diff --git a/DESIGN.md b/DESIGN.md index 8c949f4..3758db1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -119,7 +119,7 @@ components: 品牌中心是一套固定导航、快速切换与逐层下钻的内部运营界面。深海军蓝导航轨提供稳定坐标,白色操作顶栏承载全局控制,冷灰画布上以矩形白色工作面板组织真实数据、示例状态与待接入框架。 -它保留品牌中心自己的五个职责域、22 个小项入口,以及素材库、自动化工作流、人员架构与数据口径四项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。 +它保留品牌中心自己的五个职责域、22 个小项入口,以及素材库、自动化工作流、人员架构与数据口径四项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。品牌中心架构外的普通用户只进入素材资产库,使用无侧栏、无返回品牌中心入口的独立工作壳。 **Key Characteristics:** @@ -131,6 +131,7 @@ components: - 自动化工作流在 Department Shell 内使用 232px 二级业务栏与同源真实操作面,移动端改为顶部两列菜单。 - 数据口径在 Department Shell 内使用 232px 二级栏直接显示“公司组织架构”,右侧复用真实组织树和人员明细,不增加折叠层。 - 人员架构在 Department Shell 内直接复用真实组织工作面,并将组织树根节点限定为“品牌中心”。 +- 素材资产库按权威组织归属选择外壳:品牌中心成员保留 Department Shell,外部普通用户只显示 64px 中性顶栏、素材工作面与退出入口。 ## Colors @@ -188,6 +189,8 @@ components: 桌面端采用固定侧栏工作台:导航轨宽 272px,可折叠为 80px;右侧顶栏高 72px;主内容最大宽度 1600px,使用 16/24/32px 响应式内边距。访问根路径时直接进入核心指标;其余职责域及小项全部通过固定侧栏访问,不额外渲染部门总览工作地图。 +品牌中心工作台访问范围以活跃账号绑定的权威 HR 组织路径为准。系统管理员保留维护通行能力;其余人员只有任一在职组织路径包含精确“品牌中心”节点时才能进入 `/` 与 `/workspaces/*`。范围外人员访问这些地址时服务端跳转至 `/assets/material-library`,素材库仅渲染中性独立顶栏、素材内容和退出登录,不显示品牌中心标识、侧栏、移动抽屉或任何返回入口。 + 1024px 以下隐藏固定侧栏,以左侧模态抽屉替代。抽屉宽度为 `min(86vw, 304px)`,打开时锁定页面滚动、底层内容 `inert`、焦点进入并约束在导航内,支持 Escape 关闭并恢复触发器焦点。390px 页面不得产生全局横向溢出。 自动化工作流在 Department Shell 内容区内再分两层:桌面左侧为 232px 白 / 雾灰二级栏,右侧为 `minmax(0, 1fr)` 的同源实际业务操作面;移动端二级栏移动到内容顶部,以两列菜单呈现。二级栏永久不渲染“自动化工作流”标题、接入数量或独立统计区,只显示“绩效考核调研”“作品过稿看板”两个菜单。`?workflow=performance-survey|work-review` 持久化当前选择并与嵌入内容同步。 @@ -206,6 +209,8 @@ components: **The Top-Level Return Rule.** 嵌入业务的根路径返回必须跳出 iframe 到顶层浏览上下文,禁止在操作面内再次嵌入品牌中心首页。 +**The Organization Gate Rule.** 品牌中心归属由权威人员绑定与精确组织路径决定;范围外普通用户只能进入素材资产库,品牌中心、组织、管理、考核与过稿工作面均须服务端拦截,视觉隐藏与路由门禁必须同时成立。 + ## Elevation & Depth 系统以边框和底色分层为主,阴影只提供轻微离地感。顶栏与静态数据面板无需强阴影;首页职责域、指标组和悬浮菜单可使用低强度环境阴影。 @@ -258,6 +263,7 @@ components: - 大职责域按钮只展开/收起;小项链接承担导航,并以 `aria-current="page"` 标识当前位置。 - “人员架构”进入 `/workspaces/organization/people-structure`,在 Department Shell 内复用真实组织工作面并聚焦“品牌中心”;公司级 `/organization` 继续保留全量视图。 - “数据口径”使用单项二级栏直接显示“公司组织架构”;有管理员权限时复用真实组织工作面,无权限时显示明确权限提示。 +- 素材资产库根据会话中的品牌中心访问声明选择外壳;范围外普通用户的独立顶栏只显示“素材资产库”和“退出登录”,不得出现品牌中心链接。 - 移动抽屉使用 `role="dialog"`、`aria-modal="true"` 和完整键盘焦点管理。 ### Automation Workflow Split Workbench @@ -280,11 +286,18 @@ components: 所有子页面从页面标题直接进入核心内容,不渲染独立的“数据接入说明”“数据来源说明”或“计算说明”面板。真实读取、示例值、待接入、读取失败和公式状态由对应卡片或数据模块自身承担;实际业务操作帮助不属于此类,仍应保留在需要的位置。 +### Standalone Material Library Shell + +- **Audience:** 品牌中心权威组织范围外的普通用户。 +- **Structure:** 64px 白色中性顶栏 + 完整素材资产工作面;不渲染 Department Shell、侧栏、品牌中心副标题或返回图标。 +- **Actions:** 顶栏只提供素材资产库定位和退出登录;素材上传、筛选、分享与下载能力仍由原业务权限控制。 +- **Security:** `/` 与 `/workspaces/*` 同时经过会话声明、Middleware 和服务端 Layout 门禁,禁止仅靠 CSS 或条件隐藏导航。 + ## Do's and Don'ts ### Do: -- **Do** 让根路径直接进入核心指标,并在框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell。 +- **Do** 让品牌中心成员从根路径直接进入核心指标,并在框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell;范围外普通用户在素材库使用独立工作壳。 - **Do** 保持 5 个职责域、22 个小项、18 个框架入口,以及素材库、自动化工作流、人员架构和数据口径四项已接入业务的产品真相。 - **Do** 明确区分真实数据、示例值、公式待接入与空数据。 - **Do** 在桌面与 390px 移动宽度验证导航、焦点和横向溢出。 @@ -292,6 +305,7 @@ components: - **Do** 让嵌入业务的根路径返回以 `target="_top"` 跳出 iframe。 - **Do** 让数据口径直接显示“公司组织架构”并沿用主线管理员权限。 - **Do** 让人员架构只显示“品牌中心”节点、下级组织与对应人员,并沿用主线管理员权限。 +- **Do** 让品牌中心归属声明、服务端重定向和素材库外壳保持一致,并在桌面与移动端验证没有返回品牌中心入口。 ### Don't: @@ -305,3 +319,4 @@ components: - **Don't** 让 `href="/"` 在 iframe 内打开并嵌套品牌中心首页。 - **Don't** 在数据口径二级栏增加“数据来源”标题、折叠层或占位说明卡。 - **Don't** 让人员架构回退到公司全量组织树,或复制一套脱离主线数据源的人员列表。 +- **Don't** 仅隐藏品牌中心侧栏却继续允许范围外用户通过 `/` 或 `/workspaces/*` 直接进入工作台。 diff --git a/PRODUCT.md b/PRODUCT.md index ad86f25..19a18e9 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -8,7 +8,7 @@ web ## Users -品牌中心内部成员、部门负责人和管理者。他们需要从统一入口定位业务、资产、组织与治理相关工作,并按自身权限进入具体工具。 +品牌中心内部成员、部门负责人和管理者需要从统一入口定位业务、资产、组织与治理相关工作;品牌中心架构以外的人员只使用素材资产库,并按自身权限浏览、上传、分享或下载素材,品牌中心工作台、组织、管理、考核与过稿工作面统一重定向回素材库。 ## Product Purpose @@ -24,6 +24,7 @@ web - “由壹点3D素材”作为“资产与AI自动化”下的二级页面保留完整现有能力。 - “自动化工作流”作为“资产与AI自动化”下的业务入口,集中链接现有的绩效考核调研与作品过稿看板。 - “人员架构”在部门工作台内复用主线 `/organization` 的真实组织树、人员明细与原权限设置,并只聚焦“品牌中心”节点及其下级人员;公司级 `/organization` 全量视图保持不变。 +- 品牌中心工作台访问范围以权威人员绑定和 HR 组织路径为准;品牌中心以外的普通用户登录后只进入 `/assets/material-library`,不显示品牌中心侧栏、返回入口,也不能直接访问 `/` 或 `/workspaces/*`。系统管理员保留维护通行能力。 - “数据口径”使用独立二级栏直接进入“公司组织架构”,在品牌中心外壳内复用 `/organization` 的真实组织树、人员明细与管理员权限。 - 除素材资产库、自动化工作流、人员架构、数据口径和已接入的中心指标外,其余板块现阶段只展示可理解、可扩展的模块框架,不虚构业务数据。 - 沿用项目现有 Next.js、NextAuth、Tailwind CSS 和权限体系。 @@ -45,6 +46,7 @@ web - 先让入口与职责边界清楚,再接入数据。 - 核心指标作为默认工作面,固定侧栏负责其它能力导航,具体页面负责完成任务。 - 保持线上业务稳定,本地验证充分后再由用户决定是否提交和部署。 +- 组织归属门禁必须服务端收紧,隐藏导航不能替代路由权限校验。 - 对不可用能力诚实标注状态,不用假数据制造完成感。 ## Accessibility & Inclusion diff --git a/app/assets/material-library/layout.tsx b/app/assets/material-library/layout.tsx index 7fa9c52..d6fbf2e 100644 --- a/app/assets/material-library/layout.tsx +++ b/app/assets/material-library/layout.tsx @@ -1,8 +1,8 @@ import type { Metadata } from 'next' export const metadata: Metadata = { - title: '由壹点3D素材 | 品牌中心部门看板', - description: '品牌中心资产与AI自动化素材库', + title: '素材资产库 | 由壹点3D素材', + description: '浏览、上传、分享与下载素材资产', } export default function MaterialLibraryLayout({ diff --git a/app/assets/material-library/page.tsx b/app/assets/material-library/page.tsx index 424c608..5685e86 100644 --- a/app/assets/material-library/page.tsx +++ b/app/assets/material-library/page.tsx @@ -6,7 +6,7 @@ import { useRouter, usePathname } from 'next/navigation' import Link from 'next/link' import UploadModal from '@/components/UploadModal' import RankingPanel from '@/components/RankingPanel' -import DepartmentShell from '@/components/department-dashboard/DepartmentShell' +import MaterialLibraryAccessShell from '@/components/material-library/MaterialLibraryAccessShell' import AIChatWidget from '@/components/AIChatWidget' import VideoPlayerModal from '@/components/VideoPlayerModal' import ToastBubble from '@/components/ToastBubble' @@ -1331,8 +1331,10 @@ export default function MaterialLibraryPage() { return null } + const canAccessBrandCenter = session.user.canAccessBrandCenter === true + return ( - +
{/* 收藏操作提示气泡 */} @@ -1344,7 +1346,7 @@ export default function MaterialLibraryPage() { {/* 内网切换提示已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转 */} - +
{/* Header Section */} @@ -1634,6 +1636,6 @@ export default function MaterialLibraryPage() { {/* AI聊天助手 */}
-
+ ) } diff --git a/app/page.tsx b/app/page.tsx index d1c1723..a76d899 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,11 +1,16 @@ import type { Metadata } from 'next' +import { getServerSession } from 'next-auth' import { redirect } from 'next/navigation' +import { authOptions } from '@/lib/auth' export const metadata: Metadata = { title: '核心指标 | 品牌中心部门看板', description: '品牌中心项目进度、交付质量、团队负荷与风险状态', } -export default function BrandCenterDashboardPage() { +export default async function BrandCenterDashboardPage() { + const session = await getServerSession(authOptions) + if (!session?.user) redirect('/login') + if (session.user.canAccessBrandCenter !== true) redirect('/assets/material-library') redirect('/workspaces/dashboard/core-metrics') } diff --git a/app/workspaces/layout.tsx b/app/workspaces/layout.tsx new file mode 100644 index 0000000..999b911 --- /dev/null +++ b/app/workspaces/layout.tsx @@ -0,0 +1,10 @@ +import { getServerSession } from 'next-auth' +import { redirect } from 'next/navigation' +import { authOptions } from '@/lib/auth' + +export default async function WorkspacesLayout({ children }: Readonly<{ children: React.ReactNode }>) { + const session = await getServerSession(authOptions) + if (!session?.user) redirect('/login') + if (session.user.canAccessBrandCenter !== true) redirect('/assets/material-library') + return children +} diff --git a/components/material-library/MaterialLibraryAccessShell.test.tsx b/components/material-library/MaterialLibraryAccessShell.test.tsx new file mode 100644 index 0000000..413ef03 --- /dev/null +++ b/components/material-library/MaterialLibraryAccessShell.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import MaterialLibraryAccessShell from './MaterialLibraryAccessShell' + +const signOut = jest.fn() + +jest.mock('next-auth/react', () => ({ + signOut: (...args: unknown[]) => signOut(...args), +})) + +jest.mock('@/components/department-dashboard/DepartmentShell', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +describe('MaterialLibraryAccessShell', () => { + beforeEach(() => signOut.mockClear()) + + test('品牌中心人员保留部门工作台导航', () => { + render(

素材列表

) + + expect(screen.getByTestId('department-shell')).toBeInTheDocument() + expect(screen.queryByRole('banner', { name: '素材资产库顶栏' })).not.toBeInTheDocument() + }) + + test('品牌中心外人员只看到独立素材库壳且可以退出', async () => { + render(

素材列表

) + + expect(screen.queryByTestId('department-shell')).not.toBeInTheDocument() + expect(screen.getByRole('banner', { name: '素材资产库顶栏' })).toBeInTheDocument() + expect(screen.getByText('素材资产库')).toBeInTheDocument() + expect(screen.queryByRole('link')).not.toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: '退出登录' })) + expect(signOut).toHaveBeenCalledWith({ callbackUrl: '/login' }) + }) +}) diff --git a/components/material-library/MaterialLibraryAccessShell.tsx b/components/material-library/MaterialLibraryAccessShell.tsx new file mode 100644 index 0000000..8266311 --- /dev/null +++ b/components/material-library/MaterialLibraryAccessShell.tsx @@ -0,0 +1,44 @@ +'use client' + +import { LibraryBig, LogOut } from 'lucide-react' +import { signOut } from 'next-auth/react' +import type { ReactNode } from 'react' +import DepartmentShell from '@/components/department-dashboard/DepartmentShell' + +export default function MaterialLibraryAccessShell({ + canAccessBrandCenter, + children, +}: { + canAccessBrandCenter: boolean + children: ReactNode +}) { + if (canAccessBrandCenter) { + return {children} + } + + return ( +
+
+
+
+ + + 素材资产库 +
+ +
+
+ {children} +
+ ) +} diff --git a/lib/__tests__/brand-center-access-policy.test.ts b/lib/__tests__/brand-center-access-policy.test.ts new file mode 100644 index 0000000..c93c32c --- /dev/null +++ b/lib/__tests__/brand-center-access-policy.test.ts @@ -0,0 +1,52 @@ +import { + hasBrandCenterAccess, + isBrandCenterOrganizationPath, + isBrandCenterRestrictedPath, + shouldRedirectToStandaloneMaterialLibrary, +} from '@/lib/brand-center-access-policy' + +describe('brand center access policy', () => { + test('只把路径中的精确品牌中心节点视为品牌中心范围', () => { + expect(isBrandCenterOrganizationPath('江苏简尚品牌管理有限公司/品牌中心/品牌创意A部')).toBe(true) + expect(isBrandCenterOrganizationPath('江苏简尚品牌管理有限公司/龙晨品牌中心')).toBe(false) + expect(isBrandCenterOrganizationPath('江苏简尚品牌管理有限公司/运营中心A')).toBe(false) + }) + + test('品牌中心任一任职路径可进入,明确离职则不可进入', () => { + expect(hasBrandCenterAccess({ + role: 'USER', + departmentPaths: ['集团/摄影中心', '集团/品牌中心/综合支持部'], + })).toBe(true) + expect(hasBrandCenterAccess({ + role: 'USER', + employmentState: 'RESIGNED', + departmentPaths: ['集团/品牌中心'], + })).toBe(false) + }) + + test('系统管理员保留维护通行,外部门管理员不自动获得品牌中心权限', () => { + expect(hasBrandCenterAccess({ role: 'SYSTEM_ADMIN', departmentPaths: [] })).toBe(true) + expect(hasBrandCenterAccess({ role: 'USER', isSuperAdmin: true, departmentPaths: [] })).toBe(true) + expect(hasBrandCenterAccess({ role: 'DEPT_ADMIN', departmentPaths: ['集团/运营中心A'] })).toBe(false) + }) + + test('品牌中心与管理工作面统一受组织范围门禁,素材工作流保持可用', () => { + expect(isBrandCenterRestrictedPath('/')).toBe(true) + expect(isBrandCenterRestrictedPath('/workspaces/dashboard/core-metrics')).toBe(true) + expect(isBrandCenterRestrictedPath('/admin')).toBe(true) + expect(isBrandCenterRestrictedPath('/organization')).toBe(true) + expect(isBrandCenterRestrictedPath('/performance-survey/monthly')).toBe(true) + expect(isBrandCenterRestrictedPath('/work-review')).toBe(true) + expect(isBrandCenterRestrictedPath('/teacher/templates')).toBe(true) + expect(isBrandCenterRestrictedPath('/assets/material-library')).toBe(false) + expect(isBrandCenterRestrictedPath('/materials')).toBe(false) + expect(isBrandCenterRestrictedPath('/profile')).toBe(false) + }) + + test('外部人员访问旧素材库或品牌中心工作面时统一进入正式素材库', () => { + expect(shouldRedirectToStandaloneMaterialLibrary('/materials', false)).toBe(true) + expect(shouldRedirectToStandaloneMaterialLibrary('/workspaces/dashboard/core-metrics', false)).toBe(true) + expect(shouldRedirectToStandaloneMaterialLibrary('/assets/material-library', false)).toBe(false) + expect(shouldRedirectToStandaloneMaterialLibrary('/materials', true)).toBe(false) + }) +}) diff --git a/lib/__tests__/brand-center-access.test.ts b/lib/__tests__/brand-center-access.test.ts new file mode 100644 index 0000000..f275702 --- /dev/null +++ b/lib/__tests__/brand-center-access.test.ts @@ -0,0 +1,69 @@ +import { prisma } from '@/lib/prisma' +import { resolveBrandCenterAccessForUser } from '@/lib/brand-center-access' + +jest.mock('@/lib/prisma', () => ({ + prisma: { + user: { findUnique: jest.fn() }, + performanceSurveyEmployeeProfile: { findUnique: jest.fn() }, + organizationUnit: { findMany: jest.fn() }, + }, +})) + +const mockedPrisma = prisma as unknown as { + user: { findUnique: jest.Mock } + performanceSurveyEmployeeProfile: { findUnique: jest.Mock } + organizationUnit: { findMany: jest.Mock } +} + +describe('resolveBrandCenterAccessForUser', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedPrisma.organizationUnit.findMany.mockResolvedValue([]) + mockedPrisma.performanceSurveyEmployeeProfile.findUnique.mockResolvedValue(null) + }) + + test('系统管理员不依赖人员绑定即可进入', async () => { + await expect(resolveBrandCenterAccessForUser('admin', { role: 'SYSTEM_ADMIN' })).resolves.toBe(true) + expect(mockedPrisma.user.findUnique).not.toHaveBeenCalled() + }) + + test('读取活跃绑定的多部门任职路径', async () => { + mockedPrisma.user.findUnique.mockResolvedValue({ + dingtalkUserId: 'ding-1', + employeeBindings: [{ + employeeProfile: { + departmentPath: '集团/摄影中心', + employmentState: 'ACTIVE', + departmentMemberships: [{ + departmentPath: '集团/品牌中心/综合支持部', + sourceDeptId: '20', + }], + }, + }], + }) + + await expect(resolveBrandCenterAccessForUser('user-1', { role: 'USER' })).resolves.toBe(true) + }) + + test('部门路径缺失时可按任职部门 ID 解析真实组织路径', async () => { + mockedPrisma.user.findUnique.mockResolvedValue({ + dingtalkUserId: null, + employeeBindings: [{ + employeeProfile: { + departmentPath: null, + employmentState: 'ACTIVE', + departmentMemberships: [{ departmentPath: null, sourceDeptId: '20' }], + }, + }], + }) + mockedPrisma.organizationUnit.findMany.mockResolvedValue([{ path: '集团/品牌中心' }]) + + await expect(resolveBrandCenterAccessForUser('user-2', { role: 'USER' })).resolves.toBe(true) + }) + + test('未绑定且不在品牌中心时按无权限处理', async () => { + mockedPrisma.user.findUnique.mockResolvedValue({ dingtalkUserId: null, employeeBindings: [] }) + + await expect(resolveBrandCenterAccessForUser('user-3', { role: 'USER' })).resolves.toBe(false) + }) +}) diff --git a/lib/auth.ts b/lib/auth.ts index 60218f9..5156704 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -4,6 +4,7 @@ import { prisma } from '@/lib/prisma' import bcrypt from 'bcryptjs' import { resolveSecureAuthCookieForCurrentRequest } from '@/lib/auth-cookie' import { authorizeDingtalkH5UserForNextAuth } from '@/lib/dingtalk-login' +import { resolveBrandCenterAccessForUser } from '@/lib/brand-center-access' const LEGACY_DINGTALK_AUTO_LOGIN_PASSWORD = 'dingtalk_auto_login' @@ -146,9 +147,17 @@ export const authOptions: NextAuthOptions = { token.managedDepartment = (user as any).managedDepartment || null; token.accountStatus = 'active'; token.authCheckedAt = Date.now(); + token.canAccessBrandCenter = await resolveBrandCenterAccessForUser(user.id, { + role: (user as any).role, + isSuperAdmin: (user as any).isSuperAdmin, + }).catch((error) => { + console.error('读取品牌中心访问范围失败,已按无权限处理:', error) + return false + }) } else if (token.id) { const lastCheckedAt = Number((token as any).authCheckedAt || 0) - if (Date.now() - lastCheckedAt >= 30_000) { + const accessClaimMissing = typeof (token as any).canAccessBrandCenter !== 'boolean' + if (accessClaimMissing || Date.now() - lastCheckedAt >= 30_000) { const currentUser = await prisma.user.findUnique({ where: { id: String(token.id) }, select: { @@ -173,6 +182,15 @@ export const authOptions: NextAuthOptions = { token.isSuperAdmin = currentUser.isSuperAdmin token.department = currentUser.department || '' token.managedDepartment = currentUser.managedDepartment || null + token.canAccessBrandCenter = await resolveBrandCenterAccessForUser(String(token.id), { + role: currentUser.role, + isSuperAdmin: currentUser.isSuperAdmin, + }).catch((error) => { + console.error('刷新品牌中心访问范围失败,已按无权限处理:', error) + return false + }) + } else { + token.canAccessBrandCenter = false } } } @@ -190,6 +208,7 @@ export const authOptions: NextAuthOptions = { (session.user as any).isSuperAdmin = token.isSuperAdmin || false; // 添加超级管理员标识到session (session.user as any).department = (token as any).department || ''; (session.user as any).managedDepartment = (token as any).managedDepartment || null; + (session.user as any).canAccessBrandCenter = (token as any).canAccessBrandCenter === true; } return session; }, diff --git a/lib/brand-center-access-policy.ts b/lib/brand-center-access-policy.ts new file mode 100644 index 0000000..46f4de7 --- /dev/null +++ b/lib/brand-center-access-policy.ts @@ -0,0 +1,47 @@ +const BRAND_CENTER_SEGMENT = '品牌中心' + +const BRAND_CENTER_ADMIN_ROLES = new Set(['ADMIN', 'SYSTEM_ADMIN']) + +export function isBrandCenterOrganizationPath(value: unknown): boolean { + return String(value ?? '') + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean) + .includes(BRAND_CENTER_SEGMENT) +} + +export function hasBrandCenterAccess(input: { + role?: unknown + isSuperAdmin?: unknown + employmentState?: unknown + departmentPaths?: unknown[] +}): boolean { + const role = String(input.role ?? '') + if (input.isSuperAdmin === true || BRAND_CENTER_ADMIN_ROLES.has(role)) return true + if (input.employmentState === 'RESIGNED') return false + return (input.departmentPaths ?? []).some(isBrandCenterOrganizationPath) +} + +const BRAND_CENTER_RESTRICTED_PREFIXES = [ + '/workspaces', + '/admin', + '/organization', + '/performance-survey', + '/work-review', + '/teacher', +] + +export function isBrandCenterRestrictedPath(pathname: string): boolean { + return pathname === '/' || BRAND_CENTER_RESTRICTED_PREFIXES.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), + ) +} + +export function shouldRedirectToStandaloneMaterialLibrary( + pathname: string, + canAccessBrandCenter: unknown, +): boolean { + return canAccessBrandCenter !== true && ( + pathname === '/materials' || isBrandCenterRestrictedPath(pathname) + ) +} diff --git a/lib/brand-center-access.ts b/lib/brand-center-access.ts new file mode 100644 index 0000000..1784a8c --- /dev/null +++ b/lib/brand-center-access.ts @@ -0,0 +1,68 @@ +import { prisma } from '@/lib/prisma' +import { hasBrandCenterAccess } from '@/lib/brand-center-access-policy' + +type BrandCenterAccessIdentity = { + role?: unknown + isSuperAdmin?: unknown +} +const EMPLOYEE_ACCESS_SELECT = { + departmentPath: true, + employmentState: true, + departmentMemberships: { + select: { + departmentPath: true, + sourceDeptId: true, + }, + }, +} as const + +export async function resolveBrandCenterAccessForUser( + userId: string | null | undefined, + identity: BrandCenterAccessIdentity = {}, +): Promise { + if (hasBrandCenterAccess(identity)) return true + if (!userId) return false + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + dingtalkUserId: true, + employeeBindings: { + where: { status: 'ACTIVE' }, + take: 1, + select: { + employeeProfile: { select: EMPLOYEE_ACCESS_SELECT }, + }, + }, + }, + }) + + let employee = user?.employeeBindings[0]?.employeeProfile ?? null + if (!employee && user?.dingtalkUserId) { + employee = await prisma.performanceSurveyEmployeeProfile.findUnique({ + where: { dingtalkUserId: user.dingtalkUserId }, + select: EMPLOYEE_ACCESS_SELECT, + }) + } + if (!employee) return false + + const sourceDeptIds = employee.departmentMemberships + .map((membership) => Number(membership.sourceDeptId)) + .filter((value) => Number.isInteger(value)) + const organizationUnits = sourceDeptIds.length > 0 + ? await prisma.organizationUnit.findMany({ + where: { sourceDeptId: { in: sourceDeptIds }, isActive: true }, + select: { path: true }, + }) + : [] + + return hasBrandCenterAccess({ + ...identity, + employmentState: employee.employmentState, + departmentPaths: [ + employee.departmentPath, + ...employee.departmentMemberships.map((membership) => membership.departmentPath), + ...organizationUnits.map((unit) => unit.path), + ], + }) +} diff --git a/middleware.ts b/middleware.ts index 0b20065..e8d8ce0 100644 --- a/middleware.ts +++ b/middleware.ts @@ -5,6 +5,7 @@ import { getAuthSessionCookieCandidates, resolveSecureAuthCookieFromRequest, } from '@/lib/auth-cookie' +import { shouldRedirectToStandaloneMaterialLibrary } from '@/lib/brand-center-access-policy' const API_CORS_ALLOWED_HEADERS = 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization, Cookie' @@ -92,7 +93,10 @@ export async function middleware(request: NextRequest) { isDepartmentPage(pathname) || pathname === '/materials' || pathname.startsWith('/admin') || - pathname.startsWith('/organization') + pathname.startsWith('/organization') || + pathname.startsWith('/performance-survey') || + pathname.startsWith('/work-review') || + pathname.startsWith('/teacher') ) { const preferredSecureCookie = resolveSecureAuthCookieFromRequest(request) const cookieCandidates = getAuthSessionCookieCandidates(preferredSecureCookie) @@ -121,6 +125,15 @@ export async function middleware(request: NextRequest) { return redirectResponse } + if (shouldRedirectToStandaloneMaterialLibrary(pathname, (token as any).canAccessBrandCenter)) { + const url = new URL('/assets/material-library', request.url) + const redirectResponse = NextResponse.redirect(url) + redirectResponse.headers.set('Cache-Control', 'private, no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0') + redirectResponse.headers.set('Pragma', 'no-cache') + redirectResponse.headers.set('Expires', '0') + return redirectResponse + } + // 管理员页面需要管理员权限 if (pathname.startsWith('/admin') || pathname.startsWith('/organization')) { const role = String((token as any)?.role || '') @@ -207,5 +220,7 @@ export const config = { '/dingtalk-entry', '/dingtalk-callback', '/performance-survey/:path*', + '/work-review/:path*', + '/teacher/:path*', ], } diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts index f2429d8..694be30 100644 --- a/types/next-auth.d.ts +++ b/types/next-auth.d.ts @@ -11,6 +11,7 @@ declare module 'next-auth' { managedDepartment?: string | null avatar?: string | null isSuperAdmin?: boolean + canAccessBrandCenter?: boolean } } @@ -23,6 +24,7 @@ declare module 'next-auth' { managedDepartment?: string | null avatar?: string | null isSuperAdmin?: boolean + canAccessBrandCenter?: boolean } } @@ -34,6 +36,6 @@ declare module 'next-auth/jwt' { managedDepartment?: string | null avatar?: string | null isSuperAdmin?: boolean + canAccessBrandCenter?: boolean } } -