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:
Lazy Bone
2026-01-31 20:25:52 +08:00
committed by GitHub
parent 4f60aef6a4
commit 38938fe971
7 changed files with 285 additions and 39 deletions

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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);
}
}

View File

@@ -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) {

View File

@@ -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));