feat(folders): Preserve feature state on server-side errors

This commit is contained in:
cogwheel0
2025-12-06 10:16:44 +05:30
parent 951a568fc1
commit 4e105270b8
4 changed files with 101 additions and 21 deletions

View File

@@ -35,6 +35,13 @@ class ApiAuthInterceptor extends Interceptor {
'/api/v1/configs/models', '/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<String> _featureEndpoints = {
'/api/v1/folders/',
'/api/v1/folders',
};
ApiAuthInterceptor({ ApiAuthInterceptor({
String? authToken, String? authToken,
this.onAuthTokenInvalid, this.onAuthTokenInvalid,
@@ -79,6 +86,20 @@ class ApiAuthInterceptor extends Interceptor {
return _optionalAuthEndpoints.contains(path); 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 @override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) { void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final path = options.path; final path = options.path;
@@ -153,9 +174,15 @@ class ApiAuthInterceptor extends Interceptor {
} }
} else if (statusCode == 403) { } else if (statusCode == 403) {
// 403 on protected endpoints indicates insufficient permissions or invalid token // 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 requiresAuth = _requiresAuth(path);
final optionalAuth = _hasOptionalAuth(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( _notifyAuthFailure(
'403 Forbidden on protected endpoint $path - notifying app without clearing token', '403 Forbidden on protected endpoint $path - notifying app without clearing token',
); );

View File

@@ -1285,7 +1285,10 @@ class Conversations extends _$Conversations {
error: error, error: error,
stackTrace: stackTrace, stackTrace: stackTrace,
); );
return <Map<String, dynamic>>[]; // 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 <Map<String, dynamic>>[], currentEnabled);
}); });
final results = await Future.wait<dynamic>([ final results = await Future.wait<dynamic>([
@@ -1293,7 +1296,14 @@ class Conversations extends _$Conversations {
foldersFuture, foldersFuture,
]); ]);
final conversations = results[0] as List<Conversation>; final conversations = results[0] as List<Conversation>;
final foldersData = results[1] as List<Map<String, dynamic>>; final foldersResult = results[1] as (List<Map<String, dynamic>>, bool);
final foldersData = foldersResult.$1;
final foldersEnabled = foldersResult.$2;
// Update the folders feature enabled state
ref
.read(foldersFeatureEnabledProvider.notifier)
.setEnabled(foldersEnabled);
DebugLogger.log( DebugLogger.log(
'fetch-ok', 'fetch-ok',
scope: 'conversations', scope: 'conversations',
@@ -2130,6 +2140,22 @@ final webSearchAvailableProvider = Provider<bool>((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, bool>(
FoldersFeatureEnabledNotifier.new,
);
class FoldersFeatureEnabledNotifier extends Notifier<bool> {
@override
bool build() => true;
void setEnabled(bool enabled) {
state = enabled;
}
}
// Folders provider // Folders provider
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
class Folders extends _$Folders { class Folders extends _$Folders {
@@ -2224,14 +2250,20 @@ class Folders extends _$Folders {
Future<List<Folder>> _load(ApiService api) async { Future<List<Folder>> _load(ApiService api) async {
try { 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 final folders = foldersData
.map((folderData) => Folder.fromJson(folderData)) .map((folderData) => Folder.fromJson(folderData))
.toList(); .toList();
DebugLogger.log( DebugLogger.log(
'fetch-ok', 'fetch-ok',
scope: 'folders', scope: 'folders',
data: {'count': folders.length}, data: {'count': folders.length, 'enabled': featureEnabled},
); );
final sorted = _sort(folders); final sorted = _sort(folders);
_persistFoldersAsync(sorted); _persistFoldersAsync(sorted);

View File

@@ -1123,7 +1123,9 @@ class ApiService {
} }
// Folders // Folders
Future<List<Map<String, dynamic>>> getFolders() async { /// Returns a record with (folders data, feature enabled flag).
/// When the folders feature is disabled server-side (403), returns ([], false).
Future<(List<Map<String, dynamic>>, bool)> getFolders() async {
try { try {
final response = await _dio.get('/api/v1/folders/'); final response = await _dio.get('/api/v1/folders/');
DebugLogger.log( DebugLogger.log(
@@ -1136,15 +1138,27 @@ class ApiService {
final data = response.data; final data = response.data;
if (data is List) { if (data is List) {
_traceApi('Found ${data.length} folders'); _traceApi('Found ${data.length} folders');
return data.cast<Map<String, dynamic>>(); return (data.cast<Map<String, dynamic>>(), true);
} else { } else {
DebugLogger.warning( DebugLogger.warning(
'unexpected-type', 'unexpected-type',
scope: 'api/folders', scope: 'api/folders',
data: {'type': data.runtimeType}, data: {'type': data.runtimeType},
); );
return []; return (const <Map<String, dynamic>>[], 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 <Map<String, dynamic>>[], false);
}
DebugLogger.error('fetch-failed', scope: 'api/folders', error: e);
rethrow;
} catch (e) { } catch (e) {
DebugLogger.error('fetch-failed', scope: 'api/folders', error: e); DebugLogger.error('fetch-failed', scope: 'api/folders', error: e);
rethrow; rethrow;

View File

@@ -327,6 +327,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
final showPinned = ref.watch(_showPinnedProvider); final showPinned = ref.watch(_showPinnedProvider);
final showFolders = ref.watch(_showFoldersProvider); final showFolders = ref.watch(_showFoldersProvider);
final showRecent = ref.watch(_showRecentProvider); final showRecent = ref.watch(_showRecentProvider);
final foldersEnabled = ref.watch(foldersFeatureEnabledProvider);
final slivers = <Widget>[ final slivers = <Widget>[
if (pinned.isNotEmpty) ...[ if (pinned.isNotEmpty) ...[
@@ -347,12 +348,14 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)), const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)),
], ],
// Folders section (shown even if empty) // Folders section (hidden when feature is disabled server-side)
SliverPadding( if (foldersEnabled) ...[
padding: const EdgeInsets.symmetric(horizontal: Spacing.md), SliverPadding(
sliver: SliverToBoxAdapter(child: _buildFoldersSectionHeader()), padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
), sliver: SliverToBoxAdapter(child: _buildFoldersSectionHeader()),
if (showFolders) ...[ ),
],
if (showFolders && foldersEnabled) ...[
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)), const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
if (_isDragging && _draggingHasFolder) ...[ if (_isDragging && _draggingHasFolder) ...[
SliverPadding( SliverPadding(
@@ -586,14 +589,18 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
); );
} }
slivers.add( // Folders section (hidden when feature is disabled server-side)
SliverPadding( final foldersEnabled = ref.watch(foldersFeatureEnabledProvider);
padding: const EdgeInsets.symmetric(horizontal: Spacing.md), if (foldersEnabled) {
sliver: SliverToBoxAdapter(child: _buildFoldersSectionHeader()), slivers.add(
), SliverPadding(
); padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(child: _buildFoldersSectionHeader()),
),
);
}
if (showFolders) { if (showFolders && foldersEnabled) {
slivers.add( slivers.add(
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)), const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
); );