403 lines
12 KiB
TypeScript
403 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
Refine,
|
|
type AccessControlProvider,
|
|
type AuthProvider,
|
|
} from "@refinedev/core";
|
|
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
|
import { RefineSnackbarProvider } from "@refinedev/mui";
|
|
import { SessionProvider, signIn, useSession } from "next-auth/react";
|
|
import { usePathname } from "next/navigation";
|
|
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 { SessionExpiryDialog } from "@/components/auth/SessionExpiryDialog";
|
|
import { useAuthStore } from "@/store/authStore";
|
|
import { useAccessStore } from "@/store/accessStore";
|
|
import { useProjectStore } from "@/store/projectStore";
|
|
import { apiFetch } from "@/lib/apiFetch";
|
|
import { completeLogout, reportLogoutAudit } from "@/lib/logoutFlow";
|
|
import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
|
|
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
|
|
import { config } from "@config/config";
|
|
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
|
|
import { supportsThreeDimensionalScene } from "@components/threeDimensional/sceneData";
|
|
|
|
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
|
import { TbActivity, TbDatabaseEdit, TbLocationPin } from "react-icons/tb";
|
|
import { LuReplace } from "react-icons/lu";
|
|
import { AiOutlineSecurityScan } from "react-icons/ai";
|
|
import { MdCleaningServices, MdOutlineWaterDrop } from "react-icons/md";
|
|
import {
|
|
FactCheck as FactCheckIcon,
|
|
ManageAccounts as ManageAccountsIcon,
|
|
MyLocation as MyLocationIcon,
|
|
Search as SearchIcon,
|
|
ViewInAr as ViewInArIcon,
|
|
} from "@mui/icons-material";
|
|
|
|
type RefineContextProps = {
|
|
defaultMode?: string;
|
|
};
|
|
|
|
export const RefineContext = (
|
|
props: React.PropsWithChildren<RefineContextProps>,
|
|
) => (
|
|
<SessionProvider>
|
|
<App {...props} />
|
|
</SessionProvider>
|
|
);
|
|
|
|
type AppProps = {
|
|
defaultMode?: string;
|
|
};
|
|
|
|
export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|
const { data, status } = useSession();
|
|
const to = usePathname();
|
|
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
|
const markSessionExpired = useAuthStore((state) => state.markSessionExpired);
|
|
const clearSessionExpired = useAuthStore((state) => state.clearSessionExpired);
|
|
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
|
const currentProjectCode = useProjectStore(
|
|
(state) => state.currentProjectCode,
|
|
);
|
|
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,
|
|
);
|
|
}, [data?.accessToken, setAccessToken]);
|
|
|
|
useEffect(() => {
|
|
if (data?.error === "SessionExpired") {
|
|
markSessionExpired("session_max_age");
|
|
return;
|
|
}
|
|
if (data?.error === "RefreshAccessTokenError") {
|
|
markSessionExpired("refresh_failed");
|
|
return;
|
|
}
|
|
if (status === "unauthenticated") {
|
|
markSessionExpired("unauthorized");
|
|
return;
|
|
}
|
|
if (status === "authenticated") {
|
|
clearSessionExpired();
|
|
}
|
|
}, [clearSessionExpired, data?.error, markSessionExpired, status]);
|
|
|
|
useEffect(() => {
|
|
if (status !== "authenticated") {
|
|
resetAccess();
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
setAccessLoading(true);
|
|
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
|
|
projectHeaderMode: currentProjectId ? "include" : "omit",
|
|
})
|
|
.then(async (response) => {
|
|
if (cancelled) return;
|
|
if (!response.ok) {
|
|
resetAccess();
|
|
return;
|
|
}
|
|
setAccessContext(await response.json());
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) resetAccess();
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [
|
|
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-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>;
|
|
}
|
|
|
|
const authProvider: AuthProvider = {
|
|
login: async () => {
|
|
signIn("keycloak", {
|
|
callbackUrl: to ? to.toString() : "/",
|
|
redirect: true,
|
|
});
|
|
return { success: true };
|
|
},
|
|
logout: () =>
|
|
completeLogout({
|
|
reportAudit: () =>
|
|
reportLogoutAudit({
|
|
endpoint: `${config.BACKEND_URL}/api/v1/audit-events`,
|
|
accessToken:
|
|
typeof data?.accessToken === "string"
|
|
? data.accessToken
|
|
: useAuthStore.getState().accessToken,
|
|
}),
|
|
clearLocalState: () => {
|
|
if (data?.user?.id) {
|
|
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
|
|
}
|
|
clearSessionRecoveryDrafts();
|
|
},
|
|
navigate: (path) => window.location.assign(path),
|
|
}),
|
|
onError: async (error) => {
|
|
return { error };
|
|
},
|
|
check: async () =>
|
|
status === "unauthenticated"
|
|
? { authenticated: false, redirectTo: "/login" }
|
|
: { authenticated: true },
|
|
getPermissions: async () => permissions,
|
|
getIdentity: async () => {
|
|
if (!data?.user) return null;
|
|
return {
|
|
id: data.user.id,
|
|
username: data.user.username,
|
|
name: data.user.name,
|
|
avatar: data.user.image,
|
|
};
|
|
},
|
|
};
|
|
|
|
const accessControlProvider: AccessControlProvider = {
|
|
can: async ({ resource }) => {
|
|
const requiredPermission = resource
|
|
? resourcePermissions[resource]
|
|
: undefined;
|
|
return {
|
|
can: !requiredPermission || permissions.includes(requiredPermission),
|
|
reason: requiredPermission
|
|
? `需要权限:${requiredPermission}`
|
|
: undefined,
|
|
};
|
|
},
|
|
};
|
|
|
|
const resources = [
|
|
...(supportsThreeDimensionalScene(currentProjectCode) &&
|
|
can(permissionCodes.webgisView)
|
|
? [
|
|
{
|
|
name: "三维场景",
|
|
list: "/three-dimensional-scene",
|
|
meta: {
|
|
icon: <ViewInArIcon />,
|
|
label: "三维场景",
|
|
},
|
|
},
|
|
]
|
|
: []),
|
|
...(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={props.defaultMode}>
|
|
<RefineSnackbarProvider>
|
|
<Refine
|
|
routerProvider={routerProvider}
|
|
dataProvider={dataProvider}
|
|
notificationProvider={useAppNotificationProvider}
|
|
authProvider={authProvider}
|
|
accessControlProvider={accessControlProvider}
|
|
resources={resources}
|
|
options={{
|
|
syncWithLocation: true,
|
|
warnWhenUnsavedChanges: true,
|
|
}}
|
|
>
|
|
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
|
<RoutePermissionGuard authenticated={status === "authenticated"}>
|
|
{props.children}
|
|
</RoutePermissionGuard>
|
|
<RefineKbar />
|
|
</Refine>
|
|
</RefineSnackbarProvider>
|
|
</ColorModeContextProvider>
|
|
</RefineKbarProvider>
|
|
</ProjectProvider>
|
|
);
|
|
};
|