diff --git a/README.md b/README.md
index 2bb87065..426eb1ac 100644
--- a/README.md
+++ b/README.md
@@ -45,22 +45,22 @@ If you would like, you can support the project here!\
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based
solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal
-access, SSH tunneling capabilities, remote file management, with many more tools to come.
+access, SSH tunneling capabilities, and remote file management, with many more tools to come.
# Features
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) and tab system
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring
- **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly.
-- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders and easily save reusable login info while being able to automate the deploying of SSH keys
+- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders, and easily save reusable login info while being able to automate the deployment of SSH keys
- **Server Stats** - View CPU, memory, and HDD usage on any SSH server
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support
- **Database Encryption** - SQLite database files encrypted at rest with automatic encryption/decryption
- **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data with incremental sync
- **Automatic SSL Setup** - Built-in SSL certificate generation and management with HTTPS redirects
-- **Modern UI** - Clean desktop/mobile friendly interface built with React, Tailwind CSS, and Shadcn
+- **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn
- **Languages** - Built-in support for English and Chinese
-- **Platform Support** - Available as a web app, desktop application (Windows & Linux), and dedicated mobile app for iOS and Android (coming in a few days)
+- **Platform Support** - Available as a web app, desktop application (Windows & Linux), and dedicated mobile app for iOS and Android. macOS and iPadOS support is planned.
# Planned Features
@@ -73,12 +73,12 @@ Supported Devices:
- Website (any modern browser like Google, Safari, and Firefox)
- Windows (app)
- Linux (app)
-- iOS (coming in a few days)
-- Android (coming in a few days)
+- iOS (app)
+- Android (app)
- iPadOS and macOS are in progress
Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix on all platforms. Otherwise, view
-a sample docker-compose file here:
+a sample Docker Compose file here:
```yaml
services:
@@ -121,6 +121,10 @@ repo.
+
+
+
+
Your browser does not support the video tag.
diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf
index c2f90f35..f64e8e4e 100644
--- a/docker/nginx-https.conf
+++ b/docker/nginx-https.conf
@@ -10,6 +10,9 @@ http {
keepalive_timeout 65;
client_header_timeout 300s;
+ set_real_ip_from 127.0.0.1;
+ real_ip_header X-Forwarded-For;
+
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
ssl_prefer_server_ciphers off;
@@ -23,7 +26,6 @@ http {
return 301 https://$host:${SSL_PORT}$request_uri;
}
- # HTTPS Server
server {
listen ${SSL_PORT} ssl;
server_name _;
@@ -41,7 +43,6 @@ http {
index index.html index.htm;
}
- # Handle missing source map files gracefully
location ~* \.map$ {
return 404;
access_log off;
diff --git a/docker/nginx.conf b/docker/nginx.conf
index b78418b7..c180c180 100644
--- a/docker/nginx.conf
+++ b/docker/nginx.conf
@@ -10,6 +10,9 @@ http {
keepalive_timeout 65;
client_header_timeout 300s;
+ set_real_ip_from 127.0.0.1;
+ real_ip_header X-Forwarded-For;
+
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
ssl_prefer_server_ciphers off;
@@ -29,7 +32,6 @@ http {
index index.html index.htm;
}
- # Handle missing source map files gracefully
location ~* \.map$ {
return 404;
access_log off;
diff --git a/package-lock.json b/package-lock.json
index 2b07667d..9054dd7d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "termix",
- "version": "1.7.1",
+ "version": "1.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "termix",
- "version": "1.7.1",
+ "version": "1.8.0",
"dependencies": {
"@codemirror/autocomplete": "^6.18.7",
"@codemirror/commands": "^6.3.3",
@@ -1325,6 +1325,8 @@
"integrity": "sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw==",
"dev": true,
"license": "BSD-2-Clause",
+ "optional": true,
+ "peer": true,
"dependencies": {
"compare-version": "^0.1.2",
"debug": "^4.3.4",
@@ -1347,6 +1349,8 @@
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -1365,6 +1369,8 @@
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -1393,6 +1399,8 @@
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"dev": true,
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -1406,6 +1414,9 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
"node_modules/@electron/osx-sign/node_modules/universalify": {
"version": "2.0.1",
@@ -1413,6 +1424,8 @@
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
+ "optional": true,
+ "peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -9350,6 +9363,14 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@electron/asar": "^3.2.1",
+ "debug": "^4.1.1",
+ "fs-extra": "^7.0.1",
+ "lodash": "^4.17.21",
+ "temp": "^0.9.0"
+ },
"engines": {
"node": ">=6.6.0"
}
@@ -9359,6 +9380,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -9376,6 +9398,7 @@
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
@@ -9396,7 +9419,9 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "license": "MIT"
+ "dev": true,
+ "license": "MIT",
+ "peer": true
},
"node_modules/express/node_modules/qs": {
"version": "6.14.0",
@@ -11245,8 +11270,11 @@
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"license": "MIT",
- "bin": {
- "json5": "lib/cli.js"
+ "dependencies": {
+ "@babel/runtime": "^7.27.6"
+ },
+ "peerDependencies": {
+ "typescript": "^5"
},
"engines": {
"node": ">=6"
diff --git a/package.json b/package.json
index 6ee59e8f..67fdd920 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "termix",
"private": true,
- "version": "1.7.1",
+ "version": "1.7.2",
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
"author": "Karmaa",
"main": "electron/main.cjs",
diff --git a/public/fonts/CaskaydiaCoveNerdFontMono-Bold.ttf b/public/fonts/CaskaydiaCoveNerdFontMono-Bold.ttf
new file mode 100644
index 00000000..52aca34f
Binary files /dev/null and b/public/fonts/CaskaydiaCoveNerdFontMono-Bold.ttf differ
diff --git a/public/fonts/CaskaydiaCoveNerdFontMono-BoldItalic.ttf b/public/fonts/CaskaydiaCoveNerdFontMono-BoldItalic.ttf
new file mode 100644
index 00000000..d75f329e
Binary files /dev/null and b/public/fonts/CaskaydiaCoveNerdFontMono-BoldItalic.ttf differ
diff --git a/public/fonts/CaskaydiaCoveNerdFontMono-Italic.ttf b/public/fonts/CaskaydiaCoveNerdFontMono-Italic.ttf
new file mode 100644
index 00000000..582b3acc
Binary files /dev/null and b/public/fonts/CaskaydiaCoveNerdFontMono-Italic.ttf differ
diff --git a/public/fonts/CaskaydiaCoveNerdFontMono-Regular.ttf b/public/fonts/CaskaydiaCoveNerdFontMono-Regular.ttf
new file mode 100644
index 00000000..d64d4402
Binary files /dev/null and b/public/fonts/CaskaydiaCoveNerdFontMono-Regular.ttf differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFont-Bold.ttf b/public/fonts/JetBrainsMonoNLNerdFont-Bold.ttf
deleted file mode 100644
index 2bc4a977..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFont-Bold.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFont-BoldItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFont-BoldItalic.ttf
deleted file mode 100644
index c55b451e..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFont-BoldItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFont-ExtraBold.ttf b/public/fonts/JetBrainsMonoNLNerdFont-ExtraBold.ttf
deleted file mode 100644
index 7ed5f10c..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFont-ExtraBold.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFont-ExtraBoldItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFont-ExtraBoldItalic.ttf
deleted file mode 100644
index 9b12297a..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFont-ExtraBoldItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFont-ExtraLight.ttf b/public/fonts/JetBrainsMonoNLNerdFont-ExtraLight.ttf
deleted file mode 100644
index 4bab5993..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFont-ExtraLight.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFont-ExtraLightItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFont-ExtraLightItalic.ttf
deleted file mode 100644
index fcc424ec..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFont-ExtraLightItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFont-Italic.ttf b/public/fonts/JetBrainsMonoNLNerdFont-Italic.ttf
deleted file mode 100644
index 5af240c4..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFont-Italic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFont-Light.ttf b/public/fonts/JetBrainsMonoNLNerdFont-Light.ttf
deleted file mode 100644
index c8d0bc0c..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFont-Light.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFont-LightItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFont-LightItalic.ttf
deleted file mode 100644
index a0ad2560..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFont-LightItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFont-Medium.ttf b/public/fonts/JetBrainsMonoNLNerdFont-Medium.ttf
deleted file mode 100644
index 61901270..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFont-Medium.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFont-MediumItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFont-MediumItalic.ttf
deleted file mode 100644
index 05bfc119..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFont-MediumItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFont-Regular.ttf b/public/fonts/JetBrainsMonoNLNerdFont-Regular.ttf
deleted file mode 100644
index 5e7d395e..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFont-Regular.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFont-SemiBold.ttf b/public/fonts/JetBrainsMonoNLNerdFont-SemiBold.ttf
deleted file mode 100644
index 7034d5dc..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFont-SemiBold.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFont-SemiBoldItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFont-SemiBoldItalic.ttf
deleted file mode 100644
index e2876a5e..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFont-SemiBoldItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFont-Thin.ttf b/public/fonts/JetBrainsMonoNLNerdFont-Thin.ttf
deleted file mode 100644
index d84f9acb..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFont-Thin.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFont-ThinItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFont-ThinItalic.ttf
deleted file mode 100644
index e422ca6e..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFont-ThinItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-Bold.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-Bold.ttf
deleted file mode 100644
index 61d25d73..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontMono-Bold.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf
deleted file mode 100644
index 2885c6be..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-ExtraBold.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-ExtraBold.ttf
deleted file mode 100644
index 8615f9fe..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontMono-ExtraBold.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-ExtraBoldItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-ExtraBoldItalic.ttf
deleted file mode 100644
index e3634227..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontMono-ExtraBoldItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-ExtraLight.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-ExtraLight.ttf
deleted file mode 100644
index 607663c8..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontMono-ExtraLight.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-ExtraLightItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-ExtraLightItalic.ttf
deleted file mode 100644
index 3d032d43..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontMono-ExtraLightItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-Italic.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-Italic.ttf
deleted file mode 100644
index 6ceae280..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontMono-Italic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-Light.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-Light.ttf
deleted file mode 100644
index 8f9c54a6..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontMono-Light.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-LightItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-LightItalic.ttf
deleted file mode 100644
index 205a508c..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontMono-LightItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-Medium.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-Medium.ttf
deleted file mode 100644
index 10f9c843..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontMono-Medium.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-MediumItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-MediumItalic.ttf
deleted file mode 100644
index 0a5b4ed5..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontMono-MediumItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf
deleted file mode 100644
index 5e77e110..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-SemiBold.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-SemiBold.ttf
deleted file mode 100644
index 9d2a13e7..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontMono-SemiBold.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-SemiBoldItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-SemiBoldItalic.ttf
deleted file mode 100644
index 61750cc0..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontMono-SemiBoldItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-Thin.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-Thin.ttf
deleted file mode 100644
index 2ab3abaa..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontMono-Thin.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-ThinItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-ThinItalic.ttf
deleted file mode 100644
index 4878d86b..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontMono-ThinItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontPropo-Bold.ttf b/public/fonts/JetBrainsMonoNLNerdFontPropo-Bold.ttf
deleted file mode 100644
index be2de11c..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontPropo-Bold.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontPropo-BoldItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFontPropo-BoldItalic.ttf
deleted file mode 100644
index e37df50e..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontPropo-BoldItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontPropo-ExtraBold.ttf b/public/fonts/JetBrainsMonoNLNerdFontPropo-ExtraBold.ttf
deleted file mode 100644
index 46d20899..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontPropo-ExtraBold.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontPropo-ExtraBoldItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFontPropo-ExtraBoldItalic.ttf
deleted file mode 100644
index 394b69d0..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontPropo-ExtraBoldItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontPropo-ExtraLight.ttf b/public/fonts/JetBrainsMonoNLNerdFontPropo-ExtraLight.ttf
deleted file mode 100644
index 09c63db8..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontPropo-ExtraLight.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontPropo-ExtraLightItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFontPropo-ExtraLightItalic.ttf
deleted file mode 100644
index 841f3a85..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontPropo-ExtraLightItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontPropo-Italic.ttf b/public/fonts/JetBrainsMonoNLNerdFontPropo-Italic.ttf
deleted file mode 100644
index 98c29e51..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontPropo-Italic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontPropo-Light.ttf b/public/fonts/JetBrainsMonoNLNerdFontPropo-Light.ttf
deleted file mode 100644
index 6b010fa7..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontPropo-Light.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontPropo-LightItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFontPropo-LightItalic.ttf
deleted file mode 100644
index a4a3e05e..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontPropo-LightItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontPropo-Medium.ttf b/public/fonts/JetBrainsMonoNLNerdFontPropo-Medium.ttf
deleted file mode 100644
index b6f1deab..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontPropo-Medium.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontPropo-MediumItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFontPropo-MediumItalic.ttf
deleted file mode 100644
index 046cf6f7..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontPropo-MediumItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontPropo-Regular.ttf b/public/fonts/JetBrainsMonoNLNerdFontPropo-Regular.ttf
deleted file mode 100644
index 28ccb3db..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontPropo-Regular.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontPropo-SemiBold.ttf b/public/fonts/JetBrainsMonoNLNerdFontPropo-SemiBold.ttf
deleted file mode 100644
index a416f4a9..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontPropo-SemiBold.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontPropo-SemiBoldItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFontPropo-SemiBoldItalic.ttf
deleted file mode 100644
index c325b269..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontPropo-SemiBoldItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontPropo-Thin.ttf b/public/fonts/JetBrainsMonoNLNerdFontPropo-Thin.ttf
deleted file mode 100644
index 7e584a7c..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontPropo-Thin.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontPropo-ThinItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFontPropo-ThinItalic.ttf
deleted file mode 100644
index 9e61037e..00000000
Binary files a/public/fonts/JetBrainsMonoNLNerdFontPropo-ThinItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFont-Bold.ttf b/public/fonts/JetBrainsMonoNerdFont-Bold.ttf
deleted file mode 100644
index 610c8c02..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFont-Bold.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFont-BoldItalic.ttf b/public/fonts/JetBrainsMonoNerdFont-BoldItalic.ttf
deleted file mode 100644
index 440908b8..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFont-BoldItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFont-ExtraBold.ttf b/public/fonts/JetBrainsMonoNerdFont-ExtraBold.ttf
deleted file mode 100644
index 7a3d52ef..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFont-ExtraBold.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFont-ExtraBoldItalic.ttf b/public/fonts/JetBrainsMonoNerdFont-ExtraBoldItalic.ttf
deleted file mode 100644
index 7c10cdec..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFont-ExtraBoldItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFont-ExtraLight.ttf b/public/fonts/JetBrainsMonoNerdFont-ExtraLight.ttf
deleted file mode 100644
index 92a24626..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFont-ExtraLight.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFont-ExtraLightItalic.ttf b/public/fonts/JetBrainsMonoNerdFont-ExtraLightItalic.ttf
deleted file mode 100644
index 0dfeb0ea..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFont-ExtraLightItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFont-Italic.ttf b/public/fonts/JetBrainsMonoNerdFont-Italic.ttf
deleted file mode 100644
index b9110760..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFont-Italic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFont-Light.ttf b/public/fonts/JetBrainsMonoNerdFont-Light.ttf
deleted file mode 100644
index 8d39f62e..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFont-Light.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFont-LightItalic.ttf b/public/fonts/JetBrainsMonoNerdFont-LightItalic.ttf
deleted file mode 100644
index b6c3736b..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFont-LightItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFont-Medium.ttf b/public/fonts/JetBrainsMonoNerdFont-Medium.ttf
deleted file mode 100644
index f378f285..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFont-Medium.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFont-MediumItalic.ttf b/public/fonts/JetBrainsMonoNerdFont-MediumItalic.ttf
deleted file mode 100644
index 23cbd1fb..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFont-MediumItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFont-Regular.ttf b/public/fonts/JetBrainsMonoNerdFont-Regular.ttf
deleted file mode 100644
index 2e02cab2..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFont-Regular.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFont-SemiBold.ttf b/public/fonts/JetBrainsMonoNerdFont-SemiBold.ttf
deleted file mode 100644
index e3726bfa..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFont-SemiBold.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFont-SemiBoldItalic.ttf b/public/fonts/JetBrainsMonoNerdFont-SemiBoldItalic.ttf
deleted file mode 100644
index 33925a2b..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFont-SemiBoldItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFont-Thin.ttf b/public/fonts/JetBrainsMonoNerdFont-Thin.ttf
deleted file mode 100644
index a612c9aa..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFont-Thin.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFont-ThinItalic.ttf b/public/fonts/JetBrainsMonoNerdFont-ThinItalic.ttf
deleted file mode 100644
index 418a82b4..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFont-ThinItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontMono-Bold.ttf b/public/fonts/JetBrainsMonoNerdFontMono-Bold.ttf
deleted file mode 100644
index 5381b98d..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontMono-Bold.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontMono-BoldItalic.ttf b/public/fonts/JetBrainsMonoNerdFontMono-BoldItalic.ttf
deleted file mode 100644
index 6c365de2..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontMono-BoldItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontMono-ExtraBold.ttf b/public/fonts/JetBrainsMonoNerdFontMono-ExtraBold.ttf
deleted file mode 100644
index b6bdc9c8..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontMono-ExtraBold.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontMono-ExtraBoldItalic.ttf b/public/fonts/JetBrainsMonoNerdFontMono-ExtraBoldItalic.ttf
deleted file mode 100644
index aaa69b40..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontMono-ExtraBoldItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontMono-ExtraLight.ttf b/public/fonts/JetBrainsMonoNerdFontMono-ExtraLight.ttf
deleted file mode 100644
index 5b227b30..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontMono-ExtraLight.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontMono-ExtraLightItalic.ttf b/public/fonts/JetBrainsMonoNerdFontMono-ExtraLightItalic.ttf
deleted file mode 100644
index ae140864..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontMono-ExtraLightItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontMono-Italic.ttf b/public/fonts/JetBrainsMonoNerdFontMono-Italic.ttf
deleted file mode 100644
index 11801a70..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontMono-Italic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontMono-Light.ttf b/public/fonts/JetBrainsMonoNerdFontMono-Light.ttf
deleted file mode 100644
index e89541f2..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontMono-Light.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontMono-LightItalic.ttf b/public/fonts/JetBrainsMonoNerdFontMono-LightItalic.ttf
deleted file mode 100644
index ffee3792..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontMono-LightItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontMono-Medium.ttf b/public/fonts/JetBrainsMonoNerdFontMono-Medium.ttf
deleted file mode 100644
index 12415b7d..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontMono-Medium.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontMono-MediumItalic.ttf b/public/fonts/JetBrainsMonoNerdFontMono-MediumItalic.ttf
deleted file mode 100644
index 1f97931e..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontMono-MediumItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontMono-Regular.ttf b/public/fonts/JetBrainsMonoNerdFontMono-Regular.ttf
deleted file mode 100644
index 31e03a21..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontMono-Regular.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontMono-SemiBold.ttf b/public/fonts/JetBrainsMonoNerdFontMono-SemiBold.ttf
deleted file mode 100644
index 6c87ea46..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontMono-SemiBold.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontMono-SemiBoldItalic.ttf b/public/fonts/JetBrainsMonoNerdFontMono-SemiBoldItalic.ttf
deleted file mode 100644
index 9d64da48..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontMono-SemiBoldItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontMono-Thin.ttf b/public/fonts/JetBrainsMonoNerdFontMono-Thin.ttf
deleted file mode 100644
index e66845c4..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontMono-Thin.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontMono-ThinItalic.ttf b/public/fonts/JetBrainsMonoNerdFontMono-ThinItalic.ttf
deleted file mode 100644
index d39de1a2..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontMono-ThinItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontPropo-Bold.ttf b/public/fonts/JetBrainsMonoNerdFontPropo-Bold.ttf
deleted file mode 100644
index 2877b14e..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontPropo-Bold.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontPropo-BoldItalic.ttf b/public/fonts/JetBrainsMonoNerdFontPropo-BoldItalic.ttf
deleted file mode 100644
index e2bdc28d..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontPropo-BoldItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontPropo-ExtraBold.ttf b/public/fonts/JetBrainsMonoNerdFontPropo-ExtraBold.ttf
deleted file mode 100644
index 9288f1c1..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontPropo-ExtraBold.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontPropo-ExtraBoldItalic.ttf b/public/fonts/JetBrainsMonoNerdFontPropo-ExtraBoldItalic.ttf
deleted file mode 100644
index 59097c4a..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontPropo-ExtraBoldItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontPropo-ExtraLight.ttf b/public/fonts/JetBrainsMonoNerdFontPropo-ExtraLight.ttf
deleted file mode 100644
index 991334db..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontPropo-ExtraLight.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontPropo-ExtraLightItalic.ttf b/public/fonts/JetBrainsMonoNerdFontPropo-ExtraLightItalic.ttf
deleted file mode 100644
index c57e01c9..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontPropo-ExtraLightItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontPropo-Italic.ttf b/public/fonts/JetBrainsMonoNerdFontPropo-Italic.ttf
deleted file mode 100644
index 620e90fb..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontPropo-Italic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontPropo-Light.ttf b/public/fonts/JetBrainsMonoNerdFontPropo-Light.ttf
deleted file mode 100644
index 460f73fc..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontPropo-Light.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontPropo-LightItalic.ttf b/public/fonts/JetBrainsMonoNerdFontPropo-LightItalic.ttf
deleted file mode 100644
index fadfcb4d..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontPropo-LightItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontPropo-Medium.ttf b/public/fonts/JetBrainsMonoNerdFontPropo-Medium.ttf
deleted file mode 100644
index 3a3818d8..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontPropo-Medium.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontPropo-MediumItalic.ttf b/public/fonts/JetBrainsMonoNerdFontPropo-MediumItalic.ttf
deleted file mode 100644
index 1554311d..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontPropo-MediumItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontPropo-Regular.ttf b/public/fonts/JetBrainsMonoNerdFontPropo-Regular.ttf
deleted file mode 100644
index 8547bd48..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontPropo-Regular.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontPropo-SemiBold.ttf b/public/fonts/JetBrainsMonoNerdFontPropo-SemiBold.ttf
deleted file mode 100644
index 311a4122..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontPropo-SemiBold.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontPropo-SemiBoldItalic.ttf b/public/fonts/JetBrainsMonoNerdFontPropo-SemiBoldItalic.ttf
deleted file mode 100644
index 25c5bd8f..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontPropo-SemiBoldItalic.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontPropo-Thin.ttf b/public/fonts/JetBrainsMonoNerdFontPropo-Thin.ttf
deleted file mode 100644
index 7a00eb3e..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontPropo-Thin.ttf and /dev/null differ
diff --git a/public/fonts/JetBrainsMonoNerdFontPropo-ThinItalic.ttf b/public/fonts/JetBrainsMonoNerdFontPropo-ThinItalic.ttf
deleted file mode 100644
index 0831a7f2..00000000
Binary files a/public/fonts/JetBrainsMonoNerdFontPropo-ThinItalic.ttf and /dev/null differ
diff --git a/repo-images/Image 7.png b/repo-images/Image 7.png
new file mode 100644
index 00000000..f77d750e
Binary files /dev/null and b/repo-images/Image 7.png differ
diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts
index ca085f68..36f6f070 100644
--- a/src/backend/database/database.ts
+++ b/src/backend/database/database.ts
@@ -6,6 +6,7 @@ import userRoutes from "./routes/users.js";
import sshRoutes from "./routes/ssh.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
+import snippetsRoutes from "./routes/snippets.js";
import cors from "cors";
import fetch from "node-fetch";
import fs from "fs";
@@ -30,6 +31,7 @@ import {
dismissedAlerts,
sshCredentialUsage,
settings,
+ snippets,
} from "./db/schema.js";
import { getDb } from "./db/index.js";
import Database from "better-sqlite3";
@@ -1402,6 +1404,7 @@ app.use("/users", userRoutes);
app.use("/ssh", sshRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
+app.use("/snippets", snippetsRoutes);
app.use(
(
diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts
index 03c614f7..acc0e375 100644
--- a/src/backend/database/db/index.ts
+++ b/src/backend/database/db/index.ts
@@ -243,6 +243,17 @@ async function initializeCompleteDatabase(): Promise {
FOREIGN KEY (user_id) REFERENCES users (id)
);
+ CREATE TABLE IF NOT EXISTS snippets (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ content TEXT NOT NULL,
+ description TEXT,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users (id)
+ );
+
`);
migrateSchema();
diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts
index bc2bb4d8..5957276c 100644
--- a/src/backend/database/db/schema.ts
+++ b/src/backend/database/db/schema.ts
@@ -46,7 +46,7 @@ export const sshData = sqliteTable("ssh_data", {
password: text("password"),
key: text("key", { length: 8192 }),
- keyPassword: text("key_password"),
+ key_password: text("key_password"),
keyType: text("key_type"),
autostartPassword: text("autostart_password"),
@@ -142,9 +142,9 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
username: text("username").notNull(),
password: text("password"),
key: text("key", { length: 16384 }),
- privateKey: text("private_key", { length: 16384 }),
- publicKey: text("public_key", { length: 4096 }),
- keyPassword: text("key_password"),
+ private_key: text("private_key", { length: 16384 }),
+ public_key: text("public_key", { length: 4096 }),
+ key_password: text("key_password"),
keyType: text("key_type"),
detectedKeyType: text("detected_key_type"),
usageCount: integer("usage_count").notNull().default(0),
@@ -172,3 +172,19 @@ export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
+
+export const snippets = sqliteTable("snippets", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id),
+ name: text("name").notNull(),
+ content: text("content").notNull(),
+ description: text("description"),
+ createdAt: text("created_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+ updatedAt: text("updated_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+});
diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts
index 86a46705..b85bb5ec 100644
--- a/src/backend/database/routes/credentials.ts
+++ b/src/backend/database/routes/credentials.ts
@@ -172,9 +172,9 @@ router.post(
username: username.trim(),
password: plainPassword,
key: plainKey,
- privateKey: keyInfo?.privateKey || plainKey,
- publicKey: keyInfo?.publicKey || null,
- keyPassword: plainKeyPassword,
+ private_key: keyInfo?.privateKey || plainKey,
+ public_key: keyInfo?.publicKey || null,
+ key_password: plainKeyPassword,
keyType: keyType || null,
detectedKeyType: keyInfo?.keyType || null,
usageCount: 0,
@@ -422,13 +422,13 @@ router.put(
error: `Invalid SSH key: ${keyInfo.error}`,
});
}
- updateFields.privateKey = keyInfo.privateKey;
- updateFields.publicKey = keyInfo.publicKey;
+ updateFields.private_key = keyInfo.privateKey;
+ updateFields.public_key = keyInfo.publicKey;
updateFields.detectedKeyType = keyInfo.keyType;
}
}
if (updateData.keyPassword !== undefined) {
- updateFields.keyPassword = updateData.keyPassword || null;
+ updateFields.key_password = updateData.keyPassword || null;
}
if (Object.keys(updateFields).length === 0) {
@@ -535,7 +535,7 @@ router.delete(
credentialId: null,
password: null,
key: null,
- keyPassword: null,
+ key_password: null,
authType: "password",
})
.where(
@@ -631,7 +631,7 @@ router.post(
authType: credential.auth_type || credential.authType,
password: null,
key: null,
- keyPassword: null,
+ key_password: null,
keyType: null,
updatedAt: new Date().toISOString(),
})
diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts
new file mode 100644
index 00000000..23af7bf7
--- /dev/null
+++ b/src/backend/database/routes/snippets.ts
@@ -0,0 +1,251 @@
+import express from "express";
+import { db } from "../db/index.js";
+import { snippets } from "../db/schema.js";
+import { eq, and, desc, sql } from "drizzle-orm";
+import type { Request, Response } from "express";
+import { authLogger } from "../../utils/logger.js";
+import { AuthManager } from "../../utils/auth-manager.js";
+
+const router = express.Router();
+
+function isNonEmptyString(val: any): val is string {
+ return typeof val === "string" && val.trim().length > 0;
+}
+
+const authManager = AuthManager.getInstance();
+const authenticateJWT = authManager.createAuthMiddleware();
+const requireDataAccess = authManager.createDataAccessMiddleware();
+
+// Get all snippets for the authenticated user
+// GET /snippets
+router.get(
+ "/",
+ authenticateJWT,
+ requireDataAccess,
+ async (req: Request, res: Response) => {
+ const userId = (req as any).userId;
+
+ if (!isNonEmptyString(userId)) {
+ authLogger.warn("Invalid userId for snippets fetch");
+ return res.status(400).json({ error: "Invalid userId" });
+ }
+
+ try {
+ const result = await db
+ .select()
+ .from(snippets)
+ .where(eq(snippets.userId, userId))
+ .orderBy(desc(snippets.updatedAt));
+
+ res.json(result);
+ } catch (err) {
+ authLogger.error("Failed to fetch snippets", err);
+ res.status(500).json({ error: "Failed to fetch snippets" });
+ }
+ },
+);
+
+// Get a specific snippet by ID
+// GET /snippets/:id
+router.get(
+ "/:id",
+ authenticateJWT,
+ requireDataAccess,
+ async (req: Request, res: Response) => {
+ const userId = (req as any).userId;
+ const { id } = req.params;
+ const snippetId = parseInt(id, 10);
+
+ if (!isNonEmptyString(userId) || isNaN(snippetId)) {
+ authLogger.warn("Invalid request for snippet fetch: invalid ID", { userId, id });
+ return res.status(400).json({ error: "Invalid request parameters" });
+ }
+
+ try {
+ const result = await db
+ .select()
+ .from(snippets)
+ .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
+
+ if (result.length === 0) {
+ return res.status(404).json({ error: "Snippet not found" });
+ }
+
+ res.json(result[0]);
+ } catch (err) {
+ authLogger.error("Failed to fetch snippet", err);
+ res.status(500).json({
+ error: err instanceof Error ? err.message : "Failed to fetch snippet",
+ });
+ }
+ },
+);
+
+// Create a new snippet
+// POST /snippets
+router.post(
+ "/",
+ authenticateJWT,
+ requireDataAccess,
+ async (req: Request, res: Response) => {
+ const userId = (req as any).userId;
+ const { name, content, description } = req.body;
+
+ if (
+ !isNonEmptyString(userId) ||
+ !isNonEmptyString(name) ||
+ !isNonEmptyString(content)
+ ) {
+ authLogger.warn("Invalid snippet creation data validation failed", {
+ operation: "snippet_create",
+ userId,
+ hasName: !!name,
+ hasContent: !!content,
+ });
+ return res.status(400).json({ error: "Name and content are required" });
+ }
+
+ try {
+ const insertData = {
+ userId,
+ name: name.trim(),
+ content: content.trim(),
+ description: description?.trim() || null,
+ };
+
+ const result = await db.insert(snippets).values(insertData).returning();
+
+ authLogger.success(`Snippet created: ${name} by user ${userId}`, {
+ operation: "snippet_create_success",
+ userId,
+ snippetId: result[0].id,
+ name,
+ });
+
+ res.status(201).json(result[0]);
+ } catch (err) {
+ authLogger.error("Failed to create snippet", err);
+ res.status(500).json({
+ error: err instanceof Error ? err.message : "Failed to create snippet",
+ });
+ }
+ },
+);
+
+// Update a snippet
+// PUT /snippets/:id
+router.put(
+ "/:id",
+ authenticateJWT,
+ requireDataAccess,
+ async (req: Request, res: Response) => {
+ const userId = (req as any).userId;
+ const { id } = req.params;
+ const updateData = req.body;
+
+ if (!isNonEmptyString(userId) || !id) {
+ authLogger.warn("Invalid request for snippet update");
+ return res.status(400).json({ error: "Invalid request" });
+ }
+
+ try {
+ const existing = await db
+ .select()
+ .from(snippets)
+ .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
+
+ if (existing.length === 0) {
+ return res.status(404).json({ error: "Snippet not found" });
+ }
+
+ const updateFields: any = {
+ updatedAt: sql`CURRENT_TIMESTAMP`,
+ };
+
+ if (updateData.name !== undefined)
+ updateFields.name = updateData.name.trim();
+ if (updateData.content !== undefined)
+ updateFields.content = updateData.content.trim();
+ if (updateData.description !== undefined)
+ updateFields.description = updateData.description?.trim() || null;
+
+ await db
+ .update(snippets)
+ .set(updateFields)
+ .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
+
+ const updated = await db
+ .select()
+ .from(snippets)
+ .where(eq(snippets.id, parseInt(id)));
+
+ authLogger.success(
+ `Snippet updated: ${updated[0].name} by user ${userId}`,
+ {
+ operation: "snippet_update_success",
+ userId,
+ snippetId: parseInt(id),
+ name: updated[0].name,
+ },
+ );
+
+ res.json(updated[0]);
+ } catch (err) {
+ authLogger.error("Failed to update snippet", err);
+ res.status(500).json({
+ error: err instanceof Error ? err.message : "Failed to update snippet",
+ });
+ }
+ },
+);
+
+// Delete a snippet
+// DELETE /snippets/:id
+router.delete(
+ "/:id",
+ authenticateJWT,
+ requireDataAccess,
+ async (req: Request, res: Response) => {
+ const userId = (req as any).userId;
+ const { id } = req.params;
+
+ if (!isNonEmptyString(userId) || !id) {
+ authLogger.warn("Invalid request for snippet delete");
+ return res.status(400).json({ error: "Invalid request" });
+ }
+
+ try {
+ const existing = await db
+ .select()
+ .from(snippets)
+ .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
+
+ if (existing.length === 0) {
+ return res.status(404).json({ error: "Snippet not found" });
+ }
+
+ await db
+ .delete(snippets)
+ .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
+
+ authLogger.success(
+ `Snippet deleted: ${existing[0].name} by user ${userId}`,
+ {
+ operation: "snippet_delete_success",
+ userId,
+ snippetId: parseInt(id),
+ name: existing[0].name,
+ },
+ );
+
+ res.json({ success: true });
+ } catch (err) {
+ authLogger.error("Failed to delete snippet", err);
+ res.status(500).json({
+ error: err instanceof Error ? err.message : "Failed to delete snippet",
+ });
+ }
+ },
+);
+
+export default router;
diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts
index 23f7191d..5c4ce328 100644
--- a/src/backend/database/routes/ssh.ts
+++ b/src/backend/database/routes/ssh.ts
@@ -150,7 +150,7 @@ router.get("/db/host/internal/all", async (req: Request, res: Response) => {
username: host.username,
password: host.autostartPassword || host.password,
key: host.autostartKey || host.key,
- keyPassword: host.autostartKeyPassword || host.keyPassword,
+ keyPassword: host.autostartKeyPassword || host.key_password,
autostartPassword: host.autostartPassword,
autostartKey: host.autostartKey,
autostartKeyPassword: host.autostartKeyPassword,
@@ -273,17 +273,17 @@ router.post(
if (effectiveAuthType === "password") {
sshDataObj.password = password || null;
sshDataObj.key = null;
- sshDataObj.keyPassword = null;
+ sshDataObj.key_password = null;
sshDataObj.keyType = null;
} else if (effectiveAuthType === "key") {
sshDataObj.key = key || null;
- sshDataObj.keyPassword = keyPassword || null;
+ sshDataObj.key_password = keyPassword || null;
sshDataObj.keyType = keyType;
sshDataObj.password = null;
} else {
sshDataObj.password = null;
sshDataObj.key = null;
- sshDataObj.keyPassword = null;
+ sshDataObj.key_password = null;
sshDataObj.keyType = null;
}
@@ -457,14 +457,14 @@ router.put(
sshDataObj.password = password;
}
sshDataObj.key = null;
- sshDataObj.keyPassword = null;
+ sshDataObj.key_password = null;
sshDataObj.keyType = null;
} else if (effectiveAuthType === "key") {
if (key) {
sshDataObj.key = key;
}
if (keyPassword !== undefined) {
- sshDataObj.keyPassword = keyPassword || null;
+ sshDataObj.key_password = keyPassword || null;
}
if (keyType) {
sshDataObj.keyType = keyType;
@@ -473,7 +473,7 @@ router.put(
} else {
sshDataObj.password = null;
sshDataObj.key = null;
- sshDataObj.keyPassword = null;
+ sshDataObj.key_password = null;
sshDataObj.keyType = null;
}
@@ -1238,7 +1238,22 @@ async function resolveHostCredentials(host: any): Promise {
};
}
}
- return host;
+
+ const result = { ...host };
+ if (host.key_password !== undefined) {
+ if (result.keyPassword === undefined) {
+ result.keyPassword = host.key_password;
+ }
+ delete result.key_password;
+ }
+ const result = { ...host };
+ if (host.key_password !== undefined) {
+ if (result.keyPassword === undefined) {
+ result.keyPassword = host.key_password;
+ }
+ delete result.key_password;
+ }
+ return result;
} catch (error) {
sshLogger.warn(
`Failed to resolve credentials for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
@@ -1403,8 +1418,8 @@ router.post(
credentialId:
hostData.authType === "credential" ? hostData.credentialId : null,
key: hostData.authType === "key" ? hostData.key : null,
- keyPassword:
- hostData.authType === "key" ? hostData.keyPassword : null,
+ key_password:
+ hostData.authType === "key" ? hostData.key_password : null,
keyType:
hostData.authType === "key" ? hostData.keyType || "auto" : null,
pin: hostData.pin || false,
@@ -1539,7 +1554,7 @@ router.post(
...tunnel,
endpointPassword: decryptedEndpoint.password || null,
endpointKey: decryptedEndpoint.key || null,
- endpointKeyPassword: decryptedEndpoint.keyPassword || null,
+ endpointKeyPassword: decryptedEndpoint.key_password || null,
endpointAuthType: endpointHost.authType,
};
}
@@ -1562,7 +1577,7 @@ router.post(
.set({
autostartPassword: decryptedConfig.password || null,
autostartKey: decryptedConfig.key || null,
- autostartKeyPassword: decryptedConfig.keyPassword || null,
+ autostartKeyPassword: decryptedConfig.key_password || null,
tunnelConnections: updatedTunnelConnections,
})
.where(eq(sshData.id, sshConfigId));
diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts
index c70676cc..92d9451a 100644
--- a/src/backend/database/routes/users.ts
+++ b/src/backend/database/routes/users.ts
@@ -833,6 +833,23 @@ router.post("/login", async (req, res) => {
return res.status(400).json({ error: "Invalid username or password" });
}
+ try {
+ const row = db.$client
+ .prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
+ .get();
+ if (row && (row as { value: string }).value !== "true") {
+ return res
+ .status(403)
+ .json({ error: "Password authentication is currently disabled" });
+ }
+ } catch (e) {
+ authLogger.error("Failed to check password login status", {
+ operation: "login_check",
+ error: e,
+ });
+ return res.status(500).json({ error: "Failed to check login status" });
+ }
+
try {
const user = await db
.select()
@@ -1083,6 +1100,43 @@ router.patch("/registration-allowed", authenticateJWT, async (req, res) => {
}
});
+// Route: Get password login allowed status (public - needed for login page)
+// GET /users/password-login-allowed
+router.get("/password-login-allowed", async (req, res) => {
+ try {
+ const row = db.$client
+ .prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
+ .get();
+ res.json({ allowed: row ? (row as { value: string }).value === "true" : true });
+ } catch (err) {
+ authLogger.error("Failed to get password login allowed", err);
+ res.status(500).json({ error: "Failed to get password login allowed" });
+ }
+});
+
+// Route: Set password login allowed status (admin only)
+// PATCH /users/password-login-allowed
+router.patch("/password-login-allowed", authenticateJWT, async (req, res) => {
+ const userId = (req as any).userId;
+ try {
+ const user = await db.select().from(users).where(eq(users.id, userId));
+ if (!user || user.length === 0 || !user[0].is_admin) {
+ return res.status(403).json({ error: "Not authorized" });
+ }
+ const { allowed } = req.body;
+ if (typeof allowed !== "boolean") {
+ return res.status(400).json({ error: "Invalid value for allowed" });
+ }
+ db.$client
+ .prepare("UPDATE settings SET value = ? WHERE key = 'allow_password_login'")
+ .run(allowed ? "true" : "false");
+ res.json({ allowed });
+ } catch (err) {
+ authLogger.error("Failed to set password login allowed", err);
+ res.status(500).json({ error: "Failed to set password login allowed" });
+ }
+});
+
// Route: Delete user account
// DELETE /users/delete-account
router.delete("/delete-account", authenticateJWT, async (req, res) => {
diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts
index bdd2892a..4cc4642d 100644
--- a/src/backend/ssh/file-manager.ts
+++ b/src/backend/ssh/file-manager.ts
@@ -94,7 +94,16 @@ interface SSHSession {
timeout?: NodeJS.Timeout;
}
+interface PendingTOTPSession {
+ client: SSHClient;
+ finish: (responses: string[]) => void;
+ config: import("ssh2").ConnectConfig;
+ createdAt: number;
+ sessionId: string;
+}
+
const sshSessions: Record = {};
+const pendingTOTPSessions: Record = {};
function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
@@ -241,6 +250,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
host: ip,
port: port || 22,
username,
+ tryKeyboard: true,
readyTimeout: 60000,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
@@ -366,9 +376,142 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
cleanupSession(sessionId);
});
+ client.on(
+ "keyboard-interactive",
+ (
+ name: string,
+ instructions: string,
+ instructionsLang: string,
+ prompts: Array<{ prompt: string; echo: boolean }>,
+ finish: (responses: string[]) => void,
+ ) => {
+ fileLogger.info("Keyboard-interactive authentication requested", {
+ operation: "file_keyboard_interactive",
+ hostId,
+ sessionId,
+ promptsCount: prompts.length,
+ });
+
+ const totpPrompt = prompts.find((p) =>
+ /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
+ p.prompt,
+ ),
+ );
+
+ if (totpPrompt) {
+ if (responseSent) return;
+ responseSent = true;
+
+ pendingTOTPSessions[sessionId] = {
+ client,
+ finish,
+ config,
+ createdAt: Date.now(),
+ sessionId,
+ };
+
+ res.json({
+ requires_totp: true,
+ sessionId,
+ prompt: totpPrompt.prompt,
+ });
+ } else {
+ if (resolvedCredentials.password) {
+ const responses = prompts.map(() => resolvedCredentials.password || "");
+ finish(responses);
+ } else {
+ finish(prompts.map(() => ""));
+ }
+ }
+ },
+ );
+
client.connect(config);
});
+app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
+ const { sessionId, totpCode } = req.body;
+
+ const userId = (req as any).userId;
+
+ if (!userId) {
+ fileLogger.error("TOTP verification rejected: no authenticated user", {
+ operation: "file_totp_auth",
+ sessionId,
+ });
+ return res.status(401).json({ error: "Authentication required" });
+ }
+
+ if (!sessionId || !totpCode) {
+ return res.status(400).json({ error: "Session ID and TOTP code required" });
+ }
+
+ const session = pendingTOTPSessions[sessionId];
+
+ if (!session) {
+ fileLogger.warn("TOTP session not found or expired", {
+ operation: "file_totp_verify",
+ sessionId,
+ userId,
+ });
+ return res.status(404).json({ error: "TOTP session expired. Please reconnect." });
+ }
+
+ delete pendingTOTPSessions[sessionId];
+
+ if (Date.now() - session.createdAt > 120000) {
+ try {
+ session.client.end();
+ } catch {}
+ return res.status(408).json({ error: "TOTP session timeout. Please reconnect." });
+ }
+
+ session.finish([totpCode]);
+
+ let responseSent = false;
+
+ session.client.on("ready", () => {
+ if (responseSent) return;
+ responseSent = true;
+
+ sshSessions[sessionId] = {
+ client: session.client,
+ isConnected: true,
+ lastActive: Date.now(),
+ };
+ scheduleSessionCleanup(sessionId);
+
+ fileLogger.success("TOTP verification successful", {
+ operation: "file_totp_verify",
+ sessionId,
+ userId,
+ });
+
+ res.json({ status: "success", message: "TOTP verified, SSH connection established" });
+ });
+
+ session.client.on("error", (err) => {
+ if (responseSent) return;
+ responseSent = true;
+
+ fileLogger.error("TOTP verification failed", {
+ operation: "file_totp_verify",
+ sessionId,
+ userId,
+ error: err.message,
+ });
+
+ res.status(401).json({ status: "error", message: "Invalid TOTP code" });
+ });
+
+ setTimeout(() => {
+ if (!responseSent) {
+ responseSent = true;
+ res.status(408).json({ error: "TOTP verification timeout" });
+ }
+ }, 60000);
+});
+
app.post("/ssh/file_manager/ssh/disconnect", (req, res) => {
const { sessionId } = req.body;
cleanupSession(sessionId);
diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts
index 73911848..da0897ff 100644
--- a/src/backend/ssh/server-stats.ts
+++ b/src/backend/ssh/server-stats.ts
@@ -95,6 +95,40 @@ class SSHConnectionPool {
reject(err);
});
+ client.on(
+ "keyboard-interactive",
+ (
+ name: string,
+ instructions: string,
+ instructionsLang: string,
+ prompts: Array<{ prompt: string; echo: boolean }>,
+ finish: (responses: string[]) => void,
+ ) => {
+ const totpPrompt = prompts.find((p) =>
+ /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
+ p.prompt,
+ ),
+ );
+
+ if (totpPrompt) {
+ statsLogger.warn(
+ `Server Stats cannot handle TOTP for host ${host.ip}. Connection will fail.`,
+ {
+ operation: "server_stats_totp_detected",
+ hostId: host.id,
+ },
+ );
+ client.end();
+ reject(new Error("TOTP authentication required but not supported in Server Stats"));
+ } else if (host.password) {
+ const responses = prompts.map(() => host.password || "");
+ finish(responses);
+ } else {
+ finish(prompts.map(() => ""));
+ }
+ },
+ );
+
try {
client.connect(buildSshConfig(host));
} catch (err) {
@@ -484,7 +518,7 @@ async function resolveHostCredentials(
function addLegacyCredentials(baseHost: any, host: any): void {
baseHost.password = host.password || null;
baseHost.key = host.key || null;
- baseHost.keyPassword = host.keyPassword || null;
+ baseHost.keyPassword = host.key_password || host.keyPassword || null;
baseHost.keyType = host.keyType;
}
@@ -493,6 +527,7 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
host: host.ip,
port: host.port || 22,
username: host.username || "root",
+ tryKeyboard: true,
readyTimeout: 10_000,
algorithms: {
kex: [
@@ -657,7 +692,8 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
}
return requestQueue.queueRequest(host.id, async () => {
- return withSshConnection(host, async (client) => {
+ try {
+ return await withSshConnection(host, async (client) => {
let cpuPercent: number | null = null;
let cores: number | null = null;
let loadTriplet: [number, number, number] | null = null;
@@ -818,6 +854,12 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
metricsCache.set(host.id, result);
return result;
});
+ } catch (error) {
+ if (error instanceof Error && error.message.includes("TOTP authentication required")) {
+ throw error;
+ }
+ throw error;
+ }
});
}
@@ -990,6 +1032,17 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
const metrics = await collectMetrics(host);
res.json({ ...metrics, lastChecked: new Date().toISOString() });
} catch (err) {
+ if (err instanceof Error && err.message.includes("TOTP authentication required")) {
+ return res.status(403).json({
+ error: "TOTP_REQUIRED",
+ message: "Server Stats unavailable for TOTP-enabled servers",
+ cpu: { percent: null, cores: null, load: null },
+ memory: { percent: null, usedGiB: null, totalGiB: null },
+ disk: { percent: null, usedHuman: null, totalHuman: null },
+ lastChecked: new Date().toISOString(),
+ });
+ }
+
statsLogger.error("Failed to collect metrics", err);
if (err instanceof Error && err.message.includes("timeout")) {
diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts
index 94650bd6..c60eb0ce 100644
--- a/src/backend/ssh/terminal.ts
+++ b/src/backend/ssh/terminal.ts
@@ -154,6 +154,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
let sshConn: Client | null = null;
let sshStream: ClientChannel | null = null;
let pingInterval: NodeJS.Timeout | null = null;
+ let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
ws.on("close", () => {
const userWs = userConnections.get(userId);
@@ -257,6 +258,33 @@ wss.on("connection", async (ws: WebSocket, req) => {
ws.send(JSON.stringify({ type: "pong" }));
break;
+ case "totp_response":
+ if (keyboardInteractiveFinish && data?.code) {
+ const totpCode = data.code;
+ sshLogger.info("TOTP code received from user", {
+ operation: "totp_response",
+ userId,
+ codeLength: totpCode.length,
+ });
+
+ keyboardInteractiveFinish([totpCode]);
+ keyboardInteractiveFinish = null;
+ } else {
+ sshLogger.warn("TOTP response received but no callback available", {
+ operation: "totp_response_error",
+ userId,
+ hasCallback: !!keyboardInteractiveFinish,
+ hasCode: !!data?.code,
+ });
+ ws.send(
+ JSON.stringify({
+ type: "error",
+ message: "TOTP authentication state lost. Please reconnect.",
+ }),
+ );
+ }
+ break;
+
default:
sshLogger.warn("Unknown message type received", {
operation: "websocket_message_unknown_type",
@@ -557,10 +585,56 @@ wss.on("connection", async (ws: WebSocket, req) => {
cleanupSSH(connectionTimeout);
});
+ sshConn.on(
+ "keyboard-interactive",
+ (
+ name: string,
+ instructions: string,
+ instructionsLang: string,
+ prompts: Array<{ prompt: string; echo: boolean }>,
+ finish: (responses: string[]) => void,
+ ) => {
+ sshLogger.info("Keyboard-interactive authentication requested", {
+ operation: "ssh_keyboard_interactive",
+ hostId: id,
+ promptsCount: prompts.length,
+ instructions: instructions || "none",
+ });
+
+ const totpPrompt = prompts.find((p) =>
+ /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
+ p.prompt,
+ ),
+ );
+
+ if (totpPrompt) {
+ keyboardInteractiveFinish = finish;
+ ws.send(
+ JSON.stringify({
+ type: "totp_required",
+ prompt: totpPrompt.prompt,
+ }),
+ );
+ } else {
+ if (resolvedCredentials.password) {
+ const responses = prompts.map(() => resolvedCredentials.password || "");
+ finish(responses);
+ } else {
+ sshLogger.warn("Keyboard-interactive requires password but none available", {
+ operation: "ssh_keyboard_interactive_no_password",
+ hostId: id,
+ });
+ finish(prompts.map(() => ""));
+ }
+ }
+ },
+ );
+
const connectConfig: any = {
host: ip,
port,
username,
+ tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 60000,
diff --git a/src/backend/utils/field-crypto.ts b/src/backend/utils/field-crypto.ts
index a23dc086..78221220 100644
--- a/src/backend/utils/field-crypto.ts
+++ b/src/backend/utils/field-crypto.ts
@@ -17,13 +17,9 @@ class FieldCrypto {
private static readonly ENCRYPTED_FIELDS = {
users: new Set([
"password_hash",
- "passwordHash",
"client_secret",
- "clientSecret",
"totp_secret",
- "totpSecret",
"totp_backup_codes",
- "totpBackupCodes",
"oidc_identifier",
"oidcIdentifier",
]),
@@ -31,12 +27,9 @@ class FieldCrypto {
ssh_credentials: new Set([
"password",
"private_key",
- "privateKey",
"key_password",
- "keyPassword",
"key",
"public_key",
- "publicKey",
]),
};
diff --git a/src/backend/utils/lazy-field-encryption.ts b/src/backend/utils/lazy-field-encryption.ts
index 5b376c4f..98f7aa44 100644
--- a/src/backend/utils/lazy-field-encryption.ts
+++ b/src/backend/utils/lazy-field-encryption.ts
@@ -6,10 +6,20 @@ export class LazyFieldEncryption {
key_password: "keyPassword",
private_key: "privateKey",
public_key: "publicKey",
- // Reverse mappings for Drizzle ORM (camelCase -> snake_case)
+ password_hash: "passwordHash",
+ client_secret: "clientSecret",
+ totp_secret: "totpSecret",
+ totp_backup_codes: "totpBackupCodes",
+ oidc_identifier: "oidcIdentifier",
+
keyPassword: "key_password",
privateKey: "private_key",
publicKey: "public_key",
+ passwordHash: "password_hash",
+ clientSecret: "client_secret",
+ totpSecret: "totp_secret",
+ totpBackupCodes: "totp_backup_codes",
+ oidcIdentifier: "oidc_identifier",
};
static isPlaintextField(value: string): boolean {
diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts
index 7caed3a3..e06a7467 100644
--- a/src/backend/utils/user-crypto.ts
+++ b/src/backend/utils/user-crypto.ts
@@ -70,7 +70,36 @@ class UserCrypto {
}
async setupOIDCUserEncryption(userId: string): Promise {
- const DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
+ const existingEncryptedDEK = await this.getEncryptedDEK(userId);
+
+ let DEK: Buffer;
+
+ if (existingEncryptedDEK) {
+ const systemKey = this.deriveOIDCSystemKey(userId);
+ DEK = this.decryptDEK(existingEncryptedDEK, systemKey);
+ systemKey.fill(0);
+ } else {
+ DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
+ const systemKey = this.deriveOIDCSystemKey(userId);
+
+ try {
+ const encryptedDEK = this.encryptDEK(DEK, systemKey);
+ await this.storeEncryptedDEK(userId, encryptedDEK);
+
+ const storedEncryptedDEK = await this.getEncryptedDEK(userId);
+ if (
+ storedEncryptedDEK &&
+ storedEncryptedDEK.data !== encryptedDEK.data
+ ) {
+ DEK.fill(0);
+ DEK = this.decryptDEK(storedEncryptedDEK, systemKey);
+ } else if (!storedEncryptedDEK) {
+ throw new Error("Failed to store and retrieve user encryption key.");
+ }
+ } finally {
+ systemKey.fill(0);
+ }
+ }
const now = Date.now();
this.userSessions.set(userId, {
@@ -134,20 +163,14 @@ class UserCrypto {
async authenticateOIDCUser(userId: string): Promise {
try {
- const kekSalt = await this.getKEKSalt(userId);
- if (!kekSalt) {
+ const encryptedDEK = await this.getEncryptedDEK(userId);
+
+ if (!encryptedDEK) {
await this.setupOIDCUserEncryption(userId);
return true;
}
const systemKey = this.deriveOIDCSystemKey(userId);
- const encryptedDEK = await this.getEncryptedDEK(userId);
- if (!encryptedDEK) {
- systemKey.fill(0);
- await this.setupOIDCUserEncryption(userId);
- return true;
- }
-
const DEK = this.decryptDEK(encryptedDEK, systemKey);
systemKey.fill(0);
diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts
index 884d4e43..cdd414e3 100644
--- a/src/i18n/i18n.ts
+++ b/src/i18n/i18n.ts
@@ -4,12 +4,13 @@ import LanguageDetector from "i18next-browser-languagedetector";
import enTranslation from "../locales/en/translation.json";
import zhTranslation from "../locales/zh/translation.json";
+import deTranslation from "../locales/de/translation.json";
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
- supportedLngs: ["en", "zh"],
+ supportedLngs: ["en", "zh", "de"],
fallbackLng: "en",
debug: false,
@@ -28,6 +29,9 @@ i18n
zh: {
translation: zhTranslation,
},
+ de: {
+ translation: deTranslation,
+ },
},
interpolation: {
diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json
new file mode 100644
index 00000000..be2ed47a
--- /dev/null
+++ b/src/locales/de/translation.json
@@ -0,0 +1,1385 @@
+{
+ "credentials": {
+ "credentialsViewer": "Anmeldeinformationsanzeige",
+ "manageYourSSHCredentials": "Sichere Verwaltung Ihrer SSH-Anmeldedaten",
+ "addCredential": "Anmeldeinformationen hinzufügen",
+ "createCredential": "Anmeldeinformationen erstellen",
+ "editCredential": "Anmeldeinformationen bearbeiten",
+ "viewCredential": "Anmeldeinformationen anzeigen",
+ "duplicateCredential": "Doppelte Anmeldeinformationen",
+ "deleteCredential": "Anmeldeinformationen löschen",
+ "updateCredential": "Anmeldedaten aktualisieren",
+ "credentialName": "Name der Anmeldeinformationen",
+ "credentialDescription": "Beschreibung",
+ "username": "Benutzername",
+ "searchCredentials": "Anmeldeinformationen suchen...",
+ "selectFolder": "Ordner auswählen",
+ "selectAuthType": "Authentifizierungstyp auswählen",
+ "allFolders": "Alle Ordner",
+ "allAuthTypes": "Alle Authentifizierungstypen",
+ "uncategorized": "Unkategorisiert",
+ "totalCredentials": "Gesamt",
+ "keyBased": "Schlüsselbasiert",
+ "passwordBased": "Passwortbasiert",
+ "folders": "Ordner",
+ "noCredentialsMatchFilters": "Keine Anmeldeinformationen stimmen mit Ihren Filtern überein",
+ "noCredentialsYet": "Noch keine Anmeldeinformationen erstellt",
+ "createFirstCredential": "Erstellen Sie Ihre ersten Anmeldeinformationen",
+ "failedToFetchCredentials": "Anmeldeinformationen konnten nicht abgerufen werden",
+ "credentialDeletedSuccessfully": "Anmeldeinformationen erfolgreich gelöscht",
+ "failedToDeleteCredential": "Fehler beim Löschen der Anmeldeinformationen",
+ "confirmDeleteCredential": "Möchten Sie die Anmeldeinformationen „ {{name}} “ wirklich löschen?",
+ "credentialCreatedSuccessfully": "Anmeldeinformationen erfolgreich erstellt",
+ "credentialUpdatedSuccessfully": "Anmeldeinformationen erfolgreich aktualisiert",
+ "failedToSaveCredential": "Fehler beim Speichern der Anmeldeinformationen",
+ "failedToFetchCredentialDetails": "Fehler beim Abrufen der Anmeldeinformationen",
+ "failedToFetchHostsUsing": "Fehler beim Abrufen von Hosts mit diesen Anmeldeinformationen",
+ "loadingCredentials": "Anmeldeinformationen werden geladen...",
+ "retry": "Wiederholen",
+ "noCredentials": "Keine Anmeldeinformationen",
+ "noCredentialsMessage": "Sie haben noch keine Anmeldeinformationen hinzugefügt. Klicken Sie auf „Anmeldeinformationen hinzufügen“, um zu beginnen.",
+ "sshCredentials": "SSH-Anmeldeinformationen",
+ "credentialsCount": "{{count}} Anmeldeinformationen",
+ "refresh": "Aktualisieren",
+ "passwordRequired": "Passwort erforderlich",
+ "sshKeyRequired": "SSH-Schlüssel ist erforderlich",
+ "credentialAddedSuccessfully": "Anmeldeinformationen „ {{name}} “ erfolgreich hinzugefügt",
+ "general": "Allgemein",
+ "description": "Beschreibung",
+ "folder": "Ordner",
+ "tags": "Schlagwörter",
+ "addTagsSpaceToAdd": "Schlagwörter hinzufügen (zum Hinzufügen die Leertaste drücken)",
+ "password": "Passwort",
+ "key": "Schlüssel",
+ "sshPrivateKey": "Privater SSH-Schlüssel",
+ "upload": "Hochladen",
+ "updateKey": "Schlüssel aktualisieren",
+ "keyPassword": "Schlüsselkennwort (optional)",
+ "keyType": "Schlüsseltyp",
+ "keyTypeRSA": "RSA",
+ "keyTypeECDSA": "ECDSA",
+ "keyTypeEd25519": "Ed25519",
+ "basicInfo": "Basisinformation",
+ "authentication": "Authentifizierung",
+ "organization": "Organisation",
+ "basicInformation": "Grundlegende Informationen",
+ "basicInformationDescription": "Geben Sie die grundlegenden Informationen für diese Anmeldeinformationen ein",
+ "authenticationMethod": "Authentifizierungsmethode",
+ "authenticationMethodDescription": "Wählen Sie aus, wie Sie sich bei SSH-Servern authentifizieren möchten",
+ "organizationDescription": "Organisieren Sie Ihre Anmeldeinformationen mit Ordnern und Tags",
+ "enterCredentialName": "Geben Sie den Namen der Anmeldeinformationen ein",
+ "enterCredentialDescription": "Beschreibung eingeben (optional)",
+ "enterUsername": "Benutzernamen eingeben",
+ "nameIsRequired": "Der Name der Anmeldeinformationen ist erforderlich",
+ "usernameIsRequired": "Benutzername ist erforderlich",
+ "authenticationType": "Authentifizierungstyp",
+ "passwordAuthDescription": "Passwort-Authentifizierung verwenden",
+ "sshKeyAuthDescription": "SSH-Schlüssel-Authentifizierung verwenden",
+ "passwordIsRequired": "Passwort erforderlich",
+ "sshKeyIsRequired": "SSH-Schlüssel ist erforderlich",
+ "sshKeyType": "SSH-Schlüsseltyp",
+ "privateKey": "Privater Schlüssel",
+ "enterPassword": "Passwort eingeben",
+ "enterPrivateKey": "Geben Sie den privaten Schlüssel ein",
+ "keyPassphrase": "Schlüssel-Passphrase",
+ "enterKeyPassphrase": "Schlüssel-Passphrase eingeben (optional)",
+ "keyPassphraseOptional": "Optional: Leer lassen, wenn Ihr Schlüssel keine Passphrase hat",
+ "leaveEmptyToKeepCurrent": "Leer lassen, um den aktuellen Wert beizubehalten",
+ "uploadKeyFile": "Schlüsseldatei hochladen",
+ "generateKeyPairButton": "Schlüsselpaar generieren",
+ "generateKeyPair": "Schlüsselpaar generieren",
+ "generateKeyPairDescription": "Generieren Sie ein neues SSH-Schlüsselpaar. Wenn Sie den Schlüssel mit einer Passphrase schützen möchten, geben Sie diese zunächst in das Feld Schlüsselkennwort unten ein.",
+ "deploySSHKey": "SSH-Schlüssel bereitstellen",
+ "deploySSHKeyDescription": "Bereitstellen des öffentlichen Schlüssels auf dem Zielserver",
+ "sourceCredential": "Quellanmeldeinformationen",
+ "targetHost": "Ziel-Host",
+ "deploymentProcess": "Bereitstellungsprozess",
+ "deploymentProcessDescription": "Dadurch wird der öffentliche Schlüssel sicher zur Datei ~\/.ssh\/authorized_keys des Zielhosts hinzugefügt, ohne vorhandene Schlüssel zu überschreiben. Der Vorgang ist umkehrbar.",
+ "chooseHostToDeploy": "Wählen Sie einen Host für die Bereitstellung aus ...",
+ "deploying": "Bereitstellen...",
+ "name": "Name",
+ "noHostsAvailable": "Keine Hosts verfügbar",
+ "noHostsMatchSearch": "Kein Host entspricht Ihrer Suche",
+ "sshKeyGenerationNotImplemented": "Funktion zur SSH-Schlüsselgenerierung in Kürze verfügbar",
+ "connectionTestingNotImplemented": "Die Funktion zum Testen der Verbindung ist in Kürze verfügbar",
+ "testConnection": "Verbindung testen",
+ "selectOrCreateFolder": "Ordner auswählen oder erstellen",
+ "noFolder": "Kein Ordner",
+ "orCreateNewFolder": "Oder erstellen Sie einen neuen Ordner",
+ "addTag": "Schlüsselwort hinzufügen",
+ "saving": "Speichern...",
+ "overview": "Überblick",
+ "security": "Sicherheit",
+ "usage": "Verwendung",
+ "securityDetails": "Sicherheitsdetails",
+ "securityDetailsDescription": "Anzeigen verschlüsselter Anmeldeinformationen",
+ "credentialSecured": "Anmeldeinformationen gesichert",
+ "credentialSecuredDescription": "Alle sensiblen Daten werden mit AES-256 verschlüsselt",
+ "passwordAuthentication": "Kennwortauthentifizierung",
+ "keyAuthentication": "Schlüsselauthentifizierung",
+ "securityReminder": "Sicherheitshinweis",
+ "securityReminderText": "Geben Sie niemals Ihre Anmeldeinformationen weiter. Alle Daten werden im Ruhezustand verschlüsselt.",
+ "hostsUsingCredential": "Hosts, die diese Anmeldeinformationen verwenden",
+ "noHostsUsingCredential": "Derzeit verwenden keine Hosts diese Anmeldeinformationen",
+ "timesUsed": "Anzahl Verwendungen",
+ "lastUsed": "Zuletzt verwendet",
+ "connectedHosts": "Verbundene Hosts",
+ "created": "Erstellt",
+ "lastModified": "Zuletzt geändert",
+ "usageStatistics": "Nutzungsstatistiken",
+ "copiedToClipboard": "{{field}} in die Zwischenablage kopiert",
+ "failedToCopy": "Kopieren in die Zwischenablage fehlgeschlagen",
+ "sshKey": "SSH-Schlüssel",
+ "createCredentialDescription": "Erstellen Sie neue SSH-Anmeldeinformationen für den sicheren Zugriff",
+ "editCredentialDescription": "Aktualisieren Sie die Anmeldeinformationen",
+ "listView": "Liste",
+ "folderView": "Ordner",
+ "unknownCredential": "Unbekannt",
+ "confirmRemoveFromFolder": "Sind Sie sicher, dass Sie \"{{name}}\" aus Ordner \"{{folder}}\" entfernen möchten? Die Zugangsdaten werden in den Bereich \"Nicht kategorisiert\" verschoben.",
+ "removedFromFolder": "Anmeldeinformationen „ {{name}} “ erfolgreich aus dem Ordner entfernt",
+ "failedToRemoveFromFolder": "Anmeldeinformationen konnten nicht aus dem Ordner entfernt werden",
+ "folderRenamed": "Ordner „ {{oldName}} “ erfolgreich in „ {{newName}} “ umbenannt",
+ "failedToRenameFolder": "Ordner konnte nicht umbenannt werden",
+ "movedToFolder": "Anmeldeinformationen „ {{name}} “ wurden erfolgreich nach „ {{folder}} “ verschoben.",
+ "failedToMoveToFolder": "Anmeldeinformationen konnten nicht in den Ordner verschoben werden",
+ "sshPublicKey": "Öffentlicher SSH-Schlüssel",
+ "publicKeyNote": "Der öffentliche Schlüssel ist optional, wird jedoch zur Schlüsselvalidierung empfohlen",
+ "publicKeyUploaded": "Öffentlicher Schlüssel hochgeladen",
+ "uploadPublicKey": "Öffentlichen Schlüssel hochladen",
+ "uploadPrivateKeyFile": "Private Schlüsseldatei hochladen",
+ "uploadPublicKeyFile": "Öffentliche Schlüsseldatei hochladen",
+ "privateKeyRequiredForGeneration": "Zum Generieren des öffentlichen Schlüssels ist ein privater Schlüssel erforderlich",
+ "failedToGeneratePublicKey": "Öffentlicher Schlüssel konnte nicht generiert werden",
+ "generatePublicKey": "Aus privatem Schlüssel generieren",
+ "publicKeyGeneratedSuccessfully": "Öffentlicher Schlüssel erfolgreich generiert",
+ "detectedKeyType": "Erkannter Schlüsseltyp",
+ "detectingKeyType": "erkennen...",
+ "optional": "Optional",
+ "generateKeyPairNew": "Neues Schlüsselpaar generieren",
+ "generateEd25519": "Ed25519 generieren",
+ "generateECDSA": "ECDSA generieren",
+ "generateRSA": "RSA generieren",
+ "keyPairGeneratedSuccessfully": "{{keyType}} Schlüsselpaar erfolgreich generiert",
+ "failedToGenerateKeyPair": "Das Generieren des Schlüsselpaars ist fehlgeschlagen.",
+ "generateKeyPairNote": "Generieren Sie direkt ein neues SSH-Schlüsselpaar. Dadurch werden alle vorhandenen Schlüssel im Formular ersetzt.",
+ "invalidKey": "Ungültiger Schlüssel",
+ "detectionError": "Erkennungsfehler",
+ "unknown": "Unbekannt"
+ },
+ "dragIndicator": {
+ "error": "Fehler: {{error}}",
+ "dragging": "Ziehen von {{fileName}}",
+ "preparing": "{{fileName}} wird vorbereitet",
+ "readySingle": "Bereit zum Download {{fileName}}",
+ "readyMultiple": "Bereit zum Herunterladen von {{count}} Dateien",
+ "batchDrag": "Ziehen Sie {{count}} Dateien auf den Desktop",
+ "dragToDesktop": "Auf den Desktop ziehen",
+ "canDragAnywhere": "Sie können Dateien an eine beliebige Stelle auf Ihrem Desktop ziehen"
+ },
+ "sshTools": {
+ "title": "SSH-Tools",
+ "closeTools": "SSH-Tools schließen",
+ "keyRecording": "Tastenaufzeichnung",
+ "startKeyRecording": "Tastenaufzeichnung starten",
+ "stopKeyRecording": "Tastenerfassung stoppen",
+ "selectTerminals": "Terminals auswählen:",
+ "typeCommands": "Geben Sie Befehle ein (alle Tasten werden unterstützt):",
+ "commandsWillBeSent": "Befehle werden an {{count}} ausgewählte Terminals gesendet.",
+ "settings": "Einstellungen",
+ "enableRightClickCopyPaste": "Rechtsklick-Kopieren\/Einfügen aktivieren",
+ "shareIdeas": "Haben Sie Ideen, was als nächstes für SSH-Tools kommen sollte? Teilen Sie diese mit uns"
+ },
+ "homepage": {
+ "loggedInTitle": "Eingeloggt!",
+ "loggedInMessage": "Sie sind angemeldet! Über die Seitenleiste haben Sie Zugriff auf alle verfügbaren Tools. Erstellen Sie zunächst einen SSH-Host im Tab „SSH-Manager“. Anschließend können Sie sich über die anderen Apps in der Seitenleiste mit diesem Host verbinden.",
+ "failedToLoadAlerts": "Warnmeldungen konnten nicht geladen werden",
+ "failedToDismissAlert": "Benachrichtigung konnte nicht geschlossen werden"
+ },
+ "serverConfig": {
+ "title": "Serverkonfiguration",
+ "description": "Konfigurieren Sie die Termix-Server-URL, um eine Verbindung zu Ihren Backend-Diensten herzustellen",
+ "serverUrl": "Server-URL",
+ "enterServerUrl": "Bitte geben Sie eine Server-URL ein",
+ "testConnectionFirst": "Bitte testen Sie zuerst die Verbindung",
+ "connectionSuccess": "Verbindung erfolgreich!",
+ "connectionFailed": "Verbindung fehlgeschlagen",
+ "connectionError": "Verbindungsfehler aufgetreten",
+ "connected": "Verbunden",
+ "disconnected": "Getrennt",
+ "configSaved": "Konfiguration erfolgreich gespeichert",
+ "saveFailed": "Konfiguration konnte nicht gespeichert werden",
+ "saveError": "Fehler beim Speichern der Konfiguration",
+ "saving": "Speichern...",
+ "saveConfig": "Konfiguration speichern",
+ "helpText": "Geben Sie die URL ein, unter der Ihr Termix-Server ausgeführt wird (z. B. http:\/\/localhost:30001 oder https:\/\/your-server.com)."
+ },
+ "versionCheck": {
+ "error": "Fehler bei der Versionsprüfung",
+ "checkFailed": "Suche nach Updates fehlgeschlagen",
+ "upToDate": "App ist auf dem neuesten Stand",
+ "currentVersion": "Sie verwenden Version {{version}}",
+ "updateAvailable": "Update verfügbar",
+ "newVersionAvailable": "Eine neue Version ist verfügbar! Sie verwenden {{current}}, aber {{latest}} ist verfügbar.",
+ "releasedOn": "Veröffentlicht am {{date}}",
+ "downloadUpdate": "Update herunterladen",
+ "dismiss": "Schließen",
+ "checking": "Suche nach Updates...",
+ "checkUpdates": "Nach Updates suchen",
+ "checkingUpdates": "Suche nach Updates...",
+ "refresh": "Aktualisieren",
+ "updateRequired": "Aktualisierung erforderlich",
+ "updateDismissed": "Update-Benachrichtigung abgelehnt",
+ "noUpdatesFound": "Keine Updates gefunden"
+ },
+ "common": {
+ "close": "Schließen",
+ "minimize": "Minimieren",
+ "online": "Online",
+ "offline": "Offline",
+ "continue": "Fortsetzen",
+ "maintenance": "Wartung",
+ "degraded": "Herabgestuft",
+ "discord": "Discord",
+ "error": "Fehler",
+ "warning": "Warnung",
+ "info": "Info",
+ "success": "Erfolgreich",
+ "loading": "Laden...",
+ "required": "Erforderlich",
+ "optional": "Optional",
+ "clear": "Löschen",
+ "toggleSidebar": "Seitenleiste ein-\/ausblenden",
+ "sidebar": "Seitenleiste",
+ "home": "Startseite",
+ "expired": "Abgelaufen",
+ "expiresToday": "Läuft heute ab",
+ "expiresTomorrow": "Läuft morgen ab",
+ "expiresInDays": "Läuft in {{days}} Tagen ab",
+ "updateAvailable": "Update verfügbar",
+ "sshPath": "SSH-Pfad",
+ "localPath": "Lokaler Pfad",
+ "noAuthCredentials": "Für diesen SSH-Host sind keine Anmeldeinformationen verfügbar",
+ "noReleases": "Keine Releases",
+ "updatesAndReleases": "Updates & Veröffentlichungen",
+ "newVersionAvailable": "Eine neue Version ({{version}}) ist verfügbar.",
+ "failedToFetchUpdateInfo": "Abrufen der Aktualisierungsinformationen fehlgeschlagen",
+ "preRelease": "Vorabversion",
+ "loginFailed": "Anmeldung fehlgeschlagen",
+ "noReleasesFound": "Keine Releases gefunden.",
+ "yourBackupCodes": "Ihre Backup-Codes",
+ "sendResetCode": "Reset-Code senden",
+ "verifyCode": "Code bestätigen",
+ "resetPassword": "Passwort zurücksetzen",
+ "resetCode": "Code zurücksetzen",
+ "newPassword": "Neues Passwort",
+ "folder": "Ordner",
+ "file": "Datei",
+ "renamedSuccessfully": "erfolgreich umbenannt",
+ "deletedSuccessfully": "Erfolgreich gelöscht",
+ "noTunnelConnections": "Keine Tunnelverbindungen konfiguriert",
+ "sshTools": "SSH-Tools",
+ "english": "Englisch",
+ "chinese": "Chinesisch",
+ "german": "Deutsch",
+ "cancel": "Abbrechen",
+ "username": "Benutzername",
+ "name": "Name",
+ "login": "Anmelden",
+ "logout": "Ausloggen",
+ "register": "Registrieren",
+ "password": "Passwort",
+ "version": "Version",
+ "confirmPassword": "Passwort bestätigen",
+ "back": "Zurück",
+ "email": "E-Mail",
+ "submit": "Senden",
+ "change": "Ändern",
+ "save": "Speichern",
+ "delete": "Löschen",
+ "edit": "Bearbeiten",
+ "add": "Hinzufügen",
+ "search": "Suchen",
+ "confirm": "Bestätigen",
+ "yes": "Ja",
+ "no": "Nein",
+ "ok": "OK",
+ "enabled": "Aktiviert",
+ "disabled": "Deaktiviert",
+ "important": "Wichtig",
+ "notEnabled": "Nicht aktiviert",
+ "settingUp": "Einrichten...",
+ "next": "Weiter",
+ "previous": "Vorherige",
+ "refresh": "Aktualisieren",
+ "settings": "Einstellungen",
+ "profile": "Profil",
+ "help": "Hilfe",
+ "about": "Über",
+ "language": "Sprache",
+ "autoDetect": "Automatische Erkennung",
+ "changeAccountPassword": "Passwort für Ihr Konto ändern",
+ "enterSixDigitCode": "Geben Sie den 6-stelligen Code aus den Docker-Container-Protokollen \/ logs für den Benutzer ein:",
+ "enterNewPassword": "Geben Sie Ihr neues Passwort für den Benutzer ein:",
+ "passwordsDoNotMatch": "Passwörter stimmen nicht überein",
+ "passwordMinLength": "Das Passwort muss mindestens 6 Zeichen lang sein",
+ "passwordResetSuccess": "Passwort erfolgreich zurückgesetzt! Sie können sich jetzt mit Ihrem neuen Passwort anmelden.",
+ "failedToInitiatePasswordReset": "Das Zurücksetzen des Kennworts konnte nicht eingeleitet werden.",
+ "failedToVerifyResetCode": "Reset-Code konnte nicht verifiziert werden",
+ "failedToCompletePasswordReset": "Das Zurücksetzen des Kennworts konnte nicht abgeschlossen werden.",
+ "documentation": "Dokumentation",
+ "retry": "Wiederholen",
+ "checking": "Prüfen...",
+ "checkingDatabase": "Prüfen der Datenbankverbindung..."
+ },
+ "nav": {
+ "home": "Startseite",
+ "hosts": "Hosts",
+ "credentials": "Anmeldeinformationen",
+ "terminal": "Terminal",
+ "tunnels": "Tunnel",
+ "fileManager": "Dateimanager",
+ "serverStats": "Serverstatus",
+ "admin": "Administrator",
+ "userProfile": "Benutzerprofil",
+ "tools": "Werkzeuge",
+ "newTab": "Neuer Tab",
+ "splitScreen": "Geteilter Bildschirm",
+ "closeTab": "Tab schließen",
+ "sshManager": "SSH-Manager",
+ "hostManager": "Host-Manager",
+ "cannotSplitTab": "Diese Registerkarte kann nicht geteilt werden",
+ "tabNavigation": "Registerkarte Navigation"
+ },
+ "admin": {
+ "title": "Administratoreinstellungen",
+ "oidc": "OIDC",
+ "users": "Benutzer",
+ "userManagement": "Benutzerverwaltung",
+ "makeAdmin": "Zum Administrator machen",
+ "removeAdmin": "Administrator entfernen",
+ "deleteUser": "Benutzer {{username}} löschen? Dies kann nicht rückgängig gemacht werden.",
+ "allowRegistration": "Registrierung zulassen",
+ "oidcSettings": "OIDC-Einstellungen",
+ "clientId": "Client-ID",
+ "clientSecret": "Client-Geheimnis",
+ "issuerUrl": "Aussteller-URL",
+ "authorizationUrl": "Autorisierungs-URL",
+ "tokenUrl": "Token-URL",
+ "updateSettings": "Update-Einstellungen",
+ "confirmDelete": "Möchten Sie diesen Benutzer wirklich löschen?",
+ "confirmMakeAdmin": "Sind Sie sicher, dass Sie diesen Benutzer zum Admin machen möchten?",
+ "confirmRemoveAdmin": "Möchten Sie die Administratorrechte für diesen Benutzer wirklich entfernen?",
+ "externalAuthentication": "Externe Authentifizierung (OIDC)",
+ "configureExternalProvider": "Externen Identitätsanbieter für OIDC\/OAuth2-Authentifizierung konfigurieren.",
+ "userIdentifierPath": "Pfad für Benutzerkennung",
+ "displayNamePath": "Anzeigenamenpfad",
+ "scopes": "Scopes",
+ "saving": "Speichern...",
+ "saveConfiguration": "Konfiguration speichern",
+ "reset": "Zurücksetzen",
+ "success": "Erfolgreich",
+ "loading": "Laden...",
+ "refresh": "Aktualisieren",
+ "loadingUsers": "Benutzer werden geladen …",
+ "username": "Benutzername",
+ "type": "Typ",
+ "actions": "Aktionen",
+ "external": "Extern",
+ "local": "Lokal",
+ "adminManagement": "Verwaltung von Administratoren",
+ "makeUserAdmin": "Benutzer zum Administrator machen",
+ "adding": "Hinzufügen...",
+ "currentAdmins": "Aktuelle Administratoren",
+ "adminBadge": "Administrator",
+ "removeAdminButton": "Administrator entfernen",
+ "general": "Allgemein",
+ "userRegistration": "Benutzerregistrierung",
+ "allowNewAccountRegistration": "Registrierung neuer Konten zulassen",
+ "missingRequiredFields": "Fehlende Pflichtfelder: {{fields}}",
+ "oidcConfigurationUpdated": "OIDC-Konfiguration erfolgreich aktualisiert!",
+ "failedToFetchOidcConfig": "OIDC-Konfiguration konnte nicht abgerufen werden",
+ "failedToFetchRegistrationStatus": "Abrufen des Registrierungsstatus fehlgeschlagen",
+ "failedToFetchUsers": "Benutzer konnten nicht abgerufen werden",
+ "oidcConfigurationDisabled": "OIDC-Konfiguration erfolgreich deaktiviert!",
+ "failedToUpdateOidcConfig": "Aktualisierung der OIDC-Konfiguration fehlgeschlagen",
+ "failedToDisableOidcConfig": "OIDC-Konfiguration konnte nicht deaktiviert werden",
+ "enterUsernameToMakeAdmin": "Geben Sie den Benutzernamen ein, um zum Administrator zu werden",
+ "userIsNowAdmin": "Der Benutzer {{username}} ist jetzt ein Administrator",
+ "failedToMakeUserAdmin": "Fehler beim Festlegen des Benutzers als Administrator",
+ "removeAdminStatus": "Admin-Status von {{username}} entfernen?",
+ "adminStatusRemoved": "Admin-Status von {{username}} entfernt",
+ "failedToRemoveAdminStatus": "Admin-Status konnte nicht entfernt werden",
+ "userDeletedSuccessfully": "Benutzer {{username}} wurde erfolgreich gelöscht",
+ "failedToDeleteUser": "Benutzer konnte nicht gelöscht werden",
+ "overrideUserInfoUrl": "URL für Benutzerinformationen überschreiben (nicht erforderlich)",
+ "databaseSecurity": "Datenbanksicherheit",
+ "encryptionStatus": "Verschlüsselungsstatus",
+ "encryptionEnabled": "Verschlüsselung aktiviert",
+ "enabled": "Aktiviert",
+ "disabled": "Deaktiviert",
+ "keyId": "Schlüssel-ID",
+ "created": "Erstellt",
+ "migrationStatus": "Migrationsstatus",
+ "migrationCompleted": "Migration abgeschlossen",
+ "migrationRequired": "Migration erforderlich",
+ "deviceProtectedMasterKey": "Umgebungs-geschützter Hauptschlüssel",
+ "legacyKeyStorage": "Legacy-Schlüsselspeicher",
+ "masterKeyEncryptedWithDeviceFingerprint": "Hauptschlüssel mit Umgebungs-Fingerabdruck verschlüsselt (KEK-Schutz aktiv)",
+ "keyNotProtectedByDeviceBinding": "Schlüssel nicht durch Umgebungsbindung geschützt (Upgrade empfohlen)",
+ "valid": "Gültig",
+ "initializeDatabaseEncryption": "Datenbankverschlüsselung initialisieren",
+ "enableAes256EncryptionWithDeviceBinding": "Aktivieren Sie AES-256-Verschlüsselung mit umgebungsgebundener Master-Schlüssel-Sicherung. Dadurch entsteht Sicherheitsniveau in Unternehmensqualität für SSH-Schlüssel, Passwörter und Authentifizierungs-Token.",
+ "featuresEnabled": "Aktivierte Funktionen:",
+ "aes256GcmAuthenticatedEncryption": "Authentifizierte Verschlüsselung mit AES-256-GCM",
+ "deviceFingerprintMasterKeyProtection": "Schutz des Master-Schlüssels der Umgebungs-Fingerabdruckkennung (KEK)",
+ "pbkdf2KeyDerivation": "PBKDF2-Schlüsselableitung mit 100.000 Iterationen",
+ "automaticKeyManagement": "Automatische Schlüsselverwaltung und -rotation",
+ "initializing": "Initialisierung läuft...",
+ "initializeEnterpriseEncryption": "Unternehmensverschlüsselung initialisieren",
+ "migrateExistingData": "Vorhandene Daten migrieren",
+ "encryptExistingUnprotectedData": "Verschlüsseln Sie vorhandene ungeschützte Daten in Ihrer Datenbank. Dieser Vorgang ist sicher und erstellt automatische Backups.",
+ "testMigrationDryRun": "Verschlüsselungskompatibilität überprüfen",
+ "migrating": "Migration läuft...",
+ "migrateData": "Daten migrieren",
+ "securityInformation": "Sicherheitsinformationen",
+ "sshPrivateKeysEncryptedWithAes256": "SSH-Privatschlüssel und Passwörter werden mit AES-256-GCM verschlüsselt",
+ "userAuthTokensProtected": "Benutzerauthentifizierungstoken und 2FA-Geheimnisse sind geschützt",
+ "masterKeysProtectedByDeviceFingerprint": "Hauptverschlüsselungsschlüssel sind durch den Geräte-Fingerabdruck (KEK) geschützt",
+ "keysBoundToServerInstance": "Schlüssel sind an die aktuelle Serverumgebung gebunden (migrierbar über Umgebungsvariablen)",
+ "pbkdf2HkdfKeyDerivation": "PBKDF2 + HKDF-Schlüsselableitung mit 100.000 Iterationen",
+ "backwardCompatibleMigration": "Alle Daten bleiben während der Migration abwärtskompatibel",
+ "enterpriseGradeSecurityActive": "Unternehmenssichere Sicherheit aktiv",
+ "masterKeysProtectedByDeviceBinding": "Ihre Master-Verschlüsselungsschlüssel sind durch Umgebungs-Fingerprinting geschützt. Dabei werden Server-Hostname, Pfade und andere Umgebungsinformationen verwendet, um Schutzschlüssel zu erzeugen. Um Server zu migrieren, setzen Sie die Umgebungsvariable DB_ENCRYPTION_KEY auf dem neuen Server.",
+ "important": "Wichtig",
+ "keepEncryptionKeysSecure": "Sorgen Sie für Datensicherheit: Sichern Sie regelmäßig Ihre Datenbankdateien und die Serverkonfiguration. Um auf einen neuen Server zu migrieren, setzen Sie die Umgebungsvariable DB_ENCRYPTION_KEY in der neuen Umgebung oder behalten Sie denselben Hostnamen und die gleiche Verzeichnisstruktur bei.",
+ "loadingEncryptionStatus": "Verschlüsselungsstatus wird geladen...",
+ "testMigrationDescription": "Überprüfen, dass vorhandene Daten sicher in ein verschlüsseltes Format migriert werden können, ohne tatsächlich irgendwelche Daten zu ändern",
+ "serverMigrationGuide": "Leitfaden zur Servermigration",
+ "migrationInstructions": "So migrieren Sie verschlüsselte Daten auf einen neuen Server: 1) Datenbankdateien sichern, 2) Umgebungsvariable DB_ENCRYPTION_KEY=\"your-key\" auf dem neuen Server setzen, 3) Datenbankdateien wiederherstellen",
+ "environmentProtection": "Umweltschutz",
+ "environmentProtectionDesc": "Schützt Verschlüsselungsschlüssel basierend auf Serverumgebungsinformationen (Hostname, Pfade usw.), migrierbar über Umgebungsvariablen",
+ "verificationCompleted": "Kompatibilitätsprüfung abgeschlossen – keine Daten wurden geändert",
+ "verificationInProgress": "Überprüfung abgeschlossen",
+ "dataMigrationCompleted": "Datenmigration erfolgreich abgeschlossen!",
+ "verificationFailed": "Kompatibilitätsprüfung fehlgeschlagen",
+ "migrationFailed": "Migration fehlgeschlagen",
+ "runningVerification": "Kompatibilitätsüberprüfung wird ausgeführt...",
+ "startingMigration": "Migration wird gestartet...",
+ "hardwareFingerprintSecurity": "Hardware-Fingerabdrucksicherheit",
+ "hardwareBoundEncryption": "Hardwaregebundene Verschlüsselung aktiv",
+ "masterKeysNowProtectedByHardwareFingerprint": "Hauptschlüssel werden jetzt durch echte Hardware-Fingerprinting statt durch Umgebungsvariablen geschützt",
+ "cpuSerialNumberDetection": "Erkennung der CPU-Seriennummer",
+ "motherboardUuidIdentification": "Identifizierung der Motherboard-UUID",
+ "diskSerialNumberVerification": "Überprüfung der Festplatten-Seriennummer",
+ "biosSerialNumberCheck": "Überprüfung der BIOS-Seriennummer",
+ "stableMacAddressFiltering": "Stabiles MAC-Adressfiltering",
+ "databaseFileEncryption": "Datenbankdatei-Verschlüsselung",
+ "dualLayerProtection": "Dualer Schutz mit zwei Ebenen aktiv",
+ "bothFieldAndFileEncryptionActive": "Sowohl die Feld- als auch die Dateiebene sind jetzt verschlüsselt – für maximale Sicherheit",
+ "fieldLevelAes256Encryption": "Feldbasierte AES-256-Verschlüsselung für sensible Daten",
+ "fileLevelDatabaseEncryption": "Dateiebene-Datenbankverschlüsselung mit Hardwarebindung",
+ "hardwareBoundFileKeys": "Hardwaregebundene Dateiverschlüsselungsschlüssel",
+ "automaticEncryptedBackups": "Automatische Erstellung verschlüsselter Backups",
+ "createEncryptedBackup": "Verschlüsselte Sicherung erstellen",
+ "creatingBackup": "Backup wird erstellt...",
+ "backupCreated": "Sicherung erstellt",
+ "encryptedBackupCreatedSuccessfully": "Verschlüsselte Sicherung erfolgreich erstellt",
+ "backupCreationFailed": "Erstellung des Backups fehlgeschlagen",
+ "databaseMigration": "Datenbankmigration",
+ "exportForMigration": "Export für Migration",
+ "exportDatabaseForHardwareMigration": "Datenbank als SQLite-Datei mit entschlüsselten Daten für die Migration auf neue Hardware exportieren",
+ "exportDatabase": "SQLite-Datenbank exportieren",
+ "exporting": "Wird exportiert...",
+ "exportCreated": "SQLite-Export erstellt",
+ "exportContainsDecryptedData": "SQLite-Export enthält entschlüsselte Daten – sicher aufbewahren!",
+ "databaseExportedSuccessfully": "SQLite-Datenbank erfolgreich exportiert",
+ "databaseExportFailed": "Export der SQLite-Datenbank fehlgeschlagen",
+ "importFromMigration": "Import aus Migration",
+ "importDatabaseFromAnotherSystem": "SQLite-Datenbank von einem anderen System oder einer anderen Hardware importieren",
+ "importDatabase": "SQLite-Datenbank importieren",
+ "importing": "Importieren...",
+ "selectedFile": "Ausgewählte SQLite-Datei",
+ "importWillReplaceExistingData": "SQLite-Import ersetzt vorhandene Daten – Sicherung empfohlen!",
+ "pleaseSelectImportFile": "Bitte wählen Sie eine SQLite-Importdatei aus",
+ "databaseImportedSuccessfully": "SQLite-Datenbank erfolgreich importiert",
+ "databaseImportFailed": "Import der SQLite-Datenbank fehlgeschlagen",
+ "manageEncryptionAndBackups": "Verschlüsselungsschlüssel, Databasesicherheit und Sicherungsabläufe verwalten",
+ "activeSecurityFeatures": "Derzeit aktive Sicherheitsmaßnahmen und Schutzvorkehrungen",
+ "deviceBindingTechnology": "Fortschrittliche, hardwarebasierte Technologie zum Schutz von Schlüsseln",
+ "backupAndRecovery": "Optionen für die sichere Erstellung von Backups und die Wiederherstellung der Datenbank",
+ "crossSystemDataTransfer": "Datenbanken systemübergreifend exportieren und importieren",
+ "noMigrationNeeded": "Keine Migration erforderlich",
+ "encryptionKey": "Verschlüsselungsschlüssel",
+ "keyProtection": "Schutz von Schlüsseln",
+ "active": "Aktiv",
+ "legacy": "Legacy",
+ "dataStatus": "Datenstatus",
+ "encrypted": "Verschlüsselt",
+ "needsMigration": "Erfordert Migration",
+ "ready": "Bereit",
+ "initializeEncryption": "Verschlüsselung initialisieren",
+ "initialize": "Initialisieren",
+ "test": "Test",
+ "migrate": "Migrieren",
+ "backup": "Backup",
+ "createBackup": "Backup erstellen",
+ "exportImport": "Export\/Import",
+ "export": "Export",
+ "import": "Import",
+ "passwordRequired": "Passwort erforderlich",
+ "confirmExport": "Export bestätigen",
+ "exportDescription": "SSH-Hosts und Anmeldedaten als SQLite-Datei exportieren",
+ "importDescription": "SQLite-Datei mit inkrementellem Zusammenführen importieren (überspringt Duplikate)"
+ },
+ "hosts": {
+ "title": "Host-Manager",
+ "sshHosts": "SSH-Hosts",
+ "noHosts": "Keine SSH-Hosts",
+ "noHostsMessage": "Sie haben noch keine SSH-Hosts hinzugefügt. Klicken Sie auf \"Host hinzufügen\", um zu beginnen.",
+ "loadingHosts": "Hosts werden geladen...",
+ "failedToLoadHosts": "Hosts konnten nicht geladen werden",
+ "retry": "Wiederholen",
+ "refresh": "Aktualisieren",
+ "hostsCount": "{{count}} Hosts",
+ "importJson": "JSON importieren",
+ "importing": "Importieren...",
+ "importJsonTitle": "SSH-Hosts aus JSON importieren",
+ "importJsonDesc": "Laden Sie eine JSON-Datei hoch, um mehrere SSH-Hosts in einem Schritt zu importieren (max. 100).",
+ "downloadSample": "Beispiel herunterladen",
+ "formatGuide": "Formatleitfaden",
+ "exportCredentialWarning": "Warnung: Der Host \"{{name}}\" verwendet eine Anmeldeauthentifizierung. Die exportierte Datei enthält keine Anmeldedaten und muss nach dem Import manuell neu konfiguriert werden. Möchten Sie fortfahren?",
+ "exportSensitiveDataWarning": "Warnung: Der Host \"{{name}}\" enthält vertrauliche Authentifizierungsdaten (Passwort\/SSH-Schlüssel). Die exportierte Datei wird diese Daten im Klartext enthalten. Bitte bewahren Sie die Datei sicher auf und löschen Sie sie nach der Verwendung. Möchten Sie fortfahren?",
+ "uncategorized": "Unkategorisiert",
+ "confirmDelete": "Möchten Sie \"{{name}}\" wirklich löschen?",
+ "failedToDeleteHost": "Host konnte nicht gelöscht werden",
+ "failedToExportHost": "Export des Hosts fehlgeschlagen. Bitte stellen Sie sicher, dass Sie angemeldet sind und Zugriff auf die Hostdaten haben.",
+ "jsonMustContainHosts": "JSON muss ein \"hosts\"-Array enthalten oder selbst ein Array von Hosts sein",
+ "noHostsInJson": "Keine Hosts in JSON-Datei gefunden",
+ "maxHostsAllowed": "Pro Import sind maximal 100 Hosts erlaubt",
+ "importCompleted": "Import abgeschlossen: {{success}} erfolgreich, {{failed}} fehlgeschlagen",
+ "importFailed": "Import fehlgeschlagen",
+ "importError": "Importfehler",
+ "failedToImportJson": "JSON-Datei konnte nicht importiert werden",
+ "connectionDetails": "Verbindungsdetails",
+ "organization": "Organisation",
+ "ipAddress": "IP-Adresse",
+ "port": "Port",
+ "name": "Name",
+ "username": "Benutzername",
+ "folder": "Ordner",
+ "tags": "Schlagwörter",
+ "pin": "PIN",
+ "passwordRequired": "Bei Verwendung der Kennwortauthentifizierung ist ein Kennwort erforderlich",
+ "sshKeyRequired": "Bei Verwendung der Schlüsselauthentifizierung ist ein privater SSH-Schlüssel erforderlich",
+ "keyTypeRequired": "Bei Verwendung der Schlüsselauthentifizierung ist der Schlüsseltyp erforderlich",
+ "mustSelectValidSshConfig": "Sie müssen eine gültige SSH-Konfiguration aus der Liste auswählen",
+ "addHost": "Host hinzufügen",
+ "editHost": "Host bearbeiten",
+ "cloneHost": "Host klonen",
+ "updateHost": "Host aktualisieren",
+ "hostUpdatedSuccessfully": "Host „{{name}}“ wurde erfolgreich aktualisiert!",
+ "hostAddedSuccessfully": "Host „{{name}}“ wurde erfolgreich hinzugefügt!",
+ "hostDeletedSuccessfully": "Host „{{name}}“ wurde erfolgreich gelöscht!",
+ "failedToSaveHost": "Host konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.",
+ "enableTerminal": "Terminal aktivieren",
+ "enableTerminalDesc": "Host-Sichtbarkeit im Terminal-Tab aktivieren\/deaktivieren",
+ "enableTunnel": "Tunnel aktivieren",
+ "enableTunnelDesc": "Sichtbarkeit des Hosts im Tab „Tunnel“ aktivieren\/deaktivieren",
+ "enableFileManager": "Dateimanager aktivieren",
+ "enableFileManagerDesc": "Sichtbarkeit des Hosts im Reiter „Dateimanager“ aktivieren\/deaktivieren",
+ "defaultPath": "Standard-Pfad",
+ "defaultPathDesc": "Standardverzeichnis beim Öffnen des Dateimanagers für diesen Host",
+ "tunnelConnections": "Tunnel-Verbindungen",
+ "connection": "Verbindung",
+ "remove": "Entfernen",
+ "sourcePort": "Quellport",
+ "sourcePortDesc": " (Quelle bezieht sich auf die aktuellen Verbindungsdetails im Reiter Allgemein)",
+ "endpointPort": "Endpunkt-Port",
+ "endpointSshConfig": "SSH-Konfiguration für Endpunkte",
+ "tunnelForwardDescription": "Dieser Tunnel leitet den Datenverkehr vom Port {{sourcePort}} auf der Quellmaschine (aktuelle Verbindungsdetails auf der Registerkarte „Allgemein“) an den Port {{endpointPort}} auf der Endpunktmaschine weiter.",
+ "maxRetries": "Max. Wiederholungsversuche",
+ "maxRetriesDescription": "Maximale Anzahl der Wiederholungsversuche für die Tunnelverbindung.",
+ "retryInterval": "Wiederholungsintervall (Sekunden)",
+ "retryIntervalDescription": "Wartezeit zwischen Wiederholungsversuchen.",
+ "autoStartContainer": "Automatischer Start beim Container-Start",
+ "autoStartDesc": "Diesen Tunnel beim Start des Containers automatisch starten",
+ "addConnection": "Tunnelverbindung hinzufügen",
+ "sshpassRequired": "Sshpass erforderlich für die Passwort-Authentifizierung",
+ "sshpassRequiredDesc": "Für die Passwortauthentifizierung in Tunneln muss sshpass auf dem System installiert sein.",
+ "otherInstallMethods": "Andere Installationsmethoden:",
+ "debianUbuntuEquivalent": "(Debian\/Ubuntu) oder das entsprechende Pendant für Ihr Betriebssystem.",
+ "or": "oder",
+ "centosRhelFedora": "CentOS\/RHEL\/Fedora",
+ "macos": "macOS",
+ "windows": "Windows",
+ "sshServerConfigRequired": "SSH-Serverkonfiguration erforderlich",
+ "sshServerConfigDesc": "Für Tunnelverbindungen muss der SSH-Server so konfiguriert sein, dass Portweiterleitung möglich ist:",
+ "gatewayPortsYes": "Remote-Ports an alle Schnittstellen binden",
+ "allowTcpForwardingYes": "Portweiterleitung aktivieren",
+ "permitRootLoginYes": "bei Verwendung des Root-Benutzers für das Tunneling",
+ "editSshConfig": "Bearbeiten Sie \/etc\/ssh\/sshd_config und starten Sie SSH neu: sudo systemctl restart sshd",
+ "upload": "Hochladen",
+ "authentication": "Authentifizierung",
+ "password": "Passwort",
+ "key": "Schlüssel",
+ "credential": "Anmeldedaten",
+ "selectCredential": "Anmeldeinformationen auswählen",
+ "selectCredentialPlaceholder": "Wähle eine Anmeldedaten aus...",
+ "credentialRequired": "Für die Anmeldeauthentifizierung ist eine Anmeldeinformation erforderlich",
+ "credentialDescription": "Durch die Auswahl einer Anmeldeinformation wird der aktuelle Benutzername überschrieben und die Authentifizierungsdetails der Anmeldeinformation verwendet.",
+ "sshPrivateKey": "Privater SSH-Schlüssel",
+ "keyPassword": "Schlüsselkennwort",
+ "keyType": "Schlüsseltyp",
+ "autoDetect": "Automatische Erkennung",
+ "rsa": "RSA",
+ "ed25519": "ED25519",
+ "ecdsaNistP256": "ECDSA NIST P-256",
+ "ecdsaNistP384": "ECDSA NIST P-384",
+ "ecdsaNistP521": "ECDSA NIST P-521",
+ "dsa": "DSA",
+ "rsaSha2256": "RSA SHA2-256",
+ "rsaSha2512": "RSA SHA2-512",
+ "uploadFile": "Datei hochladen",
+ "pasteKey": "Schlüssel einfügen",
+ "updateKey": "Schlüssel aktualisieren",
+ "existingKey": "Vorhandener Schlüssel (zum Ändern klicken)",
+ "existingCredential": "Vorhandenes Anmeldedatum (zum Ändern klicken)",
+ "addTagsSpaceToAdd": "Tags hinzufügen (Leertaste zum Hinzufügen)",
+ "terminalBadge": "Terminal",
+ "tunnelBadge": "Tunnel",
+ "fileManagerBadge": "Dateimanager",
+ "general": "Allgemein",
+ "terminal": "Terminal",
+ "tunnel": "Tunnel",
+ "fileManager": "Dateimanager",
+ "hostViewer": "Host-Viewer",
+ "confirmRemoveFromFolder": "Möchten Sie \"{{name}}\" wirklich aus dem Ordner \"{{folder}}\" entfernen? Der Host wird in \"Kein Ordner\" verschoben.",
+ "removedFromFolder": "Host \"{{name}}\" erfolgreich aus dem Ordner entfernt",
+ "failedToRemoveFromFolder": "Host konnte nicht aus dem Ordner entfernt werden",
+ "folderRenamed": "Ordner „ {{oldName}} “ erfolgreich in „ {{newName}} “ umbenannt",
+ "failedToRenameFolder": "Ordner konnte nicht umbenannt werden",
+ "movedToFolder": "Host \"{{name}}\" wurde erfolgreich nach \"{{folder}}\" verschoben",
+ "failedToMoveToFolder": "Host konnte nicht in den Ordner verschoben werden"
+ },
+ "terminal": {
+ "title": "Terminal",
+ "connect": "Mit Host verbinden",
+ "disconnect": "Trennen",
+ "clear": "Löschen",
+ "copy": "Kopieren",
+ "paste": "Einfügen",
+ "find": "Finden",
+ "fullscreen": "Vollbild",
+ "splitHorizontal": "Horizontal teilen",
+ "splitVertical": "Vertikal teilen",
+ "closePanel": "Panel schließen",
+ "reconnect": "Erneut verbinden",
+ "sessionEnded": "Sitzung beendet",
+ "connectionLost": "Verbindung verloren",
+ "error": "FEHLER: {{message}}",
+ "disconnected": "Getrennt",
+ "connectionClosed": "Verbindung geschlossen",
+ "connectionError": "Verbindungsfehler: {{message}}",
+ "connected": "Verbunden",
+ "sshConnected": "SSH-Verbindung hergestellt",
+ "authError": "Authentifizierung fehlgeschlagen: {{message}}",
+ "unknownError": "Unbekannter Fehler ist aufgetreten",
+ "messageParseError": "Servernachricht konnte nicht analysiert werden",
+ "websocketError": "WebSocket-Verbindungsfehler",
+ "connecting": "Verbindung wird hergestellt...",
+ "reconnecting": "Verbindung wird wiederhergestellt... ({{attempt}}\/{{max}})",
+ "reconnected": "Erfolgreich wiederverbunden",
+ "maxReconnectAttemptsReached": "Maximale Anzahl an Wiederverbindungsversuchen erreicht",
+ "connectionTimeout": "Zeitüberschreitung der Verbindung",
+ "terminalTitle": "Terminal - {{host}}",
+ "terminalWithPath": "Terminal - {{host}} : {{path}}",
+ "runTitle": "Ausführen von {{command}} – {{host}}"
+ },
+ "fileManager": {
+ "title": "Dateimanager",
+ "file": "Datei",
+ "folder": "Ordner",
+ "connectToSsh": "Stellen Sie eine SSH-Verbindung her, um Dateivorgänge zu verwenden",
+ "uploadFile": "Datei hochladen",
+ "downloadFile": "Herunterladen",
+ "edit": "Bearbeiten",
+ "preview": "Vorschau",
+ "previous": "Vorherige",
+ "next": "Weiter",
+ "pageXOfY": "Seite {{current}} von {{total}}",
+ "zoomOut": "Verkleinern",
+ "zoomIn": "Vergrößern",
+ "newFile": "Neue Datei",
+ "newFolder": "Neuer Ordner",
+ "rename": "Umbenennen",
+ "renameItem": "Element umbenennen",
+ "deleteItem": "Element löschen",
+ "currentPath": "Aktueller Pfad",
+ "uploadFileTitle": "Datei hochladen",
+ "maxFileSize": "Max.: 1 GB (JSON) \/ 5 GB (Binär) – Große Dateien werden unterstützt",
+ "removeFile": "Datei entfernen",
+ "clickToSelectFile": "Klicken Sie, um eine Datei auszuwählen",
+ "chooseFile": "Datei auswählen",
+ "uploading": "Wird hochgeladen...",
+ "downloading": "Wird heruntergeladen...",
+ "uploadingFile": "Lade {{name}} hoch...",
+ "uploadingLargeFile": "Große Datei wird hochgeladen {{name}} ({{size}})...",
+ "downloadingFile": "{{name}} wird heruntergeladen...",
+ "creatingFile": "Erstelle {{name}}...",
+ "creatingFolder": "Erstellen von {{name}}...",
+ "deletingItem": "Löschen von {{type}} {{name}}...",
+ "renamingItem": "Benenne {{type}} {{oldName}} in {{newName}} um...",
+ "createNewFile": "Neue Datei erstellen",
+ "fileName": "Dateiname",
+ "creating": "Wird erstellt...",
+ "createFile": "Datei erstellen",
+ "createNewFolder": "Neuen Ordner erstellen",
+ "folderName": "Ordnername",
+ "createFolder": "Ordner erstellen",
+ "warningCannotUndo": "Warnung: Diese Aktion kann nicht rückgängig gemacht werden",
+ "itemPath": "Elementpfad",
+ "thisIsDirectory": "Dies ist ein Verzeichnis (wird rekursiv gelöscht)",
+ "deleting": "Löschen...",
+ "currentPathLabel": "Aktueller Pfad",
+ "newName": "Neuer Name",
+ "thisIsDirectoryRename": "Dies ist ein Verzeichnis",
+ "renaming": "Wird umbenannt...",
+ "fileUploadedSuccessfully": "Datei \"{{name}}\" wurde erfolgreich hochgeladen",
+ "failedToUploadFile": "Datei konnte nicht hochgeladen werden",
+ "fileDownloadedSuccessfully": "Datei wurde erfolgreich heruntergeladen",
+ "failedToDownloadFile": "Datei konnte nicht heruntergeladen werden",
+ "noFileContent": "Keine Dateiinhalte empfangen",
+ "filePath": "Dateipfad",
+ "fileCreatedSuccessfully": "Datei \"{{name}}\" wurde erfolgreich erstellt",
+ "failedToCreateFile": "Datei konnte nicht erstellt werden",
+ "folderCreatedSuccessfully": "Ordner \"{{name}}\" wurde erfolgreich erstellt",
+ "failedToCreateFolder": "Ordner konnte nicht erstellt werden",
+ "failedToCreateItem": "Element konnte nicht erstellt werden",
+ "operationFailed": "{{operation}}-Vorgang für {{name}} fehlgeschlagen: {{error}}",
+ "failedToResolveSymlink": "Symbolische Verknüpfung konnte nicht aufgelöst werden",
+ "itemDeletedSuccessfully": "{{type}} erfolgreich gelöscht",
+ "itemsDeletedSuccessfully": "{{count}} Elemente erfolgreich gelöscht",
+ "failedToDeleteItems": "Löschen der Elemente fehlgeschlagen",
+ "dragFilesToUpload": "Dateien hierher ziehen, um sie hochzuladen",
+ "emptyFolder": "Dieser Ordner ist leer",
+ "itemCount": "{{count}} Elemente",
+ "selectedCount": "{{count}} ausgewählt",
+ "searchFiles": "Dateien durchsuchen...",
+ "upload": "Hochladen",
+ "selectHostToStart": "Wähle einen Host aus, um die Dateiverwaltung zu starten",
+ "failedToConnect": "Verbindung mit SSH fehlgeschlagen",
+ "failedToLoadDirectory": "Verzeichnis konnte nicht geladen werden",
+ "noSSHConnection": "Keine SSH-Verbindung verfügbar",
+ "enterFolderName": "Ordnernamen eingeben:",
+ "enterFileName": "Dateiname eingeben:",
+ "copy": "Kopieren",
+ "cut": "Ausschneiden",
+ "paste": "Einfügen",
+ "delete": "Löschen",
+ "properties": "Eigenschaften",
+ "refresh": "Aktualisieren",
+ "downloadFiles": "Laden Sie {{count}} Dateien in den Browser herunter",
+ "copyFiles": "{{count}} Element(e) kopieren",
+ "cutFiles": "{{count}} Element(e) ausschneiden",
+ "deleteFiles": "{{count}} Element(e) löschen",
+ "filesCopiedToClipboard": "{{count}} Element(e) in die Zwischenablage kopiert",
+ "filesCutToClipboard": "{{count}} Element(e) in die Zwischenablage ausschneiden",
+ "movedItems": "{{count}} Element(e) verschoben",
+ "failedToDeleteItem": "Das Löschen des Elements ist fehlgeschlagen.",
+ "itemRenamedSuccessfully": "{{type}} erfolgreich umbenannt",
+ "failedToRenameItem": "Fehler beim Umbenennen des Elements",
+ "download": "Herunterladen",
+ "permissions": "Berechtigungen",
+ "size": "Größe",
+ "modified": "Geändert",
+ "path": "Pfad",
+ "confirmDelete": "Sind Sie sicher, dass Sie {{name}} löschen möchten?",
+ "uploadSuccess": "Datei erfolgreich hochgeladen",
+ "uploadFailed": "Datei-Upload fehlgeschlagen",
+ "downloadSuccess": "Datei erfolgreich heruntergeladen",
+ "downloadFailed": "Datei-Download fehlgeschlagen",
+ "permissionDenied": "Zugriff verweigert",
+ "checkDockerLogs": "Überprüfen Sie die Docker-Logs auf detaillierte Fehlerinformationen",
+ "internalServerError": "Interner Serverfehler aufgetreten",
+ "serverError": "Serverfehler",
+ "error": "Fehler",
+ "requestFailed": "Anforderung mit Statuscode fehlgeschlagen",
+ "unknownFileError": "unbekannt",
+ "cannotReadFile": "Datei kann nicht gelesen werden",
+ "noSshSessionId": "Keine SSH-Sitzungs-ID verfügbar",
+ "noFilePath": "Kein Dateipfad verfügbar",
+ "noCurrentHost": "Kein aktueller Host verfügbar",
+ "fileSavedSuccessfully": "Datei erfolgreich gespeichert",
+ "saveTimeout": "Beim Speichern ist eine Zeitüberschreitung aufgetreten. Die Datei wurde möglicherweise erfolgreich gespeichert, der Vorgang hat jedoch zu lange gedauert. Überprüfen Sie die Docker-Protokolle zur Bestätigung.",
+ "failedToSaveFile": "Datei konnte nicht gespeichert werden",
+ "deletedSuccessfully": "erfolgreich gelöscht",
+ "connectToServer": "Mit einem Server verbinden",
+ "selectServerToEdit": "Wähle einen Server in der Seitenleiste aus, um mit der Bearbeitung von Dateien zu beginnen",
+ "fileOperations": "Dateioperationen",
+ "confirmDeleteMessage": "Möchten Sie {{name}}<\/strong> wirklich löschen?",
+ "confirmDeleteSingleItem": "Möchten Sie „ {{name}} “ wirklich dauerhaft löschen?",
+ "confirmDeleteMultipleItems": "Sind Sie sicher, dass Sie {{name}} löschen möchten?",
+ "confirmDeleteMultipleItemsWithFolders": "Möchten Sie wirklich {{count}} Elemente dauerhaft löschen? Dies schließt Ordner und deren Inhalte ein.",
+ "confirmDeleteFolder": "Möchten Sie den Ordner „ {{name}} “ und seinen gesamten Inhalt wirklich dauerhaft löschen?",
+ "deleteDirectoryWarning": "Dadurch werden der Ordner und sein gesamter Inhalt gelöscht.",
+ "actionCannotBeUndone": "Diese Aktion kann nicht rückgängig gemacht werden.",
+ "permanentDeleteWarning": "Diese Aktion kann nicht rückgängig gemacht werden. Die Elemente werden dauerhaft vom Server gelöscht.",
+ "recent": "Neueste",
+ "pinned": "Angeheftet",
+ "folderShortcuts": "Ordner-Verknüpfungen",
+ "noRecentFiles": "Keine aktuellen Dateien.",
+ "noPinnedFiles": "Keine angehefteten Dateien.",
+ "enterFolderPath": "Ordnerpfad eingeben",
+ "noShortcuts": "Keine Abkürzungen.",
+ "searchFilesAndFolders": "Dateien und Ordner suchen...",
+ "noFilesOrFoldersFound": "Keine Dateien oder Ordner gefunden.",
+ "failedToConnectSSH": "Verbindung mit SSH konnte nicht hergestellt werden",
+ "failedToReconnectSSH": "Wiederherstellen der SSH-Sitzung fehlgeschlagen",
+ "failedToListFiles": "Dateien konnten nicht aufgelistet werden",
+ "fetchHomeDataTimeout": "Zeitüberschreitung beim Abrufen der Startseitendaten",
+ "sshStatusCheckTimeout": "SSH-Statusprüfung hat zu lange gedauert",
+ "sshReconnectionTimeout": "SSH-Wiederverbindung hat eine Zeitüberschreitung",
+ "saveOperationTimeout": "Speichervorgang hat zu lange gedauert",
+ "cannotSaveFile": "Datei kann nicht gespeichert werden",
+ "dragSystemFilesToUpload": "Ziehen Sie Systemdateien hierher, um sie hochzuladen",
+ "dragFilesToWindowToDownload": "Dateien aus dem Fenster ziehen, um sie herunterzuladen",
+ "openTerminalHere": "Terminal hier öffnen",
+ "run": "Ausführen",
+ "saveToSystem": "Speichern unter...",
+ "selectLocationToSave": "Wählen Sie den Speicherort aus",
+ "openTerminalInFolder": "Terminal in diesem Ordner öffnen",
+ "openTerminalInFileLocation": "Terminal am Dateispeicherort öffnen",
+ "terminalWithPath": "Terminal - {{host}} : {{path}}",
+ "runningFile": "Wird ausgeführt - {{file}}",
+ "onlyRunExecutableFiles": "Kann nur ausführbare Dateien ausführen",
+ "noHostSelected": "Kein Host ausgewählt",
+ "starred": "Mit Stern markiert",
+ "shortcuts": "Kurzbefehle",
+ "directories": "Verzeichnisse",
+ "removedFromRecentFiles": "„ {{name}} “ aus den zuletzt verwendeten letzten Dateien entfernt",
+ "removeFailed": "Entfernen fehlgeschlagen",
+ "unpinnedSuccessfully": "\" {{name}} \" erfolgreich gelöst",
+ "unpinFailed": "Anheften fehlgeschlagen",
+ "removedShortcut": "Verknüpfung „ {{name}} “ entfernt",
+ "removeShortcutFailed": "Verknüpfung entfernen fehlgeschlagen",
+ "clearedAllRecentFiles": "Alle zuletzt verwendeten Dateien gelöscht",
+ "clearFailed": "Löschen fehlgeschlagen",
+ "removeFromRecentFiles": "Aus den zuletzt verwendeten Dateien entfernen",
+ "clearAllRecentFiles": "Alle zuletzt verwendeten Dateien löschen",
+ "unpinFile": "Datei lösen",
+ "removeShortcut": "Verknüpfung entfernen",
+ "saveFilesToSystem": "{{count}} Dateien speichern unter...",
+ "pinFile": "Datei anheften",
+ "addToShortcuts": "Zu Shortcuts hinzufügen",
+ "downloadToDefaultLocation": "An Standardort herunterladen",
+ "pasteFailed": "Einfügen fehlgeschlagen",
+ "noUndoableActions": "Keine rückgängig zu machenden Aktionen",
+ "undoCopySuccess": "Kopiervorgang rückgängig gemacht: {{count}} kopierte Dateien gelöscht",
+ "undoCopyFailedDelete": "Rückgängig machen fehlgeschlagen: Es konnten keine kopierten Dateien gelöscht werden",
+ "undoCopyFailedNoInfo": "Rückgängig machen fehlgeschlagen: Informationen zur kopierten Datei konnten nicht gefunden werden",
+ "undoMoveSuccess": "Verschiebevorgang rückgängig gemacht: {{count}} Dateien zurück an den ursprünglichen Speicherort verschoben",
+ "undoMoveFailedMove": "Rückgängig fehlgeschlagen: Es konnten keine Dateien zurück verschoben werden",
+ "undoMoveFailedNoInfo": "Rückgängig machen fehlgeschlagen: Informationen zur verschobenen Datei konnten nicht gefunden werden",
+ "undoDeleteNotSupported": "Der Löschvorgang kann nicht rückgängig gemacht werden: Die Dateien wurden dauerhaft vom Server gelöscht.",
+ "undoTypeNotSupported": "Nicht unterstützter Rückgängig-Operationstyp",
+ "undoOperationFailed": "Rückgängig-Operation fehlgeschlagen",
+ "unknownError": "Unbekannter Fehler",
+ "enterPath": "Pfad eingeben...",
+ "editPath": "Pfad bearbeiten",
+ "confirm": "Bestätigen",
+ "cancel": "Abbrechen",
+ "find": "Suchen...",
+ "replaceWith": "Ersetzen durch...",
+ "replace": "Ersetzen",
+ "replaceAll": "Alle ersetzen",
+ "downloadInstead": "Stattdessen herunterladen",
+ "keyboardShortcuts": "Tastenkürzel",
+ "searchAndReplace": "Suchen & Ersetzen",
+ "editing": "Bearbeitung",
+ "navigation": "Navigation",
+ "code": "Code",
+ "search": "Suchen",
+ "findNext": "Weitersuchen",
+ "findPrevious": "Vorheriges suchen",
+ "save": "Speichern",
+ "selectAll": "Alles auswählen",
+ "undo": "Rückgängig",
+ "redo": "Wiederholen",
+ "goToLine": "Gehe zu Zeile",
+ "moveLineUp": "Zeile nach oben verschieben",
+ "moveLineDown": "Zeile nach unten verschieben",
+ "toggleComment": "Kommentar umschalten",
+ "indent": "Einzug",
+ "outdent": "Ausrücken",
+ "autoComplete": "Automatische Vervollständigung",
+ "imageLoadError": "Bild konnte nicht geladen werden",
+ "rotate": "Drehen",
+ "originalSize": "Originalgröße",
+ "startTyping": "Beginnen Sie mit der Eingabe...",
+ "unknownSize": "Unbekannte Größe",
+ "fileIsEmpty": "Die Datei ist leer",
+ "largeFileWarning": "Warnung zu großen Dateie(n)",
+ "largeFileWarningDesc": "Diese Datei hat eine Größe {{size}}, was beim Öffnen als Text zu Leistungsproblemen führen kann.",
+ "fileNotFoundAndRemoved": "Datei „ {{name}} “ nicht gefunden und aus den letzten\/angehefteten Dateien entfernt",
+ "failedToLoadFile": "Datei konnte nicht geladen werden: {{error}}",
+ "serverErrorOccurred": "Es ist ein Serverfehler aufgetreten. Bitte versuchen Sie es später noch einmal.",
+ "autoSaveFailed": "Automatisches Speichern fehlgeschlagen",
+ "fileAutoSaved": "Datei automatisch gespeichert",
+ "moveFileFailed": "{{name}} konnte nicht verschoben werden",
+ "moveOperationFailed": "Verschiebevorgang fehlgeschlagen",
+ "canOnlyCompareFiles": "Kann nur zwei Dateien vergleichen",
+ "comparingFiles": "Dateien werden verglichen: {{file1}} und {{file2}}",
+ "dragFailed": "Ziehvorgang fehlgeschlagen",
+ "filePinnedSuccessfully": "Datei \"{{name}}\" erfolgreich angeheftet",
+ "pinFileFailed": "Datei konnte nicht angeheftet werden",
+ "fileUnpinnedSuccessfully": "Datei „ {{name}} “ erfolgreich gelöst",
+ "unpinFileFailed": "Fehler beim Lösen der Datei",
+ "shortcutAddedSuccessfully": "Ordnerverknüpfung „ {{name}} “ erfolgreich hinzugefügt",
+ "addShortcutFailed": "Verknüpfung konnte nicht hinzugefügt werden",
+ "operationCompletedSuccessfully": "{{operation}} {{count}} Element(e) erfolgreich",
+ "operationCompleted": "{{operation}} {{count}} Element(e)",
+ "downloadFileSuccess": "Datei {{name}} erfolgreich heruntergeladen",
+ "downloadFileFailed": "Download fehlgeschlagen",
+ "moveTo": "Nach {{name}} verschieben",
+ "diffCompareWith": "Diff-Vergleich mit {{name}}",
+ "dragOutsideToDownload": "Zum Herunterladen aus dem Fenster ziehen ( {{count}} Dateien)",
+ "newFolderDefault": "Neuer Ordner",
+ "newFileDefault": "NeueDatei.txt",
+ "successfullyMovedItems": "{{count}} Elemente erfolgreich nach {{target}} verschoben",
+ "move": "Verschieben",
+ "searchInFile": "In Datei suchen (Strg+F)",
+ "showKeyboardShortcuts": "Tastenkombinationen anzeigen",
+ "startWritingMarkdown": "Beginnen Sie, Ihre Markdown-Inhalte zu schreiben...",
+ "loadingFileComparison": "Dateivergleich wird geladen...",
+ "reload": "Neu laden",
+ "compare": "Vergleichen",
+ "sideBySide": "Nebeneinander",
+ "inline": "Inline",
+ "fileComparison": "Dateivergleich: {{file1}} vs {{file2}}",
+ "fileTooLarge": "Datei zu groß: {{error}}",
+ "sshConnectionFailed": "SSH-Verbindung fehlgeschlagen. Bitte überprüfen Sie Ihre Verbindung zu {{name}} ( {{ip}} : {{port}} )",
+ "loadFileFailed": "Datei konnte nicht geladen werden: {{error}}"
+ },
+ "tunnels": {
+ "title": "SSH-Tunnel",
+ "noSshTunnels": "Keine SSH-Tunnel",
+ "createFirstTunnelMessage": "Erstellen Sie zunächst Ihren ersten SSH-Tunnel. Verwenden Sie den SSH-Manager, um Hosts mit Tunnelverbindungen hinzuzufügen.",
+ "connected": "Verbunden",
+ "disconnected": "Getrennt",
+ "connecting": "Verbindung wird hergestellt...",
+ "disconnecting": "Verbindung wird getrennt...",
+ "unknownTunnelStatus": "Unbekannt",
+ "unknown": "Unbekannt",
+ "error": "Fehler",
+ "failed": "Fehlgeschlagen",
+ "retrying": "Erneuter Versuch",
+ "waiting": "Warten",
+ "waitingForRetry": "Warten auf erneuten Versuch",
+ "retryingConnection": "Erneuter Verbindungsversuch",
+ "canceling": "Abbrechen...",
+ "connect": "Verbinden",
+ "disconnect": "Trennen",
+ "cancel": "Abbrechen",
+ "port": "Port",
+ "attempt": "Versuch {{current}} von {{max}}",
+ "nextRetryIn": "Nächster Wiederholungsversuch in {{seconds}} Sekunden",
+ "checkDockerLogs": "Überprüfen Sie Ihre Docker-Protokolle auf den Fehlergrund und treten Sie dem Discord bei",
+ "noTunnelConnections": "Keine Tunnelverbindungen konfiguriert",
+ "tunnelConnections": "Tunnel-Verbindungen",
+ "addTunnel": "Tunnel hinzufügen",
+ "editTunnel": "Tunnel bearbeiten",
+ "deleteTunnel": "Tunnel löschen",
+ "tunnelName": "Name des Tunnels",
+ "localPort": "Lokaler Port",
+ "remoteHost": "Remote-Host",
+ "remotePort": "Remote-Port",
+ "autoStart": "Autostart",
+ "status": "Status",
+ "active": "Aktiv",
+ "inactive": "Inaktiv",
+ "start": "Start",
+ "stop": "Stoppen",
+ "restart": "Neustart",
+ "connectionType": "Verbindungstyp",
+ "local": "Lokal",
+ "remote": "Remote",
+ "dynamic": "Dynamisch",
+ "unknownConnectionStatus": "Unbekannt",
+ "portMapping": "Port {{sourcePort}} → {{endpointHost}} : {{endpointPort}}",
+ "endpointHostNotFound": "Endpunkthost nicht gefunden",
+ "discord": "Discord",
+ "githubIssue": "GitHub-issue",
+ "forHelp": "für Hilfe"
+ },
+ "serverStats": {
+ "title": "Server-Statistiken",
+ "cpu": "CPU",
+ "memory": "Speicher",
+ "disk": "Festplatte",
+ "network": "Netzwerk",
+ "uptime": "Betriebszeit",
+ "loadAverage": "Durchschnitt: {{avg1}}, {{avg5}}, {{avg15}}",
+ "processes": "Prozesse",
+ "connections": "Verbindungen",
+ "usage": "Verwendung",
+ "available": "Verfügbar",
+ "total": "Gesamt",
+ "free": "Frei",
+ "used": "Gebraucht",
+ "percentage": "Prozentsatz",
+ "refreshStatusAndMetrics": "Aktualisierungsstatus und Metriken",
+ "refreshStatus": "Aktualisierungsstatus",
+ "fileManagerAlreadyOpen": "Der Dateimanager ist für diesen Host bereits geöffnet",
+ "openFileManager": "Dateimanager öffnen",
+ "cpuCores_one": "{{count}} CPU",
+ "cpuCores_other": "{{count}} CPUs",
+ "naCpus": "N\/A CPU(s)",
+ "loadAverageNA": "Durchschnitt: N\/A",
+ "cpuUsage": "CPU-Auslastung",
+ "memoryUsage": "Speicherauslastung",
+ "rootStorageSpace": "Root-Speicherplatz",
+ "of": "von",
+ "feedbackMessage": "Haben Sie Ideen für die nächsten Schritte im Bereich der Serververwaltung? Teilen Sie diese mit uns",
+ "failedToFetchHostConfig": "Abrufen der Hostkonfiguration fehlgeschlagen",
+ "failedToFetchStatus": "Abrufen des Serverstatus fehlgeschlagen",
+ "failedToFetchMetrics": "Abrufen der Servermetriken fehlgeschlagen",
+ "failedToFetchHomeData": "Abrufen der Home-Daten fehlgeschlagen",
+ "loadingMetrics": "Laden von Metriken...",
+ "refreshing": "Aktualisieren...",
+ "serverOffline": "Server offline",
+ "cannotFetchMetrics": "Metriken können nicht vom Offline-Server abgerufen werden",
+ "load": "Laden"
+ },
+ "auth": {
+ "loginTitle": "Melden Sie sich bei Termix an",
+ "registerTitle": "Benutzerkonto erstellen",
+ "loginButton": "Anmelden",
+ "registerButton": "Registrieren",
+ "forgotPassword": "Passwort vergessen?",
+ "rememberMe": "Erinnere dich an mich",
+ "noAccount": "Sie haben noch kein Konto?",
+ "hasAccount": "Sie haben bereits ein Konto?",
+ "loginSuccess": "Anmeldung erfolgreich",
+ "loginFailed": "Anmeldung fehlgeschlagen",
+ "registerSuccess": "Registrierung erfolgreich",
+ "registerFailed": "Registrierung fehlgeschlagen",
+ "logoutSuccess": "Erfolgreich abgemeldet",
+ "invalidCredentials": "Ungültiger Benutzername oder Passwort",
+ "accountCreated": "Konto erfolgreich erstellt",
+ "passwordReset": "Link zum Zurücksetzen des Passworts gesendet",
+ "twoFactorAuth": "Zwei-Faktor-Authentifizierung",
+ "enterCode": "Bestätigungscode eingeben",
+ "backupCode": "Oder verwenden Sie den Backup-Code",
+ "verifyCode": "Code bestätigen",
+ "enableTwoFactor": "Zwei-Faktor-Authentifizierung aktivieren",
+ "disableTwoFactor": "Zwei-Faktor-Authentifizierung deaktivieren",
+ "scanQRCode": "Scannen Sie diesen QR-Code mit Ihrer Authentifizierungs-App",
+ "backupCodes": "Sicherungs-Codes",
+ "saveBackupCodes": "Bewahren Sie diese Backup-Codes an einem sicheren Ort auf",
+ "twoFactorEnabledSuccess": "Zwei-Faktor-Authentifizierung erfolgreich aktiviert!",
+ "twoFactorDisabled": "Zwei-Faktor-Authentifizierung deaktiviert",
+ "newBackupCodesGenerated": "Neue Backup-Codes generiert",
+ "backupCodesDownloaded": "Backup-Codes heruntergeladen",
+ "pleaseEnterSixDigitCode": "Bitte geben Sie einen 6-stelligen Code ein",
+ "invalidVerificationCode": "Ungültiger Bestätigungscode",
+ "failedToDisableTotp": "TOTP konnte nicht deaktiviert werden",
+ "failedToGenerateBackupCodes": "Fehler beim Generieren von Backup-Codes",
+ "enterPassword": "Geben Sie Ihr Passwort ein",
+ "lockedOidcAuth": "Gesperrt (OIDC-Authentifizierung)",
+ "twoFactorTitle": "Zwei-Faktor-Authentifizierung",
+ "twoFactorProtected": "Ihr Konto ist durch eine Zwei-Faktor-Authentifizierung geschützt",
+ "twoFactorActive": "Für Ihr Konto ist derzeit die Zwei-Faktor-Authentifizierung aktiv",
+ "disable2FA": "2FA deaktivieren",
+ "disableTwoFactorWarning": "Die Deaktivierung der Zwei-Faktor-Authentifizierung macht Ihr Konto unsicherer",
+ "passwordOrTotpCode": "Passwort oder TOTP-Code",
+ "or": "Oder",
+ "generateNewBackupCodesText": "Generieren Sie neue Backup-Codes, wenn Sie Ihre bestehenden verloren haben",
+ "generateNewBackupCodes": "Neue Backup-Codes generieren",
+ "yourBackupCodes": "Ihre Backup-Codes",
+ "download": "Herunterladen",
+ "setupTwoFactorTitle": "Zwei-Faktor-Authentifizierung einrichten",
+ "step1ScanQR": "Schritt 1: Scannen Sie den QR-Code mit Ihrer Authentifizierungs-App",
+ "manualEntryCode": "Manueller Eingabecode",
+ "cannotScanQRText": "Wenn Sie den QR-Code nicht scannen können, geben Sie diesen Code manuell in Ihre Authentifizierungs-App ein",
+ "nextVerifyCode": "Weiter: Code überprüfen",
+ "verifyAuthenticator": "Überprüfen Sie Ihren Authentifikator",
+ "step2EnterCode": "Schritt 2: Geben Sie den 6-stelligen Code aus Ihrer Authentifizierungs-App ein",
+ "verificationCode": "Bestätigungscode",
+ "back": "Zurück",
+ "verifyAndEnable": "Überprüfen und Aktivieren",
+ "saveBackupCodesTitle": "Speichern Sie Ihre Backup-Codes",
+ "step3StoreCodesSecurely": "Schritt 3: Bewahren Sie diese Codes an einem sicheren Ort auf",
+ "importantBackupCodesText": "Bewahren Sie diese Backup-Codes an einem sicheren Ort auf. Sie können sie verwenden, um auf Ihr Konto zuzugreifen, falls Sie Ihr Authentifizierungsgerät verlieren.",
+ "completeSetup": "Vollständiges Setup",
+ "notEnabledText": "Die Zwei-Faktor-Authentifizierung fügt eine zusätzliche Sicherheitsebene hinzu, indem bei der Anmeldung ein Code von Ihrer Authentifizierungs-App erforderlich ist.",
+ "enableTwoFactorButton": "Zwei-Faktor-Authentifizierung aktivieren",
+ "addExtraSecurityLayer": "Fügen Sie Ihrem Konto eine zusätzliche Sicherheitsebene hinzu",
+ "firstUser": "Erster Benutzer",
+ "firstUserMessage": "Sie sind der erste Benutzer und werden zum Administrator ernannt. Sie können die Administratoreinstellungen im Dropdown-Menü der Seitenleiste einsehen. Wenn Sie glauben, dass dies ein Fehler ist, überprüfen Sie die Docker-Protokolle oder erstellen Sie ein GitHub Ticket.",
+ "external": "Extern",
+ "loginWithExternal": "Anmeldung mit externem Anbieter",
+ "loginWithExternalDesc": "Melden Sie sich mit Ihrem konfigurierten externen Identitätsanbieter an",
+ "externalNotSupportedInElectron": "Externe Authentifizierung wird in der Electron-App noch nicht unterstützt. Bitte verwenden Sie die Webversion für die OIDC-Anmeldung.",
+ "resetPasswordButton": "Passwort zurücksetzen",
+ "sendResetCode": "Reset-Code senden",
+ "resetCodeDesc": "Geben Sie Ihren Benutzernamen ein, um einen Code zum Zurücksetzen des Passworts zu erhalten. Der Code wird in den Docker-Container-Protokollen angezeigt.",
+ "resetCode": "Code zurücksetzen",
+ "verifyCodeButton": "Code bestätigen",
+ "enterResetCode": "Geben Sie den 6-stelligen Code aus den Docker-Container-Protokollen \/ logs für den Benutzer ein:",
+ "goToLogin": "Zum Login",
+ "newPassword": "Neues Passwort",
+ "confirmNewPassword": "Passwort bestätigen",
+ "enterNewPassword": "Geben Sie Ihr neues Passwort für den Benutzer ein:",
+ "passwordResetSuccess": "Erfolgreich!",
+ "passwordResetSuccessDesc": "Ihr Passwort wurde erfolgreich zurückgesetzt! Sie können sich jetzt mit Ihrem neuen Passwort anmelden.",
+ "signUp": "Registrierung"
+ },
+ "errors": {
+ "notFound": "Seite nicht gefunden",
+ "unauthorized": "Unbefugter Zugriff",
+ "forbidden": "Zugang verboten",
+ "serverError": "Serverfehler",
+ "networkError": "Netzwerkfehler",
+ "databaseConnection": "Es konnte keine Verbindung zur Datenbank hergestellt werden.",
+ "unknownError": "Unbekannter Fehler",
+ "loginFailed": "Anmeldung fehlgeschlagen",
+ "failedPasswordReset": "Das Zurücksetzen des Kennworts konnte nicht eingeleitet werden.",
+ "failedVerifyCode": "Reset-Code konnte nicht verifiziert werden",
+ "failedCompleteReset": "Das Zurücksetzen des Kennworts konnte nicht abgeschlossen werden.",
+ "invalidTotpCode": "Ungültiger TOTP-Code",
+ "failedOidcLogin": "Fehler beim Starten der OIDC-Anmeldung",
+ "failedUserInfo": "Fehler beim Abrufen von Benutzerinformationen nach der OIDC-Anmeldung",
+ "oidcAuthFailed": "OIDC-Authentifizierung fehlgeschlagen",
+ "noTokenReceived": "Kein Token vom Login erhalten",
+ "invalidAuthUrl": "Ungültige Autorisierungs-URL vom Backend empfangen",
+ "invalidInput": "Ungültige Eingabe",
+ "requiredField": "Dieses Feld ist erforderlich",
+ "minLength": "Die Mindestlänge beträgt {{min}}",
+ "maxLength": "Die maximale Länge beträgt {{max}}",
+ "invalidEmail": "Ungültige E-Mail-Adresse",
+ "passwordMismatch": "Passwörter stimmen nicht überein",
+ "weakPassword": "Das Passwort ist zu schwach",
+ "usernameExists": "Benutzername existiert bereits",
+ "emailExists": "E-Mail existiert bereits",
+ "loadFailed": "Daten konnten nicht geladen werden",
+ "saveError": "Speichern fehlgeschlagen",
+ "sessionExpired": "Sitzung abgelaufen - bitte melden Sie sich erneut an"
+ },
+ "messages": {
+ "saveSuccess": "Erfolgreich gespeichert",
+ "saveError": "Speichern fehlgeschlagen",
+ "deleteSuccess": "Erfolgreich gelöscht",
+ "deleteError": "Löschen fehlgeschlagen",
+ "updateSuccess": "Erfolgreich aktualisiert",
+ "updateError": "Aktualisierung fehlgeschlagen",
+ "copySuccess": "In die Zwischenablage kopiert",
+ "copyError": "Kopieren fehlgeschlagen",
+ "copiedToClipboard": "{{field}} in die Zwischenablage kopiert",
+ "connectionEstablished": "Verbindung hergestellt",
+ "connectionClosed": "Verbindung geschlossen",
+ "reconnecting": "Verbindung wird wiederhergestellt...",
+ "processing": "Verarbeitung...",
+ "pleaseWait": "Bitte warten...",
+ "registrationDisabled": "Die Registrierung neuer Konten ist derzeit durch einen Administrator deaktiviert. Bitte melden Sie sich an oder wenden Sie sich an einen Administrator.",
+ "databaseConnected": "Datenbank erfolgreich verbunden",
+ "databaseConnectionFailed": "Verbindung zum Datenbankserver konnte nicht hergestellt werden",
+ "checkServerConnection": "Bitte überprüfen Sie Ihre Serververbindung und versuchen Sie es erneut",
+ "resetCodeSent": "Reset-Code wurde an Docker-Protokolle gesendet",
+ "codeVerified": "Code erfolgreich verifiziert",
+ "passwordResetSuccess": "Passwort erfolgreich zurückgesetzt",
+ "loginSuccess": "Anmeldung erfolgreich",
+ "registrationSuccess": "Registrierung erfolgreich"
+ },
+ "profile": {
+ "title": "Benutzerprofil",
+ "description": "Verwalten Sie Ihre Kontoeinstellungen und Sicherheit",
+ "security": "Sicherheit",
+ "changePassword": "Kennwort ändern",
+ "twoFactorAuth": "Zwei-Faktor-Authentifizierung",
+ "accountInfo": "Kontoinformationen",
+ "role": "Rolle",
+ "admin": "Administrator",
+ "user": "Benutzer",
+ "authMethod": "Authentifizierungsmethode",
+ "local": "Lokal",
+ "external": "Extern (OIDC)",
+ "selectPreferredLanguage": "Wählen Sie Ihre bevorzugte Sprache für die Benutzeroberfläche"
+ },
+ "user": {
+ "failedToLoadVersionInfo": "Fehler beim Laden der Versionsinformationen"
+ },
+ "placeholders": {
+ "enterCode": "000000",
+ "ipAddress": "127.0.0.1",
+ "port": "22",
+ "maxRetries": "3",
+ "retryInterval": "10",
+ "language": "Sprache",
+ "username": "Benutzername",
+ "hostname": "Hostname",
+ "folder": "Ordner",
+ "password": "Passwort",
+ "keyPassword": "Schlüsselkennwort",
+ "pastePrivateKey": "Fügen Sie hier Ihren privaten Schlüssel ein ...",
+ "pastePublicKey": "Fügen Sie hier Ihren öffentlichen Schlüssel ein ...",
+ "credentialName": "Mein SSH-Server",
+ "description": "Beschreibung der SSH-Anmeldeinformationen",
+ "searchCredentials": "Suchen Sie Anmeldeinformationen nach Name, Benutzername oder Tags ...",
+ "sshConfig": "Endpunkt-SSH-Konfiguration",
+ "homePath": "\/home",
+ "clientId": "Ihre Client-ID",
+ "clientSecret": "Ihr Client-Geheimnis",
+ "authUrl": "https:\/\/ihr-anbieter.com\/application\/o\/authorize\/",
+ "redirectUrl": "https:\/\/ihr-anbieter.com\/application\/o\/termix\/",
+ "tokenUrl": "https:\/\/ihr-anbieter.com\/application\/o\/token\/",
+ "userIdField": "sub",
+ "usernameField": "name",
+ "scopes": "openid email profile",
+ "userinfoUrl": "https:\/\/ihr-anbieter.com\/application\/o\/userinfo\/",
+ "enterUsername": "Geben Sie den Benutzernamen ein, um zum Administrator zu werden",
+ "searchHosts": "Suchen Sie nach Hosts nach Name, Benutzername, IP, Ordner, Tags usw.",
+ "enterPassword": "Geben Sie Ihr Passwort ein",
+ "totpCode": "6-stelliger TOTP-Code",
+ "searchHostsAny": "Suchen Sie nach Hosts anhand beliebiger Informationen ...",
+ "confirmPassword": "Geben Sie zur Bestätigung Ihr Passwort ein",
+ "typeHere": "Hier eingeben",
+ "fileName": "Geben Sie den Dateinamen ein (z. B. Beispiel.txt)",
+ "folderName": "Ordnernamen eingeben",
+ "fullPath": "Geben Sie den vollständigen Pfad zum Element ein",
+ "currentPath": "Geben Sie den aktuellen Pfad zum Element ein",
+ "newName": "Neuen Namen eingeben"
+ },
+ "leftSidebar": {
+ "failedToLoadHosts": "Hosts konnten nicht geladen werden",
+ "noFolder": "Kein Ordner",
+ "passwordRequired": "Passwort erforderlich",
+ "failedToDeleteAccount": "Konto konnte nicht gelöscht werden",
+ "failedToMakeUserAdmin": "Fehler beim Festlegen des Benutzers als Administrator",
+ "userIsNowAdmin": "Der Benutzer {{username}} ist jetzt ein Administrator",
+ "removeAdminConfirm": "Möchten Sie die Administrationsberechtigung von {{username}} wirklich entfernen?",
+ "deleteUserConfirm": "Möchten Sie den Benutzer {{username}} wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
+ "deleteAccount": "Konto löschen",
+ "closeDeleteAccount": "Schließen Konto löschen",
+ "deleteAccountWarning": "Diese Aktion kann nicht rückgängig gemacht werden. Dadurch werden Ihr Konto und alle damit verbundenen Daten dauerhaft gelöscht.",
+ "deleteAccountWarningDetails": "Wenn Sie Ihr Konto löschen, werden alle Ihre Daten entfernt, einschließlich SSH-Hosts, Konfigurationen und Einstellungen. Diese Aktion kann nicht rückgängig gemacht werden.",
+ "cannotDeleteAccount": "Konto kann nicht gelöscht werden",
+ "lastAdminWarning": "Sie sind der letzte Administrator. Sie können Ihr Konto nicht löschen, da das System dann ohne Administratoren wäre. Bitte benennen Sie zunächst einen anderen Benutzer als Administrator oder wenden Sie sich an den Systemsupport.",
+ "confirmPassword": "Passwort bestätigen",
+ "deleting": "Löschen...",
+ "cancel": "Abbrechen"
+ },
+ "interface": {
+ "sidebar": "Seitenleiste",
+ "toggleSidebar": "Seitenleiste ein-\/ausblenden",
+ "close": "Schließen",
+ "online": "Online",
+ "offline": "Offline",
+ "maintenance": "Wartung",
+ "degraded": "Herabgestuft",
+ "noTunnelConnections": "Keine Tunnelverbindungen konfiguriert",
+ "discord": "Discord",
+ "connectToSshForOperations": "Stellen Sie eine SSH-Verbindung her, um Dateivorgänge zu verwenden",
+ "uploadFile": "Datei hochladen",
+ "newFile": "Neue Datei",
+ "newFolder": "Neuer Ordner",
+ "rename": "Umbenennen",
+ "deleteItem": "Element löschen",
+ "createNewFile": "Neue Datei erstellen",
+ "createNewFolder": "Neuen Ordner erstellen",
+ "renameItem": "Element umbenennen",
+ "clickToSelectFile": "Klicken Sie, um eine Datei auszuwählen",
+ "noSshHosts": "Keine SSH-Hosts",
+ "sshHosts": "SSH-Hosts",
+ "importSshHosts": "SSH-Hosts aus JSON importieren",
+ "clientId": "Client-ID",
+ "clientSecret": "Client-Geheimnis",
+ "error": "Fehler",
+ "warning": "Warnung",
+ "deleteAccount": "Konto löschen",
+ "closeDeleteAccount": "Schließen Konto löschen",
+ "cannotDeleteAccount": "Konto kann nicht gelöscht werden",
+ "confirmPassword": "Passwort bestätigen",
+ "deleting": "Löschen...",
+ "externalAuth": "Externe Authentifizierung (OIDC)",
+ "configureExternalProvider": "Konfigurieren Sie den externen Identitätsanbieter für die OIDC/OAuth2-Authentifizierung.",
+ "waitingForRetry": "Warten auf erneuten Versuch",
+ "retryingConnection": "Erneuter Verbindungsversuch",
+ "resetSplitSizes": "Split-Größen zurücksetzen",
+ "sshManagerAlreadyOpen": "SSH-Manager bereits geöffnet",
+ "disabledDuringSplitScreen": "Deaktiviert bei geteiltem Bildschirm",
+ "unknown": "Unbekannt",
+ "connected": "Verbunden",
+ "disconnected": "Getrennt",
+ "maxRetriesExhausted": "Maximale Wiederholungsversuche ausgeschöpft",
+ "endpointHostNotFound": "Endpunkthost nicht gefunden",
+ "administrator": "Administrator",
+ "user": "Benutzer",
+ "external": "Extern",
+ "local": "Lokal",
+ "saving": "Speichern...",
+ "saveConfiguration": "Konfiguration speichern",
+ "loading": "Laden...",
+ "refresh": "Aktualisieren",
+ "adding": "Hinzufügen...",
+ "makeAdmin": "Zum Administrator machen",
+ "verifying": "Überprüfung...",
+ "verifyAndEnable": "Überprüfen und Aktivieren",
+ "secretKey": "Geheimer Schlüssel",
+ "totpQrCode": "TOTP-QR-Code",
+ "passwordRequired": "Bei Verwendung der Kennwortauthentifizierung ist ein Kennwort erforderlich",
+ "sshKeyRequired": "Bei Verwendung der Schlüsselauthentifizierung ist ein privater SSH-Schlüssel erforderlich",
+ "keyTypeRequired": "Bei Verwendung der Schlüsselauthentifizierung ist der Schlüsseltyp erforderlich",
+ "validSshConfigRequired": "Sie müssen eine gültige SSH-Konfiguration aus der Liste auswählen",
+ "updateHost": "Host aktualisieren",
+ "addHost": "Host hinzufügen",
+ "editHost": "Host bearbeiten",
+ "pinConnection": "Pin-Verbindung",
+ "authentication": "Authentifizierung",
+ "password": "Passwort",
+ "key": "Schlüssel",
+ "sshPrivateKey": "Privater SSH-Schlüssel",
+ "keyPassword": "Schlüsselkennwort",
+ "keyType": "Schlüsseltyp",
+ "enableTerminal": "Terminal aktivieren",
+ "enableTunnel": "Tunnel aktivieren",
+ "enableFileManager": "Dateimanager aktivieren",
+ "defaultPath": "Standard-Pfad",
+ "tunnelConnections": "Tunnel-Verbindungen",
+ "maxRetries": "Max. Wiederholungsversuche",
+ "upload": "Hochladen",
+ "updateKey": "Schlüssel aktualisieren",
+ "productionFolder": "Produktion",
+ "databaseServer": "Datenbankserver",
+ "developmentServer": "Entwicklungsserver",
+ "developmentFolder": "Entwicklung",
+ "webServerProduction": "Webserver – Produktion",
+ "unknownError": "Unbekannter Fehler",
+ "failedToInitiatePasswordReset": "Das Zurücksetzen des Kennworts konnte nicht eingeleitet werden.",
+ "failedToVerifyResetCode": "Fehler beim Überprüfen des Reset-Codes",
+ "failedToCompletePasswordReset": "Das Zurücksetzen des Kennworts konnte nicht abgeschlossen werden.",
+ "invalidTotpCode": "Ungültiger TOTP-Code",
+ "failedToStartOidcLogin": "Fehler beim Starten der OIDC-Anmeldung",
+ "failedToGetUserInfoAfterOidc": "Fehler beim Abrufen von Benutzerinformationen nach der OIDC-Anmeldung",
+ "loginWithExternalProvider": "Login mit externem Anbieter",
+ "loginWithExternal": "Anmeldung mit externem Anbieter",
+ "sendResetCode": "Reset-Code senden",
+ "verifyCode": "Code bestätigen",
+ "resetPassword": "Passwort zurücksetzen",
+ "login": "Anmelden",
+ "signUp": "Registrieren",
+ "failedToUpdateOidcConfig": "Aktualisierung der OIDC-Konfiguration fehlgeschlagen",
+ "failedToMakeUserAdmin": "Fehler beim Festlegen des Benutzers als Administrator",
+ "failedToStartTotpSetup": "TOTP-Setup konnte nicht gestartet werden",
+ "invalidVerificationCode": "Ungültiger Bestätigungscode",
+ "failedToDisableTotp": "TOTP konnte nicht deaktiviert werden",
+ "failedToGenerateBackupCodes": "Fehler beim Generieren von Backup-Codes"
+ },
+ "mobile": {
+ "selectHostToStart": "Wählen Sie einen Host aus, um Ihre Terminalsitzung zu starten",
+ "limitedSupportMessage": "Die mobile Unterstützung der Website ist noch in Arbeit. Nutzen Sie die mobile App für ein besseres Erlebnis.",
+ "mobileAppInProgress": "Mobile App ist in Arbeit",
+ "mobileAppInProgressDesc": "Wir arbeiten an einer speziellen mobilen App, um ein besseres Erlebnis auf Mobilgeräten zu bieten.",
+ "viewMobileAppDocs": "Mobile App installieren",
+ "mobileAppDocumentation": "Mobile App-Dokumentation"
+ }
+}
\ No newline at end of file
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index d3aff79d..38aedff7 100644
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -191,6 +191,40 @@
"enableRightClickCopyPaste": "Enable right‑click copy/paste",
"shareIdeas": "Have ideas for what should come next for ssh tools? Share them on"
},
+ "snippets": {
+ "title": "Snippets",
+ "new": "New Snippet",
+ "create": "Create Snippet",
+ "edit": "Edit Snippet",
+ "run": "Run",
+ "empty": "No snippets yet",
+ "emptyHint": "Create a snippet to save commonly used commands",
+ "name": "Name",
+ "description": "Description",
+ "content": "Command",
+ "namePlaceholder": "e.g., Restart Nginx",
+ "descriptionPlaceholder": "Optional description",
+ "contentPlaceholder": "e.g., sudo systemctl restart nginx",
+ "nameRequired": "Name is required",
+ "contentRequired": "Command is required",
+ "createDescription": "Create a new command snippet for quick execution",
+ "editDescription": "Edit this command snippet",
+ "deleteConfirmTitle": "Delete Snippet",
+ "deleteConfirmDescription": "Are you sure you want to delete \"{{name}}\"?",
+ "createSuccess": "Snippet created successfully",
+ "updateSuccess": "Snippet updated successfully",
+ "deleteSuccess": "Snippet deleted successfully",
+ "createFailed": "Failed to create snippet",
+ "updateFailed": "Failed to update snippet",
+ "deleteFailed": "Failed to delete snippet",
+ "failedToFetch": "Failed to fetch snippets",
+ "executeSuccess": "Executing: {{name}}",
+ "copySuccess": "Copied \"{{name}}\" to clipboard",
+ "runTooltip": "Execute this snippet in the terminal",
+ "copyTooltip": "Copy snippet to clipboard",
+ "editTooltip": "Edit this snippet",
+ "deleteTooltip": "Delete this snippet"
+ },
"homepage": {
"loggedInTitle": "Logged in!",
"loggedInMessage": "You are logged in! Use the sidebar to access all available tools. To get started, create an SSH Host in the SSH Manager tab. Once created, you can connect to that host using the other apps in the sidebar.",
@@ -286,6 +320,7 @@
"sshTools": "SSH Tools",
"english": "English",
"chinese": "Chinese",
+ "german": "German",
"cancel": "Cancel",
"username": "Username",
"name": "Name",
@@ -356,6 +391,7 @@
"admin": "Admin",
"userProfile": "User Profile",
"tools": "Tools",
+ "snippets": "Snippets",
"newTab": "New Tab",
"splitScreen": "Split Screen",
"closeTab": "Close Tab",
@@ -409,10 +445,12 @@
"general": "General",
"userRegistration": "User Registration",
"allowNewAccountRegistration": "Allow new account registration",
+ "allowPasswordLogin": "Allow username/password login",
"missingRequiredFields": "Missing required fields: {{fields}}",
"oidcConfigurationUpdated": "OIDC configuration updated successfully!",
"failedToFetchOidcConfig": "Failed to fetch OIDC configuration",
"failedToFetchRegistrationStatus": "Failed to fetch registration status",
+ "failedToFetchPasswordLoginStatus": "Failed to fetch password login status",
"failedToFetchUsers": "Failed to fetch users",
"oidcConfigurationDisabled": "OIDC configuration disabled successfully!",
"failedToUpdateOidcConfig": "Failed to update OIDC configuration",
@@ -709,7 +747,11 @@
"connectionTimeout": "Connection timeout",
"terminalTitle": "Terminal - {{host}}",
"terminalWithPath": "Terminal - {{host}}:{{path}}",
- "runTitle": "Running {{command}} - {{host}}"
+ "runTitle": "Running {{command}} - {{host}}",
+ "totpRequired": "Two-Factor Authentication Required",
+ "totpCodeLabel": "Verification Code",
+ "totpPlaceholder": "000000",
+ "totpVerify": "Verify"
},
"fileManager": {
"title": "File Manager",
@@ -993,7 +1035,9 @@
"fileComparison": "File Comparison: {{file1}} vs {{file2}}",
"fileTooLarge": "File too large: {{error}}",
"sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})",
- "loadFileFailed": "Failed to load file: {{error}}"
+ "loadFileFailed": "Failed to load file: {{error}}",
+ "connectedSuccessfully": "Connected successfully",
+ "totpVerificationFailed": "TOTP verification failed"
},
"tunnels": {
"title": "SSH Tunnels",
@@ -1093,6 +1137,8 @@
"refreshing": "Refreshing...",
"serverOffline": "Server Offline",
"cannotFetchMetrics": "Cannot fetch metrics from offline server",
+ "totpRequired": "TOTP Authentication Required",
+ "totpUnavailable": "Server Stats unavailable for TOTP-enabled servers",
"load": "Load",
"free": "Free",
"available": "Available"
diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json
index 0cd92395..3ec02aa5 100644
--- a/src/locales/zh/translation.json
+++ b/src/locales/zh/translation.json
@@ -189,6 +189,40 @@
"enableRightClickCopyPaste": "启用右键复制/粘贴",
"shareIdeas": "对 SSH 工具有什么想法?在此分享"
},
+ "snippets": {
+ "title": "代码片段",
+ "new": "新建片段",
+ "create": "创建代码片段",
+ "edit": "编辑代码片段",
+ "run": "运行",
+ "empty": "暂无代码片段",
+ "emptyHint": "创建代码片段以保存常用命令",
+ "name": "名称",
+ "description": "描述",
+ "content": "命令",
+ "namePlaceholder": "例如: 重启 Nginx",
+ "descriptionPlaceholder": "可选描述",
+ "contentPlaceholder": "例如: sudo systemctl restart nginx",
+ "nameRequired": "名称不能为空",
+ "contentRequired": "命令不能为空",
+ "createDescription": "创建新的命令片段以便快速执行",
+ "editDescription": "编辑此命令片段",
+ "deleteConfirmTitle": "删除代码片段",
+ "deleteConfirmDescription": "确定要删除 \"{{name}}\" 吗?",
+ "createSuccess": "代码片段创建成功",
+ "updateSuccess": "代码片段更新成功",
+ "deleteSuccess": "代码片段删除成功",
+ "createFailed": "创建代码片段失败",
+ "updateFailed": "更新代码片段失败",
+ "deleteFailed": "删除代码片段失败",
+ "failedToFetch": "获取代码片段失败",
+ "executeSuccess": "正在执行: {{name}}",
+ "copySuccess": "已复制 \"{{name}}\" 到剪贴板",
+ "runTooltip": "在终端中执行此片段",
+ "copyTooltip": "复制片段到剪贴板",
+ "editTooltip": "编辑此片段",
+ "deleteTooltip": "删除此片段"
+ },
"homepage": {
"loggedInTitle": "登录成功!",
"loggedInMessage": "您已登录!使用侧边栏访问所有可用工具。要开始使用,请在 SSH 管理器选项卡中创建 SSH 主机。创建后,您可以使用侧边栏中的其他应用程序连接到该主机。",
@@ -280,6 +314,7 @@
"sshTools": "SSH 工具",
"english": "英语",
"chinese": "中文",
+ "german": "德语",
"cancel": "取消",
"username": "用户名",
"name": "名称",
@@ -342,6 +377,7 @@
"admin": "管理员",
"userProfile": "用户资料",
"tools": "工具",
+ "snippets": "代码片段",
"newTab": "新标签页",
"splitScreen": "分屏",
"closeTab": "关闭标签页",
@@ -395,10 +431,12 @@
"general": "常规",
"userRegistration": "用户注册",
"allowNewAccountRegistration": "允许新账户注册",
+ "allowPasswordLogin": "允许用户名/密码登录",
"missingRequiredFields": "缺少必填字段:{{fields}}",
"oidcConfigurationUpdated": "OIDC 配置更新成功!",
"failedToFetchOidcConfig": "获取 OIDC 配置失败",
"failedToFetchRegistrationStatus": "获取注册状态失败",
+ "failedToFetchPasswordLoginStatus": "获取密码登录状态失败",
"failedToFetchUsers": "获取用户列表失败",
"oidcConfigurationDisabled": "OIDC 配置禁用成功!",
"failedToUpdateOidcConfig": "更新 OIDC 配置失败",
@@ -703,6 +741,10 @@
"terminalTitle": "终端 - {{host}}",
"terminalWithPath": "终端 - {{host}}:{{path}}",
"runTitle": "运行 {{command}} - {{host}}",
+ "totpRequired": "需要双因素认证",
+ "totpCodeLabel": "验证码",
+ "totpPlaceholder": "000000",
+ "totpVerify": "验证",
"connect": "连接主机",
"disconnect": "断开连接",
"clear": "清屏",
@@ -984,7 +1026,9 @@
"fileComparison": "文件对比:{{file1}} 与 {{file2}}",
"fileTooLarge": "文件过大:{{error}}",
"sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接",
- "loadFileFailed": "加载文件失败:{{error}}"
+ "loadFileFailed": "加载文件失败:{{error}}",
+ "connectedSuccessfully": "连接成功",
+ "totpVerificationFailed": "TOTP 验证失败"
},
"tunnels": {
"title": "SSH 隧道",
@@ -1072,6 +1116,8 @@
"refreshing": "正在刷新...",
"serverOffline": "服务器离线",
"cannotFetchMetrics": "无法从离线服务器获取指标",
+ "totpRequired": "需要 TOTP 认证",
+ "totpUnavailable": "启用了 TOTP 的服务器无法使用服务器统计功能",
"load": "负载"
},
"auth": {
diff --git a/src/types/index.ts b/src/types/index.ts
index ee7cedb2..e5532893 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -413,6 +413,26 @@ export interface FolderStats {
}>;
}
+// ============================================================================
+// SNIPPETS TYPES
+// ============================================================================
+
+export interface Snippet {
+ id: number;
+ userId: string;
+ name: string;
+ content: string;
+ description?: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface SnippetData {
+ name: string;
+ content: string;
+ description?: string;
+}
+
// ============================================================================
// BACKEND TYPES
// ============================================================================
diff --git a/src/ui/Desktop/Admin/AdminSettings.tsx b/src/ui/Desktop/Admin/AdminSettings.tsx
index e176e234..50f4ec6a 100644
--- a/src/ui/Desktop/Admin/AdminSettings.tsx
+++ b/src/ui/Desktop/Admin/AdminSettings.tsx
@@ -37,8 +37,10 @@ import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getOIDCConfig,
getRegistrationAllowed,
+ getPasswordLoginAllowed,
getUserList,
updateRegistrationAllowed,
+ updatePasswordLoginAllowed,
updateOIDCConfig,
disableOIDCConfig,
makeUserAdmin,
@@ -62,6 +64,9 @@ export function AdminSettings({
const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false);
+ const [allowPasswordLogin, setAllowPasswordLogin] = React.useState(true);
+ const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false);
+
const [oidcConfig, setOidcConfig] = React.useState({
client_id: "",
client_secret: "",
@@ -141,6 +146,27 @@ export function AdminSettings({
});
}, []);
+ React.useEffect(() => {
+ if (isElectron()) {
+ const serverUrl = (window as any).configuredServerUrl;
+ if (!serverUrl) {
+ return;
+ }
+ }
+
+ getPasswordLoginAllowed()
+ .then((res) => {
+ if (typeof res?.allowed === "boolean") {
+ setAllowPasswordLogin(res.allowed);
+ }
+ })
+ .catch((err) => {
+ if (err.code !== "NO_SERVER_CONFIGURED") {
+ toast.error(t("admin.failedToFetchPasswordLoginStatus"));
+ }
+ });
+ }, []);
+
const fetchUsers = async () => {
if (isElectron()) {
const serverUrl = (window as any).configuredServerUrl;
@@ -172,6 +198,16 @@ export function AdminSettings({
}
};
+ const handleTogglePasswordLogin = async (checked: boolean) => {
+ setPasswordLoginLoading(true);
+ try {
+ await updatePasswordLoginAllowed(checked);
+ setAllowPasswordLogin(checked);
+ } finally {
+ setPasswordLoginLoading(false);
+ }
+ };
+
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setOidcLoading(true);
@@ -483,6 +519,14 @@ export function AdminSettings({
/>
{t("admin.allowNewAccountRegistration")}
+
+
+ {t("admin.allowPasswordLogin")}
+
diff --git a/src/ui/Desktop/Apps/File Manager/FileManager.tsx b/src/ui/Desktop/Apps/File Manager/FileManager.tsx
index 884e3ce8..0945fa88 100644
--- a/src/ui/Desktop/Apps/File Manager/FileManager.tsx
+++ b/src/ui/Desktop/Apps/File Manager/FileManager.tsx
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
+import { TOTPDialog } from "@/ui/components/TOTPDialog";
import {
Upload,
FolderPlus,
@@ -38,6 +39,7 @@ import {
renameSSHItem,
moveSSHItem,
connectSSH,
+ verifySSHTOTP,
getSSHStatus,
keepSSHAlive,
identifySSHSymlink,
@@ -98,6 +100,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const [searchQuery, setSearchQuery] = useState("");
const [lastRefreshTime, setLastRefreshTime] = useState(0);
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
+ const [totpRequired, setTotpRequired] = useState(false);
+ const [totpSessionId, setTotpSessionId] = useState(null);
+ const [totpPrompt, setTotpPrompt] = useState("");
const [pinnedFiles, setPinnedFiles] = useState>(new Set());
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
const [isClosing, setIsClosing] = useState(false);
@@ -288,6 +293,14 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
userId: currentHost.userId,
});
+ if (result?.requires_totp) {
+ setTotpRequired(true);
+ setTotpSessionId(sessionId);
+ setTotpPrompt(result.prompt || "Verification code:");
+ setIsLoading(false);
+ return;
+ }
+
setSshSessionId(sessionId);
try {
@@ -589,7 +602,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
async function handleDeleteFiles(files: FileItem[]) {
if (!sshSessionId || files.length === 0) return;
- // Determine the confirmation message based on file count and type
let confirmMessage: string;
if (files.length === 1) {
const file = files[0];
@@ -613,10 +625,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
});
}
- // Add permanent deletion warning
const fullMessage = `${confirmMessage}\n\n${t("fileManager.permanentDeleteWarning")}`;
- // Show confirmation dialog
confirmWithToast(
fullMessage,
async () => {
@@ -1237,6 +1247,47 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setEditingFile(null);
}
+ async function handleTotpSubmit(code: string) {
+ if (!totpSessionId || !code) return;
+
+ try {
+ setIsLoading(true);
+ const result = await verifySSHTOTP(totpSessionId, code);
+
+ if (result?.status === "success") {
+ setTotpRequired(false);
+ setTotpPrompt("");
+ setSshSessionId(totpSessionId);
+ setTotpSessionId(null);
+
+ try {
+ const response = await listSSHFiles(totpSessionId, currentPath);
+ const files = Array.isArray(response)
+ ? response
+ : response?.files || [];
+ setFiles(files);
+ clearSelection();
+ initialLoadDoneRef.current = true;
+ toast.success(t("fileManager.connectedSuccessfully"));
+ } catch (dirError: any) {
+ console.error("Failed to load initial directory:", dirError);
+ }
+ }
+ } catch (error: any) {
+ console.error("TOTP verification failed:", error);
+ toast.error(t("fileManager.totpVerificationFailed"));
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ function handleTotpCancel() {
+ setTotpRequired(false);
+ setTotpPrompt("");
+ setTotpSessionId(null);
+ if (onClose) onClose();
+ }
+
function generateUniqueName(
baseName: string,
type: "file" | "directory",
@@ -1805,6 +1856,13 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
/>
+
+
);
}
diff --git a/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx b/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx
index 565078eb..b5eff4da 100644
--- a/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx
+++ b/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx
@@ -212,7 +212,18 @@ export function HostManagerEditor({
defaultPath: z.string().optional(),
})
.superRefine((data, ctx) => {
- if (data.authType === "key") {
+ if (data.authType === "password") {
+ if (
+ !data.password ||
+ (typeof data.password === "string" && data.password.trim() === "")
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: t("hosts.passwordRequired"),
+ path: ["password"],
+ });
+ }
+ } else if (data.authType === "key") {
if (
!data.key ||
(typeof data.key === "string" && data.key.trim() === "")
@@ -868,21 +879,6 @@ export function HostManagerEditor({
| "credential";
setAuthTab(newAuthType);
form.setValue("authType", newAuthType);
-
- if (newAuthType === "password") {
- form.setValue("key", null);
- form.setValue("keyPassword", "");
- form.setValue("keyType", "auto");
- form.setValue("credentialId", null);
- } else if (newAuthType === "key") {
- form.setValue("password", "");
- form.setValue("credentialId", null);
- } else if (newAuthType === "credential") {
- form.setValue("password", "");
- form.setValue("key", null);
- form.setValue("keyPassword", "");
- form.setValue("keyType", "auto");
- }
}}
className="flex-1 flex flex-col h-full min-h-0"
>
diff --git a/src/ui/Desktop/Apps/Server/Server.tsx b/src/ui/Desktop/Apps/Server/Server.tsx
index 413f4613..67c5c4d6 100644
--- a/src/ui/Desktop/Apps/Server/Server.tsx
+++ b/src/ui/Desktop/Apps/Server/Server.tsx
@@ -119,11 +119,16 @@ export function Server({
setMetrics(data);
setShowStatsUI(true);
}
- } catch (error) {
+ } catch (error: any) {
if (!cancelled) {
setMetrics(null);
setShowStatsUI(false);
- toast.error(t("serverStats.failedToFetchMetrics"));
+ if (error?.code === "TOTP_REQUIRED" ||
+ (error?.response?.status === 403 && error?.response?.data?.error === "TOTP_REQUIRED")) {
+ toast.error(t("serverStats.totpUnavailable"));
+ } else {
+ toast.error(t("serverStats.failedToFetchMetrics"));
+ }
}
} finally {
if (!cancelled) {
@@ -210,17 +215,28 @@ export function Server({
setMetrics(data);
setShowStatsUI(true);
} catch (error: any) {
- if (error?.response?.status === 503) {
+ if (error?.code === "TOTP_REQUIRED" ||
+ (error?.response?.status === 403 && error?.response?.data?.error === "TOTP_REQUIRED")) {
+ toast.error(t("serverStats.totpUnavailable"));
+ setMetrics(null);
+ setShowStatsUI(false);
+ } else if (error?.response?.status === 503 || error?.status === 503) {
setServerStatus("offline");
- } else if (error?.response?.status === 504) {
+ setMetrics(null);
+ setShowStatsUI(false);
+ } else if (error?.response?.status === 504 || error?.status === 504) {
setServerStatus("offline");
- } else if (error?.response?.status === 404) {
+ setMetrics(null);
+ setShowStatsUI(false);
+ } else if (error?.response?.status === 404 || error?.status === 404) {
setServerStatus("offline");
+ setMetrics(null);
+ setShowStatsUI(false);
} else {
setServerStatus("offline");
+ setMetrics(null);
+ setShowStatsUI(false);
}
- setMetrics(null);
- setShowStatsUI(false);
} finally {
setIsRefreshing(false);
}
diff --git a/src/ui/Desktop/Apps/Terminal/SnippetsSidebar.tsx b/src/ui/Desktop/Apps/Terminal/SnippetsSidebar.tsx
new file mode 100644
index 00000000..aff62244
--- /dev/null
+++ b/src/ui/Desktop/Apps/Terminal/SnippetsSidebar.tsx
@@ -0,0 +1,407 @@
+import React, { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Separator } from "@/components/ui/separator";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { Plus, Play, Edit, Trash2, Copy } from "lucide-react";
+import { toast } from "sonner";
+import { useTranslation } from "react-i18next";
+import { useConfirmation } from "@/hooks/use-confirmation.ts";
+import {
+ getSnippets,
+ createSnippet,
+ updateSnippet,
+ deleteSnippet,
+} from "@/ui/main-axios";
+import type { Snippet, SnippetData } from "../../../../types/index.js";
+
+interface SnippetsSidebarProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onExecute: (content: string) => void;
+}
+
+export function SnippetsSidebar({
+ isOpen,
+ onClose,
+ onExecute,
+}: SnippetsSidebarProps) {
+ const { t } = useTranslation();
+ const { confirmWithToast } = useConfirmation();
+ const [snippets, setSnippets] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showDialog, setShowDialog] = useState(false);
+ const [editingSnippet, setEditingSnippet] = useState(null);
+ const [formData, setFormData] = useState({
+ name: "",
+ content: "",
+ description: "",
+ });
+ const [formErrors, setFormErrors] = useState({
+ name: false,
+ content: false,
+ });
+
+ useEffect(() => {
+ if (isOpen) {
+ fetchSnippets();
+ }
+ }, [isOpen]);
+
+ const fetchSnippets = async () => {
+ try {
+ setLoading(true);
+ const data = await getSnippets();
+ // Defensive: ensure data is an array
+ setSnippets(Array.isArray(data) ? data : []);
+ } catch (err) {
+ toast.error(t("snippets.failedToFetch"));
+ setSnippets([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCreate = () => {
+ setEditingSnippet(null);
+ setFormData({ name: "", content: "", description: "" });
+ setFormErrors({ name: false, content: false });
+ setShowDialog(true);
+ };
+
+ const handleEdit = (snippet: Snippet) => {
+ setEditingSnippet(snippet);
+ setFormData({
+ name: snippet.name,
+ content: snippet.content,
+ description: snippet.description || "",
+ });
+ setFormErrors({ name: false, content: false });
+ setShowDialog(true);
+ };
+
+ const handleDelete = (snippet: Snippet) => {
+ confirmWithToast(
+ t("snippets.deleteConfirmDescription", { name: snippet.name }),
+ async () => {
+ try {
+ await deleteSnippet(snippet.id);
+ toast.success(t("snippets.deleteSuccess"));
+ fetchSnippets();
+ } catch (err) {
+ toast.error(t("snippets.deleteFailed"));
+ }
+ },
+ "destructive",
+ );
+ };
+
+ const handleSubmit = async () => {
+ // Validate required fields
+ const errors = {
+ name: !formData.name.trim(),
+ content: !formData.content.trim(),
+ };
+
+ setFormErrors(errors);
+
+ if (errors.name || errors.content) {
+ return;
+ }
+
+ try {
+ if (editingSnippet) {
+ await updateSnippet(editingSnippet.id, formData);
+ toast.success(t("snippets.updateSuccess"));
+ } else {
+ await createSnippet(formData);
+ toast.success(t("snippets.createSuccess"));
+ }
+ setShowDialog(false);
+ fetchSnippets();
+ } catch (err) {
+ toast.error(
+ editingSnippet
+ ? t("snippets.updateFailed")
+ : t("snippets.createFailed"),
+ );
+ }
+ };
+
+ const handleExecute = (snippet: Snippet) => {
+ onExecute(snippet.content);
+ toast.success(t("snippets.executeSuccess", { name: snippet.name }));
+ };
+
+ const handleCopy = (snippet: Snippet) => {
+ navigator.clipboard.writeText(snippet.content);
+ toast.success(t("snippets.copySuccess", { name: snippet.name }));
+ };
+
+ if (!isOpen) return null;
+
+ return (
+ <>
+ {/* Overlay and Sidebar */}
+
+
+
+
e.stopPropagation()}
+ >
+ {/* Header */}
+
+
+ {t("snippets.title")}
+
+
+ ×
+
+
+
+ {/* Content */}
+
+
+
+
+ {t("snippets.new")}
+
+
+ {loading ? (
+
+
{t("common.loading")}
+
+ ) : snippets.length === 0 ? (
+
+
{t("snippets.empty")}
+
{t("snippets.emptyHint")}
+
+ ) : (
+
+
+ {snippets.map((snippet) => (
+
+
+
+ {snippet.name}
+
+ {snippet.description && (
+
+ {snippet.description}
+
+ )}
+
+
+
+
+ {snippet.content}
+
+
+
+
+
+
+ handleExecute(snippet)}
+ >
+
+ {t("snippets.run")}
+
+
+
+ {t("snippets.runTooltip")}
+
+
+
+
+
+ handleCopy(snippet)}
+ >
+
+
+
+
+ {t("snippets.copyTooltip")}
+
+
+
+
+
+ handleEdit(snippet)}
+ >
+
+
+
+
+ {t("snippets.editTooltip")}
+
+
+
+
+
+ handleDelete(snippet)}
+ className="hover:bg-destructive hover:text-destructive-foreground"
+ >
+
+
+
+
+ {t("snippets.deleteTooltip")}
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ {/* Create/Edit Dialog - centered modal */}
+ {showDialog && (
+ setShowDialog(false)}
+ >
+
e.stopPropagation()}
+ >
+
+
+ {editingSnippet ? t("snippets.edit") : t("snippets.create")}
+
+
+ {editingSnippet
+ ? t("snippets.editDescription")
+ : t("snippets.createDescription")}
+
+
+
+
+
+
+ {t("snippets.name")}
+ *
+
+
+ setFormData({ ...formData, name: e.target.value })
+ }
+ placeholder={t("snippets.namePlaceholder")}
+ className={`${formErrors.name ? "border-destructive focus-visible:ring-destructive" : ""}`}
+ autoFocus
+ />
+ {formErrors.name && (
+
+ {t("snippets.nameRequired")}
+
+ )}
+
+
+
+
+ {t("snippets.description")}
+
+ ({t("common.optional")})
+
+
+
+ setFormData({ ...formData, description: e.target.value })
+ }
+ placeholder={t("snippets.descriptionPlaceholder")}
+ />
+
+
+
+
+ {t("snippets.content")}
+ *
+
+
+
+
+
+
+
+ setShowDialog(false)}
+ className="flex-1"
+ >
+ {t("common.cancel")}
+
+
+ {editingSnippet ? t("common.update") : t("common.create")}
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/ui/Desktop/Apps/Terminal/Terminal.tsx b/src/ui/Desktop/Apps/Terminal/Terminal.tsx
index 1a53502e..dd6f72fd 100644
--- a/src/ui/Desktop/Apps/Terminal/Terminal.tsx
+++ b/src/ui/Desktop/Apps/Terminal/Terminal.tsx
@@ -13,6 +13,7 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getCookie, isElectron } from "@/ui/main-axios.ts";
+import { TOTPDialog } from "@/ui/components/TOTPDialog";
interface SSHTerminalProps {
hostConfig: any;
@@ -55,6 +56,8 @@ export const Terminal = forwardRef(function SSHTerminal(
const [isConnecting, setIsConnecting] = useState(false);
const [connectionError, setConnectionError] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
+ const [totpRequired, setTotpRequired] = useState(false);
+ const [totpPrompt, setTotpPrompt] = useState("");
const isVisibleRef = useRef(false);
const reconnectTimeoutRef = useRef(null);
const reconnectAttempts = useRef(0);
@@ -104,6 +107,25 @@ export const Terminal = forwardRef(function SSHTerminal(
}
}
+ function handleTotpSubmit(code: string) {
+ if (webSocketRef.current && code) {
+ webSocketRef.current.send(
+ JSON.stringify({
+ type: "totp_response",
+ data: { code },
+ }),
+ );
+ setTotpRequired(false);
+ setTotpPrompt("");
+ }
+ }
+
+ function handleTotpCancel() {
+ setTotpRequired(false);
+ setTotpPrompt("");
+ if (onClose) onClose();
+ }
+
function scheduleNotify(cols: number, rows: number) {
if (!(cols > 0 && rows > 0)) return;
pendingSizeRef.current = { cols, rows };
@@ -426,6 +448,9 @@ export const Terminal = forwardRef(function SSHTerminal(
if (onClose) {
onClose();
}
+ } else if (msg.type === "totp_required") {
+ setTotpRequired(true);
+ setTotpPrompt(msg.prompt || "Verification code:");
}
} catch (error) {
toast.error(t("terminal.messageParseError"));
@@ -521,7 +546,7 @@ export const Terminal = forwardRef(function SSHTerminal(
scrollback: 10000,
fontSize: 14,
fontFamily:
- '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
+ '"Caskaydia Cove Nerd Font Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
theme: { background: "#18181b", foreground: "#f7f7f7" },
allowTransparency: true,
convertEol: true,
@@ -739,45 +764,48 @@ export const Terminal = forwardRef(function SSHTerminal(
)}
+
+
);
});
const style = document.createElement("style");
style.innerHTML = `
-@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap');
-
-/* Load NerdFonts locally with fallback handling */
@font-face {
- font-family: 'JetBrains Mono Nerd Font';
- src: url('./fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
+ font-family: 'Caskaydia Cove Nerd Font Mono';
+ src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
- font-family: 'JetBrains Mono Nerd Font';
- src: url('./fonts/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
+ font-family: 'Caskaydia Cove Nerd Font Mono';
+ src: url('./fonts/CaskaydiaCoveNerdFontMono-Bold.ttf') format('truetype');
font-weight: bold;
font-style: normal;
font-display: swap;
}
@font-face {
- font-family: 'JetBrains Mono Nerd Font';
- src: url('./fonts/JetBrainsMonoNerdFont-Italic.ttf') format('truetype');
+ font-family: 'Caskaydia Cove Nerd Font Mono';
+ src: url('./fonts/CaskaydiaCoveNerdFontMono-Italic.ttf') format('truetype');
font-weight: normal;
font-style: italic;
font-display: swap;
}
-/* Fallback fonts for when custom fonts fail to load */
@font-face {
- font-family: 'Terminal Fallback';
- src: local('SF Mono'), local('Monaco'), local('Consolas'), local('Liberation Mono'), local('Courier New');
- font-weight: normal;
- font-style: normal;
+ font-family: 'Caskaydia Cove Nerd Font Mono';
+ src: url('./fonts/CaskaydiaCoveNerdFontMono-BoldItalic.ttf') format('truetype');
+ font-weight: bold;
+ font-style: italic;
font-display: swap;
}
@@ -805,80 +833,12 @@ style.innerHTML = `
}
.xterm .xterm-screen {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font', 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !important;
+ font-family: 'Caskaydia Cove Nerd Font Mono', 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !important;
font-variant-ligatures: contextual;
}
.xterm .xterm-screen .xterm-char {
font-feature-settings: "liga" 1, "calt" 1;
}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\\uE"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\\uE000"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\\uE001"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\\uE002"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\\uE003"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\\uE004"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\\uE005"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\\uE006"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\\uE007"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\\uE008"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\\uE009"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\\uE00A"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\\uE00B"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\\uE00C"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\\uE00D"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\\uE00E"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\\uE00F"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
`;
document.head.appendChild(style);
diff --git a/src/ui/Desktop/DesktopApp.tsx b/src/ui/Desktop/DesktopApp.tsx
index 3c1487f6..add9611d 100644
--- a/src/ui/Desktop/DesktopApp.tsx
+++ b/src/ui/Desktop/DesktopApp.tsx
@@ -24,7 +24,10 @@ function AppContent() {
const [isAdmin, setIsAdmin] = useState(false);
const [authLoading, setAuthLoading] = useState(true);
const [showVersionCheck, setShowVersionCheck] = useState(true);
- const [isTopbarOpen, setIsTopbarOpen] = useState(true);
+ const [isTopbarOpen, setIsTopbarOpen] = useState(() => {
+ const saved = localStorage.getItem("topNavbarOpen");
+ return saved !== null ? JSON.parse(saved) : true;
+ });
const { currentTab, tabs } = useTabs();
useEffect(() => {
@@ -64,6 +67,10 @@ function AppContent() {
return () => window.removeEventListener("storage", handleStorageChange);
}, []);
+ useEffect(() => {
+ localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen));
+ }, [isTopbarOpen]);
+
const handleSelectView = (nextView: string) => {
setMountedViews((prev) => {
if (prev.has(nextView)) return prev;
diff --git a/src/ui/components/DragIndicator.tsx b/src/ui/Desktop/Homepage/DragIndicator.tsx
similarity index 99%
rename from src/ui/components/DragIndicator.tsx
rename to src/ui/Desktop/Homepage/DragIndicator.tsx
index 51cff434..6aedf278 100644
--- a/src/ui/components/DragIndicator.tsx
+++ b/src/ui/Desktop/Homepage/DragIndicator.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import { cn } from "@/lib/utils";
+import { cn } from "@/lib/utils.ts";
import { useTranslation } from "react-i18next";
import {
Download,
diff --git a/src/ui/Desktop/Homepage/Homepage.tsx b/src/ui/Desktop/Homepage/Homepage.tsx
index fe4ee091..786297c6 100644
--- a/src/ui/Desktop/Homepage/Homepage.tsx
+++ b/src/ui/Desktop/Homepage/Homepage.tsx
@@ -42,8 +42,8 @@ export function Homepage({
if (isAuthenticated) {
const jwt = getCookie("jwt");
if (jwt) {
- Promise.all([getUserInfo(), getDatabaseHealth()])
- .then(([meRes]) => {
+ getUserInfo()
+ .then((meRes) => {
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
@@ -58,12 +58,20 @@ export function Homepage({
if (errorCode === "SESSION_EXPIRED") {
console.warn("Session expired - please log in again");
setDbError("Session expired - please log in again");
- } else if (err?.response?.data?.error?.includes("Database")) {
+ } else {
+ setDbError(null);
+ }
+ });
+
+ getDatabaseHealth()
+ .then(() => {
+ setDbError(null);
+ })
+ .catch((err) => {
+ if (err?.response?.data?.error?.includes("Database")) {
setDbError(
"Could not connect to the database. Please try again later.",
);
- } else {
- setDbError(null);
}
});
}
diff --git a/src/ui/Desktop/Navigation/LeftSidebar.tsx b/src/ui/Desktop/Navigation/LeftSidebar.tsx
index 2f7ef7ee..72ae3bef 100644
--- a/src/ui/Desktop/Navigation/LeftSidebar.tsx
+++ b/src/ui/Desktop/Navigation/LeftSidebar.tsx
@@ -101,7 +101,10 @@ export function LeftSidebar({
const [deleteLoading, setDeleteLoading] = React.useState(false);
const [deleteError, setDeleteError] = React.useState(null);
- const [isSidebarOpen, setIsSidebarOpen] = useState(true);
+ const [isSidebarOpen, setIsSidebarOpen] = useState(() => {
+ const saved = localStorage.getItem("leftSidebarOpen");
+ return saved !== null ? JSON.parse(saved) : true;
+ });
const {
tabs: tabList,
@@ -181,7 +184,6 @@ export function LeftSidebar({
newHost.key !== existingHost.key ||
newHost.keyPassword !== existingHost.keyPassword ||
newHost.keyType !== existingHost.keyType ||
- newHost.credentialId !== existingHost.credentialId ||
newHost.defaultPath !== existingHost.defaultPath ||
JSON.stringify(newHost.tags) !==
JSON.stringify(existingHost.tags) ||
@@ -247,6 +249,10 @@ export function LeftSidebar({
return () => clearTimeout(handler);
}, [search]);
+ React.useEffect(() => {
+ localStorage.setItem("leftSidebarOpen", JSON.stringify(isSidebarOpen));
+ }, [isSidebarOpen]);
+
const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
const q = debouncedSearch.trim().toLowerCase();
diff --git a/src/ui/Desktop/Navigation/TopNavbar.tsx b/src/ui/Desktop/Navigation/TopNavbar.tsx
index 379a4453..10487a1f 100644
--- a/src/ui/Desktop/Navigation/TopNavbar.tsx
+++ b/src/ui/Desktop/Navigation/TopNavbar.tsx
@@ -1,7 +1,7 @@
import React, { useState } from "react";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Button } from "@/components/ui/button.tsx";
-import { ChevronDown, ChevronUpIcon, Hammer } from "lucide-react";
+import { ChevronDown, ChevronUpIcon, Hammer, FileText } from "lucide-react";
import { Tab } from "@/ui/Desktop/Navigation/Tabs/Tab.tsx";
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import {
@@ -16,6 +16,7 @@ import { Separator } from "@/components/ui/separator.tsx";
import { useTranslation } from "react-i18next";
import { TabDropdown } from "@/ui/Desktop/Navigation/Tabs/TabDropdown.tsx";
import { getCookie, setCookie } from "@/ui/main-axios.ts";
+import { SnippetsSidebar } from "@/ui/Desktop/Apps/Terminal/SnippetsSidebar.tsx";
interface TopNavbarProps {
isTopbarOpen: boolean;
@@ -41,6 +42,7 @@ export function TopNavbar({
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [selectedTabIds, setSelectedTabIds] = useState([]);
+ const [snippetsSidebarOpen, setSnippetsSidebarOpen] = useState(false);
const handleTabActivate = (tabId: number) => {
setCurrentTab(tabId);
@@ -212,6 +214,13 @@ export function TopNavbar({
}
};
+ const handleSnippetExecute = (content: string) => {
+ const tab = tabs.find((t: any) => t.id === currentTab);
+ if (tab?.terminalRef?.current?.sendInput) {
+ tab.terminalRef.current.sendInput(content + "\n");
+ }
+ };
+
const isSplitScreenActive =
Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
const currentTabObj = tabs.find((t: any) => t.id === currentTab);
@@ -317,6 +326,16 @@ export function TopNavbar({
+ setSnippetsSidebarOpen(true)}
+ disabled={!currentTabObj || currentTabObj.type !== "terminal"}
+ >
+
+
+
setIsTopbarOpen(false)}
@@ -484,6 +503,12 @@ export function TopNavbar({
)}
+
+ setSnippetsSidebarOpen(false)}
+ onExecute={handleSnippetExecute}
+ />
);
}
diff --git a/src/ui/Desktop/User/LanguageSwitcher.tsx b/src/ui/Desktop/User/LanguageSwitcher.tsx
index e2b2856f..525054ed 100644
--- a/src/ui/Desktop/User/LanguageSwitcher.tsx
+++ b/src/ui/Desktop/User/LanguageSwitcher.tsx
@@ -12,6 +12,7 @@ import { Globe } from "lucide-react";
const languages = [
{ code: "en", name: "English", nativeName: "English" },
{ code: "zh", name: "Chinese", nativeName: "中文" },
+ { code: "de", name: "German", nativeName: "Deutsch" },
];
export function LanguageSwitcher() {
diff --git a/src/ui/Mobile/Apps/Terminal/Terminal.tsx b/src/ui/Mobile/Apps/Terminal/Terminal.tsx
index 4a442686..e27453e1 100644
--- a/src/ui/Mobile/Apps/Terminal/Terminal.tsx
+++ b/src/ui/Mobile/Apps/Terminal/Terminal.tsx
@@ -219,7 +219,7 @@ export const Terminal = forwardRef(function SSHTerminal(
scrollback: 10000,
fontSize: 14,
fontFamily:
- '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
+ '"Caskaydia Cove Nerd Font Mono", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
theme: { background: "#09090b", foreground: "#f7f7f7" },
allowTransparency: true,
convertEol: true,
@@ -390,39 +390,35 @@ export const Terminal = forwardRef(function SSHTerminal(
const style = document.createElement("style");
style.innerHTML = `
-@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap');
-
-/* Load NerdFonts locally with fallback handling */
@font-face {
- font-family: 'JetBrains Mono Nerd Font';
- src: url('./fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
+ font-family: 'Caskaydia Cove Nerd Font Mono';
+ src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
- font-family: 'JetBrains Mono Nerd Font';
- src: url('./fonts/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
+ font-family: 'Caskaydia Cove Nerd Font Mono';
+ src: url('./fonts/CaskaydiaCoveNerdFontMono-Bold.ttf') format('truetype');
font-weight: bold;
font-style: normal;
font-display: swap;
}
@font-face {
- font-family: 'JetBrains Mono Nerd Font';
- src: url('./fonts/JetBrainsMonoNerdFont-Italic.ttf') format('truetype');
+ font-family: 'Caskaydia Cove Nerd Font Mono';
+ src: url('./fonts/CaskaydiaCoveNerdFontMono-Italic.ttf') format('truetype');
font-weight: normal;
font-style: italic;
font-display: swap;
}
-/* Fallback fonts for when custom fonts fail to load */
@font-face {
- font-family: 'Terminal Fallback';
- src: local('SF Mono'), local('Monaco'), local('Consolas'), local('Liberation Mono'), local('Courier New');
- font-weight: normal;
- font-style: normal;
+ font-family: 'Caskaydia Cove Nerd Font Mono';
+ src: url('./fonts/CaskaydiaCoveNerdFontMono-BoldItalic.ttf') format('truetype');
+ font-weight: bold;
+ font-style: italic;
font-display: swap;
}
@@ -450,76 +446,12 @@ style.innerHTML = `
}
.xterm .xterm-screen {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font', 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !important;
+ font-family: 'Caskaydia Cove Nerd Font Mono', 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !important;
font-variant-ligatures: contextual;
}
.xterm .xterm-screen .xterm-char {
font-feature-settings: "liga" 1, "calt" 1;
}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\uE000"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\uE001"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\uE002"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\uE003"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\uE004"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\uE005"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\uE006"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\uE007"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\uE008"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\uE009"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\uE00A"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\uE00B"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\uE00C"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\uE00D"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\uE00E"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
-
-.xterm .xterm-screen .xterm-char[data-char-code^="\uE00F"] {
- font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
-}
`;
document.head.appendChild(style);
diff --git a/src/ui/components/TOTPDialog.tsx b/src/ui/components/TOTPDialog.tsx
new file mode 100644
index 00000000..8fa7f182
--- /dev/null
+++ b/src/ui/components/TOTPDialog.tsx
@@ -0,0 +1,81 @@
+import React from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Shield } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+interface TOTPDialogProps {
+ isOpen: boolean;
+ prompt: string;
+ onSubmit: (code: string) => void;
+ onCancel: () => void;
+}
+
+export function TOTPDialog({
+ isOpen,
+ prompt,
+ onSubmit,
+ onCancel,
+}: TOTPDialogProps) {
+ const { t } = useTranslation();
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
+
+
+ {t("terminal.totpRequired")}
+
+
+
{prompt}
+
+
+
+ );
+}
diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts
index 57360fe4..f72fefdb 100644
--- a/src/ui/main-axios.ts
+++ b/src/ui/main-axios.ts
@@ -526,8 +526,8 @@ function handleApiError(error: unknown, operation: string): never {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
- const message = error.response?.data?.error || error.message;
- const code = error.response?.data?.code;
+ const message = error.response?.data?.message || error.response?.data?.error || error.message;
+ const code = error.response?.data?.code || error.response?.data?.error;
const url = error.config?.url;
const method = error.config?.method?.toUpperCase();
@@ -554,11 +554,15 @@ function handleApiError(error: unknown, operation: string): never {
throw new ApiError(errorMessage, 401, "AUTH_REQUIRED");
} else if (status === 403) {
authLogger.warn(`Access denied: ${method} ${url}`, errorContext);
- throw new ApiError(
- "Access denied. You do not have permission to perform this action.",
+ const apiError = new ApiError(
+ code === "TOTP_REQUIRED"
+ ? message
+ : "Access denied. You do not have permission to perform this action.",
403,
- "ACCESS_DENIED",
+ code || "ACCESS_DENIED",
);
+ (apiError as any).response = error.response;
+ throw apiError;
} else if (status === 404) {
apiLogger.warn(`Not found: ${method} ${url}`, errorContext);
throw new ApiError(
@@ -1057,6 +1061,21 @@ export async function disconnectSSH(sessionId: string): Promise {
}
}
+export async function verifySSHTOTP(
+ sessionId: string,
+ totpCode: string,
+): Promise {
+ try {
+ const response = await fileManagerApi.post("/ssh/connect-totp", {
+ sessionId,
+ totpCode,
+ });
+ return response.data;
+ } catch (error) {
+ handleApiError(error, "verify SSH TOTP");
+ }
+}
+
export async function getSSHStatus(
sessionId: string,
): Promise<{ connected: boolean }> {
@@ -1605,6 +1624,15 @@ export async function getRegistrationAllowed(): Promise<{ allowed: boolean }> {
}
}
+export async function getPasswordLoginAllowed(): Promise<{ allowed: boolean }> {
+ try {
+ const response = await authApi.get("/users/password-login-allowed");
+ return response.data;
+ } catch (error) {
+ handleApiError(error, "check password login status");
+ }
+}
+
export async function getOIDCConfig(): Promise {
try {
const response = await authApi.get("/users/oidc-config");
@@ -1752,6 +1780,19 @@ export async function updateRegistrationAllowed(
}
}
+export async function updatePasswordLoginAllowed(
+ allowed: boolean,
+): Promise<{ allowed: boolean }> {
+ try {
+ const response = await authApi.patch("/users/password-login-allowed", {
+ allowed,
+ });
+ return response.data;
+ } catch (error) {
+ handleApiError(error, "update password login allowed");
+ }
+}
+
export async function updateOIDCConfig(config: any): Promise {
try {
const response = await authApi.post("/users/oidc-config", config);
@@ -2155,3 +2196,46 @@ export async function deployCredentialToHost(
throw handleApiError(error, "deploy credential to host");
}
}
+
+// ============================================================================
+// SNIPPETS API
+// ============================================================================
+
+export async function getSnippets(): Promise {
+ try {
+ const response = await authApi.get("/snippets");
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "fetch snippets");
+ }
+}
+
+export async function createSnippet(snippetData: any): Promise {
+ try {
+ const response = await authApi.post("/snippets", snippetData);
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "create snippet");
+ }
+}
+
+export async function updateSnippet(
+ snippetId: number,
+ snippetData: any,
+): Promise {
+ try {
+ const response = await authApi.put(`/snippets/${snippetId}`, snippetData);
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "update snippet");
+ }
+}
+
+export async function deleteSnippet(snippetId: number): Promise {
+ try {
+ const response = await authApi.delete(`/snippets/${snippetId}`);
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "delete snippet");
+ }
+}