登录修复还未升级

main
kangwenjing 2026-06-12 10:10:07 +08:00
parent 4b308e8381
commit f72fb86c16
2 changed files with 127 additions and 6 deletions

View File

@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0 * 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 { BrowserRouter, Navigate, Routes, Route } from "react-router-dom";
import Layout from "./components/Layout"; import Layout from "./components/Layout";
import Dashboard from "./pages/Dashboard"; import Dashboard from "./pages/Dashboard";
@ -15,7 +15,7 @@ import OwnerTransfer from "./pages/OwnerTransfer";
import { ThemeProvider } from "./components/ThemeProvider"; import { ThemeProvider } from "./components/ThemeProvider";
import LoginPage from "./pages/Login"; import LoginPage from "./pages/Login";
import WecomLoginCallbackPage from "./pages/WecomLoginCallback"; import WecomLoginCallbackPage from "./pages/WecomLoginCallback";
import { isAuthed } from "./lib/auth"; import { isAuthed, startAuthSessionMonitor } from "./lib/auth";
function RequireAuth({ children }: { children: ReactNode }) { function RequireAuth({ children }: { children: ReactNode }) {
if (!isAuthed()) { if (!isAuthed()) {
@ -26,6 +26,8 @@ function RequireAuth({ children }: { children: ReactNode }) {
} }
export default function App() { export default function App() {
useEffect(() => startAuthSessionMonitor(), []);
return ( return (
<ThemeProvider defaultTheme="light" storageKey="crm-theme"> <ThemeProvider defaultTheme="light" storageKey="crm-theme">
<BrowserRouter> <BrowserRouter>

View File

@ -411,6 +411,7 @@ export interface OpportunityItem {
name?: string; name?: string;
client?: string; client?: string;
owner?: string; owner?: string;
createdAt?: string;
updatedAt?: string; updatedAt?: string;
projectLocation?: string; projectLocation?: string;
operatorCode?: string; operatorCode?: string;
@ -519,6 +520,7 @@ export interface SalesExpansionItem {
ownerUserId?: number; ownerUserId?: number;
owner?: string; owner?: string;
type: "sales"; type: "sales";
createdAt?: string;
employeeNo?: string; employeeNo?: string;
name?: string; name?: string;
officeCode?: string; officeCode?: string;
@ -559,6 +561,7 @@ export interface ChannelExpansionItem {
ownerUserId?: number; ownerUserId?: number;
owner?: string; owner?: string;
type: "channel"; type: "channel";
createdAt?: string;
channelCode?: string; channelCode?: string;
name?: string; name?: string;
provinceCode?: 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 PROFILE_OVERVIEW_CACHE_KEY = "auth-cache:profile-overview";
const memoryRequestCache = new Map<string, { expiresAt: number; value: unknown }>(); const memoryRequestCache = new Map<string, { expiresAt: number; value: unknown }>();
const inFlightRequestCache = new Map<string, Promise<unknown>>(); const inFlightRequestCache = new Map<string, Promise<unknown>>();
let authExpiryTimer: number | null = null;
type AccessTokenPayload = {
exp?: number;
userId?: number;
tenantId?: number;
};
function isSuccessCode(code: unknown) { function isSuccessCode(code: unknown) {
return code === "0" || code === "200" || code === 0 || code === 200; return code === "0" || code === "200" || code === 0 || code === 200;
} }
function clearAuthExpiryTimer() {
if (authExpiryTimer !== null) {
window.clearTimeout(authExpiryTimer);
authExpiryTimer = null;
}
}
function decodeJwtPayload<T>(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<AccessTokenPayload>(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) { function applyAuthHeaders(headers: Headers) {
const token = localStorage.getItem("accessToken"); const token = localStorage.getItem("accessToken");
if (token) { if (token) {
@ -785,7 +855,26 @@ function applyAuthHeaders(headers: Headers) {
function handleUnauthorizedResponse() { function handleUnauthorizedResponse() {
clearAuth(); 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<T>(rawText: string, contentType?: string | null): (ApiEnvelope<T> & ApiErrorBody) | null { function tryParseApiBody<T>(rawText: string, contentType?: string | null): (ApiEnvelope<T> & ApiErrorBody) | null {
@ -899,10 +988,12 @@ export async function fetchWithAuth(input: string, init?: RequestInit) {
} }
export function isAuthed() { export function isAuthed() {
return Boolean(localStorage.getItem("accessToken")); const token = localStorage.getItem("accessToken");
return Boolean(token) && !hasAccessTokenExpired(token);
} }
export function clearAuth() { export function clearAuth() {
clearAuthExpiryTimer();
localStorage.removeItem("accessToken"); localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken"); localStorage.removeItem("refreshToken");
localStorage.removeItem("username"); localStorage.removeItem("username");
@ -921,7 +1012,7 @@ export function persistLogin(payload: TokenResponse, username: string) {
if (payload.availableTenants) { if (payload.availableTenants) {
localStorage.setItem("availableTenants", JSON.stringify(payload.availableTenants)); localStorage.setItem("availableTenants", JSON.stringify(payload.availableTenants));
try { try {
const tokenPayload = JSON.parse(atob(payload.accessToken.split(".")[1])); const tokenPayload = getAccessTokenPayload(payload.accessToken);
if (tokenPayload?.tenantId !== undefined) { if (tokenPayload?.tenantId !== undefined) {
localStorage.setItem("activeTenantId", String(tokenPayload.tenantId)); localStorage.setItem("activeTenantId", String(tokenPayload.tenantId));
} }
@ -929,6 +1020,8 @@ export function persistLogin(payload: TokenResponse, username: string) {
localStorage.removeItem("activeTenantId"); localStorage.removeItem("activeTenantId");
} }
} }
syncAuthExpiryTimer();
} }
function getStoredUserId() { function getStoredUserId() {
@ -950,7 +1043,7 @@ function getStoredUserId() {
return undefined; 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)) { if (typeof tokenPayload.userId === "number" && Number.isFinite(tokenPayload.userId)) {
return tokenPayload.userId; return tokenPayload.userId;
} }
@ -965,6 +1058,32 @@ export function getStoredCurrentUserId() {
return getStoredUserId(); 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() { export async function fetchCaptcha() {
return request<CaptchaResponse>("/api/sys/auth/captcha"); return request<CaptchaResponse>("/api/sys/auth/captcha");
} }