Files
sure/mobile/lib/providers/auth_provider.dart
Lazy Bone 38938fe971 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>
2026-01-31 13:25:52 +01:00

274 lines
7.6 KiB
Dart

import 'package:flutter/foundation.dart';
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 {
final AuthService _authService = AuthService();
final DeviceService _deviceService = DeviceService();
User? _user;
AuthTokens? _tokens;
String? _apiKey;
bool _isApiKeyAuth = false;
bool _isLoading = true;
bool _isInitializing = true; // Track initial auth check separately
String? _errorMessage;
bool _mfaRequired = false;
bool _showMfaInput = false; // Track if we should show MFA input field
User? get user => _user;
AuthTokens? get tokens => _tokens;
bool get isLoading => _isLoading;
bool get isInitializing => _isInitializing; // Expose initialization state
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
AuthProvider() {
_loadStoredAuth();
}
Future<void> _loadStoredAuth() async {
_isLoading = true;
_isInitializing = true;
notifyListeners();
try {
final authMode = await _authService.getStoredAuthMode();
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;
_isInitializing = false;
notifyListeners();
}
Future<bool> login({
required String email,
required String password,
String? otpCode,
}) async {
_errorMessage = null;
_mfaRequired = false;
_isLoading = true;
// Don't reset _showMfaInput if we're submitting OTP code
if (otpCode == null) {
_showMfaInput = false;
}
notifyListeners();
try {
final deviceInfo = await _deviceService.getDeviceInfo();
final result = await _authService.login(
email: email,
password: password,
deviceInfo: deviceInfo,
otpCode: otpCode,
);
LogService.instance.debug('AuthProvider', 'Login result: $result');
if (result['success'] == true) {
_tokens = result['tokens'] as AuthTokens?;
_user = result['user'] as User?;
_mfaRequired = false;
_showMfaInput = false; // Reset on successful login
_isLoading = false;
notifyListeners();
return true;
} else {
if (result['mfa_required'] == true) {
_mfaRequired = true;
_showMfaInput = true; // Show MFA input field
LogService.instance.debug('AuthProvider', 'MFA required! Setting _showMfaInput to true');
// If user already submitted an OTP code, this is likely an invalid OTP error
// Show the error message so user knows the code was wrong
if (otpCode != null && otpCode.isNotEmpty) {
// Backend returns "Two-factor authentication required" for both cases
// Replace with clearer message when OTP was actually submitted
_errorMessage = 'Invalid authentication code. Please try again.';
} else {
// First time requesting MFA - don't show error message, it's a normal flow
_errorMessage = null;
}
} else {
_errorMessage = result['error'] as String?;
// If user submitted an OTP (is in MFA flow) but got error, keep MFA input visible
if (otpCode != null) {
_showMfaInput = true;
}
}
_isLoading = false;
notifyListeners();
return false;
}
} catch (e) {
_errorMessage = 'Connection error: ${e.toString()}';
_isLoading = false;
notifyListeners();
return false;
}
}
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,
required String firstName,
required String lastName,
String? inviteCode,
}) async {
_errorMessage = null;
_isLoading = true;
notifyListeners();
try {
final deviceInfo = await _deviceService.getDeviceInfo();
final result = await _authService.signup(
email: email,
password: password,
firstName: firstName,
lastName: lastName,
deviceInfo: deviceInfo,
inviteCode: inviteCode,
);
if (result['success'] == true) {
_tokens = result['tokens'] as AuthTokens?;
_user = result['user'] as User?;
_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<void> logout() async {
await _authService.logout();
_tokens = null;
_user = null;
_apiKey = null;
_isApiKeyAuth = false;
_errorMessage = null;
_mfaRequired = false;
ApiConfig.clearApiKeyAuth();
notifyListeners();
}
Future<bool> _refreshToken() async {
if (_tokens == null) return false;
try {
final deviceInfo = await _deviceService.getDeviceInfo();
final result = await _authService.refreshToken(
refreshToken: _tokens!.refreshToken,
deviceInfo: deviceInfo,
);
if (result['success'] == true) {
_tokens = result['tokens'] as AuthTokens?;
return true;
} else {
// Token refresh failed, clear auth state
await logout();
return false;
}
} catch (e) {
await logout();
return false;
}
}
Future<String?> getValidAccessToken() async {
if (_isApiKeyAuth && _apiKey != null) {
return _apiKey;
}
if (_tokens == null) return null;
if (_tokens!.isExpired) {
final refreshed = await _refreshToken();
if (!refreshed) return null;
}
return _tokens?.accessToken;
}
void clearError() {
_errorMessage = null;
notifyListeners();
}
}