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