diff --git a/client/lib/ui/app/router.dart b/client/lib/ui/app/router.dart index 3d9d63fb..f6349c4d 100644 --- a/client/lib/ui/app/router.dart +++ b/client/lib/ui/app/router.dart @@ -127,8 +127,10 @@ class PhylumRouteInformationParser extends RouteInformationParser { } } else if (segments.length == 2) { if (segments[0] == 'login' && segments[1] == 'oauth') { + print(uri.toString()); return SynchronousFuture(OAuthLoginRoute( code: uri.queryParameters['code'] ?? '', + state: uri.queryParameters['state'] ?? '', )); } if (segments[0] == 'folder') { diff --git a/client/lib/ui/app/routes.dart b/client/lib/ui/app/routes.dart index ab9ffc50..adbf1682 100644 --- a/client/lib/ui/app/routes.dart +++ b/client/lib/ui/app/routes.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:offtheline/offtheline.dart'; import 'package:phylum/libphylum/db/db.dart'; @@ -11,6 +10,7 @@ import 'package:phylum/libphylum/requests/shared_resources_request.dart'; import 'package:phylum/libphylum/responses/responses.dart'; import 'package:phylum/ui/layout/app_layout.dart'; import 'package:phylum/ui/login/login_page.dart'; +import 'package:phylum/ui/login/oauth_login_page.dart'; import 'package:phylum/ui/login/reset_password_page.dart'; import 'package:provider/provider.dart'; @@ -24,15 +24,19 @@ sealed class PhylumRoute { class OAuthLoginRoute extends PhylumRoute { final String code; + final String state; - OAuthLoginRoute({required this.code}); + OAuthLoginRoute({required this.code, required this.state}); @override Uri get uri => Uri(path: '/login/oauth'); @override Widget buildPage(BuildContext context) { - return const LoginPage(); + return OAuthLoginPage( + code: code, + state: state, + ); } } @@ -71,7 +75,7 @@ sealed class ExplorerRoute extends PhylumRoute { context.select, PhylumAccount?>((state) => state.selectedAccount); if (account == null) { - return kDebugMode ? const LoginPage() : LoginPage(fixedInstanceUrl: Uri()); + return const LoginPage(); } return AppLayout.create(account); } diff --git a/client/lib/ui/login/instance_config.dart b/client/lib/ui/login/instance_config.dart index 34aa0757..3a5dc964 100644 --- a/client/lib/ui/login/instance_config.dart +++ b/client/lib/ui/login/instance_config.dart @@ -7,6 +7,8 @@ class InstanceConfig { final bool magicLink; final List openIDProviers; + bool get hasEmailLogin => password || magicLink; + InstanceConfig( {required this.url, required this.password, diff --git a/client/lib/ui/login/instance_url_fragment.dart b/client/lib/ui/login/instance_url_fragment.dart index 509f30c7..49e59d36 100644 --- a/client/lib/ui/login/instance_url_fragment.dart +++ b/client/lib/ui/login/instance_url_fragment.dart @@ -1,10 +1,11 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; import 'package:phylum/ui/login/instance_config.dart'; import 'package:phylum/util/dialogs.dart'; import 'package:uri/uri.dart'; +import 'package:phylum/util/base_uri_stub.dart' if (dart.library.js_interop) 'package:phylum/util/base_uri_web.dart'; + class InstanceUrlFragment extends StatefulWidget { final Function(InstanceConfig) onInstanceSelected; @@ -15,35 +16,30 @@ class InstanceUrlFragment extends StatefulWidget { } class _InstanceUrlFragmentState extends State { - Uri? _url; + Uri? _url = webAppBaseUri; @override Widget build(BuildContext context) { return Column( spacing: 12.0, children: [ - TextField( + TextFormField( decoration: InputDecoration( label: const Text('Instance URL'), hintText: 'https://phylum.example.com', ), + initialValue: webAppBaseUri?.toString(), + autofocus: true, autofillHints: const [AutofillHints.url], keyboardType: TextInputType.url, onChanged: (value) { - final Uri? url; - if (kIsWeb) { - url = value == '' ? Uri() : Uri.tryParse(value); - } else { - final tempUrl = Uri.tryParse(value); - if (tempUrl != null && (kIsWeb || tempUrl.hasAuthority && tempUrl.hasScheme)) { - url = tempUrl; - } else { - url = null; - } + var url = value == '' ? webAppBaseUri : Uri.tryParse(value); + if (url != null && (!url.hasAuthority || !url.hasScheme)) { + url = null; } setState(() => _url = url); }, - onSubmitted: _url == null ? null : (value) => checkConfig(_url!), + onFieldSubmitted: _url == null ? null : (value) => checkConfig(_url!), ), ElevatedButton(onPressed: _url == null ? null : () => checkConfig(_url!), child: Text('Next')) ], diff --git a/client/lib/ui/login/login_page.dart b/client/lib/ui/login/login_page.dart index fb80bf31..396add33 100644 --- a/client/lib/ui/login/login_page.dart +++ b/client/lib/ui/login/login_page.dart @@ -1,27 +1,11 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; -import 'package:http/http.dart'; -import 'package:offtheline/offtheline.dart'; -import 'package:phylum/libphylum/phylum_account.dart'; -import 'package:phylum/libphylum/responses/responses.dart'; -import 'package:phylum/ui/app/router.dart'; -import 'package:phylum/ui/app/routes.dart'; import 'package:phylum/ui/common/logo_row.dart'; import 'package:phylum/ui/login/instance_config.dart'; import 'package:phylum/ui/login/instance_url_fragment.dart'; import 'package:phylum/ui/login/password_login_fragment.dart'; -import 'package:phylum/util/dialogs.dart'; -import 'package:phylum/util/logging.dart'; -import 'package:provider/provider.dart'; -import 'package:uri/uri.dart'; - -import 'request.dart'; class LoginPage extends StatefulWidget { - final Uri? fixedInstanceUrl; - - const LoginPage({super.key, this.fixedInstanceUrl}); + const LoginPage({super.key}); @override State createState() => _LoginPageState(); @@ -71,73 +55,7 @@ class _LoginPageState extends State { } return PasswordLoginFragment( onBackPressed: () => setState(() => _instanceConfig = null), - instanceUrl: instanceConfig.url, - requestLogin: instanceConfig.password - ? (email, password) => _performLogin(context, instanceConfig.url, email, password) - : null, - requestPasswordReset: - instanceConfig.passwordReset ? (email) => _requestPasswordReset(context, instanceConfig.url, email) : null, - resetPassword: instanceConfig.passwordReset - ? (email, token) => context - .read() - .go(ResetPasswordRoute(instanceUri: instanceConfig.url, email: email, token: token)) - : null, - openIDProviders: instanceConfig.openIDProviers, + instanceConfig: instanceConfig, ); } - - Future _performLogin(BuildContext context, Uri instanceUri, String email, String password) async { - final builder = UriBuilder.fromUri(instanceUri); - builder.path = '${builder.path}/api/v1/auth/password'; - final request = MultipartRequest('post', builder.build()); - request.fields['email'] = email; - request.fields['password'] = password; - - final responseString = await sendRequest(context, 'Logging In', request); - if (responseString == null) return; - if (context.mounted) { - final accountManager = context.read>(); - final navigator = Navigator.of(context); - showProgressDialog(context, barrierDismissible: false, message: 'Logging In'); - try { - final response = - BootstrapLoginResponse.fromResponse((jsonDecode(responseString) as Map).cast()); - final account = await PhylumAccount.create( - serverUrl: instanceUri, - accessToken: response.accessToken!, - user: response.user, - ); - await response.process(account); - await accountManager.addAccount(account); - navigator.pop(); - } catch (e, stack) { - navigator.pop(); - if (context.mounted) { - showAlertDialog( - context, - barrierDismissible: false, - title: 'Error', - message: e.toString(), - ); - } - logger.w('Login Error', error: e, stackTrace: stack); - } - } - } - - Future _requestPasswordReset(BuildContext context, Uri instanceUrl, String email) async { - final builder = UriBuilder.fromUri(instanceUrl); - builder.path = '${builder.path}/api/v1/auth/request-password-reset'; - final request = MultipartRequest('post', builder.build()); - request.fields['email'] = email; - - final responseString = await sendRequest(context, 'Requesting Password Reset', request); - if (responseString == null) return; - if (context.mounted) { - await showAlertDialog( - context, - title: 'Password Reset Requested', - ); - } - } } diff --git a/client/lib/ui/login/oauth_login_page.dart b/client/lib/ui/login/oauth_login_page.dart new file mode 100644 index 00000000..1dc39cb9 --- /dev/null +++ b/client/lib/ui/login/oauth_login_page.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart'; +import 'package:offtheline/offtheline.dart'; +import 'package:phylum/libphylum/phylum_account.dart'; +import 'package:phylum/libphylum/responses/responses.dart'; +import 'package:phylum/ui/app/router.dart'; +import 'package:phylum/ui/app/routes.dart'; +import 'package:provider/provider.dart'; +import 'package:uri/uri.dart'; +import 'package:phylum/ui/app/dialog_scaffold.dart'; + +class OAuthLoginPage extends StatefulWidget { + final String code; + final String state; + + const OAuthLoginPage({ + super.key, + required this.code, + required this.state, + }); + + @override + State createState() => _OAuthLoginPageState(); +} + +class _OAuthLoginPageState extends State { + String? error; + + @override + void initState() { + super.initState(); + performLogin(context); + } + + @override + Widget build(BuildContext context) { + return PhylumDialogScaffold( + child: error == null + ? const Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + spacing: 12.0, + children: [ + CircularProgressIndicator(), + Text('Logging In'), + ], + ) + : Column( + mainAxisSize: MainAxisSize.min, + spacing: 6.0, + children: [ + Text('Error: $error'), + ElevatedButton( + onPressed: () { + context.read().go(ExplorerRouteHome()); + }, + child: Text('OK')) + ], + ), + ); + } + + void performLogin(BuildContext context) async { + final instanceUrl = Uri.parse(widget.state); + final builder = UriBuilder.fromUri(instanceUrl); + builder.path = '${builder.path}/api/v1/auth/oauth'; + final request = MultipartRequest('post', builder.build()); + request.fields['code'] = widget.code; + + try { + final response = await Client().send(request); + final responseString = await response.stream.bytesToString(); + final responseMap = (jsonDecode(responseString) as Map).cast(); + + if (response.statusCode >= 200 && response.statusCode < 300) { + if (context.mounted) { + final navigator = Navigator.of(context); + final routerDelegate = context.read(); + final accountManager = context.read>(); + + final response = BootstrapLoginResponse.fromResponse(responseMap); + final account = await PhylumAccount.create( + serverUrl: instanceUrl, + accessToken: response.accessToken!, + user: response.user, + ); + await response.process(account); + await accountManager.addAccount(account); + navigator.pop(); + routerDelegate.go(ExplorerRouteHome()); + } + } else { + if (context.mounted) { + setState(() => error = responseMap['msg']); + } + return null; + } + } on SocketException { + if (context.mounted) { + setState(() => error = 'unable to reach server'); + } + return null; + } catch (e) { + if (context.mounted) { + setState(() => error = e.toString()); + } + return null; + } + } +} diff --git a/client/lib/ui/login/oauth_stub.dart b/client/lib/ui/login/oauth_stub.dart index d47632a1..bb0b41b9 100644 --- a/client/lib/ui/login/oauth_stub.dart +++ b/client/lib/ui/login/oauth_stub.dart @@ -1 +1,3 @@ -void startOAuthFlow(Uri oAuthUrl) => throw UnimplementedError(); +import 'package:uri/uri.dart'; + +void startOAuthFlow(UriBuilder oAuthUriBuilder, Uri instanceUrl) => throw UnimplementedError(); diff --git a/client/lib/ui/login/oauth_vm.dart b/client/lib/ui/login/oauth_vm.dart index 7fc4e83a..949e86ba 100644 --- a/client/lib/ui/login/oauth_vm.dart +++ b/client/lib/ui/login/oauth_vm.dart @@ -1,3 +1,10 @@ +import 'package:uri/uri.dart'; import 'package:url_launcher/url_launcher.dart'; -void startOAuthFlow(Uri oAuthUrl) => launchUrl(oAuthUrl); +void startOAuthFlow(UriBuilder oAuthUriBuilder, Uri instanceUrl) { + final redirectBuilder = UriBuilder.fromUri(instanceUrl); + // This will redirect to the native scheme cloud.phylum.drive:// + redirectBuilder.path = '/api/v1/auth/oauth_native_redirect'; + oAuthUriBuilder.queryParameters['redirect_uri'] = redirectBuilder.toString(); + launchUrl(oAuthUriBuilder.build()); +} diff --git a/client/lib/ui/login/oauth_web.dart b/client/lib/ui/login/oauth_web.dart index ee983835..fdda703c 100644 --- a/client/lib/ui/login/oauth_web.dart +++ b/client/lib/ui/login/oauth_web.dart @@ -1,3 +1,11 @@ +import 'package:phylum/util/base_uri_web.dart'; +import 'package:uri/uri.dart'; import 'package:web/web.dart' as web; -void startOAuthFlow(Uri oAuthUrl) => web.window.location.href = oAuthUrl.toString(); +void startOAuthFlow(UriBuilder oAuthUriBuilder, Uri instanceUrl) { + final redirectBuilder = UriBuilder.fromUri(webAppBaseUri); + redirectBuilder.path = '/login/oauth'; + oAuthUriBuilder.queryParameters['redirect_uri'] = redirectBuilder.toString(); + oAuthUriBuilder.queryParameters['state'] = instanceUrl.toString(); + web.window.location.href = oAuthUriBuilder.toString(); +} diff --git a/client/lib/ui/login/password_login_fragment.dart b/client/lib/ui/login/password_login_fragment.dart index f3fdb4e6..76715bc4 100644 --- a/client/lib/ui/login/password_login_fragment.dart +++ b/client/lib/ui/login/password_login_fragment.dart @@ -1,27 +1,30 @@ +import 'dart:convert'; + import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:http/http.dart'; +import 'package:offtheline/offtheline.dart'; +import 'package:phylum/libphylum/phylum_account.dart'; +import 'package:phylum/libphylum/responses/responses.dart'; +import 'package:phylum/ui/app/router.dart'; +import 'package:phylum/ui/app/routes.dart'; import 'package:phylum/ui/login/instance_config.dart'; +import 'package:phylum/ui/login/request.dart'; import 'package:phylum/util/dialogs.dart'; +import 'package:phylum/util/logging.dart'; +import 'package:provider/provider.dart'; import 'package:uri/uri.dart'; import 'oauth_stub.dart' if (dart.library.js_interop) 'oauth_web.dart' if (dart.library.ffi) 'oauth_vm.dart'; class PasswordLoginFragment extends StatefulWidget { + final InstanceConfig instanceConfig; final Function()? onBackPressed; - final Uri instanceUrl; - final Function(String, String)? requestLogin; - final Function(String)? requestPasswordReset; - final Function(String, String)? resetPassword; - final List openIDProviders; const PasswordLoginFragment({ super.key, + required this.instanceConfig, required this.onBackPressed, - required this.instanceUrl, - required this.requestLogin, - required this.requestPasswordReset, - required this.resetPassword, - required this.openIDProviders, }); @override @@ -38,7 +41,11 @@ class _PasswordLoginFragmentState extends State { child: Column( spacing: 12.0, children: [ - if (widget.requestLogin != null) + ListTile( + title: Text(widget.instanceConfig.url.toString()), + trailing: IconButton(onPressed: widget.onBackPressed, icon: Icon(Icons.edit)), + ), + if (widget.instanceConfig.hasEmailLogin) TextField( decoration: const InputDecoration( label: Text('Email'), @@ -47,7 +54,7 @@ class _PasswordLoginFragmentState extends State { autofillHints: const [AutofillHints.email], onChanged: (value) => setState(() => _email = value.trim()), ), - if (widget.requestLogin != null) + if (widget.instanceConfig.password) TextField( decoration: const InputDecoration( label: Text('Password'), @@ -56,9 +63,17 @@ class _PasswordLoginFragmentState extends State { keyboardType: TextInputType.visiblePassword, autofillHints: const [AutofillHints.password], onChanged: (value) => setState(() => _password = value), - onSubmitted: _email.isEmpty ? null : (value) => widget.requestLogin!(_email, _password), + onSubmitted: _email.isEmpty + ? null + : (value) => _performLogin(context, widget.instanceConfig.url, _email, _password), ), - if (widget.requestPasswordReset != null) + ElevatedButton( + onPressed: _email.isEmpty || _password.isEmpty + ? null + : () => _performLogin(context, widget.instanceConfig.url, _email, _password), + child: Text('Login'), + ), + if (widget.instanceConfig.passwordReset) TextButton( child: Text('Reset Password', style: TextStyle(color: Theme.of(context).colorScheme.primary)), onPressed: () async { @@ -84,7 +99,8 @@ class _PasswordLoginFragmentState extends State { await showInputDialog(context, labelText: 'Reset Token', barrierDismissable: true); if (token == null || !context.mounted) return; Navigator.of(context).pop(false); - widget.resetPassword!(_email, token); + context.read().go(ResetPasswordRoute( + instanceUri: widget.instanceConfig.url, email: _email, token: token)); }, ), ], @@ -101,43 +117,75 @@ class _PasswordLoginFragmentState extends State { ) ?? false; if (!confirm || !context.mounted) return; - widget.requestPasswordReset!(_email); + _requestPasswordReset(context, widget.instanceConfig.url, _email); }, ), - ...widget.openIDProviders.map((p) => ElevatedButton( + ...widget.instanceConfig.openIDProviers.map((p) => ElevatedButton( onPressed: () async { final builder = UriBuilder.fromUri(Uri.parse(p.endpoint)); - final redirectBuilder = UriBuilder.fromUri(widget.instanceUrl); - redirectBuilder.path = '/login/oauth'; builder.queryParameters['client_id'] = p.clientID; builder.queryParameters['response_type'] = 'code'; - builder.queryParameters['redirect_uri'] = redirectBuilder.toString(); builder.queryParameters['scope'] = 'openid email profile'; - startOAuthFlow(builder.build()); + startOAuthFlow(builder, widget.instanceConfig.url); }, child: Text('Log In with ${p.name}'))), - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.end, - spacing: 12.0, - children: [ - if (widget.onBackPressed != null) - TextButton( - onPressed: widget.onBackPressed, - child: Text( - 'Back', - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ), - if (widget.requestLogin != null) - ElevatedButton( - onPressed: _email.isEmpty || _password.isEmpty ? null : () => widget.requestLogin!(_email, _password), - child: Text('Login'), - ), - ], - ), ], ), ); } + + Future _performLogin(BuildContext context, Uri instanceUri, String email, String password) async { + final builder = UriBuilder.fromUri(instanceUri); + builder.path = '${builder.path}/api/v1/auth/password'; + final request = MultipartRequest('post', builder.build()); + request.fields['email'] = email; + request.fields['password'] = password; + + final responseString = await sendRequest(context, 'Logging In', request); + if (responseString == null) return; + if (context.mounted) { + final accountManager = context.read>(); + final navigator = Navigator.of(context); + showProgressDialog(context, barrierDismissible: false, message: 'Logging In'); + try { + final response = + BootstrapLoginResponse.fromResponse((jsonDecode(responseString) as Map).cast()); + final account = await PhylumAccount.create( + serverUrl: instanceUri, + accessToken: response.accessToken!, + user: response.user, + ); + await response.process(account); + await accountManager.addAccount(account); + navigator.pop(); + } catch (e, stack) { + navigator.pop(); + if (context.mounted) { + showAlertDialog( + context, + barrierDismissible: false, + title: 'Error', + message: e.toString(), + ); + } + logger.w('Login Error', error: e, stackTrace: stack); + } + } + } + + Future _requestPasswordReset(BuildContext context, Uri instanceUrl, String email) async { + final builder = UriBuilder.fromUri(instanceUrl); + builder.path = '${builder.path}/api/v1/auth/request-password-reset'; + final request = MultipartRequest('post', builder.build()); + request.fields['email'] = email; + + final responseString = await sendRequest(context, 'Requesting Password Reset', request); + if (responseString == null) return; + if (context.mounted) { + await showAlertDialog( + context, + title: 'Password Reset Requested', + ); + } + } } diff --git a/client/lib/util/base_uri_stub.dart b/client/lib/util/base_uri_stub.dart index 9aad66a3..c582367c 100644 --- a/client/lib/util/base_uri_stub.dart +++ b/client/lib/util/base_uri_stub.dart @@ -1 +1 @@ -Uri get webAppBaseUri => Uri(); +Uri? get webAppBaseUri => null;