mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-06-25 02:54:07 +00:00
start with passwordless recovery
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
This commit is contained in:
parent
2687545e33
commit
98fa90a46b
20 changed files with 956 additions and 64 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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?';
|
||||
|
|
|
|||
|
|
@ -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?';
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 7686a412d9911bafe7585007b4eb867270e4fde4
|
||||
Subproject commit 80b21d7e566a12d105f573cb7224b6cdfe37048b
|
||||
|
|
@ -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<int> encryptionKey;
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,6 +103,11 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
|
|||
json['twonlySafeBackup'] as Map<String, dynamic>,
|
||||
)
|
||||
..isBackupEnabled = json['isBackupEnabled'] as bool? ?? false
|
||||
..passwordLessRecovery = json['passwordLessRecovery'] == null
|
||||
? null
|
||||
: PasswordLessRecovery.fromJson(
|
||||
json['passwordLessRecovery'] as Map<String, dynamic>,
|
||||
)
|
||||
..fcmToken = json['fcmToken'] as String?
|
||||
..askedForUserStudyPermission =
|
||||
json['askedForUserStudyPermission'] as bool? ?? false
|
||||
|
|
@ -171,6 +176,7 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
|
|||
'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<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,
|
||||
};
|
||||
|
|
|
|||
23
lib/src/services/passwordless_recovery.service.dart
Normal file
23
lib/src/services/passwordless_recovery.service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,11 +9,13 @@ class ContextMenu extends StatefulWidget {
|
|||
const ContextMenu({
|
||||
required this.child,
|
||||
required this.items,
|
||||
this.minWidth,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final List<ContextMenuItem> items;
|
||||
final Widget child;
|
||||
final double? minWidth;
|
||||
|
||||
@override
|
||||
State<ContextMenu> createState() => _ContextMenuState();
|
||||
|
|
@ -116,17 +118,26 @@ class _ContextMenuState extends State<ContextMenu>
|
|||
),
|
||||
items: <PopupMenuEntry<int>>[
|
||||
...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(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class UserContextMenu extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ContextMenu(
|
||||
minWidth: 150,
|
||||
items: [
|
||||
ContextMenuItem(
|
||||
title: context.lang.contextMenuUserProfile,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ enum MyButtonVariant {
|
|||
primaryMiddle,
|
||||
primaryDense,
|
||||
secondaryDense,
|
||||
error,
|
||||
}
|
||||
|
||||
class MyButton extends StatefulWidget {
|
||||
|
|
@ -211,6 +212,25 @@ class _MyButtonState extends State<MyButton>
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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<BackupView> {
|
|||
),
|
||||
]),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<int>? alreadySelected;
|
||||
final int? limit;
|
||||
final bool isAlreadySelectedLocked;
|
||||
final bool onlyVerified;
|
||||
final bool sortByMediaCount;
|
||||
|
||||
@override
|
||||
State<SelectContactsView> createState() => _SelectAdditionalUsers();
|
||||
}
|
||||
|
|
@ -47,12 +57,17 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
|
|||
|
||||
final HashSet<int> selectedUsers = HashSet();
|
||||
late HashSet<int> _alreadySelected;
|
||||
final HashSet<int> 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<SelectContactsView> {
|
|||
});
|
||||
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
|
||||
|
|
@ -74,21 +106,37 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
|
|||
}
|
||||
|
||||
Future<void> 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<SelectContactsView> {
|
|||
: () => 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<SelectContactsView> {
|
|||
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<SelectContactsView> {
|
|||
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<SelectContactsView> {
|
|||
],
|
||||
),
|
||||
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<SelectContactsView> {
|
|||
);
|
||||
},
|
||||
),
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue