diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index 73c8ad95..1858e1bd 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -25,7 +25,9 @@ void main() async { ); // Initialize session listener for Firebase Auth state changes - DataConnectService.instance.initializeAuthListener(); + DataConnectService.instance.initializeAuthListener( + allowedRoles: ['STAFF', 'BOTH'], // Only allow users with STAFF or BOTH roles + ); runApp( ModularApp( diff --git a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart index bc40deea..160b5fd4 100644 --- a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart @@ -23,6 +23,7 @@ class SessionListener extends StatefulWidget { class _SessionListenerState extends State { late StreamSubscription _sessionSubscription; bool _sessionExpiredDialogShown = false; + bool _isInitialState = true; @override void initState() { @@ -35,6 +36,8 @@ class _SessionListenerState extends State { .listen((SessionState state) { _handleSessionChange(state); }); + + debugPrint('[SessionListener] Initialized session listener'); } void _handleSessionChange(SessionState state) { @@ -45,8 +48,12 @@ class _SessionListenerState extends State { debugPrint( '[SessionListener] Unauthenticated: Session expired or user logged out', ); - // Show expiration dialog if not already shown - if (!_sessionExpiredDialogShown) { + // On initial state (cold start), just proceed to login without dialog + // Only show dialog if user was previously authenticated (session expired) + if (_isInitialState) { + _isInitialState = false; + Modular.to.toGetStartedPage(); + } else if (!_sessionExpiredDialogShown) { _sessionExpiredDialogShown = true; _showSessionExpiredDialog(); } @@ -54,6 +61,7 @@ class _SessionListenerState extends State { case SessionStateType.authenticated: // Session restored or user authenticated + _isInitialState = false; _sessionExpiredDialogShown = false; debugPrint('[SessionListener] Authenticated: ${state.userId}'); @@ -63,8 +71,14 @@ class _SessionListenerState extends State { case SessionStateType.error: // Show error notification with option to retry or logout - debugPrint('[SessionListener] Session error: ${state.errorMessage}'); - _showSessionErrorDialog(state.errorMessage ?? 'Session error occurred'); + // Only show if not initial state (avoid showing on cold start) + if (!_isInitialState) { + debugPrint('[SessionListener] Session error: ${state.errorMessage}'); + _showSessionErrorDialog(state.errorMessage ?? 'Session error occurred'); + } else { + _isInitialState = false; + Modular.to.toInitialPage(); + } break; case SessionStateType.loading: @@ -135,7 +149,7 @@ class _SessionListenerState extends State { DataConnectService.instance.handleSignOut(); // Navigate to authentication - Modular.to.toGetStarted(); + Modular.to.toInitialPage(); } @override diff --git a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart index bcfdbaa0..1269484c 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart @@ -32,7 +32,7 @@ extension StaffNavigator on IModularNavigator { /// /// This effectively logs out the user by navigating to root. /// Used when signing out or session expires. - void toGetStarted() { + void toInitialPage() { navigate(StaffPaths.root); } diff --git a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart index 539face8..c70f4789 100644 --- a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart +++ b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; +import 'package:flutter/material.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -159,6 +160,20 @@ class DataConnectService with DataErrorHandler, SessionHandlerMixin { clearCache(); } + @override + Future fetchUserRole(String userId) async { + try { + final fdc.QueryResult + response = await executeProtected( + () => connector.getUserById(id: userId).execute(), + ); + return response.data.user?.userRole; + } catch (e) { + debugPrint('Failed to fetch user role: $e'); + return null; + } + } + /// Dispose all resources (call on app shutdown). Future dispose() async { await disposeSessionHandler(); diff --git a/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart b/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart index 433b813b..393f4b8a 100644 --- a/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart +++ b/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart @@ -47,9 +47,25 @@ mixin SessionHandlerMixin { final StreamController _sessionStateController = StreamController.broadcast(); + /// Last emitted session state (for late subscribers). + SessionState? _lastSessionState; + /// Public stream for listening to session state changes. - Stream get onSessionStateChanged => - _sessionStateController.stream; + /// Late subscribers will immediately receive the last emitted state. + Stream get onSessionStateChanged { + // Create a custom stream that emits the last state before forwarding new events + return _createStreamWithLastState(); + } + + /// Creates a stream that emits the last state before subscribing to new events. + Stream _createStreamWithLastState() async* { + // If we have a last state, emit it immediately to late subscribers + if (_lastSessionState != null) { + yield _lastSessionState!; + } + // Then forward all subsequent events + yield* _sessionStateController.stream; + } /// Last token refresh timestamp to avoid excessive checks. DateTime? _lastTokenRefreshTime; @@ -66,8 +82,13 @@ mixin SessionHandlerMixin { /// Firebase Auth instance (to be provided by implementing class). firebase_auth.FirebaseAuth get auth; + /// List of allowed roles for this app (to be set during initialization). + List _allowedRoles = []; + /// Initialize the auth state listener (call once on app startup). - void initializeAuthListener() { + void initializeAuthListener({List allowedRoles = const []}) { + _allowedRoles = allowedRoles; + // Cancel any existing subscription first _authStateSubscription?.cancel(); @@ -86,6 +107,25 @@ mixin SessionHandlerMixin { ); } + /// Validates if user has one of the allowed roles. + /// Returns true if user role is in allowed roles, false otherwise. + Future validateUserRole( + String userId, + List allowedRoles, + ) async { + try { + final String? userRole = await fetchUserRole(userId); + return userRole != null && allowedRoles.contains(userRole); + } catch (e) { + debugPrint('Failed to validate user role: $e'); + return false; + } + } + + /// Fetches user role from Data Connect. + /// To be implemented by concrete class. + Future fetchUserRole(String userId); + /// Ensures the Firebase auth token is valid and refreshes if needed. /// Retries up to 3 times with exponential backoff before emitting error. Future ensureSessionValid() async { @@ -111,8 +151,8 @@ mixin SessionHandlerMixin { while (retryCount < maxRetries) { try { // Get token result (doesn't fetch from network unless needed) - final firebase_auth.IdTokenResult idToken = - await user.getIdTokenResult(); + final firebase_auth.IdTokenResult idToken = await user + .getIdTokenResult(); // Extract expiration time final DateTime? expiryTime = idToken.expirationTime; @@ -152,7 +192,9 @@ mixin SessionHandlerMixin { final Duration backoffDuration = Duration( seconds: 1 << (retryCount - 1), // 2^(retryCount-1) ); - debugPrint('Retrying token validation in ${backoffDuration.inSeconds}s'); + debugPrint( + 'Retrying token validation in ${backoffDuration.inSeconds}s', + ); await Future.delayed(backoffDuration); } } @@ -163,6 +205,19 @@ mixin SessionHandlerMixin { try { _emitSessionState(SessionState.loading()); + // Validate role if allowed roles are specified + if (_allowedRoles.isNotEmpty) { + final bool isAuthorized = await validateUserRole( + user.uid, + _allowedRoles, + ); + if (!isAuthorized) { + await auth.signOut(); + _emitSessionState(SessionState.unauthenticated()); + return; + } + } + // Get fresh token to validate session final firebase_auth.IdTokenResult idToken = await user.getIdTokenResult(); if (idToken.expirationTime != null && @@ -186,6 +241,7 @@ mixin SessionHandlerMixin { /// Emit session state update. void _emitSessionState(SessionState state) { + _lastSessionState = state; if (!_sessionStateController.isClosed) { _sessionStateController.add(state); } diff --git a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index e57c1df9..467a7c07 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -414,27 +414,4 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { return domainUser; } - - @override - Future restoreSession() async { - final firebase.User? firebaseUser = _service.auth.currentUser; - if (firebaseUser == null) { - return null; - } - - try { - return await _getUserProfile( - firebaseUserId: firebaseUser.uid, - fallbackEmail: firebaseUser.email, - requireBusinessRole: true, - ); - } catch (e) { - // If the user is not found or other permanent errors, we should probably sign out - if (e is UserNotFoundException || e is UnauthorizedAppException) { - await _service.auth.signOut(); - return null; - } - rethrow; - } - } } diff --git a/apps/mobile/packages/features/client/authentication/lib/src/domain/repositories/auth_repository_interface.dart b/apps/mobile/packages/features/client/authentication/lib/src/domain/repositories/auth_repository_interface.dart index 3dbc053f..21a1830c 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/domain/repositories/auth_repository_interface.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/domain/repositories/auth_repository_interface.dart @@ -34,7 +34,4 @@ abstract class AuthRepositoryInterface { /// Terminates the current user session and clears authentication tokens. Future signOut(); - - /// Restores the session if a user is already logged in. - Future restoreSession(); } diff --git a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart index f866b43c..5420a013 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart @@ -23,26 +23,22 @@ class _ClientIntroPageState extends State { if (!mounted) return; try { - final AuthRepositoryInterface authRepo = Modular.get(); + final AuthRepositoryInterface authRepo = + Modular.get(); // Add a timeout to prevent infinite loading - final user = await authRepo.restoreSession().timeout( - const Duration(seconds: 5), - onTimeout: () { - throw TimeoutException('Session restore timed out'); - }, - ); - + final user = true; + if (mounted) { if (user != null) { - Modular.to.navigate(ClientPaths.home); + Modular.to.navigate(ClientPaths.home); } else { - Modular.to.navigate(ClientPaths.getStarted); + Modular.to.navigate(ClientPaths.getStarted); } } } catch (e) { debugPrint('ClientIntroPage: Session check error: $e'); if (mounted) { - Modular.to.navigate(ClientPaths.getStarted); + Modular.to.navigate(ClientPaths.getStarted); } } } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index 863d815f..b247880e 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -257,77 +257,4 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { ); return domainUser; } - @override - Future restoreSession() async { - final User? firebaseUser = _service.auth.currentUser; - if (firebaseUser == null) { - return null; - } - - try { - // 1. Fetch User - final QueryResult response = - await _service.run(() => _service.connector - .getUserById( - id: firebaseUser.uid, - ) - .execute()); - final GetUserByIdUser? user = response.data.user; - - if (user == null) { - return null; - } - - // 2. Check Role - if (user.userRole != 'STAFF' && user.userRole != 'BOTH') { - return null; - } - - // 3. Fetch Staff Profile - final QueryResult - staffResponse = await _service.run(() => _service.connector - .getStaffByUserId( - userId: firebaseUser.uid, - ) - .execute()); - - if (staffResponse.data.staffs.isEmpty) { - return null; - } - - final GetStaffByUserIdStaffs staffRecord = staffResponse.data.staffs.first; - - // 4. Populate Session - final domain.User domainUser = domain.User( - id: firebaseUser.uid, - email: user.email ?? '', - phone: firebaseUser.phoneNumber, - role: user.role.stringValue, - ); - - final domain.Staff domainStaff = domain.Staff( - id: staffRecord.id, - authProviderId: staffRecord.userId, - name: staffRecord.fullName, - email: staffRecord.email ?? '', - phone: staffRecord.phone, - status: domain.StaffStatus.completedProfile, - address: staffRecord.addres, - avatar: staffRecord.photoUrl, - ); - - StaffSessionStore.instance.setSession( - StaffSession( - user: domainUser, - staff: domainStaff, - ownerId: staffRecord.ownerId, - ), - ); - - return domainUser; - } catch (e) { - // If restoration fails (network, etc), we rethrow to let UI handle it. - rethrow; - } - } } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart index e73be91d..0ee6fc5a 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart @@ -20,7 +20,4 @@ abstract interface class AuthRepositoryInterface { /// Signs out the current user. Future signOut(); - - /// Restores the session if a user is already logged in. - Future restoreSession(); } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/intro_page.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/intro_page.dart index 6d27ee1b..0acc300b 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/intro_page.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/intro_page.dart @@ -1,65 +1,14 @@ -import 'dart:async'; +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; -import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart'; -class IntroPage extends StatefulWidget { +/// A simple introductory page that displays the KROW logo. +class IntroPage extends StatelessWidget { const IntroPage({super.key}); - @override - State createState() => _IntroPageState(); -} - -class _IntroPageState extends State { - @override - void initState() { - super.initState(); - _checkSession(); - } - - Future _checkSession() async { - // Check session immediately without artificial delay - if (!mounted) return; - - try { - final AuthRepositoryInterface authRepo = Modular.get(); - // Add a timeout to prevent infinite loading - final user = await authRepo.restoreSession().timeout( - const Duration(seconds: 5), - onTimeout: () { - // If it takes too long, navigate to Get Started. - // This handles poor network conditions gracefully. - throw TimeoutException('Session restore timed out'); - }, - ); - - if (mounted) { - if (user != null) { - Modular.to.navigate(StaffPaths.home); - } else { - Modular.to.navigate(StaffPaths.getStarted); - } - } - } catch (e) { - debugPrint('IntroPage: Session check error: $e'); - if (mounted) { - Modular.to.navigate(StaffPaths.getStarted); - } - } - } - @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, - body: Center( - child: Image.asset( - 'assets/logo-yellow.png', - package: 'design_system', - width: 120, - ), - ), + body: Center(child: Image.asset(UiImageAssets.logoYellow, width: 120)), ); } } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart index 9cbf1455..109761aa 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart @@ -58,15 +58,14 @@ class _PhoneVerificationPageState extends State { } if (normalized.length == 10) { - BlocProvider.of( - context, - ).add( + BlocProvider.of(context).add( AuthSignInRequested(phoneNumber: '+1$normalized', mode: widget.mode), ); } else { UiSnackbar.show( context, - message: t.staff_authentication.phone_verification_page.validation_error, + message: + t.staff_authentication.phone_verification_page.validation_error, type: UiSnackbarType.error, margin: const EdgeInsets.only(bottom: 180, left: 16, right: 16), ); @@ -79,9 +78,7 @@ class _PhoneVerificationPageState extends State { required String otp, required String verificationId, }) { - BlocProvider.of( - context, - ).add( + BlocProvider.of(context).add( AuthOtpSubmitted( verificationId: verificationId, smsCode: otp, @@ -92,9 +89,9 @@ class _PhoneVerificationPageState extends State { /// Handles the request to resend the verification code using the phone number in the state. void _onResend({required BuildContext context}) { - BlocProvider.of(context).add( - AuthSignInRequested(mode: widget.mode), - ); + BlocProvider.of( + context, + ).add(AuthSignInRequested(mode: widget.mode)); } @override @@ -108,8 +105,6 @@ class _PhoneVerificationPageState extends State { if (state.status == AuthStatus.authenticated) { if (state.mode == AuthMode.signup) { Modular.to.toProfileSetup(); - } else { - Modular.to.toStaffHome(); } } else if (state.status == AuthStatus.error && state.mode == AuthMode.signup) { @@ -120,7 +115,11 @@ class _PhoneVerificationPageState extends State { context, message: translateErrorKey(messageKey), type: UiSnackbarType.error, - margin: const EdgeInsets.only(bottom: 180, left: 16, right: 16), + margin: const EdgeInsets.only( + bottom: 180, + left: 16, + right: 16, + ), ); Future.delayed(const Duration(seconds: 5), () { if (!mounted) return; @@ -153,9 +152,9 @@ class _PhoneVerificationPageState extends State { centerTitle: true, showBackButton: true, onLeadingPressed: () { - BlocProvider.of(context).add( - AuthResetRequested(mode: widget.mode), - ); + BlocProvider.of( + context, + ).add(AuthResetRequested(mode: widget.mode)); Navigator.of(context).pop(); }, ), @@ -175,13 +174,13 @@ class _PhoneVerificationPageState extends State { verificationId: state.verificationId ?? '', ), ) - : PhoneInput( - state: state, - onSendCode: (String phoneNumber) => _onSendCode( - context: context, - phoneNumber: phoneNumber, + : PhoneInput( + state: state, + onSendCode: (String phoneNumber) => _onSendCode( + context: context, + phoneNumber: phoneNumber, + ), ), - ), ), ), ); diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart index 344f15b9..f16beaec 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart @@ -1,22 +1,21 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart' hide ReadContext; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import '../blocs/profile_cubit.dart'; import '../blocs/profile_state.dart'; -import 'package:krow_core/core.dart'; +import '../widgets/language_selector_bottom_sheet.dart'; import '../widgets/logout_button.dart'; +import '../widgets/profile_header.dart'; import '../widgets/profile_menu_grid.dart'; import '../widgets/profile_menu_item.dart'; -import '../widgets/profile_header.dart'; import '../widgets/reliability_score_bar.dart'; import '../widgets/reliability_stats_card.dart'; -import '../widgets/reliability_stats_card.dart'; import '../widgets/section_title.dart'; -import '../widgets/language_selector_bottom_sheet.dart'; /// The main Staff Profile page. /// @@ -63,7 +62,7 @@ class StaffProfilePage extends StatelessWidget { bloc: cubit, listener: (context, state) { if (state.status == ProfileStatus.signedOut) { - Modular.to.toGetStarted(); + Modular.to.toGetStartedPage(); } else if (state.status == ProfileStatus.error && state.errorMessage != null) { UiSnackbar.show(