PA6 补齐权限与审计回归
这个提交包含在:
@@ -155,6 +155,17 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
admin: ["*"],
|
||||
};
|
||||
|
||||
export const BUSINESS_APPROVAL_PERMISSIONS = [
|
||||
"expense.approve",
|
||||
"leave.approve",
|
||||
"procurement.approve",
|
||||
"travel.approve",
|
||||
] as const;
|
||||
|
||||
const BUSINESS_APPROVER_ROLE_CODES = ["manager", "ceo", "finance", "finance_mgr", "hr"] as const;
|
||||
const FINANCE_ROLE_CODES = ["finance", "finance_mgr"] as const;
|
||||
const FINANCE_PORTAL_PERMISSIONS = ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"] as const;
|
||||
|
||||
function rolePermissions(roleCode: string): readonly string[] {
|
||||
return ROLE_PERMISSIONS[roleCode] ?? [];
|
||||
}
|
||||
@@ -183,6 +194,19 @@ export function hasAnyPermission(permissionCodes: string[], candidates: string[]
|
||||
return candidates.some((candidate) => hasPermission(permissionCodes, candidate));
|
||||
}
|
||||
|
||||
export function hasBusinessApproverRole(roleCodes: string[] | undefined, permissionCodes: string[]) {
|
||||
const hasBusinessRole = (roleCodes ?? []).some((roleCode) =>
|
||||
(BUSINESS_APPROVER_ROLE_CODES as readonly string[]).includes(roleCode),
|
||||
);
|
||||
|
||||
return hasBusinessRole && hasAnyPermission(permissionCodes, [...BUSINESS_APPROVAL_PERMISSIONS]);
|
||||
}
|
||||
|
||||
export function hasFinanceRole(roleCodes: string[] | undefined, permissionCodes: string[]) {
|
||||
const hasRole = (roleCodes ?? []).some((roleCode) => (FINANCE_ROLE_CODES as readonly string[]).includes(roleCode));
|
||||
return hasRole && hasAnyPermission(permissionCodes, [...FINANCE_PORTAL_PERMISSIONS]);
|
||||
}
|
||||
|
||||
export function isManagerRole(permissionCodes: string[]) {
|
||||
if (hasPermission(permissionCodes, "*") || hasPermission(permissionCodes, "system.users")) {
|
||||
return true;
|
||||
|
||||
@@ -2,11 +2,8 @@ import { NavLink } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchWorkflowTasks } from "../../api/workflow";
|
||||
import { useAuth } from "../../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../../auth/permissions";
|
||||
import {
|
||||
MOBILE_APPROVAL_PERMISSIONS,
|
||||
MOBILE_NAV_ITEMS,
|
||||
} from "../../features/mobile/navigation";
|
||||
import { hasAnyPermission, hasBusinessApproverRole } from "../../auth/permissions";
|
||||
import { MOBILE_NAV_ITEMS } from "../../features/mobile/navigation";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { MobileIcon } from "./MobileIcon";
|
||||
|
||||
@@ -18,8 +15,8 @@ function resolveBadgeLabel(count: number) {
|
||||
}
|
||||
|
||||
export function MobileBottomNav() {
|
||||
const { permissions } = useAuth();
|
||||
const canAccessApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
|
||||
const { me, permissions } = useAuth();
|
||||
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
||||
|
||||
const pendingCountQuery = useQuery({
|
||||
enabled: canAccessApprovals,
|
||||
@@ -40,7 +37,11 @@ export function MobileBottomNav() {
|
||||
>
|
||||
<div className="relative z-[71] grid grid-cols-4 gap-1">
|
||||
{MOBILE_NAV_ITEMS.map((item) => {
|
||||
const disabled = item.anyOf ? !hasAnyPermission(permissions, [...item.anyOf]) : false;
|
||||
const disabled = item.key === "approvals"
|
||||
? !canAccessApprovals
|
||||
: item.anyOf
|
||||
? !hasAnyPermission(permissions, [...item.anyOf])
|
||||
: false;
|
||||
const badge = item.key === "approvals" && pendingCount > 0 ? resolveBadgeLabel(pendingCount) : null;
|
||||
|
||||
if (disabled) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NavLink } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchSystemNavigation, type NavigationItem } from "../../api/workspace";
|
||||
import { useAuth } from "../../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../../auth/permissions";
|
||||
import { hasAnyPermission, hasBusinessApproverRole, hasFinanceRole } from "../../auth/permissions";
|
||||
import {
|
||||
ADMIN_NAV_ORDER,
|
||||
BACKUP_NAV_ORDER,
|
||||
@@ -32,7 +32,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/people", label: "人员管理", icon: "group", anyOf: ["system.users", "report.dashboard.view"] },
|
||||
{ to: "/organization", label: "组织架构", icon: "account_tree", anyOf: ["system.users", "report.dashboard.view"] },
|
||||
{ to: "/system/workflow-definitions", label: "流程定义", icon: "schema", anyOf: ["system.settings"] },
|
||||
{ to: "/system/settings", label: "系统设置", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.view", "masterdata.manage", "notification.channel.manage", "integration.manage"] },
|
||||
{ to: "/system/settings", label: "系统设置", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage"] },
|
||||
{ to: "/system/feature-flags", label: "功能开关", icon: "toggle_on", anyOf: ["system.settings"] },
|
||||
{ to: "/audit-logs", label: "审计日志", icon: "manage_search", anyOf: ["system.audit.view"] },
|
||||
{ to: "/workflow/tasks", label: "审批待办", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"] },
|
||||
@@ -136,9 +136,12 @@ function renderBackupSection(items: ResolvedNavItem[], onNavigate?: () => void)
|
||||
);
|
||||
}
|
||||
|
||||
function buildFallbackItems(permissions: string[]) {
|
||||
function buildFallbackItems(permissions: string[], roleCodes: string[]) {
|
||||
const financeOnlyPaths = new Set(["/payment-batches", "/ap-operations", "/invoice-pool"]);
|
||||
const mapItems = (paths: string[]) =>
|
||||
NAV_ITEMS.filter((item) => paths.includes(item.to))
|
||||
.filter((item) => item.to !== "/workflow/tasks" || hasBusinessApproverRole(roleCodes, permissions))
|
||||
.filter((item) => !financeOnlyPaths.has(item.to) || hasFinanceRole(roleCodes, permissions))
|
||||
.filter((item) => !item.anyOf || hasAnyPermission(permissions, item.anyOf))
|
||||
.map((item) => ({ key: item.to, href: item.to, label: item.label, icon: item.icon }));
|
||||
|
||||
@@ -184,7 +187,7 @@ function normalizeSections(sections: { main: NavigationItem[]; admin: Navigation
|
||||
}
|
||||
|
||||
export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const { permissions } = useAuth();
|
||||
const { me, permissions } = useAuth();
|
||||
const navigationQuery = useQuery({
|
||||
queryKey: ["system-navigation"],
|
||||
queryFn: fetchSystemNavigation,
|
||||
@@ -193,7 +196,7 @@ export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
|
||||
const sections = navigationQuery.data?.sections
|
||||
? normalizeSections(navigationQuery.data.sections)
|
||||
: buildFallbackItems(permissions);
|
||||
: buildFallbackItems(permissions, me?.roleCodes ?? []);
|
||||
|
||||
return (
|
||||
<nav className="space-y-5">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fetchEmployeeProfile } from "../api/employees";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions";
|
||||
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||
@@ -52,8 +52,15 @@ function MobileActionLink({
|
||||
|
||||
export function MobileMePage() {
|
||||
const { me, permissions, refreshMe, logout } = useAuth();
|
||||
const canAccessApprovals = hasAnyPermission(permissions, ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"]);
|
||||
const canAccessAdmin = hasAnyPermission(permissions, ["*", "system.settings", "system.users", "report.dashboard.view"]);
|
||||
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
||||
const canAccessAdmin = hasAnyPermission(permissions, [
|
||||
"*",
|
||||
"system.settings",
|
||||
"system.users",
|
||||
"masterdata.manage",
|
||||
"notification.channel.manage",
|
||||
"integration.manage",
|
||||
]);
|
||||
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
||||
const canAccessProjects = hasAnyPermission(permissions, ["project.view_own", "project.view_all", "project.manage"]);
|
||||
|
||||
|
||||
@@ -12,13 +12,12 @@ import { fetchNotifications } from "../api/notifications";
|
||||
import { isApiError } from "../api/types";
|
||||
import { fetchWorkflowTasks } from "../api/workflow";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
MOBILE_APPROVAL_PERMISSIONS,
|
||||
MOBILE_CAPTURE_PERMISSIONS,
|
||||
MOBILE_LEAVE_PERMISSIONS,
|
||||
MOBILE_REIMBURSEMENT_PERMISSIONS,
|
||||
@@ -155,8 +154,8 @@ function ActionTile({ action }: { action: MobileAction }) {
|
||||
|
||||
export function MobilePortalPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
const canAccessApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
|
||||
const { me, permissions } = useAuth();
|
||||
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
||||
const canAccessCapture = hasAnyPermission(permissions, [...MOBILE_CAPTURE_PERMISSIONS]);
|
||||
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
|
||||
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
|
||||
@@ -217,14 +216,16 @@ export function MobilePortalPage() {
|
||||
const unreadNotificationCount = notifications.filter((item) => !item.readAt).length;
|
||||
|
||||
const actions: MobileAction[] = [
|
||||
{
|
||||
key: "approvals",
|
||||
label: "审批",
|
||||
meta: pendingApprovalCount > 0 ? `${pendingApprovalCount} 条待处理` : "暂无待办",
|
||||
icon: "fact_check",
|
||||
to: "/mobile/approvals",
|
||||
tone: pendingApprovalCount > 0 ? "warning" : "primary",
|
||||
},
|
||||
canAccessApprovals
|
||||
? {
|
||||
key: "approvals",
|
||||
label: "审批",
|
||||
meta: pendingApprovalCount > 0 ? `${pendingApprovalCount} 条待处理` : "暂无待办",
|
||||
icon: "fact_check",
|
||||
to: "/mobile/approvals",
|
||||
tone: pendingApprovalCount > 0 ? "warning" : "primary",
|
||||
}
|
||||
: null,
|
||||
canAccessCapture
|
||||
? {
|
||||
key: "capture",
|
||||
|
||||
+15
-1
@@ -1,7 +1,7 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions";
|
||||
import { resolveDefaultWorkspacePath } from "../lib/runtimeContext";
|
||||
import { isOfflineSnapshotAllowedPath } from "../pwa";
|
||||
|
||||
@@ -78,3 +78,17 @@ export function RequirePermission({
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function RequireBusinessApprover({ children }: PropsWithChildren) {
|
||||
const { me, permissions, ready } = useAuth();
|
||||
|
||||
if (!ready) {
|
||||
return <FullPageLoading />;
|
||||
}
|
||||
|
||||
if (!hasBusinessApproverRole(me?.roleCodes, permissions)) {
|
||||
return <Navigate to="/403" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
+11
-7
@@ -68,7 +68,7 @@ import {
|
||||
WorkflowTaskDetailPage,
|
||||
WorkflowTasksPage,
|
||||
} from "./lazy";
|
||||
import { GuestOnly, RequireAuth, RequirePermission } from "./guards";
|
||||
import { GuestOnly, RequireAuth, RequireBusinessApprover, RequirePermission } from "./guards";
|
||||
|
||||
function withRouteFallback(node: ReactNode) {
|
||||
return <Suspense fallback={<RoutePageFallback />}>{node}</Suspense>;
|
||||
@@ -277,18 +277,18 @@ export function AppRouter() {
|
||||
<Route
|
||||
path="workflow/tasks"
|
||||
element={
|
||||
<RequirePermission anyOf={["expense.approve", "leave.approve", "procurement.approve", "travel.approve"]}>
|
||||
<RequireBusinessApprover>
|
||||
{withRouteFallback(<WorkflowTasksPage />)}
|
||||
</RequirePermission>
|
||||
</RequireBusinessApprover>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="workflow/tasks/:taskId"
|
||||
element={
|
||||
<RequirePermission anyOf={["expense.approve", "leave.approve", "procurement.approve", "travel.approve"]}>
|
||||
<RequireBusinessApprover>
|
||||
{withRouteFallback(<WorkflowTaskDetailPage />)}
|
||||
</RequirePermission>
|
||||
</RequireBusinessApprover>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -358,7 +358,11 @@ export function AppRouter() {
|
||||
|
||||
<Route
|
||||
path="mobile/approvals"
|
||||
element={withRouteFallback(<MobileApprovalsPage />)}
|
||||
element={
|
||||
<RequireBusinessApprover>
|
||||
{withRouteFallback(<MobileApprovalsPage />)}
|
||||
</RequireBusinessApprover>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="notifications" element={withRouteFallback(<NotificationsPage />)} />
|
||||
@@ -485,7 +489,7 @@ export function AppRouter() {
|
||||
<Route
|
||||
path="system/settings"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.settings", "system.users", "masterdata.view", "masterdata.manage", "notification.channel.manage", "integration.manage"]}>
|
||||
<RequirePermission anyOf={["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage"]}>
|
||||
{withRouteFallback(<SystemSettingsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户