mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-26 21:39:43 +02:00
fix(web):centralize session api handling
This commit is contained in:
parent
fcf2485863
commit
411bb0019e
2 changed files with 98 additions and 6 deletions
54
surfsense_web/hooks/use-session.ts
Normal file
54
surfsense_web/hooks/use-session.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { buildBackendUrl } from "@/lib/env-config";
|
||||||
|
|
||||||
|
type SessionState =
|
||||||
|
| { status: "loading"; authenticated: false; accessExpiresAt: null }
|
||||||
|
| { status: "authenticated"; authenticated: true; accessExpiresAt: number }
|
||||||
|
| { status: "unauthenticated"; authenticated: false; accessExpiresAt: null };
|
||||||
|
|
||||||
|
export function useSession() {
|
||||||
|
const [state, setState] = useState<SessionState>({
|
||||||
|
status: "loading",
|
||||||
|
authenticated: false,
|
||||||
|
accessExpiresAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(buildBackendUrl("/auth/session"), {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
setState({
|
||||||
|
status: "unauthenticated",
|
||||||
|
authenticated: false,
|
||||||
|
accessExpiresAt: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
authenticated: boolean;
|
||||||
|
access_expires_at: number;
|
||||||
|
};
|
||||||
|
setState({
|
||||||
|
status: "authenticated",
|
||||||
|
authenticated: true,
|
||||||
|
accessExpiresAt: data.access_expires_at,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setState({
|
||||||
|
status: "unauthenticated",
|
||||||
|
authenticated: false,
|
||||||
|
accessExpiresAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return { ...state, refresh };
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,25 @@ enum ResponseType {
|
||||||
// Add more response types as needed
|
// Add more response types as needed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REFRESH_RETRY_BLOCK_MS = 30_000;
|
||||||
|
const refreshRetryBlockedUntil = new Map<string, number>();
|
||||||
|
|
||||||
|
function getRefreshRetryKey(method: RequestOptions["method"], url: string): string {
|
||||||
|
return `${method}:${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRefreshRetryBlocked(key: string): boolean {
|
||||||
|
const blockedUntil = refreshRetryBlockedUntil.get(key);
|
||||||
|
if (!blockedUntil) return false;
|
||||||
|
if (Date.now() < blockedUntil) return true;
|
||||||
|
refreshRetryBlockedUntil.delete(key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockRefreshRetry(key: string): void {
|
||||||
|
refreshRetryBlockedUntil.set(key, Date.now() + REFRESH_RETRY_BLOCK_MS);
|
||||||
|
}
|
||||||
|
|
||||||
export type RequestOptions = {
|
export type RequestOptions = {
|
||||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
|
@ -31,7 +50,12 @@ export type RequestOptions = {
|
||||||
};
|
};
|
||||||
|
|
||||||
class BaseApiService {
|
class BaseApiService {
|
||||||
noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"];
|
noAuthEndpoints: string[] = [
|
||||||
|
"/auth/jwt/login",
|
||||||
|
"/auth/register",
|
||||||
|
"/auth/refresh",
|
||||||
|
"/auth/jwt/refresh",
|
||||||
|
];
|
||||||
|
|
||||||
// Prefixes that don't require auth (checked with startsWith)
|
// Prefixes that don't require auth (checked with startsWith)
|
||||||
noAuthPrefixes: string[] = ["/api/v1/public/"];
|
noAuthPrefixes: string[] = ["/api/v1/public/"];
|
||||||
|
|
@ -42,6 +66,10 @@ class BaseApiService {
|
||||||
return typeof window !== "undefined" ? getBearerToken() || "" : "";
|
return typeof window !== "undefined" ? getBearerToken() || "" : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isDesktopClient(): boolean {
|
||||||
|
return typeof window !== "undefined" && !!window.electronAPI;
|
||||||
|
}
|
||||||
|
|
||||||
// Keep for backward compatibility, but token is now always read from localStorage
|
// Keep for backward compatibility, but token is now always read from localStorage
|
||||||
setBearerToken(_bearerToken: string) {
|
setBearerToken(_bearerToken: string) {
|
||||||
void _bearerToken;
|
void _bearerToken;
|
||||||
|
|
@ -71,7 +99,9 @@ class BaseApiService {
|
||||||
*/
|
*/
|
||||||
const defaultOptions: RequestOptions = {
|
const defaultOptions: RequestOptions = {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${this.bearerToken || ""}`,
|
...(this.isDesktopClient && this.bearerToken
|
||||||
|
? { Authorization: `Bearer ${this.bearerToken}` }
|
||||||
|
: {}),
|
||||||
"X-SurfSense-Client-Platform":
|
"X-SurfSense-Client-Platform":
|
||||||
typeof window === "undefined" ? "web" : getClientPlatform(),
|
typeof window === "undefined" ? "web" : getClientPlatform(),
|
||||||
},
|
},
|
||||||
|
|
@ -93,7 +123,8 @@ class BaseApiService {
|
||||||
this.noAuthEndpoints.includes(url) ||
|
this.noAuthEndpoints.includes(url) ||
|
||||||
this.noAuthPrefixes.some((prefix) => url.startsWith(prefix)) ||
|
this.noAuthPrefixes.some((prefix) => url.startsWith(prefix)) ||
|
||||||
/^\/api\/v1\/invites\/[^/]+\/info$/.test(url);
|
/^\/api\/v1\/invites\/[^/]+\/info$/.test(url);
|
||||||
if (!this.bearerToken && !isNoAuthEndpoint) {
|
const refreshRetryKey = getRefreshRetryKey(mergedOptions.method, url);
|
||||||
|
if (this.isDesktopClient && !this.bearerToken && !isNoAuthEndpoint) {
|
||||||
throw new AuthenticationError("You are not authenticated. Please login again.");
|
throw new AuthenticationError("You are not authenticated. Please login again.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,6 +135,7 @@ class BaseApiService {
|
||||||
method: mergedOptions.method,
|
method: mergedOptions.method,
|
||||||
headers: mergedOptions.headers,
|
headers: mergedOptions.headers,
|
||||||
signal: mergedOptions.signal,
|
signal: mergedOptions.signal,
|
||||||
|
credentials: "include",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Automatically stringify body if Content-Type is application/json and body is an object
|
// Automatically stringify body if Content-Type is application/json and body is an object
|
||||||
|
|
@ -150,18 +182,21 @@ class BaseApiService {
|
||||||
|
|
||||||
// Handle 401 - try to refresh token first (only once)
|
// Handle 401 - try to refresh token first (only once)
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
if (!options?._isRetry) {
|
if (options?._isRetry) {
|
||||||
|
blockRefreshRetry(refreshRetryKey);
|
||||||
|
} else if (!isNoAuthEndpoint && !isRefreshRetryBlocked(refreshRetryKey)) {
|
||||||
const newToken = await refreshAccessToken();
|
const newToken = await refreshAccessToken();
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
return this.request(url, responseSchema, {
|
return this.request(url, responseSchema, {
|
||||||
...mergedOptions,
|
...mergedOptions,
|
||||||
headers: {
|
headers: {
|
||||||
...mergedOptions.headers,
|
...mergedOptions.headers,
|
||||||
Authorization: `Bearer ${newToken}`,
|
...(this.isDesktopClient ? { Authorization: `Bearer ${newToken}` } : {}),
|
||||||
},
|
},
|
||||||
_isRetry: true,
|
_isRetry: true,
|
||||||
} as RequestOptions & { responseType?: R });
|
} as RequestOptions & { responseType?: R });
|
||||||
}
|
}
|
||||||
|
blockRefreshRetry(refreshRetryKey);
|
||||||
}
|
}
|
||||||
handleUnauthorized();
|
handleUnauthorized();
|
||||||
throw new AuthenticationError(
|
throw new AuthenticationError(
|
||||||
|
|
@ -196,6 +231,7 @@ class BaseApiService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
refreshRetryBlockedUntil.delete(getRefreshRetryKey(mergedOptions.method, url));
|
||||||
|
|
||||||
// biome-ignore lint/suspicious: Unknown
|
// biome-ignore lint/suspicious: Unknown
|
||||||
let data;
|
let data;
|
||||||
|
|
@ -381,7 +417,9 @@ class BaseApiService {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
// Don't set Content-Type - let browser set it with multipart boundary
|
// Don't set Content-Type - let browser set it with multipart boundary
|
||||||
Authorization: `Bearer ${this.bearerToken}`,
|
...(this.isDesktopClient && this.bearerToken
|
||||||
|
? { Authorization: `Bearer ${this.bearerToken}` }
|
||||||
|
: {}),
|
||||||
...headersWithoutContentType,
|
...headersWithoutContentType,
|
||||||
},
|
},
|
||||||
responseType: ResponseType.JSON,
|
responseType: ResponseType.JSON,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue