diff --git a/frontend/pshared/lib/models/auth/state.dart b/frontend/pshared/lib/models/auth/state.dart new file mode 100644 index 0000000..72e1697 --- /dev/null +++ b/frontend/pshared/lib/models/auth/state.dart @@ -0,0 +1,7 @@ +enum AuthState { + idle, + checking, + ready, + empty, + error, +} \ No newline at end of file diff --git a/frontend/pshared/lib/provider/account.dart b/frontend/pshared/lib/provider/account.dart index f246241..0bfd24e 100644 --- a/frontend/pshared/lib/provider/account.dart +++ b/frontend/pshared/lib/provider/account.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:pshared/models/auth/state.dart'; import 'package:share_plus/share_plus.dart'; @@ -26,12 +27,15 @@ class AccountProvider extends ChangeNotifier { static String get currentUserRef => Constants.nilObjectRef; + /// Auth lifecycle state to avoid multiple ad-hoc flags. + AuthState _authState = AuthState.idle; + AuthState get authState => _authState; + // The resource now wraps our Account? state along with its loading/error state. Resource _resource = Resource(data: null); Resource get resource => _resource; late LocaleProvider _localeProvider; PendingLogin? _pendingLogin; - Future? _restoreFuture; Account? get account => _resource.data; PendingLogin? get pendingLogin => _pendingLogin; @@ -89,6 +93,7 @@ class AccountProvider extends ChangeNotifier { locale: locale, )); if (outcome.account != null) { + _authState = AuthState.ready; _setResource(Resource(data: outcome.account, isLoading: false)); _pickupLocale(outcome.account!.locale); } else { @@ -98,10 +103,12 @@ class AccountProvider extends ChangeNotifier { } await VerificationService.requestLoginCode(pending); _pendingLogin = pending; + _authState = AuthState.idle; _setResource(_resource.copyWith(isLoading: false)); } return outcome; } catch (e) { + _authState = AuthState.error; _setResource(_resource.copyWith(isLoading: false, error: toException(e))); rethrow; } @@ -109,6 +116,7 @@ class AccountProvider extends ChangeNotifier { void completePendingLogin(Account account) { _pendingLogin = null; + _authState = AuthState.ready; _setResource(Resource(data: account, isLoading: false, error: null)); _pickupLocale(account.locale); } @@ -116,13 +124,17 @@ class AccountProvider extends ChangeNotifier { Future isAuthorizationStored() async => AuthorizationService.isAuthorizationStored(); Future restore() async { + _authState = AuthState.checking; + notifyListeners(); _setResource(_resource.copyWith(isLoading: true, error: null)); try { final acc = await AccountService.restore(); + _authState = AuthState.ready; _setResource(Resource(data: acc, isLoading: false)); _pickupLocale(acc.locale); return acc; } catch (e) { + _authState = AuthState.error; _setResource(_resource.copyWith(isLoading: false, error: toException(e))); rethrow; } @@ -154,11 +166,14 @@ class AccountProvider extends ChangeNotifier { } Future logout() async { + _authState = AuthState.empty; _setResource(_resource.copyWith(isLoading: true, error: null)); + _pendingLogin = null; try { await AccountService.logout(); _setResource(Resource(data: null, isLoading: false)); } catch (e) { + _authState = AuthState.error; _setResource(_resource.copyWith(isLoading: false, error: toException(e))); rethrow; } @@ -235,10 +250,14 @@ class AccountProvider extends ChangeNotifier { } } - Future restoreIfPossible() { - return _restoreFuture ??= AuthorizationService.isAuthorizationStored().then((hasAuth) async { - if (!hasAuth) return; - await restore(); - }); + Future restoreIfPossible() async { + if (_authState == AuthState.checking || _authState == AuthState.ready) return; + final hasAuth = await AuthorizationService.isAuthorizationStored(); + if (!hasAuth) { + _authState = AuthState.empty; + notifyListeners(); + return; + } + await restore(); } } diff --git a/frontend/pshared/lib/provider/organizations.dart b/frontend/pshared/lib/provider/organizations.dart index 0ea744e..1c8bd81 100644 --- a/frontend/pshared/lib/provider/organizations.dart +++ b/frontend/pshared/lib/provider/organizations.dart @@ -80,4 +80,12 @@ class OrganizationsProvider extends ChangeNotifier { notifyListeners(); return true; } + + Future reset() async { + _resource = Resource(data: []); + _currentOrg = null; + notifyListeners(); + // Best-effort cleanup of stored selection to avoid using stale org on next login. + await SecureStorageService.delete(Constants.currentOrgKey); + } } diff --git a/frontend/pshared/lib/provider/permissions.dart b/frontend/pshared/lib/provider/permissions.dart index 1e1209c..3e8e20f 100644 --- a/frontend/pshared/lib/provider/permissions.dart +++ b/frontend/pshared/lib/provider/permissions.dart @@ -52,7 +52,11 @@ class PermissionsProvider extends ChangeNotifier { } /// Load the [UserAccess] for the current venue. - Future load() async { + Future load() async { + if (!_organizations.isOrganizationSet) { + // Organization is not ready yet; skip loading until it becomes available. + return _userAccess.data; + } _userAccess = _userAccess.copyWith(isLoading: true, error: null); notifyListeners(); @@ -179,6 +183,10 @@ class PermissionsProvider extends ChangeNotifier { notifyListeners(); } + Future resetAsync() async { + reset(); + } + bool canRead(ResourceType r, {Object? objectRef}) => canAccessResource(r, action: perm.Action.read, objectRef: objectRef); bool canUpdate(ResourceType r, {Object? objectRef}) => canAccessResource(r, action: perm.Action.update, objectRef: objectRef); bool canDelete(ResourceType r, {Object? objectRef}) => canAccessResource(r, action: perm.Action.delete, objectRef: objectRef); diff --git a/frontend/pweb/lib/pages/loaders/account.dart b/frontend/pweb/lib/pages/loaders/account.dart index c159b57..68e957a 100644 --- a/frontend/pweb/lib/pages/loaders/account.dart +++ b/frontend/pweb/lib/pages/loaders/account.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:logging/logging.dart'; - import 'package:provider/provider.dart'; +import 'package:pshared/models/auth/state.dart'; import 'package:pshared/provider/account.dart'; import 'package:pweb/app/router/pages.dart'; @@ -12,57 +11,63 @@ import 'package:pweb/widgets/error/snackbar.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; -class AccountLoader extends StatelessWidget { +class AccountLoader extends StatefulWidget { final Widget child; const AccountLoader({super.key, required this.child}); - static final _logger = Logger('loader.account'); + @override + State createState() => _AccountLoaderState(); +} - void _notifyErrorAndRedirect(BuildContext context, Object error) { +class _AccountLoaderState extends State { + AuthState? _handledState; + + @override + void initState() { + super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted) return; - postNotifyUserOfErrorX( - context: context, - errorSituation: AppLocalizations.of(context)!.errorLogin, - exception: error, - ); - navigateAndReplace(context, Pages.login); + if (!mounted) return; + Provider.of(context, listen: false).restoreIfPossible(); }); } - void _restoreAndHandleResult(BuildContext context, AccountProvider provider) { - WidgetsBinding.instance.addPostFrameCallback((_) async { - try { - await provider.restoreIfPossible(); - } catch (error, stack) { - _logger.warning('Account restore failed: $error', error, stack); - } - if (!context.mounted) return; - final latest = Provider.of(context, listen: false); - if (latest.account != null) return; - if (latest.error != null) { - _notifyErrorAndRedirect(context, latest.error!); - return; - } - if (!latest.isLoading) { - navigateAndReplace(context, Pages.login); - } - }); + void _handleSideEffects(AccountProvider provider) { + if (_handledState == provider.authState) return; + _handledState = provider.authState; + + void goToLogin() { + if (!mounted) return; + navigateAndReplace(context, Pages.login); + } + + switch (provider.authState) { + case AuthState.error: + final error = provider.error ?? Exception('Authorization failed'); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + postNotifyUserOfErrorX( + context: context, + errorSituation: AppLocalizations.of(context)!.errorLogin, + exception: error, + ); + goToLogin(); + }); + break; + case AuthState.empty: + WidgetsBinding.instance.addPostFrameCallback((_) => goToLogin()); + break; + default: + break; + } } @override Widget build(BuildContext context) { return Consumer(builder: (context, provider, _) { - if (provider.account != null) return child; - if (provider.error != null) { - _notifyErrorAndRedirect(context, provider.error!); - return const Center(child: CircularProgressIndicator()); + _handleSideEffects(provider); + if (provider.authState == AuthState.ready && provider.account != null) { + return widget.child; } - if (!provider.isLoading) { - _restoreAndHandleResult(context, provider); - } - if (provider.isLoading) return const Center(child: CircularProgressIndicator()); - // In case of error or missing account we show loader while side effects handle navigation. return const Center(child: CircularProgressIndicator()); }); } diff --git a/frontend/pweb/lib/pages/loaders/organization.dart b/frontend/pweb/lib/pages/loaders/organization.dart index 3a3194f..c953377 100644 --- a/frontend/pweb/lib/pages/loaders/organization.dart +++ b/frontend/pweb/lib/pages/loaders/organization.dart @@ -4,9 +4,6 @@ import 'package:provider/provider.dart'; import 'package:pshared/provider/organizations.dart'; -import 'package:pweb/app/router/pages.dart'; -import 'package:pweb/widgets/error/snackbar.dart'; - import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -19,12 +16,20 @@ class OrganizationLoader extends StatelessWidget { Widget build(BuildContext context) => Consumer(builder: (context, provider, _) { if (provider.isLoading) return const Center(child: CircularProgressIndicator()); if (provider.error != null) { - postNotifyUserOfErrorX( - context: context, - errorSituation: AppLocalizations.of(context)!.errorLogin, - exception: provider.error!, + final loc = AppLocalizations.of(context)!; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(loc.errorLogin), + const SizedBox(height: 12), + ElevatedButton( + onPressed: provider.load, + child: Text(loc.retry), + ), + ], + ), ); - navigateAndReplace(context, Pages.login); } if ((provider.error == null) && (!provider.isOrganizationSet)) { WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/frontend/pweb/lib/pages/loaders/permissions.dart b/frontend/pweb/lib/pages/loaders/permissions.dart index 0db63cf..2c102d2 100644 --- a/frontend/pweb/lib/pages/loaders/permissions.dart +++ b/frontend/pweb/lib/pages/loaders/permissions.dart @@ -5,9 +5,6 @@ import 'package:provider/provider.dart'; import 'package:pshared/provider/account.dart'; import 'package:pshared/provider/permissions.dart'; -import 'package:pweb/app/router/pages.dart'; -import 'package:pweb/widgets/error/snackbar.dart'; - import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -15,26 +12,38 @@ class PermissionsLoader extends StatelessWidget { final Widget child; const PermissionsLoader({super.key, required this.child}); - void _notifyError(BuildContext context, Exception exception) { - WidgetsBinding.instance.addPostFrameCallback((_) { - postNotifyUserOfErrorX( - context: context, - errorSituation: AppLocalizations.of(context)!.errorLogin, - exception: exception, - ); - navigateAndReplace(context, Pages.login); - }); + void _triggerLoadIfNeeded(PermissionsProvider provider) { + if (!provider.isLoading && !provider.isReady) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!provider.isLoading && !provider.isReady) { + provider.load(); + } + }); + } } @override Widget build(BuildContext context) { return Consumer2( - builder: (context, provider, accountProvider, _) { - if (provider.isLoading) { - return const Center(child: CircularProgressIndicator()); - } + builder: (context, provider, _accountProvider, _) { if (provider.error != null) { - _notifyError(context, provider.error!); + final loc = AppLocalizations.of(context)!; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(loc.errorLogin), + const SizedBox(height: 12), + ElevatedButton( + onPressed: provider.load, + child: Text(loc.retry), + ), + ], + ), + ); + } + _triggerLoadIfNeeded(provider); + if (provider.isLoading || !provider.isReady) { return const Center(child: CircularProgressIndicator()); } return child; diff --git a/frontend/pweb/lib/providers/two_factor.dart b/frontend/pweb/lib/providers/two_factor.dart index 548244a..de821fe 100644 --- a/frontend/pweb/lib/providers/two_factor.dart +++ b/frontend/pweb/lib/providers/two_factor.dart @@ -17,6 +17,7 @@ class TwoFactorProvider extends ChangeNotifier { bool _hasError = false; bool _verificationSuccess = false; String? _errorMessage; + String? _currentPendingToken; bool get isSubmitting => _isSubmitting; bool get hasError => _hasError; @@ -26,6 +27,12 @@ class TwoFactorProvider extends ChangeNotifier { void update(AccountProvider accountProvider) { _accountProvider = accountProvider; + final pending = accountProvider.pendingLogin; + final token = pending?.pendingToken.token; + if (token != _currentPendingToken || accountProvider.account == null) { + _resetState(); + _currentPendingToken = token; + } } Future submitCode(String code) async { @@ -46,6 +53,7 @@ class TwoFactorProvider extends ChangeNotifier { ); _accountProvider.completePendingLogin(account); _verificationSuccess = true; + _currentPendingToken = null; } catch (e) { _hasError = true; _errorMessage = e.toString(); @@ -71,4 +79,17 @@ class TwoFactorProvider extends ChangeNotifier { notifyListeners(); } } -} \ No newline at end of file + + void reset() { + _resetState(); + _currentPendingToken = null; + } + + void _resetState() { + _isSubmitting = false; + _hasError = false; + _errorMessage = null; + _verificationSuccess = false; + notifyListeners(); + } +} diff --git a/frontend/pweb/lib/utils/logout.dart b/frontend/pweb/lib/utils/logout.dart index 97e030e..3f8693e 100644 --- a/frontend/pweb/lib/utils/logout.dart +++ b/frontend/pweb/lib/utils/logout.dart @@ -1,15 +1,31 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:pshared/provider/account.dart'; +import 'package:pshared/provider/organizations.dart'; import 'package:pshared/provider/permissions.dart'; import 'package:pweb/app/router/pages.dart'; +import 'package:pweb/providers/two_factor.dart'; -void logoutUtil(BuildContext context) { - context.read().logout(); - context.read().reset(); - navigateAndReplace(context, Pages.login); +Future logoutUtil(BuildContext context) async { + final accountProvider = context.read(); + final permissionsProvider = context.read(); + final organizationsProvider = context.read(); + final twoFactorProvider = context.read(); + await accountProvider.logout(); + permissionsProvider.reset(); + await organizationsProvider.reset(); + twoFactorProvider.reset(); + + final router = GoRouter.of(context); + final loginPath = routerPage(Pages.login); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + router.go(loginPath); + }); } diff --git a/frontend/pweb/lib/widgets/appbar/app_bar.dart b/frontend/pweb/lib/widgets/appbar/app_bar.dart index d5c8299..bb1da64 100644 --- a/frontend/pweb/lib/widgets/appbar/app_bar.dart +++ b/frontend/pweb/lib/widgets/appbar/app_bar.dart @@ -17,7 +17,7 @@ class PayoutAppBar extends StatelessWidget implements PreferredSizeWidget { final Widget title; final VoidCallback onAddFundsPressed; final List? actions; - final VoidCallback? onLogout; + final Future Function()? onLogout; final String? avatarUrl; @override diff --git a/frontend/pweb/lib/widgets/appbar/profile.dart b/frontend/pweb/lib/widgets/appbar/profile.dart index 365395c..b72a12b 100644 --- a/frontend/pweb/lib/widgets/appbar/profile.dart +++ b/frontend/pweb/lib/widgets/appbar/profile.dart @@ -7,7 +7,7 @@ class ProfileAvatar extends StatelessWidget { const ProfileAvatar({super.key, this.avatarUrl, this.onLogout}); final String? avatarUrl; - final VoidCallback? onLogout; + final Future Function()? onLogout; @override Widget build(BuildContext context) => PopupMenuButton( @@ -37,4 +37,4 @@ class ProfileAvatar extends StatelessWidget { child: avatarUrl == null ? const Icon(Icons.person, size: 24) : null, ), ); -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/widgets/drawer/tiles/logout.dart b/frontend/pweb/lib/widgets/drawer/tiles/logout.dart index fd4561a..d482df2 100644 --- a/frontend/pweb/lib/widgets/drawer/tiles/logout.dart +++ b/frontend/pweb/lib/widgets/drawer/tiles/logout.dart @@ -1,10 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import 'package:pshared/provider/account.dart'; - -import 'package:pweb/app/router/pages.dart'; +import 'package:pweb/utils/logout.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -23,10 +19,8 @@ class LogoutTile extends StatelessWidget { ); } - void _logout(BuildContext context) { + Future _logout(BuildContext context) async { Navigator.pop(context); - final accountProvider = Provider.of(context, listen: false); - accountProvider.logout(); - navigateAndReplace(context, Pages.login); + await logoutUtil(context); } }