feat: Sign in with GitHub (#4881)
Implemented GitHub sign in button with ability to link Google account to it.
Fixes: https://github.com/flutter/flutter/issues/177627
There several possible scenarios:
1. User signing in to the dashboard with a Github account. In this scenario:
- Firebase account created based on Github account:
- User email: Github account primary email
- User avatar: Github account avatar
- User First and Last name: Github account First and Last name
- Google account can be linked using “Link Google Account” menu item
2. User signing in to the dashboard with a Github account and trying to link to a Google account that was never used before. In this scenario:
- After linking Google, Github account will be deleted from firebase and then linked to that Google account:
- User email: Google account primary email
- User avatar: Google account avatar
- User First and Last name: Google account First and Last name
- Github credential will be used for future signing in but “Unlink Google Account” menu item will be shown for user
3. User is trying to sign in with a Github account that has a primary email already registered as a Google account. In this scenario:
- Dashboard will ask the user to sign in with a Google account first (in most cases the account is already cached and the user would not see Sign in with Google popup).
- After signing in with Google, Github account automatically linked to that Google account
- Firebase account remains based on Google account:
- User email: Google account primary email
- User avatar: Google account avatar
- User First and Last name: Google account First and Last name
- Github credential will be used for future signing in but “Unlink Google Account” menu item will be shown for user
4. User signed in with a Github account that primary email was never registered but trying to link google account that already registered. In that scenario:
- Github needs to be deleted from firebase. In order to do that:
- To avoid [FirebaseAuthException] with requires-recent-login error code user will be automatically re signed in
- Then Github account will be deleted from firebase
- Then the dashboard will ask the user to sign in with a Google account (in most cases the account is already cached and the user would not see Sign in with Google popup).
- Lastly Github account automatically linked to that Google account
- Firebase account remains based on Google account:
- User email: Google account primary email
- User avatar: Google account avatar
- User First and Last name: Google account First and Last name
- Github credential will be used for future signing in but “Unlink Google Account” menu item will be shown for user.
4. If a user already signed with a Google account after dashboard update they would have the option to link Github Account to their Google account.diff --git a/dashboard/devtools_options.yaml b/dashboard/devtools_options.yaml
new file mode 100644
index 0000000..fa0b357
--- /dev/null
+++ b/dashboard/devtools_options.yaml
@@ -0,0 +1,3 @@
+description: This file stores settings for Dart & Flutter DevTools.
+documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
+extensions:
diff --git a/dashboard/lib/service/firebase_auth.dart b/dashboard/lib/service/firebase_auth.dart
index f4e554d..580d375 100644
--- a/dashboard/lib/service/firebase_auth.dart
+++ b/dashboard/lib/service/firebase_auth.dart
@@ -53,24 +53,205 @@
}
/// Initiate the Google Sign In process.
- Future<void> signIn() async {
+ Future<void> signInWithGoogle() async {
+ await _signInWithGoogle();
+ notifyListeners();
+ }
+
+ Future<void> _signInWithGoogle() async {
try {
- await _auth.signInWithProvider(GoogleAuthProvider());
- notifyListeners();
+ final userCredential = await _auth.signInWithPopup(GoogleAuthProvider());
+ _user = userCredential.user;
} catch (error) {
debugPrint('signin failed: $error');
}
}
+ /// Initiate the GitHub Sign In process.
+ Future<void> signInWithGithub() async {
+ await _signInWithGithub();
+ notifyListeners();
+ }
+
+ Future<void> _signInWithGithub() async {
+ try {
+ final userCredential = await _auth.signInWithPopup(GithubAuthProvider());
+ _user = userCredential.user;
+ } on FirebaseAuthException catch (error) {
+ // If email of Github account already registered in Frebase but with
+ // Google provider, we need to sign in with Google provider first,
+ // then link the GitHub provider to Google provider.
+ if (error.code == 'account-exists-with-different-credential') {
+ debugPrint('google account exists, signing in with google');
+ await _signInWithGoogle();
+ await _linkWithGithub();
+ return;
+ }
+ debugPrint('signin with github failed: $error');
+ } catch (error) {
+ debugPrint('signin with github failed: $error');
+ }
+ }
+
+ /// Sign out the currently signed in user.
Future<void> signOut() async {
+ await _signOut();
+ notifyListeners();
+ }
+
+ Future<void> _signOut() async {
try {
await _auth.signOut();
- notifyListeners();
+ _user = null;
} catch (error) {
debugPrint('signout error $error');
}
}
+ /// Link the Google provider to the currently signed in user.
+ ///
+ /// This method tries to keep Google account as primary provider.
+ Future<void> linkWithGoogle() async {
+ await _linkWithGoogle();
+ await _user?.getIdToken(true);
+ notifyListeners();
+ }
+
+ Future<void> _linkWithGoogle() async {
+ //Try to link google provider first.
+ try {
+ final userCredential = await _auth.currentUser?.linkWithPopup(
+ GoogleAuthProvider(),
+ );
+ _user = userCredential?.user;
+ notifyListeners();
+ await _auth.currentUser?.getIdToken(true);
+ } on FirebaseAuthException catch (error) {
+ // If Github account's credential already exists in firebase, we going to
+ // link google account to github.
+ if (error.code == 'credential-already-in-use') {
+ await _relinkGithubToGoogle();
+ return;
+ }
+ debugPrint('linkWithGoogle failed: $error');
+ } catch (error) {
+ debugPrint('linkWithGoogle failed: $error');
+ }
+ // If linking google succeeded, we need to unlink it and relink
+ // github to google to make google primary.
+ await _unlinkGoogle();
+ await _relinkGithubToGoogle();
+ }
+
+ Future<void> _relinkGithubToGoogle() async {
+ // We want to have Googole account Primary if present in firestore or linked,
+ // so we try to:
+ // 1. Delete GitHub account from firebase records, but to avoid
+ // **requires-recent-login** error we need to re-sign-in first;
+ try {
+ await _signOut();
+ await _signInWithGithub();
+ await FirebaseAuth.instance.currentUser?.delete();
+ } catch (e) {
+ debugPrint('delete user failed: $e');
+ return;
+ }
+
+ // 2. sign in with Google;
+ try {
+ await _signInWithGoogle();
+ } catch (error) {
+ debugPrint('signInWithGoogle failed: $error');
+ return;
+ }
+
+ // 3. then link GitHub account to existing Google account.
+ try {
+ await _linkWithGithub();
+ } catch (error) {
+ debugPrint('linkWithGoogle failed: $error');
+ }
+ }
+
+ /// Link the Github provider to the currently signed in user.
+ Future<void> linkWithGithub() async {
+ await _linkWithGithub();
+ await _user?.getIdToken(true);
+ notifyListeners();
+ }
+
+ Future<void> _linkWithGithub() async {
+ try {
+ final userCredential = await _auth.currentUser?.linkWithPopup(
+ GithubAuthProvider(),
+ );
+ _user = userCredential?.user;
+ } catch (error) {
+ debugPrint('linkWithGithub failed: $error');
+ //
+ }
+ }
+
+ /// Unlink the Github provider from the currently signed in user.
+ Future<void> unlinkGithub() async {
+ await _unlinkGithub();
+ await _user?.getIdToken(true);
+ notifyListeners();
+ }
+
+ Future<void> _unlinkGithub() async {
+ // Since google acount should be primary if linked to github,
+ // but single account should be github, after unlinking github we have to:
+ // delete google account and re-signin with github.
+ // 1. Unlink github provider
+ final provider = GithubAuthProvider();
+ try {
+ _user = await _auth.currentUser?.unlink(provider.providerId);
+ } catch (error) {
+ debugPrint('unlink ${provider.runtimeType} failed: $error');
+ return;
+ }
+
+ // 2. Delete Google account from firebase records, but to avoid
+ // **requires-recent-login** error we need to re-sign-in first;
+ try {
+ await _signOut();
+ await _signInWithGoogle();
+ await _auth.currentUser?.delete();
+ } catch (e) {
+ debugPrint('delete user failed: $e');
+ return;
+ }
+
+ // 3. sign in with Github;
+ try {
+ await _signOut();
+ await _signInWithGithub();
+ } catch (error) {
+ debugPrint('signInWithGithub failed: $error');
+ }
+ }
+
+ /// Unlink the Google provider from the currently signed in user.
+ ///
+ /// Only exists for some unxepected cases whend Github acccount appeared to be
+ /// primary.
+ Future<void> unlinkGoogle() async {
+ await _unlinkGoogle();
+ await _user?.getIdToken(true);
+ notifyListeners();
+ }
+
+ Future<void> _unlinkGoogle() async {
+ final provider = GoogleAuthProvider();
+ try {
+ _user = await _auth.currentUser?.unlink(provider.providerId);
+ } catch (error) {
+ debugPrint('unlink ${provider.runtimeType} failed: $error');
+ return;
+ }
+ }
+
/// Clears the active user from the service, without calling signOut on the plugin.
///
/// This refreshes the UI of the app, while making it easy for users to re-login.
diff --git a/dashboard/lib/widgets/sign_in_button/sign_in_button_native.dart b/dashboard/lib/widgets/sign_in_button/sign_in_button_native.dart
index 1c6d5f4..ac8328d 100644
--- a/dashboard/lib/widgets/sign_in_button/sign_in_button_native.dart
+++ b/dashboard/lib/widgets/sign_in_button/sign_in_button_native.dart
@@ -20,7 +20,7 @@
return TextButton(
style: TextButton.styleFrom(foregroundColor: textButtonForeground),
- onPressed: authService.signIn,
+ onPressed: authService.signInWithGithub,
child: const Text('SIGN IN'),
);
}
diff --git a/dashboard/lib/widgets/sign_in_button/sign_in_button_web.dart b/dashboard/lib/widgets/sign_in_button/sign_in_button_web.dart
index e32f9e3..c0ee17e 100644
--- a/dashboard/lib/widgets/sign_in_button/sign_in_button_web.dart
+++ b/dashboard/lib/widgets/sign_in_button/sign_in_button_web.dart
@@ -5,14 +5,15 @@
// import 'package:firebase_ui_oauth_google/firebase_ui_oauth_google.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
-
import 'package:google_sign_in/google_sign_in.dart'
show
GoogleSignIn,
GoogleSignInAuthenticationEventSignIn,
GoogleSignInAuthenticationEventSignOut;
+import 'package:provider/provider.dart';
+import 'package:sign_in_button/sign_in_button.dart' as sib;
-import 'package:google_sign_in_web/web_only.dart' as gsw;
+import '../../service/firebase_auth.dart';
/// Widget that users can click to initiate the Sign In process.
class SignInButton extends StatefulWidget {
@@ -56,11 +57,23 @@
@override
Widget build(BuildContext context) {
+ final authService = Provider.of<FirebaseAuthService>(context);
+
return FutureBuilder(
future: _initGoogleSignIn(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
- return gsw.renderButton();
+ return sib.SignInButton(
+ sib.Buttons.gitHub,
+ text: 'Sign in with GitHub',
+ onPressed: () {
+ authService.signInWithGithub();
+ },
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(8.0),
+ ),
+ );
+ // return gsw.renderButton();
}
return const CircularProgressIndicator();
},
diff --git a/dashboard/lib/widgets/user_sign_in.dart b/dashboard/lib/widgets/user_sign_in.dart
index 9a43aa3..7bb7f69 100644
--- a/dashboard/lib/widgets/user_sign_in.dart
+++ b/dashboard/lib/widgets/user_sign_in.dart
@@ -4,16 +4,24 @@
import 'dart:io';
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart'
+ show ImageRenderMethodForWeb;
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
-import 'package:google_sign_in/google_sign_in.dart';
import 'package:provider/provider.dart';
import '../service/firebase_auth.dart';
import 'sign_in_button/sign_in_button.dart';
-enum _SignInButtonAction { logout }
+enum _SignInButtonAction {
+ logout,
+ linkGithub,
+ unlinkGithub,
+ linkGoogle,
+ unlinkGoogle,
+}
/// Widget for displaying sign in information for the current user.
///
@@ -34,16 +42,24 @@
return PopupMenuButton<_SignInButtonAction>(
offset: const Offset(0, 50),
itemBuilder: (BuildContext context) =>
- <PopupMenuEntry<_SignInButtonAction>>[
- const PopupMenuItem<_SignInButtonAction>(
- value: _SignInButtonAction.logout,
- child: Text('Log out'),
- ),
- ],
+ _buildLinkUnlinkMenuItem(authService.user!.providerData),
onSelected: (_SignInButtonAction value) async {
switch (value) {
case _SignInButtonAction.logout:
await authService.signOut();
+ break;
+ case _SignInButtonAction.linkGithub:
+ await authService.linkWithGithub();
+ break;
+ case _SignInButtonAction.unlinkGithub:
+ await authService.unlinkGithub();
+ break;
+ case _SignInButtonAction.linkGoogle:
+ await authService.linkWithGoogle();
+ break;
+ case _SignInButtonAction.unlinkGoogle:
+ await authService.unlinkGoogle();
+ break;
}
},
iconSize: Scaffold.of(context).appBarMaxHeight,
@@ -56,8 +72,23 @@
child: Text(authService.user?.email ?? '[email protected]'),
);
}
- return GoogleUserCircleAvatar(
- identity: FirebaseUserIdentity(authService.user!),
+ return CircleAvatar(
+ foregroundImage: CachedNetworkImageProvider(
+ authService.user!.photoURL!,
+ imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
+ ),
+ child: Text(
+ <String?>[
+ authService.user!.displayName,
+ authService.user!.email,
+ '-',
+ ]
+ .firstWhere(
+ (String? str) => str?.trimLeft().isNotEmpty ?? false,
+ )!
+ .getUserInitials(),
+ textAlign: TextAlign.center,
+ ),
);
},
),
@@ -67,23 +98,91 @@
},
);
}
+
+ List<PopupMenuItem<_SignInButtonAction>> _buildLinkUnlinkMenuItem(
+ List<UserInfo> providerData,
+ ) {
+ final items = <PopupMenuItem<_SignInButtonAction>>[];
+ if (providerData.isNotEmpty) {
+ // One provider linked to firebase user. Show link option for the other.
+ if (providerData.length == 1) {
+ // Linked provider is Google. Show Link GitHub Account option.
+ if (providerData.first.providerId == GoogleAuthProvider.PROVIDER_ID) {
+ items.add(
+ const PopupMenuItem<_SignInButtonAction>(
+ value: _SignInButtonAction.linkGithub,
+ child: Text('Link GitHub Account'),
+ ),
+ );
+ }
+ // Linked provider is Github. Show Link Google Account option.
+ else if (providerData.first.providerId ==
+ GithubAuthProvider.PROVIDER_ID) {
+ items.add(
+ const PopupMenuItem<_SignInButtonAction>(
+ value: _SignInButtonAction.linkGoogle,
+ child: Text('Link Google Account'),
+ ),
+ );
+ }
+ }
+ // Two providers linked. Show unlink option.
+ else if (providerData.length >= 2) {
+ // The only way to figure out which account was linked is to check order.
+ // If last linked provider is Google. Allow unlinking Google Account.
+ // Thus Google is always primary oAuth provider keeep this option only
+ // for some unexpected cases.
+ if (providerData.last.providerId == GoogleAuthProvider.PROVIDER_ID) {
+ items.add(
+ const PopupMenuItem<_SignInButtonAction>(
+ value: _SignInButtonAction.unlinkGoogle,
+ child: Text('Unlink Google Account'),
+ ),
+ );
+ }
+ // Pretend we unlink Google account but actualy:
+ // - Unlink Github account.
+ // - Delete Google account.
+ // - Sign in with Github again.
+ else if (providerData.last.providerId ==
+ GithubAuthProvider.PROVIDER_ID) {
+ items.add(
+ const PopupMenuItem<_SignInButtonAction>(
+ value: _SignInButtonAction.unlinkGithub,
+ child: Text('Unlink Google Account'),
+ ),
+ );
+ }
+ }
+ }
+ // Always show logout option.
+ items.add(
+ const PopupMenuItem<_SignInButtonAction>(
+ value: _SignInButtonAction.logout,
+ child: Text('Log out'),
+ ),
+ );
+ return items;
+ }
}
-class FirebaseUserIdentity implements GoogleIdentity {
- FirebaseUserIdentity(this.user);
+extension on String {
+ static final RegExp _splitter = RegExp(r'[ ._-]+');
- final User user;
+ String getUserInitials() {
+ final parts =
+ split('@') // Split the email into local and domain parts
+ .first // Take only the local part (before the '@' symbol)
+ .split(_splitter); // Split string into a list of substrings
- @override
- String? get displayName => user.displayName;
+ // Extract the first character of each non-empty part and join them.
+ final result = parts
+ .where((part) => part.isNotEmpty) // Filter out empty strings from split
+ .map((part) => part[0]) // Get the first character of each part
+ .join() // Join the characters into a single string
+ .toUpperCase(); // Convert to upper case
- @override
- String get email => user.email!;
-
- @override
- String get id => '1234';
-
- @override
- // TODO: implement photoUrl
- String? get photoUrl => user.photoURL;
+ // Ensure no more than 2 characters are returned.
+ return result.length > 2 ? result.substring(0, 2) : result;
+ }
}
diff --git a/dashboard/pubspec.yaml b/dashboard/pubspec.yaml
index 0710499..7b4696a 100644
--- a/dashboard/pubspec.yaml
+++ b/dashboard/pubspec.yaml
@@ -11,6 +11,8 @@
sdk: ^3.9.0
dependencies:
+ cached_network_image: ^3.4.1
+ cached_network_image_platform_interface: ^4.1.1
cocoon_common:
path: ../packages/cocoon_common
collection: any # Match Flutter SDK
@@ -27,6 +29,7 @@
json_annotation: ^4.9.0
meta: any # Match Flutter SDK
provider: 6.1.5 # Rolled by dependabot
+ sign_in_button: ^4.0.1
truncate: 3.0.1 # Rolled by dependabot
url_launcher: 6.3.2 # Rolled by dependabot
url_launcher_platform_interface: 2.3.2 # Rolled by dependabot
diff --git a/dashboard/test/state/build_test.dart b/dashboard/test/state/build_test.dart
index 50491ed..f64b6d5 100644
--- a/dashboard/test/state/build_test.dart
+++ b/dashboard/test/state/build_test.dart
@@ -732,7 +732,7 @@
mockSignIn.authStateChanges(),
).thenAnswer((_) => const Stream<User>.empty());
when(
- mockSignIn.signInWithProvider(any),
+ mockSignIn.signInWithPopup(any),
).thenAnswer((_) async => MockUserCredential());
when(mockSignIn.signOut()).thenAnswer((_) async {});
final mockCocoonService = MockCocoonService();
@@ -768,7 +768,7 @@
await tester.pump(const Duration(seconds: 5));
- await signInService.signIn();
+ await signInService.signInWithGoogle();
expect(callCount, 1);
await signInService.signOut();
diff --git a/dashboard/test/utils/fake_firebase_user.dart b/dashboard/test/utils/fake_firebase_user.dart
index 8ad550e..5959afd 100644
--- a/dashboard/test/utils/fake_firebase_user.dart
+++ b/dashboard/test/utils/fake_firebase_user.dart
@@ -10,6 +10,9 @@
throw UnimplementedError();
}
+ final tokens = <String>[];
+ final _providerData = <UserInfo>[];
+
@override
String? get displayName => 'Dr. Test';
@@ -19,8 +22,6 @@
@override
bool get emailVerified => true;
- final tokens = <String>[];
-
@override
Future<String?> getIdToken([bool forceRefresh = false]) async {
if (tokens.isEmpty) return null;
@@ -77,7 +78,7 @@
'https://lh3.googleusercontent.com/-ukEAtRyRhw8/AAAAAAAAAAI/AAAAAAAAAAA/ACHi3rfhID9XACtdb9q_xK43VSXQvBV11Q.CMID';
@override
- List<UserInfo> get providerData => throw UnimplementedError();
+ List<UserInfo> get providerData => _providerData;
@override
Future<UserCredential> reauthenticateWithCredential(
diff --git a/dashboard/test/utils/mocks.mocks.dart b/dashboard/test/utils/mocks.mocks.dart
index 49c8c01..ec04475 100644
--- a/dashboard/test/utils/mocks.mocks.dart
+++ b/dashboard/test/utils/mocks.mocks.dart
@@ -33,6 +33,7 @@
// ignore_for_file: unnecessary_parenthesis
// ignore_for_file: camel_case_types
// ignore_for_file: subtype_of_sealed_class
+// ignore_for_file: invalid_use_of_internal_member
class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response {
_FakeResponse_0(Object parent, Invocation parentInvocation)
@@ -90,6 +91,12 @@
: super(parent, parentInvocation);
}
+class _FakePasswordValidationStatus_10 extends _i1.SmartFake
+ implements _i7.PasswordValidationStatus {
+ _FakePasswordValidationStatus_10(Object parent, Invocation parentInvocation)
+ : super(parent, parentInvocation);
+}
+
/// A class which mocks [Client].
///
/// See the documentation for Mockito's code generation for more information.
@@ -355,6 +362,57 @@
as _i8.Future<_i3.CocoonResponse<List<String>>>);
@override
+ _i8.Future<_i3.CocoonResponse<List<_i12.TreeStatusChange>>>
+ fetchTreeStatusChanges({required String? idToken, required String? repo}) =>
+ (super.noSuchMethod(
+ Invocation.method(#fetchTreeStatusChanges, [], {
+ #idToken: idToken,
+ #repo: repo,
+ }),
+ returnValue:
+ _i8.Future<
+ _i3.CocoonResponse<List<_i12.TreeStatusChange>>
+ >.value(
+ _FakeCocoonResponse_2<List<_i12.TreeStatusChange>>(
+ this,
+ Invocation.method(#fetchTreeStatusChanges, [], {
+ #idToken: idToken,
+ #repo: repo,
+ }),
+ ),
+ ),
+ )
+ as _i8.Future<_i3.CocoonResponse<List<_i12.TreeStatusChange>>>);
+
+ @override
+ _i8.Future<_i3.CocoonResponse<void>> updateTreeStatus({
+ required String? idToken,
+ required String? repo,
+ required _i12.TreeStatus? status,
+ String? reason,
+ }) =>
+ (super.noSuchMethod(
+ Invocation.method(#updateTreeStatus, [], {
+ #idToken: idToken,
+ #repo: repo,
+ #status: status,
+ #reason: reason,
+ }),
+ returnValue: _i8.Future<_i3.CocoonResponse<void>>.value(
+ _FakeCocoonResponse_2<void>(
+ this,
+ Invocation.method(#updateTreeStatus, [], {
+ #idToken: idToken,
+ #repo: repo,
+ #status: status,
+ #reason: reason,
+ }),
+ ),
+ ),
+ )
+ as _i8.Future<_i3.CocoonResponse<void>>);
+
+ @override
_i8.Future<_i3.CocoonResponse<bool>> rerunTask({
required String? idToken,
required String? taskName,
@@ -540,14 +598,14 @@
as _i5.Brook<String>);
@override
- set authService(_i4.FirebaseAuthService? _authService) => super.noSuchMethod(
- Invocation.setter(#authService, _authService),
+ set authService(_i4.FirebaseAuthService? value) => super.noSuchMethod(
+ Invocation.setter(#authService, value),
returnValueForMissingStub: null,
);
@override
- set refreshTimer(_i8.Timer? _refreshTimer) => super.noSuchMethod(
- Invocation.setter(#refreshTimer, _refreshTimer),
+ set refreshTimer(_i8.Timer? value) => super.noSuchMethod(
+ Invocation.setter(#refreshTimer, value),
returnValueForMissingStub: null,
);
@@ -645,9 +703,18 @@
as bool);
@override
- _i8.Future<void> signIn() =>
+ _i8.Future<void> signInWithGoogle() =>
(super.noSuchMethod(
- Invocation.method(#signIn, []),
+ Invocation.method(#signInWithGoogle, []),
+ returnValue: _i8.Future<void>.value(),
+ returnValueForMissingStub: _i8.Future<void>.value(),
+ )
+ as _i8.Future<void>);
+
+ @override
+ _i8.Future<void> signInWithGithub() =>
+ (super.noSuchMethod(
+ Invocation.method(#signInWithGithub, []),
returnValue: _i8.Future<void>.value(),
returnValueForMissingStub: _i8.Future<void>.value(),
)
@@ -663,6 +730,42 @@
as _i8.Future<void>);
@override
+ _i8.Future<void> linkWithGoogle() =>
+ (super.noSuchMethod(
+ Invocation.method(#linkWithGoogle, []),
+ returnValue: _i8.Future<void>.value(),
+ returnValueForMissingStub: _i8.Future<void>.value(),
+ )
+ as _i8.Future<void>);
+
+ @override
+ _i8.Future<void> linkWithGithub() =>
+ (super.noSuchMethod(
+ Invocation.method(#linkWithGithub, []),
+ returnValue: _i8.Future<void>.value(),
+ returnValueForMissingStub: _i8.Future<void>.value(),
+ )
+ as _i8.Future<void>);
+
+ @override
+ _i8.Future<void> unlinkGithub() =>
+ (super.noSuchMethod(
+ Invocation.method(#unlinkGithub, []),
+ returnValue: _i8.Future<void>.value(),
+ returnValueForMissingStub: _i8.Future<void>.value(),
+ )
+ as _i8.Future<void>);
+
+ @override
+ _i8.Future<void> unlinkGoogle() =>
+ (super.noSuchMethod(
+ Invocation.method(#unlinkGoogle, []),
+ returnValue: _i8.Future<void>.value(),
+ returnValueForMissingStub: _i8.Future<void>.value(),
+ )
+ as _i8.Future<void>);
+
+ @override
_i8.Future<void> clearUser() =>
(super.noSuchMethod(
Invocation.method(#clearUser, []),
@@ -713,8 +816,8 @@
as _i6.FirebaseApp);
@override
- set app(_i6.FirebaseApp? _app) => super.noSuchMethod(
- Invocation.setter(#app, _app),
+ set app(_i6.FirebaseApp? value) => super.noSuchMethod(
+ Invocation.setter(#app, value),
returnValueForMissingStub: null,
);
@@ -815,14 +918,6 @@
as _i8.Future<_i7.UserCredential>);
@override
- _i8.Future<List<String>> fetchSignInMethodsForEmail(String? email) =>
- (super.noSuchMethod(
- Invocation.method(#fetchSignInMethodsForEmail, [email]),
- returnValue: _i8.Future<List<String>>.value(<String>[]),
- )
- as _i8.Future<List<String>>);
-
- @override
_i8.Future<_i7.UserCredential> getRedirectResult() =>
(super.noSuchMethod(
Invocation.method(#getRedirectResult, []),
@@ -1079,15 +1174,6 @@
as _i8.Future<void>);
@override
- _i8.Future<void> signOut() =>
- (super.noSuchMethod(
- Invocation.method(#signOut, []),
- returnValue: _i8.Future<void>.value(),
- returnValueForMissingStub: _i8.Future<void>.value(),
- )
- as _i8.Future<void>);
-
- @override
_i8.Future<String> verifyPasswordResetCode(String? code) =>
(super.noSuchMethod(
Invocation.method(#verifyPasswordResetCode, [code]),
@@ -1143,6 +1229,40 @@
returnValueForMissingStub: _i8.Future<void>.value(),
)
as _i8.Future<void>);
+
+ @override
+ _i8.Future<void> signOut() =>
+ (super.noSuchMethod(
+ Invocation.method(#signOut, []),
+ returnValue: _i8.Future<void>.value(),
+ returnValueForMissingStub: _i8.Future<void>.value(),
+ )
+ as _i8.Future<void>);
+
+ @override
+ _i8.Future<void> initializeRecaptchaConfig() =>
+ (super.noSuchMethod(
+ Invocation.method(#initializeRecaptchaConfig, []),
+ returnValue: _i8.Future<void>.value(),
+ returnValueForMissingStub: _i8.Future<void>.value(),
+ )
+ as _i8.Future<void>);
+
+ @override
+ _i8.Future<_i7.PasswordValidationStatus> validatePassword(
+ _i7.FirebaseAuth? auth,
+ String? password,
+ ) =>
+ (super.noSuchMethod(
+ Invocation.method(#validatePassword, [auth, password]),
+ returnValue: _i8.Future<_i7.PasswordValidationStatus>.value(
+ _FakePasswordValidationStatus_10(
+ this,
+ Invocation.method(#validatePassword, [auth, password]),
+ ),
+ ),
+ )
+ as _i8.Future<_i7.PasswordValidationStatus>);
}
/// A class which mocks [UserCredential].
diff --git a/dashboard/test/widgets/user_sign_in_test.dart b/dashboard/test/widgets/user_sign_in_test.dart
index 89a70a0..b1ef155 100644
--- a/dashboard/test/widgets/user_sign_in_test.dart
+++ b/dashboard/test/widgets/user_sign_in_test.dart
@@ -2,12 +2,12 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dashboard/service/firebase_auth.dart';
import 'package:flutter_dashboard/widgets/state_provider.dart';
import 'package:flutter_dashboard/widgets/user_sign_in.dart';
import 'package:flutter_test/flutter_test.dart';
-import 'package:google_sign_in/widgets.dart';
import 'package:mockito/mockito.dart';
import '../utils/fake_firebase_user.dart';
@@ -44,7 +44,7 @@
);
await tester.pump();
- expect(find.byType(GoogleUserCircleAvatar), findsNothing);
+ expect(find.byType(CircleAvatar), findsNothing);
expect(find.text('SIGN IN'), findsOneWidget);
expect(find.text('[email protected]'), findsNothing);
await expectGoldenMatches(
@@ -67,12 +67,11 @@
);
await tester.pump();
- verifyNever(mockAuthService.signIn());
-
+ verifyNever(mockAuthService.signInWithGithub());
await tester.tap(find.text('SIGN IN'));
await tester.pump();
- verify(mockAuthService.signIn()).called(1);
+ verify(mockAuthService.signInWithGithub()).called(1);
});
testWidgets('SignInButton shows avatar when authenticated', (
@@ -124,4 +123,160 @@
verify(mockAuthService.signOut()).called(1);
});
+
+ testWidgets('SignInButton let link google account when authenticated', (
+ WidgetTester tester,
+ ) async {
+ when(mockAuthService.isAuthenticated).thenReturn(true);
+
+ final user = FakeFirebaseUser();
+ user.providerData.add(
+ UserInfo.fromJson({
+ 'providerId': GithubAuthProvider.PROVIDER_ID,
+ 'uid': 'qwerty12345',
+ 'isAnonymous': false,
+ 'isEmailVerified': true,
+ }),
+ );
+
+ when(mockAuthService.user).thenReturn(user);
+
+ await tester.pumpWidget(
+ ValueProvider<FirebaseAuthService?>(
+ value: mockAuthService,
+ child: testApp,
+ ),
+ );
+ await tester.pump();
+
+ await tester.tap(find.byType(UserSignIn));
+ await tester.pumpAndSettle();
+
+ verifyNever(mockAuthService.linkWithGoogle());
+
+ await tester.tap(find.text('Link Google Account'));
+
+ verify(mockAuthService.linkWithGoogle()).called(1);
+ });
+
+ testWidgets('SignInButton let link github account when authenticated', (
+ WidgetTester tester,
+ ) async {
+ when(mockAuthService.isAuthenticated).thenReturn(true);
+
+ final user = FakeFirebaseUser();
+ user.providerData.add(
+ UserInfo.fromJson({
+ 'providerId': GoogleAuthProvider.PROVIDER_ID,
+ 'uid': 'qwerty12345',
+ 'isAnonymous': false,
+ 'isEmailVerified': true,
+ }),
+ );
+
+ when(mockAuthService.user).thenReturn(user);
+
+ await tester.pumpWidget(
+ ValueProvider<FirebaseAuthService?>(
+ value: mockAuthService,
+ child: testApp,
+ ),
+ );
+ await tester.pump();
+
+ await tester.tap(find.byType(UserSignIn));
+ await tester.pumpAndSettle();
+
+ verifyNever(mockAuthService.linkWithGithub());
+
+ await tester.tap(find.text('Link GitHub Account'));
+
+ verify(mockAuthService.linkWithGithub()).called(1);
+ });
+
+ testWidgets('SignInButton let unlink google account when github primary', (
+ WidgetTester tester,
+ ) async {
+ when(mockAuthService.isAuthenticated).thenReturn(true);
+
+ final user = FakeFirebaseUser();
+ user.providerData.add(
+ UserInfo.fromJson({
+ 'providerId': GithubAuthProvider.PROVIDER_ID,
+ 'uid': 'qwerty12345',
+ 'isAnonymous': false,
+ 'isEmailVerified': true,
+ }),
+ );
+ user.providerData.add(
+ UserInfo.fromJson({
+ 'providerId': GoogleAuthProvider.PROVIDER_ID,
+ 'uid': 'asdfgh67890',
+ 'isAnonymous': false,
+ 'isEmailVerified': true,
+ }),
+ );
+
+ when(mockAuthService.user).thenReturn(user);
+
+ await tester.pumpWidget(
+ ValueProvider<FirebaseAuthService?>(
+ value: mockAuthService,
+ child: testApp,
+ ),
+ );
+ await tester.pump();
+
+ await tester.tap(find.byType(UserSignIn));
+ await tester.pumpAndSettle();
+
+ verifyNever(mockAuthService.unlinkGoogle());
+
+ await tester.tap(find.text('Unlink Google Account'));
+
+ verify(mockAuthService.unlinkGoogle()).called(1);
+ });
+
+ testWidgets('SignInButton let unlink google account when google primary', (
+ WidgetTester tester,
+ ) async {
+ when(mockAuthService.isAuthenticated).thenReturn(true);
+
+ final user = FakeFirebaseUser();
+ user.providerData.add(
+ UserInfo.fromJson({
+ 'providerId': GoogleAuthProvider.PROVIDER_ID,
+ 'uid': 'qwerty12345',
+ 'isAnonymous': false,
+ 'isEmailVerified': true,
+ }),
+ );
+ user.providerData.add(
+ UserInfo.fromJson({
+ 'providerId': GithubAuthProvider.PROVIDER_ID,
+ 'uid': 'asdfgh67890',
+ 'isAnonymous': false,
+ 'isEmailVerified': true,
+ }),
+ );
+
+ when(mockAuthService.user).thenReturn(user);
+
+ await tester.pumpWidget(
+ ValueProvider<FirebaseAuthService?>(
+ value: mockAuthService,
+ child: testApp,
+ ),
+ );
+ await tester.pump();
+
+ await tester.tap(find.byType(UserSignIn));
+ await tester.pumpAndSettle();
+
+ verifyNever(mockAuthService.unlinkGithub());
+
+ await tester.tap(find.text('Unlink Google Account'));
+
+ verify(mockAuthService.unlinkGithub()).called(1);
+ });
}