feat: Implement role-based session management and refactor authentication flow

This commit is contained in:
Achintha Isuru
2026-02-17 15:10:10 -05:00
parent be40614274
commit 8ce37d2306
13 changed files with 138 additions and 210 deletions

View File

@@ -25,7 +25,9 @@ void main() async {
);
// Initialize session listener for Firebase Auth state changes
DataConnectService.instance.initializeAuthListener();
DataConnectService.instance.initializeAuthListener(
allowedRoles: <String>['STAFF', 'BOTH'], // Only allow users with STAFF or BOTH roles
);
runApp(
ModularApp(

View File

@@ -23,6 +23,7 @@ class SessionListener extends StatefulWidget {
class _SessionListenerState extends State<SessionListener> {
late StreamSubscription<SessionState> _sessionSubscription;
bool _sessionExpiredDialogShown = false;
bool _isInitialState = true;
@override
void initState() {
@@ -35,6 +36,8 @@ class _SessionListenerState extends State<SessionListener> {
.listen((SessionState state) {
_handleSessionChange(state);
});
debugPrint('[SessionListener] Initialized session listener');
}
void _handleSessionChange(SessionState state) {
@@ -45,8 +48,12 @@ class _SessionListenerState extends State<SessionListener> {
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<SessionListener> {
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<SessionListener> {
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<SessionListener> {
DataConnectService.instance.handleSignOut();
// Navigate to authentication
Modular.to.toGetStarted();
Modular.to.toInitialPage();
}
@override

View File

@@ -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);
}

View File

@@ -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<String?> fetchUserRole(String userId) async {
try {
final fdc.QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables>
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<void> dispose() async {
await disposeSessionHandler();

View File

@@ -47,9 +47,25 @@ mixin SessionHandlerMixin {
final StreamController<SessionState> _sessionStateController =
StreamController<SessionState>.broadcast();
/// Last emitted session state (for late subscribers).
SessionState? _lastSessionState;
/// Public stream for listening to session state changes.
Stream<SessionState> get onSessionStateChanged =>
_sessionStateController.stream;
/// Late subscribers will immediately receive the last emitted state.
Stream<SessionState> 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<SessionState> _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<String> _allowedRoles = <String>[];
/// Initialize the auth state listener (call once on app startup).
void initializeAuthListener() {
void initializeAuthListener({List<String> allowedRoles = const <String>[]}) {
_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<bool> validateUserRole(
String userId,
List<String> 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<String?> 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<void> 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<void>.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);
}

View File

@@ -414,27 +414,4 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
return domainUser;
}
@override
Future<domain.User?> 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;
}
}
}

View File

@@ -34,7 +34,4 @@ abstract class AuthRepositoryInterface {
/// Terminates the current user session and clears authentication tokens.
Future<void> signOut();
/// Restores the session if a user is already logged in.
Future<User?> restoreSession();
}

View File

@@ -23,26 +23,22 @@ class _ClientIntroPageState extends State<ClientIntroPage> {
if (!mounted) return;
try {
final AuthRepositoryInterface authRepo = Modular.get<AuthRepositoryInterface>();
final AuthRepositoryInterface authRepo =
Modular.get<AuthRepositoryInterface>();
// 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);
}
}
}

View File

@@ -257,77 +257,4 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
);
return domainUser;
}
@override
Future<domain.User?> restoreSession() async {
final User? firebaseUser = _service.auth.currentUser;
if (firebaseUser == null) {
return null;
}
try {
// 1. Fetch User
final QueryResult<GetUserByIdData, GetUserByIdVariables> 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<GetStaffByUserIdData, GetStaffByUserIdVariables>
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;
}
}
}

View File

@@ -20,7 +20,4 @@ abstract interface class AuthRepositoryInterface {
/// Signs out the current user.
Future<void> signOut();
/// Restores the session if a user is already logged in.
Future<User?> restoreSession();
}

View File

@@ -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<IntroPage> createState() => _IntroPageState();
}
class _IntroPageState extends State<IntroPage> {
@override
void initState() {
super.initState();
_checkSession();
}
Future<void> _checkSession() async {
// Check session immediately without artificial delay
if (!mounted) return;
try {
final AuthRepositoryInterface authRepo = Modular.get<AuthRepositoryInterface>();
// 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)),
);
}
}

View File

@@ -58,15 +58,14 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
}
if (normalized.length == 10) {
BlocProvider.of<AuthBloc>(
context,
).add(
BlocProvider.of<AuthBloc>(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<PhoneVerificationPage> {
required String otp,
required String verificationId,
}) {
BlocProvider.of<AuthBloc>(
context,
).add(
BlocProvider.of<AuthBloc>(context).add(
AuthOtpSubmitted(
verificationId: verificationId,
smsCode: otp,
@@ -92,9 +89,9 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
/// Handles the request to resend the verification code using the phone number in the state.
void _onResend({required BuildContext context}) {
BlocProvider.of<AuthBloc>(context).add(
AuthSignInRequested(mode: widget.mode),
);
BlocProvider.of<AuthBloc>(
context,
).add(AuthSignInRequested(mode: widget.mode));
}
@override
@@ -108,8 +105,6 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
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<PhoneVerificationPage> {
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<void>.delayed(const Duration(seconds: 5), () {
if (!mounted) return;
@@ -153,9 +152,9 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
centerTitle: true,
showBackButton: true,
onLeadingPressed: () {
BlocProvider.of<AuthBloc>(context).add(
AuthResetRequested(mode: widget.mode),
);
BlocProvider.of<AuthBloc>(
context,
).add(AuthResetRequested(mode: widget.mode));
Navigator.of(context).pop();
},
),
@@ -175,13 +174,13 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
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,
),
),
),
),
),
);

View File

@@ -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(