import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../config/api_config.dart'; import '../models/auth_user.dart' as model; enum AuthChangeEvent { signedIn, signedOut, tokenRefreshed } class AuthState { final AuthChangeEvent event; final Session? session; const AuthState(this.event, this.session); } class Session { final String accessToken; final String tokenType; final User user; const Session({ required this.accessToken, required this.tokenType, required this.user, }); } class User { final String id; final String? email; final Map userMetadata; final String? role; final DateTime? createdAt; final DateTime? updatedAt; const User({ required this.id, this.email, this.userMetadata = const {}, this.role, this.createdAt, this.updatedAt, }); } class AuthException implements Exception { final String message; AuthException(this.message); @override String toString() => message; } /// Authentication service for sojorn /// Handles sign up, sign in, sign out, and auth state class AuthService { // Singleton pattern for easy access static AuthService? _instance; static AuthService get instance => _instance ??= AuthService._internal(); factory AuthService() { _instance ??= AuthService._internal(); return _instance!; } final _storage = const FlutterSecureStorage(); String? _accessToken; String? _temporaryToken; model.AuthUser? _localUser; bool _initialized = false; final _authEventController = StreamController.broadcast(); AuthService._internal() { _init(); } Future _init() async { if (_initialized) return; _accessToken = await _storage.read(key: 'access_token'); final refreshToken = await _storage.read(key: 'refresh_token'); // Also load legacy/temporary token just in case _temporaryToken = await _storage.read(key: 'go_auth_token'); final userJson = await _storage.read(key: 'go_auth_user'); if (userJson != null) { try { _localUser = model.AuthUser.fromJson(jsonDecode(userJson)); } catch (_) {} } if (_accessToken != null && refreshToken != null) { // Optimistic check: decode JWT to see if expired. if (_isTokenExpired(_accessToken!)) { print('[AuthService] Token expired at init, attempting refresh...'); await refreshSession(); } } else if (refreshToken != null) { // Have refresh but no access? Try refresh await refreshSession(); } _initialized = true; if (isAuthenticated) { _notifyGoAuthChange(); } } bool _isTokenExpired(String token) { try { final parts = token.split('.'); if (parts.length != 3) { return true; } final payload = json.decode(utf8.decode(base64Url.decode(base64.normalize(parts[1])))); if (payload is Map && payload.containsKey('exp')) { final exp = DateTime.fromMillisecondsSinceEpoch(payload['exp'] * 1000); return DateTime.now().isAfter(exp); } } catch (e) { // If we can't parse it, assume it's invalid return true; } return false; // Default to assumed valid if no exp } void _notifyGoAuthChange() { // Create a synthetic AuthState for the Go token // We treat this as a signedIn event for the app final event = AuthState( AuthChangeEvent.signedIn, Session( accessToken: _accessToken ?? '', tokenType: 'bearer', user: currentUser!, ), ); _authEventController.add(event); } /// Ensure service is initialized before use Future ensureInitialized() async { if (!_initialized) await _init(); } /// Refresh Logic (The Engine) Future refreshSession() async { final refreshToken = await _storage.read(key: 'refresh_token'); if (refreshToken == null) return false; try { print('[AuthService] Refreshing session...'); final response = await http.post( Uri.parse('${ApiConfig.baseUrl}/auth/refresh'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({'refresh_token': refreshToken}), ); if (response.statusCode == 200) { final data = jsonDecode(response.body); // data should contain access_token and refresh_token // Or if using the structure returned by handler: { "access_token": "...", "refresh_token": "..." } await _saveTokens(data['access_token'], data['refresh_token']); print('[AuthService] Session refreshed successfully'); return true; } else { print('[AuthService] Refresh failed: ${response.statusCode}'); await signOut(); // Refresh failed (revoked/expired), force logout return false; } } catch (e) { print('[AuthService] Refresh error: $e'); return false; } } Future _saveTokens(String access, String refresh) async { _accessToken = access; await _storage.write(key: 'access_token', value: access); await _storage.write(key: 'refresh_token', value: refresh); // Legacy support await _storage.write(key: 'go_auth_token', value: access); _temporaryToken = access; } /// Get current user (Wraps locaUser as Supabase User if needed) User? get currentUser { if (_localUser != null) { return User( id: _localUser!.id, email: _localUser!.email, createdAt: _localUser!.createdAt, updatedAt: _localUser!.updatedAt, ); } return null; } /// Get current session Session? get currentSession => _accessToken != null && currentUser != null ? Session( accessToken: _accessToken!, tokenType: 'bearer', user: currentUser!, ) : null; /// Check if user is authenticated bool get isAuthenticated => accessToken != null; /// Get auth state stream Stream get authStateChanges => _authEventController.stream; /// Sign up with email and password @Deprecated('Use registerWithGoBackend') Future signUpWithEmail({ required String email, required String password, }) async { // No-op } /// Sign in with Go Backend (Migration) Future> signInWithGoBackend({ required String email, required String password, }) async { try { final uri = Uri.parse('${ApiConfig.baseUrl}/auth/login'); print('[AuthService] POST $uri'); final response = await http.post( uri, headers: {'Content-Type': 'application/json'}, body: jsonEncode({'email': email, 'password': password}), ); print('[AuthService] Response: ${response.statusCode}'); final data = jsonDecode(response.body); if (response.statusCode == 200) { final accessToken = data['token'] ?? data['access_token']; final refreshToken = data['refresh_token']; if (accessToken == null || refreshToken == null) { print('[AuthService] Login response missing tokens: $data'); throw AuthException('Invalid response from server: missing tokens'); } await _saveTokens(accessToken as String, refreshToken as String); if (data['user'] != null) { final userJson = data['user']; try { _localUser = model.AuthUser.fromJson(userJson); await _storage.write(key: 'go_auth_user', value: jsonEncode(userJson)); } catch (e) { print('[AuthService] Failed to parse user data: $e. Data: $userJson'); } } // Handle profile data specifically for onboarding check if (data['profile'] != null) { final profileJson = data['profile']; // Ideally store this in a ProfileService or emit it // For now, we can rely on the app asking ApiService for profile await _storage.write(key: 'go_auth_profile_onboarding', value: profileJson['has_completed_onboarding'].toString()); } _notifyGoAuthChange(); return data; } else { throw AuthException( 'Login failed: ${response.statusCode} - ${response.body}', ); } } catch (e) { print('[AuthService] Sign-in exception: $e'); if (e is AuthException) rethrow; throw AuthException('Connection failed: $e'); } } /// Register with Go Backend (Migration) Future> registerWithGoBackend({ required String email, required String password, required String handle, required String displayName, }) async { try { final uri = Uri.parse('${ApiConfig.baseUrl}/auth/register'); print('[AuthService] POST $uri'); final response = await http.post( uri, headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'email': email, 'password': password, 'handle': handle, 'display_name': displayName, }), ); print('[AuthService] Response: ${response.statusCode}'); final data = jsonDecode(response.body); if (response.statusCode == 201) { // Success: Registration created, verification required. // We do NOT have tokens here anymore. // The backend now returns: {"message": "Registration successful...", "user_id": "..."} return data; } else { throw AuthException( 'Registration failed: ${response.statusCode} - ${response.body}', ); } } catch (e) { print('[AuthService] Registration exception: $e'); if (e is AuthException) rethrow; throw AuthException('Connection failed: $e'); } } /// Sign out Future signOut() async { _temporaryToken = null; _accessToken = null; _localUser = null; // Clear auth tokens but preserve E2EE keys await _storage.delete(key: 'access_token'); await _storage.delete(key: 'refresh_token'); await _storage.delete(key: 'go_auth_token'); await _storage.delete(key: 'go_auth_user'); _authEventController.add(const AuthState(AuthChangeEvent.signedOut, null)); } /// Get current access token String? get accessToken => _accessToken ?? _temporaryToken ?? currentSession?.accessToken; /// Send password reset email Future resetPassword(String email) async { // Migrate to Go API } /// Update password Future updatePassword(String newPassword) async { // Migrate to Go API } Future markOnboardingCompleteLocally() async { await _storage.write(key: 'go_auth_profile_onboarding', value: 'true'); } Future isOnboardingComplete() async { final val = await _storage.read(key: 'go_auth_profile_onboarding'); return val == 'true'; } }