feat: added reset password tile and did some widget refactoring

This commit is contained in:
Aliaksei Tratseuski 2025-03-05 22:09:43 +04:00 committed by Inex Code
parent 71c2b526b9
commit d59c3dcda3
19 changed files with 729 additions and 761 deletions

View file

@ -632,7 +632,6 @@ type User {
input UserMutationInput {
username: String!
password: String!
}
type UserMutationReturn implements MutationReturnInterface {

View file

@ -1629,13 +1629,9 @@ class _CopyWithStubImpl$Input$UseRecoveryKeyInput<TRes>
}
class Input$UserMutationInput {
factory Input$UserMutationInput({
required String username,
required String password,
}) =>
factory Input$UserMutationInput({required String username}) =>
Input$UserMutationInput._({
r'username': username,
r'password': password,
});
Input$UserMutationInput._(this._$data);
@ -1644,8 +1640,6 @@ class Input$UserMutationInput {
final result$data = <String, dynamic>{};
final l$username = data['username'];
result$data['username'] = (l$username as String);
final l$password = data['password'];
result$data['password'] = (l$password as String);
return Input$UserMutationInput._(result$data);
}
@ -1653,14 +1647,10 @@ class Input$UserMutationInput {
String get username => (_$data['username'] as String);
String get password => (_$data['password'] as String);
Map<String, dynamic> toJson() {
final result$data = <String, dynamic>{};
final l$username = username;
result$data['username'] = l$username;
final l$password = password;
result$data['password'] = l$password;
return result$data;
}
@ -1684,22 +1674,13 @@ class Input$UserMutationInput {
if (l$username != lOther$username) {
return false;
}
final l$password = password;
final lOther$password = other.password;
if (l$password != lOther$password) {
return false;
}
return true;
}
@override
int get hashCode {
final l$username = username;
final l$password = password;
return Object.hashAll([
l$username,
l$password,
]);
return Object.hashAll([l$username]);
}
}
@ -1712,10 +1693,7 @@ abstract class CopyWith$Input$UserMutationInput<TRes> {
factory CopyWith$Input$UserMutationInput.stub(TRes res) =
_CopyWithStubImpl$Input$UserMutationInput;
TRes call({
String? username,
String? password,
});
TRes call({String? username});
}
class _CopyWithImpl$Input$UserMutationInput<TRes>
@ -1731,16 +1709,11 @@ class _CopyWithImpl$Input$UserMutationInput<TRes>
static const _undefined = <dynamic, dynamic>{};
TRes call({
Object? username = _undefined,
Object? password = _undefined,
}) =>
TRes call({Object? username = _undefined}) =>
_then(Input$UserMutationInput._({
..._instance._$data,
if (username != _undefined && username != null)
'username': (username as String),
if (password != _undefined && password != null)
'password': (password as String),
}));
}
@ -1750,11 +1723,7 @@ class _CopyWithStubImpl$Input$UserMutationInput<TRes>
TRes _res;
call({
String? username,
String? password,
}) =>
_res;
call({String? username}) => _res;
}
enum Enum$BackupProvider {

View file

@ -45,14 +45,11 @@ mixin UsersApi on GraphQLApiMap {
return user;
}
Future<GenericResult<User?>> createUser(
final String username,
final String password,
) async {
Future<GenericResult<User?>> createUser(final String username) async {
try {
final GraphQLClient client = await getClient();
final variables = Variables$Mutation$CreateUser(
user: Input$UserMutationInput(username: username, password: password),
user: Input$UserMutationInput(username: username),
);
final mutation = Options$Mutation$CreateUser(variables: variables);
final response = await client.mutate$CreateUser(mutation);
@ -100,36 +97,6 @@ mixin UsersApi on GraphQLApiMap {
}
}
Future<GenericResult<User?>> updateUser(
final String username,
final String password,
) async {
try {
final GraphQLClient client = await getClient();
final variables = Variables$Mutation$UpdateUser(
user: Input$UserMutationInput(username: username, password: password),
);
final mutation = Options$Mutation$UpdateUser(variables: variables);
final response = await client.mutate$UpdateUser(mutation);
return GenericResult(
success: true,
code: response.parsedData?.users.updateUser.code ?? 500,
message: response.parsedData?.users.updateUser.message,
data: response.parsedData?.users.updateUser.user != null
? User.fromGraphQL(response.parsedData!.users.updateUser.user!)
: null,
);
} catch (e) {
print(e);
return GenericResult(
data: null,
success: false,
code: 0,
message: e.toString(),
);
}
}
Future<GenericResult<User?>> addSshKey(
final String username,
final String sshKey,

View file

@ -0,0 +1,88 @@
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:selfprivacy/config/get_it_config.dart';
import 'package:selfprivacy/logic/models/hive/user.dart';
import 'package:selfprivacy/utils/app_logger.dart';
class ResetPasswordBloc extends Bloc<ResetPasswordEvent, ResetPasswordState> {
ResetPasswordBloc({
required this.user,
}) : super(
const ResetPasswordState(),
) {
log('ResetPasswordBloc created for user: ${user.login}');
on<RequestNewPassword>(
_mapResetPasswordRequestedToState,
transformer: droppable(),
);
}
static final log = const AppLogger(name: 'ResetPasswordBloc').log;
final User user;
Future<void> _mapResetPasswordRequestedToState(
final RequestNewPassword event,
final Emitter<ResetPasswordState> emit,
) async {
log('Reset password requested for user: ${user.login}');
if (state.isLoading) {
return;
}
emit(
const ResetPasswordState(
passwordResetLink: null,
isLoading: true,
),
);
log('Load start');
final (link, message) =
await getIt<ApiConnectionRepository>().generatePasswordResetLink(user);
log('Got link: $link, message: $message');
emit(
link != null
? ResetPasswordState(
passwordResetLink: link,
passwordResetMessage: message,
)
: ResetPasswordState(
errorMessage: message,
),
);
}
}
sealed class ResetPasswordEvent extends Equatable {
const ResetPasswordEvent();
@override
List<Object?> get props => [];
}
class RequestNewPassword extends ResetPasswordEvent {
const RequestNewPassword();
}
class ResetPasswordState extends Equatable {
const ResetPasswordState({
this.passwordResetLink,
this.passwordResetMessage = '',
this.errorMessage = '',
this.isLoading = false,
});
final Uri? passwordResetLink;
final bool isLoading;
final String passwordResetMessage;
final String errorMessage;
@override
List<Object?> get props =>
[passwordResetMessage, isLoading, passwordResetLink, errorMessage];
}

View file

@ -1,65 +1,68 @@
import 'dart:async';
import 'package:cubit_form/cubit_form.dart';
import 'package:selfprivacy/logic/cubit/client_jobs/client_jobs_cubit.dart';
import 'package:selfprivacy/config/get_it_config.dart';
import 'package:selfprivacy/logic/cubit/forms/factories/field_cubit_factory.dart';
import 'package:selfprivacy/logic/models/hive/user.dart';
import 'package:selfprivacy/logic/models/job.dart';
import 'package:selfprivacy/utils/password_generator.dart';
class UserFormCubit extends FormCubit {
UserFormCubit({
required this.jobsCubit,
required final FieldCubitFactory fieldFactory,
this.initialUser,
}) {
if (initialUser == null) {
login = fieldFactory.createUserLoginField();
login.setValue('');
password = fieldFactory.createUserPasswordField();
password.setValue(
StringGenerators.userPassword(),
);
required this.initialUser,
}) : userCreated = initialUser != null {
login = initialUser == null
? fieldFactory.createUserLoginField()
: fieldFactory.createRequiredStringField();
login.setValue(initialUser?.login ?? '');
super.addFields([login, password]);
} else {
login = fieldFactory.createRequiredStringField();
login.setValue(initialUser!.login);
password = fieldFactory.createUserPasswordField();
password.setValue(
initialUser?.password ?? '',
);
super.addFields([login, password]);
}
super.addFields([login]);
}
@override
FutureOr<void> onSubmit() {
if (initialUser == null) {
FutureOr<void> onSubmit() async {
if (!userCreated) {
final User user = User(
login: login.state.value,
type: UserType.normal,
password: password.state.value,
);
jobsCubit.addJob(CreateUserJob(user: user));
final (result, message) =
await getIt<ApiConnectionRepository>().createUser(user);
if (result) {
initialUser = user;
userCreated = true;
userCreationMessage = message;
errorMessage = '';
} else {
errorMessage = message;
getIt<NavigationService>().showSnackBar(errorMessage);
}
} else {
/// We got request to reset password
final User user = User(
login: initialUser?.login ?? login.state.value,
type: initialUser?.type ?? UserType.normal,
password: password.state.value,
);
jobsCubit.addJob(ResetUserPasswordJob(user: user));
final (link, message) = await getIt<ApiConnectionRepository>()
.generatePasswordResetLink(user);
if (link != null) {
passwordResetLink = link;
passwordResetMessage = message;
} else {
passwordResetMessage = message;
getIt<NavigationService>().showSnackBar(passwordResetMessage);
}
}
}
late FieldCubit<String> login;
late FieldCubit<String> password;
User? initialUser;
void genNewPassword() {
password.externalSetValue(StringGenerators.userPassword());
}
bool userCreated;
String? userCreationMessage = '';
String errorMessage = '';
final JobsCubit jobsCubit;
final User? initialUser;
Uri? passwordResetLink;
String passwordResetMessage = '';
}

View file

@ -87,13 +87,10 @@ class ApiConnectionRepository {
.any((final User u) => u.login == user.login && u.isFoundOnServer)) {
return (false, 'users.user_already_exists'.tr());
}
final String? password = user.password;
if (password == null) {
return (false, 'users.could_not_create_user'.tr());
}
// If API returned error, do nothing
final GenericResult<User?> result =
await api.createUser(user.login, password);
final GenericResult<User?> result = await api.createUser(user.login);
if (result.data == null) {
return (false, result.message ?? 'users.could_not_create_user'.tr());
}
@ -126,23 +123,26 @@ class ApiConnectionRepository {
return (true, result.message ?? 'basis.done'.tr());
}
Future<(bool, String)> generatePasswordResetLink(
// url and error message
Future<(Uri?, String)> generatePasswordResetLink(
final User user,
) async {
String errorMessage = 'users.user_modify_protected'.tr();
if (user.type == UserType.root) {
return (false, errorMessage);
return (null, errorMessage);
}
final GenericResult<String?> result = await api.generatePasswordResetLink(
user.login,
);
if (result.data == null) {
final GenericResult<String?> result =
await api.generatePasswordResetLink(user.login);
// check if got valid url
final uri = Uri.tryParse(result.data ?? '');
if (uri == null) {
errorMessage =
result.message ?? 'users.could_not_generate_password_link'.tr();
getIt<NavigationService>().showSnackBar(errorMessage);
return (false, errorMessage);
return (null, errorMessage);
}
return (true, result.message ?? 'basis.done'.tr());
return (uri, result.message ?? 'basis.done'.tr());
}
Future<(bool, String)> addSshKey(

View file

@ -46,7 +46,14 @@ class User extends Equatable {
final UserType type;
@override
List<Object?> get props => [login, password, sshKeys, isFoundOnServer, note];
List<Object?> get props => [
login,
password,
sshKeys,
isFoundOnServer,
note,
type,
];
Color get color => stringToColor(login);

View file

@ -146,66 +146,6 @@ class RebootServerJob extends ClientJob {
);
}
class CreateUserJob extends ClientJob {
CreateUserJob({
required this.user,
super.status,
super.message,
super.id,
}) : super(title: '${"jobs.create_user".tr()} ${user.login}');
final User user;
@override
Future<(bool, String)> execute() async =>
getIt<ApiConnectionRepository>().createUser(user);
@override
List<Object> get props => [...super.props, user];
@override
CreateUserJob copyWithNewStatus({
required final JobStatusEnum status,
final String? message,
}) =>
CreateUserJob(
user: user,
status: status,
message: message,
id: id,
);
}
class ResetUserPasswordJob extends ClientJob {
ResetUserPasswordJob({
required this.user,
super.status,
super.message,
super.id,
}) : super(title: '${"jobs.reset_user_password".tr()} ${user.login}');
final User user;
@override
Future<(bool, String)> execute() async => (false, '');
//getIt<ApiConnectionRepository>().changeUserPassword(user, user.password!);
@override
List<Object> get props => [...super.props, user];
@override
ResetUserPasswordJob copyWithNewStatus({
required final JobStatusEnum status,
final String? message,
}) =>
ResetUserPasswordJob(
user: user,
status: status,
message: message,
id: id,
);
}
class DeleteUserJob extends ClientJob {
DeleteUserJob({
required this.user,

View file

@ -1,84 +0,0 @@
import 'package:cubit_form/cubit_form.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:selfprivacy/logic/cubit/client_jobs/client_jobs_cubit.dart';
import 'package:selfprivacy/logic/cubit/forms/factories/field_cubit_factory.dart';
import 'package:selfprivacy/logic/cubit/forms/user/user_form_cubit.dart';
import 'package:selfprivacy/logic/models/hive/user.dart';
import 'package:selfprivacy/ui/atoms/buttons/brand_button.dart';
class ResetPasswordModal extends StatelessWidget {
const ResetPasswordModal({
required this.user,
required this.scrollController,
super.key,
});
final User user;
final ScrollController scrollController;
@override
Widget build(final BuildContext context) => BlocProvider(
create: (final BuildContext context) => UserFormCubit(
jobsCubit: context.read<JobsCubit>(),
fieldFactory: FieldCubitFactory(context),
initialUser: user,
),
child: Builder(
builder: (final BuildContext context) {
final FormCubitState formCubitState =
context.watch<UserFormCubit>().state;
return BlocListener<UserFormCubit, FormCubitState>(
listener:
(final BuildContext context, final FormCubitState state) {
if (state.isSubmitted) {
Navigator.pop(context);
}
},
child: ListView(
controller: scrollController,
padding: const EdgeInsets.all(16),
children: [
const Gap(16),
Text(
'users.reset_password'.tr(),
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const Gap(16),
CubitFormTextField(
autofocus: true,
formFieldCubit: context.read<UserFormCubit>().password,
decoration: InputDecoration(
alignLabelWithHint: false,
labelText: 'basis.password'.tr(),
filled: true,
suffixIcon: Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
icon: Icon(
Icons.refresh,
color: Theme.of(context).colorScheme.secondary,
),
onPressed:
context.read<UserFormCubit>().genNewPassword,
),
),
),
),
const Gap(16),
BrandButton.filled(
onPressed: formCubitState.isSubmitting
? null
: () => context.read<UserFormCubit>().trySubmit(),
title: 'basis.apply'.tr(),
),
],
),
);
},
),
);
}

View file

@ -2,20 +2,46 @@ import 'package:auto_route/auto_route.dart';
import 'package:cubit_form/cubit_form.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:selfprivacy/config/get_it_config.dart';
import 'package:selfprivacy/logic/cubit/client_jobs/client_jobs_cubit.dart';
import 'package:gap/gap.dart';
import 'package:selfprivacy/logic/cubit/forms/factories/field_cubit_factory.dart';
import 'package:selfprivacy/logic/cubit/forms/user/user_form_cubit.dart';
import 'package:selfprivacy/logic/cubit/server_installation/server_installation_cubit.dart';
import 'package:selfprivacy/ui/atoms/buttons/brand_button.dart';
import 'package:selfprivacy/ui/layouts/brand_hero_screen.dart';
import 'package:selfprivacy/utils/platform_adapter.dart';
import 'package:selfprivacy/utils/ui_helpers.dart';
@RoutePage()
class NewUserPage extends StatelessWidget {
const NewUserPage({super.key});
@override
Widget build(final BuildContext context) => BlocProvider(
create: (final BuildContext context) => UserFormCubit(
fieldFactory: FieldCubitFactory(context),
initialUser: null,
),
child: BlocConsumer<UserFormCubit, FormCubitState>(
listener: (
final BuildContext context,
final FormCubitState state,
) {
if (state.isSubmitted) {
context.router.maybePop();
}
},
builder: (
final BuildContext context,
final FormCubitState state,
) =>
NewUserScreen(state: state),
),
);
}
class NewUserScreen extends StatelessWidget {
const NewUserScreen({required this.state, super.key});
final FormCubitState state;
@override
Widget build(final BuildContext context) {
final ServerInstallationState config =
@ -23,115 +49,44 @@ class NewUserPage extends StatelessWidget {
final String domainName = UiHelpers.getDomainName(config);
return BlocProvider(
create: (final BuildContext context) {
final jobCubit = context.read<JobsCubit>();
// final jobsState = jobCubit.state;
// final users = <User>[
// ...context.read<UsersBloc>().state.users,
// if (jobsState is JobsStateWithJobs)
// ...jobsState.clientJobList
// .whereType<CreateUserJob>()
// .map((final job) => job.user),
// ];
return UserFormCubit(
jobsCubit: jobCubit,
fieldFactory: FieldCubitFactory(context),
);
},
child: Builder(
builder: (final BuildContext context) {
final FormCubitState formCubitState =
context.watch<UserFormCubit>().state;
return BlocListener<UserFormCubit, FormCubitState>(
listener: (final BuildContext context, final FormCubitState state) {
if (state.isSubmitted) {
context.router.maybePop();
}
},
child: BrandHeroScreen(
heroTitle: 'users.new_user'.tr(),
heroIcon: Icons.person_add_outlined,
children: [
if (formCubitState.isErrorShown)
Text(
'users.username_rule'.tr(),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
const SizedBox(width: 14),
IntrinsicHeight(
child: CubitFormTextField(
autofocus: true,
formFieldCubit: context.read<UserFormCubit>().login,
decoration: InputDecoration(
labelText: 'users.login'.tr(),
suffixText: '@$domainName',
),
),
),
const SizedBox(height: 20),
CubitFormTextField(
formFieldCubit: context.read<UserFormCubit>().password,
decoration: InputDecoration(
alignLabelWithHint: false,
labelText: 'basis.password'.tr(),
suffixIcon: Padding(
padding: const EdgeInsets.only(right: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
Icons.copy,
size: 24.0,
color: Theme.of(context).colorScheme.secondary,
),
onPressed: () {
final String currentPassword = context
.read<UserFormCubit>()
.password
.state
.value;
PlatformAdapter.setClipboard(currentPassword);
getIt<NavigationService>().showSnackBar(
'basis.copied_to_clipboard'.tr(),
);
},
),
IconButton(
icon: Icon(
Icons.refresh,
size: 24.0,
color: Theme.of(context).colorScheme.secondary,
),
onPressed:
context.read<UserFormCubit>().genNewPassword,
),
],
),
),
),
),
const SizedBox(height: 30),
BrandButton.filled(
onPressed: formCubitState.isSubmitting
? null
: () => context.read<UserFormCubit>().trySubmit(),
title: 'basis.create'.tr(),
),
const SizedBox(height: 40),
Text('users.new_user_info_note'.tr()),
const SizedBox(height: 30),
],
return BrandHeroScreen(
heroTitle: 'users.new_user'.tr(),
heroIcon: Icons.person_add_outlined,
children: [
if (state.isErrorShown)
Text(
'users.username_rule'.tr(),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
);
},
),
),
const Gap(14),
IntrinsicHeight(
child: CubitFormTextField(
/// should make this read-only when the user is created
autofocus: true,
formFieldCubit: context.read<UserFormCubit>().login,
decoration: InputDecoration(
labelText: 'users.login'.tr(),
suffixText: '@$domainName',
),
),
),
// if (state.userCreated) ...[
// const Gap(20),
// Text('users.generate_password_reset_link'.tr()),
// ],
const Gap(30),
BrandButton.filled(
onPressed: state.isSubmitting
? null
: () => context.read<UserFormCubit>().trySubmit(),
title: 'basis.create'.tr(),
),
const SizedBox(height: 40),
Text('users.new_user_info_note'.tr()),
const SizedBox(height: 30),
],
);
}
}

View file

@ -1,312 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:selfprivacy/config/get_it_config.dart';
import 'package:selfprivacy/logic/bloc/users/users_bloc.dart';
import 'package:selfprivacy/logic/cubit/client_jobs/client_jobs_cubit.dart';
import 'package:selfprivacy/logic/cubit/server_detailed_info/server_detailed_info_cubit.dart';
import 'package:selfprivacy/logic/cubit/server_installation/server_installation_cubit.dart';
import 'package:selfprivacy/logic/models/hive/user.dart';
import 'package:selfprivacy/logic/models/job.dart';
import 'package:selfprivacy/ui/atoms/cards/filled_card.dart';
import 'package:selfprivacy/ui/atoms/list_tiles/list_tile_on_surface_variant.dart';
import 'package:selfprivacy/ui/layouts/brand_hero_screen.dart';
import 'package:selfprivacy/ui/molecules/info_box/info_box.dart';
import 'package:selfprivacy/ui/organisms/modals/new_ssh_key_modal.dart';
import 'package:selfprivacy/ui/organisms/modals/reset_password_modal.dart';
import 'package:selfprivacy/utils/platform_adapter.dart';
import 'package:selfprivacy/utils/ui_helpers.dart';
@RoutePage()
class UserDetailsPage extends StatelessWidget {
const UserDetailsPage({
required this.login,
super.key,
});
final String login;
@override
Widget build(final BuildContext context) {
final ServerInstallationState config =
context.watch<ServerInstallationCubit>().state;
final String domainName = UiHelpers.getDomainName(config);
final User user = context.watch<UsersBloc>().state.users.firstWhere(
(final User user) => user.login == login,
orElse: () => const User(
type: UserType.normal,
login: 'error',
),
);
if (user.type == UserType.root) {
return BrandHeroScreen(
hasBackButton: true,
hasFlashButton: true,
heroTitle: 'ssh.root_title'.tr(),
heroSubtitle: 'ssh.root_subtitle'.tr(),
children: [
_SshKeysCard(user: user),
],
);
}
return BrandHeroScreen(
hasBackButton: true,
hasFlashButton: true,
heroTitle: user.login,
heroIconWidget: CircleAvatar(
backgroundColor: user.color,
child: Text(
user.login[0].toUpperCase(),
),
),
children: [
_UserLogins(user: user, domainName: domainName),
const SizedBox(height: 8),
_SshKeysCard(user: user),
const SizedBox(height: 8),
ListTile(
iconColor: Theme.of(context).colorScheme.onSurface,
onTap: () => showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (final BuildContext context) => DraggableScrollableSheet(
expand: false,
maxChildSize: 0.9,
minChildSize: 0.3,
initialChildSize: 0.5,
builder: (final context, final scrollController) =>
ResetPasswordModal(
user: user,
scrollController: scrollController,
),
),
),
leading: const Icon(Icons.lock_reset_outlined),
title: Text(
'users.reset_password'.tr(),
),
),
if (user.type == UserType.normal) _DeleteUserTile(user: user),
const Divider(height: 8),
Padding(
padding: const EdgeInsets.all(16.0),
child: InfoBox(
text: 'users.no_ssh_notice'.tr(),
isWarning: true,
),
),
],
);
}
}
class _DeleteUserTile extends StatelessWidget {
const _DeleteUserTile({
required this.user,
});
final User user;
@override
Widget build(final BuildContext context) => ListTile(
iconColor: Theme.of(context).colorScheme.error,
textColor: Theme.of(context).colorScheme.error,
onTap: () => showDialog(
context: context,
// useRootNavigator: false,
builder: (final BuildContext context) => AlertDialog(
title: Text('basis.confirmation'.tr()),
content: SingleChildScrollView(
child: ListBody(
children: <Widget>[
Text(
'users.delete_confirm_question'.tr(),
),
],
),
),
actions: <Widget>[
TextButton(
child: Text('basis.cancel'.tr()),
onPressed: () {
context.router.maybePop();
},
),
TextButton(
child: Text(
'basis.delete'.tr(),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
onPressed: () {
context.read<JobsCubit>().addJob(DeleteUserJob(user: user));
context.router.childControllers.first.maybePop();
context.router.maybePop();
},
),
],
),
),
leading: const Icon(Icons.person_remove_outlined),
title: Text(
'users.delete_user'.tr(),
),
);
}
class _UserLogins extends StatelessWidget {
const _UserLogins({
required this.user,
required this.domainName,
});
final User user;
final String domainName;
@override
Widget build(final BuildContext context) {
final email = '${user.login}@$domainName';
return FilledCard(
child: Column(
children: [
ListTileOnSurfaceVariant(
onTap: () {
PlatformAdapter.setClipboard(email);
getIt<NavigationService>().showSnackBar(
'basis.copied_to_clipboard'.tr(),
);
},
title: email,
subtitle: 'users.email_login'.tr(),
leadingIcon: Icons.alternate_email_outlined,
),
],
),
);
}
}
class _SshKeysCard extends StatelessWidget {
const _SshKeysCard({
required this.user,
});
final User user;
@override
Widget build(final BuildContext context) {
final serverDetailsState = context.watch<ServerDetailsCubit>().state;
final bool sshDisabled =
serverDetailsState is Loaded && !serverDetailsState.sshSettings.enable;
return FilledCard(
child: Column(
children: [
ListTileOnSurfaceVariant(
title: 'ssh.title'.tr(),
),
const Divider(height: 0),
ListTileOnSurfaceVariant(
title: 'ssh.create'.tr(),
leadingIcon: Icons.add_circle_outline,
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (final BuildContext context) =>
DraggableScrollableSheet(
expand: false,
maxChildSize: 0.9,
minChildSize: 0.3,
initialChildSize: 0.5,
builder: (final context, final scrollController) =>
NewSshKeyModal(
user: user,
scrollController: scrollController,
),
),
);
},
),
Column(
children: user.sshKeys.map((final String 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 ListTileOnSurfaceVariant(
title: '$keyName ($keyType)',
disableSubtitleOverflow: true,
// do not overflow text
subtitle: publicKey,
onTap: () {
showDialog(
context: context,
builder: (final BuildContext context) => 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: Theme.of(context).colorScheme.error,
),
),
onPressed: () {
context.read<JobsCubit>().addJob(
DeleteSSHKeyJob(
user: user,
publicKey: key,
),
);
context.maybePop();
},
),
],
),
);
},
);
}).toList(),
),
if (sshDisabled)
Column(
children: [
const Divider(height: 0),
Padding(
padding: const EdgeInsets.all(8.0),
child: InfoBox(
text: 'ssh.ssh_disabled_warning'.tr(),
isWarning: true,
),
),
],
),
],
),
);
}
}

View file

@ -0,0 +1,78 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:selfprivacy/logic/bloc/users/users_bloc.dart';
import 'package:selfprivacy/logic/cubit/server_installation/server_installation_cubit.dart';
import 'package:selfprivacy/logic/models/hive/user.dart';
import 'package:selfprivacy/ui/layouts/brand_hero_screen.dart';
import 'package:selfprivacy/ui/molecules/info_box/info_box.dart';
import 'package:selfprivacy/ui/pages/users/user_details_page/widgets/widgets.dart';
import 'package:selfprivacy/utils/ui_helpers.dart';
@RoutePage()
class UserDetailsPage extends StatelessWidget {
const UserDetailsPage({
required this.login,
super.key,
});
final String login;
@override
Widget build(final BuildContext context) {
final ServerInstallationState config =
context.watch<ServerInstallationCubit>().state;
final String domainName = UiHelpers.getDomainName(config);
final User user = context.watch<UsersBloc>().state.users.firstWhere(
(final User user) => user.login == login,
orElse: () => const User(
type: UserType.normal,
login: 'error',
),
);
if (user.type == UserType.root) {
return BrandHeroScreen(
hasBackButton: true,
hasFlashButton: true,
heroTitle: 'ssh.root_title'.tr(),
heroSubtitle: 'ssh.root_subtitle'.tr(),
children: [
SshKeysCard(user: user),
],
);
}
return BrandHeroScreen(
hasBackButton: true,
hasFlashButton: true,
heroTitle: user.login,
heroIconWidget: CircleAvatar(
backgroundColor: user.color,
child: Text(
user.login[0].toUpperCase(),
),
),
children: [
UserLoginTile(user: user, domainName: domainName),
const Gap(8),
SshKeysCard(user: user),
const Gap(8),
ResetPasswordTile(user: user),
const Gap(8),
if (user.type == UserType.normal) DeleteUserTile(user: user),
const Divider(height: 8),
Padding(
padding: const EdgeInsets.all(16.0),
child: InfoBox(
text: 'users.no_ssh_notice'.tr(),
isWarning: true,
),
),
],
);
}
}

View file

@ -0,0 +1,62 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:selfprivacy/logic/cubit/client_jobs/client_jobs_cubit.dart';
import 'package:selfprivacy/logic/models/hive/user.dart';
import 'package:selfprivacy/logic/models/job.dart';
class DeleteUserTile extends StatelessWidget {
const DeleteUserTile({
required this.user,
super.key,
});
final User user;
@override
Widget build(final BuildContext context) => ListTile(
iconColor: Theme.of(context).colorScheme.error,
textColor: Theme.of(context).colorScheme.error,
onTap: () => showDialog(
context: context,
// useRootNavigator: false,
builder: (final BuildContext context) => AlertDialog(
title: Text('basis.confirmation'.tr()),
content: SingleChildScrollView(
child: ListBody(
children: <Widget>[
Text(
'users.delete_confirm_question'.tr(),
),
],
),
),
actions: <Widget>[
TextButton(
child: Text('basis.cancel'.tr()),
onPressed: () {
context.router.maybePop();
},
),
TextButton(
child: Text(
'basis.delete'.tr(),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
onPressed: () {
context.read<JobsCubit>().addJob(DeleteUserJob(user: user));
context.router.childControllers.first.maybePop();
context.router.maybePop();
},
),
],
),
),
leading: const Icon(Icons.person_remove_outlined),
title: Text(
'users.delete_user'.tr(),
),
);
}

View file

@ -0,0 +1,94 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:selfprivacy/config/get_it_config.dart';
import 'package:selfprivacy/logic/cubit/forms/user/reset_password_bloc.dart';
import 'package:selfprivacy/logic/models/hive/user.dart';
import 'package:selfprivacy/utils/platform_adapter.dart';
class ResetPasswordTile extends StatelessWidget {
const ResetPasswordTile({required this.user, super.key});
final User user;
@override
Widget build(final BuildContext context) => BlocProvider<ResetPasswordBloc>(
create: (final BuildContext context) => ResetPasswordBloc(user: user),
child: BlocConsumer<ResetPasswordBloc, ResetPasswordState>(
listener:
(final BuildContext context, final ResetPasswordState state) {
/// check on error
if (state.errorMessage != '') {
getIt<NavigationService>().showSnackBar(state.errorMessage);
}
/// check on success
if (state.passwordResetLink != null) {
showDialog(
context: context,
builder: (final BuildContext context) =>
_ResetPasswordLinkDialog(state: state),
);
}
},
builder:
(final BuildContext context, final ResetPasswordState state) =>
_Tile(state: state),
),
);
}
class _ResetPasswordLinkDialog extends StatelessWidget {
const _ResetPasswordLinkDialog({required this.state});
final ResetPasswordState state;
@override
Widget build(final BuildContext context) => AlertDialog(
title: Text(state.passwordResetMessage),
content: Text(
state.passwordResetLink.toString(),
),
actions: <Widget>[
TextButton(
child: Text('basis.copy'.tr()),
onPressed: () {
PlatformAdapter.setClipboard(state.passwordResetLink.toString());
getIt<NavigationService>().showSnackBar(
'basis.copied_to_clipboard'.tr(),
);
},
),
TextButton(
child: Text(
'basis.close'.tr(),
),
onPressed: () {
context.router.maybePop();
},
),
],
);
}
class _Tile extends StatelessWidget {
const _Tile({required this.state});
final ResetPasswordState state;
@override
Widget build(final BuildContext context) => ListTile(
iconColor: Theme.of(context).colorScheme.onSurface,
onTap: state.isLoading
? null
: () {
context
.read<ResetPasswordBloc>()
.add(const RequestNewPassword());
},
leading: const Icon(Icons.lock_reset_outlined),
trailing: state.isLoading ? const CircularProgressIndicator() : null,
title: Text(
'users.request_password_reset_link'.tr(),
),
);
}

View file

@ -0,0 +1,155 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:selfprivacy/logic/cubit/client_jobs/client_jobs_cubit.dart';
import 'package:selfprivacy/logic/cubit/server_detailed_info/server_detailed_info_cubit.dart';
import 'package:selfprivacy/logic/models/hive/user.dart';
import 'package:selfprivacy/logic/models/job.dart';
import 'package:selfprivacy/ui/atoms/cards/filled_card.dart';
import 'package:selfprivacy/ui/atoms/list_tiles/list_tile_on_surface_variant.dart';
import 'package:selfprivacy/ui/molecules/info_box/info_box.dart';
import 'package:selfprivacy/ui/organisms/modals/new_ssh_key_modal.dart';
class SshKeysCard extends StatelessWidget {
const SshKeysCard({required this.user, super.key});
final User user;
@override
Widget build(final BuildContext context) {
final serverDetailsState = context.watch<ServerDetailsCubit>().state;
final bool sshDisabled =
serverDetailsState is Loaded && !serverDetailsState.sshSettings.enable;
return FilledCard(
child: Column(
children: [
ListTileOnSurfaceVariant(
title: 'ssh.title'.tr(),
),
const Divider(height: 0),
ListTileOnSurfaceVariant(
title: 'ssh.create'.tr(),
leadingIcon: Icons.add_circle_outline,
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (final BuildContext context) =>
DraggableScrollableSheet(
expand: false,
maxChildSize: 0.9,
minChildSize: 0.3,
initialChildSize: 0.5,
builder: (final context, final scrollController) =>
NewSshKeyModal(
user: user,
scrollController: scrollController,
),
),
);
},
),
Column(
children: user.sshKeys.map((final String fullKey) {
final keyParts = fullKey.split(' ');
final keyType = keyParts[0];
final publicKey = keyParts.length > 1 ? keyParts[1] : fullKey;
final keyName =
keyParts.length > 2 ? keyParts[2] : 'ssh.no_key_name'.tr();
return ListTileOnSurfaceVariant(
title: '$keyName ($keyType)',
disableSubtitleOverflow: true,
// do not overflow text
subtitle: publicKey,
onTap: () {
showDialog(
context: context,
builder: (final BuildContext context) =>
_DeleteConfirmationDialog(
fullKey: fullKey,
keyType: keyType,
publicKey: publicKey,
keyName: keyName,
user: user,
),
);
},
);
}).toList(),
),
if (sshDisabled)
Column(
children: [
const Divider(height: 0),
Padding(
padding: const EdgeInsets.all(8.0),
child: InfoBox(
text: 'ssh.ssh_disabled_warning'.tr(),
isWarning: true,
),
),
],
),
],
),
);
}
}
class _DeleteConfirmationDialog extends StatelessWidget {
const _DeleteConfirmationDialog({
required this.fullKey,
required this.keyName,
required this.keyType,
required this.publicKey,
required this.user,
});
final String fullKey;
final String keyName;
final String keyType;
final String publicKey;
final User user;
@override
Widget build(final BuildContext context) => 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: Theme.of(context).colorScheme.error,
),
),
onPressed: () {
context.read<JobsCubit>().addJob(
DeleteSSHKeyJob(
user: user,
publicKey: fullKey,
),
);
context.maybePop();
},
),
],
);
}

