better login flow

Co-authored-by: Henry-Hiles <henry@henryhiles.com>
This commit is contained in:
istalri 2026-06-05 17:28:53 -04:00 committed by Henry-Hiles
commit 27dca24889
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
10 changed files with 292 additions and 328 deletions

View file

@ -261,12 +261,12 @@ class ClientController extends AsyncNotifier<int> {
} }
} }
Future<String?> discoverHomeserver(Uri homeserver) async { Future<Uri?> discoverHomeserver(Uri homeserver) async {
try { try {
final response = await _sendCommand("discover_homeserver", { final response = await _sendCommand("discover_homeserver", {
"user_id": "@fake-user:${homeserver.host}", "user_id": "@fake-user:${homeserver.host}",
}); });
return response["m.homeserver"]?["base_url"]; return Uri.parse(response["m.homeserver"]?["base_url"]);
} catch (error) { } catch (error) {
return null; return null;
} }

View file

@ -0,0 +1,2 @@
String? requiredValidator(String? value) =>
value == null || value.isEmpty ? "This field is required" : null;

View file

@ -11,7 +11,7 @@ import "package:nexus/controllers/shared_prefs_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/scheme_to_theme.dart"; import "package:nexus/helpers/extensions/scheme_to_theme.dart";
import "package:nexus/pages/chat_page.dart"; import "package:nexus/pages/chat_page.dart";
import "package:nexus/pages/login_page.dart"; import "package:nexus/pages/select_server_page.dart";
import "package:nexus/pages/verify_page.dart"; import "package:nexus/pages/verify_page.dart";
import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
@ -124,7 +124,7 @@ class App extends StatelessWidget {
} }
if (!clientState.isLoggedIn) { if (!clientState.isLoggedIn) {
return LoginPage(); return SelectServerPage();
} else if (!clientState.isVerified) { } else if (!clientState.isVerified) {
return VerifyPage(); return VerifyPage();
} else { } else {

View file

@ -1,205 +1,95 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:flutter_svg/flutter_svg.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/models/homeserver.dart";
import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/divider_text.dart"; import "package:nexus/helpers/required_validator_helper.dart";
import "package:nexus/widgets/loading.dart";
class LoginPage extends HookConsumerWidget { class LoginPage extends HookConsumerWidget {
const LoginPage({super.key}); final Uri homeserver;
const LoginPage({super.key, required this.homeserver});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final client = ref.watch(ClientController.provider.notifier); final client = ref.watch(ClientController.provider.notifier);
final isLoading = useState(false); final isLoading = useState(false);
final homeserver = useState<String?>(null);
final launch = ref.watch(LaunchHelper.provider).launchUrl;
Future<void> setHomeserver(Uri? newHomeserver) async {
isLoading.value = true;
homeserver.value = newHomeserver == null
? null
: await client.discoverHomeserver(
newHomeserver.hasScheme
? newHomeserver
: Uri.https(newHomeserver.path),
);
if (homeserver.value == null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
.new(
content: Text(
"Homeserver verification failed. Is your homeserver down?",
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
backgroundColor: theme.colorScheme.errorContainer,
),
);
}
isLoading.value = false;
}
final homeserverUrl = useTextEditingController();
final username = useTextEditingController(); final username = useTextEditingController();
final password = useTextEditingController(); final password = useTextEditingController();
final inputError = useState<String?>(null);
final formKey = useRef(GlobalKey<FormState>());
Future<void> tryLogin() async {
isLoading.value = true;
try {
if (formKey.value.currentState?.validate() != true) return;
final error = await client.login(
.new(
username: username.text,
password: password.text,
homeserverUrl: homeserver.origin,
),
);
if (error != null) {
inputError.value = error;
isLoading.value = false;
} else {
if (context.mounted) Navigator.of(context).pop();
}
} finally {
isLoading.value = false;
}
}
return Scaffold( return Scaffold(
appBar: Appbar(), appBar: Appbar(
body: Center( leading: IconButton(
child: ConstrainedBox( icon: Icon(Icons.arrow_back),
constraints: .new(maxWidth: 600), onPressed: Navigator.of(context).pop,
child: ListView( ),
padding: .symmetric(horizontal: 16, vertical: 64), ),
body: AlertDialog(
title: Text("Login to ${homeserver.host}"),
content: Form(
key: formKey.value,
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [ children: [
Row( TextFormField(
children: [ autofocus: true,
SvgPicture.asset("assets/icon.svg", width: 128), textInputAction: .next,
SizedBox(width: 12), autovalidateMode: .onUserInteraction,
Expanded( validator: requiredValidator,
child: Column( decoration: .new(label: Text("Username")),
crossAxisAlignment: .start, controller: username,
children: [
Text("Nexus", style: theme.textTheme.displayMedium),
Text(
"A Simple Matrix Client",
style: theme.textTheme.headlineMedium,
overflow: .ellipsis,
),
],
),
),
],
), ),
Padding(padding: .symmetric(vertical: 12), child: Divider()), SizedBox(height: 12),
TextFormField(
DividerText("Enter a homeserver domain:"), textInputAction: .done,
Row( decoration: .new(
spacing: 8, label: Text("Password"),
children: [ errorText: inputError.value,
Expanded( errorMaxLines: 5,
child: TextField( ),
controller: homeserverUrl, autovalidateMode: .onUserInteraction,
decoration: .new( validator: requiredValidator,
labelText: "Homeserver URL (e.g. matrix.org)", controller: password,
), obscureText: true,
),
),
IconButton.filled(
tooltip: "Confirm homeserver choice",
onPressed: isLoading.value
? null
: () => setHomeserver(.tryParse(homeserverUrl.text)),
icon: Icon(Icons.check),
),
],
), ),
DividerText("Or, choose from some popular homeservers:"),
...(<Homeserver>[
.new(
name: "Matrix.org",
description:
"The Matrix.org Foundation offers the matrix.org homeserver as an easy entry point for anyone wanting to try out Matrix.",
url: .https("matrix.org"),
iconUrl:
"https://raw.githubusercontent.com/element-hq/logos/refs/heads/master/matrix/matrix-favicon${Theme.brightnessOf(context) == Brightness.dark ? "-white" : ""}.png",
),
.new(
name: "Federated Nexus",
description:
"Federated Nexus is a community resource hosting multiple FOSS (especially federated) services, including Matrix and Forgejo. By the same developers who made Nexus client.",
url: .https("federated.nexus"),
iconUrl: "https://federated.nexus/images/icon.png",
),
.new(
name: "Unredacted",
description:
"Unredacted is a 501(c)(3) non-profit organization that builds Internet infrastructure and services to help people evade censorship and protect their right to privacy.",
url: .https("unredacted.org", "services/si/matrix"),
iconUrl: "https://unredacted.org/favicon.ico",
),
].map(
(homeserver) => Card(
child: ListTile(
title: Text(homeserver.name),
leading: Image.network(
homeserver.iconUrl,
errorBuilder: (_, _, _) => SizedBox.shrink(),
height: 32,
),
subtitle: Text(homeserver.description),
onTap: isLoading.value
? null
: () => setHomeserver(homeserver.url),
trailing: IconButton(
tooltip: "Launch homeserver info page",
onPressed: () => launch(homeserver.url),
icon: Icon(Icons.info_outline),
),
),
),
)),
SizedBox(height: 8),
TextButton(
onPressed: () => launch(.https("servers.joinmatrix.org")),
child: Text("See more homeservers..."),
),
if (isLoading.value)
Padding(padding: .only(top: 32), child: Loading())
else if (homeserver.value != null) ...[
DividerText("Then, sign in:"),
SizedBox(height: 4),
TextField(
decoration: .new(label: Text("Username")),
controller: username,
),
SizedBox(height: 12),
TextField(
decoration: .new(label: Text("Password")),
controller: password,
obscureText: true,
),
SizedBox(height: 12),
ElevatedButton(
onPressed: () async {
isLoading.value = true;
final error = await client.login(
.new(
username: username.text,
password: password.text,
homeserverUrl: homeserver.value!,
),
);
if (error != null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
.new(
content: Text(
"Login failed. Is your password right?\nError: $error",
style: .new(
color: theme.colorScheme.onErrorContainer,
),
),
backgroundColor: theme.colorScheme.errorContainer,
),
);
isLoading.value = false;
}
},
child: Text("Sign In"),
),
],
], ],
), ),
), ),
actions: [
TextButton(
onPressed: isLoading.value ? null : tryLogin,
child: Text("Sign In"),
),
],
), ),
); );
} }

View file

@ -0,0 +1,169 @@
import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:flutter_svg/flutter_svg.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/models/homeserver.dart";
import "package:nexus/pages/login_page.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/divider_text.dart";
class SelectServerPage extends HookConsumerWidget {
const SelectServerPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final launch = ref.watch(LaunchHelper.provider).launchUrl;
final isLoading = useState(false);
final homeserverUrl = useTextEditingController();
Future<void> setHomeserver(Uri? newHomeserver) async {
isLoading.value = true;
try {
if (newHomeserver?.hasScheme == false) {
newHomeserver = Uri.https(newHomeserver!.path);
}
final newUrl = newHomeserver == null
? null
: await ref
.watch(ClientController.provider.notifier)
.discoverHomeserver(newHomeserver);
if (context.mounted) {
if (newUrl == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"Homeserver verification failed. Is your homeserver down?",
style: .new(color: theme.colorScheme.onErrorContainer),
),
backgroundColor: theme.colorScheme.errorContainer,
),
);
} else {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => LoginPage(homeserver: newUrl)),
);
}
}
} finally {
isLoading.value = false;
}
}
return Scaffold(
appBar: Appbar(),
body: Center(
child: ConstrainedBox(
constraints: .new(maxWidth: 600),
child: ListView(
children: [
Row(
children: [
SvgPicture.asset("assets/icon.svg", width: 128),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: .start,
children: [
Text("Nexus", style: theme.textTheme.displayMedium),
Text(
"A Simple Matrix Client",
style: theme.textTheme.headlineMedium,
overflow: .ellipsis,
),
],
),
),
],
),
Padding(padding: .symmetric(vertical: 12), child: Divider()),
DividerText("Enter a homeserver domain:"),
Row(
spacing: 8,
children: [
Expanded(
child: TextField(
textInputAction: .done,
autofocus: true,
onSubmitted: (text) => setHomeserver(.tryParse(text)),
controller: homeserverUrl,
decoration: .new(
labelText: "Homeserver URL",
hintText: "matrix.org",
),
),
),
IconButton.filled(
tooltip: "Confirm homeserver choice",
onPressed: isLoading.value
? null
: () => setHomeserver(.tryParse(homeserverUrl.text)),
icon: Icon(Icons.check),
),
],
),
DividerText("Or, choose from some popular homeservers:"),
...(<Homeserver>[
.new(
name: "Matrix.org",
description:
"The Matrix.org Foundation offers the matrix.org homeserver as an easy entry point for anyone wanting to try out Matrix.",
url: .https("matrix.org"),
iconUrl:
"https://raw.githubusercontent.com/element-hq/logos/refs/heads/master/matrix/matrix-favicon${Theme.brightnessOf(context) == Brightness.dark ? "-white" : ""}.png",
),
.new(
name: "Federated Nexus",
description:
"Federated Nexus is a community resource hosting multiple FOSS (especially federated) services, including Matrix and Forgejo. By the same developers who made Nexus client.",
url: .https("federated.nexus"),
iconUrl: "https://federated.nexus/images/icon.png",
),
.new(
name: "Unredacted",
description:
"Unredacted is a 501(c)(3) non-profit organization that builds Internet infrastructure and services to help people evade censorship and protect their right to privacy.",
url: .https("unredacted.org", "services/si/matrix"),
iconUrl: "https://unredacted.org/favicon.ico",
),
].map(
(homeserver) => Card(
child: ListTile(
enabled: !isLoading.value,
title: Text(homeserver.name),
leading: Image.network(
homeserver.iconUrl,
errorBuilder: (_, _, _) => SizedBox.shrink(),
height: 32,
),
subtitle: Text(homeserver.description),
onTap: isLoading.value
? null
: () => setHomeserver(homeserver.url),
trailing: IconButton(
tooltip: "Launch homeserver info page",
onPressed: () => launch(homeserver.url),
icon: Icon(Icons.info_outline),
),
),
),
)),
TextButton(
onPressed: () => launch(.https("servers.joinmatrix.org")),
child: Text("See more homeservers..."),
),
],
),
),
),
);
}
}

