Files
geo/apps/admin-web/src/stores/auth.ts
T
root d1f2a14477 feat(auth): extend refresh TTL and keep session on transient errors
Bumps refresh token lifetime to 720h with env overrides, and stops
clearing the local session for non-401 refresh failures so users aren't
logged out by transient network blips. Adds a 60s retry timer when a
refresh fails for non-auth reasons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:09:01 +08:00

183 lines
4.7 KiB
TypeScript

import { defineStore } from "pinia";
import { computed, ref } from "vue";
import type { LoginRequest, UserInfo } from "@geo/shared-types";
import {
authApi,
isAuthSessionExpiredError,
refreshStoredAuthSession,
shouldRefreshAccessToken,
} from "@/lib/api";
import {
clearStoredSession,
readStoredSession,
setStoredTokens,
setStoredUser,
subscribeStoredSession,
type SessionSnapshot,
} from "@/lib/session";
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 (error) {
if (isAuthSessionExpiredError(error)) {
clearStoredSession({ notifyAuthExpired: true });
return;
}
scheduleRefreshRetry();
}
}
function scheduleRefreshRetry(): void {
clearRefreshTimer();
if (!refreshToken.value || typeof window === "undefined") {
return;
}
refreshTimer = window.setTimeout(() => {
void refreshSession();
}, 60 * 1000);
}
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> {
if (!unsubscribe) {
unsubscribe = subscribeStoredSession(sync);
}
sync();
registerLifecycleRefresh();
try {
if (refreshToken.value) {
await refreshSessionIfNeeded(Number.POSITIVE_INFINITY);
}
if (accessToken.value) {
const currentUser = await authApi.me();
setStoredUser(currentUser);
}
} catch (error) {
if (isAuthSessionExpiredError(error)) {
clearStoredSession({ notifyAuthExpired: true });
}
}
initialized.value = true;
}
async function login(payload: LoginRequest): Promise<UserInfo> {
const response = await authApi.login(payload);
setStoredTokens({
accessToken: response.access_token,
refreshToken: response.refresh_token,
expiresAt: response.expires_at,
});
setStoredUser(response.user);
return response.user;
}
async function logout(): Promise<void> {
try {
if (accessToken.value) {
await authApi.logout();
}
} finally {
clearStoredSession();
}
}
const isAuthenticated = computed(() => Boolean(accessToken.value && user.value));
const membership = computed(() => user.value?.membership ?? null);
const isMembershipBlocked = computed(() => Boolean(membership.value?.blocked));
const kolProfile = computed(() => user.value?.kol_profile ?? null);
const isActiveKol = computed(() => kolProfile.value?.status === "active");
return {
accessToken,
refreshToken,
accessTokenExpiresAt,
user,
initialized,
isAuthenticated,
membership,
isMembershipBlocked,
kolProfile,
isActiveKol,
bootstrap,
login,
logout,
};
});