The AuthInterceptor was calling signOut() on any 401 error, which caused the theme toggle to trigger logout when the preferences API returned 401. Now 401 errors propagate to calling code for graceful handling. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
174 lines
5.6 KiB
Dart
174 lines
5.6 KiB
Dart
import 'dart:developer' as developer;
|
|
|
|
import 'package:dio/dio.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:tatlock_ui/core/auth/auth_provider.dart';
|
|
import 'package:tatlock_ui/core/config/app_config.dart';
|
|
import 'package:tatlock_ui/core/error/app_exception.dart';
|
|
|
|
/// Adds authentication token to requests.
|
|
///
|
|
/// - **LAN mode**: Skipped entirely (no auth required)
|
|
/// - **Web + Mobile**: Adds Bearer token from OIDC authentication
|
|
class AuthInterceptor extends Interceptor {
|
|
AuthInterceptor(this._ref);
|
|
|
|
final Ref _ref;
|
|
|
|
@override
|
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
|
// Skip auth for LAN development
|
|
if (!AppConfig.requiresAuth) {
|
|
handler.next(options);
|
|
return;
|
|
}
|
|
|
|
// Add Bearer token for all platforms (web + mobile)
|
|
final authState = _ref.read(authProvider);
|
|
|
|
authState.whenData((auth) {
|
|
if (auth.isAuthenticated && auth.accessToken != null) {
|
|
options.headers['Authorization'] = 'Bearer ${auth.accessToken}';
|
|
}
|
|
});
|
|
|
|
handler.next(options);
|
|
}
|
|
|
|
@override
|
|
void onError(DioException err, ErrorInterceptorHandler handler) {
|
|
// Don't auto-signout on 401 - let calling code handle auth errors gracefully.
|
|
// Auto-signout was causing issues (e.g., theme toggle triggering logout when
|
|
// preferences API returned 401).
|
|
handler.next(err);
|
|
}
|
|
}
|
|
|
|
/// Logs API requests and responses to the console.
|
|
///
|
|
/// All requests are logged with method, URL, query params, and body.
|
|
/// Responses include status code. Errors include full details.
|
|
class LoggingInterceptor extends Interceptor {
|
|
@override
|
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
|
final buffer = StringBuffer()
|
|
..writeln('┌── API Request ──────────────────────────────────────')
|
|
..writeln('│ ${options.method} ${options.path}');
|
|
|
|
if (options.queryParameters.isNotEmpty) {
|
|
buffer.writeln('│ Query: ${options.queryParameters}');
|
|
}
|
|
|
|
if (options.data != null) {
|
|
buffer.writeln('│ Body: ${options.data}');
|
|
}
|
|
|
|
buffer.writeln('└─────────────────────────────────────────────────────');
|
|
|
|
final message = buffer.toString();
|
|
developer.log(message, name: 'API');
|
|
debugPrint(message);
|
|
handler.next(options);
|
|
}
|
|
|
|
@override
|
|
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
|
final message =
|
|
'✓ ${response.statusCode} ${response.requestOptions.method} ${response.requestOptions.path}';
|
|
developer.log(message, name: 'API');
|
|
debugPrint(message);
|
|
handler.next(response);
|
|
}
|
|
|
|
@override
|
|
void onError(DioException err, ErrorInterceptorHandler handler) {
|
|
final buffer = StringBuffer()
|
|
..writeln('┌── API Error ────────────────────────────────────────')
|
|
..writeln('│ ${err.requestOptions.method} ${err.requestOptions.path}')
|
|
..writeln('│ Status: ${err.response?.statusCode ?? 'NETWORK ERROR'}')
|
|
..writeln('│ Message: ${err.message}');
|
|
|
|
if (err.response?.data != null) {
|
|
buffer.writeln('│ Response: ${err.response?.data}');
|
|
}
|
|
|
|
buffer.writeln('└─────────────────────────────────────────────────────');
|
|
|
|
final message = buffer.toString();
|
|
developer.log(message, name: 'API', error: err);
|
|
debugPrint(message);
|
|
handler.next(err);
|
|
}
|
|
}
|
|
|
|
/// Converts Dio errors to AppException types.
|
|
class ErrorInterceptor extends Interceptor {
|
|
@override
|
|
void onError(DioException err, ErrorInterceptorHandler handler) {
|
|
final exception = _mapToAppException(err);
|
|
handler.reject(
|
|
DioException(
|
|
requestOptions: err.requestOptions,
|
|
response: err.response,
|
|
type: err.type,
|
|
error: exception,
|
|
),
|
|
);
|
|
}
|
|
|
|
AppException _mapToAppException(DioException err) {
|
|
switch (err.type) {
|
|
case DioExceptionType.connectionTimeout:
|
|
case DioExceptionType.sendTimeout:
|
|
case DioExceptionType.receiveTimeout:
|
|
return NetworkException(
|
|
message: 'Connection timed out',
|
|
cause: err,
|
|
);
|
|
|
|
case DioExceptionType.connectionError:
|
|
return NetworkException(
|
|
message: 'Unable to connect to server',
|
|
cause: err,
|
|
);
|
|
|
|
case DioExceptionType.badResponse:
|
|
final statusCode = err.response?.statusCode ?? 0;
|
|
final data = err.response?.data;
|
|
|
|
String message = 'Request failed';
|
|
String? code;
|
|
|
|
if (data is Map<String, dynamic>) {
|
|
final error = data['error'];
|
|
if (error is Map<String, dynamic>) {
|
|
message = error['message'] as String? ?? message;
|
|
code = error['code'] as String?;
|
|
} else if (error is String) {
|
|
message = error;
|
|
}
|
|
}
|
|
|
|
return ApiException(
|
|
message: message,
|
|
statusCode: statusCode,
|
|
code: code,
|
|
cause: err,
|
|
);
|
|
|
|
case DioExceptionType.cancel:
|
|
return const NetworkException(message: 'Request cancelled');
|
|
|
|
case DioExceptionType.badCertificate:
|
|
return const NetworkException(message: 'Invalid SSL certificate');
|
|
|
|
case DioExceptionType.unknown:
|
|
return NetworkException(
|
|
message: err.message ?? 'Unknown error',
|
|
cause: err,
|
|
);
|
|
}
|
|
}
|
|
}
|