fix(auth): align session lifecycle with Keycloak

This commit is contained in:
2026-08-05 17:10:59 +08:00
parent 5d9c40e454
commit e496fbe4b7
20 changed files with 339 additions and 24 deletions
+15
View File
@@ -57,6 +57,7 @@ import {
import { useNotification } from "@refinedev/core";
import { config } from "@config/config";
import { apiFetch } from "@/lib/apiFetch";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { useProjectStore } from "@/store/projectStore";
type MetadataUser = {
@@ -479,6 +480,20 @@ export const SystemAdminPanel = () => {
const [databaseForms, setDatabaseForms] = useState(createDefaultDatabaseForms);
const [databaseHealth, setDatabaseHealth] = useState(createEmptyDatabaseHealth);
const recoveryDraft = useMemo(
() => ({ tab, projectId, memberForm, projectForm, createProjectOpen, createProjectForm }),
[createProjectForm, createProjectOpen, memberForm, projectForm, projectId, tab],
);
const restoreRecoveryDraft = useCallback((draft: typeof recoveryDraft) => {
setTab(draft.tab);
setProjectId(draft.projectId);
setMemberForm(draft.memberForm);
setProjectForm(draft.projectForm);
setCreateProjectOpen(draft.createProjectOpen);
setCreateProjectForm(draft.createProjectForm);
}, []);
useSessionRecoveryDraft("system-admin", recoveryDraft, restoreRecoveryDraft);
useEffect(() => {
openNotificationRef.current = openNotification;
}, [openNotification]);
@@ -0,0 +1,89 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { signIn } from "next-auth/react";
import AccessTimeOutlinedIcon from "@mui/icons-material/AccessTimeOutlined";
import {
Alert,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Stack,
Typography,
} from "@mui/material";
import { useAuthStore } from "@/store/authStore";
const WARNING_WINDOW_MS = 15 * 60 * 1000;
type SessionExpiryDialogProps = {
expiresAt?: number;
};
export const SessionExpiryDialog = ({
expiresAt,
}: SessionExpiryDialogProps) => {
const sessionExpired = useAuthStore((state) => state.sessionExpired);
const reason = useAuthStore((state) => state.sessionExpiryReason);
const [now, setNow] = useState(() => Date.now());
const [warningDismissed, setWarningDismissed] = useState(false);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 30_000);
return () => window.clearInterval(timer);
}, []);
const isExpiringSoon = useMemo(
() =>
!sessionExpired &&
!warningDismissed &&
typeof expiresAt === "number" &&
expiresAt > now &&
expiresAt - now <= WARNING_WINDOW_MS,
[expiresAt, now, sessionExpired, warningDismissed],
);
const handleReauthenticate = () => {
const callbackUrl = `${window.location.pathname}${window.location.search}`;
void signIn("keycloak", { callbackUrl, redirect: true });
};
const isOpen = sessionExpired || isExpiringSoon;
const title = sessionExpired ? "登录已过期" : "登录即将到期";
const detail = sessionExpired
? reason === "session_max_age"
? "已达到 12 小时的最长连续登录时间。请重新认证后继续。"
: "无法续期当前登录。请重新认证后继续。"
: "当前登录将在 15 分钟内到期。请先保存正在编辑的内容。";
return (
<Dialog
open={isOpen}
disableEscapeKeyDown={sessionExpired}
onClose={sessionExpired ? undefined : () => setWarningDismissed(true)}
aria-labelledby="session-expiry-dialog-title"
>
<DialogTitle id="session-expiry-dialog-title">{title}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ pt: 0.5 }}>
<Alert icon={<AccessTimeOutlinedIcon />} severity={sessionExpired ? "warning" : "info"}>
{detail}
</Alert>
<Typography variant="body2" color="text.secondary">
</Typography>
</Stack>
</DialogContent>
<DialogActions>
{!sessionExpired && (
<Button onClick={() => setWarningDismissed(true)}></Button>
)}
<Button variant="contained" onClick={handleReauthenticate}>
</Button>
</DialogActions>
</Dialog>
);
};
@@ -20,6 +20,7 @@ import "dayjs/locale/zh-cn";
import { api } from "@/lib/api";
import { NETWORK_NAME } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { BurstDetectionResult } from "./types";
interface Props {
@@ -121,6 +122,12 @@ const AnalysisParameters: React.FC<Props> = ({
} = parametersState;
const [running, setRunning] = useState(false);
const [frequencyLoading, setFrequencyLoading] = useState(false);
useSessionRecoveryDraft("burst-detection", parametersState, (draft) =>
setParametersState({
...draft,
targetTime: draft.targetTime ? dayjs(draft.targetTime) : null,
}),
);
useEffect(() => {
if (samplingIntervalSource !== "metadata") return;
@@ -26,6 +26,7 @@ import "dayjs/locale/zh-cn";
import { api } from "@/lib/api";
import { NETWORK_NAME, config } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
import { BurstLocationResult } from "./types";
import { getBurstLocationErrorNotice } from "./burstLocationError";
@@ -105,6 +106,13 @@ const AnalysisParameters: React.FC<Props> = ({
} = parametersState;
const [schemeLoading, setSchemeLoading] = useState(false);
const [running, setRunning] = useState(false);
useSessionRecoveryDraft("burst-location", parametersState, (draft) =>
setParametersState({
...draft,
burstStartTime: draft.burstStartTime ? dayjs(draft.burstStartTime) : null,
burstEndTime: draft.burstEndTime ? dayjs(draft.burstEndTime) : null,
}),
);
const isSimulationMode = dataSource === "simulation";
const applySchemeTimeRange = useCallback((scheme: SchemeItem) => {
@@ -29,6 +29,7 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { config, NETWORK_NAME } from "@/config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { along, lineString, length, toMercator } from "@turf/turf";
import { Point } from "ol/geom";
import { toLonLat } from "ol/proj";
@@ -74,6 +75,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
);
const { pipePoints, startTime, duration, schemeName, network } =
parametersState;
useSessionRecoveryDraft("burst-simulation", parametersState, (draft) =>
setParametersState({
...draft,
startTime: draft.startTime ? dayjs(draft.startTime) : null,
}),
);
const [isSelecting, setIsSelecting] = useState<boolean>(false);
const [highlightLayer, setHighlightLayer] =
@@ -29,6 +29,7 @@ import {
queryFeaturesByIds,
} from "@/utils/mapQueryService";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
export interface ContaminantAnalysisParametersState {
schemeName: string;
@@ -62,7 +63,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
const { open } = useNotification();
const network = NETWORK_NAME;
const [parametersState, , setFormField] = useControllableObjectState(
const [parametersState, setParametersState, setFormField] = useControllableObjectState(
state,
onStateChange,
createContaminantAnalysisParametersState(),
@@ -75,6 +76,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
duration,
pattern,
} = parametersState;
useSessionRecoveryDraft("contaminant-simulation", parametersState, (draft) =>
setParametersState({
...draft,
startTime: draft.startTime ? dayjs(draft.startTime) : null,
}),
);
const [isSelecting, setIsSelecting] = useState<boolean>(false);
const [submitting, setSubmitting] = useState<boolean>(false);
@@ -20,6 +20,7 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { config } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { LeakageResultDetail } from "./types";
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
@@ -58,7 +59,7 @@ const AnalysisParameters: React.FC<Props> = ({
onStateChange,
}) => {
const { open } = useNotification();
const [parametersState, , setFormField] = useControllableObjectState(
const [parametersState, setParametersState, setFormField] = useControllableObjectState(
state,
onStateChange,
createDMALeakAnalysisParametersState(),
@@ -73,6 +74,13 @@ const AnalysisParameters: React.FC<Props> = ({
qSum,
advancedOpen,
} = parametersState;
useSessionRecoveryDraft("dma-leak-detection", parametersState, (draft) =>
setParametersState({
...draft,
startTime: draft.startTime ? dayjs(draft.startTime) : null,
endTime: draft.endTime ? dayjs(draft.endTime) : null,
}),
);
const [running, setRunning] = useState(false);
const [qSumInput, setQSumInput] = useState(() => String(qSum));
@@ -35,6 +35,7 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { config, NETWORK_NAME } from "@/config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
export interface ValveItem {
@@ -80,6 +81,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
);
const { schemeName, valves, drainageNode, startTime, flushFlow, duration } =
parametersState;
useSessionRecoveryDraft("flushing-analysis", parametersState, (draft) =>
setParametersState({
...draft,
startTime: draft.startTime ? dayjs(draft.startTime) : null,
}),
);
const [valveFeatures, setValveFeatures] = useState<Feature[]>([]);
const [drainageFeature, setDrainageFeature] = useState<Feature | null>(null);