1+ import 'package:flutter/foundation.dart' ;
12import 'package:flutter/material.dart' ;
3+ import 'package:flutter/services.dart' ;
24import 'package:flutter_gen/gen_l10n/zulip_localizations.dart' ;
5+ import 'package:url_launcher/url_launcher.dart' ;
36
47import '../api/exception.dart' ;
8+ import '../api/model/web_auth.dart' ;
59import '../api/route/account.dart' ;
610import '../api/route/realm.dart' ;
711import '../api/route/users.dart' ;
12+ import '../log.dart' ;
13+ import '../model/binding.dart' ;
814import '../model/store.dart' ;
915import 'app.dart' ;
1016import 'dialog.dart' ;
1117import 'input.dart' ;
1218import 'page.dart' ;
1319import 'store.dart' ;
20+ import 'text.dart' ;
1421
1522class _LoginSequenceRoute extends MaterialWidgetRoute <void > {
1623 _LoginSequenceRoute ({
@@ -176,7 +183,6 @@ class _AddAccountPageState extends State<AddAccountPage> {
176183 return ;
177184 }
178185
179- // TODO(#36): support login methods beyond username/password
180186 Navigator .push (context,
181187 LoginPage .buildRoute (serverSettings: serverSettings));
182188 } finally {
@@ -240,18 +246,108 @@ class LoginPage extends StatefulWidget {
240246
241247 static Route <void > buildRoute ({required GetServerSettingsResult serverSettings}) {
242248 return _LoginSequenceRoute (
243- page: LoginPage (serverSettings: serverSettings));
249+ page: LoginPage (serverSettings: serverSettings, key : _lastBuiltKey ));
244250 }
245251
246252 final GetServerSettingsResult serverSettings;
247253
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+
248266 @override
249267 State <LoginPage > createState () => _LoginPageState ();
250268}
251269
252270class _LoginPageState extends State <LoginPage > {
253271 bool _inProgress = false ;
254272
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+
255351 Future <void > _tryInsertAccountAndNavigate ({
256352 required String email,
257353 required String apiKey,
@@ -312,6 +408,26 @@ class _LoginPageState extends State<LoginPage> {
312408 assert (! PerAccountStoreWidget .debugExistsOf (context));
313409 final zulipLocalizations = ZulipLocalizations .of (context);
314410
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+
315431 return Scaffold (
316432 appBar: AppBar (title: Text (zulipLocalizations.loginPageTitle),
317433 bottom: _inProgress
@@ -330,7 +446,7 @@ class _LoginPageState extends State<LoginPage> {
330446 // left or the right of this box
331447 child: ConstrainedBox (
332448 constraints: const BoxConstraints (maxWidth: 400 ),
333- child: _UsernamePasswordForm (loginPageState : this ) ))))));
449+ child: loginForm ))))));
334450 }
335451}
336452
@@ -495,3 +611,31 @@ class _UsernamePasswordFormState extends State<_UsernamePasswordForm> {
495611 ])));
496612 }
497613}
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