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:" "confirm_backblaze_description": "Enter a Backblaze token with access to backup storage:"
}, },
"recovery_key": { "recovery_key": {
"key_connection_error": "Couldn't connect to the server.",
"key_synchronizing": "Synchronizing...",
"key_main_header": "Recovery key", "key_main_header": "Recovery key",
"key_main_description": "Is needed for SelfPrivacy authorization when all your other authorized devices aren't available.", "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_toggle": "Limit by number of uses",
"key_amount_field_title": "Max number of uses", "key_amount_field_title": "Max number of uses",
"key_duedate_toggle": "Limit by time", "key_duedate_toggle": "Limit by time",
"key_duedate_field_title": "Due date of expiration", "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": { "modals": {
"_comment": "messages in modals", "_comment": "messages in modals",

View File

@ -3,6 +3,13 @@ import 'package:flutter/cupertino.dart';
import 'package:ionicons/ionicons.dart'; import 'package:ionicons/ionicons.dart';
import 'package:selfprivacy/ui/components/brand_icons/brand_icons.dart'; import 'package:selfprivacy/ui/components/brand_icons/brand_icons.dart';
enum LoadingStatus {
uninitialized,
refreshing,
success,
error,
}
enum InitializingSteps { enum InitializingSteps {
setHetznerKey, setHetznerKey,
setCloudFlareKey, 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/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/cubit/app_config_dependent/authentication_dependend_cubit.dart';
import 'package:selfprivacy/logic/models/json/recovery_token_status.dart'; import 'package:selfprivacy/logic/models/json/recovery_token_status.dart';
@ -18,7 +19,8 @@ class RecoveryKeyCubit
if (status == null) { if (status == null) {
emit(state.copyWith(loadingStatus: LoadingStatus.error)); emit(state.copyWith(loadingStatus: LoadingStatus.error));
} else { } else {
emit(state.copyWith(status: status, loadingStatus: LoadingStatus.good)); emit(state.copyWith(
status: status, loadingStatus: LoadingStatus.success));
} }
} else { } else {
emit(state.copyWith(loadingStatus: LoadingStatus.uninitialized)); emit(state.copyWith(loadingStatus: LoadingStatus.uninitialized));
@ -41,7 +43,8 @@ class RecoveryKeyCubit
if (status == null) { if (status == null) {
emit(state.copyWith(loadingStatus: LoadingStatus.error)); emit(state.copyWith(loadingStatus: LoadingStatus.error));
} else { } 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'; part of 'recovery_key_cubit.dart';
enum LoadingStatus {
uninitialized,
refreshing,
good,
error,
}
class RecoveryKeyState extends ServerInstallationDependendState { class RecoveryKeyState extends ServerInstallationDependendState {
const RecoveryKeyState(this._status, this.loadingStatus); const RecoveryKeyState(this._status, this.loadingStatus);
@ -20,7 +13,7 @@ class RecoveryKeyState extends ServerInstallationDependendState {
bool get exists => _status.exists; bool get exists => _status.exists;
bool get isValid => _status.valid; bool get isValid => _status.valid;
DateTime? get generatedAt => _status.date; DateTime? get generatedAt => _status.date;
DateTime? get expiresAt => _status.date; DateTime? get expiresAt => _status.expiration;
int? get usesLeft => _status.usesLeft; int? get usesLeft => _status.usesLeft;
@override @override
List<Object> get props => [_status, loadingStatus]; List<Object> get props => [_status, loadingStatus];

View File

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

View File

@ -392,6 +392,7 @@ class ServerInstallationRepository {
Future<ServerHostingDetails> authorizeByNewDeviceKey( Future<ServerHostingDetails> authorizeByNewDeviceKey(
ServerDomain serverDomain, ServerDomain serverDomain,
String newDeviceKey, String newDeviceKey,
ServerRecoveryCapabilities recoveryCapabilities,
) async { ) async {
var serverApi = ServerApi( var serverApi = ServerApi(
isWithToken: false, isWithToken: false,
@ -424,6 +425,7 @@ class ServerInstallationRepository {
Future<ServerHostingDetails> authorizeByRecoveryKey( Future<ServerHostingDetails> authorizeByRecoveryKey(
ServerDomain serverDomain, ServerDomain serverDomain,
String recoveryKey, String recoveryKey,
ServerRecoveryCapabilities recoveryCapabilities,
) async { ) async {
var serverApi = ServerApi( var serverApi = ServerApi(
isWithToken: false, isWithToken: false,
@ -455,12 +457,34 @@ class ServerInstallationRepository {
Future<ServerHostingDetails> authorizeByApiToken( Future<ServerHostingDetails> authorizeByApiToken(
ServerDomain serverDomain, ServerDomain serverDomain,
String apiToken, String apiToken,
ServerRecoveryCapabilities recoveryCapabilities,
) async { ) async {
var serverApi = ServerApi( var serverApi = ServerApi(
isWithToken: false, isWithToken: false,
overrideDomain: serverDomain.domainName, overrideDomain: serverDomain.domainName,
customToken: apiToken, 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 deviceAuthKey = await serverApi.createDeviceToken();
final apiResponse = await serverApi.authorizeDevice( final apiResponse = await serverApi.authorizeDevice(
DeviceToken(device: await getDeviceName(), token: deviceAuthKey.data)); DeviceToken(device: await getDeviceName(), token: deviceAuthKey.data));

View File

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

View File

@ -1,5 +1,17 @@
/*import 'package:flutter/src/foundation/key.dart'; import 'package:cubit_form/cubit_form.dart';
import 'package:flutter/src/widgets/framework.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 { class RecoveryKey extends StatefulWidget {
const RecoveryKey({Key? key}) : super(key: key); const RecoveryKey({Key? key}) : super(key: key);
@ -10,5 +22,219 @@ class RecoveryKey extends StatefulWidget {
class _RecoveryKeyState extends State<RecoveryKey> { class _RecoveryKeyState extends State<RecoveryKey> {
@override @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 && if (state is ServerInstallationRecovery &&
state.currentStep != RecoveryStep.selecting) { state.currentStep != RecoveryStep.selecting) {
Navigator.of(context).pop(); Navigator.of(context).pop();
Navigator.of(context).pop();
} }
}, },
child: BrandHeroScreen( child: BrandHeroScreen(

View File

@ -1,5 +1,6 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.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/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/brand_button.dart';
import 'package:selfprivacy/ui/components/brand_cards/brand_cards.dart'; import 'package:selfprivacy/ui/components/brand_cards/brand_cards.dart';
@ -59,58 +60,68 @@ class RecoveryFallbackMethodSelect extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BrandHeroScreen( return BlocListener<ServerInstallationCubit, ServerInstallationState>(
heroTitle: 'recovering.recovery_main_header'.tr(), listener: (context, state) {
heroSubtitle: 'recovering.fallback_select_description'.tr(), if (state is ServerInstallationRecovery &&
hasBackButton: true, state.recoveryCapabilities ==
hasFlashButton: false, ServerRecoveryCapabilities.loginTokens &&
children: [ state.currentStep != RecoveryStep.selecting) {
BrandCards.outlined( Navigator.of(context).pop();
child: ListTile( }
title: Text( },
'recovering.fallback_select_token_copy'.tr(), child: BrandHeroScreen(
style: Theme.of(context).textTheme.titleMedium, 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),
const SizedBox(height: 16), BrandCards.outlined(
BrandCards.outlined( child: ListTile(
child: ListTile( title: Text(
title: Text( 'recovering.fallback_select_root_ssh'.tr(),
'recovering.fallback_select_root_ssh'.tr(), style: Theme.of(context).textTheme.titleMedium,
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),
const SizedBox(height: 16), BrandCards.outlined(
BrandCards.outlined( child: ListTile(
child: ListTile( title: Text(
title: Text( 'recovering.fallback_select_provider_console'.tr(),
'recovering.fallback_select_provider_console'.tr(), style: Theme.of(context).textTheme.titleMedium,
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) { if (serverInstallation is ServerInstallationRecovery) {
switch (serverInstallation.currentStep) { switch (serverInstallation.currentStep) {
case RecoveryStep.selecting: case RecoveryStep.selecting:
if (serverInstallation.recoveryCapabilities != if (serverInstallation.recoveryCapabilities ==
ServerRecoveryCapabilities.none) { ServerRecoveryCapabilities.loginTokens) {
currentPage = const RecoveryMethodSelect(); currentPage = const RecoveryMethodSelect();
} }
if (serverInstallation.recoveryCapabilities ==
ServerRecoveryCapabilities.legacy) {
currentPage = const RecoveryFallbackMethodSelect();
}
break; break;
case RecoveryStep.recoveryKey: case RecoveryStep.recoveryKey:
currentPage = const RecoverByRecoveryKey(); currentPage = const RecoverByRecoveryKey();