Add account UI and secure session storage
This commit is contained in:
@@ -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: 'إعداد الاتصال',
|
||||
|
||||
Reference in New Issue
Block a user