mirror of
https://git.selfprivacy.org/kherel/selfprivacy.org.app.git
synced 2025-01-27 11:16:45 +00:00
update
This commit is contained in:
parent
94a0e22b15
commit
84e9259ec2
16
.editorconfig
Normal file
16
.editorconfig
Normal file
|
@ -0,0 +1,16 @@
|
|||
# editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.dart]
|
||||
max_line_length = 150
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
|
@ -147,6 +147,13 @@
|
|||
"bottom_sheet": {
|
||||
"1": "You can connect and create a new user here:"
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"title": "VPN Server",
|
||||
"subtitle": "Private VPN server",
|
||||
"bottom_sheet": {
|
||||
"1": "Openconnect VPN Server. Engine for secure and scalable VPN infrastructure"
|
||||
}
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
|
@ -217,7 +224,6 @@
|
|||
"serviceTurnOff": "Turn off",
|
||||
"serviceTurnOn": "Turn on",
|
||||
"jobAdded": "Job added"
|
||||
|
||||
},
|
||||
"validations": {
|
||||
"required": "Required",
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:selfprivacy/logic/cubit/app_settings/app_settings_cubit.dart';
|
|||
import 'package:selfprivacy/logic/cubit/app_config/app_config_cubit.dart';
|
||||
import 'package:selfprivacy/logic/cubit/jobs/jobs_cubit.dart';
|
||||
import 'package:selfprivacy/logic/cubit/providers/providers_cubit.dart';
|
||||
import 'package:selfprivacy/logic/cubit/services/services_cubit.dart';
|
||||
import 'package:selfprivacy/logic/cubit/users/users_cubit.dart';
|
||||
|
||||
class BlocAndProviderConfig extends StatelessWidget {
|
||||
|
@ -13,11 +14,9 @@ class BlocAndProviderConfig extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// var platformBrightness =
|
||||
// SchedulerBinding.instance.window.platformBrightness;
|
||||
// var isDark = platformBrightness == Brightness.dark;
|
||||
var isDark = false;
|
||||
var usersCubit = UsersCubit();
|
||||
var servicesCubit = ServicesCubit();
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
|
@ -28,11 +27,17 @@ class BlocAndProviderConfig extends StatelessWidget {
|
|||
),
|
||||
BlocProvider(
|
||||
lazy: false,
|
||||
create: (_) => AppConfigCubit()..load(),
|
||||
create: (_) => AppConfigCubit(servicesCubit)..load(),
|
||||
),
|
||||
BlocProvider(create: (_) => ProvidersCubit()),
|
||||
BlocProvider(create: (_) => usersCubit..load(), lazy: false),
|
||||
BlocProvider(create: (_) => JobsCubit(usersCubit)),
|
||||
BlocProvider(create: (_) => servicesCubit..load(), lazy: false),
|
||||
BlocProvider(
|
||||
create: (_) => JobsCubit(
|
||||
usersCubit: usersCubit,
|
||||
servicesCubit: servicesCubit,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: child,
|
||||
);
|
||||
|
|
|
@ -20,6 +20,7 @@ class HiveConfig {
|
|||
|
||||
await Hive.openBox(BNames.appSettings);
|
||||
await Hive.openBox<User>(BNames.users);
|
||||
await Hive.openBox(BNames.servicesState);
|
||||
|
||||
var cipher = HiveAesCipher(await getEncriptedKey());
|
||||
await Hive.openBox(BNames.appConfig, encryptionCipher: cipher);
|
||||
|
@ -45,6 +46,7 @@ class BNames {
|
|||
static String users = 'users';
|
||||
|
||||
static String appSettings = 'appSettings';
|
||||
static String servicesState = 'servicesState';
|
||||
|
||||
static String key = 'key';
|
||||
|
||||
|
|
|
@ -90,8 +90,11 @@ class HetznerApi extends ApiMap {
|
|||
var dbPassword = StringGenerators.dbPassword();
|
||||
var dbId = dbCreateResponse.data['volume']['id'];
|
||||
|
||||
/// add ssh key when you need it: e.g. "ssh_keys":["kherel"]
|
||||
/// check the branch name, it could be "development" or "master".
|
||||
|
||||
var data = jsonDecode(
|
||||
'''{"name":"$domainName","server_type":"cx11","start_after_create":false,"image":"ubuntu-20.04", "volumes":[$dbId], "networks":[], ssh_keys:[kherel], "user_data":"#cloud-config\\nruncmd:\\n- curl https://git.selfprivacy.org/ilchub/selfprivacy-nixos-infect/raw/branch/development/nixos-infect | PROVIDER=hetzner NIX_CHANNEL=nixos-21.05 DOMAIN=$domainName LUSER=${rootUser.login} PASSWORD=${rootUser.password} CF_TOKEN=$cloudFlareKey DB_PASSWORD=$dbPassword bash 2>&1 | tee /tmp/infect.log","labels":{},"automount":true, "location": "fsn1"}''');
|
||||
'''{"name":"$domainName","server_type":"cx11","start_after_create":false,"image":"ubuntu-20.04", "volumes":[$dbId], "networks":[], "ssh_keys":["kherel"], "user_data":"#cloud-config\\nruncmd:\\n- curl https://git.selfprivacy.org/ilchub/selfprivacy-nixos-infect/raw/branch/development/nixos-infect | PROVIDER=hetzner NIX_CHANNEL=nixos-21.05 DOMAIN=$domainName LUSER=${rootUser.login} PASSWORD=${rootUser.password} CF_TOKEN=$cloudFlareKey DB_PASSWORD=$dbPassword bash 2>&1 | tee /tmp/infect.log","labels":{},"automount":true, "location": "fsn1"}''');
|
||||
|
||||
Response serverCreateResponse = await client.post(
|
||||
'/servers',
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:selfprivacy/config/get_it_config.dart';
|
||||
import 'package:selfprivacy/logic/common_enum/common_enum.dart';
|
||||
import 'package:selfprivacy/logic/models/user.dart';
|
||||
|
||||
import 'api_map.dart';
|
||||
|
@ -90,3 +91,29 @@ class ServerApi extends ApiMap {
|
|||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension UrlServerExt on ServiceTypes {
|
||||
String get url {
|
||||
switch (this) {
|
||||
// case ServiceTypes.mail:
|
||||
// return ''; // cannot be swithch off
|
||||
// case ServiceTypes.messenger:
|
||||
// return ''; // external service
|
||||
// case ServiceTypes.video:
|
||||
// return ''; // jeetsu meet not working
|
||||
case ServiceTypes.passwordManager:
|
||||
return 'bitwarden';
|
||||
case ServiceTypes.cloud:
|
||||
return 'nextcloud';
|
||||
case ServiceTypes.socialNetwork:
|
||||
return 'pleroma';
|
||||
case ServiceTypes.git:
|
||||
return 'gitea';
|
||||
case ServiceTypes.vpn:
|
||||
return 'ocserv';
|
||||
default:
|
||||
throw Exception('wrong state');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:selfprivacy/ui/components/brand_icons/brand_icons.dart';
|
||||
import 'package:unicons/unicons.dart';
|
||||
|
||||
enum InitializingSteps {
|
||||
setHeznerKey,
|
||||
|
@ -22,6 +23,7 @@ enum ServiceTypes {
|
|||
cloud,
|
||||
socialNetwork,
|
||||
git,
|
||||
vpn,
|
||||
}
|
||||
|
||||
extension ServiceTypesExt on ServiceTypes {
|
||||
|
@ -41,6 +43,8 @@ extension ServiceTypesExt on ServiceTypes {
|
|||
return 'services.social_network.title'.tr();
|
||||
case ServiceTypes.git:
|
||||
return 'services.git.title'.tr();
|
||||
case ServiceTypes.vpn:
|
||||
return 'services.vpn.title'.tr();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -60,6 +64,8 @@ extension ServiceTypesExt on ServiceTypes {
|
|||
return 'services.social_network.subtitle'.tr();
|
||||
case ServiceTypes.git:
|
||||
return 'services.git.subtitle'.tr();
|
||||
case ServiceTypes.vpn:
|
||||
return 'services.vpn.subtitle'.tr();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -79,6 +85,10 @@ extension ServiceTypesExt on ServiceTypes {
|
|||
return BrandIcons.social;
|
||||
case ServiceTypes.git:
|
||||
return BrandIcons.git;
|
||||
case ServiceTypes.vpn:
|
||||
return UniconsLine.cloud_lock;
|
||||
}
|
||||
}
|
||||
|
||||
String get txt => this.toString().split('.')[1];
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:selfprivacy/logic/cubit/services/services_cubit.dart';
|
||||
import 'package:selfprivacy/logic/models/backblaze_credential.dart';
|
||||
import 'package:selfprivacy/logic/models/cloudflare_domain.dart';
|
||||
|
||||
|
@ -43,9 +44,10 @@ part 'app_config_state.dart';
|
|||
/// c. if server is okay set that fully checked
|
||||
|
||||
class AppConfigCubit extends Cubit<AppConfigState> {
|
||||
AppConfigCubit() : super(InitialAppConfigState());
|
||||
AppConfigCubit(this.servicesCubit) : super(InitialAppConfigState());
|
||||
|
||||
final repository = AppConfigRepository();
|
||||
final ServicesCubit servicesCubit;
|
||||
|
||||
Future<void> load() async {
|
||||
var state = await repository.load();
|
||||
|
@ -232,6 +234,7 @@ class AppConfigCubit extends Cubit<AppConfigState> {
|
|||
|
||||
if (isServerWorking) {
|
||||
await repository.saveHasFinalChecked(true);
|
||||
servicesCubit.allOn();
|
||||
|
||||
emit(state.copyWith(
|
||||
hasFinalChecked: true,
|
||||
|
@ -259,12 +262,16 @@ class AppConfigCubit extends Cubit<AppConfigState> {
|
|||
|
||||
void clearAppConfig() {
|
||||
closeTimer();
|
||||
servicesCubit.allOff();
|
||||
|
||||
repository.clearAppConfig();
|
||||
emit(InitialAppConfigState());
|
||||
}
|
||||
|
||||
Future<void> serverDelete() async {
|
||||
closeTimer();
|
||||
servicesCubit.allOff();
|
||||
|
||||
if (state.hetznerServer != null) {
|
||||
await repository.deleteServer(state.cloudFlareDomain!);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
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/models/jobs/job.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
@ -12,10 +13,14 @@ import 'package:easy_localization/easy_localization.dart';
|
|||
part 'jobs_state.dart';
|
||||
|
||||
class JobsCubit extends Cubit<JobsState> {
|
||||
JobsCubit(this.usersCubit) : super(JobsStateEmpty());
|
||||
JobsCubit({
|
||||
required this.usersCubit,
|
||||
required this.servicesCubit,
|
||||
}) : super(JobsStateEmpty());
|
||||
|
||||
final api = ServerApi();
|
||||
final UsersCubit usersCubit;
|
||||
final ServicesCubit servicesCubit;
|
||||
|
||||
void addJob(Job job) {
|
||||
var newJobsList = <Job>[];
|
||||
|
|
|
@ -1,10 +1,63 @@
|
|||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:selfprivacy/config/hive_config.dart';
|
||||
import 'package:selfprivacy/logic/common_enum/common_enum.dart';
|
||||
|
||||
part 'services_state.dart';
|
||||
|
||||
class ServicesCubit extends Cubit<ServicesState> {
|
||||
ServicesCubit() : super(ServicesInitial());
|
||||
ServicesCubit() : super(ServicesState.allOff());
|
||||
|
||||
|
||||
Box box = Hive.box(BNames.servicesState);
|
||||
|
||||
void load() {
|
||||
emit(
|
||||
ServicesState(
|
||||
isPasswordManagerEnable:
|
||||
box.get(ServiceTypes.passwordManager.txt, defaultValue: false),
|
||||
isCloudEnable: box.get(ServiceTypes.cloud.txt, defaultValue: false),
|
||||
isGitEnable: box.get(ServiceTypes.git.txt, defaultValue: false),
|
||||
isSocialNetworkEnable:
|
||||
box.get(ServiceTypes.socialNetwork.txt, defaultValue: false),
|
||||
isVpnEnable: box.get(ServiceTypes.vpn.txt, defaultValue: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void allOn() {
|
||||
box.put(ServiceTypes.passwordManager.txt, true);
|
||||
box.put(ServiceTypes.cloud.txt, true);
|
||||
box.put(ServiceTypes.git.txt, true);
|
||||
box.put(ServiceTypes.socialNetwork.txt, true);
|
||||
box.put(ServiceTypes.vpn.txt, true);
|
||||
|
||||
emit(ServicesState.allOn());
|
||||
}
|
||||
|
||||
void allOff() {
|
||||
box.put(ServiceTypes.passwordManager.txt, false);
|
||||
box.put(ServiceTypes.cloud.txt, false);
|
||||
box.put(ServiceTypes.git.txt, false);
|
||||
box.put(ServiceTypes.socialNetwork.txt, false);
|
||||
box.put(ServiceTypes.vpn.txt, false);
|
||||
|
||||
emit(ServicesState.allOff());
|
||||
}
|
||||
|
||||
void turnOffList(List<ServiceTypes> list) {
|
||||
for (final service in list) {
|
||||
box.put(service.txt, false);
|
||||
}
|
||||
|
||||
emit(state.disableList(list));
|
||||
}
|
||||
|
||||
void turnOnist(List<ServiceTypes> list) {
|
||||
for (final service in list) {
|
||||
box.put(service.txt, true);
|
||||
}
|
||||
|
||||
emit(state.enableList(list));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,84 @@
|
|||
part of 'services_cubit.dart';
|
||||
|
||||
abstract class ServicesState extends Equatable {
|
||||
const ServicesState();
|
||||
const switchableServices = [
|
||||
ServiceTypes.passwordManager,
|
||||
ServiceTypes.cloud,
|
||||
ServiceTypes.socialNetwork,
|
||||
ServiceTypes.git,
|
||||
ServiceTypes.vpn,
|
||||
];
|
||||
|
||||
class ServicesState extends Equatable {
|
||||
const ServicesState({
|
||||
required this.isPasswordManagerEnable,
|
||||
required this.isCloudEnable,
|
||||
required this.isGitEnable,
|
||||
required this.isSocialNetworkEnable,
|
||||
required this.isVpnEnable,
|
||||
});
|
||||
|
||||
final bool isPasswordManagerEnable;
|
||||
final bool isCloudEnable;
|
||||
final bool isGitEnable;
|
||||
final bool isSocialNetworkEnable;
|
||||
final bool isVpnEnable;
|
||||
|
||||
factory ServicesState.allOff() => ServicesState(
|
||||
isPasswordManagerEnable: false,
|
||||
isCloudEnable: false,
|
||||
isGitEnable: false,
|
||||
isSocialNetworkEnable: false,
|
||||
isVpnEnable: false,
|
||||
);
|
||||
factory ServicesState.allOn() => ServicesState(
|
||||
isPasswordManagerEnable: true,
|
||||
isCloudEnable: true,
|
||||
isGitEnable: true,
|
||||
isSocialNetworkEnable: true,
|
||||
isVpnEnable: true,
|
||||
);
|
||||
|
||||
ServicesState enableList(
|
||||
List<ServiceTypes> list,
|
||||
) =>
|
||||
ServicesState(
|
||||
isPasswordManagerEnable: list.contains(ServiceTypes.passwordManager)
|
||||
? true
|
||||
: isPasswordManagerEnable,
|
||||
isCloudEnable: list.contains(ServiceTypes.cloud) ? true : isCloudEnable,
|
||||
isGitEnable:
|
||||
list.contains(ServiceTypes.git) ? true : isPasswordManagerEnable,
|
||||
isSocialNetworkEnable: list.contains(ServiceTypes.socialNetwork)
|
||||
? true
|
||||
: isPasswordManagerEnable,
|
||||
isVpnEnable:
|
||||
list.contains(ServiceTypes.vpn) ? true : isPasswordManagerEnable,
|
||||
);
|
||||
|
||||
ServicesState disableList(
|
||||
List<ServiceTypes> list,
|
||||
) =>
|
||||
ServicesState(
|
||||
isPasswordManagerEnable: list.contains(ServiceTypes.passwordManager)
|
||||
? false
|
||||
: isPasswordManagerEnable,
|
||||
isCloudEnable:
|
||||
list.contains(ServiceTypes.cloud) ? false : isCloudEnable,
|
||||
isGitEnable:
|
||||
list.contains(ServiceTypes.git) ? false : isPasswordManagerEnable,
|
||||
isSocialNetworkEnable: list.contains(ServiceTypes.socialNetwork)
|
||||
? false
|
||||
: isPasswordManagerEnable,
|
||||
isVpnEnable:
|
||||
list.contains(ServiceTypes.vpn) ? false : isPasswordManagerEnable,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object> get props => [];
|
||||
List<Object> get props => [
|
||||
isPasswordManagerEnable,
|
||||
isCloudEnable,
|
||||
isGitEnable,
|
||||
isSocialNetworkEnable,
|
||||
isVpnEnable
|
||||
];
|
||||
}
|
||||
|
||||
class ServicesInitial extends ServicesState {}
|
||||
|
|
|
@ -119,69 +119,72 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
|
|||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: EdgeInsets.only(top: 20, bottom: 5),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(width: 1, color: BrandColors.dividerColor),
|
||||
)),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: _TextColumn(
|
||||
title: 'more.settings.5'.tr(),
|
||||
value: 'more.settings.6'.tr(),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 5),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: BrandColors.red1,
|
||||
),
|
||||
child: Text(
|
||||
'basis.delete'.tr(),
|
||||
style: TextStyle(
|
||||
color: BrandColors.white,
|
||||
fontWeight: NamedFontWeight.demiBold,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) {
|
||||
return BrandAlert(
|
||||
title: 'modals.3'.tr(),
|
||||
contentText: 'modals.6'.tr(),
|
||||
acitons: [
|
||||
ActionButton(
|
||||
text: 'modals.7'.tr(),
|
||||
isRed: true,
|
||||
onPressed: () async {
|
||||
await context
|
||||
.read<AppConfigCubit>()
|
||||
.serverDelete();
|
||||
Navigator.of(context).pop();
|
||||
}),
|
||||
ActionButton(
|
||||
text: 'basis.cancel'.tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
// deleteServer(context)
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget deleteServer(BuildContext context) {
|
||||
// todo: need to check
|
||||
return Container(
|
||||
padding: EdgeInsets.only(top: 20, bottom: 5),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(width: 1, color: BrandColors.dividerColor),
|
||||
)),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: _TextColumn(
|
||||
title: 'more.settings.5'.tr(),
|
||||
value: 'more.settings.6'.tr(),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 5),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: BrandColors.red1,
|
||||
),
|
||||
child: Text(
|
||||
'basis.delete'.tr(),
|
||||
style: TextStyle(
|
||||
color: BrandColors.white,
|
||||
fontWeight: NamedFontWeight.demiBold,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) {
|
||||
return BrandAlert(
|
||||
title: 'modals.3'.tr(),
|
||||
contentText: 'modals.6'.tr(),
|
||||
acitons: [
|
||||
ActionButton(
|
||||
text: 'modals.7'.tr(),
|
||||
isRed: true,
|
||||
onPressed: () async {
|
||||
await context.read<AppConfigCubit>().serverDelete();
|
||||
Navigator.of(context).pop();
|
||||
}),
|
||||
ActionButton(
|
||||
text: 'basis.cancel'.tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TextColumn extends StatelessWidget {
|
||||
|
|
|
@ -301,6 +301,10 @@ class _ServiceDetails extends StatelessWidget {
|
|||
],
|
||||
));
|
||||
break;
|
||||
case ServiceTypes.vpn:
|
||||
child = Text(
|
||||
'services.vpn.bottom_sheet.1'.tr(),
|
||||
);
|
||||
}
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
|
|
|
@ -24,7 +24,6 @@ dependencies:
|
|||
get_it: ^7.2.0
|
||||
hive: ^2.0.0
|
||||
hive_flutter: ^1.0.0
|
||||
ionicons: ^0.1.2
|
||||
json_annotation: ^4.0.0
|
||||
modal_bottom_sheet: ^2.0.0
|
||||
nanoid: ^1.0.0
|
||||
|
|
Loading…
Reference in a new issue