feat(P15A-1,P15A-5): 解耦请假审批权限
这个提交包含在:
@@ -22,7 +22,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
to: "/workflow/tasks",
|
||||
label: "待我处理",
|
||||
icon: "fact_check",
|
||||
anyOf: ["expense.approve"],
|
||||
anyOf: ["expense.approve", "leave.approve"],
|
||||
},
|
||||
{
|
||||
to: "/invoice-pool",
|
||||
@@ -40,7 +40,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
to: "/mobile/approvals",
|
||||
label: "移动审批",
|
||||
icon: "mobile_friendly",
|
||||
anyOf: ["expense.approve"],
|
||||
anyOf: ["expense.approve", "leave.approve"],
|
||||
},
|
||||
{
|
||||
to: "/budgets",
|
||||
|
||||
@@ -90,7 +90,7 @@ export function AppRouter() {
|
||||
<Route
|
||||
path="workflow/tasks"
|
||||
element={
|
||||
<RequirePermission anyOf={["expense.approve"]}>
|
||||
<RequirePermission anyOf={["expense.approve", "leave.approve"]}>
|
||||
<WorkflowTasksPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export function AppRouter() {
|
||||
<Route
|
||||
path="workflow/tasks/:taskId"
|
||||
element={
|
||||
<RequirePermission anyOf={["expense.approve"]}>
|
||||
<RequirePermission anyOf={["expense.approve", "leave.approve"]}>
|
||||
<WorkflowTaskDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -126,7 +126,7 @@ export function AppRouter() {
|
||||
<Route
|
||||
path="mobile/approvals"
|
||||
element={
|
||||
<RequirePermission anyOf={["expense.approve"]}>
|
||||
<RequirePermission anyOf={["expense.approve", "leave.approve"]}>
|
||||
<MobileApprovalsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
P15A-0,P15A执行合同建档,创建并校验P15A(权限全域解耦)执行合同CSV,CSV通过校验且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv,manual,DONE,DONE,DONE,issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv,[],"需在P14彻底结束后建档"
|
||||
P15A-1,常量字典与角色补齐,重构 permissions.js 新增 LEAVE 域权限集并应用给相应角色,字典分离成功并且在测试或路由里可以解析出权限,manual,manual,TODO,TODO,TODO,server/src/constants/permissions.js | server/src/scripts/seed-dev.js,"[""P15A-0""]","应用角色权限"
|
||||
P15A-2,工作流动态鉴权剥离,将硬编码的 expense.approve 替换为 resolveEntityPermission 以根据 entityType 支持多态鉴权,提交和审批节点鉴权可自动识别 leave 和 expense 的不同所需 Token,cd server && npm run test,manual,TODO,TODO,TODO,server/src/constants/permissions.js | server/src/plugins/auth.js | server/src/routes/workflow.js,"[""P15A-1""]","核心路由侧改动;不新增中间映射文件"
|
||||
P15A-3,前端路由与菜单显隐域解耦,调整现有待办与审批入口的 anyOf 守卫以支持 leave.approve 避免菜单与路由继续借用 expense 域,具备 leave.approve 的审批人可见现有审批入口且不新增独立请假配置菜单,manual,manual,TODO,TODO,TODO,client/src/components/navigation/NavLinks.tsx | client/src/router/index.tsx,"[""P15A-1""]","仅调整现有入口守卫,不扩独立请假菜单"
|
||||
P15A-4,OpenAPI Tag分离,更新 leave 相关的 OpenAPI schema 注释将其脱离 Finance 组并收归 HR 分区,导出或查阅 json 中正确呈现独立 tag,curl /api/docs/json,manual,TODO,TODO,TODO,server/src/routes/leave.js,"[""P15A-2""]","不涉及业务逻辑"
|
||||
P15A-5,P15A全量回归测试,运行回归脚本确保由 P15A 引发的基座层鉴权改动并未破坏原有的报销流转 (Expense),全量基线回归绿柱通过,cd server && ./scripts/regression-all.sh,manual,TODO,TODO,TODO,none,"[""P15A-3"",""P15A-4""]","严查expense业务回归"
|
||||
P15A-0,P15A执行合同建档,创建并校验P15A(权限全域解耦)执行合同CSV,CSV通过校验且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv,manual,DONE,DONE,DONE,issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv,[],需在P14彻底结束后建档
|
||||
P15A-1,常量字典与角色补齐,重构 permissions.js 新增 LEAVE 域权限集并应用给相应角色,字典分离成功并且在测试或路由里可以解析出权限,manual,manual,DONE,DONE,DONE,server/src/constants/permissions.js | server/src/scripts/seed-dev.js,"[""P15A-0""]",应用角色权限
|
||||
P15A-2,工作流动态鉴权剥离,将硬编码的 expense.approve 替换为 resolveEntityPermission 以根据 entityType 支持多态鉴权,提交和审批节点鉴权可自动识别 leave 和 expense 的不同所需 Token,cd server && npm run test,manual,DONE,DONE,DONE,server/src/constants/permissions.js | server/src/plugins/auth.js | server/src/routes/workflow.js,"[""P15A-1""]",核心路由侧改动;不新增中间映射文件
|
||||
P15A-3,前端路由与菜单显隐域解耦,调整现有待办与审批入口的 anyOf 守卫以支持 leave.approve 避免菜单与路由继续借用 expense 域,具备 leave.approve 的审批人可见现有审批入口且不新增独立请假配置菜单,manual,manual,DONE,DONE,DONE,client/src/components/navigation/NavLinks.tsx | client/src/router/index.tsx,"[""P15A-1""]",仅调整现有入口守卫,不扩独立请假菜单
|
||||
P15A-4,OpenAPI Tag分离,更新 leave 相关的 OpenAPI schema 注释将其脱离 Finance 组并收归 HR 分区,导出或查阅 json 中正确呈现独立 tag,curl /api/docs/json,manual,DONE,DONE,DONE,server/src/routes/leave.js,"[""P15A-2""]",不涉及业务逻辑
|
||||
P15A-5,P15A全量回归测试,运行回归脚本确保由 P15A 引发的基座层鉴权改动并未破坏原有的报销流转 (Expense),全量基线回归绿柱通过,cd server && ./scripts/regression-all.sh,manual,DONE,DONE,DONE,none,"[""P15A-3"",""P15A-4""]",严查expense业务回归
|
||||
|
||||
|
@@ -1,10 +1,10 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
R0,P15-P18路线图合同建档,创建并校验P15-P18 roadmap preflight 执行合同CSV,CSV通过校验且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-06_20-00-00-p15-p18-roadmap-preflight.csv,manual,DONE,DONE,DONE,issues/2026-03-06_20-00-00-p15-p18-roadmap-preflight.csv,[],作为后续六个月总控合同
|
||||
R1,P14基线冻结与双提交,封板P14运营化产物并按计划完成基线提交收口,两笔chore提交完成且工作区清空,"manual: git status && git log --oneline -n 2",manual,DONE,DONE,DONE,.github/workflows/ci-minimal.yml | .github/workflows/ci-regression.yml | docs/feature-matrix.md | docs/ops/ci-regression-reporting.md | issues/2026-02-13_00-11-56-p14-l5-operationalization.csv | scripts/coverage_gate.js | scripts/openapi_diff_check.py | scripts/validate_regression_report.py | scripts/validate-feature-matrix.py | server/.env.example | server/package.json | server/package-lock.json | server/scripts/export_openapi.js | server/src/app.js | server/src/config.js | server/src/index.js | server/src/observability/tracing.js | server/src/routes/invoicePool/read.js | server/src/routes/reimbursements.js | server/src/routes/workflow.js | server/tests/unit/services/ | server/tests/unit/utils/,"[""R0""]","本次以两笔chore提交完成基线封板"
|
||||
R2,P15合同锚点校正,修正P15A伪文件锚点并补齐P15B列表/撤回能力合同,不再引用不存在文件且P15B覆盖列表与撤回流程,"python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv && python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-06_14-30-00-p15b-l5-leave-v1.csv",manual,DONE,DONE,DONE,issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv | issues/2026-03-06_14-30-00-p15b-l5-leave-v1.csv,"[""R0""]","P15A-2 锚定 constants/auth/workflow;P15B补齐列表与撤回"
|
||||
R3,Skills与MCP装配计划建档,固定skills安装与MCP启用边界并完成第一波必要skills安装,playwright与security-best-practices可用且MCP策略已固定,"manual: ls -1 /home/shunli/.codex/skills",manual,DONE,DONE,DONE,/home/shunli/.codex/config.toml | /home/shunli/.codex/skills/playwright | /home/shunli/.codex/skills/security-best-practices,"[""R0""]","已安装 playwright 与 security-best-practices;MCP继续按需启用,仅预留postgres/github"
|
||||
R4,P15A启动就绪审查,冻结P15A权限矩阵与接口落点避免开工后反复改合同,权限矩阵和落点与真实代码锚点一致,manual,manual,TODO,TODO,TODO,issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv | server/src/constants/permissions.js | server/src/plugins/auth.js | server/src/routes/workflow.js | server/src/routes/leave.js | client/src/components/navigation/NavLinks.tsx | client/src/router/index.tsx,"[""R2""]","不改表结构;不扩transfer到leave"
|
||||
R5,P15B启动就绪审查,冻结P15B四层模型与账本语义及列表/撤回边界,四层模型与账本语义和接口范围全部固定,manual,manual,TODO,TODO,TODO,issues/2026-03-06_14-30-00-p15b-l5-leave-v1.csv | server/prisma/schema.prisma | server/src/routes/leave.js | server/src/routes/workflow.js,"[""R2""]","默认自然日+半天边界;日历与节假日算法后置"
|
||||
R6,P16共享主数据蓝图,定义 LegalEntity HolidayCalendar CostCenter DocumentCategory Supplier 的模型与API边界,主数据模型和API范围固定且可被leave purchase asset复用,manual,manual,TODO,TODO,TODO,module:master-data,"[""R5""]","先共享主数据后扩业务,避免再次耦回system.settings"
|
||||
R7,P17轻采购蓝图,定义轻采购V1范围仅覆盖申请审批预算校验供应商与归档,采购范围固定且明确不含收货三单匹配与ERP过账,manual,manual,TODO,TODO,TODO,module:procurement,"[""R6""]","依赖Supplier与CostCenter等主数据"
|
||||
R8,P18固资台账蓝图,定义资产卡片领用归还调拨盘点的V1范围,资产台账范围固定且明确不含折旧过账与ERP集成,manual,manual,TODO,TODO,TODO,module:assets,"[""R6""]","建议引用LegalEntity CostCenter DocumentCategory"
|
||||
R1,P14基线冻结与双提交,封板P14运营化产物并按计划完成基线提交收口,两笔chore提交完成且工作区清空,manual: git status && git log --oneline -n 2,manual,DONE,DONE,DONE,.github/workflows/ci-minimal.yml | .github/workflows/ci-regression.yml | docs/feature-matrix.md | docs/ops/ci-regression-reporting.md | issues/2026-02-13_00-11-56-p14-l5-operationalization.csv | scripts/coverage_gate.js | scripts/openapi_diff_check.py | scripts/validate_regression_report.py | scripts/validate-feature-matrix.py | server/.env.example | server/package.json | server/package-lock.json | server/scripts/export_openapi.js | server/src/app.js | server/src/config.js | server/src/index.js | server/src/observability/tracing.js | server/src/routes/invoicePool/read.js | server/src/routes/reimbursements.js | server/src/routes/workflow.js | server/tests/unit/services/ | server/tests/unit/utils/,"[""R0""]",本次以两笔chore提交完成基线封板
|
||||
R2,P15合同锚点校正,修正P15A伪文件锚点并补齐P15B列表/撤回能力合同,不再引用不存在文件且P15B覆盖列表与撤回流程,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv && python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-06_14-30-00-p15b-l5-leave-v1.csv,manual,DONE,DONE,DONE,issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv | issues/2026-03-06_14-30-00-p15b-l5-leave-v1.csv,"[""R0""]",P15A-2 锚定 constants/auth/workflow;P15B补齐列表与撤回
|
||||
R3,Skills与MCP装配计划建档,固定skills安装与MCP启用边界并完成第一波必要skills安装,playwright与security-best-practices可用且MCP策略已固定,manual: ls -1 /home/shunli/.codex/skills,manual,DONE,DONE,DONE,/home/shunli/.codex/config.toml | /home/shunli/.codex/skills/playwright | /home/shunli/.codex/skills/security-best-practices,"[""R0""]",已安装 playwright 与 security-best-practices;MCP继续按需启用,仅预留postgres/github
|
||||
R4,P15A启动就绪审查,冻结P15A权限矩阵与接口落点避免开工后反复改合同,权限矩阵和落点与真实代码锚点一致,manual,manual,DONE,DONE,DONE,issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv | server/src/constants/permissions.js | server/src/plugins/auth.js | server/src/routes/workflow.js | server/src/routes/leave.js | client/src/components/navigation/NavLinks.tsx | client/src/router/index.tsx,"[""R2""]",不改表结构;不扩transfer到leave
|
||||
R5,P15B启动就绪审查,冻结P15B四层模型与账本语义及列表/撤回边界,四层模型与账本语义和接口范围全部固定,manual,manual,TODO,TODO,TODO,issues/2026-03-06_14-30-00-p15b-l5-leave-v1.csv | server/prisma/schema.prisma | server/src/routes/leave.js | server/src/routes/workflow.js,"[""R2""]",默认自然日+半天边界;日历与节假日算法后置
|
||||
R6,P16共享主数据蓝图,定义 LegalEntity HolidayCalendar CostCenter DocumentCategory Supplier 的模型与API边界,主数据模型和API范围固定且可被leave purchase asset复用,manual,manual,TODO,TODO,TODO,module:master-data,"[""R5""]",先共享主数据后扩业务,避免再次耦回system.settings
|
||||
R7,P17轻采购蓝图,定义轻采购V1范围仅覆盖申请审批预算校验供应商与归档,采购范围固定且明确不含收货三单匹配与ERP过账,manual,manual,TODO,TODO,TODO,module:procurement,"[""R6""]",依赖Supplier与CostCenter等主数据
|
||||
R8,P18固资台账蓝图,定义资产卡片领用归还调拨盘点的V1范围,资产台账范围固定且明确不含折旧过账与ERP集成,manual,manual,TODO,TODO,TODO,module:assets,"[""R6""]",建议引用LegalEntity CostCenter DocumentCategory
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
const EMPLOYEE_BASE_PERMISSIONS = [
|
||||
const EXPENSE_SELF_SERVICE_PERMISSIONS = [
|
||||
"expense.create",
|
||||
"expense.view_own",
|
||||
"expense.edit_own",
|
||||
@@ -10,6 +10,50 @@ const EMPLOYEE_BASE_PERMISSIONS = [
|
||||
"expense.proxy_manage",
|
||||
];
|
||||
|
||||
const LEAVE_SELF_SERVICE_PERMISSIONS = [
|
||||
"leave.create",
|
||||
"leave.view_own",
|
||||
"leave.submit",
|
||||
];
|
||||
|
||||
const EMPLOYEE_BASE_PERMISSIONS = [
|
||||
...EXPENSE_SELF_SERVICE_PERMISSIONS,
|
||||
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
||||
];
|
||||
|
||||
const ENTITY_PERMISSION_MAP = {
|
||||
reimbursement: {
|
||||
create: "expense.create",
|
||||
view_own: "expense.view_own",
|
||||
view_all: "expense.view_all",
|
||||
edit_own: "expense.edit_own",
|
||||
delete_own: "expense.delete_own",
|
||||
submit: "expense.submit",
|
||||
withdraw: "expense.withdraw",
|
||||
approve: "expense.approve",
|
||||
transfer: "expense.transfer",
|
||||
},
|
||||
expense: {
|
||||
create: "expense.create",
|
||||
view_own: "expense.view_own",
|
||||
view_all: "expense.view_all",
|
||||
edit_own: "expense.edit_own",
|
||||
delete_own: "expense.delete_own",
|
||||
submit: "expense.submit",
|
||||
withdraw: "expense.withdraw",
|
||||
approve: "expense.approve",
|
||||
transfer: "expense.transfer",
|
||||
},
|
||||
leave: {
|
||||
create: "leave.create",
|
||||
view_own: "leave.view_own",
|
||||
view_all: "leave.view_all",
|
||||
submit: "leave.submit",
|
||||
approve: "leave.approve",
|
||||
manage: "leave.manage",
|
||||
},
|
||||
};
|
||||
|
||||
const ROLE_PERMISSIONS = {
|
||||
employee: EMPLOYEE_BASE_PERMISSIONS,
|
||||
manager: [
|
||||
@@ -17,6 +61,7 @@ const ROLE_PERMISSIONS = {
|
||||
"expense.approve",
|
||||
"expense.transfer",
|
||||
"expense.view_all",
|
||||
"leave.approve",
|
||||
"report.dashboard.view",
|
||||
"budget.view",
|
||||
"budget.manage",
|
||||
@@ -26,6 +71,8 @@ const ROLE_PERMISSIONS = {
|
||||
"expense.approve",
|
||||
"expense.transfer",
|
||||
"expense.view_all",
|
||||
"leave.approve",
|
||||
"leave.view_all",
|
||||
"report.dashboard.view",
|
||||
"budget.view",
|
||||
],
|
||||
@@ -57,16 +104,17 @@ const ROLE_PERMISSIONS = {
|
||||
],
|
||||
administration: [
|
||||
"expense.proxy_submit",
|
||||
"expense.create",
|
||||
"expense.view_own",
|
||||
"expense.edit_own",
|
||||
"expense.delete_own",
|
||||
"expense.submit",
|
||||
"expense.withdraw",
|
||||
"invoice.upload",
|
||||
"invoice.view_own",
|
||||
...EMPLOYEE_BASE_PERMISSIONS,
|
||||
],
|
||||
hr: [
|
||||
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
||||
"leave.view_all",
|
||||
"leave.manage",
|
||||
"system.users",
|
||||
"system.users_sensitive",
|
||||
"expense.view_all",
|
||||
"report.dashboard.view",
|
||||
],
|
||||
hr: ["system.users", "system.users_sensitive", "expense.view_all", "report.dashboard.view"],
|
||||
admin: ["*"],
|
||||
};
|
||||
|
||||
@@ -90,8 +138,13 @@ function hasPermission(permissionCodes, permission) {
|
||||
return permissionCodes.includes("*") || permissionCodes.includes(permission);
|
||||
}
|
||||
|
||||
function resolveEntityPermission(entityType, action) {
|
||||
return ENTITY_PERMISSION_MAP[entityType]?.[action] ?? null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ROLE_PERMISSIONS,
|
||||
resolvePermissionsForRoles,
|
||||
hasPermission,
|
||||
resolveEntityPermission,
|
||||
};
|
||||
|
||||
+37
-7
@@ -1,7 +1,11 @@
|
||||
const fastifyJwt = require("@fastify/jwt");
|
||||
const fp = require("fastify-plugin");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions");
|
||||
const {
|
||||
resolvePermissionsForRoles,
|
||||
hasPermission,
|
||||
resolveEntityPermission,
|
||||
} = require("../constants/permissions");
|
||||
|
||||
async function authPlugin(fastify) {
|
||||
const { jwtSecret, accessTokenTtl } = fastify.config;
|
||||
@@ -26,18 +30,44 @@ async function authPlugin(fastify) {
|
||||
return hasPermission(employee?.permissionCodes ?? [], permissionCode);
|
||||
});
|
||||
|
||||
fastify.decorate("requirePermission", (permissionCode) => {
|
||||
return async (request) => {
|
||||
await fastify.authenticate(request);
|
||||
|
||||
if (!fastify.hasPermission(request.employee, permissionCode)) {
|
||||
fastify.decorate("assertPermission", (employee, permissionCode, details = {}) => {
|
||||
if (!permissionCode || !fastify.hasPermission(employee, permissionCode)) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
details: { permission: permissionCode },
|
||||
details: { permission: permissionCode, ...details },
|
||||
});
|
||||
}
|
||||
|
||||
return permissionCode;
|
||||
});
|
||||
|
||||
fastify.decorate("assertEntityPermission", (employee, entityType, action) => {
|
||||
const permissionCode = resolveEntityPermission(entityType, action);
|
||||
if (!permissionCode) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { entityType, action, permission: "unsupported_entity_action" },
|
||||
});
|
||||
}
|
||||
|
||||
return fastify.assertPermission(employee, permissionCode, { entityType, action });
|
||||
});
|
||||
|
||||
fastify.decorate("requirePermission", (permissionCode) => {
|
||||
return async (request) => {
|
||||
await fastify.authenticate(request);
|
||||
fastify.assertPermission(request.employee, permissionCode);
|
||||
};
|
||||
});
|
||||
|
||||
fastify.decorate("requireEntityPermission", (entityType, action) => {
|
||||
return async (request) => {
|
||||
await fastify.authenticate(request);
|
||||
fastify.assertEntityPermission(request.employee, entityType, action);
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -95,7 +95,8 @@ async function leaveRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/leave-requests",
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
schema: { tags: ["Leave"], summary: "Create Leave Request" },
|
||||
preHandler: fastify.requireEntityPermission("leave", "create"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const body = parseCreateBody(request.body);
|
||||
@@ -131,6 +132,7 @@ async function leaveRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/leave-requests/:id",
|
||||
{
|
||||
schema: { tags: ["Leave"], summary: "Get Leave Request" },
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
@@ -159,7 +161,7 @@ async function leaveRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
||||
const canViewAll = fastify.hasPermission(request.employee, "leave.view_all");
|
||||
if (!canViewAll && leave.owner_id !== request.employee.id) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
@@ -185,7 +187,8 @@ async function leaveRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/leave-requests/:id/submit",
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
schema: { tags: ["Leave"], summary: "Submit Leave Request" },
|
||||
preHandler: fastify.requireEntityPermission("leave", "submit"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const leaveId = parseLeaveId(request.params?.id);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const { z } = require("zod");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { resolvePermissionsForRoles, hasPermission, ROLE_PERMISSIONS } = require("../constants/permissions");
|
||||
const { resolvePermissionsForRoles, hasPermission, ROLE_PERMISSIONS, resolveEntityPermission } = require("../constants/permissions");
|
||||
const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow");
|
||||
const {
|
||||
createNotifications,
|
||||
@@ -199,7 +199,8 @@ function listAllowedActionsForTask(request, task, actionable) {
|
||||
}
|
||||
|
||||
const actions = [];
|
||||
if (hasPermission(request.employee.permissionCodes, "expense.approve")) {
|
||||
const approvePermission = resolveEntityPermission(task.workflow_instance?.entity_type, "approve");
|
||||
if (approvePermission && hasPermission(request.employee.permissionCodes, approvePermission)) {
|
||||
actions.push("approve", "reject", "return");
|
||||
}
|
||||
|
||||
@@ -731,7 +732,7 @@ async function workflowRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/workflow/tasks/:taskId/approve",
|
||||
{
|
||||
preHandler: fastify.requirePermission("expense.approve"),
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const taskId = parseTaskId(request.params?.taskId);
|
||||
@@ -744,6 +745,7 @@ async function workflowRoutes(fastify) {
|
||||
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
|
||||
|
||||
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
|
||||
fastify.assertEntityPermission(request.employee, workflowInstance.entity_type, "approve");
|
||||
const steps = getWorkflowStepsSnapshot(workflowInstance);
|
||||
const currentStepIndex = steps.findIndex((step) => step.id === workflowInstance.current_step_id);
|
||||
if (currentStepIndex < 0) {
|
||||
@@ -966,7 +968,7 @@ async function workflowRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/workflow/tasks/:taskId/reject",
|
||||
{
|
||||
preHandler: fastify.requirePermission("expense.approve"),
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const taskId = parseTaskId(request.params?.taskId);
|
||||
@@ -980,6 +982,7 @@ async function workflowRoutes(fastify) {
|
||||
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
|
||||
|
||||
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
|
||||
fastify.assertEntityPermission(request.employee, workflowInstance.entity_type, "approve");
|
||||
const now = new Date();
|
||||
|
||||
await tx.workflowTask.update({
|
||||
@@ -1095,7 +1098,7 @@ async function workflowRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/workflow/tasks/:taskId/return",
|
||||
{
|
||||
preHandler: fastify.requirePermission("expense.approve"),
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const taskId = parseTaskId(request.params?.taskId);
|
||||
@@ -1109,6 +1112,7 @@ async function workflowRoutes(fastify) {
|
||||
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
|
||||
|
||||
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
|
||||
fastify.assertEntityPermission(request.employee, workflowInstance.entity_type, "approve");
|
||||
const now = new Date();
|
||||
|
||||
await tx.workflowTask.update({
|
||||
@@ -1353,3 +1357,7 @@ async function workflowRoutes(fastify) {
|
||||
}
|
||||
|
||||
module.exports = workflowRoutes;
|
||||
|
||||
module.exports.__testables = {
|
||||
listAllowedActionsForTask,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
const {
|
||||
hasPermission,
|
||||
resolveEntityPermission,
|
||||
resolvePermissionsForRoles,
|
||||
} = require("../../../src/constants/permissions");
|
||||
|
||||
describe("Permission Constants", () => {
|
||||
it("grants leave self-service permissions to employee roles", () => {
|
||||
const employeePermissions = resolvePermissionsForRoles(["employee"]);
|
||||
|
||||
expect(employeePermissions).toEqual(
|
||||
expect.arrayContaining(["leave.create", "leave.view_own", "leave.submit"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("grants leave approval only to configured approver roles", () => {
|
||||
expect(hasPermission(resolvePermissionsForRoles(["manager"]), "leave.approve")).toBe(true);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["ceo"]), "leave.approve")).toBe(true);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["finance"]), "leave.approve")).toBe(false);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "leave.approve")).toBe(false);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["hr"]), "leave.approve")).toBe(false);
|
||||
});
|
||||
|
||||
it("grants hr leave view-all and manage permissions", () => {
|
||||
const hrPermissions = resolvePermissionsForRoles(["hr"]);
|
||||
|
||||
expect(hrPermissions).toEqual(expect.arrayContaining(["leave.view_all", "leave.manage"]));
|
||||
});
|
||||
|
||||
it("maps workflow entities to correct permission codes", () => {
|
||||
expect(resolveEntityPermission("reimbursement", "approve")).toBe("expense.approve");
|
||||
expect(resolveEntityPermission("reimbursement", "transfer")).toBe("expense.transfer");
|
||||
expect(resolveEntityPermission("leave", "approve")).toBe("leave.approve");
|
||||
expect(resolveEntityPermission("leave", "submit")).toBe("leave.submit");
|
||||
expect(resolveEntityPermission("unknown", "approve")).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
const workflowRoutes = require("../../../src/routes/workflow");
|
||||
|
||||
describe("Workflow Route Permissions", () => {
|
||||
const { listAllowedActionsForTask } = workflowRoutes.__testables;
|
||||
|
||||
it("allows leave approvers to act on leave tasks", () => {
|
||||
const actions = listAllowedActionsForTask(
|
||||
{ employee: { permissionCodes: ["leave.approve"] } },
|
||||
{
|
||||
status: "pending",
|
||||
workflow_instance: { entity_type: "leave" },
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
expect(actions).toEqual(["approve", "reject", "return"]);
|
||||
});
|
||||
|
||||
it("does not allow expense approvers to act on leave tasks without leave.approve", () => {
|
||||
const actions = listAllowedActionsForTask(
|
||||
{ employee: { permissionCodes: ["expense.approve"] } },
|
||||
{
|
||||
status: "pending",
|
||||
workflow_instance: { entity_type: "leave" },
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps transfer action limited to reimbursement tasks", () => {
|
||||
const reimbursementActions = listAllowedActionsForTask(
|
||||
{ employee: { permissionCodes: ["expense.approve", "expense.transfer"] } },
|
||||
{
|
||||
status: "pending",
|
||||
workflow_instance: { entity_type: "reimbursement" },
|
||||
},
|
||||
true,
|
||||
);
|
||||
const leaveActions = listAllowedActionsForTask(
|
||||
{ employee: { permissionCodes: ["leave.approve", "expense.transfer"] } },
|
||||
{
|
||||
status: "pending",
|
||||
workflow_instance: { entity_type: "leave" },
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
expect(reimbursementActions).toContain("transfer");
|
||||
expect(leaveActions).not.toContain("transfer");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户