d1f2a14477
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>
183 lines
4.7 KiB
TypeScript
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,
|
|
};
|
|
});
|