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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user