Implement recovery key pages and device cubit

Co-authored-by: Inex Code <inex.code@selfprivacy.org>
pull/90/head
NaiJi ✨ 2022-05-26 04:02:06 +03:00
parent 5dcaa060a1
commit 72ef16c6f6
14 changed files with 502 additions and 70 deletions

View File

@ -325,13 +325,24 @@
"confirm_backblaze_description": "Enter a Backblaze token with access to backup storage:"
},
"recovery_key": {
"key_connection_error": "Couldn't connect to the server.",
"key_synchronizing": "Synchronizing...",
"key_main_header": "Recovery key",
"key_main_description": "Is needed for SelfPrivacy authorization when all your other authorized devices aren't available.",
"key_amount_toggle": "Limit by number of uses",
"key_amount_field_title": "Max number of uses",
"key_duedate_toggle": "Limit by time",
"key_duedate_field_title": "Due date of expiration",
"key_receive_button": "Receive key"
"key_receive_button": "Receive key",
"key_valid": "Your key is valid",
"key_invalid": "Your key is no longer valid",
"key_valid_until": "Valid until {}",
"key_valid_for": "Valid for {} uses",
"key_creation_date": "Created on {}",
"key_replace_button": "Generate new key",
"key_receiving_description": "Write down this key and put to a safe place. It is used to restore full access to your server:",
"key_receiving_info": "The key will never ever be shown again, but you will be able to replace it with another one.",
"key_receiving_done": "Done!"
},
"modals": {
"_comment": "messages in modals",

View File

@ -3,6 +3,13 @@ import 'package:flutter/cupertino.dart';
import 'package:ionicons/ionicons.dart';
import 'package:selfprivacy/ui/components/brand_icons/brand_icons.dart';
enum LoadingStatus {
uninitialized,
refreshing,
success,
error,
}
enum InitializingSteps {
setHetznerKey,
setCloudFlareKey,

View File

@ -0,0 +1,75 @@
import 'package:selfprivacy/config/get_it_config.dart';
import 'package:selfprivacy/logic/api_maps/server.dart';
import 'package:selfprivacy/logic/common_enum/common_enum.dart';
import 'package:selfprivacy/logic/cubit/app_config_dependent/authentication_dependend_cubit.dart';
import 'package:selfprivacy/logic/models/json/api_token.dart';
part 'devices_state.dart';
class ApiDevicesCubit
extends ServerInstallationDependendCubit<ApiDevicesState> {
ApiDevicesCubit(ServerInstallationCubit serverInstallationCubit)
: super(serverInstallationCubit, const ApiDevicesState.initial());
final api = ServerApi();
@override
void load() async {
if (serverInstallationCubit.state is ServerInstallationFinished) {
emit(const ApiDevicesState([], LoadingStatus.refreshing));
final devices = await _getApiTokens();
if (devices != null) {
emit(ApiDevicesState(devices, LoadingStatus.success));
} else {
emit(const ApiDevicesState([], LoadingStatus.error));
}
}
}
Future<void> refresh() async {
emit(const ApiDevicesState([], LoadingStatus.refreshing));
final devices = await _getApiTokens();
if (devices != null) {
emit(ApiDevicesState(devices, LoadingStatus.success));
} else {
emit(const ApiDevicesState([], LoadingStatus.error));
}
}
Future<List<ApiToken>?> _getApiTokens() async {
final response = await api.getApiTokens();
if (response.isSuccess) {
return response.data;
} else {
return null;
}
}
Future<void> deleteDevice(ApiToken device) async {
final response = await api.deleteApiToken(device.name);
if (response.isSuccess) {
emit(ApiDevicesState(
state.devices.where((d) => d.name != device.name).toList(),
LoadingStatus.success));
} else {
getIt<NavigationService>()
.showSnackBar(response.errorMessage ?? 'Error deleting device');
}
}
Future<String?> getNewDeviceKey() async {
final response = await api.createDeviceToken();
if (response.isSuccess) {
return response.data;
} else {
getIt<NavigationService>().showSnackBar(
response.errorMessage ?? 'Error getting new device key');
return null;
}
}
@override
void clear() {
emit(const ApiDevicesState.initial());
}
}

View File

@ -0,0 +1,33 @@
part of 'devices_cubit.dart';
class ApiDevicesState extends ServerInstallationDependendState {
const ApiDevicesState(this._devices, this.status);
const ApiDevicesState.initial() : this(const [], LoadingStatus.uninitialized);
final List<ApiToken> _devices;
final LoadingStatus status;
List<ApiToken> get devices => _devices;
ApiToken get thisDevice => _devices.firstWhere((device) => device.isCaller,
orElse: () => ApiToken(
name: 'Error fetching device',
isCaller: true,
date: DateTime.now(),
));
List<ApiToken> get otherDevices =>
_devices.where((device) => !device.isCaller).toList();
ApiDevicesState copyWith({
List<ApiToken>? devices,
LoadingStatus? status,
}) {
return ApiDevicesState(
devices ?? _devices,
status ?? this.status,
);
}
@override
List<Object?> get props => [_devices];
}

View File

@ -1,4 +1,5 @@
import 'package:selfprivacy/logic/api_maps/server.dart';
import 'package:selfprivacy/logic/common_enum/common_enum.dart';
import 'package:selfprivacy/logic/cubit/app_config_dependent/authentication_dependend_cubit.dart';
import 'package:selfprivacy/logic/models/json/recovery_token_status.dart';
@ -18,7 +19,8 @@ class RecoveryKeyCubit
if (status == null) {
emit(state.copyWith(loadingStatus: LoadingStatus.error));
} else {
emit(state.copyWith(status: status, loadingStatus: LoadingStatus.good));
emit(state.copyWith(
status: status, loadingStatus: LoadingStatus.success));
}
} else {
emit(state.copyWith(loadingStatus: LoadingStatus.uninitialized));
@ -41,7 +43,8 @@ class RecoveryKeyCubit
if (status == null) {
emit(state.copyWith(loadingStatus: LoadingStatus.error));
} else {
emit(state.copyWith(status: status, loadingStatus: LoadingStatus.good));
emit(
state.copyWith(status: status, loadingStatus: LoadingStatus.success));
}
}

View File

@ -1,12 +1,5 @@
part of 'recovery_key_cubit.dart';
enum LoadingStatus {
uninitialized,
refreshing,
good,
error,
}
class RecoveryKeyState extends ServerInstallationDependendState {
const RecoveryKeyState(this._status, this.loadingStatus);
@ -20,7 +13,7 @@ class RecoveryKeyState extends ServerInstallationDependendState {
bool get exists => _status.exists;
bool get isValid => _status.valid;
DateTime? get generatedAt => _status.date;
DateTime? get expiresAt => _status.date;
DateTime? get expiresAt => _status.expiration;
int? get usesLeft => _status.usesLeft;
@override
List<Object> get props => [_status, loadingStatus];

View File

@ -307,8 +307,8 @@ class ServerInstallationCubit extends Cubit<ServerInstallationState> {
return;
}
try {
Future<ServerHostingDetails> Function(ServerDomain, String)
recoveryFunction;
Future<ServerHostingDetails> Function(
ServerDomain, String, ServerRecoveryCapabilities) recoveryFunction;
switch (method) {
case ServerRecoveryMethods.newDeviceKey:
recoveryFunction = repository.authorizeByNewDeviceKey;
@ -325,6 +325,7 @@ class ServerInstallationCubit extends Cubit<ServerInstallationState> {
final serverDetails = await recoveryFunction(
serverDomain,
token,
dataState.recoveryCapabilities,
);
await repository.saveServerDetails(serverDetails);
emit(dataState.copyWith(

View File

@ -392,6 +392,7 @@ class ServerInstallationRepository {
Future<ServerHostingDetails> authorizeByNewDeviceKey(
ServerDomain serverDomain,
String newDeviceKey,
ServerRecoveryCapabilities recoveryCapabilities,
) async {
var serverApi = ServerApi(
isWithToken: false,
@ -424,6 +425,7 @@ class ServerInstallationRepository {
Future<ServerHostingDetails> authorizeByRecoveryKey(
ServerDomain serverDomain,
String recoveryKey,
ServerRecoveryCapabilities recoveryCapabilities,
) async {
var serverApi = ServerApi(
isWithToken: false,
@ -455,12 +457,34 @@ class ServerInstallationRepository {
Future<ServerHostingDetails> authorizeByApiToken(
ServerDomain serverDomain,
String apiToken,
ServerRecoveryCapabilities recoveryCapabilities,
) async {
var serverApi = ServerApi(
isWithToken: false,
overrideDomain: serverDomain.domainName,
customToken: apiToken,
);
if (recoveryCapabilities == ServerRecoveryCapabilities.legacy) {
final apiResponse = await serverApi.servicesPowerCheck();
if (apiResponse.isNotEmpty) {
return ServerHostingDetails(
apiToken: apiToken,
volume: ServerVolume(
id: 0,
name: '',
),
provider: ServerProvider.unknown,
id: 0,
ip4: '',
startTime: null,
createTime: null,
);
} else {
throw ServerAuthorizationException(
'Couldn\'t connect to server with this token',
);
}
}
final deviceAuthKey = await serverApi.createDeviceToken();
final apiResponse = await serverApi.authorizeDevice(
DeviceToken(device: await getDeviceName(), token: deviceAuthKey.data));

View File

@ -6,20 +6,29 @@ class FilledButton extends StatelessWidget {
this.onPressed,
this.title,
this.child,
this.disabled = false,
}) : super(key: key);
final VoidCallback? onPressed;
final String? title;
final Widget? child;
final bool disabled;
@override
Widget build(BuildContext context) {
final ButtonStyle _enabledStyle = ElevatedButton.styleFrom(
onPrimary: Theme.of(context).colorScheme.onPrimary,
primary: Theme.of(context).colorScheme.primary,
).copyWith(elevation: ButtonStyleButton.allOrNull(0.0));
final ButtonStyle _disabledStyle = ElevatedButton.styleFrom(
onPrimary: Theme.of(context).colorScheme.onSurface.withAlpha(30),
primary: Theme.of(context).colorScheme.onSurface.withAlpha(98),
).copyWith(elevation: ButtonStyleButton.allOrNull(0.0));
return ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
onPrimary: Theme.of(context).colorScheme.onPrimary,
primary: Theme.of(context).colorScheme.primary,
).copyWith(elevation: ButtonStyleButton.allOrNull(0.0)),
style: disabled ? _disabledStyle : _enabledStyle,
child: child ?? Text(title ?? ''),
);
}

View File

@ -1,5 +1,17 @@
/*import 'package:flutter/src/foundation/key.dart';
import 'package:flutter/src/widgets/framework.dart';
import 'package:cubit_form/cubit_form.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:selfprivacy/logic/common_enum/common_enum.dart';
import 'package:selfprivacy/logic/cubit/recovery_key/recovery_key_cubit.dart';
import 'package:selfprivacy/logic/cubit/server_installation/server_installation_cubit.dart';
import 'package:selfprivacy/ui/components/brand_button/brand_button.dart';
import 'package:selfprivacy/ui/components/brand_button/filled_button.dart';
import 'package:selfprivacy/ui/components/brand_cards/brand_cards.dart';
import 'package:selfprivacy/ui/components/brand_hero_screen/brand_hero_screen.dart';
import 'package:selfprivacy/ui/components/brand_text/brand_text.dart';
import 'package:selfprivacy/ui/pages/recovery_key/recovery_key_receiving.dart';
import 'package:selfprivacy/utils/route_transitions/basic.dart';
class RecoveryKey extends StatefulWidget {
const RecoveryKey({Key? key}) : super(key: key);
@ -10,5 +22,219 @@ class RecoveryKey extends StatefulWidget {
class _RecoveryKeyState extends State<RecoveryKey> {
@override
Widget build(BuildContext context) {}
}*/
Widget build(BuildContext context) {
var keyStatus = context.watch<RecoveryKeyCubit>().state;
final List<Widget> widgets;
final String? subtitle =
keyStatus.exists ? null : 'recovery_key.key_main_description'.tr();
switch (keyStatus.loadingStatus) {
case LoadingStatus.refreshing:
widgets = [
const Icon(Icons.refresh_outlined),
const SizedBox(height: 18),
BrandText(
'recovery_key.key_synchronizing'.tr(),
type: TextType.h1,
),
];
break;
case LoadingStatus.success:
widgets = [
const RecoveryKeyContent(),
];
break;
case LoadingStatus.uninitialized:
case LoadingStatus.error:
widgets = [
const Icon(Icons.sentiment_dissatisfied_outlined),
const SizedBox(height: 18),
BrandText(
'recovery_key.key_connection_error'.tr(),
type: TextType.h1,
),
];
break;
}
return BrandHeroScreen(
heroTitle: 'recovery_key.key_main_header'.tr(),
heroSubtitle: subtitle,
hasBackButton: true,
hasFlashButton: false,
children: widgets,
);
}
}
class RecoveryKeyContent extends StatefulWidget {
const RecoveryKeyContent({Key? key}) : super(key: key);
@override
State<RecoveryKeyContent> createState() => _RecoveryKeyContentState();
}
class _RecoveryKeyContentState extends State<RecoveryKeyContent> {
bool _isAmountToggled = true;
bool _isExpirationToggled = true;
bool _isConfigurationVisible = false;
final _amountController = TextEditingController();
final _expirationController = TextEditingController();
@override
Widget build(BuildContext context) {
var keyStatus = context.read<RecoveryKeyCubit>().state;
_isConfigurationVisible = !keyStatus.exists;
List<Widget> widgets = [];
if (keyStatus.exists) {
if (keyStatus.isValid) {
widgets = [
BrandCards.filled(
child: ListTile(
title: Text('recovery_key.key_valid'.tr()),
leading: const Icon(Icons.check_circle_outlined),
tileColor: Colors.lightGreen,
),
),
...widgets
];
} else {
widgets = [
BrandCards.filled(
child: ListTile(
title: Text('recovery_key.key_invalid'.tr()),
leading: const Icon(Icons.cancel_outlined),
tileColor: Colors.redAccent,
),
),
...widgets
];
}
if (keyStatus.expiresAt != null && !_isConfigurationVisible) {
widgets = [
...widgets,
const SizedBox(height: 18),
Text(
'recovery_key.key_valid_until'.tr(
args: [keyStatus.expiresAt!.toIso8601String()],
),
)
];
}
if (keyStatus.usesLeft != null && !_isConfigurationVisible) {
widgets = [
...widgets,
const SizedBox(height: 18),
Text(
'recovery_key.key_valid_for'.tr(
args: [keyStatus.usesLeft!.toString()],
),
)
];
}
if (keyStatus.generatedAt != null && !_isConfigurationVisible) {
widgets = [
...widgets,
const SizedBox(height: 18),
Text(
'recovery_key.key_creation_date'.tr(
args: [keyStatus.generatedAt!.toIso8601String()],
),
)
];
}
if (!_isConfigurationVisible) {
if (keyStatus.isValid) {
widgets = [
...widgets,
const SizedBox(height: 18),
BrandButton.text(
title: 'recovery_key.key_replace_button'.tr(),
onPressed: () => _isConfigurationVisible = true,
),
];
} else {
widgets = [
...widgets,
const SizedBox(height: 18),
FilledButton(
title: 'recovery_key.key_replace_button'.tr(),
onPressed: () => _isConfigurationVisible = true,
),
];
}
}
}
if (_isConfigurationVisible) {
widgets = [
...widgets,
const SizedBox(height: 18),
Row(
children: [
Text('key_amount_toggle'.tr()),
Switch(
value: _isAmountToggled,
onChanged: (bool toogled) => _isAmountToggled = toogled,
),
],
),
const SizedBox(height: 18),
TextField(
enabled: _isAmountToggled,
controller: _amountController,
decoration: InputDecoration(
labelText: 'recovery_key.key_amount_field_title'.tr()),
keyboardType: TextInputType.number,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly,
], // Only numbers can be entered
),
const SizedBox(height: 18),
Row(
children: [
Text('key_duedate_toggle'.tr()),
Switch(
value: _isExpirationToggled,
onChanged: (bool toogled) => _isExpirationToggled = toogled,
),
],
),
const SizedBox(height: 18),
TextField(
enabled: _isExpirationToggled,
controller: _expirationController,
decoration: InputDecoration(
labelText: 'recovery_key.key_duedate_field_title'.tr()),
keyboardType: TextInputType.number,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly,
], // Only numbers can be entered
),
const SizedBox(height: 18),
FilledButton(
title: 'recovery_key.key_receive_button'.tr(),
disabled:
(_isExpirationToggled && _expirationController.text.isEmpty) ||
(_isAmountToggled && _amountController.text.isEmpty),
onPressed: () {
Navigator.of(context).push(
materialRoute(
const RecoveryKeyReceiving(recoveryKey: ''), // TO DO
),
);
},
),
];
}
return Column(children: widgets);
}
}

View File

@ -1 +1,37 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:selfprivacy/ui/components/brand_button/filled_button.dart';
import 'package:selfprivacy/ui/components/brand_hero_screen/brand_hero_screen.dart';
import 'package:selfprivacy/ui/pages/root_route.dart';
import 'package:selfprivacy/utils/route_transitions/basic.dart';
class RecoveryKeyReceiving extends StatelessWidget {
const RecoveryKeyReceiving({required this.recoveryKey, Key? key})
: super(key: key);
final String recoveryKey;
@override
Widget build(BuildContext context) {
return BrandHeroScreen(
heroTitle: 'recovery_key.key_main_header'.tr(),
heroSubtitle: 'recovering.method_select_description'.tr(),
hasBackButton: true,
hasFlashButton: false,
children: [
Text(recoveryKey, style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 18),
const Icon(Icons.info_outlined, size: 14),
Text('recovery_key.key_receiving_info'.tr()),
const SizedBox(height: 18),
FilledButton(
title: 'recovery_key.key_receiving_done'.tr(),
onPressed: () {
Navigator.of(context)
.pushReplacement(materialRoute(const RootPage()));
},
),
],
);
}
}

View File

@ -21,7 +21,6 @@ class RecoverByOldTokenInstruction extends StatelessWidget {
if (state is ServerInstallationRecovery &&
state.currentStep != RecoveryStep.selecting) {
Navigator.of(context).pop();
Navigator.of(context).pop();
}
},
child: BrandHeroScreen(

View File

@ -1,5 +1,6 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:selfprivacy/logic/cubit/server_installation/server_installation_cubit.dart';
import 'package:selfprivacy/ui/components/brand_button/brand_button.dart';
import 'package:selfprivacy/ui/components/brand_cards/brand_cards.dart';
@ -59,58 +60,68 @@ class RecoveryFallbackMethodSelect extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BrandHeroScreen(
heroTitle: 'recovering.recovery_main_header'.tr(),
heroSubtitle: 'recovering.fallback_select_description'.tr(),
hasBackButton: true,
hasFlashButton: false,
children: [
BrandCards.outlined(
child: ListTile(
title: Text(
'recovering.fallback_select_token_copy'.tr(),
style: Theme.of(context).textTheme.titleMedium,
return BlocListener<ServerInstallationCubit, ServerInstallationState>(
listener: (context, state) {
if (state is ServerInstallationRecovery &&
state.recoveryCapabilities ==
ServerRecoveryCapabilities.loginTokens &&
state.currentStep != RecoveryStep.selecting) {
Navigator.of(context).pop();
}
},
child: BrandHeroScreen(
heroTitle: 'recovering.recovery_main_header'.tr(),
heroSubtitle: 'recovering.fallback_select_description'.tr(),
hasBackButton: true,
hasFlashButton: false,
children: [
BrandCards.outlined(
child: ListTile(
title: Text(
'recovering.fallback_select_token_copy'.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
leading: const Icon(Icons.vpn_key),
onTap: () => Navigator.of(context)
.push(materialRoute(const RecoverByOldTokenInstruction(
instructionFilename: 'how_fallback_old',
))),
),
leading: const Icon(Icons.vpn_key),
onTap: () => Navigator.of(context)
.push(materialRoute(const RecoverByOldTokenInstruction(
instructionFilename: 'how_fallback_old',
))),
),
),
const SizedBox(height: 16),
BrandCards.outlined(
child: ListTile(
title: Text(
'recovering.fallback_select_root_ssh'.tr(),
style: Theme.of(context).textTheme.titleMedium,
const SizedBox(height: 16),
BrandCards.outlined(
child: ListTile(
title: Text(
'recovering.fallback_select_root_ssh'.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
leading: const Icon(Icons.terminal),
onTap: () => Navigator.of(context)
.push(materialRoute(const RecoverByOldTokenInstruction(
instructionFilename: 'how_fallback_ssh',
))),
),
leading: const Icon(Icons.terminal),
onTap: () => Navigator.of(context)
.push(materialRoute(const RecoverByOldTokenInstruction(
instructionFilename: 'how_fallback_ssh',
))),
),
),
const SizedBox(height: 16),
BrandCards.outlined(
child: ListTile(
title: Text(
'recovering.fallback_select_provider_console'.tr(),
style: Theme.of(context).textTheme.titleMedium,
const SizedBox(height: 16),
BrandCards.outlined(
child: ListTile(
title: Text(
'recovering.fallback_select_provider_console'.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
subtitle: Text(
'recovering.fallback_select_provider_console_hint'.tr(),
style: Theme.of(context).textTheme.bodyMedium,
),
leading: const Icon(Icons.web),
onTap: () => Navigator.of(context)
.push(materialRoute(const RecoverByOldTokenInstruction(
instructionFilename: 'how_fallback_terminal',
))),
),
subtitle: Text(
'recovering.fallback_select_provider_console_hint'.tr(),
style: Theme.of(context).textTheme.bodyMedium,
),
leading: const Icon(Icons.web),
onTap: () => Navigator.of(context)
.push(materialRoute(const RecoverByOldTokenInstruction(
instructionFilename: 'how_fallback_terminal',
))),
),
),
],
],
),
);
}
}

View File

@ -27,10 +27,14 @@ class RecoveryRouting extends StatelessWidget {
if (serverInstallation is ServerInstallationRecovery) {
switch (serverInstallation.currentStep) {
case RecoveryStep.selecting:
if (serverInstallation.recoveryCapabilities !=
ServerRecoveryCapabilities.none) {
if (serverInstallation.recoveryCapabilities ==
ServerRecoveryCapabilities.loginTokens) {
currentPage = const RecoveryMethodSelect();
}
if (serverInstallation.recoveryCapabilities ==
ServerRecoveryCapabilities.legacy) {
currentPage = const RecoveryFallbackMethodSelect();
}
break;
case RecoveryStep.recoveryKey:
currentPage = const RecoverByRecoveryKey();