PostHog last fixes hopefully #109

Merged
tech merged 2 commits from SEND006 into main 2025-12-17 12:22:35 +00:00
12 changed files with 180 additions and 88 deletions
Showing only changes of commit a2c05745ad - Show all commits

View File

@@ -0,0 +1,7 @@
enum AuthState {
idle,
checking,
ready,
empty,
error,
}

View File

@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pshared/models/auth/state.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
@@ -26,12 +27,15 @@ class AccountProvider extends ChangeNotifier {
static String get currentUserRef => Constants.nilObjectRef; 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. // The resource now wraps our Account? state along with its loading/error state.
Resource<Account?> _resource = Resource(data: null); Resource<Account?> _resource = Resource(data: null);
Resource<Account?> get resource => _resource; Resource<Account?> get resource => _resource;
late LocaleProvider _localeProvider; late LocaleProvider _localeProvider;
PendingLogin? _pendingLogin; PendingLogin? _pendingLogin;
Future<void>? _restoreFuture;
Account? get account => _resource.data; Account? get account => _resource.data;
PendingLogin? get pendingLogin => _pendingLogin; PendingLogin? get pendingLogin => _pendingLogin;
@@ -89,6 +93,7 @@ class AccountProvider extends ChangeNotifier {
locale: locale, locale: locale,
)); ));
if (outcome.account != null) { if (outcome.account != null) {
_authState = AuthState.ready;
_setResource(Resource(data: outcome.account, isLoading: false)); _setResource(Resource(data: outcome.account, isLoading: false));
_pickupLocale(outcome.account!.locale); _pickupLocale(outcome.account!.locale);
} else { } else {
@@ -98,10 +103,12 @@ class AccountProvider extends ChangeNotifier {
} }
await VerificationService.requestLoginCode(pending); await VerificationService.requestLoginCode(pending);
_pendingLogin = pending; _pendingLogin = pending;
_authState = AuthState.idle;
_setResource(_resource.copyWith(isLoading: false)); _setResource(_resource.copyWith(isLoading: false));
} }
return outcome; return outcome;
} catch (e) { } catch (e) {
_authState = AuthState.error;
_setResource(_resource.copyWith(isLoading: false, error: toException(e))); _setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow; rethrow;
} }
@@ -109,6 +116,7 @@ class AccountProvider extends ChangeNotifier {
void completePendingLogin(Account account) { void completePendingLogin(Account account) {
_pendingLogin = null; _pendingLogin = null;
_authState = AuthState.ready;
_setResource(Resource(data: account, isLoading: false, error: null)); _setResource(Resource(data: account, isLoading: false, error: null));
_pickupLocale(account.locale); _pickupLocale(account.locale);
} }
@@ -116,13 +124,17 @@ class AccountProvider extends ChangeNotifier {
Future<bool> isAuthorizationStored() async => AuthorizationService.isAuthorizationStored(); Future<bool> isAuthorizationStored() async => AuthorizationService.isAuthorizationStored();
Future<Account?> restore() async { Future<Account?> restore() async {
_authState = AuthState.checking;
notifyListeners();
_setResource(_resource.copyWith(isLoading: true, error: null)); _setResource(_resource.copyWith(isLoading: true, error: null));
try { try {
final acc = await AccountService.restore(); final acc = await AccountService.restore();
_authState = AuthState.ready;
_setResource(Resource(data: acc, isLoading: false)); _setResource(Resource(data: acc, isLoading: false));
_pickupLocale(acc.locale); _pickupLocale(acc.locale);
return acc; return acc;
} catch (e) { } catch (e) {
_authState = AuthState.error;
_setResource(_resource.copyWith(isLoading: false, error: toException(e))); _setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow; rethrow;
} }
@@ -154,11 +166,14 @@ class AccountProvider extends ChangeNotifier {
} }
Future<void> logout() async { Future<void> logout() async {
_authState = AuthState.empty;
_setResource(_resource.copyWith(isLoading: true, error: null)); _setResource(_resource.copyWith(isLoading: true, error: null));
_pendingLogin = null;
try { try {
await AccountService.logout(); await AccountService.logout();
_setResource(Resource(data: null, isLoading: false)); _setResource(Resource(data: null, isLoading: false));
} catch (e) { } catch (e) {
_authState = AuthState.error;
_setResource(_resource.copyWith(isLoading: false, error: toException(e))); _setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow; rethrow;
} }
@@ -235,10 +250,14 @@ class AccountProvider extends ChangeNotifier {
} }
} }
Future<void> restoreIfPossible() { Future<void> restoreIfPossible() async {
return _restoreFuture ??= AuthorizationService.isAuthorizationStored().then<void>((hasAuth) async { if (_authState == AuthState.checking || _authState == AuthState.ready) return;
if (!hasAuth) return; final hasAuth = await AuthorizationService.isAuthorizationStored();
await restore(); if (!hasAuth) {
}); _authState = AuthState.empty;
notifyListeners();
return;
}
await restore();
} }
} }

View File

