feat(folders): Preserve feature state on server-side errors
This commit is contained in:
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user