parent
784e5254e2
commit
23046f08a9
8 changed files with 448 additions and 36 deletions
|
|
@ -31,6 +31,7 @@ import "package:nexus/models/requests/get_room_state.dart";
|
||||||
import "package:nexus/models/requests/get_space_hierarchy.dart";
|
import "package:nexus/models/requests/get_space_hierarchy.dart";
|
||||||
import "package:nexus/models/requests/join_room.dart";
|
import "package:nexus/models/requests/join_room.dart";
|
||||||
import "package:nexus/models/profile_response.dart";
|
import "package:nexus/models/profile_response.dart";
|
||||||
|
import "package:nexus/models/recovery_key.dart";
|
||||||
import "package:nexus/models/requests/oauth/exchange_token.dart";
|
import "package:nexus/models/requests/oauth/exchange_token.dart";
|
||||||
import "package:nexus/models/requests/oauth/get_auth_url.dart";
|
import "package:nexus/models/requests/oauth/get_auth_url.dart";
|
||||||
import "package:nexus/models/requests/oauth/register_client.dart";
|
import "package:nexus/models/requests/oauth/register_client.dart";
|
||||||
|
|
@ -190,6 +191,33 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<RecoveryKey> generateRecoveryKey({String passphrase = ""}) async =>
|
||||||
|
.fromJson(
|
||||||
|
await _sendCommand("generate_recovery_key", {"passphrase": passphrase}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> resetEncryption(RecoveryKey key) =>
|
||||||
|
_sendCommand("reset_encryption", key.toJson());
|
||||||
|
|
||||||
|
Future<Uri?> getCrossSigningResetUrl(Uri homeserver) async {
|
||||||
|
const action = "org.matrix.cross_signing_reset";
|
||||||
|
final oauth = (await _sendCommand("get_login_flows", {
|
||||||
|
"homeserver_url": homeserver.toString(),
|
||||||
|
}))["oauth"];
|
||||||
|
|
||||||
|
if (oauth?["account_management_uri"] case final String uri
|
||||||
|
when (oauth["account_management_actions_supported"] as List?)?.contains(
|
||||||
|
action,
|
||||||
|
) ??
|
||||||
|
false) {
|
||||||
|
final parsed = Uri.parse(uri);
|
||||||
|
return parsed.replace(
|
||||||
|
queryParameters: {...parsed.queryParameters, "action": action},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<String> joinRoom(JoinRoomRequest request) async =>
|
Future<String> joinRoom(JoinRoomRequest request) async =>
|
||||||
(await _sendCommand("join_room", request.toJson()))["room_id"];
|
(await _sendCommand("join_room", request.toJson()))["room_id"];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import "package:nexus/models/room_order.dart";
|
||||||
import "package:nexus/models/settings_category.dart";
|
import "package:nexus/models/settings_category.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:nexus/widgets/settings/dialog_list_tile.dart";
|
import "package:nexus/widgets/settings/dialog_list_tile.dart";
|
||||||
|
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
||||||
|
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||||
|
|
||||||
class SettingsSectionsController
|
class SettingsSectionsController
|
||||||
extends AsyncNotifier<IMap<String, IList<SettingsCategory>>> {
|
extends AsyncNotifier<IMap<String, IList<SettingsCategory>>> {
|
||||||
|
|
@ -312,6 +314,21 @@ class SettingsSectionsController
|
||||||
title: "Other",
|
title: "Other",
|
||||||
icon: Icons.key,
|
icon: Icons.key,
|
||||||
settings: .new([
|
settings: .new([
|
||||||
|
.new(
|
||||||
|
title: "Reset Encryption Identity",
|
||||||
|
description: "Replace your encryption identity and recovery key with new ones, if you've lost your recovery key or your encryption is broken.",
|
||||||
|
builder: (title, description, icon) => Builder(
|
||||||
|
builder: (context) => M3EButton.icon(
|
||||||
|
onPressed: () => context.showAdaptiveSheet(
|
||||||
|
(_) => const ResetEncryptionSheet(),
|
||||||
|
),
|
||||||
|
label: Text(title),
|
||||||
|
icon: Icon(icon),
|
||||||
|
tooltip: description,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: Icons.key_off,
|
||||||
|
),
|
||||||
.new(
|
.new(
|
||||||
title: "Log Out",
|
title: "Log Out",
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,23 @@ class const ClientState({
|
||||||
required final String? userId,
|
required final String? userId,
|
||||||
required final String? deviceId,
|
required final String? deviceId,
|
||||||
required final String? homeserverUrl,
|
required final String? homeserverUrl,
|
||||||
|
final VerificationState verificationState = const .new(),
|
||||||
}) with _$ClientState {
|
}) with _$ClientState {
|
||||||
Map<String, Object?> toJson() => _$ClientStateToJson(this);
|
Map<String, Object?> toJson() => _$ClientStateToJson(this);
|
||||||
|
|
||||||
factory ClientState.fromJson(Map<String, Object?> json) =>
|
factory ClientState.fromJson(Map<String, Object?> json) =>
|
||||||
_$ClientStateFromJson(json);
|
_$ClientStateFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Freezed(toJson: false, fromJson: false)
|
||||||
|
@JsonSerializable()
|
||||||
|
class const VerificationState({
|
||||||
|
final bool stateChecked = false,
|
||||||
|
final bool hasCrossSigning = false,
|
||||||
|
@JsonKey(name: "has_ssss") final bool hasSecretStorage = false,
|
||||||
|
}) with _$VerificationState {
|
||||||
|
Map<String, Object?> toJson() => _$VerificationStateToJson(this);
|
||||||
|
|
||||||
|
factory VerificationState.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$VerificationStateFromJson(json);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ class GomuksException implements Exception {
|
||||||
String? get errorCode =>
|
String? get errorCode =>
|
||||||
RegExp(r"^(M_[A-Z_]+)").firstMatch(message)?.group(1);
|
RegExp(r"^(M_[A-Z_]+)").firstMatch(message)?.group(1);
|
||||||
|
|
||||||
|
/// The HTTP status code of the failed request
|
||||||
|
int? get statusCode => switch (RegExp(r"HTTP (\d{3})").firstMatch(message)) {
|
||||||
|
final match? => int.parse(match.group(1)!),
|
||||||
|
null => null,
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => message;
|
String toString() => message;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
lib/models/recovery_key.dart
Normal file
19
lib/models/recovery_key.dart
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
|
||||||
|
part "recovery_key.freezed.dart";
|
||||||
|
part "recovery_key.g.dart";
|
||||||
|
|
||||||
|
@Freezed(toJson: false, fromJson: false)
|
||||||
|
@JsonSerializable(includeIfNull: false)
|
||||||
|
class const RecoveryKey({
|
||||||
|
required final String recoveryKey,
|
||||||
|
|
||||||
|
required final IMap<String, dynamic> crossSigningKeys,
|
||||||
|
final IMap<String, dynamic>? passphraseMeta,
|
||||||
|
}) with _$RecoveryKey {
|
||||||
|
Map<String, Object?> toJson() => _$RecoveryKeyToJson(this);
|
||||||
|
|
||||||
|
factory RecoveryKey.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$RecoveryKeyFromJson(json);
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,14 @@ import "package:material_ui/material_ui.dart";
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
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.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/pages/settings.dart";
|
||||||
import "package:nexus/widgets/appbar.dart";
|
import "package:nexus/widgets/appbar.dart";
|
||||||
import "package:nexus/helpers/required_validator_helper.dart";
|
import "package:nexus/helpers/required_validator_helper.dart";
|
||||||
|
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
||||||
|
|
||||||
class const VerifyPage({super.key}) extends HookConsumerWidget {
|
class const VerifyPage({super.key}) extends HookConsumerWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -13,6 +18,11 @@ class const VerifyPage({super.key}) extends HookConsumerWidget {
|
||||||
final isLoading = useState(false);
|
final isLoading = useState(false);
|
||||||
final inputError = useState<String?>(null);
|
final inputError = useState<String?>(null);
|
||||||
final formKey = useRef(GlobalKey<FormState>());
|
final formKey = useRef(GlobalKey<FormState>());
|
||||||
|
final verificationState = ref.watch(
|
||||||
|
ClientStateController.provider.select(
|
||||||
|
(state) => state?.verificationState,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> verify() async {
|
Future<void> verify() async {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
|
@ -40,45 +50,65 @@ class const VerifyPage({super.key}) extends HookConsumerWidget {
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
body: AlertDialog(
|
body: switch (verificationState) {
|
||||||
insetPadding: .symmetric(vertical: 12, horizontal: 32),
|
null || VerificationState(stateChecked: false) => const Loading(),
|
||||||
scrollable: true,
|
// Nothing to verify with yet, so go straight to setting it up.
|
||||||
title: Text("Verify"),
|
VerificationState(hasSecretStorage: false) => Dialog(
|
||||||
content: Form(
|
insetPadding: const .symmetric(vertical: 12, horizontal: 32),
|
||||||
key: formKey.value,
|
child: ConstrainedBox(
|
||||||
child: Column(
|
constraints: const .new(maxWidth: 450),
|
||||||
mainAxisSize: .min,
|
child: const ResetEncryptionSheet(isPage: true),
|
||||||
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: [
|
_ => AlertDialog(
|
||||||
TextButton(
|
insetPadding: .symmetric(vertical: 12, horizontal: 32),
|
||||||
onPressed: isLoading.value ? null : verify,
|
scrollable: true,
|
||||||
child: Text("Verify"),
|
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