feat(frontend): enforce RBAC and refine burst analysis

This commit is contained in:
2026-07-30 16:45:10 +08:00
parent f355ddd002
commit 723931b6ae
15 changed files with 772 additions and 390 deletions
+256 -193
View File
@@ -1,32 +1,38 @@
"use client";
import { Refine, type AuthProvider } from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import {
RefineSnackbarProvider,
} from "@refinedev/mui";
Refine,
type AccessControlProvider,
type AuthProvider,
} from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import { RefineSnackbarProvider } from "@refinedev/mui";
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
import { usePathname } from "next/navigation";
import React, { useEffect, useState } from "react";
import React, { useEffect } from "react";
import routerProvider from "@refinedev/nextjs-router";
import { ColorModeContextProvider } from "@contexts/color-mode";
import { dataProvider } from "@providers/data-provider";
import { ProjectProvider } from "@/contexts/ProjectContext";
import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard";
import { useAuthStore } from "@/store/authStore";
import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore";
import { apiFetch } from "@/lib/apiFetch";
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
import { config } from "@config/config";
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
import { LiaNetworkWiredSolid } from "react-icons/lia";
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
import { TbActivity, TbDatabaseEdit, TbLocationPin } from "react-icons/tb";
import { LuReplace } from "react-icons/lu";
import { AiOutlineSecurityScan } from "react-icons/ai";
import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md";
import { MdCleaningServices, MdOutlineWaterDrop } from "react-icons/md";
import {
ManageAccounts as ManageAccountsIcon,
FactCheck as FactCheckIcon,
ManageAccounts as ManageAccountsIcon,
MyLocation as MyLocationIcon,
Search as SearchIcon,
} from "@mui/icons-material";
@@ -36,16 +42,12 @@ type RefineContextProps = {
};
export const RefineContext = (
props: React.PropsWithChildren<RefineContextProps>
) => {
return (
<SessionProvider>
<ProjectProvider>
<App {...props} />
</ProjectProvider>
</SessionProvider>
);
};
props: React.PropsWithChildren<RefineContextProps>,
) => (
<SessionProvider>
<App {...props} />
</SessionProvider>
);
type AppProps = {
defaultMode?: string;
@@ -55,40 +57,71 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
const { data, status } = useSession();
const to = usePathname();
const setAccessToken = useAuthStore((state) => state.setAccessToken);
const [isMetadataAdmin, setIsMetadataAdmin] = useState(false);
const currentProjectId = useProjectStore((state) => state.currentProjectId);
const permissions = useAccessStore((state) => state.permissions);
const setAccessContext = useAccessStore((state) => state.setContext);
const setAccessLoading = useAccessStore((state) => state.setLoading);
const resetAccess = useAccessStore((state) => state.reset);
const can = (permission: string) => permissions.includes(permission);
useEffect(() => {
setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null);
setAccessToken(
typeof data?.accessToken === "string" ? data.accessToken : null,
);
}, [data?.accessToken, setAccessToken]);
useEffect(() => {
if (status !== "authenticated") {
setIsMetadataAdmin(false);
resetAccess();
return;
}
let cancelled = false;
apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
projectHeaderMode: "omit",
setAccessLoading(true);
apiFetch(`${config.BACKEND_URL}/api/v1/access/context`, {
projectHeaderMode: currentProjectId ? "include" : "omit",
skipAuthRedirect: true,
})
.then(async (response) => {
if (cancelled) return;
if (!response.ok) {
setIsMetadataAdmin(false);
resetAccess();
return;
}
const payload = await response.json();
setIsMetadataAdmin(Boolean(payload?.is_superuser || payload?.role === "admin"));
setAccessContext(await response.json());
})
.catch(() => {
if (!cancelled) setIsMetadataAdmin(false);
if (!cancelled) resetAccess();
});
return () => {
cancelled = true;
};
}, [status]);
}, [
currentProjectId,
resetAccess,
setAccessContext,
setAccessLoading,
status,
]);
useEffect(() => {
if (status !== "authenticated" || !data?.user?.id) return;
const auditKey = `tjwater-login-audit:${data.user.id}`;
if (sessionStorage.getItem(auditKey)) return;
apiFetch(`${config.BACKEND_URL}/api/v1/audit/session-events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "login" }),
projectHeaderMode: "omit",
skipAuthRedirect: true,
})
.then((response) => {
if (response.ok) sessionStorage.setItem(auditKey, "1");
})
.catch(() => undefined);
}, [data?.user?.id, status]);
if (status === "loading") {
return <span>loading...</span>;
@@ -100,200 +133,230 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
callbackUrl: to ? to.toString() : "/",
redirect: true,
});
return {
success: true,
};
return { success: true };
},
logout: async () => {
signOut({
redirect: true,
callbackUrl: "/login",
});
return {
success: true,
};
try {
await apiFetch(`${config.BACKEND_URL}/api/v1/audit/session-events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "logout" }),
projectHeaderMode: "omit",
skipAuthRedirect: true,
});
} catch {
// Logout must still complete when audit storage is unavailable.
}
if (data?.user?.id) {
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
}
signOut({ redirect: true, callbackUrl: "/login" });
return { success: true };
},
onError: async (error) => {
if (error.response?.status === 401) {
return {
logout: true,
};
return { logout: true };
}
return {
error,
};
},
check: async () => {
if (status === "unauthenticated") {
return {
authenticated: false,
redirectTo: "/login",
};
}
return {
authenticated: true,
};
},
getPermissions: async () => {
return null;
return { error };
},
check: async () =>
status === "unauthenticated"
? { authenticated: false, redirectTo: "/login" }
: { authenticated: true },
getPermissions: async () => permissions,
getIdentity: async () => {
if (data?.user) {
const { user } = data;
return {
id: user.id,
username: user.username,
name: user.name,
avatar: user.image,
};
}
return null;
if (!data?.user) return null;
return {
id: data.user.id,
username: data.user.username,
name: data.user.name,
avatar: data.user.image,
};
},
};
const defaultMode = props?.defaultMode;
const accessControlProvider: AccessControlProvider = {
can: async ({ resource }) => {
const requiredPermission = resource
? resourcePermissions[resource]
: undefined;
return {
can: !requiredPermission || permissions.includes(requiredPermission),
reason: requiredPermission
? `需要权限:${requiredPermission}`
: undefined,
};
},
};
const resources = [
...(can(permissionCodes.simulationView)
? [
{
name: "管网在线模拟",
list: "/network-simulation",
meta: {
icon: <LiaNetworkWiredSolid className="w-6 h-6" />,
label: "管网在线模拟",
},
},
]
: []),
...(can(permissionCodes.scadaClean)
? [
{
name: "SCADA 数据清洗",
list: "/scada-data-cleaning",
meta: {
icon: <TbDatabaseEdit className="w-6 h-6" />,
label: "SCADA 数据清洗",
},
},
]
: []),
...(can(permissionCodes.optimizationRun)
? [
{
name: "监测点优化布置",
list: "/monitoring-place-optimization",
meta: {
icon: <LuReplace className="w-6 h-6" />,
label: "监测点优化布置",
},
},
]
: []),
...(can(permissionCodes.riskRun)
? [
{
name: "健康风险分析",
list: "/health-risk-analysis",
meta: {
icon: <AiOutlineSecurityScan className="w-6 h-6" />,
label: "健康风险分析",
},
},
]
: []),
...(can(permissionCodes.simulationRun) || can(permissionCodes.burstRun)
? [
{
name: "Hydraulic Simulation",
meta: { label: "事件模拟" },
},
]
: []),
...(can(permissionCodes.burstRun)
? [
{
name: "爆管模拟",
list: "/hydraulic-simulation/burst-simulation",
meta: {
parent: "Hydraulic Simulation",
icon: <TbLocationPin className="w-6 h-6" />,
label: "爆管模拟",
},
},
{
name: "爆管侦测",
list: "/hydraulic-simulation/burst-detection",
meta: {
parent: "Hydraulic Simulation",
icon: <TbActivity className="w-6 h-6" />,
label: "爆管侦测",
},
},
{
name: "爆管定位",
list: "/hydraulic-simulation/burst-location",
meta: {
parent: "Hydraulic Simulation",
icon: <MyLocationIcon className="w-6 h-6" />,
label: "爆管定位",
},
},
{
name: "DMA 漏损识别",
list: "/hydraulic-simulation/dma-leak-detection",
meta: {
parent: "Hydraulic Simulation",
icon: <SearchIcon className="w-6 h-6" />,
label: "DMA 漏损识别",
},
},
]
: []),
...(can(permissionCodes.simulationRun)
? [
{
name: "水质模拟",
list: "/hydraulic-simulation/contaminant-simulation",
meta: {
parent: "Hydraulic Simulation",
icon: <MdOutlineWaterDrop className="w-6 h-6" />,
label: "水质模拟",
},
},
{
name: "管道冲洗",
list: "/hydraulic-simulation/flushing-analysis",
meta: {
parent: "Hydraulic Simulation",
icon: <MdCleaningServices className="w-6 h-6" />,
label: "管道冲洗",
},
},
]
: []),
...(can(permissionCodes.environmentManage)
? [
{
name: "系统管理",
list: "/system-admin",
meta: {
icon: <ManageAccountsIcon className="w-6 h-6" />,
label: "系统管理",
},
},
]
: []),
...(can(permissionCodes.auditView)
? [
{
name: "审计日志",
list: "/audit-logs",
meta: {
icon: <FactCheckIcon className="w-6 h-6" />,
label: "审计日志",
},
},
]
: []),
];
return (
<>
<ProjectProvider>
<RefineKbarProvider>
<ColorModeContextProvider defaultMode={defaultMode}>
<ColorModeContextProvider defaultMode={props.defaultMode}>
<RefineSnackbarProvider>
<Refine
routerProvider={routerProvider}
dataProvider={dataProvider}
notificationProvider={useAppNotificationProvider}
authProvider={authProvider}
resources={[
{
name: "管网在线模拟",
list: "/network-simulation",
meta: {
icon: <LiaNetworkWiredSolid className="w-6 h-6" />,
label: "管网在线模拟",
},
},
{
name: "SCADA 数据清洗",
list: "/scada-data-cleaning",
meta: {
icon: <TbDatabaseEdit className="w-6 h-6" />,
label: "SCADA 数据清洗",
},
},
{
name: "监测点优化布置",
list: "/monitoring-place-optimization",
meta: {
icon: <LuReplace className="w-6 h-6" />,
label: "监测点优化布置",
},
},
{
name: "健康风险分析",
list: "/health-risk-analysis",
meta: {
icon: <AiOutlineSecurityScan className="w-6 h-6" />,
label: "健康风险分析",
},
},
{
name: "Hydraulic Simulation",
meta: {
// icon: <MdWater className="w-6 h-6" />,
label: "事件模拟",
},
},
{
name: "爆管模拟",
list: "/hydraulic-simulation/burst-simulation",
meta: {
parent: "Hydraulic Simulation",
icon: <TbLocationPin className="w-6 h-6" />,
label: "爆管模拟",
},
},
{
name: "爆管侦测",
list: "/hydraulic-simulation/burst-detection",
meta: {
parent: "Hydraulic Simulation",
icon: <TbActivity className="w-6 h-6" />,
label: "爆管侦测",
},
},
{
name: "爆管定位",
list: "/hydraulic-simulation/burst-location",
meta: {
parent: "Hydraulic Simulation",
icon: <MyLocationIcon className="w-6 h-6" />,
label: "爆管定位",
},
},
{
name: "DMA 漏损识别",
list: "/hydraulic-simulation/dma-leak-detection",
meta: {
parent: "Hydraulic Simulation",
icon: <SearchIcon className="w-6 h-6" />,
label: "DMA 漏损识别",
},
},
{
name: "水质模拟",
list: "/hydraulic-simulation/contaminant-simulation",
meta: {
parent: "Hydraulic Simulation",
icon: <MdOutlineWaterDrop className="w-6 h-6" />,
label: "水质模拟",
},
},
{
name: "管道冲洗",
list: "/hydraulic-simulation/flushing-analysis",
meta: {
parent: "Hydraulic Simulation",
icon: <MdCleaningServices className="w-6 h-6" />,
label: "管道冲洗",
},
},
...(isMetadataAdmin
? [
{
name: "系统管理",
list: "/system-admin",
meta: {
icon: <ManageAccountsIcon className="w-6 h-6" />,
label: "系统管理",
},
},
{
name: "审计日志",
list: "/audit-logs",
meta: {
icon: <FactCheckIcon className="w-6 h-6" />,
label: "审计日志",
},
},
]
: []),
]}
accessControlProvider={accessControlProvider}
resources={resources}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
}}
>
{props.children}
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
<RefineKbar />
</Refine>
</RefineSnackbarProvider>
</ColorModeContextProvider>
</RefineKbarProvider>
</>
</ProjectProvider>
);
};