1
1
import 'package:flutter/material.dart' ;
2
+ import 'package:flutter/services.dart' ;
2
3
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart' ;
4
+ import 'package:url_launcher/url_launcher.dart' ;
3
5
4
6
import '../api/exception.dart' ;
7
+ import '../api/model/web_auth.dart' ;
5
8
import '../api/route/account.dart' ;
6
9
import '../api/route/realm.dart' ;
7
10
import '../api/route/users.dart' ;
11
+ import '../log.dart' ;
12
+ import '../model/binding.dart' ;
8
13
import '../model/store.dart' ;
9
14
import 'app.dart' ;
10
15
import 'dialog.dart' ;
11
16
import 'input.dart' ;
12
17
import 'page.dart' ;
13
18
import 'store.dart' ;
19
+ import 'text.dart' ;
14
20
15
21
class _LoginSequenceRoute extends MaterialWidgetRoute <void > {
16
22
_LoginSequenceRoute ({
@@ -176,7 +182,6 @@ class _AddAccountPageState extends State<AddAccountPage> {
176
182
return ;
177
183
}
178
184
179
- // TODO(#36): support login methods beyond username/password
180
185
Navigator .push (context,
181
186
LoginPage .buildRoute (serverSettings: serverSettings));
182
187
} finally {
@@ -238,11 +243,14 @@ class _AddAccountPageState extends State<AddAccountPage> {
238
243
class LoginPage extends StatefulWidget {
239
244
const LoginPage ({super .key, required this .serverSettings});
240
245
246
+ /// A key for the page from the last [buildRoute] call.
247
+ static final lastBuiltKey = GlobalKey <LoginPageState >();
248
+
241
249
final GetServerSettingsResult serverSettings;
242
250
243
251
static Route <void > buildRoute ({required GetServerSettingsResult serverSettings}) {
244
252
return _LoginSequenceRoute (
245
- page: LoginPage (serverSettings: serverSettings));
253
+ page: LoginPage (serverSettings: serverSettings, key : lastBuiltKey ));
246
254
}
247
255
248
256
@override
@@ -252,6 +260,83 @@ class LoginPage extends StatefulWidget {
252
260
class LoginPageState extends State <LoginPage > {
253
261
bool _inProgress = false ;
254
262
263
+ /// The OTP to use, instead of an app-generated one, for testing.
264
+ @visibleForTesting
265
+ static String ? debugOtpOverride;
266
+ String ? _otp;
267
+ static const LaunchMode _webAuthLaunchMode = LaunchMode .inAppBrowserView;
268
+
269
+ /// Log in using the payload of a web-auth URL like zulip://login?…
270
+ Future <void > handleWebAuthUrl (Uri url) async {
271
+ setState (() {
272
+ _inProgress = true ;
273
+ });
274
+ try {
275
+ assert (await ZulipBinding .instance.supportsCloseForLaunchMode (_webAuthLaunchMode));
276
+ await ZulipBinding .instance.closeInAppWebView ();
277
+ if ((debugOtpOverride ?? _otp) == null ) {
278
+ throw Error ();
279
+ }
280
+ final payload = WebAuthPayload .parse (url);
281
+ final apiKey = payload.decodeApiKey ((debugOtpOverride ?? _otp)! );
282
+ await _tryInsertAccountAndNavigate (
283
+ // TODO(server-5): Rely on userId from payload.
284
+ userId: payload.userId ?? await _getUserId (payload.email, apiKey),
285
+ email: payload.email,
286
+ apiKey: apiKey,
287
+ );
288
+ } catch (e) {
289
+ assert (debugLog (e.toString ()));
290
+ if (! mounted) return ;
291
+ final zulipLocalizations = ZulipLocalizations .of (context);
292
+ // Could show different error messages for different failure modes.
293
+ await showErrorDialog (context: context,
294
+ title: zulipLocalizations.errorWebAuthOperationalErrorTitle,
295
+ message: zulipLocalizations.errorWebAuthOperationalError);
296
+ } finally {
297
+ setState (() {
298
+ _inProgress = false ;
299
+ _otp = null ;
300
+ });
301
+ }
302
+ }
303
+
304
+ Future <void > _beginWebAuth (ExternalAuthenticationMethod method) async {
305
+ _otp = generateOtp ();
306
+ try {
307
+ final url = widget.serverSettings.realmUrl.resolve (method.loginUrl)
308
+ .replace (queryParameters: {'mobile_flow_otp' : (debugOtpOverride ?? _otp)! });
309
+ if (! (await ZulipBinding .instance.canLaunchUrl (url))) {
310
+ throw Error ();
311
+ }
312
+
313
+ // Could set [_inProgress]… but we'd need to unset it if the web-auth
314
+ // attempt is aborted (by the user closing the browser, for example),
315
+ // and I don't think we can reliably know when that happens.
316
+ await ZulipBinding .instance.launchUrl (url, mode: _webAuthLaunchMode);
317
+ } catch (e) {
318
+ assert (debugLog (e.toString ()));
319
+
320
+ if (e is PlatformException && e.message != null && e.message! .startsWith ('Error while launching' )) {
321
+ // Ignore; I've seen this on my iPhone even when auth succeeds.
322
+ // Specifically, Apple web auth…which on iOS should be replaced by
323
+ // Apple native auth; that's #462.
324
+ // Possibly related:
325
+ // https://github.com/flutter/flutter/issues/91660
326
+ // but in that issue, people report authentication not succeeding.
327
+ // TODO(#462) remove this?
328
+ return ;
329
+ }
330
+
331
+ if (! mounted) return ;
332
+ final zulipLocalizations = ZulipLocalizations .of (context);
333
+ // Could show different error messages for different failure modes.
334
+ await showErrorDialog (context: context,
335
+ title: zulipLocalizations.errorWebAuthOperationalErrorTitle,
336
+ message: zulipLocalizations.errorWebAuthOperationalError);
337
+ }
338
+ }
339
+
255
340
Future <void > _tryInsertAccountAndNavigate ({
256
341
required String email,
257
342
required String apiKey,
@@ -312,6 +397,8 @@ class LoginPageState extends State<LoginPage> {
312
397
assert (! PerAccountStoreWidget .debugExistsOf (context));
313
398
final zulipLocalizations = ZulipLocalizations .of (context);
314
399
400
+ final externalAuthenticationMethods = widget.serverSettings.externalAuthenticationMethods;
401
+
315
402
return Scaffold (
316
403
appBar: AppBar (title: Text (zulipLocalizations.loginPageTitle),
317
404
bottom: _inProgress
@@ -330,7 +417,23 @@ class LoginPageState extends State<LoginPage> {
330
417
// left or the right of this box
331
418
child: ConstrainedBox (
332
419
constraints: const BoxConstraints (maxWidth: 400 ),
333
- child: _UsernamePasswordForm (loginPageState: this )))))));
420
+ child: Column (mainAxisAlignment: MainAxisAlignment .center, children: [
421
+ _UsernamePasswordForm (loginPageState: this ),
422
+ if (externalAuthenticationMethods.isNotEmpty) ...[
423
+ const OrDivider (),
424
+ ...externalAuthenticationMethods.map ((method) {
425
+ final icon = method.displayIcon;
426
+ return OutlinedButton .icon (
427
+ icon: icon != null
428
+ ? Image .network (icon, width: 24 , height: 24 )
429
+ : null ,
430
+ onPressed: ! _inProgress
431
+ ? () => _beginWebAuth (method)
432
+ : null ,
433
+ label: Text (zulipLocalizations.signInWithFoo (method.displayName)));
434
+ }),
435
+ ],
436
+ ])))))));
334
437
}
335
438
}
336
439
@@ -495,3 +598,31 @@ class _UsernamePasswordFormState extends State<_UsernamePasswordForm> {
495
598
])));
496
599
}
497
600
}
601
+
602
+ // Loosely based on the corresponding element in the web app.
603
+ class OrDivider extends StatelessWidget {
604
+ const OrDivider ({super .key});
605
+
606
+ @override
607
+ Widget build (BuildContext context) {
608
+ final zulipLocalizations = ZulipLocalizations .of (context);
609
+
610
+ const divider = Expanded (
611
+ child: Divider (color: Color (0xffdedede ), thickness: 2 ));
612
+
613
+ return Padding (
614
+ padding: const EdgeInsets .symmetric (vertical: 10 ),
615
+ child: Row (crossAxisAlignment: CrossAxisAlignment .center, children: [
616
+ divider,
617
+ Padding (
618
+ padding: const EdgeInsets .symmetric (horizontal: 5 ),
619
+ child: Text (zulipLocalizations.loginMethodDivider,
620
+ textAlign: TextAlign .center,
621
+ style: const TextStyle (
622
+ color: Color (0xff575757 ),
623
+ height: 1.5 ,
624
+ ).merge (weightVariableTextStyle (context, wght: 600 )))),
625
+ divider,
626
+ ]));
627
+ }
628
+ }
0 commit comments