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) {
@@ -423,6 +423,28 @@ class ChatCubit extends Cubit<ChatState> {
}
}
Future<void> reloadForAccountChange() async {
if (state.isSending) return;
emit(
state.copyWith(
messages: const [],
conversations: const [],
clearActiveConversation: true,
isLoadingHistory: true,
isWorkspaceMode: false,
clearSelectedWorkspacePath: true,
availableWorkspaceFiles: const [],
selectedWorkspaceFiles: const [],
clearSelectedSkill: true,
requestStatus: RequestStatus.idle,
clearAgentProgress: true,
clearPendingFileChangeProposal: true,
clearError: true,
),
);
await Future.wait([_loadHistory(), _loadAgentSkills()]);
}
Future<void> _saveCurrent(List<ChatMessage> messages) async {
if (messages.isEmpty) return;
final firstQuestion = messages.firstWhere(
@@ -11,6 +11,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../../../audio/presentation/cubit/voice_cubit.dart';
import '../../../audio/presentation/cubit/voice_state.dart';
import '../../../../core/notifications/completion_notification_service.dart';
import '../../../../core/network/api_repository.dart';
import '../cubit/chat_cubit.dart';
import '../cubit/chat_state.dart';
@@ -423,6 +424,232 @@ class _ChatPageState extends State<ChatPage> {
controller.dispose();
}
Future<void> _manageAccount() async {
final cubit = context.read<ChatCubit>();
final api = context.read<ApiRepository>();
if (cubit.state.isSending) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('انتظر حتى ينتهي الطلب قبل تبديل الحساب.'),
),
);
return;
}
if (api.accountEmail != null) {
final confirmed = await showDialog<bool>(
context: context,
builder:
(context) => Directionality(
textDirection: TextDirection.rtl,
child: AlertDialog(
title: const Text('تسجيل الخروج'),
content: Text('هل تريد تسجيل الخروج من ${api.accountEmail}؟'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('إلغاء'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('تسجيل الخروج'),
),
],
),
),
);
if (confirmed != true) return;
try {
await api.logout();
await cubit.reloadForAccountChange();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('تم تسجيل الخروج والعودة إلى الحساب المحلي.'),
),
);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(error.toString().replaceFirst('Exception: ', '')),
),
);
}
return;
}
final emailController = TextEditingController();
final passwordController = TextEditingController();
var register = false;
var validationError = '';
final credentials = await showDialog<(bool, String, String)>(
context: context,
builder:
(dialogContext) => StatefulBuilder(
builder:
(context, setDialogState) => Directionality(
textDirection: TextDirection.rtl,
child: AlertDialog(
title: Text(register ? 'إنشاء حساب' : 'تسجيل الدخول'),
content: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: emailController,
autofocus: true,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(
labelText: 'البريد الإلكتروني',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: passwordController,
obscureText: true,
autofillHints: [
register
? AutofillHints.newPassword
: AutofillHints.password,
],
onSubmitted: (_) {
final email = emailController.text.trim();
final password = passwordController.text;
if (email.isEmpty || password.isEmpty) {
setDialogState(
() =>
validationError =
'أدخل البريد وكلمة المرور.',
);
} else if (register && password.length < 12) {
setDialogState(
() =>
validationError =
'كلمة المرور يجب أن تكون 12 محرفًا على الأقل.',
);
} else {
Navigator.pop(dialogContext, (
register,
email,
password,
));
}
},
decoration: const InputDecoration(
labelText: 'كلمة المرور',
border: OutlineInputBorder(),
),
),
if (register) ...[
const SizedBox(height: 8),
const Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
'استخدم 12 محرفًا على الأقل.',
style: TextStyle(fontSize: 12),
),
),
],
if (validationError.isNotEmpty) ...[
const SizedBox(height: 8),
Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
validationError,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
),
],
TextButton(
onPressed:
() => setDialogState(() {
register = !register;
validationError = '';
}),
child: Text(
register
? 'لديك حساب؟ سجّل الدخول'
: 'ليس لديك حساب؟ أنشئ حسابًا',
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('إلغاء'),
),
FilledButton(
onPressed: () {
final email = emailController.text.trim();
final password = passwordController.text;
if (email.isEmpty || password.isEmpty) {
setDialogState(
() =>
validationError = 'أدخل البريد وكلمة المرور.',
);
} else if (register && password.length < 12) {
setDialogState(
() =>
validationError =
'كلمة المرور يجب أن تكون 12 محرفًا على الأقل.',
);
} else {
Navigator.pop(dialogContext, (
register,
email,
password,
));
}
},
child: Text(register ? 'إنشاء الحساب' : 'دخول'),
),
],
),
),
),
);
emailController.dispose();
passwordController.dispose();
if (credentials == null) return;
try {
if (credentials.$1) {
await api.registerAccount(
email: credentials.$2,
password: credentials.$3,
);
} else {
await api.login(email: credentials.$2, password: credentials.$3);
}
await cubit.reloadForAccountChange();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
credentials.$1
? 'تم إنشاء الحساب وتخزين الجلسة بأمان.'
: 'تم تسجيل الدخول وتخزين الجلسة بأمان.',
),
),
);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(error.toString().replaceFirst('Exception: ', '')),
),
);
}
}
Future<void> _toggleRecording(VoiceState voice) async {
final cubit = context.read<VoiceCubit>();
if (voice.status == VoiceStatus.recording) {
@@ -1081,6 +1308,18 @@ class _ChatPageState extends State<ChatPage> {
),
),
SizedBox(width: compact ? 2 : 8),
IconButton(
onPressed: _manageAccount,
tooltip:
context.read<ApiRepository>().accountEmail == null
? 'تسجيل الدخول أو إنشاء حساب'
: 'الحساب: ${context.read<ApiRepository>().accountEmail} · تسجيل الخروج',
color:
context.read<ApiRepository>().accountEmail == null
? _ink
: _green,
icon: const Icon(Icons.account_circle_outlined),
),
IconButton(
onPressed: _settings,
tooltip: 'إعداد الاتصال',