Merge remote-tracking branch 'upstream/dev' into fix/documents

This commit is contained in:
Anish Sarkar 2026-02-06 05:36:32 +05:30
commit c132e5ddb0
49 changed files with 1625 additions and 354 deletions

View file

@ -1,5 +1,5 @@
import type { ZodType } from "zod";
import { getBearerToken, handleUnauthorized } from "../auth-utils";
import { getBearerToken, handleUnauthorized, refreshAccessToken } from "../auth-utils";
import { AppError, AuthenticationError, AuthorizationError, NotFoundError } from "../error";
enum ResponseType {
@ -17,6 +17,7 @@ export type RequestOptions = {
signal?: AbortSignal;
body?: any;
responseType?: ResponseType;
_isRetry?: boolean; // Internal flag to prevent infinite retry loops
// Add more options as needed
};
@ -135,8 +136,23 @@ class BaseApiService {
throw new AppError("Failed to parse response", response.status, response.statusText);
}
// Handle 401 first before other error handling - ensures token is cleared and user redirected
// Handle 401 - try to refresh token first (only once)
if (response.status === 401) {
if (!options?._isRetry) {
const newToken = await refreshAccessToken();
if (newToken) {
// Retry the request with the new token
return this.request(url, responseSchema, {
...mergedOptions,
headers: {
...mergedOptions.headers,
Authorization: `Bearer ${newToken}`,
},
_isRetry: true,
} as RequestOptions & { responseType?: R });
}
}
// Refresh failed or retry failed, redirect to login
handleUnauthorized();
throw new AuthenticationError(
typeof data === "object" && "detail" in data

View file

@ -4,6 +4,11 @@
const REDIRECT_PATH_KEY = "surfsense_redirect_path";
const BEARER_TOKEN_KEY = "surfsense_bearer_token";
const REFRESH_TOKEN_KEY = "surfsense_refresh_token";
// Flag to prevent multiple simultaneous refresh attempts
let isRefreshing = false;
let refreshPromise: Promise<string | null> | null = null;
/**
* Saves the current path and redirects to login page
@ -21,8 +26,9 @@ export function handleUnauthorized(): void {
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
}
// Clear the token
// Clear both tokens
localStorage.removeItem(BEARER_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
// Redirect to home page (which has login options)
window.location.href = "/login";
@ -66,6 +72,71 @@ export function clearBearerToken(): void {
localStorage.removeItem(BEARER_TOKEN_KEY);
}
/**
* Gets the refresh token from localStorage
*/
export function getRefreshToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(REFRESH_TOKEN_KEY);
}
/**
* Sets the refresh token in localStorage
*/
export function setRefreshToken(token: string): void {
if (typeof window === "undefined") return;
localStorage.setItem(REFRESH_TOKEN_KEY, token);
}
/**
* Clears the refresh token from localStorage
*/
export function clearRefreshToken(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
/**
* Clears all auth tokens from localStorage
*/
export function clearAllTokens(): void {
clearBearerToken();
clearRefreshToken();
}
/**
* Logout the current user by revoking the refresh token and clearing localStorage.
* Returns true if logout was successful (or tokens were cleared), false otherwise.
*/
export async function logout(): Promise<boolean> {
const refreshToken = getRefreshToken();
// Call backend to revoke the refresh token
if (refreshToken) {
try {
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
const response = await fetch(`${backendUrl}/auth/jwt/revoke`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!response.ok) {
console.warn("Failed to revoke refresh token:", response.status, await response.text());
}
} catch (error) {
console.warn("Failed to revoke refresh token on server:", error);
// Continue to clear local tokens even if server call fails
}
}
// Clear all tokens from localStorage
clearAllTokens();
return true;
}
/**
* Checks if the user is authenticated (has a token)
*/
@ -106,14 +177,67 @@ export function getAuthHeaders(additionalHeaders?: Record<string, string>): Reco
}
/**
* Authenticated fetch wrapper that handles 401 responses uniformly
* Automatically redirects to login on 401 and saves the current path
* Attempts to refresh the access token using the stored refresh token.
* Returns the new access token if successful, null otherwise.
* Exported for use by API services.
*/
export async function refreshAccessToken(): Promise<string | null> {
// If already refreshing, wait for that request to complete
if (isRefreshing && refreshPromise) {
return refreshPromise;
}
const currentRefreshToken = getRefreshToken();
if (!currentRefreshToken) {
return null;
}
isRefreshing = true;
refreshPromise = (async () => {
try {
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
const response = await fetch(`${backendUrl}/auth/jwt/refresh`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ refresh_token: currentRefreshToken }),
});
if (!response.ok) {
// Refresh failed, clear tokens
clearAllTokens();
return null;
}
const data = await response.json();
if (data.access_token && data.refresh_token) {
setBearerToken(data.access_token);
setRefreshToken(data.refresh_token);
return data.access_token;
}
return null;
} catch {
return null;
} finally {
isRefreshing = false;
refreshPromise = null;
}
})();
return refreshPromise;
}
/**
* Authenticated fetch wrapper that handles 401 responses uniformly.
* On 401, attempts to refresh the token and retry the request.
* If refresh fails, redirects to login and saves the current path.
*/
export async function authenticatedFetch(
url: string,
options?: RequestInit & { skipAuthRedirect?: boolean }
options?: RequestInit & { skipAuthRedirect?: boolean; skipRefresh?: boolean }
): Promise<Response> {
const { skipAuthRedirect = false, ...fetchOptions } = options || {};
const { skipAuthRedirect = false, skipRefresh = false, ...fetchOptions } = options || {};
const headers = getAuthHeaders(fetchOptions.headers as Record<string, string>);
@ -124,6 +248,23 @@ export async function authenticatedFetch(
// Handle 401 Unauthorized
if (response.status === 401 && !skipAuthRedirect) {
// Try to refresh the token (unless skipRefresh is set to prevent infinite loops)
if (!skipRefresh) {
const newToken = await refreshAccessToken();
if (newToken) {
// Retry the original request with the new token
const retryHeaders = {
...(fetchOptions.headers as Record<string, string>),
Authorization: `Bearer ${newToken}`,
};
return fetch(url, {
...fetchOptions,
headers: retryHeaders,
});
}
}
// Refresh failed or was skipped, redirect to login
handleUnauthorized();
throw new Error("Unauthorized: Redirecting to login page");
}

View file

@ -9,6 +9,8 @@
* as it may prevent the sed replacement from working correctly.
*/
import packageJson from "../package.json";
// Auth type: "LOCAL" for email/password, "GOOGLE" for OAuth
// Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__
export const AUTH_TYPE = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE";
@ -28,6 +30,10 @@ export const ETL_SERVICE = process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING";
// Placeholder: __NEXT_PUBLIC_DEPLOYMENT_MODE__
export const DEPLOYMENT_MODE = process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted";
// App version - defaults to package.json version
// Can be overridden at build time with NEXT_PUBLIC_APP_VERSION for full git tag version
export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version;
// Helper to check if local auth is enabled
export const isLocalAuth = () => AUTH_TYPE === "LOCAL";

View file

@ -12,3 +12,44 @@ export const formatDate = (date: Date): string => {
day: "numeric",
});
};
/**
* Copy text to clipboard with fallback for older browsers and non-secure contexts.
* Returns true if successful, false otherwise.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
// Use modern Clipboard API if available and in secure context
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error("Clipboard API failed:", err);
return false;
}
}
// Fallback for non-secure contexts or browsers without Clipboard API
const textArea = document.createElement("textarea");
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand("copy");
document.body.removeChild(textArea);
return successful;
} catch (err) {
console.error("Fallback copy failed:", err);
document.body.removeChild(textArea);
return false;
}
}