diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 91d594f30d..3b32727aeb 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -25,6 +25,14 @@
+
+
+
+
+
+
+
+
diff --git a/lib/api/route/realm.dart b/lib/api/route/realm.dart
index 890709316e..7b6f9c2b7f 100644
--- a/lib/api/route/realm.dart
+++ b/lib/api/route/realm.dart
@@ -28,10 +28,32 @@ Future getServerSettings({
}
}
+@JsonSerializable(fieldRename: FieldRename.snake)
+class ExternalAuthenticationMethod {
+ final String name;
+ final String displayName;
+ final String? displayIcon;
+ final String loginUrl;
+ final String signupUrl;
+
+ ExternalAuthenticationMethod({
+ required this.name,
+ required this.displayName,
+ this.displayIcon,
+ required this.loginUrl,
+ required this.signupUrl,
+ });
+
+ factory ExternalAuthenticationMethod.fromJson(Map json) =>
+ _$ExternalAuthenticationMethodFromJson(json);
+
+ Map toJson() => _$ExternalAuthenticationMethodToJson(this);
+}
+
@JsonSerializable(fieldRename: FieldRename.snake)
class GetServerSettingsResult {
final Map authenticationMethods;
- // final List external_authentication_methods; // TODO handle
+ final List externalAuthenticationMethods;
final int zulipFeatureLevel;
final String zulipVersion;
@@ -44,12 +66,13 @@ class GetServerSettingsResult {
final bool requireEmailFormatUsernames;
final Uri realmUri;
final String realmName;
- final String realmIcon;
+ final Uri? realmIcon;
final String realmDescription;
final bool? realmWebPublicAccessEnabled; // TODO(server-5)
GetServerSettingsResult({
required this.authenticationMethods,
+ required this.externalAuthenticationMethods,
required this.zulipFeatureLevel,
required this.zulipVersion,
this.zulipMergeBase,
diff --git a/lib/api/route/realm.g.dart b/lib/api/route/realm.g.dart
index a7bea0c493..cd571ab410 100644
--- a/lib/api/route/realm.g.dart
+++ b/lib/api/route/realm.g.dart
@@ -8,11 +8,36 @@ part of 'realm.dart';
// JsonSerializableGenerator
// **************************************************************************
+ExternalAuthenticationMethod _$ExternalAuthenticationMethodFromJson(
+ Map json) =>
+ ExternalAuthenticationMethod(
+ name: json['name'] as String,
+ displayName: json['display_name'] as String,
+ displayIcon: json['display_icon'] as String?,
+ loginUrl: json['login_url'] as String,
+ signupUrl: json['signup_url'] as String,
+ );
+
+Map _$ExternalAuthenticationMethodToJson(
+ ExternalAuthenticationMethod instance) =>
+ {
+ 'name': instance.name,
+ 'display_name': instance.displayName,
+ 'display_icon': instance.displayIcon,
+ 'login_url': instance.loginUrl,
+ 'signup_url': instance.signupUrl,
+ };
+
GetServerSettingsResult _$GetServerSettingsResultFromJson(
Map json) =>
GetServerSettingsResult(
authenticationMethods:
Map.from(json['authentication_methods'] as Map),
+ externalAuthenticationMethods: (json['external_authentication_methods']
+ as List)
+ .map((e) =>
+ ExternalAuthenticationMethod.fromJson(e as Map))
+ .toList(),
zulipFeatureLevel: json['zulip_feature_level'] as int,
zulipVersion: json['zulip_version'] as String,
zulipMergeBase: json['zulip_merge_base'] as String?,
@@ -23,7 +48,9 @@ GetServerSettingsResult _$GetServerSettingsResultFromJson(
json['require_email_format_usernames'] as bool,
realmUri: Uri.parse(json['realm_uri'] as String),
realmName: json['realm_name'] as String,
- realmIcon: json['realm_icon'] as String,
+ realmIcon: json['realm_icon'] == null
+ ? null
+ : Uri.parse(json['realm_icon'] as String),
realmDescription: json['realm_description'] as String,
realmWebPublicAccessEnabled:
json['realm_web_public_access_enabled'] as bool?,
@@ -33,6 +60,7 @@ Map _$GetServerSettingsResultToJson(
GetServerSettingsResult instance) =>
{
'authentication_methods': instance.authenticationMethods,
+ 'external_authentication_methods': instance.externalAuthenticationMethods,
'zulip_feature_level': instance.zulipFeatureLevel,
'zulip_version': instance.zulipVersion,
'zulip_merge_base': instance.zulipMergeBase,
@@ -42,7 +70,7 @@ Map _$GetServerSettingsResultToJson(
'require_email_format_usernames': instance.requireEmailFormatUsernames,
'realm_uri': instance.realmUri.toString(),
'realm_name': instance.realmName,
- 'realm_icon': instance.realmIcon,
+ 'realm_icon': instance.realmIcon?.toString(),
'realm_description': instance.realmDescription,
'realm_web_public_access_enabled': instance.realmWebPublicAccessEnabled,
};
diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart
index 3d054ad0c6..83b381af11 100644
--- a/lib/widgets/app.dart
+++ b/lib/widgets/app.dart
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../model/narrow.dart';
import 'about_zulip.dart';
import 'login.dart';
+import 'login/browser_login.dart';
import 'message_list.dart';
import 'page.dart';
import 'recent_dm_conversations.dart';
@@ -25,10 +26,29 @@ class ZulipApp extends StatelessWidget {
// https://m3.material.io/theme-builder#/custom
colorScheme: ColorScheme.fromSeed(seedColor: kZulipBrandColor));
return GlobalStoreWidget(
- child: MaterialApp(
- title: 'Zulip',
- theme: theme,
- home: const ChooseAccountPage()));
+ child: BrowserLoginWidget(
+ child: Builder(
+ builder: (context) => MaterialApp(
+ title: 'Zulip',
+ theme: theme,
+ home: const ChooseAccountPage(),
+ navigatorKey: BrowserLoginWidget.of(context).navigatorKey,
+ // TODO: Migrate to `MaterialApp.router` & `Router`, so that we can receive
+ // a full Uri instead of just path+query components and also maybe
+ // remove the InheritedWidget + navigatorKey hack.
+ // See docs:
+ // https://api.flutter.dev/flutter/widgets/Router-class.html
+ onGenerateRoute: (settings) {
+ if (settings.name == null) return null;
+ final uri = Uri.parse(settings.name!);
+ if (uri.queryParameters.containsKey('otp_encrypted_api_key')) {
+ BrowserLoginWidget.of(context).loginFromExternalRoute(context, uri);
+ return null;
+ }
+ return null;
+ })),
+ ),
+ );
}
}
diff --git a/lib/widgets/login.dart b/lib/widgets/login.dart
index ec140c0474..a765a6b327 100644
--- a/lib/widgets/login.dart
+++ b/lib/widgets/login.dart
@@ -9,11 +9,12 @@ import '../model/store.dart';
import 'app.dart';
import 'dialog.dart';
import 'input.dart';
+import 'login/browser_login.dart';
import 'page.dart';
import 'store.dart';
-class _LoginSequenceRoute extends MaterialWidgetRoute {
- _LoginSequenceRoute({
+class LoginSequenceRoute extends MaterialWidgetRoute {
+ LoginSequenceRoute({
required super.page,
});
}
@@ -102,7 +103,7 @@ class AddAccountPage extends StatefulWidget {
const AddAccountPage({super.key});
static Route buildRoute() {
- return _LoginSequenceRoute(page: const AddAccountPage());
+ return LoginSequenceRoute(page: const AddAccountPage());
}
@override
@@ -167,9 +168,8 @@ class _AddAccountPageState extends State {
return;
}
- // TODO(#36): support login methods beyond username/password
Navigator.push(context,
- PasswordLoginPage.buildRoute(serverSettings: serverSettings));
+ AuthMethodsPage.buildRoute(serverSettings: serverSettings));
} finally {
setState(() {
_inProgress = false;
@@ -225,13 +225,89 @@ class _AddAccountPageState extends State {
}
}
+class AuthMethodsPage extends StatefulWidget {
+ const AuthMethodsPage({super.key, required this.serverSettings});
+
+ final GetServerSettingsResult serverSettings;
+
+ static Route buildRoute({required GetServerSettingsResult serverSettings}) {
+ return LoginSequenceRoute(
+ page: AuthMethodsPage(serverSettings: serverSettings));
+ }
+
+ @override
+ State createState() => _AuthMethodsPageState();
+}
+
+class _AuthMethodsPageState extends State {
+ // TODO: Remove this list when all the methods are tested,
+ // or update to add a new method.
+ static const Set _testedAuthMethods = {
+ 'github',
+ 'gitlab',
+ 'google',
+ };
+
+ Future _openBrowserLogin(ExternalAuthenticationMethod method) =>
+ BrowserLoginWidget.of(context).openLoginUrl(widget.serverSettings, method.loginUrl);
+
+ @override
+ Widget build(BuildContext context) {
+ // 'realmIcon' for chat.zulip.org, only contains the path component.
+ // So, resolve it to the 'realmUri' to get the full Uri with host.
+ final Uri? iconUrl = widget.serverSettings.realmIcon != null
+ ? widget.serverSettings.realmUri.resolveUri(widget.serverSettings.realmIcon!)
+ : null;
+
+ return Scaffold(
+ appBar: AppBar(title: const Text('Log in')),
+ body: SafeArea(
+ child: ListView(
+ padding: const EdgeInsets.all(8),
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ if (iconUrl != null) ...[
+ Image.network(
+ iconUrl.toString(),
+ key: const Key('realm_icon'),
+ width: 48,
+ height: 48),
+ const SizedBox(width: 8),
+ ],
+ Text(widget.serverSettings.realmName, style: const TextStyle(fontSize: 20)),
+ ]),
+ ),
+ if (widget.serverSettings.emailAuthEnabled)
+ OutlinedButton(
+ onPressed: () => Navigator.push(context, PasswordLoginPage.buildRoute(serverSettings: widget.serverSettings)),
+ child: const Text('Sign in with password')),
+ ...widget.serverSettings.externalAuthenticationMethods.map(
+ (authMethod) => switch (authMethod.displayIcon) {
+ null || '' => OutlinedButton(
+ onPressed: _testedAuthMethods.contains(authMethod.name) ? () => _openBrowserLogin(authMethod) : null,
+ child: Text('Sign in with ${authMethod.displayName}'),
+ ),
+ final displayIcon => OutlinedButton.icon(
+ onPressed: _testedAuthMethods.contains(authMethod.name) ? () => _openBrowserLogin(authMethod) : null,
+ icon: Image.network(displayIcon, width: 24, height: 24),
+ label: Text('Sign in with ${authMethod.displayName}'),
+ ),
+ }).toList(),
+ ])));
+ }
+}
+
class PasswordLoginPage extends StatefulWidget {
const PasswordLoginPage({super.key, required this.serverSettings});
final GetServerSettingsResult serverSettings;
static Route buildRoute({required GetServerSettingsResult serverSettings}) {
- return _LoginSequenceRoute(
+ return LoginSequenceRoute(
page: PasswordLoginPage(serverSettings: serverSettings));
}
@@ -323,7 +399,7 @@ class _PasswordLoginPageState extends State {
Navigator.of(context).pushAndRemoveUntil(
HomePage.buildRoute(accountId: accountId),
- (route) => (route is! _LoginSequenceRoute),
+ (route) => (route is! LoginSequenceRoute),
);
} finally {
setState(() {
diff --git a/lib/widgets/login/browser_login.dart b/lib/widgets/login/browser_login.dart
new file mode 100644
index 0000000000..75eebce4eb
--- /dev/null
+++ b/lib/widgets/login/browser_login.dart
@@ -0,0 +1,181 @@
+import 'dart:math';
+import 'dart:typed_data';
+
+import 'package:convert/convert.dart';
+import 'package:drift/drift.dart';
+import 'package:flutter/widgets.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+import '../../api/route/realm.dart';
+import '../../log.dart';
+import '../../model/store.dart';
+import '../app.dart';
+import '../login.dart';
+import '../store.dart';
+
+/// An InheritedWidget to co-ordinate the browser auth flow
+///
+/// The provided [navigatorKey] by this object should be attached to
+/// the main app widget so that when the browser redirects to the app
+/// using the universal link this widget can use it to access the current
+/// navigator instance.
+///
+/// This object also stores the temporarily generated OTP required for
+/// the completion of the flow.
+class BrowserLoginWidget extends InheritedWidget {
+ BrowserLoginWidget({super.key, required super.child});
+
+ final GlobalKey navigatorKey = GlobalKey();
+
+ // TODO: Maybe store these on local DB too, because OS can close the
+ // app while user is using the browser during the auth flow.
+
+ // Temporary mobile_flow_otp, that was generated while initiating a browser auth flow.
+ final Map _tempAuthOtp = {};
+ // Temporary server settngs, that was stored while initiating a browser auth flow.
+ final Map _tempServerSettings = {};
+
+ @override
+ bool updateShouldNotify(covariant BrowserLoginWidget oldWidget) =>
+ !identical(oldWidget.navigatorKey, navigatorKey)
+ && !identical(oldWidget._tempAuthOtp, _tempAuthOtp)
+ && !identical(oldWidget._tempServerSettings, _tempServerSettings);
+
+ static BrowserLoginWidget of(BuildContext context) {
+ final widget = context.dependOnInheritedWidgetOfExactType();
+ assert(widget != null, 'No BrowserLogin ancestor');
+ return widget!;
+ }
+
+ Future openLoginUrl(GetServerSettingsResult serverSettings, String loginUrl) async {
+ // Generate a temporary otp and store it for later use - for decoding the
+ // api key returned by server which will be XOR-ed with this otp.
+ final otp = _generateMobileFlowOtp();
+ _tempAuthOtp[serverSettings.realmUri] = otp;
+ _tempServerSettings[serverSettings.realmUri] = serverSettings;
+
+ // Open the browser
+ await launchUrl(serverSettings.realmUri.replace(
+ path: loginUrl,
+ queryParameters: {'mobile_flow_otp': otp},
+ ));
+ }
+
+ Future loginFromExternalRoute(BuildContext context, Uri uri) async {
+ final globalStore = GlobalStoreWidget.of(context);
+
+ // Parse the query params from the browser redirect url
+ final String otpEncryptedApiKey;
+ final String email;
+ final int userId;
+ final Uri realm;
+ try {
+ if (uri.queryParameters case {
+ 'otp_encrypted_api_key': final String otpEncryptedApiKeyStr,
+ 'email': final String emailStr,
+ 'user_id': final String userIdStr,
+ 'realm': final String realmStr,
+ }) {
+ if (otpEncryptedApiKeyStr.isEmpty || emailStr.isEmpty || userIdStr.isEmpty || realmStr.isEmpty) {
+ throw 'Got invalid query params from browser redirect url';
+ }
+ otpEncryptedApiKey = otpEncryptedApiKeyStr;
+ realm = Uri.parse(realmStr);
+ userId = int.parse(userIdStr);
+ email = emailStr;
+ } else {
+ throw 'Got invalid query params from browser redirect url';
+ }
+ } catch (e, st) {
+ // TODO: Log error to Sentry
+ debugLog('$e\n$st');
+ return;
+ }
+
+ // Get the previously temporarily stored otp & serverSettings.
+ final GetServerSettingsResult serverSettings;
+ final String apiKey;
+ try {
+ final otp = _tempAuthOtp[realm];
+ _tempAuthOtp.clear();
+ final settings = _tempServerSettings[realm];
+ _tempServerSettings.clear();
+ if (otp == null) {
+ throw 'Failed to find the previously generated mobile_auth_otp';
+ }
+ if (settings == null) {
+ // TODO: Maybe try refetching instead of error-ing out.
+ throw 'Failed to find the previously stored serverSettings';
+ }
+
+ // Decode the otp XOR-ed api key
+ apiKey = _decodeApiKey(otp, otpEncryptedApiKey);
+ serverSettings = settings;
+ } catch (e, st) {
+ // TODO: Log error to Sentry
+ debugLog('$e\n$st');
+ return;
+ }
+
+ // TODO(#108): give feedback to user on SQL exception, like dupe realm+user
+ final accountId = await globalStore.insertAccount(AccountsCompanion.insert(
+ realmUrl: serverSettings.realmUri,
+ email: email,
+ apiKey: apiKey,
+ userId: userId,
+ zulipFeatureLevel: serverSettings.zulipFeatureLevel,
+ zulipVersion: serverSettings.zulipVersion,
+ zulipMergeBase: Value(serverSettings.zulipMergeBase),
+ ));
+
+ if (!context.mounted) {
+ return;
+ }
+ navigatorKey.currentState?.pushAndRemoveUntil(
+ HomePage.buildRoute(accountId: accountId),
+ (route) => (route is! LoginSequenceRoute),
+ );
+ }
+}
+
+/// Generates a `mobile_flow_otp` to be used by the server for
+/// mobile login flow, server XOR's the api key with the otp hex
+/// and returns the resulting value. So, the same otp that was passed
+/// to the server can be used again to decode the actual api key.
+String _generateMobileFlowOtp() {
+ final rand = Random.secure();
+ return hex.encode(rand.nextBytes(32));
+}
+
+String _decodeApiKey(String otp, String otpEncryptedApiKey) {
+ final otpHex = hex.decode(otp);
+ final otpEncryptedApiKeyHex = hex.decode(otpEncryptedApiKey);
+ return String.fromCharCodes(otpHex ^ otpEncryptedApiKeyHex);
+}
+
+// TODO: Remove this when upstream issue is fixed
+// https://github.com/dart-lang/sdk/issues/53339
+extension _RandomNextBytes on Random {
+ static const int _pow2_32 = 0x100000000;
+ Uint8List nextBytes(int length) {
+ if ((length % 4) != 0) {
+ throw ArgumentError('\'length\' must be a multiple of 4');
+ }
+ final result = Uint32List(length);
+ for (int i = 0; i < length; i++) {
+ result[i] = nextInt(_pow2_32);
+ }
+ return result.buffer.asUint8List(0, length);
+ }
+}
+
+extension _IntListOpXOR on List {
+ Iterable operator ^(List other) sync* {
+ if (length != other.length) {
+ throw ArgumentError('Both lists must have the same length');
+ }
+ for (var i = 0; i < length; i++) {
+ yield this[i] ^ other[i];
+ }
+ }
+}
diff --git a/pubspec.lock b/pubspec.lock
index 320c58b783..82e78b4985 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -186,7 +186,7 @@ packages:
source: hosted
version: "1.18.0"
convert:
- dependency: transitive
+ dependency: "direct main"
description:
name: convert
sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"
@@ -921,18 +921,18 @@ packages:
dependency: "direct main"
description:
name: url_launcher
- sha256: "781bd58a1eb16069412365c98597726cd8810ae27435f04b3b4d3a470bacd61e"
+ sha256: "47e208a6711459d813ba18af120d9663c20bdf6985d6ad39fe165d2538378d27"
url: "https://pub.dev"
source: hosted
- version: "6.1.12"
+ version: "6.1.14"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
- sha256: "3dd2388cc0c42912eee04434531a26a82512b9cb1827e0214430c9bcbddfe025"
+ sha256: b04af59516ab45762b2ca6da40fa830d72d0f6045cd97744450b73493fa76330
url: "https://pub.dev"
source: hosted
- version: "6.0.38"
+ version: "6.1.0"
url_launcher_ios:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 57b235bee9..b69a30edb4 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -56,7 +56,8 @@ dependencies:
image_picker: ^1.0.0
package_info_plus: ^4.0.1
collection: ^1.17.2
- url_launcher: ^6.1.11
+ url_launcher: ^6.1.14
+ convert: ^3.1.1
dev_dependencies:
flutter_test:
diff --git a/test/example_data.dart b/test/example_data.dart
index ac329161ec..e4452671a2 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -1,5 +1,6 @@
import 'package:zulip/api/model/initial_snapshot.dart';
import 'package:zulip/api/model/model.dart';
+import 'package:zulip/api/route/realm.dart';
import 'package:zulip/model/store.dart';
import 'api/fake_api.dart';
@@ -277,3 +278,53 @@ PerAccountStore store() {
initialSnapshot: initialSnapshot(),
);
}
+
+GetServerSettingsResult serverSettings({
+ Map? authenticationMethods,
+ List? externalAuthenticationMethods,
+ int? zulipFeatureLevel,
+ String? zulipVersion,
+ String? zulipMergeBase,
+ bool? pushNotificationsEnabled,
+ bool? isIncompatible,
+ bool? emailAuthEnabled,
+ bool? requireEmailFormatUsernames,
+ Uri? realmUri,
+ String? realmName,
+ Uri? realmIcon,
+ String? realmDescription,
+ bool? realmWebPublicAccessEnabled,
+ }) {
+ return GetServerSettingsResult(
+ authenticationMethods: authenticationMethods ?? {},
+ externalAuthenticationMethods: externalAuthenticationMethods ?? [],
+ zulipFeatureLevel: zulipFeatureLevel ?? recentZulipFeatureLevel,
+ zulipVersion: zulipVersion ?? recentZulipVersion,
+ zulipMergeBase: zulipMergeBase ?? recentZulipVersion,
+ pushNotificationsEnabled: pushNotificationsEnabled ?? false,
+ isIncompatible: isIncompatible ?? false,
+ emailAuthEnabled: emailAuthEnabled ?? true,
+ requireEmailFormatUsernames: requireEmailFormatUsernames ?? true,
+ realmUri: realmUri ?? realmUrl,
+ realmName: realmName ?? '',
+ realmIcon: realmIcon,
+ realmDescription: realmDescription ?? '',
+ realmWebPublicAccessEnabled: realmWebPublicAccessEnabled ?? false,
+ );
+}
+
+ExternalAuthenticationMethod externalAuthenticationMethod({
+ String? name,
+ String? displayName,
+ String? displayIcon,
+ String? loginUrl,
+ String? signupUrl,
+}) {
+ return ExternalAuthenticationMethod(
+ name: name ?? '',
+ displayName: displayName ?? '',
+ displayIcon: displayIcon,
+ loginUrl: loginUrl ?? '',
+ signupUrl: signupUrl ?? '',
+ );
+}
diff --git a/test/widgets/login_test.dart b/test/widgets/login_test.dart
index 081dbc6c5b..8ed8963227 100644
--- a/test/widgets/login_test.dart
+++ b/test/widgets/login_test.dart
@@ -1,10 +1,108 @@
import 'package:checks/checks.dart';
+import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:zulip/api/route/realm.dart';
import 'package:zulip/widgets/login.dart';
+import '../model/binding.dart';
import '../stdlib_checks.dart';
+import '../example_data.dart' as eg;
void main() {
+ TestZulipBinding.ensureInitialized();
+
+ group('AuthMethodsPage', () {
+ Future setupPage(WidgetTester tester, {
+ required GetServerSettingsResult serverSettings,
+ }) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: AuthMethodsPage(serverSettings: serverSettings)));
+ }
+
+ testWidgets('shows all the external methods', (tester) async {
+ final methods = [
+ eg.externalAuthenticationMethod(
+ name: 'some_new_method',
+ displayName: 'Some new method',
+ ),
+ eg.externalAuthenticationMethod(
+ name: 'github',
+ displayName: 'Github',
+ ),
+ ];
+ await setupPage(
+ tester,
+ serverSettings: eg.serverSettings(
+ emailAuthEnabled: false, // don't show password method
+ externalAuthenticationMethods: methods));
+
+ final widgets = tester.widgetList(
+ find.ancestor(
+ of: find.textContaining('Sign in with'),
+ matching: find.byType(OutlinedButton))
+ );
+ check(widgets.length).equals(methods.length);
+ });
+
+ testWidgets('shows all the methods', (tester) async {
+ final methods = [
+ eg.externalAuthenticationMethod(
+ name: 'some_new_method',
+ displayName: 'Some new method',
+ ),
+ eg.externalAuthenticationMethod(
+ name: 'github',
+ displayName: 'Github',
+ ),
+ ];
+ await setupPage(
+ tester,
+ serverSettings: eg.serverSettings(
+ emailAuthEnabled: true, // show password method
+ externalAuthenticationMethods: methods));
+
+ final widgets = tester.widgetList(
+ find.ancestor(
+ of: find.textContaining('Sign in with'),
+ matching: find.byType(OutlinedButton))
+ );
+ check(widgets.length).equals(methods.length + 1);
+ });
+
+ testWidgets('untested methods disabled', (tester) async {
+ final untestedMethod = eg.externalAuthenticationMethod(
+ name: 'some_new_method',
+ displayName: 'Some new method',
+ );
+ await setupPage(
+ tester,
+ serverSettings: eg.serverSettings(externalAuthenticationMethods: [untestedMethod]));
+
+ final button = tester.widget(
+ find.ancestor(
+ of: find.text('Sign in with ${untestedMethod.displayName}'),
+ matching: find.byType(OutlinedButton)));
+ check(button.enabled).isFalse();
+ });
+
+ testWidgets('tested methods enabled', (tester) async {
+ final testedMethod = eg.externalAuthenticationMethod(
+ name: 'github',
+ displayName: 'Github',
+ );
+ await setupPage(
+ tester,
+ serverSettings: eg.serverSettings(externalAuthenticationMethods: [testedMethod]));
+
+ final button = tester.firstWidget(
+ find.ancestor(
+ of: find.text('Sign in with ${testedMethod.displayName}'),
+ matching: find.byType(OutlinedButton)));
+ check(button.enabled).isTrue();
+ });
+ });
+
group('ServerUrlTextEditingController.tryParse', () {
final controller = ServerUrlTextEditingController();