feat: restrict external users to material library

This commit is contained in:
江林铖 2026-08-20 11:55:18 +08:00
parent 1769c65055
commit da2588051b
16 changed files with 419 additions and 18 deletions

View File

@ -460,11 +460,17 @@
"type": "navigation",
"description": "Department Shell 内的 232px 单项二级栏与真实公司组织架构工作面;不渲染数据来源分组或折叠层。",
"projection": "<section class=\"ds-data-source\"><nav class=\"ds-data-source-nav\" aria-label=\"数据口径二级导航\"><a class=\"ds-data-source-item\" href=\"#\" aria-current=\"page\"><svg aria-hidden=\"true\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"5\" r=\"2\"/><circle cx=\"5\" cy=\"19\" r=\"2\"/><circle cx=\"19\" cy=\"19\" r=\"2\"/><path d=\"M12 7v5M5 17v-2h14v2\"/></svg><span><b>公司组织架构</b><small>组织树与人员明细</small></span></a></nav><section class=\"ds-data-source-live\" aria-label=\"公司组织架构操作区\"><strong>组织架构</strong><p>沿用真实人员层级、归属明细与管理员权限。</p></section></section><style>.ds-data-source{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:360px;background:#fff;font-family:ui-sans-serif,system-ui}.ds-data-source-nav{border-right:1px solid #e2e8f0;background:#f8fafc;padding:24px 16px}.ds-data-source-item{display:flex;min-height:58px;align-items:center;gap:12px;border-radius:8px;background:#e6f4ff;padding:10px 12px;color:#0050cb;text-decoration:none}.ds-data-source-item svg{width:16px;height:16px;flex:none;fill:none;stroke:#1677ff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.ds-data-source-item b,.ds-data-source-item small{display:block}.ds-data-source-item b{font-size:14px}.ds-data-source-item small{margin-top:2px;color:#315f91;font-size:12px}.ds-data-source-item:focus-visible{outline:2px solid #5a9ce6}.ds-data-source-live{min-width:0;background:#fafafa;padding:24px;color:#101828}.ds-data-source-live strong{font-size:20px}.ds-data-source-live p{margin:8px 0 0;color:#66758b;font-size:14px}@media(max-width:1023px){.ds-data-source{grid-template-columns:1fr}.ds-data-source-nav{border-right:0;border-bottom:1px solid #e2e8f0;padding:16px}.ds-data-source-item small{display:none}}</style>"
},
{
"name": "Standalone Material Library Shell",
"type": "navigation",
"description": "品牌中心范围外普通用户使用的中性素材库顶栏;只保留页面定位和退出登录,不提供品牌中心入口。",
"projection": "<header class=\"ds-material-standalone\" aria-label=\"素材资产库顶栏\"><strong><svg aria-hidden=\"true\" viewBox=\"0 0 24 24\"><path d=\"M4 5h6l2 2h8v12H4z\"/></svg>素材资产库</strong><button type=\"button\">退出登录</button></header><style>.ds-material-standalone{display:flex;min-height:64px;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid #e2e8f0;background:#fff;padding:0 24px;color:#101828;font-family:ui-sans-serif,system-ui}.ds-material-standalone strong{display:flex;min-width:0;align-items:center;gap:12px;font-size:16px}.ds-material-standalone svg{width:20px;height:20px;fill:none;stroke:#176fc1;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.ds-material-standalone button{min-height:40px;border:0;border-radius:8px;background:transparent;padding:0 12px;color:#4f6077;font:600 14px/1 ui-sans-serif,system-ui}.ds-material-standalone button:hover{background:#eef2f6;color:#101828}.ds-material-standalone button:focus-visible{outline:2px solid #5a9ce6;outline-offset:2px}</style>"
}
],
"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/*` 直接进入工作台。"
]
}
}

View File

@ -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/*` 直接进入工作台。

View File

@ -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

View File

@ -1,8 +1,8 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: '由壹点3D素材 | 品牌中心部门看板',
description: '品牌中心资产与AI自动化素材库',
title: '素材资产库 | 由壹点3D素材',
description: '浏览、上传、分享与下载素材资产',
}
export default function MaterialLibraryLayout({

View File

@ -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 (
<DepartmentShell>
<MaterialLibraryAccessShell canAccessBrandCenter={canAccessBrandCenter}>
<div className="department-material-light min-h-[calc(100vh-72px)] bg-[#f2f4f7] text-[#101828]">
{/* 收藏操作提示气泡 */}
@ -1344,7 +1346,7 @@ export default function MaterialLibraryPage() {
{/* 内网切换提示已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转 */}
<RankingPanel appearance="light" safeLeft={272} dockRight />
<RankingPanel appearance="light" safeLeft={canAccessBrandCenter ? 272 : 0} dockRight />
<main className="container mx-auto max-w-7xl px-4 py-6">
{/* Header Section */}
@ -1634,6 +1636,6 @@ export default function MaterialLibraryPage() {
{/* AI聊天助手 */}
<AIChatWidget />
</div>
</DepartmentShell>
</MaterialLibraryAccessShell>
)
}

View File

@ -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')
}

10
app/workspaces/layout.tsx Normal file
View File

@ -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
}

View File

@ -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 }) => <div data-testid="department-shell">{children}</div>,
}))
describe('MaterialLibraryAccessShell', () => {
beforeEach(() => signOut.mockClear())
test('品牌中心人员保留部门工作台导航', () => {
render(<MaterialLibraryAccessShell canAccessBrandCenter><p>素材列表</p></MaterialLibraryAccessShell>)
expect(screen.getByTestId('department-shell')).toBeInTheDocument()
expect(screen.queryByRole('banner', { name: '素材资产库顶栏' })).not.toBeInTheDocument()
})
test('品牌中心外人员只看到独立素材库壳且可以退出', async () => {
render(<MaterialLibraryAccessShell canAccessBrandCenter={false}><p>素材列表</p></MaterialLibraryAccessShell>)
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' })
})
})

View File

@ -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 <DepartmentShell>{children}</DepartmentShell>
}
return (
<div className="min-h-screen bg-[#f2f4f7] text-[#101828]">
<header className="sticky top-0 z-40 border-b border-[#e2e8f0] bg-white" aria-label="素材资产库顶栏">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[#edf5ff] text-[#176fc1]">
<LibraryBig className="h-5 w-5" aria-hidden="true" />
</span>
<span className="truncate text-base font-bold tracking-[-0.02em]">素材资产库</span>
</div>
<button
type="button"
aria-label="退出登录"
onClick={() => signOut({ callbackUrl: '/login' })}
className="inline-flex min-h-10 shrink-0 items-center gap-2 rounded-lg px-3 text-sm font-semibold text-[#4f6077] transition-colors hover:bg-[#eef2f6] hover:text-[#101828] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5a9ce6] focus-visible:ring-offset-2"
>
<LogOut className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline">退出登录</span>
<span className="sr-only sm:hidden">退出登录</span>
</button>
</div>
</header>
{children}
</div>
)
}

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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;
},

View File

@ -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)
)
}

View File

@ -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<boolean> {
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),
],
})
}

View File

@ -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*',
],
}

View File

@ -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
}
}