From 85235a2e7cbd4b713de1f7711ce58bd0e02d4231 Mon Sep 17 00:00:00 2001 From: Inex Code Date: Wed, 23 Mar 2022 17:07:52 +0300 Subject: [PATCH] Add SSH key adding and deleting --- assets/translations/en.json | 17 ++++- assets/translations/ru.json | 14 +++- lib/logic/api_maps/server.dart | 9 ++- .../cubit/forms/user/ssh_form_cubit.dart | 46 +++++++++++ lib/logic/cubit/jobs/jobs_cubit.dart | 11 +-- lib/logic/cubit/users/users_cubit.dart | 32 +++++--- lib/main.dart | 60 +++++++-------- lib/ui/pages/more/more.dart | 27 ------- lib/ui/pages/ssh_keys/new_ssh_key.dart | 76 +++++++++++++++++++ lib/ui/pages/ssh_keys/ssh_keys.dart | 76 ++++++++++++++++++- lib/ui/pages/users/user_details.dart | 22 +++--- lib/ui/pages/users/users.dart | 49 ++++++------ pubspec.yaml | 2 +- 13 files changed, 319 insertions(+), 122 deletions(-) create mode 100644 lib/logic/cubit/forms/user/ssh_form_cubit.dart create mode 100644 lib/ui/pages/ssh_keys/new_ssh_key.dart diff --git a/assets/translations/en.json b/assets/translations/en.json index 4a88a948..45faa3e8 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -62,6 +62,20 @@ "6": "This removes the Server. It will be no longer accessible" } }, + "ssh": { + "title": "SSH keys", + "create": "Create SSH key", + "delete": "Delete SSH key", + "delete_confirm_question": "Are you sure you want to delete SSH key?", + "subtitle_with_keys": "{} keys", + "subtitle_without_keys": "No keys", + "no_key_name": "Unnamed key", + "root": { + "title": "These are superuser keys", + "subtitle": "Owners of these keys get full access to the server and can do anything on it. Only add your own keys to the server." + }, + "input_label": "Public ED25519 or RSA key" + }, "onboarding": { "_comment": "Onboarding pages", "page1_title": "Digital independence, available to all of us", @@ -311,6 +325,7 @@ "root_name": "User name cannot be 'root'", "key_format": "Invalid key format", "length": "Length is [] should be {}", - "user_already_exist": "Already exists" + "user_already_exist": "Already exists", + "key_already_exists": "This key already exists" } } diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 081b328a..619096a7 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -65,7 +65,16 @@ "ssh": { "title": "SSH ключи", "create": "Добавить SSH ключ", - "delete": "Удалить SSH ключ" + "delete": "Удалить SSH ключ", + "delete_confirm_question": "Вы уверены что хотите удалить следующий ключ?", + "subtitle_with_keys": "Ключей: {}", + "subtitle_without_keys": "Ключей нет", + "no_key_name": "Безымянный ключ", + "root": { + "title": "Это ключи суперпользователя", + "subtitle": "Владельцы указанных здесь ключей получают полный доступ к данным и настройкам сервера. Добавляйте исключительно свои ключи." + }, + "input_label": "Публичный ED25519 или RSA ключ" }, "onboarding": { "_comment": "страницы онбординга", @@ -317,6 +326,7 @@ "root_name": "Имя пользователя не может быть'root'.", "key_format": "Неверный формат.", "length": "Длина строки [] должна быть {}.", - "user_already_exist": "Имя уже используется." + "user_already_exist": "Имя уже используется.", + "key_already_exists": "Этот ключ уже добавлен." } } diff --git a/lib/logic/api_maps/server.dart b/lib/logic/api_maps/server.dart index 036f460f..0e8930e5 100644 --- a/lib/logic/api_maps/server.dart +++ b/lib/logic/api_maps/server.dart @@ -109,16 +109,17 @@ class ServerApi extends ApiMap { } Future>> getUsersList() async { - List res; + List res = []; Response response; var client = await getClient(); response = await client.get('/users'); try { - res = (json.decode(response.data) as List) - .map((e) => e as String) - .toList(); + for (var user in response.data) { + res.add(user.toString()); + } } catch (e) { + print(e); res = []; } diff --git a/lib/logic/cubit/forms/user/ssh_form_cubit.dart b/lib/logic/cubit/forms/user/ssh_form_cubit.dart new file mode 100644 index 00000000..d51cac6b --- /dev/null +++ b/lib/logic/cubit/forms/user/ssh_form_cubit.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:cubit_form/cubit_form.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:selfprivacy/logic/cubit/jobs/jobs_cubit.dart'; +import 'package:selfprivacy/logic/models/job.dart'; +import 'package:selfprivacy/logic/models/user.dart'; + +class SshFormCubit extends FormCubit { + SshFormCubit({ + required this.jobsCubit, + required this.user, + }) { + var keyRegExp = RegExp( + r"^(ssh-rsa AAAAB3NzaC1yc2|ssh-ed25519 AAAAC3NzaC1lZDI1NTE5)[0-9A-Za-z+/]+[=]{0,3}( .*)?$"); + + key = FieldCubit( + initalValue: '', + validations: [ + ValidationModel( + (newKey) => user.sshKeys.any((key) => key == newKey), + 'validations.key_already_exists'.tr(), + ), + RequiredStringValidation('validations.required'.tr()), + ValidationModel((s) { + print(s); + print(keyRegExp.hasMatch(s)); + return !keyRegExp.hasMatch(s); + }, 'validations.invalid_format'.tr()), + ], + ); + + super.addFields([key]); + } + + @override + FutureOr onSubmit() { + print(key.state.isValid); + jobsCubit.addJob(CreateSSHKeyJob(user: user, publicKey: key.state.value)); + } + + late FieldCubit key; + + final JobsCubit jobsCubit; + final User user; +} diff --git a/lib/logic/cubit/jobs/jobs_cubit.dart b/lib/logic/cubit/jobs/jobs_cubit.dart index 6d91d4c4..f2ce57d1 100644 --- a/lib/logic/cubit/jobs/jobs_cubit.dart +++ b/lib/logic/cubit/jobs/jobs_cubit.dart @@ -5,9 +5,7 @@ import 'package:selfprivacy/config/get_it_config.dart'; import 'package:selfprivacy/logic/api_maps/server.dart'; import 'package:selfprivacy/logic/cubit/services/services_cubit.dart'; import 'package:selfprivacy/logic/cubit/users/users_cubit.dart'; -import 'package:selfprivacy/logic/get_it/ssh.dart'; import 'package:selfprivacy/logic/models/job.dart'; -import 'package:selfprivacy/logic/models/user.dart'; export 'package:provider/provider.dart'; @@ -100,7 +98,6 @@ class JobsCubit extends Cubit { if (state is JobsStateWithJobs) { var jobs = (state as JobsStateWithJobs).jobList; emit(JobsStateLoading()); - var newUsers = []; var hasServiceJobs = false; for (var job in jobs) { if (job is CreateUserJob) { @@ -114,8 +111,10 @@ class JobsCubit extends Cubit { await api.switchService(job.type, job.needToTurnOn); } if (job is CreateSSHKeyJob) { - await getIt().generateKeys(); - api.addRootSshKey(getIt().savedPubKey!); + await usersCubit.addSshKey(job.user, job.publicKey); + } + if (job is DeleteSSHKeyJob) { + await usersCubit.deleteSshKey(job.user, job.publicKey); } } @@ -126,8 +125,6 @@ class JobsCubit extends Cubit { } emit(JobsStateEmpty()); - - getIt().navigator!.pop(); } } } diff --git a/lib/logic/cubit/users/users_cubit.dart b/lib/logic/cubit/users/users_cubit.dart index cc228b37..ecd29515 100644 --- a/lib/logic/cubit/users/users_cubit.dart +++ b/lib/logic/cubit/users/users_cubit.dart @@ -128,11 +128,11 @@ class UsersCubit extends Cubit { } Future refresh() async { - List updatedUsers = state.users; + List updatedUsers = List.from(state.users); final usersFromServer = await api.getUsersList(); if (usersFromServer.isSuccess) { updatedUsers = - mergeLocalAndServerUsers(state.users, usersFromServer.data); + mergeLocalAndServerUsers(updatedUsers, usersFromServer.data); } final usersWithSshKeys = await loadSshKeys(updatedUsers); box.clear(); @@ -157,8 +157,11 @@ class UsersCubit extends Cubit { return; } final result = await api.createUser(user); - await box.add(result.data); - emit(state.copyWith(users: box.values.toList())); + var loadedUsers = List.from(state.users); + loadedUsers.add(result.data); + await box.clear(); + await box.addAll(loadedUsers); + emit(state.copyWith(users: loadedUsers)); } Future deleteUser(User user) async { @@ -166,10 +169,13 @@ class UsersCubit extends Cubit { if (user.login == state.primaryUser.login || user.login == 'root') { return; } + var loadedUsers = List.from(state.users); final result = await api.deleteUser(user); if (result) { - await box.deleteAt(box.values.toList().indexOf(user)); - emit(state.copyWith(users: box.values.toList())); + loadedUsers.removeWhere((u) => u.login == user.login); + await box.clear(); + await box.addAll(loadedUsers); + emit(state.copyWith(users: loadedUsers)); } } @@ -199,7 +205,8 @@ class UsersCubit extends Cubit { if (result.isSuccess) { // If it is primary user, update primary user if (user.login == state.primaryUser.login) { - List primaryUserKeys = state.primaryUser.sshKeys; + List primaryUserKeys = + List.from(state.primaryUser.sshKeys); primaryUserKeys.add(publicKey); final updatedUser = User( login: state.primaryUser.login, @@ -214,7 +221,7 @@ class UsersCubit extends Cubit { )); } else { // If it is not primary user, update user - List userKeys = user.sshKeys; + List userKeys = List.from(user.sshKeys); userKeys.add(publicKey); final updatedUser = User( login: user.login, @@ -258,7 +265,8 @@ class UsersCubit extends Cubit { return; } if (user.login == state.primaryUser.login) { - List primaryUserKeys = state.primaryUser.sshKeys; + List primaryUserKeys = + List.from(state.primaryUser.sshKeys); primaryUserKeys.remove(publicKey); final updatedUser = User( login: state.primaryUser.login, @@ -273,7 +281,7 @@ class UsersCubit extends Cubit { )); return; } - List userKeys = user.sshKeys; + List userKeys = List.from(user.sshKeys); userKeys.remove(publicKey); final updatedUser = User( login: user.login, @@ -291,7 +299,9 @@ class UsersCubit extends Cubit { @override void onChange(Change change) { - print(change); super.onChange(change); + + print('UsersState changed'); + print(change); } } diff --git a/lib/main.dart b/lib/main.dart index 15c7a8a6..9a2d0233 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -35,36 +35,36 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return Localization( - child: BlocAndProviderConfig( - child: Builder(builder: (context) { - var appSettings = context.watch().state; - - return AnnotatedRegion( - value: SystemUiOverlayStyle.light, // Manually changing appbar color - child: MaterialApp( - scaffoldMessengerKey: - getIt.get().scaffoldMessengerKey, - navigatorKey: getIt.get().navigatorKey, - localizationsDelegates: context.localizationDelegates, - supportedLocales: context.supportedLocales, - locale: context.locale, - debugShowCheckedModeBanner: false, - title: 'SelfPrivacy', - theme: appSettings.isDarkModeOn ? darkTheme : lightTheme, - home: appSettings.isOnbordingShowing - ? OnboardingPage(nextPage: InitializingPage()) - : RootPage(), - builder: (BuildContext context, Widget? widget) { - Widget error = Text('...rendering error...'); - if (widget is Scaffold || widget is Navigator) - error = Scaffold(body: Center(child: error)); - ErrorWidget.builder = - (FlutterErrorDetails errorDetails) => error; - return widget!; - }, - ), - ); - }), + child: AnnotatedRegion( + value: SystemUiOverlayStyle.light, // Manually changing appbar color + child: BlocAndProviderConfig( + child: BlocBuilder( + builder: (context, appSettings) { + return MaterialApp( + scaffoldMessengerKey: + getIt.get().scaffoldMessengerKey, + navigatorKey: getIt.get().navigatorKey, + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: context.locale, + debugShowCheckedModeBanner: false, + title: 'SelfPrivacy', + theme: appSettings.isDarkModeOn ? darkTheme : lightTheme, + home: appSettings.isOnbordingShowing + ? OnboardingPage(nextPage: InitializingPage()) + : RootPage(), + builder: (BuildContext context, Widget? widget) { + Widget error = Text('...rendering error...'); + if (widget is Scaffold || widget is Navigator) + error = Scaffold(body: Center(child: error)); + ErrorWidget.builder = + (FlutterErrorDetails errorDetails) => error; + return widget!; + }, + ); + }, + ), + ), ), ); } diff --git a/lib/ui/pages/more/more.dart b/lib/ui/pages/more/more.dart index 0717d2bc..535278a0 100644 --- a/lib/ui/pages/more/more.dart +++ b/lib/ui/pages/more/more.dart @@ -26,9 +26,6 @@ class MorePage extends StatelessWidget { @override Widget build(BuildContext context) { - var jobsCubit = context.watch(); - var isReady = context.watch().state is AppConfigFinished; - return Scaffold( appBar: PreferredSize( child: BrandHeader( @@ -114,30 +111,6 @@ class _NavItem extends StatelessWidget { } } -class _MoreMenuTapItem extends StatelessWidget { - const _MoreMenuTapItem({ - Key? key, - required this.iconData, - required this.onTap, - required this.title, - }) : super(key: key); - - final IconData iconData; - final VoidCallback? onTap; - final String title; - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: _MoreMenuItem( - isActive: onTap != null, - iconData: iconData, - title: title, - ), - ); - } -} - class _MoreMenuItem extends StatelessWidget { const _MoreMenuItem({ Key? key, diff --git a/lib/ui/pages/ssh_keys/new_ssh_key.dart b/lib/ui/pages/ssh_keys/new_ssh_key.dart new file mode 100644 index 00000000..4d7a3625 --- /dev/null +++ b/lib/ui/pages/ssh_keys/new_ssh_key.dart @@ -0,0 +1,76 @@ +part of 'ssh_keys.dart'; + +class _NewSshKey extends StatelessWidget { + final User user; + + _NewSshKey(this.user); + + @override + Widget build(BuildContext context) { + return BrandBottomSheet( + child: BlocProvider( + create: (context) { + var jobCubit = context.read(); + var jobState = jobCubit.state; + if (jobState is JobsStateWithJobs) { + var jobs = jobState.jobList; + jobs.forEach((job) { + if (job is CreateSSHKeyJob && job.user.login == user.login) { + user.sshKeys.add(job.publicKey); + } + }); + } + return SshFormCubit( + jobsCubit: jobCubit, + user: user, + ); + }, + child: Builder(builder: (context) { + var formCubitState = context.watch().state; + + return BlocListener( + listener: (context, state) { + if (state.isSubmitted) { + Navigator.pop(context); + } + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + BrandHeader( + title: user.login, + ), + SizedBox(width: 14), + Padding( + padding: paddingH15V0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IntrinsicHeight( + child: CubitFormTextField( + formFieldCubit: context.read().key, + decoration: InputDecoration( + labelText: 'ssh.input_label'.tr(), + ), + ), + ), + SizedBox(height: 30), + BrandButton.rised( + onPressed: formCubitState.isSubmitting + ? null + : () => context.read().trySubmit(), + text: 'ssh.create'.tr(), + ), + SizedBox(height: 30), + ], + ), + ), + ], + ), + ); + }), + ), + ); + } +} diff --git a/lib/ui/pages/ssh_keys/ssh_keys.dart b/lib/ui/pages/ssh_keys/ssh_keys.dart index 3e3ecd7f..b67087c6 100644 --- a/lib/ui/pages/ssh_keys/ssh_keys.dart +++ b/lib/ui/pages/ssh_keys/ssh_keys.dart @@ -1,10 +1,21 @@ +import 'package:cubit_form/cubit_form.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:selfprivacy/logic/cubit/forms/user/ssh_form_cubit.dart'; +import 'package:selfprivacy/logic/models/job.dart'; +import 'package:selfprivacy/ui/components/brand_bottom_sheet/brand_bottom_sheet.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_icons/brand_icons.dart'; +import '../../../config/brand_colors.dart'; +import '../../../config/brand_theme.dart'; +import '../../../logic/cubit/jobs/jobs_cubit.dart'; import '../../../logic/models/user.dart'; +import '../../components/brand_button/brand_button.dart'; +import '../../components/brand_header/brand_header.dart'; + +part 'new_ssh_key.dart'; // Get user object as a parameter class SshKeysPage extends StatefulWidget { @@ -49,20 +60,77 @@ class _SshKeysPageState extends State { style: Theme.of(context).textTheme.headline6, ), leading: Icon(Icons.add_circle_outline_rounded), + onTap: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (BuildContext context) { + return Padding( + padding: MediaQuery.of(context).viewInsets, + child: _NewSshKey(widget.user)); + }, + ); + }, ), Divider(height: 0), // show a list of ListTiles with ssh keys // Clicking on one should delete it Column( children: widget.user.sshKeys.map((key) { + final publicKey = + key.split(' ').length > 1 ? key.split(' ')[1] : key; + final keyType = key.split(' ')[0]; + final keyName = key.split(' ').length > 2 + ? key.split(' ')[2] + : 'ssh.no_key_name'.tr(); return ListTile( - title: - Text('${key.split(' ')[2]} (${key.split(' ')[0]})'), + title: Text('$keyName ($keyType)'), // do not overflow text - subtitle: Text(key.split(' ')[1], + subtitle: Text(publicKey, maxLines: 1, overflow: TextOverflow.ellipsis), onTap: () { - // TODO: delete ssh key + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('ssh.delete'.tr()), + content: SingleChildScrollView( + child: ListBody( + children: [ + Text('ssh.delete_confirm_question'.tr()), + Text('$keyName ($keyType)'), + Text(publicKey), + ], + ), + ), + actions: [ + TextButton( + child: Text('basis.cancel'.tr()), + onPressed: () { + Navigator.of(context)..pop(); + }, + ), + TextButton( + child: Text( + 'basis.delete'.tr(), + style: TextStyle( + color: BrandColors.red1, + ), + ), + onPressed: () { + context.read().addJob( + DeleteSSHKeyJob( + user: widget.user, publicKey: key)); + Navigator.of(context) + ..pop() + ..pop(); + }, + ), + ], + ); + }, + ); }); }).toList(), ) diff --git a/lib/ui/pages/users/user_details.dart b/lib/ui/pages/users/user_details.dart index 0f614859..a40a7b9a 100644 --- a/lib/ui/pages/users/user_details.dart +++ b/lib/ui/pages/users/user_details.dart @@ -157,17 +157,6 @@ class _UserDetails extends StatelessWidget { SizedBox(height: 24), BrandDivider(), SizedBox(height: 20), - BrandButton.emptyWithIconText( - title: 'users.send_registration_data'.tr(), - icon: Icon(BrandIcons.share), - onPressed: () { - Share.share( - 'login: ${user.login}, password: ${user.password}'); - }, - ), - SizedBox(height: 20), - BrandDivider(), - SizedBox(height: 20), ListTile( onTap: () { Navigator.of(context) @@ -179,6 +168,17 @@ class _UserDetails extends StatelessWidget { .tr(args: [user.sshKeys.length.toString()])) : Text('ssh.subtitle_without_keys'.tr()), trailing: Icon(BrandIcons.key)), + SizedBox(height: 20), + ListTile( + onTap: () { + Share.share( + 'login: ${user.login}, password: ${user.password}'); + }, + title: Text( + 'users.send_registration_data'.tr(), + ), + trailing: Icon(BrandIcons.share), + ), ], ), ) diff --git a/lib/ui/pages/users/users.dart b/lib/ui/pages/users/users.dart index ed1d10bd..72c519a0 100644 --- a/lib/ui/pages/users/users.dart +++ b/lib/ui/pages/users/users.dart @@ -36,37 +36,38 @@ class UsersPage extends StatelessWidget { @override Widget build(BuildContext context) { - final usersCubitState = context.watch().state; + // final usersCubitState = context.watch().state; var isReady = context.watch().state is AppConfigFinished; - final primaryUser = usersCubitState.primaryUser; - final users = [primaryUser, ...usersCubitState.users]; - final isEmpty = users.isEmpty; + // final primaryUser = usersCubitState.primaryUser; + // final users = [primaryUser, ...usersCubitState.users]; + // final isEmpty = users.isEmpty; Widget child; if (!isReady) { child = isNotReady(); } else { - child = isEmpty - ? Container( - alignment: Alignment.center, - child: _NoUsers( - text: 'users.add_new_user'.tr(), - ), - ) - : RefreshIndicator( - onRefresh: () async { - context.read().refresh(); + child = BlocBuilder( + builder: (context, state) { + print('Rebuild users page'); + final primaryUser = state.primaryUser; + final users = [primaryUser, ...state.users]; + + return RefreshIndicator( + onRefresh: () async { + context.read().refresh(); + }, + child: ListView.builder( + itemCount: users.length, + itemBuilder: (BuildContext context, int index) { + return _User( + user: users[index], + isRootUser: index == 0, + ); }, - child: ListView.builder( - itemCount: users.length, - itemBuilder: (BuildContext context, int index) { - return _User( - user: users[index], - isRootUser: index == 0, - ); - }, - ), - ); + ), + ); + }, + ); } return Scaffold( diff --git a/pubspec.yaml b/pubspec.yaml index f38fc009..48e0a837 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: selfprivacy description: selfprivacy.org publish_to: 'none' -version: 0.4.2+10 +version: 0.5.0+11 environment: sdk: '>=2.13.4 <3.0.0'