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',
};
// 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({
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',
);

View File

@@ -1285,7 +1285,10 @@ class Conversations extends _$Conversations {
error: error,
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>([
@@ -1293,7 +1296,14 @@ class Conversations extends _$Conversations {
foldersFuture,
]);
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(
'fetch-ok',
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
@Riverpod(keepAlive: true)
class Folders extends _$Folders {
@@ -2224,14 +2250,20 @@ class Folders extends _$Folders {
Future<List<Folder>> _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);

View File

@@ -1123,7 +1123,9 @@ class ApiService {
}
// 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 {
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<Map<String, dynamic>>();
return (data.cast<Map<String, dynamic>>(), true);
} else {
DebugLogger.warning(
'unexpected-type',
scope: 'api/folders',
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) {
DebugLogger.error('fetch-failed', scope: 'api/folders', error: e);
rethrow;

View File

@@ -327,6 +327,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
final showPinned = ref.watch(_showPinnedProvider);
final showFolders = ref.watch(_showFoldersProvider);
final showRecent = ref.watch(_showRecentProvider);
final foldersEnabled = ref.watch(foldersFeatureEnabledProvider);
final slivers = <Widget>[
if (pinned.isNotEmpty) ...[
@@ -347,12 +348,14 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
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(
@@ -586,14 +589,18 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
);
}
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)),
);