mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:27:15 +01:00
sync_settings_ui: fix Connect Firebase hanging forever on a bad password
connectWithPassword() had no try/catch around firebaseFactory(), unlike
connectWithGoogle() right next to it. A thrown FirebaseAuthError (wrong
password, 400 from identitytoolkit) escaped uncaught out of the button's
async onPressed, so _busy/_status never updated and the screen sat on
"Signing in..." forever with no error shown -- reproduced live via the
browser console (confirmed "Uncaught Error" immediately after the 400,
not a hung Future).
Also reports real progress stages ("Saving account...", "Signing in to
Firebase...") instead of one static string, clears the bad password on a
confirmed rejection (previously only the null-client branch did, letting a
background sync tick keep retrying it), and surfaces the actual failure
reason in the status text instead of a generic "rejected" message.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XATAXuMzpYggWyTBPGyiSN
This commit is contained in:
parent
d22417f41b
commit
26d7507fff
@ -1,3 +1,5 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
|
||||
/// Outcome of a Firebase connect attempt, distinguishing why it did or did
|
||||
@ -127,22 +129,77 @@ class FirebaseSyncController {
|
||||
|
||||
/// Stores [email]/[password] and signs in immediately, so a typo surfaces
|
||||
/// here instead of as a silent background failure on the next sync tick.
|
||||
///
|
||||
/// [onProgress], if given, is called with a human-readable stage name as
|
||||
/// each awaited step starts, so a caller can show the user what is
|
||||
/// actually happening instead of one static "Signing in..." the whole
|
||||
/// time -- and, if it stalls, which step it stalled on.
|
||||
///
|
||||
/// Wraps [firebaseFactory] the same way [connectWithGoogle] already does:
|
||||
/// broader than Exception on purpose, matching every app-local copy -- a
|
||||
/// missing platform binding raises a FlutterError, which is an Error, not
|
||||
/// an Exception -- letting it escape left the button disabled and the
|
||||
/// screen stuck on "Signing in..." forever. That fix existed for the
|
||||
/// Google path but was never mirrored here.
|
||||
Future<FirebaseConnectResult> connectWithPassword({
|
||||
required String email,
|
||||
required String password,
|
||||
void Function(String stage)? onProgress,
|
||||
}) async {
|
||||
onProgress?.call('Saving account…');
|
||||
await accountSaver(FirebaseAccount(email: email, password: password));
|
||||
final client = await firebaseFactory();
|
||||
if (client == null) {
|
||||
try {
|
||||
onProgress?.call('Signing in to Firebase…');
|
||||
final client = await firebaseFactory();
|
||||
if (client == null) {
|
||||
log(
|
||||
'connectWithPassword: rejected (null client) for $email',
|
||||
name: 'FirebaseSyncController',
|
||||
level: 900,
|
||||
);
|
||||
await accountClearer();
|
||||
return const FirebaseConnectResult(
|
||||
outcome: FirebaseConnectOutcome.rejected,
|
||||
);
|
||||
}
|
||||
log(
|
||||
'connectWithPassword: connected as $email',
|
||||
name: 'FirebaseSyncController',
|
||||
);
|
||||
return FirebaseConnectResult(
|
||||
outcome: FirebaseConnectOutcome.connected,
|
||||
email: email,
|
||||
);
|
||||
} on FirebaseAuthError catch (error, stackTrace) {
|
||||
log(
|
||||
'connectWithPassword: rejected for $email',
|
||||
name: 'FirebaseSyncController',
|
||||
level: 900,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
// A confirmed-bad password shouldn't linger in the keystore for a
|
||||
// background sync tick to keep retrying against.
|
||||
await accountClearer();
|
||||
return const FirebaseConnectResult(
|
||||
return FirebaseConnectResult(
|
||||
outcome: FirebaseConnectOutcome.rejected,
|
||||
message: error.message,
|
||||
);
|
||||
} on Object catch (error, stackTrace) {
|
||||
log(
|
||||
'connectWithPassword: failed for $email',
|
||||
name: 'FirebaseSyncController',
|
||||
level: 1000,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
// Not cleared: this could be a transient failure (network, wrapper
|
||||
// hiccup), not proof the credentials themselves are wrong.
|
||||
return FirebaseConnectResult(
|
||||
outcome: FirebaseConnectOutcome.failed,
|
||||
message: '$error',
|
||||
);
|
||||
}
|
||||
return FirebaseConnectResult(
|
||||
outcome: FirebaseConnectOutcome.connected,
|
||||
email: email,
|
||||
);
|
||||
}
|
||||
|
||||
/// Signs in by picking a Google account -- the one-tap path. Callers must
|
||||
|
||||
@ -58,13 +58,13 @@ class _SyncSettingsScreenState extends State<SyncSettingsScreen> {
|
||||
setState(() => _status = 'Enter the sync account email and password.');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_status = 'Signing in…';
|
||||
});
|
||||
setState(() => _busy = true);
|
||||
final result = await _firebase.connectWithPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
onProgress: (stage) {
|
||||
if (mounted) setState(() => _status = stage);
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
_applyConnectResult(result, clearPasswordOnSuccess: true);
|
||||
@ -94,14 +94,15 @@ class _SyncSettingsScreenState extends State<SyncSettingsScreen> {
|
||||
_firebaseConnected = result.outcome == FirebaseConnectOutcome.connected;
|
||||
_status = switch (result.outcome) {
|
||||
FirebaseConnectOutcome.connected => 'Connected to Firebase.',
|
||||
FirebaseConnectOutcome.rejected => 'Firebase rejected that account.',
|
||||
FirebaseConnectOutcome.rejected => result.message != null
|
||||
? 'Firebase rejected that account: ${result.message}'
|
||||
: 'Firebase rejected that account.',
|
||||
FirebaseConnectOutcome.cancelled => 'Google sign-in was cancelled.',
|
||||
FirebaseConnectOutcome.signedInButNotPersisted =>
|
||||
'Signed in, but this device did not save the session - it will '
|
||||
'sync over GitHub after a restart. Try connecting again.',
|
||||
FirebaseConnectOutcome.wrongAccount => result.message,
|
||||
FirebaseConnectOutcome.failed =>
|
||||
'Google sign-in failed: ${result.message}',
|
||||
FirebaseConnectOutcome.failed => 'Sign-in failed: ${result.message}',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ description: >-
|
||||
injected closures -- no keystore or platform plugin access lives in this
|
||||
package itself.
|
||||
publish_to: 'none'
|
||||
version: 0.3.0
|
||||
version: 0.4.0
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
@ -98,6 +98,70 @@ void main() {
|
||||
expect(cleared, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'clears the account and reports rejected with the error message when '
|
||||
'firebaseFactory throws FirebaseAuthError -- reproduces the screen '
|
||||
'getting stuck on "Signing in..." forever, since nothing here used '
|
||||
'to catch this',
|
||||
() async {
|
||||
var cleared = false;
|
||||
final controller = FirebaseSyncController(
|
||||
accountLoader: () async => null,
|
||||
accountSaver: (_) async {},
|
||||
accountClearer: () async => cleared = true,
|
||||
sessionProbe: () async => false,
|
||||
firebaseFactory: () async => throw FirebaseAuthError('wrong password'),
|
||||
);
|
||||
final result = await controller.connectWithPassword(
|
||||
email: 'a@b.com',
|
||||
password: 'wrong',
|
||||
);
|
||||
expect(result.outcome, FirebaseConnectOutcome.rejected);
|
||||
expect(result.message, 'wrong password');
|
||||
expect(cleared, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'reports failed with the stringified error, and does not clear the '
|
||||
'account, on any other failure -- a possibly-transient error is not '
|
||||
'proof the credentials are wrong',
|
||||
() async {
|
||||
var cleared = false;
|
||||
final controller = FirebaseSyncController(
|
||||
accountLoader: () async => null,
|
||||
accountSaver: (_) async {},
|
||||
accountClearer: () async => cleared = true,
|
||||
sessionProbe: () async => false,
|
||||
firebaseFactory: () async => throw StateError('boom'),
|
||||
);
|
||||
final result = await controller.connectWithPassword(
|
||||
email: 'a@b.com',
|
||||
password: 'pw',
|
||||
);
|
||||
expect(result.outcome, FirebaseConnectOutcome.failed);
|
||||
expect(result.message, contains('boom'));
|
||||
expect(cleared, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('reports progress stages in order on success', () async {
|
||||
final stages = <String>[];
|
||||
final controller = FirebaseSyncController(
|
||||
accountLoader: () async => null,
|
||||
accountSaver: (_) async {},
|
||||
accountClearer: () async {},
|
||||
sessionProbe: () async => true,
|
||||
firebaseFactory: () async => _fakeClient(),
|
||||
);
|
||||
await controller.connectWithPassword(
|
||||
email: 'a@b.com',
|
||||
password: 'pw',
|
||||
onProgress: stages.add,
|
||||
);
|
||||
expect(stages, ['Saving account…', 'Signing in to Firebase…']);
|
||||
});
|
||||
});
|
||||
|
||||
group('connectWithGoogle', () {
|
||||
|
||||
@ -116,7 +116,7 @@ void main() {
|
||||
await tester.tap(find.text('Sign in with Google'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Google sign-in failed'), findsOneWidget);
|
||||
expect(find.textContaining('Sign-in failed'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
|
||||
@ -166,6 +166,48 @@ void main() {
|
||||
expect(find.text('Firebase rejected that account.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'connect with password does not get stuck on "Signing in..." when '
|
||||
'the factory throws -- reproduces the reported hang, where the '
|
||||
'button stayed disabled and the status text never changed',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
SyncSettingsScreen(
|
||||
accountLoader: () async => null,
|
||||
accountSaver: (_) async {},
|
||||
accountClearer: () async {},
|
||||
sessionProbe: () async => false,
|
||||
firebaseFactory: () async =>
|
||||
throw FirebaseAuthError('bad creds'),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Sync account email'),
|
||||
'a@b.com',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Sync account password'),
|
||||
'wrong',
|
||||
);
|
||||
await tester.tap(find.text('Connect Firebase'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Signing in to Firebase…'), findsNothing);
|
||||
expect(
|
||||
find.text('Firebase rejected that account: bad creds'),
|
||||
findsOneWidget,
|
||||
);
|
||||
final button = tester.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, 'Connect Firebase'),
|
||||
);
|
||||
expect(button.onPressed, isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'shows the Google button when googleAvailable, and connecting '
|
||||
'succeeds',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user