diff --git a/lib/core/auth/api_auth_interceptor.dart b/lib/core/auth/api_auth_interceptor.dart index ebb4146..f283e77 100644 --- a/lib/core/auth/api_auth_interceptor.dart +++ b/lib/core/auth/api_auth_interceptor.dart @@ -35,6 +35,13 @@ class ApiAuthInterceptor extends Interceptor { '/api/v1/configs/models', }; + // Endpoints for features that can be disabled server-side. + // A 403 on these indicates the feature is disabled, not an auth failure. + static const Set _featureEndpoints = { + '/api/v1/folders/', + '/api/v1/folders', + }; + ApiAuthInterceptor({ String? authToken, this.onAuthTokenInvalid, @@ -79,6 +86,20 @@ class ApiAuthInterceptor extends Interceptor { return _optionalAuthEndpoints.contains(path); } + /// Check if endpoint is for a feature that can be disabled server-side. + /// A 403 on these indicates feature disabled, not an auth failure. + bool _isFeatureEndpoint(String path) { + // Direct match for exact paths like /api/v1/folders or /api/v1/folders/ + if (_featureEndpoints.contains(path)) { + return true; + } + // Check for folder sub-paths (e.g., /api/v1/folders/{id}) + if (path.startsWith('/api/v1/folders/')) { + return true; + } + return false; + } + @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { final path = options.path; @@ -153,9 +174,15 @@ class ApiAuthInterceptor extends Interceptor { } } else if (statusCode == 403) { // 403 on protected endpoints indicates insufficient permissions or invalid token + // BUT 403 on feature endpoints indicates the feature is disabled server-side final requiresAuth = _requiresAuth(path); final optionalAuth = _hasOptionalAuth(path); - if (requiresAuth && !optionalAuth) { + final isFeatureEndpoint = _isFeatureEndpoint(path); + if (isFeatureEndpoint) { + DebugLogger.auth( + '403 Forbidden on feature endpoint $path - feature likely disabled server-side', + ); + } else if (requiresAuth && !optionalAuth) { _notifyAuthFailure( '403 Forbidden on protected endpoint $path - notifying app without clearing token', ); diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index 7e71b01..ac620e5 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -1285,7 +1285,10 @@ class Conversations extends _$Conversations { error: error, stackTrace: stackTrace, ); - return >[]; + // Preserve the existing enabled state on error (don't override a + // previously determined disabled state due to network errors) + final currentEnabled = ref.read(foldersFeatureEnabledProvider); + return (const >[], currentEnabled); }); final results = await Future.wait([ @@ -1293,7 +1296,14 @@ class Conversations extends _$Conversations { foldersFuture, ]); final conversations = results[0] as List; - final foldersData = results[1] as List>; + final foldersResult = results[1] as (List>, bool); + final foldersData = foldersResult.$1; + final foldersEnabled = foldersResult.$2; + + // Update the folders feature enabled state + ref + .read(foldersFeatureEnabledProvider.notifier) + .setEnabled(foldersEnabled); DebugLogger.log( 'fetch-ok', scope: 'conversations', @@ -2130,6 +2140,22 @@ final webSearchAvailableProvider = Provider((ref) { ); }); +/// Tracks whether the folders feature is enabled on the server. +/// When the server returns 403 for folders endpoint, this becomes false. +final foldersFeatureEnabledProvider = + NotifierProvider( + FoldersFeatureEnabledNotifier.new, + ); + +class FoldersFeatureEnabledNotifier extends Notifier { + @override + bool build() => true; + + void setEnabled(bool enabled) { + state = enabled; + } +} + // Folders provider @Riverpod(keepAlive: true) class Folders extends _$Folders { @@ -2224,14 +2250,20 @@ class Folders extends _$Folders { Future> _load(ApiService api) async { try { - final foldersData = await api.getFolders(); + final (foldersData, featureEnabled) = await api.getFolders(); + + // Update the folders feature enabled state + ref + .read(foldersFeatureEnabledProvider.notifier) + .setEnabled(featureEnabled); + final folders = foldersData .map((folderData) => Folder.fromJson(folderData)) .toList(); DebugLogger.log( 'fetch-ok', scope: 'folders', - data: {'count': folders.length}, + data: {'count': folders.length, 'enabled': featureEnabled}, ); final sorted = _sort(folders); _persistFoldersAsync(sorted); diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index daecffb..488770c 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -1123,7 +1123,9 @@ class ApiService { } // Folders - Future>> getFolders() async { + /// Returns a record with (folders data, feature enabled flag). + /// When the folders feature is disabled server-side (403), returns ([], false). + Future<(List>, bool)> getFolders() async { try { final response = await _dio.get('/api/v1/folders/'); DebugLogger.log( @@ -1136,15 +1138,27 @@ class ApiService { final data = response.data; if (data is List) { _traceApi('Found ${data.length} folders'); - return data.cast>(); + return (data.cast>(), true); } else { DebugLogger.warning( 'unexpected-type', scope: 'api/folders', data: {'type': data.runtimeType}, ); - return []; + return (const >[], true); } + } on DioException catch (e) { + // 403 indicates folders feature is disabled server-side + if (e.response?.statusCode == 403) { + DebugLogger.log( + 'feature-disabled', + scope: 'api/folders', + data: {'status': 403}, + ); + return (const >[], false); + } + DebugLogger.error('fetch-failed', scope: 'api/folders', error: e); + rethrow; } catch (e) { DebugLogger.error('fetch-failed', scope: 'api/folders', error: e); rethrow; diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index 7687019..c60500f 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -327,6 +327,7 @@ class _ChatsDrawerState extends ConsumerState { final showPinned = ref.watch(_showPinnedProvider); final showFolders = ref.watch(_showFoldersProvider); final showRecent = ref.watch(_showRecentProvider); + final foldersEnabled = ref.watch(foldersFeatureEnabledProvider); final slivers = [ if (pinned.isNotEmpty) ...[ @@ -347,12 +348,14 @@ class _ChatsDrawerState extends ConsumerState { const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)), ], - // Folders section (shown even if empty) - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.md), - sliver: SliverToBoxAdapter(child: _buildFoldersSectionHeader()), - ), - if (showFolders) ...[ + // Folders section (hidden when feature is disabled server-side) + if (foldersEnabled) ...[ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.md), + sliver: SliverToBoxAdapter(child: _buildFoldersSectionHeader()), + ), + ], + if (showFolders && foldersEnabled) ...[ const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)), if (_isDragging && _draggingHasFolder) ...[ SliverPadding( @@ -442,7 +445,8 @@ class _ChatsDrawerState extends ConsumerState { ], ), ], - const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)), + if (foldersEnabled) + const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)), if (regular.isNotEmpty) ...[ SliverPadding( @@ -586,14 +590,18 @@ class _ChatsDrawerState extends ConsumerState { ); } - slivers.add( - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.md), - sliver: SliverToBoxAdapter(child: _buildFoldersSectionHeader()), - ), - ); + // Folders section (hidden when feature is disabled server-side) + final foldersEnabled = ref.watch(foldersFeatureEnabledProvider); + if (foldersEnabled) { + slivers.add( + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.md), + sliver: SliverToBoxAdapter(child: _buildFoldersSectionHeader()), + ), + ); + } - if (showFolders) { + if (showFolders && foldersEnabled) { slivers.add( const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)), ); @@ -686,9 +694,11 @@ class _ChatsDrawerState extends ConsumerState { slivers.addAll(folderSlivers); } - slivers.add( - const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)), - ); + if (foldersEnabled) { + slivers.add( + const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)), + ); + } if (regular.isNotEmpty) { slivers.add(