mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 08:46:22 +02:00
- Updated the development script to include a build step before launching the app. - Refactored the registration of quick ask and autocomplete functionalities to be asynchronous, ensuring proper initialization. - Introduced IPC channels for getting and setting keyboard shortcuts, allowing users to customize their experience. - Enhanced the platform module to support better interaction with the Electron API for clipboard operations. - Improved the user interface for managing keyboard shortcuts in the settings dialog, providing a more intuitive experience.
342 lines
9.6 KiB
TypeScript
342 lines
9.6 KiB
TypeScript
/**
|
|
* Authentication utilities for handling token expiration and redirects
|
|
*/
|
|
|
|
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;
|
|
|
|
/** Path prefixes for routes that do not require auth (no current-user fetch, no redirect on 401) */
|
|
const PUBLIC_ROUTE_PREFIXES = [
|
|
"/login",
|
|
"/register",
|
|
"/auth",
|
|
"/desktop/login",
|
|
"/docs",
|
|
"/public",
|
|
"/invite",
|
|
"/contact",
|
|
"/pricing",
|
|
"/privacy",
|
|
"/terms",
|
|
"/changelog",
|
|
];
|
|
|
|
/**
|
|
* Returns true if the pathname is a public route where we should not run auth checks
|
|
* or redirect to login on 401.
|
|
*/
|
|
export function isPublicRoute(pathname: string): boolean {
|
|
if (pathname === "/" || pathname === "") return true;
|
|
return PUBLIC_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
|
}
|
|
|
|
export function getLoginPath(): string {
|
|
if (typeof window !== "undefined" && window.electronAPI) return "/desktop/login";
|
|
return "/login";
|
|
}
|
|
|
|
/**
|
|
* Clears tokens and optionally redirects to login.
|
|
* Call this when a 401 response is received.
|
|
* Only redirects when the current route is protected; on public routes we just clear tokens.
|
|
*/
|
|
export function handleUnauthorized(): void {
|
|
if (typeof window === "undefined") return;
|
|
|
|
const pathname = window.location.pathname;
|
|
|
|
// Always clear tokens
|
|
localStorage.removeItem(BEARER_TOKEN_KEY);
|
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
|
|
// Only redirect on protected routes; stay on public pages (e.g. /docs)
|
|
if (!isPublicRoute(pathname)) {
|
|
const currentPath = pathname + window.location.search + window.location.hash;
|
|
const excludedPaths = ["/auth", "/auth/callback", "/"];
|
|
if (!excludedPaths.includes(pathname)) {
|
|
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
|
}
|
|
window.location.href = getLoginPath();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the stored redirect path and clears it from storage
|
|
* Call this after successful login to redirect the user back
|
|
*/
|
|
export function getAndClearRedirectPath(): string | null {
|
|
if (typeof window === "undefined") return null;
|
|
|
|
const redirectPath = localStorage.getItem(REDIRECT_PATH_KEY);
|
|
if (redirectPath) {
|
|
localStorage.removeItem(REDIRECT_PATH_KEY);
|
|
}
|
|
return redirectPath;
|
|
}
|
|
|
|
/**
|
|
* Gets the bearer token from localStorage
|
|
*/
|
|
export function getBearerToken(): string | null {
|
|
if (typeof window === "undefined") return null;
|
|
return localStorage.getItem(BEARER_TOKEN_KEY);
|
|
}
|
|
|
|
/**
|
|
* Sets the bearer token in localStorage
|
|
*/
|
|
export function setBearerToken(token: string): void {
|
|
if (typeof window === "undefined") return;
|
|
localStorage.setItem(BEARER_TOKEN_KEY, token);
|
|
syncTokensToElectron();
|
|
}
|
|
|
|
/**
|
|
* Clears the bearer token from localStorage
|
|
*/
|
|
export function clearBearerToken(): void {
|
|
if (typeof window === "undefined") return;
|
|
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);
|
|
syncTokensToElectron();
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
|
|
/**
|
|
* Pushes the current localStorage tokens into the Electron main process
|
|
* so that other BrowserWindows (Quick Ask, Autocomplete) can access them.
|
|
*/
|
|
function syncTokensToElectron(): void {
|
|
if (typeof window === "undefined" || !window.electronAPI?.setAuthTokens) return;
|
|
const bearer = localStorage.getItem(BEARER_TOKEN_KEY) || "";
|
|
const refresh = localStorage.getItem(REFRESH_TOKEN_KEY) || "";
|
|
if (bearer) {
|
|
window.electronAPI.setAuthTokens(bearer, refresh);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Attempts to pull auth tokens from the Electron main process into localStorage.
|
|
* Useful for popup windows (Quick Ask, Autocomplete) on platforms where
|
|
* localStorage is not reliably shared across BrowserWindow instances.
|
|
* Returns true if tokens were found and written to localStorage.
|
|
*/
|
|
export async function ensureTokensFromElectron(): Promise<boolean> {
|
|
if (typeof window === "undefined" || !window.electronAPI?.getAuthTokens) return false;
|
|
if (getBearerToken()) return true;
|
|
|
|
try {
|
|
const tokens = await window.electronAPI.getAuthTokens();
|
|
if (tokens?.bearer) {
|
|
localStorage.setItem(BEARER_TOKEN_KEY, tokens.bearer);
|
|
if (tokens.refresh) {
|
|
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh);
|
|
}
|
|
return true;
|
|
}
|
|
} catch {
|
|
// IPC failure — fall through
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
*/
|
|
export function isAuthenticated(): boolean {
|
|
return !!getBearerToken();
|
|
}
|
|
|
|
/**
|
|
* Saves the current path and redirects to login page
|
|
* Use this for client-side auth checks (e.g., in useEffect)
|
|
* Unlike handleUnauthorized, this doesn't clear the token (user might not have one)
|
|
*/
|
|
export function redirectToLogin(): void {
|
|
if (typeof window === "undefined") return;
|
|
|
|
// Save the current path (including search params and hash) for redirect after login
|
|
const currentPath = window.location.pathname + window.location.search + window.location.hash;
|
|
|
|
// Don't save auth-related paths or home page
|
|
const excludedPaths = ["/auth", "/auth/callback", "/", "/login", "/register", "/desktop/login"];
|
|
if (!excludedPaths.includes(window.location.pathname)) {
|
|
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
|
}
|
|
|
|
window.location.href = getLoginPath();
|
|
}
|
|
|
|
/**
|
|
* Creates headers with authorization bearer token
|
|
*/
|
|
export function getAuthHeaders(additionalHeaders?: Record<string, string>): Record<string, string> {
|
|
const token = getBearerToken();
|
|
return {
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
...additionalHeaders,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Attempts to refresh the access token using the stored refresh token.
|
|
* Returns the new access token if successful, null otherwise.
|
|
*/
|
|
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; skipRefresh?: boolean }
|
|
): Promise<Response> {
|
|
const { skipAuthRedirect = false, skipRefresh = false, ...fetchOptions } = options || {};
|
|
|
|
const headers = getAuthHeaders(fetchOptions.headers as Record<string, string>);
|
|
|
|
const response = await fetch(url, {
|
|
...fetchOptions,
|
|
headers,
|
|
});
|
|
|
|
// 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");
|
|
}
|
|
|
|
return response;
|
|
}
|