feat: restrict external users to material library
This commit is contained in:
parent
1769c65055
commit
da2588051b
@ -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/*` 直接进入工作台。"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
19
DESIGN.md
19
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/*` 直接进入工作台。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '由壹点3D素材 | 品牌中心部门看板',
|
||||
description: '品牌中心资产与AI自动化素材库',
|
||||
title: '素材资产库 | 由壹点3D素材',
|
||||
description: '浏览、上传、分享与下载素材资产',
|
||||
}
|
||||
|
||||
export default function MaterialLibraryLayout({
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
10
app/workspaces/layout.tsx
Normal 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
|
||||
}
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
44
components/material-library/MaterialLibraryAccessShell.tsx
Normal file
44
components/material-library/MaterialLibraryAccessShell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
lib/__tests__/brand-center-access-policy.test.ts
Normal file
52
lib/__tests__/brand-center-access-policy.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
69
lib/__tests__/brand-center-access.test.ts
Normal file
69
lib/__tests__/brand-center-access.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
21
lib/auth.ts
21
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;
|
||||
},
|
||||
|
||||
47
lib/brand-center-access-policy.ts
Normal file
47
lib/brand-center-access-policy.ts
Normal 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)
|
||||
)
|
||||
}
|
||||
68
lib/brand-center-access.ts
Normal file
68
lib/brand-center-access.ts
Normal 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),
|
||||
],
|
||||
})
|
||||
}
|
||||
@ -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*',
|
||||
],
|
||||
}
|
||||
|
||||
4
types/next-auth.d.ts
vendored
4
types/next-auth.d.ts
vendored
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user