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',
|
'/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',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user