Skip to content

Commit fecbcfd

Browse files
login: Add AuthMethodsPage
1 parent 1b44a6f commit fecbcfd

File tree

5 files changed

+279
-6
lines changed

5 files changed

+279
-6
lines changed

lib/api/route/realm.dart

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,32 @@ Future<GetServerSettingsResult> getServerSettings({
2828
}
2929
}
3030

31+
@JsonSerializable(fieldRename: FieldRename.snake)
32+
class ExternalAuthenticationMethod {
33+
final String name;
34+
final String displayName;
35+
final String? displayIcon;
36+
final String loginUrl;
37+
final String signupUrl;
38+
39+
ExternalAuthenticationMethod({
40+
required this.name,
41+
required this.displayName,
42+
this.displayIcon,
43+
required this.loginUrl,
44+
required this.signupUrl,
45+
});
46+
47+
factory ExternalAuthenticationMethod.fromJson(Map<String, dynamic> json) =>
48+
_$ExternalAuthenticationMethodFromJson(json);
49+
50+
Map<String, dynamic> toJson() => _$ExternalAuthenticationMethodToJson(this);
51+
}
52+
3153
@JsonSerializable(fieldRename: FieldRename.snake)
3254
class GetServerSettingsResult {
3355
final Map<String, bool> authenticationMethods;
34-
// final List<ExternalAuthenticationMethod> external_authentication_methods; // TODO handle
56+
final List<ExternalAuthenticationMethod> externalAuthenticationMethods;
3557

3658
final int zulipFeatureLevel;
3759
final String zulipVersion;
@@ -44,12 +66,13 @@ class GetServerSettingsResult {
4466
final bool requireEmailFormatUsernames;
4567
final Uri realmUri;
4668
final String realmName;
47-
final String realmIcon;
69+
final Uri? realmIcon;
4870
final String realmDescription;
4971
final bool? realmWebPublicAccessEnabled; // TODO(server-5)
5072

5173
GetServerSettingsResult({
5274
required this.authenticationMethods,
75+
required this.externalAuthenticationMethods,
5376
required this.zulipFeatureLevel,
5477
required this.zulipVersion,
5578
this.zulipMergeBase,

lib/api/route/realm.g.dart

Lines changed: 30 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/widgets/login.dart

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,8 @@ class _AddAccountPageState extends State<AddAccountPage> {
167167
return;
168168
}
169169

170-
// TODO(#36): support login methods beyond username/password
171170
Navigator.push(context,
172-
PasswordLoginPage.buildRoute(serverSettings: serverSettings));
171+
AuthMethodsPage.buildRoute(serverSettings: serverSettings));
173172
} finally {
174173
setState(() {
175174
_inProgress = false;
@@ -225,6 +224,80 @@ class _AddAccountPageState extends State<AddAccountPage> {
225224
}
226225
}
227226

227+
class AuthMethodsPage extends StatefulWidget {
228+
const AuthMethodsPage({super.key, required this.serverSettings});
229+
230+
final GetServerSettingsResult serverSettings;
231+
232+
static Route<void> buildRoute({required GetServerSettingsResult serverSettings}) {
233+
return _LoginSequenceRoute(
234+
page: AuthMethodsPage(serverSettings: serverSettings));
235+
}
236+
237+
@override
238+
State<AuthMethodsPage> createState() => _AuthMethodsPageState();
239+
}
240+
241+
class _AuthMethodsPageState extends State<AuthMethodsPage> {
242+
// TODO: Remove this list when all the methods are tested,
243+
// or update to add a new method.
244+
static const Set<String> _testedAuthMethods = {
245+
'github',
246+
'google',
247+
};
248+
249+
Future<void> _openBrowserLogin(ExternalAuthenticationMethod method) async {}
250+
251+
@override
252+
Widget build(BuildContext context) {
253+
// 'realmIcon' for chat.zulip.org, only contains the path component.
254+
// So, resolve it to the 'realmUri' to get the full Uri with host.
255+
final Uri? iconUrl = widget.serverSettings.realmIcon != null
256+
? widget.serverSettings.realmUri.resolveUri(widget.serverSettings.realmIcon!)
257+
: null;
258+
259+
return Scaffold(
260+
appBar: AppBar(title: const Text('Log in')),
261+
body: SafeArea(
262+
child: ListView(
263+
padding: const EdgeInsets.all(8),
264+
children: [
265+
Padding(
266+
padding: const EdgeInsets.only(bottom: 8),
267+
child: Row(
268+
mainAxisAlignment: MainAxisAlignment.center,
269+
children: [
270+
if (iconUrl != null) ...[
271+
Image.network(
272+
iconUrl.toString(),
273+
key: const Key('realm_icon'),
274+
width: 48,
275+
height: 48),
276+
const SizedBox(width: 8),
277+
],
278+
Text(widget.serverSettings.realmName, style: const TextStyle(fontSize: 20)),
279+
]),
280+
),
281+
if (widget.serverSettings.emailAuthEnabled)
282+
OutlinedButton(
283+
onPressed: () => Navigator.push(context, PasswordLoginPage.buildRoute(serverSettings: widget.serverSettings)),
284+
child: const Text('Sign in with password')),
285+
...widget.serverSettings.externalAuthenticationMethods.map(
286+
(authMethod) => switch (authMethod.displayIcon) {
287+
null || '' => OutlinedButton(
288+
onPressed: _testedAuthMethods.contains(authMethod.name) ? () => _openBrowserLogin(authMethod) : null,
289+
child: Text('Sign in with ${authMethod.displayName}'),
290+
),
291+
final displayIcon => OutlinedButton.icon(
292+
onPressed: _testedAuthMethods.contains(authMethod.name) ? () => _openBrowserLogin(authMethod) : null,
293+
icon: Image.network(displayIcon, width: 24, height: 24),
294+
label: Text('Sign in with ${authMethod.displayName}'),
295+
),
296+
}).toList(),
297+
])));
298+
}
299+
}
300+
228301
class PasswordLoginPage extends StatefulWidget {
229302
const PasswordLoginPage({super.key, required this.serverSettings});
230303

test/example_data.dart

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:zulip/api/model/initial_snapshot.dart';
22
import 'package:zulip/api/model/model.dart';
3+
import 'package:zulip/api/route/realm.dart';
34
import 'package:zulip/model/store.dart';
45

56
import 'api/fake_api.dart';
@@ -277,3 +278,53 @@ PerAccountStore store() {
277278
initialSnapshot: initialSnapshot(),
278279
);
279280
}
281+
282+
GetServerSettingsResult serverSettings({
283+
Map<String, bool>? authenticationMethods,
284+
List<ExternalAuthenticationMethod>? externalAuthenticationMethods,
285+
int? zulipFeatureLevel,
286+
String? zulipVersion,
287+
String? zulipMergeBase,
288+
bool? pushNotificationsEnabled,
289+
bool? isIncompatible,
290+
bool? emailAuthEnabled,
291+
bool? requireEmailFormatUsernames,
292+
Uri? realmUri,
293+
String? realmName,
294+
Uri? realmIcon,
295+
String? realmDescription,
296+
bool? realmWebPublicAccessEnabled,
297+
}) {
298+
return GetServerSettingsResult(
299+
authenticationMethods: authenticationMethods ?? {},
300+
externalAuthenticationMethods: externalAuthenticationMethods ?? [],
301+
zulipFeatureLevel: zulipFeatureLevel ?? recentZulipFeatureLevel,
302+
zulipVersion: zulipVersion ?? recentZulipVersion,
303+
zulipMergeBase: zulipMergeBase ?? recentZulipVersion,
304+
pushNotificationsEnabled: pushNotificationsEnabled ?? false,
305+
isIncompatible: isIncompatible ?? false,
306+
emailAuthEnabled: emailAuthEnabled ?? true,
307+
requireEmailFormatUsernames: requireEmailFormatUsernames ?? true,
308+
realmUri: realmUri ?? realmUrl,
309+
realmName: realmName ?? '',
310+
realmIcon: realmIcon,
311+
realmDescription: realmDescription ?? '',
312+
realmWebPublicAccessEnabled: realmWebPublicAccessEnabled ?? false,
313+
);
314+
}
315+
316+
ExternalAuthenticationMethod externalAuthenticationMethod({
317+
String? name,
318+
String? displayName,
319+
String? displayIcon,
320+
String? loginUrl,
321+
String? signupUrl,
322+
}) {
323+
return ExternalAuthenticationMethod(
324+
name: name ?? '',
325+
displayName: displayName ?? '',
326+
displayIcon: displayIcon,
327+
loginUrl: loginUrl ?? '',
328+
signupUrl: signupUrl ?? '',
329+
);
330+
}

test/widgets/login_test.dart

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,108 @@
11
import 'package:checks/checks.dart';
2+
import 'package:flutter/material.dart';
23
import 'package:flutter_test/flutter_test.dart';
4+
import 'package:zulip/api/route/realm.dart';
35
import 'package:zulip/widgets/login.dart';
46

7+
import '../model/binding.dart';
58
import '../stdlib_checks.dart';
9+
import '../example_data.dart' as eg;
610

711
void main() {
12+
TestZulipBinding.ensureInitialized();
13+
14+
group('AuthMethodsPage', () {
15+
Future<void> setupPage(WidgetTester tester, {
16+
required GetServerSettingsResult serverSettings,
17+
}) async {
18+
await tester.pumpWidget(
19+
MaterialApp(
20+
home: AuthMethodsPage(serverSettings: serverSettings)));
21+
}
22+
23+
testWidgets('shows all the external methods', (tester) async {
24+
final methods = <ExternalAuthenticationMethod>[
25+
eg.externalAuthenticationMethod(
26+
name: 'some_new_method',
27+
displayName: 'Some new method',
28+
),
29+
eg.externalAuthenticationMethod(
30+
name: 'github',
31+
displayName: 'Github',
32+
),
33+
];
34+
await setupPage(
35+
tester,
36+
serverSettings: eg.serverSettings(
37+
emailAuthEnabled: false, // don't show password method
38+
externalAuthenticationMethods: methods));
39+
40+
final widgets = tester.widgetList<OutlinedButton>(
41+
find.ancestor(
42+
of: find.textContaining('Sign in with'),
43+
matching: find.byType(OutlinedButton))
44+
);
45+
check(widgets.length).equals(methods.length);
46+
});
47+
48+
testWidgets('shows all the methods', (tester) async {
49+
final methods = <ExternalAuthenticationMethod>[
50+
eg.externalAuthenticationMethod(
51+
name: 'some_new_method',
52+
displayName: 'Some new method',
53+
),
54+
eg.externalAuthenticationMethod(
55+
name: 'github',
56+
displayName: 'Github',
57+
),
58+
];
59+
await setupPage(
60+
tester,
61+
serverSettings: eg.serverSettings(
62+
emailAuthEnabled: true, // show password method
63+
externalAuthenticationMethods: methods));
64+
65+
final widgets = tester.widgetList<OutlinedButton>(
66+
find.ancestor(
67+
of: find.textContaining('Sign in with'),
68+
matching: find.byType(OutlinedButton))
69+
);
70+
check(widgets.length).equals(methods.length + 1);
71+
});
72+
73+
testWidgets('untested methods disabled', (tester) async {
74+
final untestedMethod = eg.externalAuthenticationMethod(
75+
name: 'some_new_method',
76+
displayName: 'Some new method',
77+
);
78+
await setupPage(
79+
tester,
80+
serverSettings: eg.serverSettings(externalAuthenticationMethods: [untestedMethod]));
81+
82+
final button = tester.widget<OutlinedButton>(
83+
find.ancestor(
84+
of: find.text('Sign in with ${untestedMethod.displayName}'),
85+
matching: find.byType(OutlinedButton)));
86+
check(button.enabled).isFalse();
87+
});
88+
89+
testWidgets('tested methods enabled', (tester) async {
90+
final testedMethod = eg.externalAuthenticationMethod(
91+
name: 'github',
92+
displayName: 'Github',
93+
);
94+
await setupPage(
95+
tester,
96+
serverSettings: eg.serverSettings(externalAuthenticationMethods: [testedMethod]));
97+
98+
final button = tester.firstWidget<OutlinedButton>(
99+
find.ancestor(
100+
of: find.text('Sign in with ${testedMethod.displayName}'),
101+
matching: find.byType(OutlinedButton)));
102+
check(button.enabled).isTrue();
103+
});
104+
});
105+
8106
group('ServerUrlTextEditingController.tryParse', () {
9107
final controller = ServerUrlTextEditingController();
10108

0 commit comments

Comments
 (0)