diff --git a/assets/translations/en.json b/assets/translations/en.json index e75e564c..46cee9b8 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -28,7 +28,8 @@ "no_data": "No data", "wait": "Wait", "remove": "Remove", - "apply": "Apply" + "apply": "Apply", + "done": "Done" }, "more": { "_comment": "'More' tab", @@ -286,7 +287,7 @@ "recovery_main_header": "Connect to an existing server", "domain_recovery_description": "Enter a server domain you want to get access for:", "domain_recover_placeholder": "Your domain", - "domain_recover_error": "Server with such domain is not found", + "domain_recover_error": "Server with such domain was not found", "method_select_description": "Select a recovery method:", "method_select_other_device": "I have access on another device", "method_select_recovery_key": "I have a recovery key", @@ -324,6 +325,31 @@ "confirm_backblaze": "Connect to Backblaze", "confirm_backblaze_description": "Enter a Backblaze token with access to backup storage:" }, + "devices": { + "main_screen": { + "header": "Devices", + "description": "These devices have full access to the server via SelfPrivacy app.", + "this_device": "This device", + "other_devices": "Other devices", + "authorize_new_device": "Authorize new device", + "access_granted_on" : "Access granted on {}", + "tip": "Press on the device to revoke access." + }, + "add_new_device_screen": { + "header": "Authorizing new device", + "description": "Enter the key on the device you want to authorize:", + "please_wait": "Please wait", + "tip": "The key is valid for 10 minutes.", + "expired": "The key has expired.", + "get_new_key": "Get new key" + }, + "revoke_device_alert": { + "header": "Revoke access?", + "description": "The device {} will no longer have access to the server.", + "yes": "Revoke", + "no": "Cancel" + } + }, "recovery_key": { "key_connection_error": "Couldn't connect to the server.", "key_synchronizing": "Synchronizing...", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 042998f4..68665e26 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -28,7 +28,8 @@ "no_data": "Нет данных", "wait": "Загрузка", "remove": "Удалить", - "apply": "Подать" + "apply": "Подать", + "done": "Готово" }, "more": { "_comment": "вкладка ещё", @@ -322,6 +323,31 @@ "confirm_backblze": "Подключение к Backblaze", "confirm_backblaze_description": "Введите токен Backblaze, который имеет права на хранилище резервных копий:" }, + "devices": { + "main_screen": { + "header": "Устройства", + "description": "Эти устройства имеют полный доступ к управлению сервером через приложение SelfPrivacy.", + "this_device": "Это устройство", + "other_devices": "Другие устройства", + "authorize_new_device": "Авторизовать новое устройство", + "access_granted_on" : "Доступ выдан {}", + "tip": "Нажмите на устройство, чтобы отозвать доступ." + }, + "add_new_device_screen": { + "header": "Авторизация нового устройства", + "description": "Введите этот ключ на новом устройстве:", + "please_wait": "Пожалуйста, подождите", + "tip": "Ключ действителен 10 минут.", + "expired": "Срок действия ключа истёк.", + "get_new_key": "Получить новый ключ" + }, + "revoke_device_alert": { + "header": "Отозвать доступ?", + "description": "Устройство {} больше не сможет управлять сервером.", + "yes": "Отозвать", + "no": "Отмена" + } + }, "recovery_key": { "key_connection_error": "Не удалось соединиться с сервером", "key_synchronizing": "Синхронизация...", diff --git a/lib/config/bloc_config.dart b/lib/config/bloc_config.dart index 222b70bf..9fce0383 100644 --- a/lib/config/bloc_config.dart +++ b/lib/config/bloc_config.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:selfprivacy/logic/cubit/devices/devices_cubit.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/logic/cubit/app_settings/app_settings_cubit.dart'; @@ -24,6 +25,7 @@ class BlocAndProviderConfig extends StatelessWidget { var backupsCubit = BackupsCubit(serverInstallationCubit); var dnsRecordsCubit = DnsRecordsCubit(serverInstallationCubit); var recoveryKeyCubit = RecoveryKeyCubit(serverInstallationCubit); + var apiDevicesCubit = ApiDevicesCubit(serverInstallationCubit); return MultiProvider( providers: [ BlocProvider( @@ -39,6 +41,7 @@ class BlocAndProviderConfig extends StatelessWidget { BlocProvider(create: (_) => backupsCubit..load(), lazy: false), BlocProvider(create: (_) => dnsRecordsCubit..load()), BlocProvider(create: (_) => recoveryKeyCubit..load()), + BlocProvider(create: (_) => apiDevicesCubit..load()), BlocProvider( create: (_) => JobsCubit(usersCubit: usersCubit, servicesCubit: servicesCubit), diff --git a/lib/logic/api_maps/server.dart b/lib/logic/api_maps/server.dart index 35c3c753..71bd03f1 100644 --- a/lib/logic/api_maps/server.dart +++ b/lib/logic/api_maps/server.dart @@ -847,7 +847,7 @@ class ServerApi extends ApiMap { response = await client.delete( '/auth/tokens', data: { - 'device': device, + 'token_name': device, }, ); } on DioError catch (e) { diff --git a/lib/ui/pages/devices/devices.dart b/lib/ui/pages/devices/devices.dart new file mode 100644 index 00000000..eb5b0531 --- /dev/null +++ b/lib/ui/pages/devices/devices.dart @@ -0,0 +1,143 @@ +import 'package:cubit_form/cubit_form.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:selfprivacy/logic/cubit/devices/devices_cubit.dart'; +import 'package:selfprivacy/logic/cubit/server_installation/server_installation_cubit.dart'; +import 'package:selfprivacy/logic/models/json/api_token.dart'; +import 'package:selfprivacy/ui/components/brand_hero_screen/brand_hero_screen.dart'; +import 'package:selfprivacy/ui/pages/devices/new_device.dart'; +import 'package:selfprivacy/utils/route_transitions/basic.dart'; + +class DevicesScreen extends StatefulWidget { + const DevicesScreen({Key? key}) : super(key: key); + + @override + State createState() => _DevicesScreenState(); +} + +class _DevicesScreenState extends State { + @override + Widget build(BuildContext context) { + final devicesStatus = context.watch().state; + + return RefreshIndicator( + onRefresh: () async { + context.read().refresh(); + }, + child: BrandHeroScreen( + heroTitle: 'devices.main_screen.header'.tr(), + heroSubtitle: 'devices.main_screen.description'.tr(), + hasBackButton: true, + hasFlashButton: false, + children: [ + Text( + 'devices.main_screen.this_device'.tr(), + style: Theme.of(context).textTheme.labelLarge!.copyWith( + color: Theme.of(context).colorScheme.secondary, + ), + ), + _DeviceTile(device: devicesStatus.thisDevice), + const Divider(height: 1), + const SizedBox(height: 16), + Text( + 'devices.main_screen.other_devices'.tr(), + style: Theme.of(context).textTheme.labelLarge!.copyWith( + color: Theme.of(context).colorScheme.secondary, + ), + ), + ...devicesStatus.otherDevices + .map((device) => _DeviceTile(device: device)) + .toList(), + const SizedBox(height: 16), + OutlinedButton( + onPressed: () => Navigator.of(context) + .push(materialRoute(const NewDeviceScreen())), + child: Text('devices.main_screen.authorize_new_device'.tr()), + ), + const SizedBox(height: 16), + const Divider(height: 1), + const SizedBox(height: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + color: Theme.of(context).colorScheme.onBackground, + ), + const SizedBox(height: 16), + Text( + 'devices.main_screen.tip'.tr(), + style: Theme.of(context).textTheme.bodyMedium!, + ), + ], + ), + const SizedBox(height: 24), + ], + ), + ); + } +} + +class _DeviceTile extends StatelessWidget { + const _DeviceTile({Key? key, required this.device}) : super(key: key); + + final ApiToken device; + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 4), + title: Text(device.name), + subtitle: Text('devices.main_screen.access_granted_on' + .tr(args: [DateFormat.yMMMMd().format(device.date)])), + onTap: device.isCaller + ? null + : () => _showConfirmationDialog(context, device), + ); + } + + _showConfirmationDialog(BuildContext context, ApiToken device) => showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(Icons.link_off_outlined), + const SizedBox(height: 16), + Text( + 'devices.revoke_device_alert.header'.tr(), + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'devices.revoke_device_alert.description' + .tr(args: [device.name]), + style: Theme.of(context).textTheme.bodyMedium), + ], + ), + actions: [ + TextButton( + child: Text('devices.revoke_device_alert.no'.tr()), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + TextButton( + child: Text('devices.revoke_device_alert.yes'.tr()), + onPressed: () { + context.read().deleteDevice(device); + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); +} diff --git a/lib/ui/pages/devices/new_device.dart b/lib/ui/pages/devices/new_device.dart new file mode 100644 index 00000000..7929b73e --- /dev/null +++ b/lib/ui/pages/devices/new_device.dart @@ -0,0 +1,84 @@ +import 'package:cubit_form/cubit_form.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:selfprivacy/logic/cubit/devices/devices_cubit.dart'; +import 'package:selfprivacy/logic/cubit/server_installation/server_installation_cubit.dart'; +import 'package:selfprivacy/ui/components/brand_button/filled_button.dart'; +import 'package:selfprivacy/ui/components/brand_hero_screen/brand_hero_screen.dart'; + +class NewDeviceScreen extends StatelessWidget { + const NewDeviceScreen({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BrandHeroScreen( + heroTitle: 'devices.add_new_device_screen.header'.tr(), + heroSubtitle: 'devices.add_new_device_screen.description'.tr(), + hasBackButton: true, + hasFlashButton: false, + children: [ + FutureBuilder( + future: context.read().getNewDeviceKey(), + builder: (context, snapshot) { + if (snapshot.hasData) { + return _KeyDisplay( + newDeviceKey: snapshot.data.toString(), + ); + } else { + return const Center(child: CircularProgressIndicator()); + } + }, + ), + ], + ); + } +} + +class _KeyDisplay extends StatelessWidget { + const _KeyDisplay({Key? key, required this.newDeviceKey}) : super(key: key); + final String newDeviceKey; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(), + const SizedBox(height: 16), + Text( + newDeviceKey, + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + fontSize: 24, + fontFamily: 'RobotoMono', + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + color: Theme.of(context).colorScheme.onBackground, + ), + const SizedBox(height: 16), + Text( + 'devices.add_new_device_screen.tip'.tr(), + style: Theme.of(context).textTheme.bodyMedium!, + ), + ], + ), + const SizedBox(height: 16), + FilledButton( + child: Text( + 'basis.done'.tr(), + ), + onPressed: () => Navigator.of(context).pop(), + ), + const SizedBox(height: 24), + ], + ); + } +} diff --git a/lib/ui/pages/more/more.dart b/lib/ui/pages/more/more.dart index 9c32128a..334d0707 100644 --- a/lib/ui/pages/more/more.dart +++ b/lib/ui/pages/more/more.dart @@ -6,6 +6,7 @@ import 'package:selfprivacy/logic/cubit/server_installation/server_installation_ import 'package:selfprivacy/ui/components/brand_cards/brand_cards.dart'; import 'package:selfprivacy/ui/components/brand_header/brand_header.dart'; import 'package:selfprivacy/ui/components/brand_icons/brand_icons.dart'; +import 'package:selfprivacy/ui/pages/devices/devices.dart'; import 'package:selfprivacy/ui/pages/recovery_key/recovery_key.dart'; import 'package:selfprivacy/ui/pages/setup/initializing.dart'; import 'package:selfprivacy/ui/pages/onboarding/onboarding.dart'; @@ -61,6 +62,12 @@ class MorePage extends StatelessWidget { goTo: const RecoveryKey(), title: 'recovery_key.key_main_header'.tr(), ), + if (isReady) + _MoreMenuItem( + iconData: Icons.devices_outlined, + goTo: const DevicesScreen(), + title: 'devices.main_screen.header'.tr(), + ), _MoreMenuItem( title: 'more.settings.title'.tr(), iconData: Icons.settings_outlined,