@@ -80,4 +80,12 @@ class OrganizationsProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
return true; return true;
} }
Future<void> 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);
}
} }

View File

@@ -52,7 +52,11 @@ class PermissionsProvider extends ChangeNotifier {
} }
/// Load the [UserAccess] for the current venue. /// Load the [UserAccess] for the current venue.
Future<UserAccess?> load() async { Future<UserAccess?> 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); _userAccess = _userAccess.copyWith(isLoading: true, error: null);
notifyListeners(); notifyListeners();
@@ -179,6 +183,10 @@ class PermissionsProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> resetAsync() async {
reset();
}
bool canRead(ResourceType r, {Object? objectRef}) => canAccessResource(r, action: perm.Action.read, objectRef: objectRef); 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 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); bool canDelete(ResourceType r, {Object? objectRef}) => canAccessResource(r, action: perm.Action.delete, objectRef: objectRef);

View File

@@ -1,9 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/models/auth/state.dart';
import 'package:pshared/provider/account.dart'; import 'package:pshared/provider/account.dart';
import 'package:pweb/app/router/pages.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'; import 'package:pweb/generated/i18n/app_localizations.dart';
class AccountLoader extends StatelessWidget { class AccountLoader extends StatefulWidget {
final Widget child; final Widget child;
const AccountLoader({super.key, required this.child}); const AccountLoader({super.key, required this.child});
static final _logger = Logger('loader.account'); @override
State<AccountLoader> createState() => _AccountLoaderState();
}
void _notifyErrorAndRedirect(BuildContext context, Object error) { class _AccountLoaderState extends State<AccountLoader> {
AuthState? _handledState;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return; if (!mounted) return;
postNotifyUserOfErrorX( Provider.of<AccountProvider>(context, listen: false).restoreIfPossible();
context: context,
errorSituation: AppLocalizations.of(context)!.errorLogin,
exception: error,
);
navigateAndReplace(context, Pages.login);
}); });
} }
void _restoreAndHandleResult(BuildContext context, AccountProvider provider) { void _handleSideEffects(AccountProvider provider) {
WidgetsBinding.instance.addPostFrameCallback((_) async { if (_handledState == provider.authState) return;
try { _handledState = provider.authState;
await provider.restoreIfPossible();
} catch (error, stack) { void goToLogin() {
_logger.warning('Account restore failed: $error', error, stack); if (!mounted) return;
} navigateAndReplace(context, Pages.login);
if (!context.mounted) return; }
final latest = Provider.of<AccountProvider>(context, listen: false);
if (latest.account != null) return; switch (provider.authState) {
if (latest.error != null) { case AuthState.error:
_notifyErrorAndRedirect(context, latest.error!); final error = provider.error ?? Exception('Authorization failed');
return; WidgetsBinding.instance.addPostFrameCallback((_) {
} if (!mounted) return;
if (!latest.isLoading) { postNotifyUserOfErrorX(
navigateAndReplace(context, Pages.login); context: context,
} errorSituation: AppLocalizations.of(context)!.errorLogin,
}); exception: error,
);
goToLogin();
});
break;
case AuthState.empty:
WidgetsBinding.instance.addPostFrameCallback((_) => goToLogin());
break;
default:
break;
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<AccountProvider>(builder: (context, provider, _) { return Consumer<AccountProvider>(builder: (context, provider, _) {
if (provider.account != null) return child; _handleSideEffects(provider);
if (provider.error != null) { if (provider.authState == AuthState.ready && provider.account != null) {
_notifyErrorAndRedirect(context, provider.error!); return widget.child;
return const Center(child: CircularProgressIndicator());
} }
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()); return const Center(child: CircularProgressIndicator());
}); });
} }

View File

