feat(admin-web): proactively refresh access tokens before expiry

Track access token expires_at, dedupe concurrent refreshes, retry SSE on
401, and refresh on window focus/visibility so long-lived sessions stop
hitting the login redirect mid-task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 16:48:15 +08:00
parent 4f3c39f5f5
commit 19527cdf42
6 changed files with 218 additions and 26 deletions
+78 -5
View File
@@ -3,7 +3,7 @@ import { computed, ref } from "vue";
import type { LoginRequest, UserInfo } from "@geo/shared-types";
import { authApi } from "@/lib/api";
import { authApi, refreshStoredAuthSession, shouldRefreshAccessToken } from "@/lib/api";
import {
clearStoredSession,
readStoredSession,
@@ -16,14 +16,79 @@ import {
export const useAuthStore = defineStore("auth", () => {
const accessToken = ref<string | null>(null);
const refreshToken = ref<string | null>(null);
const accessTokenExpiresAt = ref<number | null>(null);
const user = ref<UserInfo | null>(null);
const initialized = ref(false);
let unsubscribe: (() => void) | null = null;
let refreshTimer: number | null = null;
let lifecycleRefreshRegistered = false;
function clearRefreshTimer(): void {
if (refreshTimer) {
window.clearTimeout(refreshTimer);
refreshTimer = null;
}
}
function scheduleRefresh(): void {
clearRefreshTimer();
if (!refreshToken.value || typeof window === "undefined") {
return;
}
const nextRefreshAt = accessTokenExpiresAt.value
? accessTokenExpiresAt.value * 1000 - 2 * 60 * 1000
: Date.now() + 30 * 1000;
const delay = Math.max(30 * 1000, nextRefreshAt - Date.now());
refreshTimer = window.setTimeout(() => {
void refreshSession();
}, delay);
}
function sync(snapshot: SessionSnapshot = readStoredSession()): void {
accessToken.value = snapshot.accessToken;
refreshToken.value = snapshot.refreshToken;
accessTokenExpiresAt.value = snapshot.accessTokenExpiresAt;
user.value = snapshot.user;
scheduleRefresh();
}
async function refreshSession(): Promise<void> {
if (!refreshToken.value) {
return;
}
try {
await refreshStoredAuthSession(refreshToken.value);
} catch {
clearStoredSession({ notifyAuthExpired: true });
}
}
async function refreshSessionIfNeeded(minRemainingMs = 2 * 60 * 1000): Promise<void> {
if (!refreshToken.value) {
return;
}
if (!accessToken.value || shouldRefreshAccessToken(accessTokenExpiresAt.value, minRemainingMs)) {
await refreshSession();
}
}
function registerLifecycleRefresh(): void {
if (lifecycleRefreshRegistered || typeof window === "undefined") {
return;
}
lifecycleRefreshRegistered = true;
window.addEventListener("focus", () => {
void refreshSessionIfNeeded();
});
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
void refreshSessionIfNeeded();
}
});
}
async function bootstrap(): Promise<void> {
@@ -32,13 +97,19 @@ export const useAuthStore = defineStore("auth", () => {
}
sync();
if (accessToken.value) {
try {
registerLifecycleRefresh();
try {
if (refreshToken.value) {
await refreshSessionIfNeeded(Number.POSITIVE_INFINITY);
}
if (accessToken.value) {
const currentUser = await authApi.me();
setStoredUser(currentUser);
} catch {
clearStoredSession();
}
} catch {
clearStoredSession({ notifyAuthExpired: true });
}
initialized.value = true;
@@ -49,6 +120,7 @@ export const useAuthStore = defineStore("auth", () => {
setStoredTokens({
accessToken: response.access_token,
refreshToken: response.refresh_token,
expiresAt: response.expires_at,
});
setStoredUser(response.user);
return response.user;
@@ -73,6 +145,7 @@ export const useAuthStore = defineStore("auth", () => {
return {
accessToken,
refreshToken,
accessTokenExpiresAt,
user,
initialized,
isAuthenticated,