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
This commit is contained in:
Claude
2026-01-31 09:32:11 +00:00
parent 4adc4199ee
commit 0b218059d3
7 changed files with 281 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,39 @@ 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) {
_errorMessage = 'Connection error: ${e.toString()}';
_isLoading = false;
notifyListeners();
return false;
}
}
Future<bool> signup({
required String email,
required String password,
@@ -167,8 +218,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 +251,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,100 @@ class _LoginScreenState extends State<LoginScreen> {
super.dispose();
}
void _showApiKeyDialog() {
final apiKeyController = TextEditingController();
bool isLoading = false;
showDialog(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, 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(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).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 : () => 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>(
context,
listen: false,
);
final success = await authProvider.loginWithApiKey(
apiKey: apiKey,
);
if (!dialogContext.mounted) return;
if (success) {
Navigator.of(dialogContext).pop();
} else {
setDialogState(() {
isLoading = false;
});
if (dialogContext.mounted) {
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(
content: Text(
authProvider.errorMessage ?? 'Invalid API key',
),
backgroundColor:
Theme.of(dialogContext).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 +357,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));