start with passwordless recovery
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-06-16 20:46:15 +02:00
parent 2687545e33
commit 98fa90a46b
20 changed files with 956 additions and 64 deletions

View file

@ -1928,6 +1928,12 @@ abstract class AppLocalizations {
/// **'Already in Group'** /// **'Already in Group'**
String get alreadyInGroup; String get alreadyInGroup;
/// No description provided for @contactNotVerified.
///
/// In en, this message translates to:
/// **'Not verified'**
String get contactNotVerified;
/// No description provided for @removeContactFromGroupTitle. /// No description provided for @removeContactFromGroupTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -1026,6 +1026,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get alreadyInGroup => 'Bereits Mitglied'; String get alreadyInGroup => 'Bereits Mitglied';
@override
String get contactNotVerified => 'Nicht verifiziert';
@override @override
String removeContactFromGroupTitle(Object username) { String removeContactFromGroupTitle(Object username) {
return '$username aus dieser Gruppe entfernen?'; return '$username aus dieser Gruppe entfernen?';

View file

@ -1020,6 +1020,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get alreadyInGroup => 'Already in Group'; String get alreadyInGroup => 'Already in Group';
@override
String get contactNotVerified => 'Not verified';
@override @override
String removeContactFromGroupTitle(Object username) { String removeContactFromGroupTitle(Object username) {
return 'Remove $username from this group?'; return 'Remove $username from this group?';

@ -1 +1 @@
Subproject commit 7686a412d9911bafe7585007b4eb867270e4fde4 Subproject commit 80b21d7e566a12d105f573cb7224b6cdfe37048b

View file

@ -156,6 +156,8 @@ class UserData {
@JsonKey(defaultValue: false) @JsonKey(defaultValue: false)
bool isBackupEnabled = false; bool isBackupEnabled = false;
PasswordLessRecovery? passwordLessRecovery;
// Used for push notifcation via FCM. // Used for push notifcation via FCM.
String? fcmToken; String? fcmToken;
@ -203,3 +205,23 @@ class TwonlySafeBackup {
List<int> encryptionKey; List<int> encryptionKey;
Map<String, dynamic> toJson() => _$TwonlySafeBackupToJson(this); Map<String, dynamic> toJson() => _$TwonlySafeBackupToJson(this);
} }
@JsonSerializable()
class PasswordLessRecovery {
PasswordLessRecovery({
this.email,
this.pinSeed,
this.pinUnlockToken,
this.threshold,
});
factory PasswordLessRecovery.fromJson(Map<String, dynamic> json) =>
_$PasswordLessRecoveryFromJson(json);
String? email;
String? pinSeed;
String? pinUnlockToken;
int? threshold;
Map<String, dynamic> toJson() => _$PasswordLessRecoveryToJson(this);
}

View file

@ -103,6 +103,11 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
json['twonlySafeBackup'] as Map<String, dynamic>, json['twonlySafeBackup'] as Map<String, dynamic>,
) )
..isBackupEnabled = json['isBackupEnabled'] as bool? ?? false ..isBackupEnabled = json['isBackupEnabled'] as bool? ?? false
..passwordLessRecovery = json['passwordLessRecovery'] == null
? null
: PasswordLessRecovery.fromJson(
json['passwordLessRecovery'] as Map<String, dynamic>,
)
..fcmToken = json['fcmToken'] as String? ..fcmToken = json['fcmToken'] as String?
..askedForUserStudyPermission = ..askedForUserStudyPermission =
json['askedForUserStudyPermission'] as bool? ?? false json['askedForUserStudyPermission'] as bool? ?? false
@ -171,6 +176,7 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'canUseLoginTokenForAuth': instance.canUseLoginTokenForAuth, 'canUseLoginTokenForAuth': instance.canUseLoginTokenForAuth,
'twonlySafeBackup': instance.twonlySafeBackup, 'twonlySafeBackup': instance.twonlySafeBackup,
'isBackupEnabled': instance.isBackupEnabled, 'isBackupEnabled': instance.isBackupEnabled,
'passwordLessRecovery': instance.passwordLessRecovery,
'fcmToken': instance.fcmToken, 'fcmToken': instance.fcmToken,
'askedForUserStudyPermission': instance.askedForUserStudyPermission, 'askedForUserStudyPermission': instance.askedForUserStudyPermission,
'userStudyParticipantsToken': instance.userStudyParticipantsToken, 'userStudyParticipantsToken': instance.userStudyParticipantsToken,
@ -234,3 +240,21 @@ const _$LastBackupUploadStateEnumMap = {
LastBackupUploadState.failed: 'failed', LastBackupUploadState.failed: 'failed',
LastBackupUploadState.success: 'success', LastBackupUploadState.success: 'success',
}; };
PasswordLessRecovery _$PasswordLessRecoveryFromJson(
Map<String, dynamic> 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<String, dynamic> _$PasswordLessRecoveryToJson(
PasswordLessRecovery instance,
) => <String, dynamic>{
'email': instance.email,
'pin_seed': instance.pinSeed,
'pin_unlock_token': instance.pinUnlockToken,
'threshold': instance.threshold,
};

View file

@ -0,0 +1,23 @@
import 'dart:async';
import 'package:twonly/src/utils/log.dart';
class PasswordlessRecoveryService {
static Future<bool> enablePasswordlessRecovery({
required List<int> 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;
}
}

View file

@ -9,11 +9,13 @@ class ContextMenu extends StatefulWidget {
const ContextMenu({ const ContextMenu({
required this.child, required this.child,
required this.items, required this.items,
this.minWidth,
super.key, super.key,
}); });
final List<ContextMenuItem> items; final List<ContextMenuItem> items;
final Widget child; final Widget child;
final double? minWidth;
@override @override
State<ContextMenu> createState() => _ContextMenuState(); State<ContextMenu> createState() => _ContextMenuState();
@ -116,17 +118,26 @@ class _ContextMenuState extends State<ContextMenu>
), ),
items: <PopupMenuEntry<int>>[ items: <PopupMenuEntry<int>>[
...widget.items.map( ...widget.items.map(
(item) => PopupMenuItem( (item) {
padding: const EdgeInsets.only(right: 4), Widget child = ListTile(
child: ListTile(
title: Text(item.title), title: Text(item.title),
onTap: () async { onTap: () async {
if (mounted) Navigator.pop(context); if (mounted) Navigator.pop(context);
await item.onTap(); await item.onTap();
}, },
leading: _getIcon(item.icon), 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( position: RelativeRect.fromRect(

View file

@ -18,6 +18,7 @@ class UserContextMenu extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ContextMenu( return ContextMenu(
minWidth: 150,
items: [ items: [
ContextMenuItem( ContextMenuItem(
title: context.lang.contextMenuUserProfile, title: context.lang.contextMenuUserProfile,

View file

@ -10,6 +10,7 @@ enum MyButtonVariant {
primaryMiddle, primaryMiddle,
primaryDense, primaryDense,
secondaryDense, secondaryDense,
error,
} }
class MyButton extends StatefulWidget { class MyButton extends StatefulWidget {
@ -211,6 +212,25 @@ class _MyButtonState extends State<MyButton>
fontWeight: FontWeight.bold, 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 final childButton = widget.variant == MyButtonVariant.text

View file

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:twonly/locator.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/services/backup.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.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 { class BackupView extends StatefulWidget {
const BackupView({super.key}); const BackupView({super.key});
@ -176,9 +178,30 @@ class _BackupViewState extends State<BackupView> {
), ),
]), ]),
), ),
const SizedBox(height: 10), ],
MyButton( ),
if (userService.currentUser.passwordLessRecovery == null &&
kDebugMode) ...[
const SizedBox(height: 20),
Center(
child: MyButton(
variant: MyButtonVariant.primaryMiddle, 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 onPressed: _isLoading
? null ? null
: () async { : () async {
@ -192,20 +215,22 @@ class _BackupViewState extends State<BackupView> {
}, },
child: Text(context.lang.backupTwonlySaveNow), child: Text(context.lang.backupTwonlySaveNow),
), ),
const SizedBox(width: 12),
], ],
), MyButton(
const SizedBox(height: 32),
Center(
child: MyButton(
variant: MyButtonVariant.secondaryDense, variant: MyButtonVariant.secondaryDense,
onPressed: () => onPressed: () => context.push(
context.push(Routes.settingsBackupSetup, extra: true), Routes.settingsBackupSetup,
extra: true,
),
child: Text( child: Text(
!userService.currentUser.isBackupEnabled !userService.currentUser.isBackupEnabled
? context.lang.backupEnableBackup ? context.lang.backupEnableBackup
: context.lang.backupChangePassword, : context.lang.backupChangePassword,
), ),
), ),
],
),
), ),
], ],
), ),

View file

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

View file

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

View file

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

View file

@ -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<PasswordLessRecoverySetup> createState() =>
_PasswordLessRecoverySetupState();
}
class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
List<Contact> _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<void> _selectTrustedFriends() async {
final selectedIds = await Navigator.push<List<int>>(
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 = <Contact>[];
for (final id in selectedIds) {
final contact = await twonlyDB.contactsDao.getContactById(id);
if (contact != null) contacts.add(contact);
}
setState(() {
_selectedContacts = contacts;
_threshold = _validThreshold;
});
}
Future<void> _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<Color>(
Colors.black87,
),
),
)
else
const Icon(Icons.check_circle_outline_rounded, size: 20),
const SizedBox(width: 8),
const Text('Enable Passwordless Recovery'),
],
),
),
],
),
),
),
);
}
}

