diff --git a/it0_app/lib/core/router/app_router.dart b/it0_app/lib/core/router/app_router.dart index 3322dd9..4b5b5b7 100644 --- a/it0_app/lib/core/router/app_router.dart +++ b/it0_app/lib/core/router/app_router.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../features/auth/data/providers/auth_provider.dart'; import '../../features/auth/presentation/pages/login_page.dart'; import '../../features/dashboard/presentation/pages/dashboard_page.dart'; import '../../features/chat/presentation/pages/chat_page.dart'; @@ -15,8 +16,12 @@ import '../../features/notifications/presentation/providers/notification_provide final routerProvider = Provider((ref) { return GoRouter( - initialLocation: '/login', + initialLocation: '/splash', routes: [ + GoRoute( + path: '/splash', + builder: (context, state) => const _SplashPage(), + ), GoRoute( path: '/login', builder: (context, state) => const LoginPage(), @@ -107,3 +112,39 @@ class ScaffoldWithNav extends ConsumerWidget { ); } } + +/// Splash page that tries to restore a previous session. +class _SplashPage extends ConsumerStatefulWidget { + const _SplashPage(); + + @override + ConsumerState<_SplashPage> createState() => _SplashPageState(); +} + +class _SplashPageState extends ConsumerState<_SplashPage> { + @override + void initState() { + super.initState(); + _tryRestore(); + } + + Future _tryRestore() async { + final auth = ref.read(authStateProvider.notifier); + final restored = await auth.tryRestoreSession(); + if (!mounted) return; + if (restored) { + context.go('/dashboard'); + } else { + context.go('/login'); + } + } + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + } +} diff --git a/it0_app/lib/features/auth/data/providers/auth_provider.dart b/it0_app/lib/features/auth/data/providers/auth_provider.dart index e61af19..aea9b54 100644 --- a/it0_app/lib/features/auth/data/providers/auth_provider.dart +++ b/it0_app/lib/features/auth/data/providers/auth_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -59,6 +60,80 @@ class AuthNotifier extends StateNotifier { AuthNotifier(this._ref) : super(const AuthState()); + /// Try to restore session from stored tokens on app startup. + /// Returns true if a valid session was restored. + Future tryRestoreSession() async { + final storage = _ref.read(secureStorageProvider); + final accessToken = await storage.read(key: _keyAccessToken); + if (accessToken == null) return false; + + // Decode JWT payload to get user info + final user = _decodeUserFromJwt(accessToken); + if (user == null) { + // Token is malformed, try refresh + return refreshToken(); + } + + // Check if token is expired + if (_isTokenExpired(accessToken)) { + // Try to refresh + return refreshToken(); + } + + // Restore auth state + state = state.copyWith( + isAuthenticated: true, + user: user, + ); + + // Restore tenant context + if (user.tenantId != null) { + _ref.read(currentTenantIdProvider.notifier).state = user.tenantId; + } + + // Reconnect notifications + _connectNotifications(user.tenantId); + + return true; + } + + /// Decode user info from JWT payload without verification. + AuthUser? _decodeUserFromJwt(String token) { + try { + final parts = token.split('.'); + if (parts.length != 3) return null; + final payload = json.decode( + utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))), + ) as Map; + return AuthUser( + id: payload['sub'] as String, + email: payload['email'] as String? ?? '', + name: payload['name'] as String? ?? payload['email'] as String? ?? '', + roles: (payload['roles'] as List?)?.cast() ?? [], + tenantId: payload['tenantId'] as String?, + ); + } catch (_) { + return null; + } + } + + /// Check if JWT is expired (with 60s buffer). + bool _isTokenExpired(String token) { + try { + final parts = token.split('.'); + if (parts.length != 3) return true; + final payload = json.decode( + utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))), + ) as Map; + final exp = payload['exp'] as int?; + if (exp == null) return true; + final expiry = DateTime.fromMillisecondsSinceEpoch(exp * 1000); + return DateTime.now().isAfter(expiry.subtract(const Duration(seconds: 60))); + } catch (_) { + return true; + } + } + Future login(String email, String password) async { state = state.copyWith(isLoading: true, error: null); @@ -134,24 +209,37 @@ class AuthNotifier extends StateNotifier { Future refreshToken() async { try { final storage = _ref.read(secureStorageProvider); - final refreshToken = await storage.read(key: _keyRefreshToken); - if (refreshToken == null) return false; + final storedRefreshToken = await storage.read(key: _keyRefreshToken); + if (storedRefreshToken == null) return false; final config = _ref.read(appConfigProvider); final dio = Dio(BaseOptions(baseUrl: config.apiBaseUrl)); final response = await dio.post( ApiEndpoints.refreshToken, - data: {'refreshToken': refreshToken}, + data: {'refreshToken': storedRefreshToken}, ); final data = response.data as Map; - await storage.write( - key: _keyAccessToken, value: data['accessToken'] as String); + final newAccessToken = data['accessToken'] as String; + await storage.write(key: _keyAccessToken, value: newAccessToken); await storage.write( key: _keyRefreshToken, value: data['refreshToken'] as String); _ref.invalidate(accessTokenProvider); + + // Restore user state from new token if not already authenticated + if (!state.isAuthenticated) { + final user = _decodeUserFromJwt(newAccessToken); + if (user != null) { + state = state.copyWith(isAuthenticated: true, user: user); + if (user.tenantId != null) { + _ref.read(currentTenantIdProvider.notifier).state = user.tenantId; + } + _connectNotifications(user.tenantId); + } + } + return true; } catch (_) { await logout();