From f72fb86c168b3f81c24cc489df959c8bf5c6a868 Mon Sep 17 00:00:00 2001 From: kangwenjing <1138819403@qq.com> Date: Fri, 12 Jun 2026 10:10:07 +0800 Subject: [PATCH] =?UTF-8?q?=E7=99=BB=E5=BD=95=E4=BF=AE=E5=A4=8D=E8=BF=98?= =?UTF-8?q?=E6=9C=AA=E5=8D=87=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 6 +- frontend/src/lib/auth.ts | 127 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 127 insertions(+), 6 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 55a2b281..49e74d29 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { ReactNode } from "react"; +import { useEffect, type ReactNode } from "react"; import { BrowserRouter, Navigate, Routes, Route } from "react-router-dom"; import Layout from "./components/Layout"; import Dashboard from "./pages/Dashboard"; @@ -15,7 +15,7 @@ import OwnerTransfer from "./pages/OwnerTransfer"; import { ThemeProvider } from "./components/ThemeProvider"; import LoginPage from "./pages/Login"; import WecomLoginCallbackPage from "./pages/WecomLoginCallback"; -import { isAuthed } from "./lib/auth"; +import { isAuthed, startAuthSessionMonitor } from "./lib/auth"; function RequireAuth({ children }: { children: ReactNode }) { if (!isAuthed()) { @@ -26,6 +26,8 @@ function RequireAuth({ children }: { children: ReactNode }) { } export default function App() { + useEffect(() => startAuthSessionMonitor(), []); + return ( diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 8024c765..51c9578c 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -411,6 +411,7 @@ export interface OpportunityItem { name?: string; client?: string; owner?: string; + createdAt?: string; updatedAt?: string; projectLocation?: string; operatorCode?: string; @@ -519,6 +520,7 @@ export interface SalesExpansionItem { ownerUserId?: number; owner?: string; type: "sales"; + createdAt?: string; employeeNo?: string; name?: string; officeCode?: string; @@ -559,6 +561,7 @@ export interface ChannelExpansionItem { ownerUserId?: number; owner?: string; type: "channel"; + createdAt?: string; channelCode?: string; name?: string; provinceCode?: string; @@ -759,11 +762,78 @@ const CURRENT_USER_CACHE_KEY = "auth-cache:current-user"; const PROFILE_OVERVIEW_CACHE_KEY = "auth-cache:profile-overview"; const memoryRequestCache = new Map(); const inFlightRequestCache = new Map>(); +let authExpiryTimer: number | null = null; + +type AccessTokenPayload = { + exp?: number; + userId?: number; + tenantId?: number; +}; function isSuccessCode(code: unknown) { return code === "0" || code === "200" || code === 0 || code === 200; } +function clearAuthExpiryTimer() { + if (authExpiryTimer !== null) { + window.clearTimeout(authExpiryTimer); + authExpiryTimer = null; + } +} + +function decodeJwtPayload(token: string): T | null { + try { + const [, payloadSegment] = token.split("."); + if (!payloadSegment) { + return null; + } + + const normalizedPayload = payloadSegment + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(Math.ceil(payloadSegment.length / 4) * 4, "="); + + return JSON.parse(atob(normalizedPayload)) as T; + } catch { + return null; + } +} + +function getAccessTokenPayload(token?: string | null) { + if (!token) { + return null; + } + + return decodeJwtPayload(token); +} + +function getAccessTokenExpiresAt(token?: string | null) { + const payload = getAccessTokenPayload(token); + if (!payload || typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) { + return null; + } + + return payload.exp * 1000; +} + +function hasAccessTokenExpired(token?: string | null, now = Date.now()) { + const expiresAt = getAccessTokenExpiresAt(token); + if (expiresAt === null) { + return true; + } + + return expiresAt <= now; +} + +function redirectToLoginForTimeout() { + const currentUrl = new URL(window.location.href); + if (currentUrl.pathname === LOGIN_PATH && currentUrl.searchParams.get("timeout") === "1") { + return; + } + + window.location.replace(`${LOGIN_PATH}?timeout=1`); +} + function applyAuthHeaders(headers: Headers) { const token = localStorage.getItem("accessToken"); if (token) { @@ -785,7 +855,26 @@ function applyAuthHeaders(headers: Headers) { function handleUnauthorizedResponse() { clearAuth(); - window.location.href = `${LOGIN_PATH}?timeout=1`; + redirectToLoginForTimeout(); +} + +function syncAuthExpiryTimer() { + clearAuthExpiryTimer(); + + const token = localStorage.getItem("accessToken"); + if (!token) { + return; + } + + const expiresAt = getAccessTokenExpiresAt(token); + if (expiresAt === null || expiresAt <= Date.now()) { + handleUnauthorizedResponse(); + return; + } + + authExpiryTimer = window.setTimeout(() => { + handleUnauthorizedResponse(); + }, expiresAt - Date.now()); } function tryParseApiBody(rawText: string, contentType?: string | null): (ApiEnvelope & ApiErrorBody) | null { @@ -899,10 +988,12 @@ export async function fetchWithAuth(input: string, init?: RequestInit) { } export function isAuthed() { - return Boolean(localStorage.getItem("accessToken")); + const token = localStorage.getItem("accessToken"); + return Boolean(token) && !hasAccessTokenExpired(token); } export function clearAuth() { + clearAuthExpiryTimer(); localStorage.removeItem("accessToken"); localStorage.removeItem("refreshToken"); localStorage.removeItem("username"); @@ -921,7 +1012,7 @@ export function persistLogin(payload: TokenResponse, username: string) { if (payload.availableTenants) { localStorage.setItem("availableTenants", JSON.stringify(payload.availableTenants)); try { - const tokenPayload = JSON.parse(atob(payload.accessToken.split(".")[1])); + const tokenPayload = getAccessTokenPayload(payload.accessToken); if (tokenPayload?.tenantId !== undefined) { localStorage.setItem("activeTenantId", String(tokenPayload.tenantId)); } @@ -929,6 +1020,8 @@ export function persistLogin(payload: TokenResponse, username: string) { localStorage.removeItem("activeTenantId"); } } + + syncAuthExpiryTimer(); } function getStoredUserId() { @@ -950,7 +1043,7 @@ function getStoredUserId() { return undefined; } - const tokenPayload = JSON.parse(atob(token.split(".")[1])) as { userId?: number }; + const tokenPayload = getAccessTokenPayload(token); if (typeof tokenPayload.userId === "number" && Number.isFinite(tokenPayload.userId)) { return tokenPayload.userId; } @@ -965,6 +1058,32 @@ export function getStoredCurrentUserId() { return getStoredUserId(); } +export function startAuthSessionMonitor() { + const syncIfVisible = () => { + if (document.visibilityState !== "hidden") { + syncAuthExpiryTimer(); + } + }; + + const handleStorageChange = (event: StorageEvent) => { + if (!event.key || event.key === "accessToken") { + syncAuthExpiryTimer(); + } + }; + + syncAuthExpiryTimer(); + document.addEventListener("visibilitychange", syncIfVisible); + window.addEventListener("focus", syncIfVisible); + window.addEventListener("storage", handleStorageChange); + + return () => { + clearAuthExpiryTimer(); + document.removeEventListener("visibilitychange", syncIfVisible); + window.removeEventListener("focus", syncIfVisible); + window.removeEventListener("storage", handleStorageChange); + }; +} + export async function fetchCaptcha() { return request("/api/sys/auth/captcha"); }