View file

@ -3,7 +3,7 @@ import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/form_text_input.dart"; import "package:nexus/helpers/required_validator_helper.dart";
class VerifyPage extends HookConsumerWidget { class VerifyPage extends HookConsumerWidget {
const VerifyPage({super.key}); const VerifyPage({super.key});
@ -11,70 +11,56 @@ class VerifyPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final passphraseController = useTextEditingController(); final passphraseController = useTextEditingController();
final isVerifying = useState(false); final isLoading = useState(false);
final inputError = useState<String?>(null);
final formKey = useRef(GlobalKey<FormState>());
return Scaffold( return Scaffold(
appBar: Appbar(), appBar: Appbar(),
body: AlertDialog( body: AlertDialog(
title: Text("Verify"), title: Text("Verify"),
content: Column( content: Form(
mainAxisSize: .min, key: formKey.value,
crossAxisAlignment: .start, child: Column(
children: [ mainAxisSize: .min,
Text( crossAxisAlignment: .start,
"Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.", children: [
), Text(
SizedBox(height: 12), "Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.",
FormTextInput( ),
required: false, SizedBox(height: 12),
autofocus: true, TextFormField(
capitalize: true, autofocus: true,
controller: passphraseController, controller: passphraseController,
obscure: true, textInputAction: .done,
title: "Recovery Key or Passphrase", autovalidateMode: .onUserInteraction,
), validator: requiredValidator,
], obscureText: true,
decoration: .new(
label: Text("Recovery Key or Passphrase"),
errorText: inputError.value,
),
),
],
),
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: isVerifying.value onPressed: isLoading.value
? null ? null
: () async { : () async {
final scaffoldMessenger = ScaffoldMessenger.of(context); isLoading.value = true;
final snackbar = scaffoldMessenger.showSnackBar(
.new(
content: Text(
"Attempting to verify with recovery key...",
),
duration: .new(days: 999),
),
);
isVerifying.value = true; try {
if (formKey.value.currentState?.validate() != true) {
final error = await ref return;
.watch(ClientController.provider.notifier)
.verify(passphraseController.text);
snackbar.close();
if (error != null) {
isVerifying.value = false;
if (context.mounted) {
scaffoldMessenger.showSnackBar(
.new(
backgroundColor: Theme.of(
context,
).colorScheme.errorContainer,
content: Text(
"Verification failed. Is your passphrase correct?\nError: $error",
style: .new(
color: Theme.of(
context,
).colorScheme.onErrorContainer,
),
),
),
);
} }
inputError.value = await ref
.watch(ClientController.provider.notifier)
.verify(passphraseController.text);
} finally {
isLoading.value = false;
} }
}, },
child: Text("Verify"), child: Text("Verify"),

View file

@ -1,81 +0,0 @@
import "package:flutter/material.dart";
import "package:flutter/services.dart";
class FormTextInput extends StatelessWidget {
final List<String? Function(String value)> extraValidators;
final TextEditingController? controller;
final TextInputType keyboardType;
final String? initialValue;
final bool readOnly;
final bool obscure;
final String? title;
final int? minLines;
final int? maxLength;
final bool outlined;
final int? maxLines;
final bool capitalize;
final bool required;
final bool autocorrect;
final void Function()? onTap;
final Widget? trailing;
final InputBorder? border;
final List<TextInputFormatter>? formatters;
final bool autofocus;
const FormTextInput({
super.key,
this.border,
this.controller,
this.autofocus = false,
this.title,
this.obscure = false,
this.readOnly = false,
this.extraValidators = const [],
this.keyboardType = TextInputType.text,
this.initialValue,
this.minLines,
this.capitalize = false,
this.maxLength,
this.formatters,
this.maxLines = 1,
this.outlined = true,
this.trailing,
this.onTap,
this.autocorrect = true,
this.required = true,
});
@override
Widget build(BuildContext context) => TextFormField(
autofocus: autofocus,
controller: controller,
keyboardType: keyboardType,
readOnly: readOnly,
minLines: minLines,
maxLines: maxLines,
maxLength: maxLength,
inputFormatters: formatters,
textCapitalization: capitalize ? .sentences : .none,
initialValue: initialValue,
autocorrect: autocorrect,
obscureText: obscure,
onTap: onTap,
decoration: .new(
labelText: title,
border: border ?? (outlined ? null : const UnderlineInputBorder()),
suffixIcon: trailing,
),
validator: (value) {
if ((value?.isEmpty ?? true) && required) {
return "This field is required";
}
for (final validator in extraValidators) {
final reason = validator(value!);
if (reason != null) return reason;
}
return null;
},
);
}

View file

@ -6,7 +6,6 @@ import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/key_controller.dart"; import "package:nexus/controllers/key_controller.dart";
import "package:nexus/controllers/spaces_controller.dart"; import "package:nexus/controllers/spaces_controller.dart";
import "package:nexus/helpers/extensions/link_to_mention.dart"; import "package:nexus/helpers/extensions/link_to_mention.dart";
import "package:nexus/widgets/form_text_input.dart";
class JoinDialog extends HookWidget { class JoinDialog extends HookWidget {
final WidgetRef ref; final WidgetRef ref;
@ -23,11 +22,9 @@ class JoinDialog extends HookWidget {
children: [ children: [
Text("Enter the room alias, Matrix URI, or Matrix.to link."), Text("Enter the room alias, Matrix URI, or Matrix.to link."),
SizedBox(height: 12), SizedBox(height: 12),
FormTextInput( TextField(
required: false,
capitalize: true,
controller: roomAlias, controller: roomAlias,
title: "#room:server", decoration: .new(hintText: "#room:server"),
), ),
], ],
), ),

View file

@ -22,7 +22,6 @@ import "package:nexus/widgets/member_list.dart";
import "package:nexus/widgets/room_appbar.dart"; import "package:nexus/widgets/room_appbar.dart";
import "package:nexus/widgets/flash_wrapper.dart"; import "package:nexus/widgets/flash_wrapper.dart";
import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/form_text_input.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
import "package:super_sliver_list/super_sliver_list.dart"; import "package:super_sliver_list/super_sliver_list.dart";
@ -288,11 +287,12 @@ class RoomChat extends HookConsumerWidget {
"Are you sure you want to delete this message? This can not be reversed.", "Are you sure you want to delete this message? This can not be reversed.",
), ),
SizedBox(height: 12), SizedBox(height: 12),
FormTextInput( TextField(
required: false,
capitalize: true,
controller: deleteReasonController, controller: deleteReasonController,
title: "Reason for deletion (optional)", textCapitalization: .sentences,
decoration: .new(
labelText: "Reason for deletion (optional)",
),
), ),
], ],
), ),
@ -340,11 +340,12 @@ class RoomChat extends HookConsumerWidget {
), ),
SizedBox(height: 12), SizedBox(height: 12),
FormTextInput( TextField(
required: false,
capitalize: true,
controller: reasonController, controller: reasonController,
title: "Reason for report (optional)", textCapitalization: .sentences,
decoration: .new(
labelText: "Reason for report (optional)",
),
), ),
], ],
), ),

View file

@ -14,7 +14,6 @@ import "package:nexus/models/requests/membership_action.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/form_text_input.dart";
class UserPopover extends ConsumerWidget { class UserPopover extends ConsumerWidget {
final MembershipContent member; final MembershipContent member;
@ -41,11 +40,12 @@ class UserPopover extends ConsumerWidget {
children: [ children: [
Text("Are you sure you want to ${action.name} $userId?"), Text("Are you sure you want to ${action.name} $userId?"),
SizedBox(height: 12), SizedBox(height: 12),
FormTextInput( TextField(
required: false, textCapitalization: .sentences,
capitalize: true,
controller: actionReasonController, controller: actionReasonController,
title: "Reason for ${action.name} (optional)", decoration: .new(
labelText: "Reason for ${action.name} (optional)",
),
), ),
], ],
), ),