parent
784e5254e2
commit
23046f08a9
8 changed files with 448 additions and 36 deletions
|
|
@ -2,9 +2,14 @@ import "package:material_ui/material_ui.dart";
|
|||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/models/client_state.dart";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||
import "package:nexus/widgets/loading.dart";
|
||||
import "package:nexus/widgets/pages/settings.dart";
|
||||
import "package:nexus/widgets/appbar.dart";
|
||||
import "package:nexus/helpers/required_validator_helper.dart";
|
||||
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
||||
|
||||
class const VerifyPage({super.key}) extends HookConsumerWidget {
|
||||
@override
|
||||
|
|
@ -13,6 +18,11 @@ class const VerifyPage({super.key}) extends HookConsumerWidget {
|
|||
final isLoading = useState(false);
|
||||
final inputError = useState<String?>(null);
|
||||
final formKey = useRef(GlobalKey<FormState>());
|
||||
final verificationState = ref.watch(
|
||||
ClientStateController.provider.select(
|
||||
(state) => state?.verificationState,
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> verify() async {
|
||||
isLoading.value = true;
|
||||
|
|
@ -40,45 +50,65 @@ class const VerifyPage({super.key}) extends HookConsumerWidget {
|
|||
),
|
||||
]),
|
||||
),
|
||||
body: AlertDialog(
|
||||
insetPadding: .symmetric(vertical: 12, horizontal: 32),
|
||||
scrollable: true,
|
||||
title: Text("Verify"),
|
||||
content: Form(
|
||||
key: formKey.value,
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Text(
|
||||
"Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.",
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
TextFormField(
|
||||
autofocus: true,
|
||||
controller: passphraseController,
|
||||
textInputAction: .done,
|
||||
autovalidateMode: .onUserInteraction,
|
||||
validator: requiredValidator,
|
||||
obscureText: true,
|
||||
decoration: .new(
|
||||
label: Text("Recovery Key or Passphrase"),
|
||||
errorText: inputError.value,
|
||||
),
|
||||
onFieldSubmitted: (_) => verify(),
|
||||
// Don't defocus on submit
|
||||
onEditingComplete: () {},
|
||||
),
|
||||
],
|
||||
body: switch (verificationState) {
|
||||
null || VerificationState(stateChecked: false) => const Loading(),
|
||||
// Nothing to verify with yet, so go straight to setting it up.
|
||||
VerificationState(hasSecretStorage: false) => Dialog(
|
||||
insetPadding: const .symmetric(vertical: 12, horizontal: 32),
|
||||
child: ConstrainedBox(
|
||||
constraints: const .new(maxWidth: 450),
|
||||
child: const ResetEncryptionSheet(isPage: true),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: isLoading.value ? null : verify,
|
||||
child: Text("Verify"),
|
||||
_ => AlertDialog(
|
||||
insetPadding: .symmetric(vertical: 12, horizontal: 32),
|
||||
scrollable: true,
|
||||
title: Text("Verify"),
|
||||
content: Form(
|
||||
key: formKey.value,
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Text(
|
||||
"Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.",
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
TextFormField(
|
||||
autofocus: true,
|
||||
controller: passphraseController,
|
||||
textInputAction: .done,
|
||||
autovalidateMode: .onUserInteraction,
|
||||
validator: requiredValidator,
|
||||
obscureText: true,
|
||||
decoration: .new(
|
||||
label: Text("Recovery Key or Passphrase"),
|
||||
errorText: inputError.value,
|
||||
),
|
||||
onFieldSubmitted: (_) => verify(),
|
||||
// Don't defocus on submit
|
||||
onEditingComplete: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => context.showAdaptiveSheet(
|
||||
(_) => const ResetEncryptionSheet(),
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text("Reset Identity"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: isLoading.value ? null : verify,
|
||||
child: Text("Verify"),
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
145
lib/widgets/recovery_key_sheet.dart
Normal file
145
lib/widgets/recovery_key_sheet.dart
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:flutter/services.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/helpers/launch_helper.dart";
|
||||
import "package:nexus/models/gomuks_exception.dart";
|
||||
import "package:nexus/models/recovery_key.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
|
||||
/// Shows a newly generated recovery key, and once the user confirms they've
|
||||
/// saved it, resets encryption with it. Pops with true if that succeeded.
|
||||
class const RecoveryKeySheet(
|
||||
final RecoveryKey recoveryKey, {
|
||||
required final bool isReset,
|
||||
super.key,
|
||||
}) extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final saved = useState(false);
|
||||
final isLoading = useState(false);
|
||||
final error = useState<String?>(null);
|
||||
final needsApproval = useState(false);
|
||||
final approvalUrl = useState<Uri?>(null);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
Future<void> reset() async {
|
||||
if (isLoading.value) return;
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.resetEncryption(recoveryKey);
|
||||
if (context.mounted) Navigator.of(context).pop(true);
|
||||
} catch (resetError) {
|
||||
if (resetError case GomuksException(statusCode: 401)) {
|
||||
// Open the approval page straight away the first time.
|
||||
if (!needsApproval.value) {
|
||||
needsApproval.value = true;
|
||||
final homeserver = ref
|
||||
.read(ClientStateController.provider)
|
||||
?.homeserverUrl;
|
||||
final url = homeserver == null
|
||||
? null
|
||||
: await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.getCrossSigningResetUrl(.parse(homeserver))
|
||||
.onError((_, _) => null);
|
||||
if (!context.mounted || url == null) return;
|
||||
|
||||
approvalUrl.value = url;
|
||||
await ref.read(LaunchHelper.provider).launchUrl(url);
|
||||
}
|
||||
} else {
|
||||
error.value = resetError.toString();
|
||||
}
|
||||
} finally {
|
||||
if (context.mounted) isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Approval happens on a web page that can't redirect back, so keep retrying
|
||||
// in the background until it goes through, or the sheet is closed.
|
||||
useEffect(() {
|
||||
if (!needsApproval.value) return null;
|
||||
return Timer.periodic(const .new(seconds: 3), (_) => reset()).cancel;
|
||||
}, [needsApproval.value]);
|
||||
|
||||
return SheetLayout(
|
||||
title: const Text("Save Your Recovery Key"),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: !saved.value || isLoading.value ? null : reset,
|
||||
style: isReset
|
||||
? TextButton.styleFrom(foregroundColor: colorScheme.error)
|
||||
: null,
|
||||
child: Text(
|
||||
needsApproval.value
|
||||
? "Try Again"
|
||||
: isReset
|
||||
? "Reset"
|
||||
: "Set Up",
|
||||
),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
const Text(
|
||||
"This is your new recovery key. Store it somewhere safe, like a password manager. You'll need it to verify new sessions.",
|
||||
),
|
||||
Card.filled(
|
||||
child: ListTile(
|
||||
title: SelectableText(
|
||||
recoveryKey.recoveryKey,
|
||||
style: const .new(fontFamily: "bundled-mono"),
|
||||
),
|
||||
trailing: IconButton(
|
||||
tooltip: "Copy recovery key",
|
||||
onPressed: () =>
|
||||
Clipboard.setData(.new(text: recoveryKey.recoveryKey)),
|
||||
icon: const Icon(Icons.copy),
|
||||
),
|
||||
),
|
||||
),
|
||||
CheckboxListTile(
|
||||
value: saved.value,
|
||||
onChanged: (value) => saved.value = value ?? false,
|
||||
title: const Text("I've saved my recovery key"),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: Durations.medium2,
|
||||
curve: Curves.easeInOut,
|
||||
alignment: .topCenter,
|
||||
child: needsApproval.value
|
||||
? Column(
|
||||
crossAxisAlignment: .stretch,
|
||||
spacing: 8,
|
||||
children: [
|
||||
const Text(
|
||||
"Your homeserver needs you to approve resetting your identity in your account settings. Once you've approved it, come back here and the reset will finish automatically.",
|
||||
),
|
||||
if (approvalUrl.value case final url?)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
ref.read(LaunchHelper.provider).launchUrl(url),
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: const Text("Approve in account settings"),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox(width: .infinity),
|
||||
),
|
||||
if (error.value case final message?)
|
||||
Text(message, style: .new(color: colorScheme.error)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
153
lib/widgets/reset_encryption_sheet.dart
Normal file
153
lib/widgets/reset_encryption_sheet.dart
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/widgets/recovery_key_sheet.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
|
||||
class const ResetEncryptionSheet({
|
||||
/// Whether this is shown as a page rather than in a sheet, in which case it
|
||||
/// can't be cancelled, and is replaced once encryption is set up.
|
||||
final bool isPage = false,
|
||||
super.key,
|
||||
}) extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isReset = ref.watch(
|
||||
ClientStateController.provider.select(
|
||||
(state) => state?.verificationState.hasCrossSigning ?? false,
|
||||
),
|
||||
);
|
||||
|
||||
final usePassphrase = useState(false);
|
||||
final passphraseController = useTextEditingController();
|
||||
final confirmationController = useTextEditingController();
|
||||
final passphrase = useValueListenable(passphraseController).text;
|
||||
final confirmation = useValueListenable(confirmationController).text;
|
||||
final mismatch = confirmation.isNotEmpty && confirmation != passphrase;
|
||||
final isLoading = useState(false);
|
||||
final error = useState<String?>(null);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return SheetLayout(
|
||||
title: Text(isReset ? "Reset Encryption Identity" : "Set Up Encryption"),
|
||||
actions: [
|
||||
if (!isPage)
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed:
|
||||
isLoading.value ||
|
||||
(usePassphrase.value &&
|
||||
(passphrase.isEmpty || passphrase != confirmation))
|
||||
? null
|
||||
: () async {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
final recoveryKey = await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.generateRecoveryKey(
|
||||
passphrase: usePassphrase.value ? passphrase : "",
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
final done = await context.showAdaptiveSheet<bool>(
|
||||
(_) => RecoveryKeySheet(recoveryKey, isReset: isReset),
|
||||
);
|
||||
if (done != true) return;
|
||||
if (context.mounted && !isPage) Navigator.of(context).pop();
|
||||
|
||||
// Shown from the root navigator, because on the verify page,
|
||||
// this is replaced by the chat once the session is verified.
|
||||
navigatorKey.currentContext?.showAdaptiveSheet(
|
||||
(context) => SheetLayout(
|
||||
title: Text(
|
||||
isReset ? "Identity Reset" : "Encryption Set Up",
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text("Done"),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
Text(
|
||||
isReset
|
||||
? "Your encryption identity has been reset, and this session is verified. You'll need to verify your other sessions again."
|
||||
: "Encryption is set up, and this session is verified. Use your recovery key to verify your other sessions.",
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} catch (generateError) {
|
||||
error.value = generateError.toString();
|
||||
} finally {
|
||||
if (context.mounted) isLoading.value = false;
|
||||
}
|
||||
},
|
||||
child: Text(isReset ? "Continue" : "Set Up"),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
Text(
|
||||
isReset
|
||||
? "This replaces your encryption identity with a new one. Your other sessions will need to be verified again, and any messages you can't currently decrypt may become unreadable."
|
||||
: "Set up encryption so you can verify your other sessions, and get back your encrypted messages if you lose access to this one.",
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.password),
|
||||
title: const Text("Use a passphrase"),
|
||||
subtitle: const Text("Optional, as well as your recovery key"),
|
||||
value: usePassphrase.value,
|
||||
onChanged: (value) => usePassphrase.value = value,
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: Durations.medium2,
|
||||
curve: Curves.easeInOut,
|
||||
alignment: .topCenter,
|
||||
child: usePassphrase.value
|
||||
? Padding(
|
||||
padding: const .only(top: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: .stretch,
|
||||
spacing: 12,
|
||||
children: [
|
||||
const Text(
|
||||
"Your passphrase can be used to gain access to your E2EE messages, so make it long and unique. Your passphrase should ideally not be the same as your password.",
|
||||
),
|
||||
TextField(
|
||||
controller: passphraseController,
|
||||
obscureText: true,
|
||||
decoration: const .new(labelText: "Passphrase"),
|
||||
),
|
||||
TextField(
|
||||
controller: confirmationController,
|
||||
obscureText: true,
|
||||
decoration: .new(
|
||||
labelText: "Confirm passphrase",
|
||||
errorText: mismatch
|
||||
? "Passphrases don't match"
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox(width: .infinity),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (error.value case final message?)
|
||||
Text(message, style: .new(color: colorScheme.error)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue