mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
Tidy Tauri code and enable "Open file in Stirling PDF" (#4836)
# Description of Changes Tidy Tauri code and enable "Open file in Stirling PDF"
This commit is contained in:
parent
f4543d26cd
commit
ebf4bab80b
@ -8,7 +8,7 @@ import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
|
|||||||
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
|
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
|
||||||
import { SidebarProvider } from "@app/contexts/SidebarContext";
|
import { SidebarProvider } from "@app/contexts/SidebarContext";
|
||||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
import { AppConfigProvider, AppConfigRetryOptions } from "@app/contexts/AppConfigContext";
|
||||||
import { RightRailProvider } from "@app/contexts/RightRailContext";
|
import { RightRailProvider } from "@app/contexts/RightRailContext";
|
||||||
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
||||||
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||||
@ -16,6 +16,7 @@ import { OnboardingProvider } from "@app/contexts/OnboardingContext";
|
|||||||
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
|
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
|
||||||
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
|
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
|
||||||
import { useScarfTracking } from "@app/hooks/useScarfTracking";
|
import { useScarfTracking } from "@app/hooks/useScarfTracking";
|
||||||
|
import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
||||||
|
|
||||||
// Component to initialize scarf tracking (must be inside AppConfigProvider)
|
// Component to initialize scarf tracking (must be inside AppConfigProvider)
|
||||||
function ScarfTrackingInitializer() {
|
function ScarfTrackingInitializer() {
|
||||||
@ -23,19 +24,31 @@ function ScarfTrackingInitializer() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Component to run app-level initialization (must be inside AppProviders for context access)
|
||||||
|
function AppInitializer() {
|
||||||
|
useAppInitialization();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppProvidersProps {
|
||||||
|
children: ReactNode;
|
||||||
|
appConfigRetryOptions?: AppConfigRetryOptions;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core application providers
|
* Core application providers
|
||||||
* Contains all providers needed for the core
|
* Contains all providers needed for the core
|
||||||
*/
|
*/
|
||||||
export function AppProviders({ children }: { children: ReactNode }) {
|
export function AppProviders({ children, appConfigRetryOptions }: AppProvidersProps) {
|
||||||
return (
|
return (
|
||||||
<PreferencesProvider>
|
<PreferencesProvider>
|
||||||
<RainbowThemeProvider>
|
<RainbowThemeProvider>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<OnboardingProvider>
|
<OnboardingProvider>
|
||||||
<AppConfigProvider>
|
<AppConfigProvider retryOptions={appConfigRetryOptions}>
|
||||||
<ScarfTrackingInitializer />
|
<ScarfTrackingInitializer />
|
||||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||||
|
<AppInitializer />
|
||||||
<ToolRegistryProvider>
|
<ToolRegistryProvider>
|
||||||
<NavigationProvider>
|
<NavigationProvider>
|
||||||
<FilesModalProvider>
|
<FilesModalProvider>
|
||||||
|
|||||||
@ -1,6 +1,18 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
import apiClient from '@app/services/apiClient';
|
import apiClient from '@app/services/apiClient';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep utility for delays
|
||||||
|
*/
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppConfigRetryOptions {
|
||||||
|
maxRetries?: number;
|
||||||
|
initialDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
contextPath?: string;
|
contextPath?: string;
|
||||||
@ -47,12 +59,18 @@ const AppConfigContext = createContext<AppConfigContextValue | undefined>({
|
|||||||
* Provider component that fetches and provides app configuration
|
* Provider component that fetches and provides app configuration
|
||||||
* Should be placed at the top level of the app, before any components that need config
|
* Should be placed at the top level of the app, before any components that need config
|
||||||
*/
|
*/
|
||||||
export const AppConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
export const AppConfigProvider: React.FC<{
|
||||||
|
children: ReactNode;
|
||||||
|
retryOptions?: AppConfigRetryOptions;
|
||||||
|
}> = ({ children, retryOptions }) => {
|
||||||
const [config, setConfig] = useState<AppConfig | null>(null);
|
const [config, setConfig] = useState<AppConfig | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [fetchCount, setFetchCount] = useState(0);
|
const [fetchCount, setFetchCount] = useState(0);
|
||||||
|
|
||||||
|
const maxRetries = retryOptions?.maxRetries ?? 0;
|
||||||
|
const initialDelay = retryOptions?.initialDelay ?? 1000;
|
||||||
|
|
||||||
const fetchConfig = async (force = false) => {
|
const fetchConfig = async (force = false) => {
|
||||||
// Prevent duplicate fetches unless forced
|
// Prevent duplicate fetches unless forced
|
||||||
if (!force && fetchCount > 0) {
|
if (!force && fetchCount > 0) {
|
||||||
@ -60,10 +78,19 @@ export const AppConfigProvider: React.FC<{ children: ReactNode }> = ({ children
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
if (attempt > 0) {
|
||||||
|
const delay = initialDelay * Math.pow(2, attempt - 1);
|
||||||
|
console.log(`[AppConfig] Retry attempt ${attempt}/${maxRetries} after ${delay}ms delay...`);
|
||||||
|
await sleep(delay);
|
||||||
|
} else {
|
||||||
|
console.log('[AppConfig] Fetching app config...');
|
||||||
|
}
|
||||||
|
|
||||||
// apiClient automatically adds JWT header if available via interceptors
|
// apiClient automatically adds JWT header if available via interceptors
|
||||||
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config');
|
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config');
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
@ -71,24 +98,39 @@ export const AppConfigProvider: React.FC<{ children: ReactNode }> = ({ children
|
|||||||
console.debug('[AppConfig] Config fetched successfully:', data);
|
console.debug('[AppConfig] Config fetched successfully:', data);
|
||||||
setConfig(data);
|
setConfig(data);
|
||||||
setFetchCount(prev => prev + 1);
|
setFetchCount(prev => prev + 1);
|
||||||
|
setLoading(false);
|
||||||
|
return; // Success - exit function
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
const status = err?.response?.status;
|
||||||
|
|
||||||
// On 401 (not authenticated), use default config with login enabled
|
// On 401 (not authenticated), use default config with login enabled
|
||||||
// This allows the app to work even without authentication
|
// This allows the app to work even without authentication
|
||||||
if (err.response?.status === 401) {
|
if (status === 401) {
|
||||||
console.debug('[AppConfig] 401 error - using default config (login enabled)');
|
console.debug('[AppConfig] 401 error - using default config (login enabled)');
|
||||||
setConfig({ enableLogin: true });
|
setConfig({ enableLogin: true });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
// Check if we should retry (network errors or 5xx errors)
|
||||||
|
const shouldRetry = (!status || status >= 500) && attempt < maxRetries;
|
||||||
|
|
||||||
|
if (shouldRetry) {
|
||||||
|
console.warn(`[AppConfig] Attempt ${attempt + 1} failed (status ${status || 'network error'}):`, err.message, '- will retry...');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final attempt failed or non-retryable error (4xx)
|
||||||
|
const errorMessage = err?.response?.data?.message || err?.message || 'Unknown error occurred';
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
console.error('[AppConfig] Failed to fetch app config:', err);
|
console.error(`[AppConfig] Failed to fetch app config after ${attempt + 1} attempts:`, err);
|
||||||
// On error, assume login is enabled (safe default)
|
// On error, assume login is enabled (safe default)
|
||||||
setConfig({ enableLogin: true });
|
setConfig({ enableLogin: true });
|
||||||
} finally {
|
break;
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
10
frontend/src/core/hooks/useAppInitialization.ts
Normal file
10
frontend/src/core/hooks/useAppInitialization.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* App initialization hook
|
||||||
|
* Core version: no initialization needed
|
||||||
|
*
|
||||||
|
* This hook is called once when the app starts to allow different builds
|
||||||
|
* to perform initialization tasks that require access to contexts like FileContext.
|
||||||
|
*/
|
||||||
|
export function useAppInitialization(): void {
|
||||||
|
// Core version has no initialization
|
||||||
|
}
|
||||||
@ -20,21 +20,13 @@ import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
|||||||
import AppConfigModal from "@app/components/shared/AppConfigModal";
|
import AppConfigModal from "@app/components/shared/AppConfigModal";
|
||||||
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
|
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
|
||||||
import AdminAnalyticsChoiceModal from "@app/components/shared/AdminAnalyticsChoiceModal";
|
import AdminAnalyticsChoiceModal from "@app/components/shared/AdminAnalyticsChoiceModal";
|
||||||
import { useHomePageExtensions } from "@app/pages/useHomePageExtensions";
|
|
||||||
|
|
||||||
import "@app/pages/HomePage.css";
|
import "@app/pages/HomePage.css";
|
||||||
|
|
||||||
type MobileView = "tools" | "workbench";
|
type MobileView = "tools" | "workbench";
|
||||||
|
|
||||||
interface HomePageProps {
|
export default function HomePage() {
|
||||||
openedFile?: File | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomePage({ openedFile }: HomePageProps = {}) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Extension hook for desktop-specific behavior (e.g., file opening)
|
|
||||||
useHomePageExtensions(openedFile);
|
|
||||||
const {
|
const {
|
||||||
sidebarRefs,
|
sidebarRefs,
|
||||||
} = useSidebarContext();
|
} = useSidebarContext();
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extension point for HomePage behaviour.
|
|
||||||
* Core version does nothing.
|
|
||||||
*/
|
|
||||||
export function useHomePageExtensions(_openedFile?: File | null) {
|
|
||||||
useEffect(() => {
|
|
||||||
}, [_openedFile]);
|
|
||||||
}
|
|
||||||
@ -1,234 +0,0 @@
|
|||||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
|
|
||||||
import apiClient from '@app/services/apiClient';
|
|
||||||
import { springAuth } from '@app/auth/springAuthClient';
|
|
||||||
import type { Session, User, AuthError, AuthChangeEvent } from '@app/auth/springAuthClient';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auth Context Type
|
|
||||||
* Simplified version without SaaS-specific features (credits, subscriptions)
|
|
||||||
*/
|
|
||||||
interface AuthContextType {
|
|
||||||
session: Session | null;
|
|
||||||
user: User | null;
|
|
||||||
loading: boolean;
|
|
||||||
error: AuthError | null;
|
|
||||||
signOut: () => Promise<void>;
|
|
||||||
refreshSession: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType>({
|
|
||||||
session: null,
|
|
||||||
user: null,
|
|
||||||
loading: true,
|
|
||||||
error: null,
|
|
||||||
signOut: async () => {},
|
|
||||||
refreshSession: async () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auth Provider Component
|
|
||||||
*
|
|
||||||
* Manages authentication state and provides it to the entire app.
|
|
||||||
* Integrates with Spring Security + JWT backend.
|
|
||||||
*/
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [session, setSession] = useState<Session | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<AuthError | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh current session
|
|
||||||
*/
|
|
||||||
const refreshSession = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
console.debug('[Auth] Refreshing session...');
|
|
||||||
|
|
||||||
const { data, error } = await springAuth.refreshSession();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('[Auth] Session refresh error:', error);
|
|
||||||
setError(error);
|
|
||||||
setSession(null);
|
|
||||||
} else {
|
|
||||||
console.debug('[Auth] Session refreshed successfully');
|
|
||||||
setSession(data.session);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Auth] Unexpected error during session refresh:', err);
|
|
||||||
setError(err as AuthError);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign out user
|
|
||||||
*/
|
|
||||||
const signOut = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
console.debug('[Auth] Signing out...');
|
|
||||||
|
|
||||||
const { error } = await springAuth.signOut();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('[Auth] Sign out error:', error);
|
|
||||||
setError(error);
|
|
||||||
} else {
|
|
||||||
console.debug('[Auth] Signed out successfully');
|
|
||||||
setSession(null);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Auth] Unexpected error during sign out:', err);
|
|
||||||
setError(err as AuthError);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize auth on mount
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
const initializeAuth = async () => {
|
|
||||||
try {
|
|
||||||
console.debug('[Auth] Initializing auth...');
|
|
||||||
|
|
||||||
// First check if login is enabled
|
|
||||||
const configResponse = await apiClient.get('/api/v1/config/app-config');
|
|
||||||
if (configResponse.status === 200) {
|
|
||||||
const config = configResponse.data;
|
|
||||||
|
|
||||||
// If login is disabled, skip authentication entirely
|
|
||||||
if (config.enableLogin === false) {
|
|
||||||
console.debug('[Auth] Login disabled - skipping authentication');
|
|
||||||
if (mounted) {
|
|
||||||
setSession(null);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login is enabled, proceed with normal auth check
|
|
||||||
const { data, error } = await springAuth.getSession();
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('[Auth] Initial session error:', error);
|
|
||||||
setError(error);
|
|
||||||
} else {
|
|
||||||
console.debug('[Auth] Initial session loaded:', {
|
|
||||||
hasSession: !!data.session,
|
|
||||||
userId: data.session?.user?.id,
|
|
||||||
email: data.session?.user?.email,
|
|
||||||
});
|
|
||||||
setSession(data.session);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Auth] Unexpected error during auth initialization:', err);
|
|
||||||
if (mounted) {
|
|
||||||
setError(err as AuthError);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initializeAuth();
|
|
||||||
|
|
||||||
// Subscribe to auth state changes
|
|
||||||
const { data: { subscription } } = springAuth.onAuthStateChange(
|
|
||||||
async (event: AuthChangeEvent, newSession: Session | null) => {
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
console.debug('[Auth] Auth state change:', {
|
|
||||||
event,
|
|
||||||
hasSession: !!newSession,
|
|
||||||
userId: newSession?.user?.id,
|
|
||||||
email: newSession?.user?.email,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Schedule state update
|
|
||||||
setTimeout(() => {
|
|
||||||
if (mounted) {
|
|
||||||
setSession(newSession);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// Handle specific events
|
|
||||||
if (event === 'SIGNED_OUT') {
|
|
||||||
console.debug('[Auth] User signed out, clearing session');
|
|
||||||
} else if (event === 'SIGNED_IN') {
|
|
||||||
console.debug('[Auth] User signed in successfully');
|
|
||||||
} else if (event === 'TOKEN_REFRESHED') {
|
|
||||||
console.debug('[Auth] Token refreshed');
|
|
||||||
} else if (event === 'USER_UPDATED') {
|
|
||||||
console.debug('[Auth] User updated');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
subscription.unsubscribe();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const value: AuthContextType = {
|
|
||||||
session,
|
|
||||||
user: session?.user ?? null,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
signOut,
|
|
||||||
refreshSession,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to access auth context
|
|
||||||
* Must be used within AuthProvider
|
|
||||||
*/
|
|
||||||
export function useAuth() {
|
|
||||||
const context = useContext(AuthContext);
|
|
||||||
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Debug hook to expose auth state for debugging
|
|
||||||
* Can be used in development to monitor auth state
|
|
||||||
*/
|
|
||||||
export function useAuthDebug() {
|
|
||||||
const auth = useAuth();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.debug('[Auth Debug] Current auth state:', {
|
|
||||||
hasSession: !!auth.session,
|
|
||||||
hasUser: !!auth.user,
|
|
||||||
loading: auth.loading,
|
|
||||||
hasError: !!auth.error,
|
|
||||||
userId: auth.user?.id,
|
|
||||||
email: auth.user?.email,
|
|
||||||
});
|
|
||||||
}, [auth.session, auth.user, auth.loading, auth.error]);
|
|
||||||
|
|
||||||
return auth;
|
|
||||||
}
|
|
||||||
@ -1,437 +0,0 @@
|
|||||||
/**
|
|
||||||
* Spring Auth Client
|
|
||||||
*
|
|
||||||
* This client integrates with the Spring Security + JWT backend.
|
|
||||||
* - Uses localStorage for JWT storage (sent via Authorization header)
|
|
||||||
* - JWT validation handled server-side
|
|
||||||
* - No email confirmation flow (auto-confirmed on registration)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import apiClient from '@app/services/apiClient';
|
|
||||||
|
|
||||||
// Auth types
|
|
||||||
export interface User {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
username: string;
|
|
||||||
role: string;
|
|
||||||
enabled?: boolean;
|
|
||||||
is_anonymous?: boolean;
|
|
||||||
app_metadata?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Session {
|
|
||||||
user: User;
|
|
||||||
access_token: string;
|
|
||||||
expires_in: number;
|
|
||||||
expires_at?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthError {
|
|
||||||
message: string;
|
|
||||||
status?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthResponse {
|
|
||||||
user: User | null;
|
|
||||||
session: Session | null;
|
|
||||||
error: AuthError | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AuthChangeEvent =
|
|
||||||
| 'SIGNED_IN'
|
|
||||||
| 'SIGNED_OUT'
|
|
||||||
| 'TOKEN_REFRESHED'
|
|
||||||
| 'USER_UPDATED';
|
|
||||||
|
|
||||||
type AuthChangeCallback = (event: AuthChangeEvent, session: Session | null) => void;
|
|
||||||
|
|
||||||
class SpringAuthClient {
|
|
||||||
private listeners: AuthChangeCallback[] = [];
|
|
||||||
private sessionCheckInterval: NodeJS.Timeout | null = null;
|
|
||||||
private readonly SESSION_CHECK_INTERVAL = 60000; // 1 minute
|
|
||||||
private readonly TOKEN_REFRESH_THRESHOLD = 300000; // 5 minutes before expiry
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Start periodic session validation
|
|
||||||
this.startSessionMonitoring();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to get CSRF token from cookie
|
|
||||||
*/
|
|
||||||
private getCsrfToken(): string | null {
|
|
||||||
const cookies = document.cookie.split(';');
|
|
||||||
for (const cookie of cookies) {
|
|
||||||
const [name, value] = cookie.trim().split('=');
|
|
||||||
if (name === 'XSRF-TOKEN') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current session
|
|
||||||
* JWT is stored in localStorage and sent via Authorization header
|
|
||||||
*/
|
|
||||||
async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
|
|
||||||
try {
|
|
||||||
// Get JWT from localStorage
|
|
||||||
const token = localStorage.getItem('stirling_jwt');
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
console.debug('[SpringAuth] getSession: No JWT in localStorage');
|
|
||||||
return { data: { session: null }, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify with backend
|
|
||||||
// Note: We pass the token explicitly here, overriding the interceptor's default
|
|
||||||
const response = await apiClient.get('/api/v1/auth/me', {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = response.data;
|
|
||||||
|
|
||||||
// Create session object
|
|
||||||
const session: Session = {
|
|
||||||
user: data.user,
|
|
||||||
access_token: token,
|
|
||||||
expires_in: 3600,
|
|
||||||
expires_at: Date.now() + 3600 * 1000,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.debug('[SpringAuth] getSession: Session retrieved successfully');
|
|
||||||
return { data: { session }, error: null };
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[SpringAuth] getSession error:', error);
|
|
||||||
|
|
||||||
// If 401/403, token is invalid - clear it
|
|
||||||
if (error?.response?.status === 401 || error?.response?.status === 403) {
|
|
||||||
localStorage.removeItem('stirling_jwt');
|
|
||||||
console.debug('[SpringAuth] getSession: Not authenticated');
|
|
||||||
return { data: { session: null }, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear potentially invalid token on other errors too
|
|
||||||
localStorage.removeItem('stirling_jwt');
|
|
||||||
return {
|
|
||||||
data: { session: null },
|
|
||||||
error: { message: error?.response?.data?.message || error?.message || 'Unknown error' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign in with email and password
|
|
||||||
*/
|
|
||||||
async signInWithPassword(credentials: {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
}): Promise<AuthResponse> {
|
|
||||||
try {
|
|
||||||
const response = await apiClient.post('/api/v1/auth/login', {
|
|
||||||
username: credentials.email,
|
|
||||||
password: credentials.password
|
|
||||||
}, {
|
|
||||||
withCredentials: true, // Include cookies for CSRF
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = response.data;
|
|
||||||
const token = data.session.access_token;
|
|
||||||
|
|
||||||
// Store JWT in localStorage
|
|
||||||
localStorage.setItem('stirling_jwt', token);
|
|
||||||
console.log('[SpringAuth] JWT stored in localStorage');
|
|
||||||
|
|
||||||
const session: Session = {
|
|
||||||
user: data.user,
|
|
||||||
access_token: token,
|
|
||||||
expires_in: data.session.expires_in,
|
|
||||||
expires_at: Date.now() + data.session.expires_in * 1000,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Notify listeners
|
|
||||||
this.notifyListeners('SIGNED_IN', session);
|
|
||||||
|
|
||||||
return { user: data.user, session, error: null };
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[SpringAuth] signInWithPassword error:', error);
|
|
||||||
const errorMessage = error?.response?.data?.error || error?.message || 'Login failed';
|
|
||||||
return {
|
|
||||||
user: null,
|
|
||||||
session: null,
|
|
||||||
error: { message: errorMessage },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign up new user
|
|
||||||
*/
|
|
||||||
async signUp(credentials: {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
options?: { data?: { full_name?: string }; emailRedirectTo?: string };
|
|
||||||
}): Promise<AuthResponse> {
|
|
||||||
try {
|
|
||||||
const response = await apiClient.post('/api/v1/user/register', {
|
|
||||||
username: credentials.email,
|
|
||||||
password: credentials.password,
|
|
||||||
}, {
|
|
||||||
withCredentials: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = response.data;
|
|
||||||
|
|
||||||
// Note: Spring backend auto-confirms users (no email verification)
|
|
||||||
// Return user but no session (user needs to login)
|
|
||||||
return { user: data.user, session: null, error: null };
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[SpringAuth] signUp error:', error);
|
|
||||||
const errorMessage = error?.response?.data?.error || error?.message || 'Registration failed';
|
|
||||||
return {
|
|
||||||
user: null,
|
|
||||||
session: null,
|
|
||||||
error: { message: errorMessage },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign in with OAuth provider (GitHub, Google, etc.)
|
|
||||||
* Redirects to Spring OAuth2 authorization endpoint
|
|
||||||
*/
|
|
||||||
async signInWithOAuth(params: {
|
|
||||||
provider: 'github' | 'google' | 'apple' | 'azure';
|
|
||||||
options?: { redirectTo?: string; queryParams?: Record<string, any> };
|
|
||||||
}): Promise<{ error: AuthError | null }> {
|
|
||||||
try {
|
|
||||||
const redirectUrl = `/oauth2/authorization/${params.provider}`;
|
|
||||||
console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl);
|
|
||||||
window.location.assign(redirectUrl);
|
|
||||||
return { error: null };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
error: { message: error instanceof Error ? error.message : 'OAuth redirect failed' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send password reset email
|
|
||||||
* Not used in OSS version, but included for completeness
|
|
||||||
*/
|
|
||||||
async resetPasswordForEmail(email: string): Promise<{ data: object; error: AuthError | null }> {
|
|
||||||
try {
|
|
||||||
await apiClient.post('/api/v1/auth/reset-password', {
|
|
||||||
email,
|
|
||||||
}, {
|
|
||||||
withCredentials: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: {}, error: null };
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[SpringAuth] resetPasswordForEmail error:', error);
|
|
||||||
return {
|
|
||||||
data: {},
|
|
||||||
error: {
|
|
||||||
message: error?.response?.data?.error || error?.message || 'Password reset failed',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign out user (invalidate session)
|
|
||||||
*/
|
|
||||||
async signOut(): Promise<{ error: AuthError | null }> {
|
|
||||||
try {
|
|
||||||
const response = await apiClient.post('/api/v1/auth/logout', null, {
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': this.getCsrfToken() || '',
|
|
||||||
},
|
|
||||||
withCredentials: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status === 200) {
|
|
||||||
console.debug('[SpringAuth] signOut: Success');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up local storage
|
|
||||||
localStorage.removeItem('stirling_jwt');
|
|
||||||
|
|
||||||
// Notify listeners
|
|
||||||
this.notifyListeners('SIGNED_OUT', null);
|
|
||||||
|
|
||||||
return { error: null };
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[SpringAuth] signOut error:', error);
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
message: error?.response?.data?.error || error?.message || 'Logout failed',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh JWT token
|
|
||||||
*/
|
|
||||||
async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
|
|
||||||
try {
|
|
||||||
const response = await apiClient.post('/api/v1/auth/refresh', null, {
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': this.getCsrfToken() || '',
|
|
||||||
},
|
|
||||||
withCredentials: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = response.data;
|
|
||||||
const token = data.session.access_token;
|
|
||||||
|
|
||||||
// Update local storage with new token
|
|
||||||
localStorage.setItem('stirling_jwt', token);
|
|
||||||
|
|
||||||
const session: Session = {
|
|
||||||
user: data.user,
|
|
||||||
access_token: token,
|
|
||||||
expires_in: data.session.expires_in,
|
|
||||||
expires_at: Date.now() + data.session.expires_in * 1000,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Notify listeners
|
|
||||||
this.notifyListeners('TOKEN_REFRESHED', session);
|
|
||||||
|
|
||||||
return { data: { session }, error: null };
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[SpringAuth] refreshSession error:', error);
|
|
||||||
localStorage.removeItem('stirling_jwt');
|
|
||||||
|
|
||||||
// Handle different error statuses
|
|
||||||
if (error?.response?.status === 401 || error?.response?.status === 403) {
|
|
||||||
return { data: { session: null }, error: { message: 'Token refresh failed - please log in again' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: { session: null },
|
|
||||||
error: { message: error?.response?.data?.message || error?.message || 'Token refresh failed' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Listen to auth state changes
|
|
||||||
*/
|
|
||||||
onAuthStateChange(callback: AuthChangeCallback): { data: { subscription: { unsubscribe: () => void } } } {
|
|
||||||
this.listeners.push(callback);
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: {
|
|
||||||
subscription: {
|
|
||||||
unsubscribe: () => {
|
|
||||||
this.listeners = this.listeners.filter((cb) => cb !== callback);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Private helper methods
|
|
||||||
|
|
||||||
private notifyListeners(event: AuthChangeEvent, session: Session | null) {
|
|
||||||
// Use setTimeout to avoid calling callbacks synchronously
|
|
||||||
setTimeout(() => {
|
|
||||||
this.listeners.forEach((callback) => {
|
|
||||||
try {
|
|
||||||
callback(event, session);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[SpringAuth] Error in auth state change listener:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private startSessionMonitoring() {
|
|
||||||
// Periodically check session validity
|
|
||||||
// Since we use HttpOnly cookies, we just need to check with the server
|
|
||||||
this.sessionCheckInterval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
// Try to get current session
|
|
||||||
const { data } = await this.getSession();
|
|
||||||
|
|
||||||
// If we have a session, proactively refresh if needed
|
|
||||||
// (The server will handle token expiry, but we can be proactive)
|
|
||||||
if (data.session) {
|
|
||||||
const timeUntilExpiry = (data.session.expires_at || 0) - Date.now();
|
|
||||||
|
|
||||||
// Refresh if token expires soon
|
|
||||||
if (timeUntilExpiry > 0 && timeUntilExpiry < this.TOKEN_REFRESH_THRESHOLD) {
|
|
||||||
console.log('[SpringAuth] Proactively refreshing token');
|
|
||||||
await this.refreshSession();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[SpringAuth] Session monitoring error:', error);
|
|
||||||
}
|
|
||||||
}, this.SESSION_CHECK_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
public destroy() {
|
|
||||||
if (this.sessionCheckInterval) {
|
|
||||||
clearInterval(this.sessionCheckInterval);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const springAuth = new SpringAuthClient();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current user
|
|
||||||
*/
|
|
||||||
export const getCurrentUser = async () => {
|
|
||||||
const { data } = await springAuth.getSession();
|
|
||||||
return data.session?.user || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user is anonymous
|
|
||||||
*/
|
|
||||||
export const isUserAnonymous = (user: User | null) => {
|
|
||||||
return user?.is_anonymous === true;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an anonymous user object for use when login is disabled
|
|
||||||
* This provides a consistent User interface throughout the app
|
|
||||||
*/
|
|
||||||
export const createAnonymousUser = (): User => {
|
|
||||||
return {
|
|
||||||
id: 'anonymous',
|
|
||||||
email: 'anonymous@local',
|
|
||||||
username: 'Anonymous User',
|
|
||||||
role: 'USER',
|
|
||||||
enabled: true,
|
|
||||||
is_anonymous: true,
|
|
||||||
app_metadata: {
|
|
||||||
provider: 'anonymous',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an anonymous session for use when login is disabled
|
|
||||||
*/
|
|
||||||
export const createAnonymousSession = (): Session => {
|
|
||||||
return {
|
|
||||||
user: createAnonymousUser(),
|
|
||||||
access_token: '',
|
|
||||||
expires_in: Number.MAX_SAFE_INTEGER,
|
|
||||||
expires_at: Number.MAX_SAFE_INTEGER,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Export auth client as default for convenience
|
|
||||||
export default springAuth;
|
|
||||||
20
frontend/src/desktop/components/AppProviders.tsx
Normal file
20
frontend/src/desktop/components/AppProviders.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop application providers
|
||||||
|
* Wraps proprietary providers and adds desktop-specific configuration
|
||||||
|
* - Enables retry logic for app config (needed for Tauri mode when backend is starting)
|
||||||
|
*/
|
||||||
|
export function AppProviders({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ProprietaryAppProviders
|
||||||
|
appConfigRetryOptions={{
|
||||||
|
maxRetries: 5,
|
||||||
|
initialDelay: 1000, // 1 second, with exponential backoff
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ProprietaryAppProviders>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,135 +0,0 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
|
||||||
import apiClient from '@app/services/apiClient';
|
|
||||||
|
|
||||||
// Retry configuration
|
|
||||||
const MAX_RETRIES = 5;
|
|
||||||
const INITIAL_DELAY = 1000; // 1 second
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sleep utility for delays
|
|
||||||
*/
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppConfig {
|
|
||||||
baseUrl?: string;
|
|
||||||
contextPath?: string;
|
|
||||||
serverPort?: number;
|
|
||||||
appNameNavbar?: string;
|
|
||||||
languages?: string[];
|
|
||||||
enableLogin?: boolean;
|
|
||||||
enableEmailInvites?: boolean;
|
|
||||||
isAdmin?: boolean;
|
|
||||||
enableAlphaFunctionality?: boolean;
|
|
||||||
enableAnalytics?: boolean | null;
|
|
||||||
enablePosthog?: boolean | null;
|
|
||||||
enableScarf?: boolean | null;
|
|
||||||
premiumEnabled?: boolean;
|
|
||||||
premiumKey?: string;
|
|
||||||
termsAndConditions?: string;
|
|
||||||
privacyPolicy?: string;
|
|
||||||
cookiePolicy?: string;
|
|
||||||
impressum?: string;
|
|
||||||
accessibilityStatement?: string;
|
|
||||||
runningProOrHigher?: boolean;
|
|
||||||
runningEE?: boolean;
|
|
||||||
license?: string;
|
|
||||||
SSOAutoLogin?: boolean;
|
|
||||||
serverCertificateEnabled?: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AppConfigContextValue {
|
|
||||||
config: AppConfig | null;
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
refetch: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create context
|
|
||||||
const AppConfigContext = createContext<AppConfigContextValue | undefined>(undefined);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provider component that fetches and provides app configuration
|
|
||||||
* Should be placed at the top level of the app, before any components that need config
|
|
||||||
*/
|
|
||||||
export const AppConfigProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
||||||
const [config, setConfig] = useState<AppConfig | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchConfig = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
||||||
try {
|
|
||||||
if (attempt > 0) {
|
|
||||||
const delay = INITIAL_DELAY * Math.pow(2, attempt - 1);
|
|
||||||
console.log(`[AppConfig] Retry attempt ${attempt}/${MAX_RETRIES} after ${delay}ms delay...`);
|
|
||||||
await sleep(delay);
|
|
||||||
} else {
|
|
||||||
console.log('[AppConfig] Fetching app config...');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config');
|
|
||||||
|
|
||||||
setConfig(response.data);
|
|
||||||
console.log('[AppConfig] Successfully fetched app config');
|
|
||||||
setLoading(false);
|
|
||||||
return; // Success - exit function
|
|
||||||
} catch (err: any) {
|
|
||||||
const status = err?.response?.status;
|
|
||||||
|
|
||||||
// Check if we should retry (network errors or 5xx errors)
|
|
||||||
const shouldRetry = (!status || status >= 500) && attempt < MAX_RETRIES;
|
|
||||||
|
|
||||||
if (shouldRetry) {
|
|
||||||
console.warn(`[AppConfig] Attempt ${attempt + 1} failed (status ${status || 'network error'}):`, err.message, '- will retry...');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final attempt failed or non-retryable error (4xx)
|
|
||||||
const errorMessage = err?.response?.data?.message || err?.message || 'Unknown error occurred';
|
|
||||||
setError(errorMessage);
|
|
||||||
console.error(`[AppConfig] Failed to fetch app config after ${attempt + 1} attempts:`, err);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchConfig();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const value: AppConfigContextValue = {
|
|
||||||
config,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
refetch: fetchConfig,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppConfigContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</AppConfigContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to access application configuration
|
|
||||||
* Must be used within AppConfigProvider
|
|
||||||
*/
|
|
||||||
export function useAppConfig(): AppConfigContextValue {
|
|
||||||
const context = useContext(AppConfigContext);
|
|
||||||
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useAppConfig must be used within AppConfigProvider');
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
47
frontend/src/desktop/hooks/useAppInitialization.ts
Normal file
47
frontend/src/desktop/hooks/useAppInitialization.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
|
||||||
|
import { useOpenedFile } from '@app/hooks/useOpenedFile';
|
||||||
|
import { fileOpenService } from '@app/services/fileOpenService';
|
||||||
|
import { useFileManagement } from '@app/contexts/file/fileHooks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App initialization hook
|
||||||
|
* Desktop version: Handles Tauri-specific initialization
|
||||||
|
* - Starts the backend on app startup
|
||||||
|
* - Handles files opened with the app (adds directly to FileContext)
|
||||||
|
*/
|
||||||
|
export function useAppInitialization(): void {
|
||||||
|
// Initialize backend on app startup
|
||||||
|
useBackendInitializer();
|
||||||
|
|
||||||
|
// Get file management actions
|
||||||
|
const { addFiles } = useFileManagement();
|
||||||
|
|
||||||
|
// Handle file opened with app (Tauri mode)
|
||||||
|
const { openedFilePath, loading: openedFileLoading } = useOpenedFile();
|
||||||
|
|
||||||
|
// Load opened file and add directly to FileContext
|
||||||
|
useEffect(() => {
|
||||||
|
if (openedFilePath && !openedFileLoading) {
|
||||||
|
const loadOpenedFile = async () => {
|
||||||
|
try {
|
||||||
|
const fileData = await fileOpenService.readFileAsArrayBuffer(openedFilePath);
|
||||||
|
if (fileData) {
|
||||||
|
// Create a File object from the ArrayBuffer
|
||||||
|
const file = new File([fileData.arrayBuffer], fileData.fileName, {
|
||||||
|
type: 'application/pdf'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add directly to FileContext
|
||||||
|
await addFiles([file]);
|
||||||
|
console.log('[Desktop] Opened file added to FileContext:', fileData.fileName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Desktop] Failed to load opened file:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadOpenedFile();
|
||||||
|
}
|
||||||
|
}, [openedFilePath, openedFileLoading, addFiles]);
|
||||||
|
}
|
||||||
@ -1,26 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Desktop override: Handle file opened with app (Tauri mode)
|
|
||||||
*/
|
|
||||||
export function useHomePageExtensions(openedFile?: File | null) {
|
|
||||||
useEffect(() => {
|
|
||||||
if (openedFile) {
|
|
||||||
const loadOpenedFile = async () => {
|
|
||||||
try {
|
|
||||||
// TAURI NOTE: Implement file opening logic here
|
|
||||||
// // Add to active files if not already present
|
|
||||||
// await addToActiveFiles(openedFile);
|
|
||||||
|
|
||||||
// // Switch to viewer mode to show the opened file
|
|
||||||
// setCurrentView('viewer');
|
|
||||||
// setReaderMode(true);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load opened file:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadOpenedFile();
|
|
||||||
}
|
|
||||||
}, [openedFile]);
|
|
||||||
}
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
import { Navigate, useLocation } from 'react-router-dom';
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useOpenedFile } from '@app/hooks/useOpenedFile';
|
|
||||||
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
|
|
||||||
import { fileOpenService } from '@app/services/fileOpenService';
|
|
||||||
import { useAuth } from '@app/auth/UseSession';
|
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
|
||||||
import HomePage from '@app/pages/HomePage';
|
|
||||||
import Login from '@app/routes/Login';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Landing component - Smart router based on authentication status
|
|
||||||
*
|
|
||||||
* If login is disabled: Show HomePage directly (anonymous mode)
|
|
||||||
* If user is authenticated: Show HomePage
|
|
||||||
* If user is not authenticated: Show Login or redirect to /login
|
|
||||||
*/
|
|
||||||
export default function Landing() {
|
|
||||||
const { session, loading: authLoading } = useAuth();
|
|
||||||
const { config, loading: configLoading } = useAppConfig();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const loading = authLoading || configLoading;
|
|
||||||
|
|
||||||
console.log('[Landing] State:', {
|
|
||||||
pathname: location.pathname,
|
|
||||||
loading,
|
|
||||||
hasSession: !!session,
|
|
||||||
loginEnabled: config?.enableLogin,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize backend on app startup
|
|
||||||
useBackendInitializer();
|
|
||||||
|
|
||||||
// Handle file opened with app (Tauri mode)
|
|
||||||
const { openedFilePath, loading: openedFileLoading } = useOpenedFile();
|
|
||||||
const [openedFile, setOpenedFile] = useState<File | null>(null);
|
|
||||||
|
|
||||||
// Load opened file once when path is available
|
|
||||||
useEffect(() => {
|
|
||||||
if (openedFilePath && !openedFileLoading) {
|
|
||||||
const loadOpenedFile = async () => {
|
|
||||||
try {
|
|
||||||
const fileData = await fileOpenService.readFileAsArrayBuffer(openedFilePath);
|
|
||||||
if (fileData) {
|
|
||||||
// Create a File object from the ArrayBuffer
|
|
||||||
const file = new File([fileData.arrayBuffer], fileData.fileName, {
|
|
||||||
type: 'application/pdf'
|
|
||||||
});
|
|
||||||
setOpenedFile(file);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load opened file:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadOpenedFile();
|
|
||||||
}
|
|
||||||
}, [openedFilePath, openedFileLoading]);
|
|
||||||
|
|
||||||
// Show loading while checking auth and config
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
|
|
||||||
<div className="text-gray-600">
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If login is disabled, show app directly (anonymous mode)
|
|
||||||
if (config?.enableLogin === false) {
|
|
||||||
console.debug('[Landing] Login disabled - showing app in anonymous mode');
|
|
||||||
return <HomePage openedFile={openedFile} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have a session, show the main app
|
|
||||||
if (session) {
|
|
||||||
return <HomePage openedFile={openedFile} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we're at home route ("/"), show login directly (marketing/landing page)
|
|
||||||
// Otherwise navigate to login (fixes URL mismatch for tool routes)
|
|
||||||
const isHome = location.pathname === '/' || location.pathname === '';
|
|
||||||
if (isHome) {
|
|
||||||
return <Login />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For non-home routes without auth, navigate to login (preserves from location)
|
|
||||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
|
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
|
||||||
import { springAuth } from '@app/auth/springAuthClient';
|
import { springAuth } from '@app/auth/springAuthClient';
|
||||||
import type { Session, User, AuthError } from '@app/auth/springAuthClient';
|
import type { Session, User, AuthError, AuthChangeEvent } from '@app/auth/springAuthClient';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth Context Type
|
* Auth Context Type
|
||||||
@ -128,7 +128,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
// Subscribe to auth state changes
|
// Subscribe to auth state changes
|
||||||
const { data: { subscription } } = springAuth.onAuthStateChange(
|
const { data: { subscription } } = springAuth.onAuthStateChange(
|
||||||
async (event, newSession) => {
|
async (event: AuthChangeEvent, newSession: Session | null) => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
console.debug('[Auth] Auth state change:', {
|
console.debug('[Auth] Auth state change:', {
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { BASE_PATH } from '@app/constants/app';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spring Auth Client
|
* Spring Auth Client
|
||||||
*
|
*
|
||||||
@ -9,6 +7,18 @@ import { BASE_PATH } from '@app/constants/app';
|
|||||||
* - No email confirmation flow (auto-confirmed on registration)
|
* - No email confirmation flow (auto-confirmed on registration)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import apiClient from '@app/services/apiClient';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import { BASE_PATH } from '@app/constants/app';
|
||||||
|
|
||||||
|
// Helper to extract error message from axios error
|
||||||
|
function getErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof AxiosError) {
|
||||||
|
return error.response?.data?.error || error.response?.data?.message || error.message || fallback;
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
const OAUTH_REDIRECT_COOKIE = 'stirling_redirect_path';
|
const OAUTH_REDIRECT_COOKIE = 'stirling_redirect_path';
|
||||||
const OAUTH_REDIRECT_COOKIE_MAX_AGE = 60 * 5; // 5 minutes
|
const OAUTH_REDIRECT_COOKIE_MAX_AGE = 60 * 5; // 5 minutes
|
||||||
const DEFAULT_REDIRECT_PATH = `${BASE_PATH || ''}/auth/callback`;
|
const DEFAULT_REDIRECT_PATH = `${BASE_PATH || ''}/auth/callback`;
|
||||||
@ -118,43 +128,16 @@ class SpringAuthClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify with backend
|
// Verify with backend
|
||||||
|
// Note: We pass the token explicitly here, overriding the interceptor's default
|
||||||
console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
|
console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
|
||||||
const response = await fetch('/api/v1/auth/me', {
|
const response = await apiClient.get('/api/v1/auth/me', {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.debug('[SpringAuth] /me response status:', response.status);
|
console.debug('[SpringAuth] /me response status:', response.status);
|
||||||
const contentType = response.headers.get('content-type');
|
const data = response.data;
|
||||||
console.debug('[SpringAuth] /me content-type:', contentType);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
// Log the error response for debugging
|
|
||||||
const errorBody = await response.text();
|
|
||||||
console.error('[SpringAuth] getSession: /api/v1/auth/me failed', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
body: errorBody
|
|
||||||
});
|
|
||||||
|
|
||||||
// Token invalid or expired - clear it
|
|
||||||
localStorage.removeItem('stirling_jwt');
|
|
||||||
console.warn('[SpringAuth] getSession: Cleared invalid JWT from localStorage');
|
|
||||||
return { data: { session: null }, error: { message: `Auth failed: ${response.status}` } };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if response is JSON before parsing
|
|
||||||
if (!contentType?.includes('application/json')) {
|
|
||||||
const text = await response.text();
|
|
||||||
console.error('[SpringAuth] /me returned non-JSON:', {
|
|
||||||
contentType,
|
|
||||||
bodyPreview: text.substring(0, 200)
|
|
||||||
});
|
|
||||||
throw new Error(`/api/v1/auth/me returned HTML instead of JSON`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
console.debug('[SpringAuth] /me response data:', data);
|
console.debug('[SpringAuth] /me response data:', data);
|
||||||
|
|
||||||
// Create session object
|
// Create session object
|
||||||
@ -167,13 +150,21 @@ class SpringAuthClient {
|
|||||||
|
|
||||||
console.debug('[SpringAuth] getSession: Session retrieved successfully');
|
console.debug('[SpringAuth] getSession: Session retrieved successfully');
|
||||||
return { data: { session }, error: null };
|
return { data: { session }, error: null };
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error('[SpringAuth] getSession error:', error);
|
console.error('[SpringAuth] getSession error:', error);
|
||||||
// Clear potentially invalid token
|
|
||||||
|
// If 401/403, token is invalid - clear it
|
||||||
|
if (error instanceof AxiosError && (error.response?.status === 401 || error.response?.status === 403)) {
|
||||||
|
localStorage.removeItem('stirling_jwt');
|
||||||
|
console.debug('[SpringAuth] getSession: Not authenticated');
|
||||||
|
return { data: { session: null }, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear potentially invalid token on other errors too
|
||||||
localStorage.removeItem('stirling_jwt');
|
localStorage.removeItem('stirling_jwt');
|
||||||
return {
|
return {
|
||||||
data: { session: null },
|
data: { session: null },
|
||||||
error: { message: error instanceof Error ? error.message : 'Unknown error' },
|
error: { message: getErrorMessage(error, 'Unknown error') },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -186,22 +177,14 @@ class SpringAuthClient {
|
|||||||
password: string;
|
password: string;
|
||||||
}): Promise<AuthResponse> {
|
}): Promise<AuthResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/auth/login', {
|
const response = await apiClient.post('/api/v1/auth/login', {
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include', // Include cookies for CSRF
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: credentials.email,
|
username: credentials.email,
|
||||||
password: credentials.password
|
password: credentials.password
|
||||||
}),
|
}, {
|
||||||
|
withCredentials: true, // Include cookies for CSRF
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
const data = response.data;
|
||||||
const error = await response.json();
|
|
||||||
return { user: null, session: null, error: { message: error.error || 'Login failed' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const token = data.session.access_token;
|
const token = data.session.access_token;
|
||||||
|
|
||||||
// Store JWT in localStorage
|
// Store JWT in localStorage
|
||||||
@ -222,12 +205,12 @@ class SpringAuthClient {
|
|||||||
this.notifyListeners('SIGNED_IN', session);
|
this.notifyListeners('SIGNED_IN', session);
|
||||||
|
|
||||||
return { user: data.user, session, error: null };
|
return { user: data.user, session, error: null };
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error('[SpringAuth] signInWithPassword error:', error);
|
console.error('[SpringAuth] signInWithPassword error:', error);
|
||||||
return {
|
return {
|
||||||
user: null,
|
user: null,
|
||||||
session: null,
|
session: null,
|
||||||
error: { message: error instanceof Error ? error.message : 'Login failed' },
|
error: { message: getErrorMessage(error, 'Login failed') },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -241,32 +224,24 @@ class SpringAuthClient {
|
|||||||
options?: { data?: { full_name?: string }; emailRedirectTo?: string };
|
options?: { data?: { full_name?: string }; emailRedirectTo?: string };
|
||||||
}): Promise<AuthResponse> {
|
}): Promise<AuthResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/user/register', {
|
const response = await apiClient.post('/api/v1/user/register', {
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: credentials.email,
|
username: credentials.email,
|
||||||
password: credentials.password,
|
password: credentials.password,
|
||||||
}),
|
}, {
|
||||||
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
const data = response.data;
|
||||||
const error = await response.json();
|
|
||||||
return { user: null, session: null, error: { message: error.error || 'Registration failed' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Note: Spring backend auto-confirms users (no email verification)
|
// Note: Spring backend auto-confirms users (no email verification)
|
||||||
// Return user but no session (user needs to login)
|
// Return user but no session (user needs to login)
|
||||||
return { user: data.user, session: null, error: null };
|
return { user: data.user, session: null, error: null };
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error('[SpringAuth] signUp error:', error);
|
console.error('[SpringAuth] signUp error:', error);
|
||||||
return {
|
return {
|
||||||
user: null,
|
user: null,
|
||||||
session: null,
|
session: null,
|
||||||
error: { message: error instanceof Error ? error.message : 'Registration failed' },
|
error: { message: getErrorMessage(error, 'Registration failed') },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -297,104 +272,82 @@ class SpringAuthClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign out
|
* Sign out user (invalidate session)
|
||||||
*/
|
*/
|
||||||
async signOut(): Promise<{ error: AuthError | null }> {
|
async signOut(): Promise<{ error: AuthError | null }> {
|
||||||
try {
|
try {
|
||||||
// Clear JWT from localStorage immediately
|
const response = await apiClient.post('/api/v1/auth/logout', null, {
|
||||||
localStorage.removeItem('stirling_jwt');
|
headers: {
|
||||||
console.log('[SpringAuth] JWT removed from localStorage');
|
'X-CSRF-TOKEN': this.getCsrfToken() || '',
|
||||||
|
},
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
const csrfToken = this.getCsrfToken();
|
if (response.status === 200) {
|
||||||
const headers: HeadersInit = {};
|
console.debug('[SpringAuth] signOut: Success');
|
||||||
|
|
||||||
if (csrfToken) {
|
|
||||||
headers['X-XSRF-TOKEN'] = csrfToken;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify backend (optional - mainly for session cleanup)
|
// Clean up local storage
|
||||||
await fetch('/api/v1/auth/logout', {
|
localStorage.removeItem('stirling_jwt');
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Notify listeners
|
// Notify listeners
|
||||||
this.notifyListeners('SIGNED_OUT', null);
|
this.notifyListeners('SIGNED_OUT', null);
|
||||||
|
|
||||||
return { error: null };
|
return { error: null };
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error('[SpringAuth] signOut error:', error);
|
console.error('[SpringAuth] signOut error:', error);
|
||||||
// Still remove token even if backend call fails
|
// Still remove token even if backend call fails
|
||||||
localStorage.removeItem('stirling_jwt');
|
localStorage.removeItem('stirling_jwt');
|
||||||
return {
|
return {
|
||||||
error: { message: error instanceof Error ? error.message : 'Sign out failed' },
|
error: { message: getErrorMessage(error, 'Logout failed') },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh session token
|
* Refresh JWT token
|
||||||
*/
|
*/
|
||||||
async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
|
async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
|
||||||
try {
|
try {
|
||||||
const currentToken = localStorage.getItem('stirling_jwt');
|
const response = await apiClient.post('/api/v1/auth/refresh', null, {
|
||||||
|
|
||||||
if (!currentToken) {
|
|
||||||
return { data: { session: null }, error: { message: 'No token to refresh' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch('/api/v1/auth/refresh', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${currentToken}`,
|
'X-CSRF-TOKEN': this.getCsrfToken() || '',
|
||||||
},
|
},
|
||||||
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
const data = response.data;
|
||||||
localStorage.removeItem('stirling_jwt');
|
const token = data.session.access_token;
|
||||||
return { data: { session: null }, error: { message: 'Token refresh failed' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshData = await response.json();
|
// Update local storage with new token
|
||||||
const newToken = refreshData.access_token;
|
localStorage.setItem('stirling_jwt', token);
|
||||||
|
|
||||||
// Store new token
|
|
||||||
localStorage.setItem('stirling_jwt', newToken);
|
|
||||||
|
|
||||||
// Dispatch custom event for other components to react to JWT availability
|
// Dispatch custom event for other components to react to JWT availability
|
||||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||||
|
|
||||||
// Get updated user info
|
|
||||||
const userResponse = await fetch('/api/v1/auth/me', {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${newToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!userResponse.ok) {
|
|
||||||
localStorage.removeItem('stirling_jwt');
|
|
||||||
return { data: { session: null }, error: { message: 'Failed to get user info' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const userData = await userResponse.json();
|
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
user: userData.user,
|
user: data.user,
|
||||||
access_token: newToken,
|
access_token: token,
|
||||||
expires_in: 3600,
|
expires_in: data.session.expires_in,
|
||||||
expires_at: Date.now() + 3600 * 1000,
|
expires_at: Date.now() + data.session.expires_in * 1000,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Notify listeners
|
// Notify listeners
|
||||||
this.notifyListeners('TOKEN_REFRESHED', session);
|
this.notifyListeners('TOKEN_REFRESHED', session);
|
||||||
|
|
||||||
return { data: { session }, error: null };
|
return { data: { session }, error: null };
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error('[SpringAuth] refreshSession error:', error);
|
console.error('[SpringAuth] refreshSession error:', error);
|
||||||
localStorage.removeItem('stirling_jwt');
|
localStorage.removeItem('stirling_jwt');
|
||||||
|
|
||||||
|
// Handle different error statuses
|
||||||
|
if (error instanceof AxiosError && (error.response?.status === 401 || error.response?.status === 403)) {
|
||||||
|
return { data: { session: null }, error: { message: 'Token refresh failed - please log in again' } };
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: { session: null },
|
data: { session: null },
|
||||||
error: { message: error instanceof Error ? error.message : 'Refresh failed' },
|
error: { message: getErrorMessage(error, 'Token refresh failed') },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { ReactNode } from "react";
|
import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/components/AppProviders";
|
||||||
import { AppProviders as CoreAppProviders } from "@core/components/AppProviders";
|
|
||||||
import { AuthProvider } from "@app/auth/UseSession";
|
import { AuthProvider } from "@app/auth/UseSession";
|
||||||
|
|
||||||
export function AppProviders({ children }: { children: ReactNode }) {
|
export function AppProviders({ children, appConfigRetryOptions }: AppProvidersProps) {
|
||||||
return (
|
return (
|
||||||
<CoreAppProviders>
|
<CoreAppProviders appConfigRetryOptions={appConfigRetryOptions}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
{children}
|
{children}
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user