View file

@ -0,0 +1,41 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:selfprivacy/config/get_it_config.dart';
import 'package:selfprivacy/logic/models/hive/user.dart';
import 'package:selfprivacy/ui/atoms/cards/filled_card.dart';
import 'package:selfprivacy/ui/atoms/list_tiles/list_tile_on_surface_variant.dart';
import 'package:selfprivacy/utils/platform_adapter.dart';
class UserLoginTile extends StatelessWidget {
const UserLoginTile({
required this.user,
required this.domainName,
super.key,
});
final User user;
final String domainName;
@override
Widget build(final BuildContext context) {
final email = '${user.login}@$domainName';
return FilledCard(
child: Column(
children: [
ListTileOnSurfaceVariant(
onTap: () {
PlatformAdapter.setClipboard(email);
getIt<NavigationService>().showSnackBar(
'basis.copied_to_clipboard'.tr(),
);
},
title: email,
subtitle: 'users.email_login'.tr(),
leadingIcon: Icons.alternate_email_outlined,
),
],
),
);
}
}

View file

@ -0,0 +1,4 @@
export 'delete_user_tile.dart';
export 'reset_password_tile.dart';
export 'ssh_key_card.dart';
export 'user_login_tile.dart';

View file

@ -33,7 +33,7 @@ import 'package:selfprivacy/ui/pages/services/services.dart';
import 'package:selfprivacy/ui/pages/setup/initializing/initializing.dart';
import 'package:selfprivacy/ui/pages/setup/recovering/recovery_routing.dart';
import 'package:selfprivacy/ui/pages/users/new_user.dart';
import 'package:selfprivacy/ui/pages/users/user_details.dart';
import 'package:selfprivacy/ui/pages/users/user_details_page/user_details.dart';
import 'package:selfprivacy/ui/pages/users/users.dart';
part 'router.gr.dart';

