feat: New registration workflow

This commit is contained in:
Christian Pauly 2021-06-06 16:55:31 +02:00
parent afa1003e44
commit f6082c5bac
9 changed files with 167 additions and 414 deletions

View File

@ -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": {}
},

View File

@ -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;

View File

@ -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(),

View File

@ -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,

View File

@ -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<SignUp> {
final TextEditingController usernameController = TextEditingController();
String usernameError;
bool loading = false;
static MatrixFile avatar;
LoginTypes _loginTypes;
Map<String, dynamic> _rawLoginTypes;
bool registrationSupported;
StreamSubscription _intentDataStreamSubscription;
List<IdentityProvider> 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<SignUp> {
_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<LoginTypes> getLoginTypes() async {
_loginTypes ??= await Matrix.of(context).client.getLoginFlows();
return _loginTypes;
Future<Map<String, dynamic>> 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<String, dynamic> json) =>
IdentityProvider(
id: json['id'],
name: json['name'],
icon: json['icon'],
brand: json['brand'],
);
}

View File

@ -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<SignUpPassword> {
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);
}

View File

@ -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: <Widget>[
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),
),
),
),
],
),
),
);
}
}

View File

@ -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(),
),
),
]);
}),

View File

@ -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(),
),
);
});
}