fix(auth): align session lifecycle with Keycloak
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
KEYCLOAK_CLIENT_ID="tjwater"
|
KEYCLOAK_CLIENT_ID="tjwater"
|
||||||
KEYCLOAK_CLIENT_SECRET="replace-with-keycloak-client-secret"
|
KEYCLOAK_CLIENT_SECRET="replace-with-keycloak-client-secret"
|
||||||
KEYCLOAK_ISSUER="https://keycloak.example.com/realms/tjwater"
|
KEYCLOAK_ISSUER="https://keycloak.example.com/realms/tjwater"
|
||||||
|
KEYCLOAK_POST_LOGOUT_REDIRECT_URI="https://frontend.example.com/login"
|
||||||
NEXTAUTH_SECRET="replace-with-nextauth-secret"
|
NEXTAUTH_SECRET="replace-with-nextauth-secret"
|
||||||
NEXTAUTH_URL="https://frontend.example.com/"
|
NEXTAUTH_URL="https://frontend.example.com/"
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ services:
|
|||||||
KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID}
|
KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID}
|
||||||
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET}
|
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET}
|
||||||
KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER}
|
KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER}
|
||||||
|
KEYCLOAK_POST_LOGOUT_REDIRECT_URI: ${KEYCLOAK_POST_LOGOUT_REDIRECT_URI}
|
||||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
||||||
NEXTAUTH_URL: ${NEXTAUTH_URL}
|
NEXTAUTH_URL: ${NEXTAUTH_URL}
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ import { ColorModeContextProvider } from "@contexts/color-mode";
|
|||||||
import { dataProvider } from "@providers/data-provider";
|
import { dataProvider } from "@providers/data-provider";
|
||||||
import { ProjectProvider } from "@/contexts/ProjectContext";
|
import { ProjectProvider } from "@/contexts/ProjectContext";
|
||||||
import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard";
|
import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard";
|
||||||
|
import { SessionExpiryDialog } from "@/components/auth/SessionExpiryDialog";
|
||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import { useAccessStore } from "@/store/accessStore";
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
import { useProjectStore } from "@/store/projectStore";
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
|
||||||
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
|
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
|
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
|
||||||
@@ -57,6 +59,8 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
const { data, status } = useSession();
|
const { data, status } = useSession();
|
||||||
const to = usePathname();
|
const to = usePathname();
|
||||||
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
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 currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||||
const permissions = useAccessStore((state) => state.permissions);
|
const permissions = useAccessStore((state) => state.permissions);
|
||||||
const setAccessContext = useAccessStore((state) => state.setContext);
|
const setAccessContext = useAccessStore((state) => state.setContext);
|
||||||
@@ -70,6 +74,20 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
);
|
);
|
||||||
}, [data?.accessToken, setAccessToken]);
|
}, [data?.accessToken, setAccessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.error === "SessionExpired") {
|
||||||
|
markSessionExpired("session_max_age");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data?.error === "RefreshAccessTokenError") {
|
||||||
|
markSessionExpired("refresh_failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status === "authenticated") {
|
||||||
|
clearSessionExpired();
|
||||||
|
}
|
||||||
|
}, [clearSessionExpired, data?.error, markSessionExpired, status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== "authenticated") {
|
if (status !== "authenticated") {
|
||||||
resetAccess();
|
resetAccess();
|
||||||
@@ -150,13 +168,11 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
if (data?.user?.id) {
|
if (data?.user?.id) {
|
||||||
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
|
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
|
||||||
}
|
}
|
||||||
|
clearSessionRecoveryDrafts();
|
||||||
window.location.assign("/api/auth/keycloak-logout");
|
window.location.assign("/api/auth/keycloak-logout");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
onError: async (error) => {
|
onError: async (error) => {
|
||||||
if (error.response?.status === 401) {
|
|
||||||
return { logout: true };
|
|
||||||
}
|
|
||||||
return { error };
|
return { error };
|
||||||
},
|
},
|
||||||
check: async () =>
|
check: async () =>
|
||||||
@@ -351,6 +367,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
warnWhenUnsavedChanges: true,
|
warnWhenUnsavedChanges: true,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
||||||
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
|
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
|
||||||
<RefineKbar />
|
<RefineKbar />
|
||||||
</Refine>
|
</Refine>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { JWT } from "next-auth/jwt";
|
|||||||
import KeycloakProvider from "next-auth/providers/keycloak";
|
import KeycloakProvider from "next-auth/providers/keycloak";
|
||||||
import Avatar from "@assets/avatar/avatar-small.jpeg";
|
import Avatar from "@assets/avatar/avatar-small.jpeg";
|
||||||
|
|
||||||
|
const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60;
|
||||||
|
const ACCESS_TOKEN_REFRESH_SKEW_MS = 30_000;
|
||||||
|
|
||||||
type KeycloakTokenResponse = {
|
type KeycloakTokenResponse = {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
@@ -50,6 +53,7 @@ const refreshAccessToken = async (token: JWT): Promise<JWT> => {
|
|||||||
return {
|
return {
|
||||||
...token,
|
...token,
|
||||||
accessToken: refreshed.access_token,
|
accessToken: refreshed.access_token,
|
||||||
|
accessTokenIssuedAt: Date.now(),
|
||||||
accessTokenExpires: Date.now() + refreshed.expires_in * 1000,
|
accessTokenExpires: Date.now() + refreshed.expires_in * 1000,
|
||||||
refreshToken: refreshed.refresh_token ?? token.refreshToken,
|
refreshToken: refreshed.refresh_token ?? token.refreshToken,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
@@ -88,8 +92,10 @@ const authOptions: NextAuthOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
|
token.sessionExpiresAt = Date.now() + SESSION_MAX_AGE_SECONDS * 1000;
|
||||||
if (account.access_token) {
|
if (account.access_token) {
|
||||||
token.accessToken = account.access_token;
|
token.accessToken = account.access_token;
|
||||||
|
token.accessTokenIssuedAt = Date.now();
|
||||||
}
|
}
|
||||||
if (account.refresh_token) {
|
if (account.refresh_token) {
|
||||||
token.refreshToken = account.refresh_token;
|
token.refreshToken = account.refresh_token;
|
||||||
@@ -104,7 +110,19 @@ const authOptions: NextAuthOptions = {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof token.accessTokenExpires === "number" && Date.now() < token.accessTokenExpires - 30_000) {
|
if (
|
||||||
|
typeof token.sessionExpiresAt === "number" &&
|
||||||
|
Date.now() >= token.sessionExpiresAt
|
||||||
|
) {
|
||||||
|
return { ...token, error: "SessionExpired" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessTokenIsFresh =
|
||||||
|
typeof token.accessTokenExpires === "number" &&
|
||||||
|
typeof token.accessTokenIssuedAt === "number" &&
|
||||||
|
Date.now() < token.accessTokenExpires - ACCESS_TOKEN_REFRESH_SKEW_MS;
|
||||||
|
|
||||||
|
if (accessTokenIsFresh) {
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,9 +141,19 @@ const authOptions: NextAuthOptions = {
|
|||||||
if (token.error) {
|
if (token.error) {
|
||||||
session.error = token.error;
|
session.error = token.error;
|
||||||
}
|
}
|
||||||
|
if (typeof token.sessionExpiresAt === "number") {
|
||||||
|
session.sessionExpiresAt = token.sessionExpiresAt;
|
||||||
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
session: {
|
||||||
|
strategy: "jwt",
|
||||||
|
maxAge: SESSION_MAX_AGE_SECONDS,
|
||||||
|
},
|
||||||
|
jwt: {
|
||||||
|
maxAge: SESSION_MAX_AGE_SECONDS,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default authOptions;
|
export default authOptions;
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||||
import { useProjectStore } from "@/store/projectStore";
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
|
|
||||||
type MetadataUser = {
|
type MetadataUser = {
|
||||||
@@ -479,6 +480,20 @@ export const SystemAdminPanel = () => {
|
|||||||
const [databaseForms, setDatabaseForms] = useState(createDefaultDatabaseForms);
|
const [databaseForms, setDatabaseForms] = useState(createDefaultDatabaseForms);
|
||||||
const [databaseHealth, setDatabaseHealth] = useState(createEmptyDatabaseHealth);
|
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(() => {
|
useEffect(() => {
|
||||||
openNotificationRef.current = openNotification;
|
openNotificationRef.current = openNotification;
|
||||||
}, [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 { api } from "@/lib/api";
|
||||||
import { NETWORK_NAME } from "@config/config";
|
import { NETWORK_NAME } from "@config/config";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
|
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||||
import { BurstDetectionResult } from "./types";
|
import { BurstDetectionResult } from "./types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -121,6 +122,12 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
} = parametersState;
|
} = parametersState;
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [frequencyLoading, setFrequencyLoading] = useState(false);
|
const [frequencyLoading, setFrequencyLoading] = useState(false);
|
||||||
|
useSessionRecoveryDraft("burst-detection", parametersState, (draft) =>
|
||||||
|
setParametersState({
|
||||||
|
...draft,
|
||||||
|
targetTime: draft.targetTime ? dayjs(draft.targetTime) : null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (samplingIntervalSource !== "metadata") return;
|
if (samplingIntervalSource !== "metadata") return;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import "dayjs/locale/zh-cn";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { NETWORK_NAME, config } from "@config/config";
|
import { NETWORK_NAME, config } from "@config/config";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
|
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||||
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
||||||
import { BurstLocationResult } from "./types";
|
import { BurstLocationResult } from "./types";
|
||||||
import { getBurstLocationErrorNotice } from "./burstLocationError";
|
import { getBurstLocationErrorNotice } from "./burstLocationError";
|
||||||
@@ -105,6 +106,13 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
} = parametersState;
|
} = parametersState;
|
||||||
const [schemeLoading, setSchemeLoading] = useState(false);
|
const [schemeLoading, setSchemeLoading] = useState(false);
|
||||||
const [running, setRunning] = 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 isSimulationMode = dataSource === "simulation";
|
||||||
|
|
||||||
const applySchemeTimeRange = useCallback((scheme: SchemeItem) => {
|
const applySchemeTimeRange = useCallback((scheme: SchemeItem) => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { useNotification } from "@refinedev/core";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { config, NETWORK_NAME } from "@/config/config";
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
|
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||||
import { along, lineString, length, toMercator } from "@turf/turf";
|
import { along, lineString, length, toMercator } from "@turf/turf";
|
||||||
import { Point } from "ol/geom";
|
import { Point } from "ol/geom";
|
||||||
import { toLonLat } from "ol/proj";
|
import { toLonLat } from "ol/proj";
|
||||||
@@ -74,6 +75,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
);
|
);
|
||||||
const { pipePoints, startTime, duration, schemeName, network } =
|
const { pipePoints, startTime, duration, schemeName, network } =
|
||||||
parametersState;
|
parametersState;
|
||||||
|
useSessionRecoveryDraft("burst-simulation", parametersState, (draft) =>
|
||||||
|
setParametersState({
|
||||||
|
...draft,
|
||||||
|
startTime: draft.startTime ? dayjs(draft.startTime) : null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const [isSelecting, setIsSelecting] = useState<boolean>(false);
|
const [isSelecting, setIsSelecting] = useState<boolean>(false);
|
||||||
|
|
||||||
const [highlightLayer, setHighlightLayer] =
|
const [highlightLayer, setHighlightLayer] =
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
queryFeaturesByIds,
|
queryFeaturesByIds,
|
||||||
} from "@/utils/mapQueryService";
|
} from "@/utils/mapQueryService";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
|
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||||
|
|
||||||
export interface ContaminantAnalysisParametersState {
|
export interface ContaminantAnalysisParametersState {
|
||||||
schemeName: string;
|
schemeName: string;
|
||||||
@@ -62,7 +63,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
|
|
||||||
const network = NETWORK_NAME;
|
const network = NETWORK_NAME;
|
||||||
const [parametersState, , setFormField] = useControllableObjectState(
|
const [parametersState, setParametersState, setFormField] = useControllableObjectState(
|
||||||
state,
|
state,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
createContaminantAnalysisParametersState(),
|
createContaminantAnalysisParametersState(),
|
||||||
@@ -75,6 +76,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
duration,
|
duration,
|
||||||
pattern,
|
pattern,
|
||||||
} = parametersState;
|
} = parametersState;
|
||||||
|
useSessionRecoveryDraft("contaminant-simulation", parametersState, (draft) =>
|
||||||
|
setParametersState({
|
||||||
|
...draft,
|
||||||
|
startTime: draft.startTime ? dayjs(draft.startTime) : null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const [isSelecting, setIsSelecting] = useState<boolean>(false);
|
const [isSelecting, setIsSelecting] = useState<boolean>(false);
|
||||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useNotification } from "@refinedev/core";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
|
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||||
import { LeakageResultDetail } from "./types";
|
import { LeakageResultDetail } from "./types";
|
||||||
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
onStateChange,
|
onStateChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const [parametersState, , setFormField] = useControllableObjectState(
|
const [parametersState, setParametersState, setFormField] = useControllableObjectState(
|
||||||
state,
|
state,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
createDMALeakAnalysisParametersState(),
|
createDMALeakAnalysisParametersState(),
|
||||||
@@ -73,6 +74,13 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
qSum,
|
qSum,
|
||||||
advancedOpen,
|
advancedOpen,
|
||||||
} = parametersState;
|
} = 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 [running, setRunning] = useState(false);
|
||||||
const [qSumInput, setQSumInput] = useState(() => String(qSum));
|
const [qSumInput, setQSumInput] = useState(() => String(qSum));
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { useNotification } from "@refinedev/core";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { config, NETWORK_NAME } from "@/config/config";
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
|
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||||
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
export interface ValveItem {
|
export interface ValveItem {
|
||||||
@@ -80,6 +81,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
);
|
);
|
||||||
const { schemeName, valves, drainageNode, startTime, flushFlow, duration } =
|
const { schemeName, valves, drainageNode, startTime, flushFlow, duration } =
|
||||||
parametersState;
|
parametersState;
|
||||||
|
useSessionRecoveryDraft("flushing-analysis", parametersState, (draft) =>
|
||||||
|
setParametersState({
|
||||||
|
...draft,
|
||||||
|
startTime: draft.startTime ? dayjs(draft.startTime) : null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const [valveFeatures, setValveFeatures] = useState<Feature[]>([]);
|
const [valveFeatures, setValveFeatures] = useState<Feature[]>([]);
|
||||||
const [drainageFeature, setDrainageFeature] = useState<Feature | null>(null);
|
const [drainageFeature, setDrainageFeature] = useState<Feature | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { resolveRequestUrl } from "@/lib/api";
|
import { resolveRequestUrl } from "@/lib/api";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
describe("resolveRequestUrl", () => {
|
describe("resolveRequestUrl", () => {
|
||||||
it("does not prepend baseURL to an absolute request URL", () => {
|
it("does not prepend baseURL to an absolute request URL", () => {
|
||||||
@@ -19,3 +20,23 @@ describe("resolveRequestUrl", () => {
|
|||||||
).toBe("http://localhost:8000/api/v1/schemes");
|
).toBe("http://localhost:8000/api/v1/schemes");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("authentication lifecycle state", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useAuthStore.setState({
|
||||||
|
accessToken: "access-token",
|
||||||
|
sessionExpired: false,
|
||||||
|
sessionExpiryReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears only the local access token when a request becomes unauthorized", () => {
|
||||||
|
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||||
|
|
||||||
|
expect(useAuthStore.getState()).toMatchObject({
|
||||||
|
accessToken: null,
|
||||||
|
sessionExpired: true,
|
||||||
|
sessionExpiryReason: "unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+1
-8
@@ -1,6 +1,5 @@
|
|||||||
import axios, { AxiosHeaders, type InternalAxiosRequestConfig } from "axios";
|
import axios, { AxiosHeaders, type InternalAxiosRequestConfig } from "axios";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
import { signOut } from "next-auth/react";
|
|
||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import {
|
import {
|
||||||
applyAuthContextHeaders,
|
applyAuthContextHeaders,
|
||||||
@@ -13,8 +12,6 @@ export const api = axios.create({
|
|||||||
baseURL: API_URL,
|
baseURL: API_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
let isSigningOut = false;
|
|
||||||
|
|
||||||
export const resolveRequestUrl = (request: {
|
export const resolveRequestUrl = (request: {
|
||||||
baseURL?: string;
|
baseURL?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -63,11 +60,7 @@ api.interceptors.response.use(
|
|||||||
},
|
},
|
||||||
async (error) => {
|
async (error) => {
|
||||||
if (error?.response?.status === 401 && typeof window !== "undefined") {
|
if (error?.response?.status === 401 && typeof window !== "undefined") {
|
||||||
useAuthStore.getState().setAccessToken(null);
|
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||||
if (!isSigningOut) {
|
|
||||||
isSigningOut = true;
|
|
||||||
await signOut({ redirect: true, callbackUrl: "/login" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-8
@@ -1,12 +1,9 @@
|
|||||||
import { signOut } from "next-auth/react";
|
|
||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import {
|
import {
|
||||||
applyAuthContextHeaders,
|
applyAuthContextHeaders,
|
||||||
type AuthContextHeaderOptions,
|
type AuthContextHeaderOptions,
|
||||||
} from "@/lib/requestHeaders";
|
} from "@/lib/requestHeaders";
|
||||||
|
|
||||||
let isSigningOut = false;
|
|
||||||
|
|
||||||
const unwrapPage = async (response: Response) => {
|
const unwrapPage = async (response: Response) => {
|
||||||
if (
|
if (
|
||||||
!response.headers.get("content-type")?.includes("application/json")
|
!response.headers.get("content-type")?.includes("application/json")
|
||||||
@@ -58,11 +55,7 @@ export const apiFetch = async (
|
|||||||
const response = await fetch(input, requestInit);
|
const response = await fetch(input, requestInit);
|
||||||
|
|
||||||
if (response.status === 401 && typeof window !== "undefined" && !init.skipAuthRedirect) {
|
if (response.status === 401 && typeof window !== "undefined" && !init.skipAuthRedirect) {
|
||||||
useAuthStore.getState().setAccessToken(null);
|
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||||
if (!isSigningOut) {
|
|
||||||
isSigningOut = true;
|
|
||||||
await signOut({ redirect: true, callbackUrl: "/login" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return unwrapPage(response);
|
return unwrapPage(response);
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export const getAccessToken = async () => {
|
|||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
}
|
}
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
|
if (session?.error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const token = typeof session?.accessToken === "string" ? session.accessToken : null;
|
const token = typeof session?.accessToken === "string" ? session.accessToken : null;
|
||||||
if (token && !isTokenExpired(token)) {
|
if (token && !isTokenExpired(token)) {
|
||||||
setAccessToken(token);
|
setAccessToken(token);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { act, render } from "@testing-library/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { useSessionRecoveryDraft } from "./sessionRecoveryDraft";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
|
const DraftFixture = ({ initial }: { initial: string }) => {
|
||||||
|
const [value, setValue] = useState(initial);
|
||||||
|
useSessionRecoveryDraft("fixture", { value }, (draft) => setValue(draft.value));
|
||||||
|
return <output>{value}</output>;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("useSessionRecoveryDraft", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
useAuthStore.setState({
|
||||||
|
accessToken: null,
|
||||||
|
sessionExpired: false,
|
||||||
|
sessionExpiryReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves an in-progress value when authentication expires and restores it once", () => {
|
||||||
|
const first = render(<DraftFixture initial="in-progress" />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem("tjwater-session-recovery:fixture")).toBe(
|
||||||
|
JSON.stringify({ value: "in-progress" }),
|
||||||
|
);
|
||||||
|
first.unmount();
|
||||||
|
|
||||||
|
useAuthStore.getState().clearSessionExpired();
|
||||||
|
const restored = render(<DraftFixture initial="empty" />);
|
||||||
|
|
||||||
|
expect(restored.getByText("in-progress")).toBeInTheDocument();
|
||||||
|
expect(sessionStorage.getItem("tjwater-session-recovery:fixture")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = "tjwater-session-recovery:";
|
||||||
|
|
||||||
|
const storageKey = (key: string) => `${STORAGE_PREFIX}${key}`;
|
||||||
|
|
||||||
|
export const clearSessionRecoveryDrafts = () => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
|
||||||
|
const key = sessionStorage.key(index);
|
||||||
|
if (key?.startsWith(STORAGE_PREFIX)) sessionStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSessionRecoveryDraft = <T,>(
|
||||||
|
key: string,
|
||||||
|
value: T,
|
||||||
|
restore: (value: T) => void,
|
||||||
|
) => {
|
||||||
|
const sessionExpired = useAuthStore((state) => state.sessionExpired);
|
||||||
|
const restoredRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (restoredRef.current || typeof window === "undefined") return;
|
||||||
|
restoredRef.current = true;
|
||||||
|
const raw = sessionStorage.getItem(storageKey(key));
|
||||||
|
if (!raw) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
restore(JSON.parse(raw) as T);
|
||||||
|
sessionStorage.removeItem(storageKey(key));
|
||||||
|
} catch {
|
||||||
|
sessionStorage.removeItem(storageKey(key));
|
||||||
|
}
|
||||||
|
}, [key, restore]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionExpired || typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(storageKey(key), JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// Recovery is best-effort. Do not block re-authentication when storage is unavailable.
|
||||||
|
}
|
||||||
|
}, [key, sessionExpired, value]);
|
||||||
|
};
|
||||||
@@ -2,10 +2,27 @@ import { create } from "zustand";
|
|||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
|
sessionExpired: boolean;
|
||||||
|
sessionExpiryReason: "refresh_failed" | "session_max_age" | "unauthorized" | null;
|
||||||
setAccessToken: (token: string | null) => void;
|
setAccessToken: (token: string | null) => void;
|
||||||
|
markSessionExpired: (
|
||||||
|
reason: Exclude<AuthState["sessionExpiryReason"], null>,
|
||||||
|
) => void;
|
||||||
|
clearSessionExpired: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set) => ({
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
|
sessionExpired: false,
|
||||||
|
sessionExpiryReason: null,
|
||||||
setAccessToken: (token) => set({ accessToken: token }),
|
setAccessToken: (token) => set({ accessToken: token }),
|
||||||
|
markSessionExpired: (reason) => set({
|
||||||
|
accessToken: null,
|
||||||
|
sessionExpired: true,
|
||||||
|
sessionExpiryReason: reason,
|
||||||
|
}),
|
||||||
|
clearSessionExpired: () => set({
|
||||||
|
sessionExpired: false,
|
||||||
|
sessionExpiryReason: null,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Vendored
+5
-2
@@ -4,7 +4,8 @@ import "next-auth/jwt";
|
|||||||
declare module "next-auth" {
|
declare module "next-auth" {
|
||||||
interface Session {
|
interface Session {
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
error?: "RefreshAccessTokenError";
|
error?: "RefreshAccessTokenError" | "SessionExpired";
|
||||||
|
sessionExpiresAt?: number;
|
||||||
user?: {
|
user?: {
|
||||||
id?: string;
|
id?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
@@ -26,7 +27,9 @@ declare module "next-auth/jwt" {
|
|||||||
username?: string;
|
username?: string;
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
|
accessTokenIssuedAt?: number;
|
||||||
accessTokenExpires?: number;
|
accessTokenExpires?: number;
|
||||||
error?: "RefreshAccessTokenError";
|
sessionExpiresAt?: number;
|
||||||
|
error?: "RefreshAccessTokenError" | "SessionExpired";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user