View file

@ -5,88 +5,90 @@ Random _rnd = Random.secure();
typedef StringGeneratorFunction = String Function();
class StringGenerators {
static const String letters = 'abcdefghijklmnopqrstuvwxyz';
static const String numbers = '1234567890';
static const String symbols = '_';
static final List<String> letters = 'abcdefghijklmnopqrstuvwxyz'.split('');
static final List<String> upperCaseLetters =
'abcdefghijklmnopqrstuvwxyz'.toUpperCase().split('');
static final List<String> numbers = '1234567890'.split('');
static const List<String> symbols = ['_'];
static String getRandomString(
final int length, {
final hasLowercaseLetters = false,
final hasUppercaseLetters = false,
final hasNumbers = false,
final hasSymbols = false,
hasLowercaseLetters = false,
hasUppercaseLetters = false,
hasNumbers = false,
hasSymbols = false,
final isStrict = false,
}) {
String chars = '';
if (hasLowercaseLetters) {
chars += letters;
}
if (hasUppercaseLetters) {
chars += letters.toUpperCase();
}
if (hasNumbers) {
chars += numbers;
}
if (hasSymbols) {
chars += symbols;
}
final List<String> chars = [
if (hasLowercaseLetters) ...letters,
if (hasUppercaseLetters) ...upperCaseLetters,
if (hasNumbers) ...numbers,
if (hasSymbols) ...symbols,
];
assert(chars.isNotEmpty, 'chart empty');
late List<String> res;
if (!isStrict) {
return genString(length, chars);
}
res = genString(length, chars);
} else {
int sum = (hasLowercaseLetters ? 1 : 0) +
(hasUppercaseLetters ? 1 : 0) +
(hasNumbers ? 1 : 0) +
(hasSymbols ? 1 : 0);
String res = '';
int loose = length;
if (hasLowercaseLetters) {
loose -= 1;
res += genString(1, letters);
}
if (hasUppercaseLetters) {
loose -= 1;
res += genString(1, letters.toUpperCase());
}
if (hasNumbers) {
loose -= 1;
res += genString(1, numbers.toUpperCase());
}
if (hasSymbols) {
loose -= 1;
res += genString(1, symbols);
}
res += genString(loose, chars);
/// disable flags one by one when the len of generated string is less
/// than count of flags
/// so we wouldn't generate 4 len string when we need 2 or 3 symbols
while (sum > length) {
final int step = _rnd.nextInt(4);
final List<String> shuffledlist = res.split('')..shuffle();
return shuffledlist.join();
switch (step) {
case 0:
if (hasLowercaseLetters) {
hasLowercaseLetters = false;
sum--;
continue;
}
case 1:
if (hasUppercaseLetters) {
hasUppercaseLetters = false;
sum--;
continue;
}
case 2:
if (hasNumbers) {
hasNumbers = false;
sum--;
continue;
}
case 3: // symbols
if (hasSymbols) {
hasSymbols = false;
sum--;
continue;
}
}
}
res = [
if (hasLowercaseLetters) ...genString(1, letters),
if (hasUppercaseLetters) ...genString(1, upperCaseLetters),
if (hasNumbers) ...genString(1, numbers),
if (hasSymbols) ...genString(1, symbols),
...genString(
length - sum,
chars,
),
];
}
res.shuffle();
return res.join();
}
static String genString(final int length, final String chars) =>
String.fromCharCodes(
Iterable.generate(
length,
(final _) => chars.codeUnitAt(
_rnd.nextInt(chars.length),
),
),
);
static StringGeneratorFunction userPassword = () => getRandomString(
8,
hasLowercaseLetters: true,
hasUppercaseLetters: true,
hasNumbers: true,
isStrict: true,
);
static StringGeneratorFunction passwordSalt = () => getRandomString(
8,
hasLowercaseLetters: true,
);
static List<String> genString(final int length, final List<String> chars) => [
for (int i = 0; i < length; i++) chars[_rnd.nextInt(chars.length)],
];
static StringGeneratorFunction simpleId = () => getRandomString(
5,