diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index 4dbd632b9..46ce96477 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -8,7 +8,7 @@ import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; import { HotkeyProvider } from "@app/contexts/HotkeyContext"; import { SidebarProvider } from "@app/contexts/SidebarContext"; 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 { ViewerProvider } from "@app/contexts/ViewerContext"; import { SignatureProvider } from "@app/contexts/SignatureContext"; @@ -16,6 +16,7 @@ import { OnboardingProvider } from "@app/contexts/OnboardingContext"; import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext"; import ErrorBoundary from "@app/components/shared/ErrorBoundary"; import { useScarfTracking } from "@app/hooks/useScarfTracking"; +import { useAppInitialization } from "@app/hooks/useAppInitialization"; // Component to initialize scarf tracking (must be inside AppConfigProvider) function ScarfTrackingInitializer() { @@ -23,19 +24,31 @@ function ScarfTrackingInitializer() { 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 * Contains all providers needed for the core */ -export function AppProviders({ children }: { children: ReactNode }) { +export function AppProviders({ children, appConfigRetryOptions }: AppProvidersProps) { return ( - + + diff --git a/frontend/src/core/contexts/AppConfigContext.tsx b/frontend/src/core/contexts/AppConfigContext.tsx index 5bb05a50b..2a18b6fea 100644 --- a/frontend/src/core/contexts/AppConfigContext.tsx +++ b/frontend/src/core/contexts/AppConfigContext.tsx @@ -1,6 +1,18 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import apiClient from '@app/services/apiClient'; +/** + * Sleep utility for delays + */ +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export interface AppConfigRetryOptions { + maxRetries?: number; + initialDelay?: number; +} + export interface AppConfig { baseUrl?: string; contextPath?: string; @@ -47,12 +59,18 @@ const AppConfigContext = createContext({ * 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: ReactNode }> = ({ children }) => { +export const AppConfigProvider: React.FC<{ + children: ReactNode; + retryOptions?: AppConfigRetryOptions; +}> = ({ children, retryOptions }) => { const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [fetchCount, setFetchCount] = useState(0); + const maxRetries = retryOptions?.maxRetries ?? 0; + const initialDelay = retryOptions?.initialDelay ?? 1000; + const fetchConfig = async (force = false) => { // Prevent duplicate fetches unless forced if (!force && fetchCount > 0) { @@ -60,35 +78,59 @@ export const AppConfigProvider: React.FC<{ children: ReactNode }> = ({ children return; } - try { - setLoading(true); - setError(null); + setLoading(true); + setError(null); - // apiClient automatically adds JWT header if available via interceptors - const response = await apiClient.get('/api/v1/config/app-config'); - const data = response.data; + 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...'); + } - console.debug('[AppConfig] Config fetched successfully:', data); - setConfig(data); - setFetchCount(prev => prev + 1); - } catch (err: any) { - // On 401 (not authenticated), use default config with login enabled - // This allows the app to work even without authentication - if (err.response?.status === 401) { - console.debug('[AppConfig] 401 error - using default config (login enabled)'); - setConfig({ enableLogin: true }); + // apiClient automatically adds JWT header if available via interceptors + const response = await apiClient.get('/api/v1/config/app-config'); + const data = response.data; + + console.debug('[AppConfig] Config fetched successfully:', data); + setConfig(data); + setFetchCount(prev => prev + 1); setLoading(false); - return; - } + return; // Success - exit function + } catch (err: any) { + const status = err?.response?.status; - const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; - setError(errorMessage); - console.error('[AppConfig] Failed to fetch app config:', err); - // On error, assume login is enabled (safe default) - setConfig({ enableLogin: true }); - } finally { - setLoading(false); + // On 401 (not authenticated), use default config with login enabled + // This allows the app to work even without authentication + if (status === 401) { + console.debug('[AppConfig] 401 error - using default config (login enabled)'); + setConfig({ enableLogin: true }); + setLoading(false); + return; + } + + // 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); + console.error(`[AppConfig] Failed to fetch app config after ${attempt + 1} attempts:`, err); + // On error, assume login is enabled (safe default) + setConfig({ enableLogin: true }); + break; + } } + + setLoading(false); }; useEffect(() => { diff --git a/frontend/src/core/hooks/useAppInitialization.ts b/frontend/src/core/hooks/useAppInitialization.ts new file mode 100644 index 000000000..4b822e2b8 --- /dev/null +++ b/frontend/src/core/hooks/useAppInitialization.ts @@ -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 +} diff --git a/frontend/src/core/pages/HomePage.tsx b/frontend/src/core/pages/HomePage.tsx index 683f76a08..7c4d3a6c2 100644 --- a/frontend/src/core/pages/HomePage.tsx +++ b/frontend/src/core/pages/HomePage.tsx @@ -20,21 +20,13 @@ import { useFilesModalContext } from "@app/contexts/FilesModalContext"; import AppConfigModal from "@app/components/shared/AppConfigModal"; import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt"; import AdminAnalyticsChoiceModal from "@app/components/shared/AdminAnalyticsChoiceModal"; -import { useHomePageExtensions } from "@app/pages/useHomePageExtensions"; import "@app/pages/HomePage.css"; type MobileView = "tools" | "workbench"; -interface HomePageProps { - openedFile?: File | null; -} - -export default function HomePage({ openedFile }: HomePageProps = {}) { +export default function HomePage() { const { t } = useTranslation(); - - // Extension hook for desktop-specific behavior (e.g., file opening) - useHomePageExtensions(openedFile); const { sidebarRefs, } = useSidebarContext(); diff --git a/frontend/src/core/pages/useHomePageExtensions.ts b/frontend/src/core/pages/useHomePageExtensions.ts deleted file mode 100644 index 211112f9e..000000000 --- a/frontend/src/core/pages/useHomePageExtensions.ts +++ /dev/null @@ -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]); -} diff --git a/frontend/src/desktop/auth/UseSession.tsx b/frontend/src/desktop/auth/UseSession.tsx deleted file mode 100644 index 3deb17746..000000000 --- a/frontend/src/desktop/auth/UseSession.tsx +++ /dev/null @@ -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; - refreshSession: () => Promise; -} - -const AuthContext = createContext({ - 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(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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 ( - - {children} - - ); -} - -/** - * 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; -} diff --git a/frontend/src/desktop/auth/springAuthClient.ts b/frontend/src/desktop/auth/springAuthClient.ts deleted file mode 100644 index 57c04b56a..000000000 --- a/frontend/src/desktop/auth/springAuthClient.ts +++ /dev/null @@ -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; -} - -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 { - 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 { - 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 }; - }): 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; diff --git a/frontend/src/desktop/components/AppProviders.tsx b/frontend/src/desktop/components/AppProviders.tsx new file mode 100644 index 000000000..66a86af23 --- /dev/null +++ b/frontend/src/desktop/components/AppProviders.tsx @@ -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 ( + + {children} + + ); +} diff --git a/frontend/src/desktop/contexts/AppConfigContext.tsx b/frontend/src/desktop/contexts/AppConfigContext.tsx deleted file mode 100644 index 399ba2e62..000000000 --- a/frontend/src/desktop/contexts/AppConfigContext.tsx +++ /dev/null @@ -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 { - 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; -} - -// Create context -const AppConfigContext = createContext(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(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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('/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 ( - - {children} - - ); -}; - -/** - * 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; -} diff --git a/frontend/src/desktop/hooks/useAppInitialization.ts b/frontend/src/desktop/hooks/useAppInitialization.ts new file mode 100644 index 000000000..c64ea1180 --- /dev/null +++ b/frontend/src/desktop/hooks/useAppInitialization.ts @@ -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]); +} diff --git a/frontend/src/desktop/pages/useHomePageExtensions.ts b/frontend/src/desktop/pages/useHomePageExtensions.ts deleted file mode 100644 index fbffeb3e3..000000000 --- a/frontend/src/desktop/pages/useHomePageExtensions.ts +++ /dev/null @@ -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]); -} diff --git a/frontend/src/desktop/routes/Landing.tsx b/frontend/src/desktop/routes/Landing.tsx deleted file mode 100644 index a31d84c05..000000000 --- a/frontend/src/desktop/routes/Landing.tsx +++ /dev/null @@ -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(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 ( -
-
-
-
- Loading... -
-
-
- ); - } - - // If login is disabled, show app directly (anonymous mode) - if (config?.enableLogin === false) { - console.debug('[Landing] Login disabled - showing app in anonymous mode'); - return ; - } - - // If we have a session, show the main app - if (session) { - return ; - } - - // 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 ; - } - - // For non-home routes without auth, navigate to login (preserves from location) - return ; -} diff --git a/frontend/src/proprietary/auth/UseSession.tsx b/frontend/src/proprietary/auth/UseSession.tsx index 2cb2868ec..748910e33 100644 --- a/frontend/src/proprietary/auth/UseSession.tsx +++ b/frontend/src/proprietary/auth/UseSession.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react'; 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 @@ -128,7 +128,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Subscribe to auth state changes const { data: { subscription } } = springAuth.onAuthStateChange( - async (event, newSession) => { + async (event: AuthChangeEvent, newSession: Session | null) => { if (!mounted) return; console.debug('[Auth] Auth state change:', { diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts index 73c883b56..5d3e2d4dc 100644 --- a/frontend/src/proprietary/auth/springAuthClient.ts +++ b/frontend/src/proprietary/auth/springAuthClient.ts @@ -1,5 +1,3 @@ -import { BASE_PATH } from '@app/constants/app'; - /** * Spring Auth Client * @@ -9,6 +7,18 @@ import { BASE_PATH } from '@app/constants/app'; * - 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_MAX_AGE = 60 * 5; // 5 minutes const DEFAULT_REDIRECT_PATH = `${BASE_PATH || ''}/auth/callback`; @@ -118,43 +128,16 @@ class SpringAuthClient { } // 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'); - const response = await fetch('/api/v1/auth/me', { + const response = await apiClient.get('/api/v1/auth/me', { headers: { 'Authorization': `Bearer ${token}`, }, }); console.debug('[SpringAuth] /me response status:', response.status); - const contentType = response.headers.get('content-type'); - 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(); + const data = response.data; console.debug('[SpringAuth] /me response data:', data); // Create session object @@ -167,13 +150,21 @@ class SpringAuthClient { console.debug('[SpringAuth] getSession: Session retrieved successfully'); return { data: { session }, error: null }; - } catch (error) { + } catch (error: unknown) { 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'); return { 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; }): Promise { try { - const response = await fetch('/api/v1/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', // Include cookies for CSRF - body: JSON.stringify({ - username: credentials.email, - password: credentials.password - }), + const response = await apiClient.post('/api/v1/auth/login', { + username: credentials.email, + password: credentials.password + }, { + withCredentials: true, // Include cookies for CSRF }); - if (!response.ok) { - const error = await response.json(); - return { user: null, session: null, error: { message: error.error || 'Login failed' } }; - } - - const data = await response.json(); + const data = response.data; const token = data.session.access_token; // Store JWT in localStorage @@ -222,12 +205,12 @@ class SpringAuthClient { this.notifyListeners('SIGNED_IN', session); return { user: data.user, session, error: null }; - } catch (error) { + } catch (error: unknown) { console.error('[SpringAuth] signInWithPassword error:', error); return { user: 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 }; }): Promise { try { - const response = await fetch('/api/v1/user/register', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ - username: credentials.email, - password: credentials.password, - }), + const response = await apiClient.post('/api/v1/user/register', { + username: credentials.email, + password: credentials.password, + }, { + withCredentials: true, }); - if (!response.ok) { - const error = await response.json(); - return { user: null, session: null, error: { message: error.error || 'Registration failed' } }; - } - - const data = await response.json(); + 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) { + } catch (error: unknown) { console.error('[SpringAuth] signUp error:', error); return { user: 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 }> { try { - // Clear JWT from localStorage immediately - localStorage.removeItem('stirling_jwt'); - console.log('[SpringAuth] JWT removed from localStorage'); + const response = await apiClient.post('/api/v1/auth/logout', null, { + headers: { + 'X-CSRF-TOKEN': this.getCsrfToken() || '', + }, + withCredentials: true, + }); - const csrfToken = this.getCsrfToken(); - const headers: HeadersInit = {}; - - if (csrfToken) { - headers['X-XSRF-TOKEN'] = csrfToken; + if (response.status === 200) { + console.debug('[SpringAuth] signOut: Success'); } - // Notify backend (optional - mainly for session cleanup) - await fetch('/api/v1/auth/logout', { - method: 'POST', - credentials: 'include', - headers, - }); + // Clean up local storage + localStorage.removeItem('stirling_jwt'); // Notify listeners this.notifyListeners('SIGNED_OUT', null); return { error: null }; - } catch (error) { + } catch (error: unknown) { console.error('[SpringAuth] signOut error:', error); // Still remove token even if backend call fails localStorage.removeItem('stirling_jwt'); 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 }> { try { - const currentToken = localStorage.getItem('stirling_jwt'); - - if (!currentToken) { - return { data: { session: null }, error: { message: 'No token to refresh' } }; - } - - const response = await fetch('/api/v1/auth/refresh', { - method: 'POST', + const response = await apiClient.post('/api/v1/auth/refresh', null, { headers: { - 'Authorization': `Bearer ${currentToken}`, + 'X-CSRF-TOKEN': this.getCsrfToken() || '', }, + withCredentials: true, }); - if (!response.ok) { - localStorage.removeItem('stirling_jwt'); - return { data: { session: null }, error: { message: 'Token refresh failed' } }; - } + const data = response.data; + const token = data.session.access_token; - const refreshData = await response.json(); - const newToken = refreshData.access_token; - - // Store new token - localStorage.setItem('stirling_jwt', newToken); + // Update local storage with new token + localStorage.setItem('stirling_jwt', token); // Dispatch custom event for other components to react to JWT availability 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 = { - user: userData.user, - access_token: newToken, - expires_in: 3600, - expires_at: Date.now() + 3600 * 1000, + 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) { + } catch (error: unknown) { console.error('[SpringAuth] refreshSession error:', error); 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 { data: { session: null }, - error: { message: error instanceof Error ? error.message : 'Refresh failed' }, + error: { message: getErrorMessage(error, 'Token refresh failed') }, }; } } diff --git a/frontend/src/proprietary/components/AppProviders.tsx b/frontend/src/proprietary/components/AppProviders.tsx index 74a42f573..bad8887c3 100644 --- a/frontend/src/proprietary/components/AppProviders.tsx +++ b/frontend/src/proprietary/components/AppProviders.tsx @@ -1,10 +1,9 @@ -import { ReactNode } from "react"; -import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"; +import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/components/AppProviders"; import { AuthProvider } from "@app/auth/UseSession"; -export function AppProviders({ children }: { children: ReactNode }) { +export function AppProviders({ children, appConfigRetryOptions }: AppProvidersProps) { return ( - + {children}