From f6082c5bacd9d5aae9a547333aedd43773e6d748 Mon Sep 17 00:00:00 2001 From: Christian Pauly Date: Sun, 6 Jun 2021 16:55:31 +0200 Subject: [PATCH] feat: New registration workflow --- assets/l10n/intl_en.arb | 16 +- lib/config/app_config.dart | 2 +- lib/config/routes.dart | 6 - lib/config/themes.dart | 14 ++ lib/pages/sign_up.dart | 134 +++++++------- lib/pages/sign_up_password.dart | 118 ------------- lib/pages/views/sign_up_password_view.dart | 84 --------- lib/pages/views/sign_up_view.dart | 194 ++++++++------------- test/sign_up_password_test.dart | 13 -- 9 files changed, 167 insertions(+), 414 deletions(-) delete mode 100644 lib/pages/sign_up_password.dart delete mode 100644 lib/pages/views/sign_up_password_view.dart delete mode 100644 test/sign_up_password_test.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index a9c69ca2..ab7037b1 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -1131,6 +1131,11 @@ "type": "text", "placeholders": {} }, + "register": "Register", + "@register": { + "type": "text", + "placeholders": {} + }, "logInTo": "Log in to {homeserver}", "@logInTo": { "type": "text", @@ -1938,8 +1943,15 @@ "type": "text", "placeholders": {} }, - "useSSO": "Use single sign on", - "@useSSO": { + "loginWith": "Login with {brand}", + "@loginWith": { + "type": "text", + "placeholders": { + "brand": {} + } + }, + "singlesignon": "Single Sign on", + "@singlesignon": { "type": "text", "placeholders": {} }, diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 1d82cfd7..90d598a5 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -5,7 +5,7 @@ abstract class AppConfig { static String get applicationName => _applicationName; static String _applicationWelcomeMessage; static String get applicationWelcomeMessage => _applicationWelcomeMessage; - static String _defaultHomeserver = 'tchncs.de'; + static String _defaultHomeserver = 'matrix.org'; static String get defaultHomeserver => _defaultHomeserver; static String jitsiInstance = 'https://meet.jit.si/'; static double fontSizeFactor = 1.0; diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 9fd35701..75488237 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -4,7 +4,6 @@ import 'package:fluffychat/pages/invitation_selection.dart'; import 'package:fluffychat/pages/settings_emotes.dart'; import 'package:fluffychat/pages/settings_multiple_emotes.dart'; import 'package:fluffychat/pages/sign_up.dart'; -import 'package:fluffychat/pages/sign_up_password.dart'; import 'package:fluffychat/widgets/layouts/side_view_layout.dart'; import 'package:fluffychat/widgets/layouts/two_column_layout.dart'; import 'package:fluffychat/pages/chat.dart'; @@ -204,11 +203,6 @@ class AppRoutes { widget: SignUp(), buildTransition: _fadeTransition, stackedRoutes: [ - VWidget( - path: 'password/:username', - widget: SignUpPassword(), - buildTransition: _fadeTransition, - ), VWidget( path: '/login', widget: Login(), diff --git a/lib/config/themes.dart b/lib/config/themes.dart index 9a991820..eb31f3c5 100644 --- a/lib/config/themes.dart +++ b/lib/config/themes.dart @@ -59,6 +59,13 @@ abstract class FluffyThemes { borderRadius: BorderRadius.circular(AppConfig.borderRadius), ), ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + ), + ), + ), popupMenuTheme: PopupMenuThemeData( elevation: 4, shape: RoundedRectangleBorder( @@ -170,6 +177,13 @@ abstract class FluffyThemes { ), ), ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + ), + ), + ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( primary: AppConfig.primaryColor, diff --git a/lib/pages/sign_up.dart b/lib/pages/sign_up.dart index 9e721876..7107f7c4 100644 --- a/lib/pages/sign_up.dart +++ b/lib/pages/sign_up.dart @@ -1,8 +1,6 @@ import 'dart:async'; -import 'dart:io'; import 'package:famedlysdk/famedlysdk.dart'; -import 'package:file_picker_cross/file_picker_cross.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/views/sign_up_view.dart'; import 'package:fluffychat/utils/platform_infos.dart'; @@ -11,7 +9,6 @@ import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:uni_links/uni_links.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -26,13 +23,18 @@ class SignUp extends StatefulWidget { } class SignUpController extends State { - final TextEditingController usernameController = TextEditingController(); - String usernameError; - bool loading = false; - static MatrixFile avatar; - - LoginTypes _loginTypes; + Map _rawLoginTypes; + bool registrationSupported; StreamSubscription _intentDataStreamSubscription; + List get identityProviders { + if (!ssoLoginSupported) return []; + final rawProviders = _rawLoginTypes.tryGetList('flows').singleWhere( + (flow) => + flow['type'] == AuthenticationTypes.sso)['identity_providers']; + return (rawProviders as List) + .map((json) => IdentityProvider.fromJson(json)) + .toList(); + } void _loginWithToken(String token) { if (token?.isEmpty ?? true) return; @@ -82,82 +84,68 @@ class SignUpController extends State { _intentDataStreamSubscription?.cancel(); } - bool get passwordLoginSupported => _loginTypes.flows - .any((flow) => flow.type == AuthenticationTypes.password); + bool get passwordLoginSupported => + Matrix.of(context) + .client + .supportedLoginTypes + .contains(AuthenticationTypes.password) && + _rawLoginTypes + .tryGetList('flows') + .any((flow) => flow['type'] == AuthenticationTypes.password); bool get ssoLoginSupported => - _loginTypes.flows.any((flow) => flow.type == AuthenticationTypes.sso); + Matrix.of(context) + .client + .supportedLoginTypes + .contains(AuthenticationTypes.sso) && + _rawLoginTypes + .tryGetList('flows') + .any((flow) => flow['type'] == AuthenticationTypes.sso); - Future getLoginTypes() async { - _loginTypes ??= await Matrix.of(context).client.getLoginFlows(); - return _loginTypes; + Future> getLoginTypes() async { + _rawLoginTypes ??= await Matrix.of(context).client.request( + RequestType.GET, + '/client/r0/login', + ); + if (registrationSupported == null) { + try { + await Matrix.of(context).client.register(); + registrationSupported = true; + } on MatrixException catch (e) { + registrationSupported = e.requireAdditionalAuthentication ?? false; + } + } + return _rawLoginTypes; } - void ssoLoginAction() { - if (!kIsWeb && !PlatformInfos.isMobile) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Single sign on is not suppored on ${Platform.operatingSystem}'), - ), - ); - return; - } + void ssoLoginAction(String id) { final redirectUrl = kIsWeb ? html.window.location.href : AppConfig.appOpenUrlScheme.toLowerCase() + '://sso'; launch( - '${Matrix.of(context).client.homeserver?.toString()}/_matrix/client/r0/login/sso/redirect?redirectUrl=${Uri.encodeQueryComponent(redirectUrl)}'); + '${Matrix.of(context).client.homeserver?.toString()}/_matrix/client/r0/login/sso/redirect/${Uri.encodeComponent(id)}?redirectUrl=${Uri.encodeQueryComponent(redirectUrl)}'); } - void setAvatarAction() async { - final file = - await FilePickerCross.importFromStorage(type: FileTypeCross.image); - if (file != null) { - setState( - () => avatar = MatrixFile( - bytes: file.toUint8List(), - name: file.fileName, - ), - ); - } - } - - void resetAvatarAction() => setState(() => avatar = null); - - void signUpAction([_]) async { - final matrix = Matrix.of(context); - if (usernameController.text.isEmpty) { - setState(() => usernameError = L10n.of(context).pleaseChooseAUsername); - } else { - setState(() => usernameError = null); - } - - if (usernameController.text.isEmpty) { - return; - } - setState(() => loading = true); - - final preferredUsername = - usernameController.text.toLowerCase().trim().replaceAll(' ', '-'); - - try { - await matrix.client.checkUsernameAvailability(preferredUsername); - } on MatrixException catch (exception) { - setState(() => usernameError = exception.errorMessage); - return setState(() => loading = false); - } catch (exception) { - setState(() => usernameError = exception.toString()); - return setState(() => loading = false); - } - setState(() => loading = false); - - VRouter.of(context).push( - '/signup/password/${Uri.encodeComponent(preferredUsername)}', - queryParameters: {'displayname': usernameController.text}, - ); - } + void signUpAction() => launch( + '${Matrix.of(context).client.homeserver?.toString()}/_matrix/static/client/register'); @override Widget build(BuildContext context) => SignUpView(this); } + +class IdentityProvider { + final String id; + final String name; + final String icon; + final String brand; + + IdentityProvider({this.id, this.name, this.icon, this.brand}); + + factory IdentityProvider.fromJson(Map json) => + IdentityProvider( + id: json['id'], + name: json['name'], + icon: json['icon'], + brand: json['brand'], + ); +} diff --git a/lib/pages/sign_up_password.dart b/lib/pages/sign_up_password.dart deleted file mode 100644 index cce598bd..00000000 --- a/lib/pages/sign_up_password.dart +++ /dev/null @@ -1,118 +0,0 @@ -import 'package:adaptive_dialog/adaptive_dialog.dart'; - -import 'package:email_validator/email_validator.dart'; - -import 'package:famedlysdk/famedlysdk.dart'; -import 'package:fluffychat/pages/sign_up.dart'; -import 'package:fluffychat/utils/get_client_secret.dart'; -import 'package:fluffychat/pages/views/sign_up_password_view.dart'; - -import 'package:fluffychat/widgets/matrix.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:vrouter/vrouter.dart'; -import '../utils/platform_infos.dart'; - -class SignUpPassword extends StatefulWidget { - const SignUpPassword(); - @override - SignUpPasswordController createState() => SignUpPasswordController(); -} - -class SignUpPasswordController extends State { - final TextEditingController passwordController = TextEditingController(); - final TextEditingController emailController = TextEditingController(); - String passwordError; - String emailError; - bool loading = false; - bool showPassword = true; - - void toggleShowPassword() => setState(() => showPassword = !showPassword); - - void signUpAction() async { - final matrix = Matrix.of(context); - if (passwordController.text.isEmpty) { - setState(() => passwordError = L10n.of(context).pleaseEnterYourPassword); - } else { - setState(() => passwordError = emailError = null); - } - - if (passwordController.text.isEmpty) { - return; - } - - try { - setState(() => loading = true); - if (emailController.text.isNotEmpty) { - emailController.text = emailController.text.trim(); - if (!EmailValidator.validate(emailController.text)) { - setState(() => emailError = L10n.of(context).invalidEmail); - return; - } - matrix.currentClientSecret = getClientSecret(30); - Logs().d('Request email token'); - matrix.currentThreepidCreds = await matrix.client.requestEmailToken( - emailController.text, - matrix.currentClientSecret, - 1, - ); - if (OkCancelResult.ok != - await showOkCancelAlertDialog( - useRootNavigator: false, - context: context, - message: L10n.of(context).weSentYouAnEmail, - okLabel: L10n.of(context).confirm, - cancelLabel: L10n.of(context).cancel, - )) { - matrix.currentClientSecret = matrix.currentThreepidCreds = null; - setState(() => loading = false); - return; - } - } - final waitForLogin = matrix.client.onLoginStateChanged.stream.first; - final username = VRouter.of(context).pathParameters['username']; - - await matrix.client.uiaRequestBackground((auth) => matrix.client.register( - username: username, - password: passwordController.text, - initialDeviceDisplayName: PlatformInfos.clientName, - auth: auth, - )); - if (matrix.currentClientSecret != null && - matrix.currentThreepidCreds != null) { - Logs().d('Add third party identifier'); - await matrix.client.add3PID( - matrix.currentClientSecret, - matrix.currentThreepidCreds.sid, - ); - } - await waitForLogin; - } catch (exception) { - setState(() => emailError = exception.toString()); - return setState(() => loading = false); - } - await matrix.client.onLoginStateChanged.stream - .firstWhere((l) => l == LoginState.logged); - final displayname = VRouter.of(context).queryParameters['displayname']; - if (displayname != null) { - try { - await matrix.client.setDisplayName(matrix.client.userID, displayname); - } catch (exception) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(L10n.of(context).couldNotSetDisplayname))); - } - } - if (SignUpController.avatar != null) { - try { - await matrix.client.setAvatar(SignUpController.avatar); - } catch (exception) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(L10n.of(context).couldNotSetAvatar))); - } - } - if (mounted) setState(() => loading = false); - } - - @override - Widget build(BuildContext context) => SignUpPasswordView(this); -} diff --git a/lib/pages/views/sign_up_password_view.dart b/lib/pages/views/sign_up_password_view.dart deleted file mode 100644 index 6abf741d..00000000 --- a/lib/pages/views/sign_up_password_view.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:fluffychat/pages/sign_up_password.dart'; - -import 'package:fluffychat/widgets/layouts/one_page_card.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; - -class SignUpPasswordView extends StatelessWidget { - final SignUpPasswordController controller; - - const SignUpPasswordView(this.controller, {Key key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return OnePageCard( - child: Scaffold( - appBar: AppBar( - elevation: 0, - leading: controller.loading ? Container() : BackButton(), - title: Text( - L10n.of(context).chooseAStrongPassword, - ), - ), - body: ListView( - children: [ - Padding( - padding: const EdgeInsets.all(12.0), - child: TextField( - controller: controller.passwordController, - obscureText: !controller.showPassword, - autofocus: true, - readOnly: controller.loading, - autocorrect: false, - onSubmitted: (_) => controller.signUpAction, - autofillHints: - controller.loading ? null : [AutofillHints.newPassword], - decoration: InputDecoration( - prefixIcon: Icon(Icons.lock_outlined), - hintText: '****', - errorText: controller.passwordError, - suffixIcon: IconButton( - tooltip: L10n.of(context).showPassword, - icon: Icon(controller.showPassword - ? Icons.visibility_off_outlined - : Icons.visibility_outlined), - onPressed: controller.toggleShowPassword, - ), - labelText: L10n.of(context).password), - ), - ), - Padding( - padding: const EdgeInsets.all(12.0), - child: TextField( - controller: controller.emailController, - readOnly: controller.loading, - autocorrect: false, - keyboardType: TextInputType.emailAddress, - onSubmitted: (_) => controller.signUpAction, - decoration: InputDecoration( - prefixIcon: Icon(Icons.mail_outline_outlined), - errorText: controller.emailError, - hintText: 'email@example.com', - labelText: L10n.of(context).optionalAddEmail), - ), - ), - SizedBox(height: 12), - Hero( - tag: 'loginButton', - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 12), - child: ElevatedButton( - onPressed: - controller.loading ? null : controller.signUpAction, - child: controller.loading - ? LinearProgressIndicator() - : Text(L10n.of(context).createAccountNow), - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/pages/views/sign_up_view.dart b/lib/pages/views/sign_up_view.dart index 79ee65f7..21e146aa 100644 --- a/lib/pages/views/sign_up_view.dart +++ b/lib/pages/views/sign_up_view.dart @@ -1,3 +1,4 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/pages/sign_up.dart'; import 'package:fluffychat/widgets/fluffy_banner.dart'; @@ -9,6 +10,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import '../../utils/localized_exception_extension.dart'; +import 'package:famedlysdk/famedlysdk.dart'; + class SignUpView extends StatelessWidget { final SignUpController controller; @@ -20,7 +23,6 @@ class SignUpView extends StatelessWidget { child: Scaffold( appBar: AppBar( elevation: 0, - leading: controller.loading ? Container() : BackButton(), title: Text( Matrix.of(context) .client @@ -48,125 +50,83 @@ class SignUpView extends StatelessWidget { tag: 'loginBanner', child: FluffyBanner(), ), - SizedBox(height: 16), - if (controller.passwordLoginSupported) ...{ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: TextField( - readOnly: controller.loading, - autocorrect: false, - controller: controller.usernameController, - onSubmitted: controller.signUpAction, - autofillHints: controller.loading - ? null - : [AutofillHints.newUsername], - decoration: InputDecoration( - prefixIcon: Padding( - padding: const EdgeInsets.only( - left: 12.0, - right: 22, - ), - child: Icon(Icons.account_circle_outlined), - ), - hintText: L10n.of(context).username, - errorText: controller.usernameError, - labelText: L10n.of(context).chooseAUsername, - ), - ), - ), - SizedBox(height: 8), - ListTile( - leading: CircleAvatar( - backgroundImage: SignUpController.avatar == null - ? null - : MemoryImage(SignUpController.avatar.bytes), - backgroundColor: SignUpController.avatar == null - ? Theme.of(context).brightness == Brightness.dark - ? Color(0xff121212) - : Colors.white - : Theme.of(context).secondaryHeaderColor, - child: SignUpController.avatar == null - ? Icon(Icons.camera_alt_outlined, - color: Theme.of(context).primaryColor) - : null, - ), - trailing: SignUpController.avatar == null - ? null - : Icon( - Icons.close, - color: Colors.red, - ), - title: Text(SignUpController.avatar == null - ? L10n.of(context).setAProfilePicture - : L10n.of(context).discardPicture), - onTap: SignUpController.avatar == null - ? controller.setAvatarAction - : controller.resetAvatarAction, - ), - SizedBox(height: 16), - Hero( - tag: 'loginButton', - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 12), - child: ElevatedButton( - onPressed: - controller.loading ? null : controller.signUpAction, - child: controller.loading - ? LinearProgressIndicator() - : Text(L10n.of(context).signUp), - ), - ), - ), - Row( - children: [ - Expanded( - child: Container( - height: 1, - color: Theme.of(context).dividerColor, - )), - Padding( - padding: const EdgeInsets.all(12.0), - child: Text(L10n.of(context).or), - ), - Expanded( - child: Container( - height: 1, - color: Theme.of(context).dividerColor, - )), - ], - ), - }, Padding( - padding: EdgeInsets.symmetric(horizontal: 12), - child: Row(children: [ - if (controller.passwordLoginSupported) - Expanded( - child: ElevatedButton( - style: ElevatedButton.styleFrom( - primary: Theme.of(context).secondaryHeaderColor, - onPrimary: - Theme.of(context).textTheme.bodyText1.color, + padding: const EdgeInsets.all(12.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (controller.ssoLoginSupported) ...{ + for (final identityProvider + in controller.identityProviders) + OutlinedButton.icon( + onPressed: () => + controller.ssoLoginAction(identityProvider.id), + icon: identityProvider.icon == null + ? Icon(Icons.web_outlined) + : CachedNetworkImage( + imageUrl: Uri.parse(identityProvider.icon) + .getDownloadLink( + Matrix.of(context).client) + .toString(), + width: 24, + height: 24, + ), + label: Text(L10n.of(context).loginWith( + identityProvider.brand ?? + identityProvider.name ?? + L10n.of(context).singlesignon)), ), - onPressed: () => context.vRouter.push('/login'), - child: Text(L10n.of(context).login), - ), + if (controller.registrationSupported || + controller.passwordLoginSupported) + Row(children: [ + Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.all(12.0), + child: Text(L10n.of(context).or), + ), + Expanded(child: Divider()), + ]), + }, + Row( + children: [ + if (controller.passwordLoginSupported) + Expanded( + child: Container( + height: 64, + child: OutlinedButton.icon( + onPressed: () => + context.vRouter.push('/login'), + icon: Icon(Icons.login_outlined), + label: Text(L10n.of(context).login), + ), + ), + ), + if (controller.registrationSupported && + controller.passwordLoginSupported) + SizedBox(width: 12), + if (controller.registrationSupported) + Expanded( + child: Container( + height: 64, + child: OutlinedButton.icon( + onPressed: controller.signUpAction, + icon: Icon(Icons.add_box_outlined), + label: Text(L10n.of(context).register), + ), + ), + ), + ], ), - if (controller.passwordLoginSupported && - controller.ssoLoginSupported) - SizedBox(width: 12), - if (controller.ssoLoginSupported) - Expanded( - child: ElevatedButton( - style: ElevatedButton.styleFrom( - primary: Theme.of(context).secondaryHeaderColor, - onPrimary: - Theme.of(context).textTheme.bodyText1.color, - ), - onPressed: controller.ssoLoginAction, - child: Text(L10n.of(context).useSSO), - ), - ), - ]), + ] + .map( + (widget) => Container( + height: 64, + padding: EdgeInsets.only(bottom: 12), + child: widget), + ) + .toList(), + ), ), ]); }), diff --git a/test/sign_up_password_test.dart b/test/sign_up_password_test.dart deleted file mode 100644 index dd455935..00000000 --- a/test/sign_up_password_test.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:fluffychat/pages/sign_up_password.dart'; -import 'package:fluffychat/main.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - testWidgets('Test if the widget can be created', (WidgetTester tester) async { - await tester.pumpWidget( - FluffyChatApp( - testWidget: SignUpPassword(), - ), - ); - }); -}