diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 8e58589e0..bfc24670f 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -3,6 +3,7 @@ import '../models/user.dart'; import '../models/auth_tokens.dart'; import '../services/auth_service.dart'; import '../services/device_service.dart'; +import '../services/api_config.dart'; import '../services/log_service.dart'; class AuthProvider with ChangeNotifier { @@ -11,6 +12,8 @@ class AuthProvider with ChangeNotifier { User? _user; AuthTokens? _tokens; + String? _apiKey; + bool _isApiKeyAuth = false; bool _isLoading = true; bool _isInitializing = true; // Track initial auth check separately String? _errorMessage; @@ -21,7 +24,10 @@ class AuthProvider with ChangeNotifier { AuthTokens? get tokens => _tokens; bool get isLoading => _isLoading; bool get isInitializing => _isInitializing; // Expose initialization state - bool get isAuthenticated => _tokens != null && !_tokens!.isExpired; + bool get isApiKeyAuth => _isApiKeyAuth; + bool get isAuthenticated => + (_isApiKeyAuth && _apiKey != null) || + (_tokens != null && !_tokens!.isExpired); String? get errorMessage => _errorMessage; bool get mfaRequired => _mfaRequired; bool get showMfaInput => _showMfaInput; // Expose MFA input state @@ -36,16 +42,28 @@ class AuthProvider with ChangeNotifier { notifyListeners(); try { - _tokens = await _authService.getStoredTokens(); - _user = await _authService.getStoredUser(); + final authMode = await _authService.getStoredAuthMode(); - // If tokens exist but are expired, try to refresh - if (_tokens != null && _tokens!.isExpired) { - await _refreshToken(); + if (authMode == 'api_key') { + _apiKey = await _authService.getStoredApiKey(); + if (_apiKey != null) { + _isApiKeyAuth = true; + ApiConfig.setApiKeyAuth(_apiKey!); + } + } else { + _tokens = await _authService.getStoredTokens(); + _user = await _authService.getStoredUser(); + + // If tokens exist but are expired, try to refresh + if (_tokens != null && _tokens!.isExpired) { + await _refreshToken(); + } } } catch (e) { _tokens = null; _user = null; + _apiKey = null; + _isApiKeyAuth = false; } _isLoading = false; @@ -121,6 +139,40 @@ class AuthProvider with ChangeNotifier { } } + Future loginWithApiKey({ + required String apiKey, + }) async { + _errorMessage = null; + _isLoading = true; + notifyListeners(); + + try { + final result = await _authService.loginWithApiKey(apiKey: apiKey); + + LogService.instance.debug('AuthProvider', 'API key login result: $result'); + + if (result['success'] == true) { + _apiKey = apiKey; + _isApiKeyAuth = true; + ApiConfig.setApiKeyAuth(apiKey); + _isLoading = false; + notifyListeners(); + return true; + } else { + _errorMessage = result['error'] as String?; + _isLoading = false; + notifyListeners(); + return false; + } + } catch (e, stackTrace) { + LogService.instance.error('AuthProvider', 'API key login error: $e\n$stackTrace'); + _errorMessage = 'Unable to connect. Please check your network and try again.'; + _isLoading = false; + notifyListeners(); + return false; + } + } + Future signup({ required String email, required String password, @@ -167,8 +219,11 @@ class AuthProvider with ChangeNotifier { await _authService.logout(); _tokens = null; _user = null; + _apiKey = null; + _isApiKeyAuth = false; _errorMessage = null; _mfaRequired = false; + ApiConfig.clearApiKeyAuth(); notifyListeners(); } @@ -197,6 +252,10 @@ class AuthProvider with ChangeNotifier { } Future getValidAccessToken() async { + if (_isApiKeyAuth && _apiKey != null) { + return _apiKey; + } + if (_tokens == null) return null; if (_tokens!.isExpired) { diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart index c524bada7..e4c8fc6de 100644 --- a/mobile/lib/screens/login_screen.dart +++ b/mobile/lib/screens/login_screen.dart @@ -27,6 +27,103 @@ class _LoginScreenState extends State { super.dispose(); } + void _showApiKeyDialog() { + final apiKeyController = TextEditingController(); + final outerContext = context; + bool isLoading = false; + + showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (_, setDialogState) { + return AlertDialog( + title: const Text('API Key Login'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Enter your API key to sign in.', + style: Theme.of(outerContext).textTheme.bodyMedium?.copyWith( + color: Theme.of(outerContext).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + TextField( + controller: apiKeyController, + decoration: const InputDecoration( + labelText: 'API Key', + prefixIcon: Icon(Icons.vpn_key_outlined), + ), + obscureText: true, + maxLines: 1, + enabled: !isLoading, + ), + ], + ), + actions: [ + TextButton( + onPressed: isLoading + ? null + : () { + apiKeyController.dispose(); + Navigator.of(dialogContext).pop(); + }, + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: isLoading + ? null + : () async { + final apiKey = apiKeyController.text.trim(); + if (apiKey.isEmpty) return; + + setDialogState(() { + isLoading = true; + }); + + final authProvider = Provider.of( + outerContext, + listen: false, + ); + final success = await authProvider.loginWithApiKey( + apiKey: apiKey, + ); + + if (!dialogContext.mounted) return; + + final errorMsg = authProvider.errorMessage; + apiKeyController.dispose(); + Navigator.of(dialogContext).pop(); + + if (!success && mounted) { + ScaffoldMessenger.of(outerContext).showSnackBar( + SnackBar( + content: Text( + errorMsg ?? 'Invalid API key', + ), + backgroundColor: + Theme.of(outerContext).colorScheme.error, + ), + ); + } + }, + child: isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Sign In'), + ), + ], + ); + }, + ); + }, + ); + } + Future _handleLogin() async { if (!_formKey.currentState!.validate()) return; @@ -263,7 +360,16 @@ class _LoginScreenState extends State { }, ), - const SizedBox(height: 24), + const SizedBox(height: 12), + + // API Key Login Button + TextButton.icon( + onPressed: _showApiKeyDialog, + icon: const Icon(Icons.vpn_key_outlined, size: 18), + label: const Text('Via API Key Login'), + ), + + const SizedBox(height: 16), // Backend URL info Container( diff --git a/mobile/lib/services/accounts_service.dart b/mobile/lib/services/accounts_service.dart index 1f5af6e82..a98f31d28 100644 --- a/mobile/lib/services/accounts_service.dart +++ b/mobile/lib/services/accounts_service.dart @@ -16,10 +16,7 @@ class AccountsService { final response = await http.get( url, - headers: { - 'Authorization': 'Bearer $accessToken', - 'Accept': 'application/json', - }, + headers: ApiConfig.getAuthHeaders(accessToken), ).timeout(const Duration(seconds: 30)); if (response.statusCode == 200) { diff --git a/mobile/lib/services/api_config.dart b/mobile/lib/services/api_config.dart index 09e4db7f8..a51c50219 100644 --- a/mobile/lib/services/api_config.dart +++ b/mobile/lib/services/api_config.dart @@ -13,6 +13,38 @@ class ApiConfig { _baseUrl = url; } + // API key authentication mode + static bool _isApiKeyAuth = false; + static String? _apiKeyValue; + + static bool get isApiKeyAuth => _isApiKeyAuth; + + static void setApiKeyAuth(String apiKey) { + _isApiKeyAuth = true; + _apiKeyValue = apiKey; + } + + static void clearApiKeyAuth() { + _isApiKeyAuth = false; + _apiKeyValue = null; + } + + /// Returns the correct auth headers based on the current auth mode. + /// In API key mode, uses X-Api-Key header. + /// In token mode, uses Authorization: Bearer header. + static Map getAuthHeaders(String token) { + if (_isApiKeyAuth && _apiKeyValue != null) { + return { + 'X-Api-Key': _apiKeyValue!, + 'Accept': 'application/json', + }; + } + return { + 'Authorization': 'Bearer $token', + 'Accept': 'application/json', + }; + } + /// Initialize the API configuration by loading the backend URL from storage /// Returns true if a saved URL was loaded, false otherwise static Future initialize() async { diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index 789030abe..5c28ff28d 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -12,6 +12,8 @@ class AuthService { final FlutterSecureStorage _storage = const FlutterSecureStorage(); static const String _tokenKey = 'auth_tokens'; static const String _userKey = 'user_data'; + static const String _apiKeyKey = 'api_key'; + static const String _authModeKey = 'auth_mode'; Future> login({ required String email, @@ -286,9 +288,64 @@ class AuthService { } } + Future> loginWithApiKey({ + required String apiKey, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/accounts'); + + final response = await http.get( + url, + headers: { + 'X-Api-Key': apiKey, + 'Accept': 'application/json', + }, + ).timeout(const Duration(seconds: 30)); + + LogService.instance.debug('AuthService', 'API key login response status: ${response.statusCode}'); + + if (response.statusCode == 200) { + await _saveApiKey(apiKey); + return { + 'success': true, + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'Invalid API key', + }; + } else { + return { + 'success': false, + 'error': 'Login failed (status ${response.statusCode})', + }; + } + } on SocketException catch (e, stackTrace) { + LogService.instance.error('AuthService', 'API key login SocketException: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Network unavailable', + }; + } on TimeoutException catch (e, stackTrace) { + LogService.instance.error('AuthService', 'API key login TimeoutException: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Request timed out', + }; + } catch (e, stackTrace) { + LogService.instance.error('AuthService', 'API key login unexpected error: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'An unexpected error occurred', + }; + } + } + Future logout() async { await _storage.delete(key: _tokenKey); await _storage.delete(key: _userKey); + await _storage.delete(key: _apiKeyKey); + await _storage.delete(key: _authModeKey); } Future getStoredTokens() async { @@ -331,4 +388,17 @@ class AuthService { }), ); } + + Future _saveApiKey(String apiKey) async { + await _storage.write(key: _apiKeyKey, value: apiKey); + await _storage.write(key: _authModeKey, value: 'api_key'); + } + + Future getStoredApiKey() async { + return await _storage.read(key: _apiKeyKey); + } + + Future getStoredAuthMode() async { + return await _storage.read(key: _authModeKey); + } } diff --git a/mobile/lib/services/chat_service.dart b/mobile/lib/services/chat_service.dart index bb2366c15..080378c51 100644 --- a/mobile/lib/services/chat_service.dart +++ b/mobile/lib/services/chat_service.dart @@ -18,10 +18,7 @@ class ChatService { final response = await http.get( url, - headers: { - 'Authorization': 'Bearer $accessToken', - 'Accept': 'application/json', - }, + headers: ApiConfig.getAuthHeaders(accessToken), ).timeout(const Duration(seconds: 30)); if (response.statusCode == 200) { @@ -78,10 +75,7 @@ class ChatService { final response = await http.get( url, - headers: { - 'Authorization': 'Bearer $accessToken', - 'Accept': 'application/json', - }, + headers: ApiConfig.getAuthHeaders(accessToken), ).timeout(const Duration(seconds: 30)); if (response.statusCode == 200) { @@ -144,8 +138,7 @@ class ChatService { final response = await http.post( url, headers: { - 'Authorization': 'Bearer $accessToken', - 'Accept': 'application/json', + ...ApiConfig.getAuthHeaders(accessToken), 'Content-Type': 'application/json', }, body: jsonEncode(body), @@ -199,8 +192,7 @@ class ChatService { final response = await http.post( url, headers: { - 'Authorization': 'Bearer $accessToken', - 'Accept': 'application/json', + ...ApiConfig.getAuthHeaders(accessToken), 'Content-Type': 'application/json', }, body: jsonEncode({ @@ -255,8 +247,7 @@ class ChatService { final response = await http.patch( url, headers: { - 'Authorization': 'Bearer $accessToken', - 'Accept': 'application/json', + ...ApiConfig.getAuthHeaders(accessToken), 'Content-Type': 'application/json', }, body: jsonEncode({ @@ -309,10 +300,7 @@ class ChatService { final response = await http.delete( url, - headers: { - 'Authorization': 'Bearer $accessToken', - 'Accept': 'application/json', - }, + headers: ApiConfig.getAuthHeaders(accessToken), ).timeout(const Duration(seconds: 30)); if (response.statusCode == 204) { @@ -356,10 +344,7 @@ class ChatService { final response = await http.post( url, - headers: { - 'Authorization': 'Bearer $accessToken', - 'Accept': 'application/json', - }, + headers: ApiConfig.getAuthHeaders(accessToken), ).timeout(const Duration(seconds: 30)); if (response.statusCode == 202) { diff --git a/mobile/lib/services/transactions_service.dart b/mobile/lib/services/transactions_service.dart index e22c6d964..c36d86ac2 100644 --- a/mobile/lib/services/transactions_service.dart +++ b/mobile/lib/services/transactions_service.dart @@ -32,9 +32,8 @@ class TransactionsService { final response = await http.post( url, headers: { + ...ApiConfig.getAuthHeaders(accessToken), 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Authorization': 'Bearer $accessToken', }, body: jsonEncode(body), ).timeout(const Duration(seconds: 30)); @@ -99,9 +98,8 @@ class TransactionsService { final response = await http.get( url, headers: { + ...ApiConfig.getAuthHeaders(accessToken), 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Authorization': 'Bearer $accessToken', }, ).timeout(const Duration(seconds: 30)); @@ -162,9 +160,8 @@ class TransactionsService { final response = await http.delete( url, headers: { + ...ApiConfig.getAuthHeaders(accessToken), 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Authorization': 'Bearer $accessToken', }, ).timeout(const Duration(seconds: 30));