@@ -4,9 +4,6 @@ import 'package:provider/provider.dart';
import 'package:pshared/provider/organizations.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'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -19,12 +16,20 @@ class OrganizationLoader extends StatelessWidget {
Widget build(BuildContext context) => Consumer<OrganizationsProvider>(builder: (context, provider, _) { Widget build(BuildContext context) => Consumer<OrganizationsProvider>(builder: (context, provider, _) {
if (provider.isLoading) return const Center(child: CircularProgressIndicator()); if (provider.isLoading) return const Center(child: CircularProgressIndicator());
if (provider.error != null) { if (provider.error != null) {
postNotifyUserOfErrorX( final loc = AppLocalizations.of(context)!;
context: context, return Center(
errorSituation: AppLocalizations.of(context)!.errorLogin, child: Column(
exception: provider.error!, 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)) { if ((provider.error == null) && (!provider.isOrganizationSet)) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {

View File

@@ -5,9 +5,6 @@ import 'package:provider/provider.dart';
import 'package:pshared/provider/account.dart'; import 'package:pshared/provider/account.dart';
import 'package:pshared/provider/permissions.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'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -15,26 +12,38 @@ class PermissionsLoader extends StatelessWidget {
final Widget child; final Widget child;
const PermissionsLoader({super.key, required this.child}); const PermissionsLoader({super.key, required this.child});
void _notifyError(BuildContext context, Exception exception) { void _triggerLoadIfNeeded(PermissionsProvider provider) {
WidgetsBinding.instance.addPostFrameCallback((_) { if (!provider.isLoading && !provider.isReady) {
postNotifyUserOfErrorX( WidgetsBinding.instance.addPostFrameCallback((_) {
context: context, if (!provider.isLoading && !provider.isReady) {
errorSituation: AppLocalizations.of(context)!.errorLogin, provider.load();
exception: exception, }
); });
navigateAndReplace(context, Pages.login); }
});
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer2<PermissionsProvider, AccountProvider>( return Consumer2<PermissionsProvider, AccountProvider>(
builder: (context, provider, accountProvider, _) { builder: (context, provider, _accountProvider, _) {
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (provider.error != null) { 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 const Center(child: CircularProgressIndicator());
} }
return child; return child;

View File

@@ -17,6 +17,7 @@ class TwoFactorProvider extends ChangeNotifier {
bool _hasError = false; bool _hasError = false;
bool _verificationSuccess = false; bool _verificationSuccess = false;
String? _errorMessage; String? _errorMessage;
String? _currentPendingToken;
bool get isSubmitting => _isSubmitting; bool get isSubmitting => _isSubmitting;
bool get hasError => _hasError; bool get hasError => _hasError;
@@ -26,6 +27,12 @@ class TwoFactorProvider extends ChangeNotifier {
void update(AccountProvider accountProvider) { void update(AccountProvider accountProvider) {
_accountProvider = accountProvider; _accountProvider = accountProvider;
final pending = accountProvider.pendingLogin;
final token = pending?.pendingToken.token;
if (token != _currentPendingToken || accountProvider.account == null) {
_resetState();
_currentPendingToken = token;
}
} }
Future<void> submitCode(String code) async { Future<void> submitCode(String code) async {
@@ -46,6 +53,7 @@ class TwoFactorProvider extends ChangeNotifier {
); );
_accountProvider.completePendingLogin(account); _accountProvider.completePendingLogin(account);
_verificationSuccess = true; _verificationSuccess = true;
_currentPendingToken = null;
} catch (e) { } catch (e) {
_hasError = true; _hasError = true;
_errorMessage = e.toString(); _errorMessage = e.toString();
@@ -71,4 +79,17 @@ class TwoFactorProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
} }
}
void reset() {
_resetState();
_currentPendingToken = null;
}
void _resetState() {
_isSubmitting = false;
_hasError = false;
_errorMessage = null;
_verificationSuccess = false;
notifyListeners();
}
}

View File

@@ -1,15 +1,31 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/provider/account.dart'; import 'package:pshared/provider/account.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/permissions.dart'; import 'package:pshared/provider/permissions.dart';
import 'package:pweb/app/router/pages.dart'; import 'package:pweb/app/router/pages.dart';
import 'package:pweb/providers/two_factor.dart';
void logoutUtil(BuildContext context) { Future<void> logoutUtil(BuildContext context) async {
context.read<AccountProvider>().logout(); final accountProvider = context.read<AccountProvider>();
context.read<PermissionsProvider>().reset(); final permissionsProvider = context.read<PermissionsProvider>();
navigateAndReplace(context, Pages.login); final organizationsProvider = context.read<OrganizationsProvider>();
final twoFactorProvider = context.read<TwoFactorProvider>();
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);
});
} }

View File

@@ -17,7 +17,7 @@ class PayoutAppBar extends StatelessWidget implements PreferredSizeWidget {
final Widget title; final Widget title;
final VoidCallback onAddFundsPressed; final VoidCallback onAddFundsPressed;
final List<Widget>? actions; final List<Widget>? actions;
final VoidCallback? onLogout; final Future<void> Function()? onLogout;
final String? avatarUrl; final String? avatarUrl;
@override @override

View File

@@ -7,7 +7,7 @@ class ProfileAvatar extends StatelessWidget {
const ProfileAvatar({super.key, this.avatarUrl, this.onLogout}); const ProfileAvatar({super.key, this.avatarUrl, this.onLogout});
final String? avatarUrl; final String? avatarUrl;
final VoidCallback? onLogout; final Future<void> Function()? onLogout;
@override @override
Widget build(BuildContext context) => PopupMenuButton<int>( Widget build(BuildContext context) => PopupMenuButton<int>(
@@ -37,4 +37,4 @@ class ProfileAvatar extends StatelessWidget {
child: avatarUrl == null ? const Icon(Icons.person, size: 24) : null, child: avatarUrl == null ? const Icon(Icons.person, size: 24) : null,
), ),
); );
} }

View File

@@ -1,10 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:pweb/utils/logout.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/app/router/pages.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -23,10 +19,8 @@ class LogoutTile extends StatelessWidget {
); );
} }
void _logout(BuildContext context) { Future<void> _logout(BuildContext context) async {
Navigator.pop(context); Navigator.pop(context);
final accountProvider = Provider.of<AccountProvider>(context, listen: false); await logoutUtil(context);
accountProvider.logout();
navigateAndReplace(context, Pages.login);
} }
} }