mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 14:31:25 +00:00
Add API key authentication support to mobile app (#850)
* feat: Add API key login option to mobile app Add a "Via API Key Login" button on the login screen that opens a dialog for entering an API key. The API key is validated by making a test request to /api/v1/accounts with the X-Api-Key header, and on success is persisted in secure storage. All HTTP services now use a centralized ApiConfig.getAuthHeaders() helper that returns the correct auth header (X-Api-Key or Bearer) based on the current auth mode. https://claude.ai/code/session_01DnyCzdMjVpSsbBZK3XbzUH * fix: Improve API key dialog context handling and controller disposal - Use outer context for SnackBar so it displays on the main screen instead of behind the dialog - Explicitly dispose TextEditingController to prevent memory leaks - Close dialog on failure before showing error SnackBar for better UX - Avoid StatefulBuilder context parameter shadowing https://claude.ai/code/session_01DnyCzdMjVpSsbBZK3XbzUH * fix: Use user-friendly error message in API key login catch block Log the technical exception details via LogService.instance.error and show a generic "Unable to connect" message to the user instead of exposing the raw exception string. https://claude.ai/code/session_01DnyCzdMjVpSsbBZK3XbzUH --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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<bool> 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<bool> 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<String?> getValidAccessToken() async {
|
||||
if (_isApiKeyAuth && _apiKey != null) {
|
||||
return _apiKey;
|
||||
}
|
||||
|
||||
if (_tokens == null) return null;
|
||||
|
||||
if (_tokens!.isExpired) {
|
||||
|
||||
@@ -27,6 +27,103 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
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<AuthProvider>(
|
||||
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<void> _handleLogin() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
@@ -263,7 +360,16 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
},
|
||||
),
|
||||
|
||||
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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<String, String> 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<bool> initialize() async {
|
||||
|
||||
@@ -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<Map<String, dynamic>> login({
|
||||
required String email,
|
||||
@@ -286,9 +288,64 @@ class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> 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<void> logout() async {
|
||||
await _storage.delete(key: _tokenKey);
|
||||
await _storage.delete(key: _userKey);
|
||||
await _storage.delete(key: _apiKeyKey);
|
||||
await _storage.delete(key: _authModeKey);
|
||||
}
|
||||
|
||||
Future<AuthTokens?> getStoredTokens() async {
|
||||
@@ -331,4 +388,17 @@ class AuthService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveApiKey(String apiKey) async {
|
||||
await _storage.write(key: _apiKeyKey, value: apiKey);
|
||||
await _storage.write(key: _authModeKey, value: 'api_key');
|
||||
}
|
||||
|
||||
Future<String?> getStoredApiKey() async {
|
||||
return await _storage.read(key: _apiKeyKey);
|
||||
}
|
||||
|
||||
Future<String?> getStoredAuthMode() async {
|
||||
return await _storage.read(key: _authModeKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user