View file

@ -11,6 +11,7 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.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/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/context_menu/user.context_menu.dart';
import 'package:twonly/src/visual/decorations/input_text.decoration.dart'; import 'package:twonly/src/visual/decorations/input_text.decoration.dart';
@ -19,10 +20,12 @@ class SelectedContactView {
required this.title, required this.title,
required this.submitButton, required this.submitButton,
required this.submitIcon, required this.submitIcon,
this.alreadySelectedSubtitle,
}); });
final String title; final String title;
final String Function(int selected, int? limit) submitButton; final String Function(int selected, int? limit) submitButton;
final FaIconData submitIcon; final FaIconData submitIcon;
final String? alreadySelectedSubtitle;
} }
class SelectContactsView extends StatefulWidget { class SelectContactsView extends StatefulWidget {
@ -30,11 +33,18 @@ class SelectContactsView extends StatefulWidget {
required this.text, required this.text,
this.alreadySelected, this.alreadySelected,
this.limit, this.limit,
this.isAlreadySelectedLocked = true,
this.onlyVerified = false,
this.sortByMediaCount = false,
super.key, super.key,
}); });
final SelectedContactView text; final SelectedContactView text;
final List<int>? alreadySelected; final List<int>? alreadySelected;
final int? limit; final int? limit;
final bool isAlreadySelectedLocked;
final bool onlyVerified;
final bool sortByMediaCount;
@override @override
State<SelectContactsView> createState() => _SelectAdditionalUsers(); State<SelectContactsView> createState() => _SelectAdditionalUsers();
} }
@ -47,12 +57,17 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
final HashSet<int> selectedUsers = HashSet(); final HashSet<int> selectedUsers = HashSet();
late HashSet<int> _alreadySelected; late HashSet<int> _alreadySelected;
final HashSet<int> verifiedUserIds = HashSet();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_alreadySelected = HashSet.from(widget.alreadySelected ?? []); _alreadySelected = HashSet.from(widget.alreadySelected ?? []);
if (!widget.isAlreadySelectedLocked) {
selectedUsers.addAll(_alreadySelected);
_alreadySelected.clear();
}
final stream = twonlyDB.contactsDao.watchAllAcceptedContacts(); final stream = twonlyDB.contactsDao.watchAllAcceptedContacts();
@ -65,6 +80,23 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
}); });
await filterUsers(); await filterUsers();
}); });
_loadVerifiedContacts();
}
Future<void> _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 @override
@ -74,21 +106,37 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
} }
Future<void> filterUsers() async { Future<void> filterUsers() async {
if (searchUserName.value.text.isEmpty) { var filtered = allContacts;
setState(() { if (searchUserName.value.text.isNotEmpty) {
contacts = allContacts; filtered = filtered
});
return;
}
final usersFiltered = allContacts
.where( .where(
(user) => getContactDisplayName( (user) => getContactDisplayName(
user, user,
).toLowerCase().contains(searchUserName.value.text.toLowerCase()), ).toLowerCase().contains(searchUserName.value.text.toLowerCase()),
) )
.toList(); .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(() { setState(() {
contacts = usersFiltered; contacts = filtered;
}); });
} }
@ -119,7 +167,7 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
: () => Navigator.pop(context, selectedUsers.toList()), : () => Navigator.pop(context, selectedUsers.toList()),
label: Text( label: Text(
widget.text.submitButton( widget.text.submitButton(
selectedUsers.length + (widget.alreadySelected?.length ?? 0), selectedUsers.length + _alreadySelected.length,
widget.limit, widget.limit,
), ),
), ),
@ -169,10 +217,15 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
return Wrap( return Wrap(
spacing: 8, spacing: 8,
children: selected.map((w) { children: selected.map((w) {
final contact = allContacts
.where((t) => t.userId == w)
.firstOrNull;
if (contact == null) {
return const SizedBox.shrink();
}
return _Chip( return _Chip(
contact: allContacts.firstWhere( key: ValueKey(contact.userId),
(t) => t.userId == w, contact: contact,
),
onTap: toggleSelectedUser, onTap: toggleSelectedUser,
); );
}).toList(), }).toList(),
@ -188,13 +241,27 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
i -= 2; i -= 2;
} }
final user = contacts[i]; final user = contacts[i];
final isVerified = verifiedUserIds.contains(user.userId);
final isSelectionDisabled = widget.onlyVerified && !isVerified;
return UserContextMenu( return UserContextMenu(
key: ValueKey(user.userId), key: ValueKey(user.userId),
contact: user, contact: user,
child: ListTile( child: ListTile(
enabled: !isSelectionDisabled,
title: Row( title: Row(
children: [ children: [
Text(getContactDisplayName(user)), Flexible(
child: Text(
getContactDisplayName(user),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
VerificationBadgeComp(
contact: user,
size: 14,
clickable: false,
),
FlameCounterWidget( FlameCounterWidget(
contactId: user.userId, contactId: user.userId,
prefix: true, prefix: true,
@ -202,8 +269,17 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
], ],
), ),
subtitle: (_alreadySelected.contains(user.userId)) subtitle: (_alreadySelected.contains(user.userId))
? Text(context.lang.alreadyInGroup) ? (widget.text.alreadySelectedSubtitle != null
: 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( leading: AvatarIcon(
contactId: user.userId, contactId: user.userId,
fontSize: 13, fontSize: 13,
@ -222,11 +298,15 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
); );
}, },
), ),
onChanged: (value) { onChanged: isSelectionDisabled
? null
: (value) {
toggleSelectedUser(user.userId); toggleSelectedUser(user.userId);
}, },
), ),
onTap: () { onTap: isSelectionDisabled
? null
: () {
toggleSelectedUser(user.userId); toggleSelectedUser(user.userId);
}, },
), ),
@ -247,6 +327,7 @@ class _Chip extends StatelessWidget {
const _Chip({ const _Chip({
required this.contact, required this.contact,
required this.onTap, required this.onTap,
super.key,
}); });
final Contact contact; final Contact contact;
final void Function(int) onTap; final void Function(int) onTap;
@ -268,6 +349,12 @@ class _Chip extends StatelessWidget {
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(width: 4),
VerificationBadgeComp(
contact: contact,
size: 12,
clickable: false,
),
const SizedBox(width: 15), const SizedBox(width: 15),
const FaIcon( const FaIcon(
FontAwesomeIcons.xmark, FontAwesomeIcons.xmark,

View file

@ -1,5 +1,6 @@
use std::io::Result; use std::io::Result;
fn main() -> Result<()> { fn main() -> Result<()> {
prost_build::compile_protos(&["src/user_discovery/types.proto"], &["src/"])?; prost_build::compile_protos(&["src/user_discovery/types.proto"], &["src/"])?;
prost_build::compile_protos(&["src/passwordless_recovery/types.proto"], &["src/"])?;
Ok(()) Ok(())
} }

View file

@ -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<Vec<u8>>,
) {
}
}

View file

@ -36,8 +36,8 @@ message TrustedFriendShare {
// The minimum threshold required to decrypte the shares. // The minimum threshold required to decrypte the shares.
int32 threshold = 3; int32 threshold = 3;
// The actual share which will become: SecretSharedDate // The actual share which will become: SharedSecretData
bytes share = 4; bytes shared_secret_data = 4;
message User { message User {
int64 user_id = 1; int64 user_id = 1;
@ -47,7 +47,7 @@ message TrustedFriendShare {
} }
// After received all shares this is decrypted by the user restoring its own // After received all shares this is decrypted by the user restoring its own
message SecretSharedDate { message SharedSecretData {
// No second factor was selected // No second factor was selected
optional RecoveryData recovery_data = 1; 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. // 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 { message RecoveryData {
int64 user_id = 1; int64 user_id = 1;
bytes private_key = 2; bytes key_manager = 3;
bytes backup_master_key = 3;
} }