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