From 98fa90a46bf95baad9a37811639ea995439b787e Mon Sep 17 00:00:00 2001 From: otsmr Date: Tue, 16 Jun 2026 20:46:15 +0200 Subject: [PATCH] start with passwordless recovery --- .../generated/app_localizations.dart | 6 + .../generated/app_localizations_de.dart | 3 + .../generated/app_localizations_en.dart | 3 + lib/src/localization/translations | 2 +- lib/src/model/json/userdata.model.dart | 22 ++ lib/src/model/json/userdata.model.g.dart | 24 ++ .../passwordless_recovery.service.dart | 23 ++ .../context_menu/context_menu.helper.dart | 21 +- .../context_menu/user.context_menu.dart | 1 + .../visual/elements/my_button.element.dart | 20 ++ .../settings/backup/backup_settings.view.dart | 77 ++++--- .../components/second_factor_picker.comp.dart | 166 ++++++++++++++ .../components/threshold_picker.comp.dart | 129 +++++++++++ .../components/trusted_friends_card.comp.dart | 148 ++++++++++++ .../setup.passwordless_recovery.view.dart | 213 ++++++++++++++++++ .../views/shared/select_contacts.view.dart | 139 +++++++++--- rust/build.rs | 1 + rust/src/passwordless_recovery/mod.rs | 13 +- rust/src/passwordless_recovery/traits.rs | 0 rust/src/passwordless_recovery/types.proto | 9 +- 20 files changed, 956 insertions(+), 64 deletions(-) create mode 100644 lib/src/services/passwordless_recovery.service.dart create mode 100644 lib/src/visual/views/settings/backup/passwordless_recovery/components/second_factor_picker.comp.dart create mode 100644 lib/src/visual/views/settings/backup/passwordless_recovery/components/threshold_picker.comp.dart create mode 100644 lib/src/visual/views/settings/backup/passwordless_recovery/components/trusted_friends_card.comp.dart create mode 100644 lib/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart delete mode 100644 rust/src/passwordless_recovery/traits.rs diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 7aa0f250..efc85060 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -1928,6 +1928,12 @@ abstract class AppLocalizations { /// **'Already in Group'** String get alreadyInGroup; + /// No description provided for @contactNotVerified. + /// + /// In en, this message translates to: + /// **'Not verified'** + String get contactNotVerified; + /// No description provided for @removeContactFromGroupTitle. /// /// In en, this message translates to: diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 802bdb5b..1c5f4270 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -1026,6 +1026,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get alreadyInGroup => 'Bereits Mitglied'; + @override + String get contactNotVerified => 'Nicht verifiziert'; + @override String removeContactFromGroupTitle(Object username) { return '$username aus dieser Gruppe entfernen?'; diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index a49e5715..6674af20 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -1020,6 +1020,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get alreadyInGroup => 'Already in Group'; + @override + String get contactNotVerified => 'Not verified'; + @override String removeContactFromGroupTitle(Object username) { return 'Remove $username from this group?'; diff --git a/lib/src/localization/translations b/lib/src/localization/translations index 7686a412..80b21d7e 160000 --- a/lib/src/localization/translations +++ b/lib/src/localization/translations @@ -1 +1 @@ -Subproject commit 7686a412d9911bafe7585007b4eb867270e4fde4 +Subproject commit 80b21d7e566a12d105f573cb7224b6cdfe37048b diff --git a/lib/src/model/json/userdata.model.dart b/lib/src/model/json/userdata.model.dart index a3549b1f..379c1a29 100644 --- a/lib/src/model/json/userdata.model.dart +++ b/lib/src/model/json/userdata.model.dart @@ -156,6 +156,8 @@ class UserData { @JsonKey(defaultValue: false) bool isBackupEnabled = false; + PasswordLessRecovery? passwordLessRecovery; + // Used for push notifcation via FCM. String? fcmToken; @@ -203,3 +205,23 @@ class TwonlySafeBackup { List encryptionKey; Map toJson() => _$TwonlySafeBackupToJson(this); } + +@JsonSerializable() +class PasswordLessRecovery { + PasswordLessRecovery({ + this.email, + this.pinSeed, + this.pinUnlockToken, + this.threshold, + }); + + factory PasswordLessRecovery.fromJson(Map json) => + _$PasswordLessRecoveryFromJson(json); + + String? email; + String? pinSeed; + String? pinUnlockToken; + int? threshold; + + Map toJson() => _$PasswordLessRecoveryToJson(this); +} diff --git a/lib/src/model/json/userdata.model.g.dart b/lib/src/model/json/userdata.model.g.dart index 4ca1a3d7..59270896 100644 --- a/lib/src/model/json/userdata.model.g.dart +++ b/lib/src/model/json/userdata.model.g.dart @@ -103,6 +103,11 @@ UserData _$UserDataFromJson(Map json) => json['twonlySafeBackup'] as Map, ) ..isBackupEnabled = json['isBackupEnabled'] as bool? ?? false + ..passwordLessRecovery = json['passwordLessRecovery'] == null + ? null + : PasswordLessRecovery.fromJson( + json['passwordLessRecovery'] as Map, + ) ..fcmToken = json['fcmToken'] as String? ..askedForUserStudyPermission = json['askedForUserStudyPermission'] as bool? ?? false @@ -171,6 +176,7 @@ Map _$UserDataToJson(UserData instance) => { 'canUseLoginTokenForAuth': instance.canUseLoginTokenForAuth, 'twonlySafeBackup': instance.twonlySafeBackup, 'isBackupEnabled': instance.isBackupEnabled, + 'passwordLessRecovery': instance.passwordLessRecovery, 'fcmToken': instance.fcmToken, 'askedForUserStudyPermission': instance.askedForUserStudyPermission, 'userStudyParticipantsToken': instance.userStudyParticipantsToken, @@ -234,3 +240,21 @@ const _$LastBackupUploadStateEnumMap = { LastBackupUploadState.failed: 'failed', LastBackupUploadState.success: 'success', }; + +PasswordLessRecovery _$PasswordLessRecoveryFromJson( + Map json, +) => PasswordLessRecovery( + email: json['email'] as String?, + pinSeed: json['pin_seed'] as String?, + pinUnlockToken: json['pin_unlock_token'] as String?, + threshold: (json['threshold'] as num?)?.toInt(), +); + +Map _$PasswordLessRecoveryToJson( + PasswordLessRecovery instance, +) => { + 'email': instance.email, + 'pin_seed': instance.pinSeed, + 'pin_unlock_token': instance.pinUnlockToken, + 'threshold': instance.threshold, +}; diff --git a/lib/src/services/passwordless_recovery.service.dart b/lib/src/services/passwordless_recovery.service.dart new file mode 100644 index 00000000..dce869e6 --- /dev/null +++ b/lib/src/services/passwordless_recovery.service.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +import 'package:twonly/src/utils/log.dart'; + +class PasswordlessRecoveryService { + static Future enablePasswordlessRecovery({ + required List trustedFriendIds, + required String secondFactorType, + required String secondFactorValue, + required int threshold, + }) async { + Log.info('Enabling passwordless recovery with:'); + Log.info(' - Trusted Friends: $trustedFriendIds'); + Log.info(' - Second Factor Type: $secondFactorType'); + Log.info(' - Second Factor Value: $secondFactorValue'); + Log.info(' - Threshold: $threshold'); + + // Simulate network delay + await Future.delayed(const Duration(milliseconds: 1000)); + + return true; + } +} diff --git a/lib/src/visual/context_menu/context_menu.helper.dart b/lib/src/visual/context_menu/context_menu.helper.dart index f065ac27..9ba912bc 100644 --- a/lib/src/visual/context_menu/context_menu.helper.dart +++ b/lib/src/visual/context_menu/context_menu.helper.dart @@ -9,11 +9,13 @@ class ContextMenu extends StatefulWidget { const ContextMenu({ required this.child, required this.items, + this.minWidth, super.key, }); final List items; final Widget child; + final double? minWidth; @override State createState() => _ContextMenuState(); @@ -116,17 +118,26 @@ class _ContextMenuState extends State ), items: >[ ...widget.items.map( - (item) => PopupMenuItem( - padding: const EdgeInsets.only(right: 4), - child: ListTile( + (item) { + Widget child = ListTile( title: Text(item.title), onTap: () async { if (mounted) Navigator.pop(context); await item.onTap(); }, leading: _getIcon(item.icon), - ), - ), + ); + if (widget.minWidth != null) { + child = ConstrainedBox( + constraints: BoxConstraints(minWidth: widget.minWidth!), + child: child, + ); + } + return PopupMenuItem( + padding: const EdgeInsets.only(right: 4), + child: child, + ); + }, ), ], position: RelativeRect.fromRect( diff --git a/lib/src/visual/context_menu/user.context_menu.dart b/lib/src/visual/context_menu/user.context_menu.dart index 4d8bfbc9..1a35873d 100644 --- a/lib/src/visual/context_menu/user.context_menu.dart +++ b/lib/src/visual/context_menu/user.context_menu.dart @@ -18,6 +18,7 @@ class UserContextMenu extends StatelessWidget { @override Widget build(BuildContext context) { return ContextMenu( + minWidth: 150, items: [ ContextMenuItem( title: context.lang.contextMenuUserProfile, diff --git a/lib/src/visual/elements/my_button.element.dart b/lib/src/visual/elements/my_button.element.dart index 4f5e1c15..c0dfa7d1 100644 --- a/lib/src/visual/elements/my_button.element.dart +++ b/lib/src/visual/elements/my_button.element.dart @@ -10,6 +10,7 @@ enum MyButtonVariant { primaryMiddle, primaryDense, secondaryDense, + error, } class MyButton extends StatefulWidget { @@ -211,6 +212,25 @@ class _MyButtonState extends State fontWeight: FontWeight.bold, ), ); + case MyButtonVariant.error: + buttonStyle = FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.errorContainer, + foregroundColor: Theme.of(context).colorScheme.onErrorContainer, + disabledBackgroundColor: disabledBgColor, + disabledForegroundColor: disabledFgColor, + minimumSize: const Size(0, 40), + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ); } final childButton = widget.variant == MyButtonVariant.text diff --git a/lib/src/visual/views/settings/backup/backup_settings.view.dart b/lib/src/visual/views/settings/backup/backup_settings.view.dart index 62eb9a96..c0182bee 100644 --- a/lib/src/visual/views/settings/backup/backup_settings.view.dart +++ b/lib/src/visual/views/settings/backup/backup_settings.view.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:twonly/locator.dart'; @@ -8,6 +9,7 @@ import 'package:twonly/src/model/json/backup.model.dart'; import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart'; +import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart'; class BackupView extends StatefulWidget { const BackupView({super.key}); @@ -176,36 +178,59 @@ class _BackupViewState extends State { ), ]), ), - const SizedBox(height: 10), - MyButton( - variant: MyButtonVariant.primaryMiddle, - onPressed: _isLoading - ? null - : () async { - setState(() { - _isLoading = true; - }); - await BackupService.makeBackup(force: true); - setState(() { - _isLoading = false; - }); - }, - child: Text(context.lang.backupTwonlySaveNow), - ), ], ), - const SizedBox(height: 32), - Center( - child: MyButton( - variant: MyButtonVariant.secondaryDense, - onPressed: () => - context.push(Routes.settingsBackupSetup, extra: true), - child: Text( - !userService.currentUser.isBackupEnabled - ? context.lang.backupEnableBackup - : context.lang.backupChangePassword, + + if (userService.currentUser.passwordLessRecovery == null && + kDebugMode) ...[ + const SizedBox(height: 20), + Center( + child: MyButton( + variant: MyButtonVariant.primaryMiddle, + onPressed: () => + context.navPush(const PasswordLessRecoverySetup()), + child: const Text('Setup Passwordless Recovery'), ), ), + ], + const SizedBox(height: 20), + Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (userService.currentUser.isBackupEnabled) ...[ + MyButton( + variant: MyButtonVariant.secondaryDense, + onPressed: _isLoading + ? null + : () async { + setState(() { + _isLoading = true; + }); + await BackupService.makeBackup(force: true); + setState(() { + _isLoading = false; + }); + }, + child: Text(context.lang.backupTwonlySaveNow), + ), + const SizedBox(width: 12), + ], + MyButton( + variant: MyButtonVariant.secondaryDense, + onPressed: () => context.push( + Routes.settingsBackupSetup, + extra: true, + ), + child: Text( + !userService.currentUser.isBackupEnabled + ? context.lang.backupEnableBackup + : context.lang.backupChangePassword, + ), + ), + ], + ), ), ], ), diff --git a/lib/src/visual/views/settings/backup/passwordless_recovery/components/second_factor_picker.comp.dart b/lib/src/visual/views/settings/backup/passwordless_recovery/components/second_factor_picker.comp.dart new file mode 100644 index 00000000..ac73c275 --- /dev/null +++ b/lib/src/visual/views/settings/backup/passwordless_recovery/components/second_factor_picker.comp.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_input.element.dart'; +import 'package:twonly/src/visual/themes/light.dart'; +import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart'; + +class _FactorOption { + const _FactorOption({ + required this.type, + required this.icon, + required this.label, + }); + + final SecondFactorType type; + final IconData icon; + final String label; +} + +const _options = [ + _FactorOption( + type: SecondFactorType.none, + icon: Icons.block_rounded, + label: 'None', + ), + _FactorOption( + type: SecondFactorType.pin, + icon: Icons.dialpad_rounded, + label: 'PIN', + ), + _FactorOption( + type: SecondFactorType.email, + icon: Icons.email_rounded, + label: 'Email', + ), +]; + +class SecondFactorPicker extends StatelessWidget { + const SecondFactorPicker({ + required this.selected, + required this.onChanged, + required this.pinController, + required this.emailController, + required this.onInputChanged, + super.key, + }); + + final SecondFactorType selected; + final ValueChanged onChanged; + final TextEditingController pinController; + final TextEditingController emailController; + final VoidCallback onInputChanged; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const Text( + 'Second factor method', + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + _buildSegmentedControl(context), + const SizedBox(height: 16), + _buildInputField(), + ], + ); + } + + Widget _buildSegmentedControl(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: context.color.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: context.color.outlineVariant.withValues(alpha: 0.3), + ), + ), + child: Row( + children: List.generate(_options.length * 2 - 1, (index) { + if (index.isOdd) { + return Container( + width: 1, + height: 40, + color: context.color.outlineVariant.withValues(alpha: 0.3), + ); + } + + final optionIndex = index ~/ 2; + final option = _options[optionIndex]; + final isSelected = selected == option.type; + + BorderRadius? borderRadius; + if (optionIndex == 0) { + borderRadius = const BorderRadius.horizontal( + left: Radius.circular(15), + ); + } else if (optionIndex == _options.length - 1) { + borderRadius = const BorderRadius.horizontal( + right: Radius.circular(15), + ); + } + + return Expanded( + child: GestureDetector( + onTap: () => onChanged(option.type), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: isSelected ? primaryColor : Colors.transparent, + borderRadius: borderRadius, + ), + child: Column( + children: [ + Icon( + option.icon, + color: isSelected + ? Colors.black87 + : context.color.onSurfaceVariant, + ), + const SizedBox(height: 6), + Text( + option.label, + style: TextStyle( + fontWeight: FontWeight.bold, + color: isSelected + ? Colors.black87 + : context.color.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + }), + ), + ); + } + + Widget _buildInputField() { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: switch (selected) { + SecondFactorType.none => const SizedBox.shrink( + key: ValueKey('none_input'), + ), + SecondFactorType.pin => MyInput( + key: const ValueKey('pin_input'), + controller: pinController, + hintText: 'Enter PIN', + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: (_) => onInputChanged(), + ), + SecondFactorType.email => MyInput( + key: const ValueKey('email_input'), + controller: emailController, + hintText: 'Enter recovery email address', + keyboardType: TextInputType.emailAddress, + onChanged: (_) => onInputChanged(), + ), + }, + ); + } +} diff --git a/lib/src/visual/views/settings/backup/passwordless_recovery/components/threshold_picker.comp.dart b/lib/src/visual/views/settings/backup/passwordless_recovery/components/threshold_picker.comp.dart new file mode 100644 index 00000000..6cea8112 --- /dev/null +++ b/lib/src/visual/views/settings/backup/passwordless_recovery/components/threshold_picker.comp.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/themes/light.dart'; + +class ThresholdPicker extends StatelessWidget { + const ThresholdPicker({ + required this.contactCount, + required this.minThreshold, + required this.currentThreshold, + required this.onChanged, + super.key, + }); + + final int contactCount; + final int minThreshold; + final int currentThreshold; + final ValueChanged onChanged; + + /// Clamps [value] between [minThreshold] and contactCount - 2. + static int clampThreshold({ + required int value, + required int minThreshold, + required int contactCount, + }) { + final maxT = (contactCount - 2) < minThreshold + ? minThreshold + : (contactCount - 2); + if (value < minThreshold) return minThreshold; + if (value > maxT) return maxT; + return value; + } + + @override + Widget build(BuildContext context) { + if (contactCount == 0) return const SizedBox.shrink(); + + final maxT = (contactCount - 2) < minThreshold + ? minThreshold + : (contactCount - 2); + final options = List.generate( + maxT - minThreshold + 1, + (i) => minThreshold + i, + ); + + if (options.length == 1) { + return Text( + 'To recover your account you need ${options.first} of your selected trusted friends.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.color.onSurfaceVariant, + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Required trusted friends for recovery', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: context.color.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: context.color.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: context.color.outlineVariant.withValues(alpha: 0.3), + ), + ), + child: Row( + children: List.generate(options.length * 2 - 1, (index) { + if (index.isOdd) { + return Container( + width: 1, + height: 40, + color: context.color.outlineVariant.withValues(alpha: 0.3), + ); + } + + final optionIndex = index ~/ 2; + final value = options[optionIndex]; + final isSelected = value == currentThreshold; + + BorderRadius? borderRadius; + if (optionIndex == 0) { + borderRadius = const BorderRadius.horizontal( + left: Radius.circular(15), + ); + } else if (optionIndex == options.length - 1) { + borderRadius = const BorderRadius.horizontal( + right: Radius.circular(15), + ); + } + + return Expanded( + child: GestureDetector( + onTap: () => onChanged(value), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: isSelected ? primaryColor : Colors.transparent, + borderRadius: borderRadius, + ), + child: Center( + child: Text( + '$value', + style: TextStyle( + fontWeight: FontWeight.bold, + color: isSelected + ? Colors.black87 + : context.color.onSurfaceVariant, + ), + ), + ), + ), + ), + ); + }), + ), + ), + ], + ); + } +} diff --git a/lib/src/visual/views/settings/backup/passwordless_recovery/components/trusted_friends_card.comp.dart b/lib/src/visual/views/settings/backup/passwordless_recovery/components/trusted_friends_card.comp.dart new file mode 100644 index 00000000..3e7de549 --- /dev/null +++ b/lib/src/visual/views/settings/backup/passwordless_recovery/components/trusted_friends_card.comp.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/src/database/daos/contacts.dao.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; +import 'package:twonly/src/visual/components/verification_badge.comp.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; + +class TrustedFriendsCard extends StatelessWidget { + const TrustedFriendsCard({ + required this.selectedContacts, + required this.needsMoreFriends, + required this.missingCount, + required this.onSelectFriends, + required this.onRemoveContact, + super.key, + }); + + final List selectedContacts; + final bool needsMoreFriends; + final int missingCount; + final VoidCallback onSelectFriends; + final void Function(int userId) onRemoveContact; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: context.color.surfaceContainerLow, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: context.color.outlineVariant.withValues(alpha: 0.5), + ), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (selectedContacts.isEmpty) + _buildEmptyState(context) + else + Wrap( + spacing: 4, + alignment: WrapAlignment.center, + children: selectedContacts.map((contact) { + return _ContactChip( + key: ValueKey(contact.userId), + contact: contact, + onRemove: onRemoveContact, + ); + }).toList(), + ), + const SizedBox(height: 12), + MyButton( + variant: needsMoreFriends + ? MyButtonVariant.error + : MyButtonVariant.secondaryDense, + onPressed: onSelectFriends, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.add_moderator_rounded, size: 18), + const SizedBox(width: 8), + Text( + needsMoreFriends + ? 'Select friends ($missingCount more needed)' + : 'Select trusted friends', + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildEmptyState(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Column( + children: [ + Icon( + Icons.people_outline_rounded, + size: 48, + color: context.color.onSurfaceVariant.withValues(alpha: 0.6), + ), + const SizedBox(height: 12), + Text( + 'No trusted friends selected yet', + style: TextStyle( + fontSize: 14, + color: context.color.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +class _ContactChip extends StatelessWidget { + const _ContactChip({ + required this.contact, + required this.onRemove, + super.key, + }); + + final Contact contact; + final void Function(int) onRemove; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => onRemove(contact.userId), + child: Chip( + avatar: AvatarIcon( + contactId: contact.userId, + fontSize: 10, + ), + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + getContactDisplayName(contact), + style: const TextStyle(fontSize: 14), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(width: 6), + VerificationBadgeComp( + contact: contact, + size: 12, + clickable: false, + ), + const SizedBox(width: 15), + const FaIcon( + FontAwesomeIcons.xmark, + color: Colors.grey, + size: 12, + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart b/lib/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart new file mode 100644 index 00000000..6a5205f3 --- /dev/null +++ b/lib/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/services/passwordless_recovery.service.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/components/snackbar.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; +import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/second_factor_picker.comp.dart'; +import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/threshold_picker.comp.dart'; +import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/trusted_friends_card.comp.dart'; +import 'package:twonly/src/visual/views/shared/select_contacts.view.dart'; + +enum SecondFactorType { none, pin, email } + +class PasswordLessRecoverySetup extends StatefulWidget { + const PasswordLessRecoverySetup({super.key}); + + @override + State createState() => + _PasswordLessRecoverySetupState(); +} + +class _PasswordLessRecoverySetupState extends State { + List _selectedContacts = []; + SecondFactorType _secondFactor = SecondFactorType.pin; + final _pinController = TextEditingController(); + final _emailController = TextEditingController(); + bool _isLoading = false; + int _threshold = 2; + + @override + void dispose() { + _pinController.dispose(); + _emailController.dispose(); + super.dispose(); + } + + // --- Threshold logic --- + + int get _minThreshold => _secondFactor == SecondFactorType.none ? 4 : 2; + + int get _validThreshold => ThresholdPicker.clampThreshold( + value: _threshold, + minThreshold: _minThreshold, + contactCount: _selectedContacts.length, + ); + + int get _minSelectedFriends => _validThreshold + 2; + + // --- Validation --- + + bool get _canEnable { + if (_selectedContacts.length < _minSelectedFriends) return false; + return switch (_secondFactor) { + SecondFactorType.none => true, + SecondFactorType.pin => _pinController.text.length >= 4, + SecondFactorType.email => + RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$') + .hasMatch(_emailController.text), + }; + } + + // --- Actions --- + + Future _selectTrustedFriends() async { + final selectedIds = await Navigator.push>( + context, + MaterialPageRoute( + builder: (_) => SelectContactsView( + text: SelectedContactView( + title: 'Trusted Friends', + submitButton: (selected, _) => 'Done ($selected)', + submitIcon: FontAwesomeIcons.check, + ), + alreadySelected: _selectedContacts.map((c) => c.userId).toList(), + isAlreadySelectedLocked: false, + onlyVerified: true, + sortByMediaCount: true, + ), + ), + ); + + if (selectedIds == null) return; + + final contacts = []; + for (final id in selectedIds) { + final contact = await twonlyDB.contactsDao.getContactById(id); + if (contact != null) contacts.add(contact); + } + setState(() { + _selectedContacts = contacts; + _threshold = _validThreshold; + }); + } + + Future _enable() async { + setState(() => _isLoading = true); + + final secondFactorValue = switch (_secondFactor) { + SecondFactorType.none => '', + SecondFactorType.pin => _pinController.text, + SecondFactorType.email => _emailController.text, + }; + + final success = + await PasswordlessRecoveryService.enablePasswordlessRecovery( + trustedFriendIds: _selectedContacts.map((c) => c.userId).toList(), + secondFactorType: _secondFactor.name, + secondFactorValue: secondFactorValue, + threshold: _validThreshold, + ); + + if (!mounted) return; + setState(() => _isLoading = false); + + if (success) { + showSnackbar( + context, + 'Passwordless recovery enabled successfully!', + level: SnackbarLevel.success, + ); + Navigator.pop(context); + } + } + + // --- Build --- + + @override + Widget build(BuildContext context) { + final needsMoreFriends = _selectedContacts.length < _minSelectedFriends; + + return GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Scaffold( + appBar: AppBar( + title: const Text('Passwordless Recovery'), + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + children: [ + Text( + 'Recover your identity without a password.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.color.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + + TrustedFriendsCard( + selectedContacts: _selectedContacts, + needsMoreFriends: needsMoreFriends, + missingCount: _minSelectedFriends - _selectedContacts.length, + onSelectFriends: _selectTrustedFriends, + onRemoveContact: (userId) => setState(() { + _selectedContacts.removeWhere((c) => c.userId == userId); + _threshold = _validThreshold; + }), + ), + const SizedBox(height: 28), + + SecondFactorPicker( + selected: _secondFactor, + onChanged: (type) => setState(() { + _secondFactor = type; + _threshold = _validThreshold; + }), + pinController: _pinController, + emailController: _emailController, + onInputChanged: () => setState(() {}), + ), + const SizedBox(height: 28), + + ThresholdPicker( + contactCount: _selectedContacts.length, + minThreshold: _minThreshold, + currentThreshold: _validThreshold, + onChanged: (value) => setState(() => _threshold = value), + ), + const SizedBox(height: 28), + + MyButton( + onPressed: (_canEnable && !_isLoading) ? _enable : null, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_isLoading) + const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.black87, + ), + ), + ) + else + const Icon(Icons.check_circle_outline_rounded, size: 20), + const SizedBox(width: 8), + const Text('Enable Passwordless Recovery'), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/visual/views/shared/select_contacts.view.dart b/lib/src/visual/views/shared/select_contacts.view.dart index ff67b08f..96d42abe 100644 --- a/lib/src/visual/views/shared/select_contacts.view.dart +++ b/lib/src/visual/views/shared/select_contacts.view.dart @@ -11,6 +11,7 @@ import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/flame_counter.comp.dart'; +import 'package:twonly/src/visual/components/verification_badge.comp.dart'; import 'package:twonly/src/visual/context_menu/user.context_menu.dart'; import 'package:twonly/src/visual/decorations/input_text.decoration.dart'; @@ -19,10 +20,12 @@ class SelectedContactView { required this.title, required this.submitButton, required this.submitIcon, + this.alreadySelectedSubtitle, }); final String title; final String Function(int selected, int? limit) submitButton; final FaIconData submitIcon; + final String? alreadySelectedSubtitle; } class SelectContactsView extends StatefulWidget { @@ -30,11 +33,18 @@ class SelectContactsView extends StatefulWidget { required this.text, this.alreadySelected, this.limit, + this.isAlreadySelectedLocked = true, + this.onlyVerified = false, + this.sortByMediaCount = false, super.key, }); final SelectedContactView text; final List? alreadySelected; final int? limit; + final bool isAlreadySelectedLocked; + final bool onlyVerified; + final bool sortByMediaCount; + @override State createState() => _SelectAdditionalUsers(); } @@ -47,12 +57,17 @@ class _SelectAdditionalUsers extends State { final HashSet selectedUsers = HashSet(); late HashSet _alreadySelected; + final HashSet verifiedUserIds = HashSet(); @override void initState() { super.initState(); _alreadySelected = HashSet.from(widget.alreadySelected ?? []); + if (!widget.isAlreadySelectedLocked) { + selectedUsers.addAll(_alreadySelected); + _alreadySelected.clear(); + } final stream = twonlyDB.contactsDao.watchAllAcceptedContacts(); @@ -65,6 +80,23 @@ class _SelectAdditionalUsers extends State { }); await filterUsers(); }); + + _loadVerifiedContacts(); + } + + Future _loadVerifiedContacts() async { + final kvs = await twonlyDB.select(twonlyDB.keyVerifications).get(); + final urs = await (twonlyDB.select(twonlyDB.userDiscoveryUserRelations) + ..where((u) => u.publicKeyVerifiedTimestamp.isNotNull())) + .get(); + + if (!mounted) return; + setState(() { + verifiedUserIds + ..addAll(kvs.map((row) => row.contactId)) + ..addAll(urs.map((row) => row.announcedUserId)); + }); + await filterUsers(); } @override @@ -74,21 +106,37 @@ class _SelectAdditionalUsers extends State { } Future filterUsers() async { - if (searchUserName.value.text.isEmpty) { - setState(() { - contacts = allContacts; - }); - return; + var filtered = allContacts; + if (searchUserName.value.text.isNotEmpty) { + filtered = filtered + .where( + (user) => getContactDisplayName( + user, + ).toLowerCase().contains(searchUserName.value.text.toLowerCase()), + ) + .toList(); } - final usersFiltered = allContacts - .where( - (user) => getContactDisplayName( - user, - ).toLowerCase().contains(searchUserName.value.text.toLowerCase()), - ) - .toList(); + + if (widget.sortByMediaCount) { + filtered.sort((a, b) { + final aVerified = verifiedUserIds.contains(a.userId); + final bVerified = verifiedUserIds.contains(b.userId); + if (aVerified && !bVerified) return -1; + if (!aVerified && bVerified) return 1; + + final cmp = b.mediaSendCounter.compareTo(a.mediaSendCounter); + if (cmp != 0) return cmp; + + return getContactDisplayName(a).compareTo(getContactDisplayName(b)); + }); + } else { + filtered.sort( + (a, b) => getContactDisplayName(a).compareTo(getContactDisplayName(b)), + ); + } + setState(() { - contacts = usersFiltered; + contacts = filtered; }); } @@ -119,7 +167,7 @@ class _SelectAdditionalUsers extends State { : () => Navigator.pop(context, selectedUsers.toList()), label: Text( widget.text.submitButton( - selectedUsers.length + (widget.alreadySelected?.length ?? 0), + selectedUsers.length + _alreadySelected.length, widget.limit, ), ), @@ -169,10 +217,15 @@ class _SelectAdditionalUsers extends State { return Wrap( spacing: 8, children: selected.map((w) { + final contact = allContacts + .where((t) => t.userId == w) + .firstOrNull; + if (contact == null) { + return const SizedBox.shrink(); + } return _Chip( - contact: allContacts.firstWhere( - (t) => t.userId == w, - ), + key: ValueKey(contact.userId), + contact: contact, onTap: toggleSelectedUser, ); }).toList(), @@ -188,13 +241,27 @@ class _SelectAdditionalUsers extends State { i -= 2; } final user = contacts[i]; + final isVerified = verifiedUserIds.contains(user.userId); + final isSelectionDisabled = widget.onlyVerified && !isVerified; return UserContextMenu( key: ValueKey(user.userId), contact: user, child: ListTile( + enabled: !isSelectionDisabled, title: Row( children: [ - Text(getContactDisplayName(user)), + Flexible( + child: Text( + getContactDisplayName(user), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 4), + VerificationBadgeComp( + contact: user, + size: 14, + clickable: false, + ), FlameCounterWidget( contactId: user.userId, prefix: true, @@ -202,8 +269,17 @@ class _SelectAdditionalUsers extends State { ], ), subtitle: (_alreadySelected.contains(user.userId)) - ? Text(context.lang.alreadyInGroup) - : null, + ? (widget.text.alreadySelectedSubtitle != null + ? Text(widget.text.alreadySelectedSubtitle!) + : Text(context.lang.alreadyInGroup)) + : (isSelectionDisabled + ? Text( + context.lang.contactNotVerified, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ) + : null), leading: AvatarIcon( contactId: user.userId, fontSize: 13, @@ -222,13 +298,17 @@ class _SelectAdditionalUsers extends State { ); }, ), - onChanged: (value) { - toggleSelectedUser(user.userId); - }, + onChanged: isSelectionDisabled + ? null + : (value) { + toggleSelectedUser(user.userId); + }, ), - onTap: () { - toggleSelectedUser(user.userId); - }, + onTap: isSelectionDisabled + ? null + : () { + toggleSelectedUser(user.userId); + }, ), ); }, @@ -247,6 +327,7 @@ class _Chip extends StatelessWidget { const _Chip({ required this.contact, required this.onTap, + super.key, }); final Contact contact; final void Function(int) onTap; @@ -268,6 +349,12 @@ class _Chip extends StatelessWidget { style: const TextStyle(fontSize: 14), overflow: TextOverflow.ellipsis, ), + const SizedBox(width: 4), + VerificationBadgeComp( + contact: contact, + size: 12, + clickable: false, + ), const SizedBox(width: 15), const FaIcon( FontAwesomeIcons.xmark, diff --git a/rust/build.rs b/rust/build.rs index 135361e9..58d91327 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -1,5 +1,6 @@ use std::io::Result; fn main() -> Result<()> { prost_build::compile_protos(&["src/user_discovery/types.proto"], &["src/"])?; + prost_build::compile_protos(&["src/passwordless_recovery/types.proto"], &["src/"])?; Ok(()) } diff --git a/rust/src/passwordless_recovery/mod.rs b/rust/src/passwordless_recovery/mod.rs index 3ecd1210..1eac912e 100644 --- a/rust/src/passwordless_recovery/mod.rs +++ b/rust/src/passwordless_recovery/mod.rs @@ -1 +1,12 @@ -mod traits; +include!(concat!(env!("OUT_DIR"), "/passwordless_recovery.rs")); + +struct PasswordLessRecovery {} + +impl PasswordLessRecovery { + pub(crate) fn create_shared_secret_data( + total_shares: i64, + threshold: i64, + pin_unlock_token: Option>, + ) { + } +} diff --git a/rust/src/passwordless_recovery/traits.rs b/rust/src/passwordless_recovery/traits.rs deleted file mode 100644 index e69de29b..00000000 diff --git a/rust/src/passwordless_recovery/types.proto b/rust/src/passwordless_recovery/types.proto index 7490553e..ef911d77 100644 --- a/rust/src/passwordless_recovery/types.proto +++ b/rust/src/passwordless_recovery/types.proto @@ -36,8 +36,8 @@ message TrustedFriendShare { // The minimum threshold required to decrypte the shares. int32 threshold = 3; - // The actual share which will become: SecretSharedDate - bytes share = 4; + // The actual share which will become: SharedSecretData + bytes shared_secret_data = 4; message User { int64 user_id = 1; @@ -47,7 +47,7 @@ message TrustedFriendShare { } // After received all shares this is decrypted by the user restoring its own -message SecretSharedDate { +message SharedSecretData { // No second factor was selected optional RecoveryData recovery_data = 1; @@ -81,6 +81,5 @@ message SecretSharedDate { // In case the backup is not available any more the user can use its user_id and his private_key to requister as a new user. message RecoveryData { int64 user_id = 1; - bytes private_key = 2; - bytes backup_master_key = 3; + bytes key_manager = 3; }