* feat(PVE): Added display of PVE connection loading steps Added a detailed display of loading steps during the PVE connection process, including stages such as establishing an SSH tunnel, authentication, and data retrieval Also optimized the sorting of PVE storage content and the logic for handling connection errors * feat(pve): Added error handling and prompts for PVE two-factor authentication Added error handling for PVE servers when two-factor authentication is enabled, along with relevant error types and localized prompts * feat(PVE): Added support for PVE passwords during key-based authentication - Added the `pvePwd` field to the `ServerCustom` model - Added a PVE password input field to the edit page (displayed only during key-based authentication) - Updated multilingual files to support PVE-related loading states and password prompts - Optimized PVE connection logic to support password verification during key-based authentication
296 lines
9.2 KiB
Dart
296 lines
9.2 KiB
Dart
part of 'edit.dart';
|
|
|
|
/// Only permit ipv4 / ipv6 / domain chars (including IPv6 zone identifier like %en0)
|
|
final _hostReg = RegExp(r'^[a-zA-Z0-9\.\-_:%;]+$');
|
|
|
|
extension _Actions on _ServerEditPageState {
|
|
bool _isInvalidJumpSelection(String? candidateJumpId) {
|
|
final currentServer = spi;
|
|
return wouldCreateJumpCycle(
|
|
currentServerId: currentServer?.id,
|
|
candidateJumpId: candidateJumpId,
|
|
serversById: ref.read(serversProvider).servers,
|
|
);
|
|
}
|
|
|
|
void _onTapCustomItem() async {
|
|
final res = await KvEditor.route.go(
|
|
context,
|
|
KvEditorArgs(data: _customCmds.value),
|
|
);
|
|
if (res == null) return;
|
|
_customCmds.value = res;
|
|
}
|
|
|
|
void _onTapDisabledCmdTypes() async {
|
|
final allCmdTypes = ShellCmdType.all;
|
|
|
|
// [TimeSeq] depends on the `time` cmd type, so it should be removed from the list
|
|
allCmdTypes.remove(StatusCmdType.time);
|
|
|
|
await _showCmdTypesDialog(allCmdTypes);
|
|
}
|
|
|
|
void _onSave() async {
|
|
if (_ipController.text.isEmpty) {
|
|
context.showSnackBar('${libL10n.empty} ${libL10n.host}');
|
|
return;
|
|
}
|
|
|
|
if (!_hostReg.hasMatch(_ipController.text)) {
|
|
context.showSnackBar(l10n.invalidHostFormat);
|
|
return;
|
|
}
|
|
|
|
if (_keyIdx.value == null && _passwordController.text.isEmpty) {
|
|
final ok = await context.showRoundDialog<bool>(
|
|
title: libL10n.attention,
|
|
child: Text(libL10n.askContinue(l10n.useNoPwd)),
|
|
actions: Btnx.cancelRedOk,
|
|
);
|
|
if (ok != true) return;
|
|
}
|
|
|
|
// If [_pubKeyIndex] is -1, it means that the user has not selected
|
|
if (_keyIdx.value == -1) {
|
|
context.showSnackBar(libL10n.empty);
|
|
return;
|
|
}
|
|
if (_usernameController.text.isEmpty) {
|
|
_usernameController.text = 'root';
|
|
}
|
|
if (_portController.text.isEmpty) {
|
|
_portController.text = '22';
|
|
}
|
|
if (_isInvalidJumpSelection(_jumpServer.value)) {
|
|
context.showSnackBar('${l10n.invalid}: ${l10n.jumpServer}');
|
|
return;
|
|
}
|
|
|
|
final customCmds = _customCmds.value;
|
|
final custom = ServerCustom(
|
|
pveAddr: _pveAddrCtrl.text.selfNotEmptyOrNull,
|
|
pveIgnoreCert: _pveIgnoreCert.value,
|
|
pvePwd: _pvePwdCtrl.text.selfNotEmptyOrNull,
|
|
cmds: customCmds.isEmpty ? null : customCmds,
|
|
preferTempDev: _preferTempDevCtrl.text.selfNotEmptyOrNull,
|
|
logoUrl: _logoUrlCtrl.text.selfNotEmptyOrNull,
|
|
netDev: _netDevCtrl.text.selfNotEmptyOrNull,
|
|
scriptDir: _scriptDirCtrl.text.selfNotEmptyOrNull,
|
|
);
|
|
|
|
final wolEmpty =
|
|
_wolMacCtrl.text.isEmpty &&
|
|
_wolIpCtrl.text.isEmpty &&
|
|
_wolPwdCtrl.text.isEmpty;
|
|
final wol = wolEmpty
|
|
? null
|
|
: WakeOnLanCfg(
|
|
mac: _wolMacCtrl.text,
|
|
ip: _wolIpCtrl.text,
|
|
pwd: _wolPwdCtrl.text.selfNotEmptyOrNull,
|
|
);
|
|
if (wol != null) {
|
|
final wolValidation = wol.validate();
|
|
if (!wolValidation.$2) {
|
|
context.showSnackBar('${libL10n.fail}: ${wolValidation.$1}');
|
|
return;
|
|
}
|
|
}
|
|
|
|
final spi = Spi(
|
|
name: _nameController.text.isEmpty
|
|
? _ipController.text
|
|
: _nameController.text,
|
|
ip: _ipController.text,
|
|
port: int.parse(_portController.text),
|
|
user: _usernameController.text,
|
|
pwd: _passwordController.text.selfNotEmptyOrNull,
|
|
keyId: _keyIdx.value != null
|
|
? ref.read(privateKeyProvider).keys.elementAt(_keyIdx.value!).id
|
|
: null,
|
|
tags: _tags.value.isEmpty ? null : _tags.value.toList(),
|
|
alterUrl: _altUrlController.text.selfNotEmptyOrNull,
|
|
autoConnect: _autoConnect.value,
|
|
jumpId: _jumpServer.value,
|
|
custom: custom,
|
|
wolCfg: wol,
|
|
envs: _env.value.isEmpty ? null : _env.value,
|
|
id: widget.args?.spi.id ?? ShortId.generate(),
|
|
customSystemType: _systemType.value,
|
|
disabledCmdTypes: _disabledCmdTypes.value.isEmpty
|
|
? null
|
|
: _disabledCmdTypes.value.toList(),
|
|
);
|
|
|
|
if (this.spi == null) {
|
|
final existsIds = ServerStore.instance.box.keys;
|
|
if (existsIds.contains(spi.id)) {
|
|
context.showSnackBar('${l10n.sameIdServerExist}: ${spi.id}');
|
|
return;
|
|
}
|
|
ref.read(serversProvider.notifier).addServer(spi);
|
|
} else {
|
|
ref.read(serversProvider.notifier).updateServer(this.spi!, spi);
|
|
}
|
|
|
|
context.pop();
|
|
}
|
|
}
|
|
|
|
extension _Utils on _ServerEditPageState {
|
|
Future<void> _checkSSHConfigImport() async {
|
|
final hasExistingServers = ref.read(serversProvider).servers.isNotEmpty;
|
|
if (hasExistingServers) {
|
|
Stores.setting.firstTimeReadSSHCfg.put(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final servers = await SSHConfig.parseConfig();
|
|
if (!mounted) return;
|
|
if (servers.isEmpty) {
|
|
Stores.setting.firstTimeReadSSHCfg.put(false);
|
|
return;
|
|
}
|
|
|
|
final shouldImport = await context.showRoundDialog<bool>(
|
|
title: l10n.sshConfigImport,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(l10n.sshConfigFound),
|
|
const SizedBox(height: 8),
|
|
Text(l10n.sshConfigImportPermission),
|
|
],
|
|
),
|
|
actions: Btnx.cancelOk,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
Stores.setting.firstTimeReadSSHCfg.put(false);
|
|
|
|
if (shouldImport == true) {
|
|
await ServerDeduplication.importServersWithNotification(
|
|
servers: servers,
|
|
ref: ref,
|
|
context: context,
|
|
allExistMessage: l10n.sshConfigAllExist,
|
|
importedMessage: l10n.sshConfigImported,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
if (e is PathAccessException ||
|
|
e.toString().contains('Operation not permitted')) {
|
|
Stores.setting.firstTimeReadSSHCfg.put(false);
|
|
context.showSnackBar(
|
|
'${l10n.sshConfigPermissionDenied} ${l10n.sshConfigManualSelect}',
|
|
);
|
|
} else {
|
|
dprint('Error checking SSH config: $e');
|
|
Stores.setting.firstTimeReadSSHCfg.put(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _showCmdTypesDialog(Set<ShellCmdType> allCmdTypes) {
|
|
return context.showRoundDialog(
|
|
title: '${libL10n.disabled} ${libL10n.cmd}',
|
|
child: SizedBox(
|
|
width: 270,
|
|
child: _disabledCmdTypes.listenVal((disabled) {
|
|
return ListView.builder(
|
|
itemCount: allCmdTypes.length,
|
|
itemExtent: 50,
|
|
itemBuilder: (context, index) {
|
|
final cmdType = allCmdTypes.elementAtOrNull(index);
|
|
if (cmdType == null) return UIs.placeholder;
|
|
final display = cmdType.displayName;
|
|
return ListTile(
|
|
leading: Icon(cmdType.sysType.icon, size: 20),
|
|
title: Text(cmdType.name, style: const TextStyle(fontSize: 16)),
|
|
trailing: Checkbox(
|
|
value: disabled.contains(display),
|
|
onChanged: (value) {
|
|
if (value == null) return;
|
|
if (value) {
|
|
_disabledCmdTypes.value.add(display);
|
|
} else {
|
|
_disabledCmdTypes.value.remove(display);
|
|
}
|
|
_disabledCmdTypes.notify();
|
|
},
|
|
),
|
|
onTap: () {
|
|
final isDisabled = disabled.contains(display);
|
|
if (isDisabled) {
|
|
_disabledCmdTypes.value.remove(display);
|
|
} else {
|
|
_disabledCmdTypes.value.add(display);
|
|
}
|
|
_disabledCmdTypes.notify();
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}),
|
|
),
|
|
actions: Btnx.oks,
|
|
);
|
|
}
|
|
|
|
void _initWithSpi(Spi spi) {
|
|
_nameController.text = spi.name;
|
|
_ipController.text = spi.ip;
|
|
_portController.text = spi.port.toString();
|
|
_usernameController.text = spi.user;
|
|
if (spi.keyId == null) {
|
|
_passwordController.text = spi.pwd ?? '';
|
|
} else {
|
|
_keyIdx.value = ref
|
|
.read(privateKeyProvider)
|
|
.keys
|
|
.indexWhere((e) => e.id == spi.keyId);
|
|
}
|
|
|
|
/// List in dart is passed by pointer, so you need to copy it here
|
|
_tags.value = spi.tags?.toSet() ?? {};
|
|
|
|
_altUrlController.text = spi.alterUrl ?? '';
|
|
_autoConnect.value = spi.autoConnect;
|
|
_jumpServer.value = spi.jumpId;
|
|
|
|
final custom = spi.custom;
|
|
if (custom != null) {
|
|
_pveAddrCtrl.text = custom.pveAddr ?? '';
|
|
_pveIgnoreCert.value = custom.pveIgnoreCert;
|
|
_pvePwdCtrl.text = custom.pvePwd ?? '';
|
|
_customCmds.value = custom.cmds ?? {};
|
|
_preferTempDevCtrl.text = custom.preferTempDev ?? '';
|
|
_logoUrlCtrl.text = custom.logoUrl ?? '';
|
|
}
|
|
|
|
final wol = spi.wolCfg;
|
|
if (wol != null) {
|
|
_wolMacCtrl.text = wol.mac;
|
|
_wolIpCtrl.text = wol.ip;
|
|
_wolPwdCtrl.text = wol.pwd ?? '';
|
|
}
|
|
|
|
_env.value = spi.envs ?? {};
|
|
|
|
_netDevCtrl.text = spi.custom?.netDev ?? '';
|
|
_scriptDirCtrl.text = spi.custom?.scriptDir ?? '';
|
|
|
|
_systemType.value = spi.customSystemType;
|
|
|
|
final disabledCmdTypes = spi.disabledCmdTypes?.toSet() ?? {};
|
|
final allAvailableCmdTypes = ShellCmdType.all.map((e) => e.displayName);
|
|
disabledCmdTypes.removeWhere((e) => !allAvailableCmdTypes.contains(e));
|
|
_disabledCmdTypes.value = disabledCmdTypes;
|
|
}
|
|
}
|