Add account UI and secure session storage

This commit is contained in:
Hamza Ayed
2026-10-03 00:54:16 +03:00
parent b0cd825838
commit f745bed0f7
14 changed files with 456 additions and 13 deletions
@@ -0,0 +1,27 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
abstract interface class SessionTokenStore {
Future<String?> read();
Future<void> write(String token);
Future<void> delete();
}
class SecureSessionTokenStore implements SessionTokenStore {
SecureSessionTokenStore({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
static const _key = 'sovereignai.account.access_token';
final FlutterSecureStorage _storage;
@override
Future<String?> read() => _storage.read(key: _key);
@override
Future<void> write(String token) => _storage.write(key: _key, value: token);
@override
Future<void> delete() => _storage.delete(key: _key);
}
@@ -6,6 +6,7 @@ import 'package:http_parser/http_parser.dart';
import 'package:file_selector/file_selector.dart';
import 'package:uuid/uuid.dart';
import '../auth/session_token_store.dart';
import '../../features/chat/presentation/cubit/chat_state.dart';
const _uuid = Uuid();
@@ -18,13 +19,25 @@ class AgentRunResult {
}
class ApiRepository {
ApiRepository({required String baseUrl}) : _baseUrl = baseUrl;
ApiRepository({required String baseUrl, SessionTokenStore? sessionTokenStore})
: _baseUrl = baseUrl,
_sessionTokenStore = sessionTokenStore ?? SecureSessionTokenStore();
String _baseUrl;
final SessionTokenStore _sessionTokenStore;
String? _accessToken;
Future<String>? _loadingLocalSession;
Future<void>? _restoringSession;
Map<String, dynamic>? _currentUser;
bool _sessionRestored = false;
http.Client? _activeRequestClient;
String get baseUrl => _baseUrl;
Map<String, dynamic>? get currentUser => _currentUser;
String? get accountEmail {
final user = _currentUser;
if (user == null || user['mode'] != 'account') return null;
return user['email'] as String?;
}
void cancelActiveRequest() {
_activeRequestClient?.close();
@@ -37,6 +50,44 @@ class ApiRepository {
_baseUrl = value.replaceFirst(RegExp(r'/+$'), '');
_accessToken = null;
_loadingLocalSession = null;
_currentUser = null;
_sessionRestored = false;
_restoringSession = null;
}
Future<void> initializeSession() async {
if (_sessionRestored) return;
final pending = _restoringSession;
if (pending != null) return pending;
final restoring = _restoreAccountSession();
_restoringSession = restoring;
try {
await restoring;
} finally {
_restoringSession = null;
}
}
Future<void> _restoreAccountSession() async {
try {
final token = await _sessionTokenStore.read();
if (token == null || token.isEmpty) return;
final response = await http.get(
Uri.parse('$_baseUrl/v1/auth/me'),
headers: {'Authorization': 'Bearer $token'},
);
if (response.statusCode < 200 || response.statusCode >= 300) {
await _sessionTokenStore.delete();
return;
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
_accessToken = token;
_currentUser = data['user'] as Map<String, dynamic>?;
} catch (_) {
// Local single-user mode remains available if secure storage is absent.
} finally {
_sessionRestored = true;
}
}
Future<String> getModelName() async {
@@ -64,6 +115,7 @@ class ApiRepository {
}
Future<String> _localSessionToken() async {
await initializeSession();
final cached = _accessToken;
if (cached != null) return cached;
final existing = _loadingLocalSession;
@@ -86,6 +138,7 @@ class ApiRepository {
_checkStatus(response);
final data = jsonDecode(response.body) as Map<String, dynamic>;
_accessToken = data['access_token'] as String;
_currentUser = data['user'] as Map<String, dynamic>?;
return _accessToken!;
}
@@ -116,20 +169,56 @@ class ApiRepository {
);
_checkStatus(response);
final data = jsonDecode(response.body) as Map<String, dynamic>;
_accessToken = data['access_token'] as String;
final token = data['access_token'] as String;
try {
await _sessionTokenStore.write(token);
} catch (error) {
try {
await http.post(
Uri.parse('$_baseUrl/v1/auth/logout'),
headers: {'Authorization': 'Bearer $token'},
);
} catch (_) {
// The short-lived server session expires automatically if revocation fails.
}
throw Exception(
'تعذر حفظ جلسة الحساب في التخزين الآمن على هذا الجهاز: $error',
);
}
_accessToken = token;
_currentUser = data['user'] as Map<String, dynamic>?;
_sessionRestored = true;
return data;
}
Future<void> logout() async {
await initializeSession();
final token = _accessToken;
if (token == null) return;
final response = await http.post(
Uri.parse('$_baseUrl/v1/auth/logout'),
headers: {'Authorization': 'Bearer $token'},
);
_checkStatus(response);
_accessToken = null;
_loadingLocalSession = null;
Object? logoutError;
try {
final response = await http.post(
Uri.parse('$_baseUrl/v1/auth/logout'),
headers: {'Authorization': 'Bearer $token'},
);
_checkStatus(response);
} catch (error) {
logoutError = error;
} finally {
try {
await _sessionTokenStore.delete();
} finally {
_accessToken = null;
_currentUser = null;
_loadingLocalSession = null;
_sessionRestored = true;
}
}
if (logoutError != null) {
throw Exception(
'أُزيل الرمز من الجهاز، لكن تعذر تأكيد إلغاء جلسة الخادم: $logoutError',
);
}
}
void _checkStatus(http.Response response) {