Add SSH key adding and deleting

pull/85/head
Inex Code 2022-03-23 17:07:52 +03:00
parent d240e493b1
commit 85235a2e7c
13 changed files with 319 additions and 122 deletions

View File

@ -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"
}
}

View File

@ -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": "Этот ключ уже добавлен."
}
}

View File

@ -109,16 +109,17 @@ class ServerApi extends ApiMap {
}
Future<ApiResponse<List<String>>> getUsersList() async {
List<String> res;
List<String> res = [];
Response response;
var client = await getClient();
response = await client.get('/users');
try {
res = (json.decode(response.data) as List<dynamic>)
.map((e) => e as String)
.toList();
for (var user in response.data) {
res.add(user.toString());
}
} catch (e) {
print(e);
res = [];
}

View File

@ -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<String>((s) {
print(s);
print(keyRegExp.hasMatch(s));
return !keyRegExp.hasMatch(s);
}, 'validations.invalid_format'.tr()),
],
);
super.addFields([key]);
}
@override
FutureOr<void> onSubmit() {
print(key.state.isValid);
jobsCubit.addJob(CreateSSHKeyJob(user: user, publicKey: key.state.value));
}
late FieldCubit<String> key;
final JobsCubit jobsCubit;
final User user;
}

View File

@ -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<JobsState> {
if (state is JobsStateWithJobs) {
var jobs = (state as JobsStateWithJobs).jobList;
emit(JobsStateLoading());
var newUsers = <User>[];
var hasServiceJobs = false;
for (var job in jobs) {
if (job is CreateUserJob) {
@ -114,8 +111,10 @@ class JobsCubit extends Cubit<JobsState> {
await api.switchService(job.type, job.needToTurnOn);
}
if (job is CreateSSHKeyJob) {
await getIt<SSHModel>().generateKeys();
api.addRootSshKey(getIt<SSHModel>().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<JobsState> {
}
emit(JobsStateEmpty());
getIt<NavigationService>().navigator!.pop();
}
}
}

View File

@ -128,11 +128,11 @@ class UsersCubit extends Cubit<UsersState> {
}
Future<void> refresh() async {
List<User> updatedUsers = state.users;
List<User> updatedUsers = List<User>.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<UsersState> {
return;
}
final result = await api.createUser(user);
await box.add(result.data);
emit(state.copyWith(users: box.values.toList()));
var loadedUsers = List<User>.from(state.users);
loadedUsers.add(result.data);
await box.clear();
await box.addAll(loadedUsers);
emit(state.copyWith(users: loadedUsers));
}
Future<void> deleteUser(User user) async {
@ -166,10 +169,13 @@ class UsersCubit extends Cubit<UsersState> {
if (user.login == state.primaryUser.login || user.login == 'root') {
return;
}
var loadedUsers = List<User>.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<UsersState> {
if (result.isSuccess) {
// If it is primary user, update primary user
if (user.login == state.primaryUser.login) {
List<String> primaryUserKeys = state.primaryUser.sshKeys;
List<String> primaryUserKeys =
List<String>.from(state.primaryUser.sshKeys);
primaryUserKeys.add(publicKey);
final updatedUser = User(
login: state.primaryUser.login,
@ -214,7 +221,7 @@ class UsersCubit extends Cubit<UsersState> {
));
} else {
// If it is not primary user, update user
List<String> userKeys = user.sshKeys;
List<String> userKeys = List<String>.from(user.sshKeys);
userKeys.add(publicKey);
final updatedUser = User(
login: user.login,
@ -258,7 +265,8 @@ class UsersCubit extends Cubit<UsersState> {
return;
}
if (user.login == state.primaryUser.login) {
List<String> primaryUserKeys = state.primaryUser.sshKeys;
List<String> primaryUserKeys =
List<String>.from(state.primaryUser.sshKeys);
primaryUserKeys.remove(publicKey);
final updatedUser = User(
login: state.primaryUser.login,
@ -273,7 +281,7 @@ class UsersCubit extends Cubit<UsersState> {
));
return;
}
List<String> userKeys = user.sshKeys;
List<String> userKeys = List<String>.from(user.sshKeys);
userKeys.remove(publicKey);
final updatedUser = User(
login: user.login,
@ -291,7 +299,9 @@ class UsersCubit extends Cubit<UsersState> {
@override
void onChange(Change<UsersState> change) {
print(change);
super.onChange(change);
print('UsersState changed');
print(change);
}
}

View File

@ -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<AppSettingsCubit>().state;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light, // Manually changing appbar color
child: MaterialApp(
scaffoldMessengerKey:
getIt.get<NavigationService>().scaffoldMessengerKey,
navigatorKey: getIt.get<NavigationService>().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<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light, // Manually changing appbar color
child: BlocAndProviderConfig(
child: BlocBuilder<AppSettingsCubit, AppSettingsState>(
builder: (context, appSettings) {
return MaterialApp(
scaffoldMessengerKey:
getIt.get<NavigationService>().scaffoldMessengerKey,
navigatorKey: getIt.get<NavigationService>().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!;
},
);
},
),
),
),
);
}

View File

@ -26,9 +26,6 @@ class MorePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var jobsCubit = context.watch<JobsCubit>();
var isReady = context.watch<AppConfigCubit>().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,

View File

@ -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<JobsCubit>();
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<SshFormCubit>().state;
return BlocListener<SshFormCubit, FormCubitState>(
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<SshFormCubit>().key,
decoration: InputDecoration(
labelText: 'ssh.input_label'.tr(),
),
),
),
SizedBox(height: 30),
BrandButton.rised(
onPressed: formCubitState.isSubmitting
? null
: () => context.read<SshFormCubit>().trySubmit(),
text: 'ssh.create'.tr(),
),
SizedBox(height: 30),
],
),
),
],
),
);
}),
),
);
}
}

View File

@ -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<SshKeysPage> {
style: Theme.of(context).textTheme.headline6,
),
leading: Icon(Icons.add_circle_outline_rounded),
onTap: () {
showModalBottomSheet<void>(
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: <Widget>[
Text('ssh.delete_confirm_question'.tr()),
Text('$keyName ($keyType)'),
Text(publicKey),
],
),
),
actions: <Widget>[
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<JobsCubit>().addJob(
DeleteSSHKeyJob(
user: widget.user, publicKey: key));
Navigator.of(context)
..pop()
..pop();
},
),
],
);
},
);
});
}).toList(),
)

View File

@ -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),
),
],
),
)

View File

@ -36,37 +36,38 @@ class UsersPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final usersCubitState = context.watch<UsersCubit>().state;
// final usersCubitState = context.watch<UsersCubit>().state;
var isReady = context.watch<AppConfigCubit>().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<UsersCubit>().refresh();
child = BlocBuilder<UsersCubit, UsersState>(
builder: (context, state) {
print('Rebuild users page');
final primaryUser = state.primaryUser;
final users = [primaryUser, ...state.users];
return RefreshIndicator(
onRefresh: () async {
context.read<UsersCubit>().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(

View File

@ -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'