diff --git a/.gitignore b/.gitignore index 08f97481..5121dc4f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ dist-ssr electron/build-info.cjs /.mcp.json /CLAUDE.md +/old_db/ diff --git a/README.md b/README.md index 117836d6..6eaa7277 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,221 @@ -# Repo Stats +
-

-🇺🇸 English · 🇨🇳 中文 · 🇯🇵 日本語 · 🇰🇷 한국어 · 🇫🇷 Français · 🇩🇪 Deutsch · 🇪🇸 Español · 🇧🇷 Português · 🇷🇺 Русский · 🇸🇦 العربية · 🇮🇳 हिन्दी · 🇹🇷 Türkçe · 🇻🇳 Tiếng Việt · 🇮🇹 Italiano +Termix Logo + +

Termix

+ +

Self-hosted SSH management and remote desktop access

+ +

+ English · + 中文 · + 日本語 · + 한국어 · + Français · + Deutsch · + Español · + Português · + Русский · + العربية · + हिन्दी · + Türkçe · + Tiếng Việt · + Italiano

-![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) -![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) -![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&v=1) -Discord - -

- Repo of the Day Achievement -
- Achieved on September 1st, 2025 +

+ + + + Discord


-

- - Termix Banner + +Termix Banner + +
+
+ +

+ Repo of the Day Achievement +
+ Achieved on September 1st, 2025

-# Overview +
-

- - Termix Banner -

+
-Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a multi-platform -solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal -access, remote desktop control (RDP, VNC, Telnet), SSH tunneling capabilities, remote SSH file management, and many other tools. Termix is the perfect -free and self-hosted alternative to Termius available for all platforms. +## Overview -# Features +Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a multi-platform solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal access, remote desktop control (RDP, VNC, Telnet), SSH tunneling capabilities, remote file management, and many other tools. Termix is the perfect free and self-hosted alternative to Termius available for all platforms. + +
+ +## Features + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +**SSH Terminal Access:** +Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components. + + + +**Remote Desktop Access:** +RDP, VNC, and Telnet support over the browser with complete customization and split screening. + +
+ +**SSH Tunnel Management:** +Create and manage server-to-server SSH tunnels with automatic reconnection, health monitoring, and local, remote, or dynamic SOCKS forwarding. Desktop client-to-server tunnel settings are stored locally per desktop install, optional C2S preset snapshots can be saved to the server, renamed, loaded, or deleted when you want to move a local tunnel configuration between clients. + + + +**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 with sudo support. + +
+ +**Docker Management:** +Start, stop, pause, remove containers. View container stats. Control container using docker exec terminal. It was not made to replace Portainer or Dockge but rather to simply manage your containers compared to creating them. + + + +**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 disk usage along with network, uptime, system information, firewall, port monitor, on most Linux based servers. + + + +**User Authentication:** +Secure user management with admin controls and OIDC (with access control) and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together. + +
+ +**RBAC:** +Create roles and share hosts across users/roles. + + + +**Database Encryption:** +Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more. + +
+ +**Network Graph:** +Customize your Dashboard to visualize your homelab based off your SSH connections with status support. + + + +**SSH Tools:** +Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals. + +
+ +**Persistent Tabs:** +SSH sessions and tabs stay open across devices/refreshes if enabled in user profile. + + + +**Languages:** +Built-in support ~30 languages (managed by [Crowdin](https://docs.termix.site/translations)). + +
+ +
+ +
+More features +
-- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components. -- **Remote Desktop Access** - RDP, VNC, and Telnet support over the browser with complete customization and split screening -- **SSH Tunnel Management** - Create and manage server-to-server SSH tunnels with automatic reconnection, health monitoring, and local, remote, or dynamic SOCKS forwarding. Desktop client-to-server tunnel settings are stored locally per desktop install, optional C2S preset snapshots can be saved to the server, renamed, loaded, or deleted when you want to move a local tunnel configuration between clients. -- **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 with sudo support. -- **Docker Management** - Start, stop, pause, remove containers. View container stats. Control container using docker exec terminal. It was not made to replace Portainer or Dockge but rather to simply manage your containers compared to creating them. -- **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 disk usage along with network, uptime, system information, firewall, port monitor, on most Linux based servers - **Dashboard** - View server information at a glance on your dashboard -- **RBAC** - Create roles and share hosts across users/roles -- **User Authentication** - Secure user management with admin controls and OIDC (with access control) and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together. -- **Database Encryption** - Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more. - **API Keys** - Create user-scoped API keys with expiration dates to be used for automation/CI - **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data - **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. Choose between many different UI themes including light, dark, Dracula, etc. Use URL routes to open any connection in full-screen. -- **Languages** - Built-in support ~30 languages (managed by [Crowdin](https://docs.termix.site/translations)) -- **Platform Support** - Available as a web app, desktop application (Windows, Linux, and macOS, can be run standalone without Termix backend), PWA, and dedicated mobile/tablet app for iOS and Android. -- **SSH Tools** - Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals. - **Command History** - Auto-complete and view previously ran SSH commands - **Quick Connect** - Connect to a server without having to save the connection data - **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard - **SSH Feature Rich** - Supports jump hosts, Warpgate, TOTP based connections, SOCKS5, host key verification, password autofill, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc. -- **Network Graph** - Customize your Dashboard to visualize your homelab based off your SSH connections with status support -- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile -# Planned Features +
-See [Projects](https://github.com/orgs/Termix-SSH/projects/2) for all planned features. If you are looking to contribute, see [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). +
-# Installation +## Platform Support -Supported Devices: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PlatformDistribution
WebAny modern browser (Chrome, Safari, Firefox) · PWA support
Windows x64/ia32Portable · MSI Installer · Chocolatey
Linux x64/ia32Portable · AUR · AppImage · Deb · Flatpak
macOS x64/ia32, v12.0+Apple App Store · DMG · Homebrew
iOS/iPadOS v15.1+Apple App Store · IPA
Android v7.0+Google Play Store · APK
-- Website (any modern browser on any platform like Chrome, Safari, and Firefox) (includes PWA support) -- Windows (x64/ia32) - - Portable - - MSI Installer - - Chocolatey Package Manager -- Linux (x64/ia32) - - Portable - - AUR - - AppImage - - Deb - - Flatpak -- macOS (x64/ia32 on v12.0+) - - Apple App Store - - DMG - - Homebrew -- iOS/iPadOS (v15.1+) - - Apple App Store - - IPA -- Android (v7.0+) - - Google Play Store - - APK +
-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 (you can omit guacd and the network if you don't plan on using remote desktop features): +## Installation + +Visit the [Termix Docs](https://docs.termix.site/install) for full installation instructions across all platforms. + +Sample Docker Compose file (you can omit `guacd` and the network if you don't plan on using remote desktop features): ```yaml services: @@ -128,80 +252,110 @@ networks: driver: bridge ``` -# Sponsors +
-

- - DigitalOcean - -          - - Crowdin - -          - - Blacksmith - -          - - Crowdflare - -          - - TailScale - -          - - Akamai - -          - - AWS - -

+## Screenshots -# Support +
-If you need help or want to request a feature with Termix, visit the [Issues](https://github.com/Termix-SSH/Support/issues) page, log in, and press `New Issue`. -Please be as detailed as possible in your issue, preferably written in English. You can also join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support -channel, however, response times may be longer. - -# Screenshots +
[![YouTube](./repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos) -

- Termix Demo 1 - Termix Demo 2 -

+Watch update overviews on YouTube -

- Termix Demo 3 - Termix Demo 4 -

+
+
-

- Termix Demo 5 - Termix Demo 6 -

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Termix Screenshot 1Termix Screenshot 2
Termix Screenshot 3Termix Screenshot 4
Termix Screenshot 5Termix Screenshot 6
Termix Screenshot 7Termix Screenshot 8
Termix Screenshot 9Termix Screenshot 10
Termix Screenshot 11Termix Screenshot 12
Termix Screenshot 13Termix Screenshot 14
-

- Termix Demo 7 - Termix Demo 8 -

+Some videos and images may be out of date or may not perfectly showcase features. -

- Termix Demo 9 - Termix Demo 10 -

+
-

- Termix Demo 11 - Termix Demo 12 -

+
-Some videos and images may be out of date or may not perfectly showcase features. +## Planned Features -# License +See [Projects](https://github.com/orgs/Termix-SSH/projects/2) for all planned features. If you are looking to contribute, see [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). -Distributed under the Apache License Version 2.0. See LICENSE for more information. +
+ +## Sponsors + +## Sponsors + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + + +
+ +
+ +## Support + +If you need help or want to request a feature with Termix, visit the [Issues](https://github.com/Termix-SSH/Support/issues) page, log in, and press `New Issue`. Please be as detailed as possible in your issue, preferably written in English. You can also join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel, however, response times may be longer. + +
+ +## License + +Distributed under the Apache License Version 2.0. See `LICENSE` for more information. diff --git a/components.json b/components.json index 8bfc737f..26b31d57 100644 --- a/components.json +++ b/components.json @@ -1,15 +1,17 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", + "style": "radix-lyra", "rsc": false, "tsx": true, "tailwind": { "config": "", "css": "src/index.css", - "baseColor": "zinc", + "baseColor": "neutral", "cssVariables": true, "prefix": "" }, + "iconLibrary": "lucide", + "rtl": false, "aliases": { "components": "@/components", "utils": "@/lib/utils", @@ -17,5 +19,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "iconLibrary": "lucide" + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} } diff --git a/docker/Dockerfile b/docker/Dockerfile index f766cace..bb6236e0 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -65,8 +65,6 @@ COPY docker/nginx.conf /app/nginx/nginx.conf.template COPY docker/nginx-https.conf /app/nginx/nginx-https.conf.template COPY --chown=node:node --from=frontend-builder /app/dist /app/html -COPY --chown=node:node --from=frontend-builder /app/src/locales /app/html/locales -COPY --chown=node:node --from=frontend-builder /app/public/fonts /app/html/fonts COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend diff --git a/docker/compose.dev.yml b/docker/compose-dev.yml similarity index 100% rename from docker/compose.dev.yml rename to docker/compose-dev.yml diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7672b8e4..9a2db205 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -9,6 +9,7 @@ services: - termix-data:/app/data environment: PORT: "8080" + GUACD_HOST: "guacd" depends_on: - guacd networks: @@ -18,8 +19,6 @@ services: image: guacamole/guacd:1.6.0 container_name: guacd restart: unless-stopped - ports: - - "4822:4822" networks: - termix-net diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 511bb338..b72467d5 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -135,6 +135,12 @@ fi echo "Starting nginx..." nginx -c /tmp/nginx/nginx.conf +# Inject runtime BASE_PATH into frontend if configured +if [ -n "$BASE_PATH" ]; then + echo "Injecting BASE_PATH: $BASE_PATH" + find /app/html -name "index.html" -exec sed -i "s|window.__TERMIX_BASE_PATH__ = \"\"|window.__TERMIX_BASE_PATH__ = \"$BASE_PATH\"|g" {} \; +fi + echo "Starting backend services..." cd /app export NODE_ENV=production diff --git a/electron-builder.json b/electron-builder.json index f3cdaf41..b6a835ff 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -35,7 +35,8 @@ "!public/icons/**/*" ], "extraMetadata": { - "main": "electron/main.cjs" + "main": "electron/main.cjs", + "type": "commonjs" }, "buildDependenciesFromSource": false, "nodeGypRebuild": false, diff --git a/electron/main.cjs b/electron/main.cjs index d536bdfd..e3849ff4 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -423,6 +423,7 @@ app.commandLine.appendSwitch("--enable-features=NetworkService"); let mainWindow = null; let backendProcess = null; +let backendStartFailed = false; let tray = null; let isQuitting = false; @@ -563,12 +564,21 @@ async function clearElectronJwtCookiesAtStartup() { function getBackendPaths() { if (isDev) { const backendDir = path.join(appRoot, "dist", "backend", "backend"); - return { entryPath: path.join(backendDir, "starter.js"), backendCwd: backendDir }; + return { + entryPath: path.join(backendDir, "starter.js"), + backendCwd: backendDir, + }; } // fork() does not go through Electron's asar redirector — use the unpacked path - const unpackedRoot = appRoot.replace("app.asar", "app.asar.unpacked"); + const unpackedRoot = appRoot.replace( + /app\.asar(?!\.unpacked)/, + "app.asar.unpacked", + ); const backendDir = path.join(unpackedRoot, "dist", "backend", "backend"); - return { entryPath: path.join(backendDir, "starter.js"), backendCwd: backendDir }; + return { + entryPath: path.join(backendDir, "starter.js"), + backendCwd: backendDir, + }; } function getBackendDataDir() { @@ -606,7 +616,10 @@ function startBackendServer() { logToFile(" backendCwd exists:", fs.existsSync(backendCwd)); backendProcess = fork(entryPath, [], { - cwd: backendCwd, + cwd: + fs.existsSync(backendCwd) && fs.statSync(backendCwd).isDirectory() + ? backendCwd + : dataDir, env: { ...process.env, DATA_DIR: dataDir, @@ -645,6 +658,9 @@ function startBackendServer() { backendProcess.on("exit", (code, signal) => { logToFile(`Backend process exited with code ${code}, signal ${signal}`); + if (!resolved && code !== 0) { + backendStartFailed = true; + } backendProcess = null; if (!resolved) { resolved = true; @@ -953,7 +969,14 @@ function createWindow() { }); mainWindow.webContents.setWindowOpenHandler(({ url }) => { - shell.openExternal(url); + try { + const parsed = new URL(url); + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + shell.openExternal(url); + } + } catch { + // invalid URL, ignore + } return { action: "deny" }; }); } @@ -1073,12 +1096,60 @@ ipcMain.handle("get-platform", () => { ipcMain.handle("get-embedded-server-status", () => { return { - running: backendProcess !== null && !backendProcess.killed, + running: + backendProcess !== null && !backendProcess.killed && !backendStartFailed, embedded: !isDev, dataDir: isDev ? null : getBackendDataDir(), }; }); +// OIDC System Browser Authentication (RFC 8252) +ipcMain.handle( + "oidc-system-browser-auth", + async (_event, authUrl, callbackPort) => { + const http = require("http"); + + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://localhost:${callbackPort}`); + if (url.pathname === "/oidc-callback") { + const success = url.searchParams.get("success"); + const error = url.searchParams.get("error"); + const token = url.searchParams.get("token"); + + res.writeHead(200, { "Content-Type": "text/html" }); + res.end( + `

${success === "true" ? "Authentication successful!" : "Authentication failed."}

You can close this tab and return to Termix.

`, + ); + + server.close(); + if (success === "true") { + resolve({ success: true, token }); + } else { + resolve({ + success: false, + error: error || "Authentication failed", + }); + } + } + }); + + server.listen(callbackPort, "127.0.0.1", () => { + shell.openExternal(authUrl); + }); + + // Timeout after 5 minutes + setTimeout( + () => { + server.close(); + reject(new Error("OIDC authentication timed out")); + }, + 5 * 60 * 1000, + ); + }); + }, +); + ipcMain.handle("get-server-config", () => { try { const userDataPath = app.getPath("userData"); diff --git a/electron/preload.js b/electron/preload.js index a19eb5c3..ea3becad 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -43,6 +43,9 @@ contextBridge.exposeInMainWorld("electronAPI", { timeoutMs, ), + oidcSystemBrowserAuth: (authUrl, callbackPort) => + ipcRenderer.invoke("oidc-system-browser-auth", authUrl, callbackPort), + invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), }); diff --git a/eslint.config.js b/eslint.config.mjs similarity index 100% rename from eslint.config.js rename to eslint.config.mjs diff --git a/index.html b/index.html index c3d2bf08..94725365 100644 --- a/index.html +++ b/index.html @@ -47,6 +47,9 @@ +
diff --git a/package-lock.json b/package-lock.json index 87c0a961..3dd8ec56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "termix", - "version": "2.2.1", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "2.2.1", + "version": "2.3.0", "hasInstallScript": true, "dependencies": { + "@tanstack/react-virtual": "^3.13.26", "axios": "^1.15.2", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.9.0", @@ -29,7 +30,6 @@ "multer": "^2.1.1", "nanoid": "^5.1.9", "qrcode": "^1.5.4", - "react-is": "^19.2.5", "socks": "^2.8.7", "speakeasy": "^2.0.0", "ssh2": "^1.17.0", @@ -42,13 +42,13 @@ "@codemirror/search": "^6.7.0", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.41.1", - "@commitlint/cli": "^20.5.0", - "@commitlint/config-conventional": "^20.5.0", + "@commitlint/cli": "^21.0.1", + "@commitlint/config-conventional": "^21.0.1", "@deadendjs/swagger-jsdoc": "^8.1.2", "@electron/notarize": "^3.1.1", "@electron/rebuild": "^4.0.4", "@eslint/js": "^9.0.0", - "@hookform/resolvers": "^5.2.2", + "@fontsource-variable/jetbrains-mono": "^5.2.8", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -75,7 +75,7 @@ "@types/js-yaml": "^4.0.9", "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.1.0", - "@types/node": "^24.12.2", + "@types/node": "^25.9.1", "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -87,16 +87,16 @@ "@uiw/react-codemirror": "^4.25.9", "@vitejs/plugin-react": "^6.0.1", "@xterm/addon-clipboard": "^0.2.0", - "@xterm/addon-fit": "^0.10.0", - "@xterm/addon-unicode11": "^0.8.0", - "@xterm/addon-web-links": "^0.11.0", - "@xterm/xterm": "^5.5.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "concurrently": "^9.2.1", "cytoscape": "^3.33.2", - "electron": "^41.3.0", + "electron": "^42.2.0", "electron-builder": "^26.8.1", "eslint": "^9.0.0", "eslint-plugin-react-hooks": "^7.1.1", @@ -107,14 +107,13 @@ "husky": "^9.1.7", "i18next": "^26.0.8", "i18next-browser-languagedetector": "^8.2.1", - "lint-staged": "^16.4.0", + "lint-staged": "^17.0.5", "lucide-react": "^1.11.0", - "next-themes": "^0.4.6", "prettier": "3.8.3", + "radix-ui": "^1.4.3", "react": "^19.2.5", "react-cytoscapejs": "^2.0.0", "react-dom": "^19.2.5", - "react-grid-layout": "^2.2.3", "react-h5-audio-player": "^3.10.2", "react-hook-form": "^7.73.1", "react-i18next": "^17.0.4", @@ -122,19 +121,18 @@ "react-markdown": "^10.1.0", "react-pdf": "^10.4.1", "react-photo-view": "^1.2.7", - "react-resizable-panels": "^4.10.0", - "react-simple-keyboard": "^3.8.196", "react-syntax-highlighter": "^16.1.1", "react-xtermjs": "^1.0.10", - "recharts": "^3.8.1", "remark-gfm": "^4.0.1", + "sharp": "^0.34.5", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", + "tw-animate-css": "^1.4.0", "typescript": "~6.0.3", "typescript-eslint": "^8.59.0", - "vite": "^8.0.10", - "zod": "^4.3.6" + "vite": "^8.0.13", + "vite-plugin-svgr": "^5.2.0" }, "engines": { "node": ">=22.12.0", @@ -194,13 +192,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -209,9 +207,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -219,21 +217,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -260,14 +258,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -277,14 +275,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -304,9 +302,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -314,29 +312,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -346,9 +344,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -356,9 +354,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -366,9 +364,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -376,27 +374,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -406,9 +404,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "dev": true, "license": "MIT", "engines": { @@ -416,33 +414,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -450,23 +448,23 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@codemirror/autocomplete": { - "version": "6.20.1", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", - "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "version": "6.20.2", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz", + "integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==", "dev": true, "license": "MIT", "dependencies": { @@ -791,9 +789,9 @@ } }, "node_modules/@codemirror/legacy-modes": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz", - "integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==", + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz", + "integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==", "dev": true, "license": "MIT", "dependencies": { @@ -801,14 +799,14 @@ } }, "node_modules/@codemirror/lint": { - "version": "6.9.5", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", - "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz", + "integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==", "dev": true, "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.35.0", + "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, @@ -848,9 +846,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.41.1", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.1.tgz", - "integrity": "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==", + "version": "6.43.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz", + "integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==", "dev": true, "license": "MIT", "dependencies": { @@ -861,251 +859,344 @@ } }, "node_modules/@commitlint/cli": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.5.0.tgz", - "integrity": "sha512-yNkyN/tuKTJS3wdVfsZ2tXDM4G4Gi7z+jW54Cki8N8tZqwKBltbIvUUrSbT4hz1bhW/h0CdR+5sCSpXD+wMKaQ==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.0.1.tgz", + "integrity": "sha512-8vq10krmbJwBkvzXKhbs4o4JQEVscd3pqOlWuDUaDBwbeL694/P33UC29tZQFTAgPU9fVJ2+f2m3zw16yKWxHg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/format": "^20.5.0", - "@commitlint/lint": "^20.5.0", - "@commitlint/load": "^20.5.0", - "@commitlint/read": "^20.5.0", - "@commitlint/types": "^20.5.0", + "@commitlint/format": "^21.0.1", + "@commitlint/lint": "^21.0.1", + "@commitlint/load": "^21.0.1", + "@commitlint/read": "^21.0.1", + "@commitlint/types": "^21.0.1", "tinyexec": "^1.0.0", - "yargs": "^17.0.0" + "yargs": "^18.0.0" }, "bin": { "commitlint": "cli.js" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/cli/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@commitlint/cli/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@commitlint/cli/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@commitlint/cli/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/cli/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@commitlint/cli/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/@commitlint/cli/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/@commitlint/config-conventional": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.5.0.tgz", - "integrity": "sha512-t3Ni88rFw1XMa4nZHgOKJ8fIAT9M2j5TnKyTqJzsxea7FUetlNdYFus9dz+MhIRZmc16P0PPyEfh6X2d/qw8SA==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.0.1.tgz", + "integrity": "sha512-gRorrkfWOh/+V5X8GYWWbQvrzPczopGMS4CCNrQdHkK4xWElv82BDvIsDhJZWTlI7TazOlYea6VATufCsFs+sw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.5.0", + "@commitlint/types": "^21.0.1", "conventional-changelog-conventionalcommits": "^9.2.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/config-validator": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.5.0.tgz", - "integrity": "sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.0.1.tgz", + "integrity": "sha512-Zd2UFdndeMMaW2O96HK0tdfT4gOImUvidMpAd/pws2zZ4m1nrAZ/9b/v2JYuE8fs86GpXv9F7LNaIuCIWhY+pA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.5.0", + "@commitlint/types": "^21.0.1", "ajv": "^8.11.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/ensure": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.5.0.tgz", - "integrity": "sha512-IpHqAUesBeW1EDDdjzJeaOxU9tnogLAyXLRBn03SHlj1SGENn2JGZqSWGkFvBJkJzfXAuCNtsoYzax+ZPS+puw==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.0.1.tgz", + "integrity": "sha512-jJ1037967wU7YN/xkv+iRlOBlmaOXPhPO5KQSqya6GyXzBlwuLzELBFao16DVg9dZyqmNrhewzwZ3SAibetHBQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.5.0", - "lodash.camelcase": "^4.3.0", - "lodash.kebabcase": "^4.1.1", - "lodash.snakecase": "^4.1.1", - "lodash.startcase": "^4.4.0", - "lodash.upperfirst": "^4.3.1" + "@commitlint/types": "^21.0.1", + "es-toolkit": "^1.46.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/execute-rule": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-20.0.0.tgz", - "integrity": "sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-21.0.1.tgz", + "integrity": "sha512-RifH+FmImozKBE6mozhF4K3r2RRKP7SMi/Q/zLCmExtp5e05lhHOUYqGBlFBAGNHaZxU/WYw1XuugYK9jQzqnA==", "dev": true, "license": "MIT", "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/format": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.5.0.tgz", - "integrity": "sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.0.1.tgz", + "integrity": "sha512-ksmG2+cHGtuDPQQbhBbC4unwm444+6TiPw0d1bKf67hntgZqZ8E0g1MuYKUuyT5IH4IMmXZhKq22/Z3jBvtQIw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.5.0", + "@commitlint/types": "^21.0.1", "picocolors": "^1.1.1" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/is-ignored": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.5.0.tgz", - "integrity": "sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.0.1.tgz", + "integrity": "sha512-iNDP8SFdw8JEkM0CHZ2XFnhTN4Zg5jKUY2d8kBOSFrI2aA+3YJI7fcqVpfgbpJ9xtxFVYpi+DBATU5AvhoTq8g==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.5.0", + "@commitlint/types": "^21.0.1", "semver": "^7.6.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/lint": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.5.0.tgz", - "integrity": "sha512-jiM3hNUdu04jFBf1VgPdjtIPvbuVfDTBAc6L98AWcoLjF5sYqkulBHBzlVWll4rMF1T5zeQFB6r//a+s+BBKlA==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.0.1.tgz", + "integrity": "sha512-gF+iYtUw1gBG3HUH9z3VxwUjGg2R2G5j+nmvPs8aIeYkiB7TtneBu3wO85I0bUl93bYNsvsCNI9Nte2fmDUMww==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/is-ignored": "^20.5.0", - "@commitlint/parse": "^20.5.0", - "@commitlint/rules": "^20.5.0", - "@commitlint/types": "^20.5.0" + "@commitlint/is-ignored": "^21.0.1", + "@commitlint/parse": "^21.0.1", + "@commitlint/rules": "^21.0.1", + "@commitlint/types": "^21.0.1" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/load": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.5.0.tgz", - "integrity": "sha512-sLhhYTL/KxeOTZjjabKDhwidGZan84XKK1+XFkwDYL/4883kIajcz/dZFAhBJmZPtL8+nBx6bnkzA95YxPeDPw==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.0.1.tgz", + "integrity": "sha512-Btg1q1mKmiihN4W3x0EsPDrJMOQfMa9NIqlzlJyXAfxvsOGdGXOW5p3R3RcSxDCaY7JabY9flIl+Om1af3PSrw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^20.5.0", - "@commitlint/execute-rule": "^20.0.0", - "@commitlint/resolve-extends": "^20.5.0", - "@commitlint/types": "^20.5.0", + "@commitlint/config-validator": "^21.0.1", + "@commitlint/execute-rule": "^21.0.1", + "@commitlint/resolve-extends": "^21.0.1", + "@commitlint/types": "^21.0.1", "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", + "es-toolkit": "^1.46.0", "is-plain-obj": "^4.1.0", - "lodash.mergewith": "^4.6.2", "picocolors": "^1.1.1" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/message": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-20.4.3.tgz", - "integrity": "sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-21.0.1.tgz", + "integrity": "sha512-R3dVQeJQ0B6yqrZEjkUHD4r7UJYLV9Lvk2xs3PTOmtWk2G3mI6Xgc+YdRxL1PwcDfBiUjv2SkIkW4AUc976w1w==", "dev": true, "license": "MIT", "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/parse": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.5.0.tgz", - "integrity": "sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.0.1.tgz", + "integrity": "sha512-oh/nCSOqdoeQNA1tO8aAmxkq5EBo8/NzcFQRvv66AWc9HpED28sL2iSicCKU6hPintWuscL6BJEWi77Wq1LPMQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.5.0", + "@commitlint/types": "^21.0.1", "conventional-changelog-angular": "^8.2.0", "conventional-commits-parser": "^6.3.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/read": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.5.0.tgz", - "integrity": "sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.0.1.tgz", + "integrity": "sha512-pMEu4lbpC8W0ZgKJj2U6WaobXIZWdFlULpIEewYhkPXx+WZcnoO53YrVPc7QErQuNolq2Me8dP58Wu7YAVXVOA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/top-level": "^20.4.3", - "@commitlint/types": "^20.5.0", + "@commitlint/top-level": "^21.0.1", + "@commitlint/types": "^21.0.1", "git-raw-commits": "^5.0.0", - "minimist": "^1.2.8", "tinyexec": "^1.0.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/resolve-extends": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.5.0.tgz", - "integrity": "sha512-3SHPWUW2v0tyspCTcfSsYml0gses92l6TlogwzvM2cbxDgmhSRc+fldDjvGkCXJrjSM87BBaWYTPWwwyASZRrg==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.0.1.tgz", + "integrity": "sha512-0DhjYWL6uYrY16Efa032fYk3woGJDU4AGWiG1XXltT9AMUNYKyb5cIZU2ivbaMZ3+kKFqUjikD2cjh66Sbh/Sg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^20.5.0", - "@commitlint/types": "^20.5.0", - "global-directory": "^4.0.1", - "import-meta-resolve": "^4.0.0", - "lodash.mergewith": "^4.6.2", + "@commitlint/config-validator": "^21.0.1", + "@commitlint/types": "^21.0.1", + "es-toolkit": "^1.46.0", + "global-directory": "^5.0.0", "resolve-from": "^5.0.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/rules": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.5.0.tgz", - "integrity": "sha512-5NdQXQEdnDPT5pK8O39ZA7HohzPRHEsDGU23cyVCNPQy4WegAbAwrQk3nIu7p2sl3dutPk8RZd91yKTrMTnRkQ==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.0.1.tgz", + "integrity": "sha512-VMooYpz4nJg7xlaUso6CCOWEz8D/ChkvsvZUMARcoJ1ZpfKPyFCGrHNha2tbsETNAb6ErgiRuCr2DvghrvPDYQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/ensure": "^20.5.0", - "@commitlint/message": "^20.4.3", - "@commitlint/to-lines": "^20.0.0", - "@commitlint/types": "^20.5.0" + "@commitlint/ensure": "^21.0.1", + "@commitlint/message": "^21.0.1", + "@commitlint/to-lines": "^21.0.1", + "@commitlint/types": "^21.0.1" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/to-lines": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-20.0.0.tgz", - "integrity": "sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-21.0.1.tgz", + "integrity": "sha512-bd1BFII7p1EQZre9Kaj+kKaMFP3cFCdt21K7DItVux9XP5WjLgJ0/Uy1pJJh9aPwVJ6SKg62PxqlZaHI8hQAXw==", "dev": true, "license": "MIT", "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/top-level": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-20.4.3.tgz", - "integrity": "sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-21.0.1.tgz", + "integrity": "sha512-4esUYqzY7K0FCgcJ/1xWEZekV7Ch4yZT1+xjEb7KzqbJ05XEkxHVsTfC8ADKNNtlCE2pj98KEbPGZWw9WwEnVw==", "dev": true, "license": "MIT", "dependencies": { "escalade": "^3.2.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/types": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.5.0.tgz", - "integrity": "sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.0.1.tgz", + "integrity": "sha512-4u7w8jcoCUFWhjWnASYzZHAP34OqOtuFBN87nQmFvqda03YU0T6z+yB4w0gSAMpekiRqqGk5rt+qSlW+a2vSEg==", "dev": true, "license": "MIT", "dependencies": { @@ -1113,7 +1204,7 @@ "picocolors": "^1.1.1" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@conventional-changelog/git-client": { @@ -1144,15 +1235,14 @@ } }, "node_modules/@deadendjs/swagger-jsdoc": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/@deadendjs/swagger-jsdoc/-/swagger-jsdoc-8.1.2.tgz", - "integrity": "sha512-SbwXKaif1eicRLzK10RbmMjHd0GUbmi/izVlQaJJhpIAy3tfphbfgk6r+maWkqJeMSosmllIH91DVXLyFlavgA==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@deadendjs/swagger-jsdoc/-/swagger-jsdoc-8.2.0.tgz", + "integrity": "sha512-KwySDId/J0C/dlMGd77H3IN3nL7/HYVTerFp6DBhRnIY7RsbGGz30DBIyAhzhLDdqStW4wQHMP+/3qX8BQ1wTg==", "dev": true, "license": "MIT", "dependencies": { "@apidevtools/swagger-parser": "^12.1.0", "commander": "^14.0.0", - "doctrine": "3.0.0", "glob": "^12.0.0", "yaml": "2.0.0-4" }, @@ -1163,16 +1253,6 @@ "node": ">=20.0.0" } }, - "node_modules/@deadendjs/swagger-jsdoc/node_modules/yaml": { - "version": "2.0.0-4", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-4.tgz", - "integrity": "sha512-MoQoNhTFI400tkaeod+X0Vety1KD2L9dUa6pa1CVcyfcATjC/iDxoMLvqZ6U3D8c5KzxBrU2HnJH+PfaXOqI7w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 10" - } - }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -1208,16 +1288,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@develar/schema-utils/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, "node_modules/@develar/schema-utils/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -1463,9 +1533,9 @@ "license": "MIT" }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -1473,9 +1543,9 @@ } }, "node_modules/@electron/universal/node_modules/fs-extra": { - "version": "11.3.4", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", - "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", "dev": true, "license": "MIT", "dependencies": { @@ -1556,19 +1626,6 @@ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", @@ -1602,9 +1659,9 @@ "license": "MIT" }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -1700,9 +1757,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -1743,19 +1800,6 @@ "node": "*" } }, - "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@eslint/js": { "version": "9.39.4", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", @@ -1835,17 +1879,14 @@ "dev": true, "license": "MIT" }, - "node_modules/@hookform/resolvers": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", - "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==", "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/utils": "^0.3.0" - }, - "peerDependencies": { - "react-hook-form": "^7.55.0" + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" } }, "node_modules/@humanfs/core": { @@ -1937,6 +1978,544 @@ "dev": true, "license": "MIT" }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -2292,9 +2871,9 @@ } }, "node_modules/@napi-rs/canvas": { - "version": "0.1.99", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.99.tgz", - "integrity": "sha512-zN4eQlK3eBf7aJBcTHZilpBH3tDekBzPMIWC8r0s94Ecl73XfOyFi4w7yKFMRVUT0lvNQjtOL8YSrwqQj6mZFg==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", + "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", "dev": true, "license": "MIT", "optional": true, @@ -2309,23 +2888,23 @@ "url": "https://github.com/sponsors/Brooooooklyn" }, "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.99", - "@napi-rs/canvas-darwin-arm64": "0.1.99", - "@napi-rs/canvas-darwin-x64": "0.1.99", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.99", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.99", - "@napi-rs/canvas-linux-arm64-musl": "0.1.99", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.99", - "@napi-rs/canvas-linux-x64-gnu": "0.1.99", - "@napi-rs/canvas-linux-x64-musl": "0.1.99", - "@napi-rs/canvas-win32-arm64-msvc": "0.1.99", - "@napi-rs/canvas-win32-x64-msvc": "0.1.99" + "@napi-rs/canvas-android-arm64": "0.1.100", + "@napi-rs/canvas-darwin-arm64": "0.1.100", + "@napi-rs/canvas-darwin-x64": "0.1.100", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", + "@napi-rs/canvas-linux-arm64-musl": "0.1.100", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-musl": "0.1.100", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", + "@napi-rs/canvas-win32-x64-msvc": "0.1.100" } }, "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.99", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.99.tgz", - "integrity": "sha512-9OCRt8VVxA17m32NWZKyNC2qamdaS/SC5CEOIQwFngRq0DIeVm4PDal+6Ljnhqm2whZiC63DNuKZ4xSp2nbj9w==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", + "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", "cpu": [ "arm64" ], @@ -2344,9 +2923,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.99", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.99.tgz", - "integrity": "sha512-lupMDMy1+H38dhyCcLirOKKVUyzzlxi7j7rGPLI3vViMHOoPjcXO1b10ivy+ad+q6MiwHfoLjKTCoLke5ySOBg==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", + "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", "cpu": [ "arm64" ], @@ -2365,9 +2944,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.99", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.99.tgz", - "integrity": "sha512-fdz02t4w8n6Ii/rYhWig6STb/zcTmCC/6YZTGmjoDeidDwn9Wf0ukQVynhCPEs29vqUc66wHZKsuIgMs9tycCg==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", + "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", "cpu": [ "x64" ], @@ -2386,9 +2965,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.99", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.99.tgz", - "integrity": "sha512-w4FwVwlNo00ezeRhfY62IVIyt6G3u8wodkPtiqWc52BUHx+VDBUM2vkS3ogfANaLI7hnf3s6WK4LyZVUjBg1lA==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", + "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", "cpu": [ "arm" ], @@ -2407,13 +2986,16 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.99", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.99.tgz", - "integrity": "sha512-8JvHeexKQ8c7g0q7YJ29NVQwnf1ePghP9ys9ZN0R0qzyqJQ9Uw6N9qnDINArlm3IYHexB7LjzArIfhQiqSDGvQ==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", + "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2428,13 +3010,16 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.99", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.99.tgz", - "integrity": "sha512-Z+6nyLdJXWzLPVxi4H6g9TJop4DwN3KSgHWto5JCbZV5/uKoVqcSynPs0tGlUHOoWI8S8tEvJspz51GQkvr07w==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", + "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2449,13 +3034,16 @@ } }, "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.99", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.99.tgz", - "integrity": "sha512-jAnfOUv4IO1l8Levk5t85oVtEBOXLa07KnIUgWo1CDlPxiqpxS3uBfiE38Lvj/CQgHaNF6Nxk/SaemwLgsVJgw==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", + "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2470,13 +3058,16 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.99", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.99.tgz", - "integrity": "sha512-mIkXw3fGmbYyFjSmfWEvty4jN+rwEOmv0+Dy9bRvvTzLYWCgm3RMgUEQVfAKFw96nIRFnyNZiK83KNQaVVFjng==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", + "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2491,13 +3082,16 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.99", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.99.tgz", - "integrity": "sha512-f3Uz2P0RgrtBHISxZqr6yiYXJlTDyCVBumDacxo+4AmSg7z0HiqYZKGWC/gszq3fbPhyQUya1W2AEteKxT9Y6A==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", + "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2512,9 +3106,9 @@ } }, "node_modules/@napi-rs/canvas-win32-arm64-msvc": { - "version": "0.1.99", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.99.tgz", - "integrity": "sha512-XE6KUkfqRsCNejcoRMiMr3RaUeObxNf6y7dut3hrq2rn7PzfRTZgrjF1F/B2C7FcdgqY/vSHWpQeMuNz1vTNHg==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", + "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", "cpu": [ "arm64" ], @@ -2533,9 +3127,9 @@ } }, "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.99", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.99.tgz", - "integrity": "sha512-plMYGVbc/vmmPF9MtmHbwNk1rL1Aj53vQZt+Gnv1oZn6gmd9jEHHJ0n9Nd2nxa5sKH7TS5IjkCDM6289O0d6PQ==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", + "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", "cpu": [ "x64" ], @@ -2573,9 +3167,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, "license": "MIT", "funding": { @@ -2596,6 +3190,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", + "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-accordion": { "version": "1.2.12", "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", @@ -2700,6 +3318,58 @@ } } }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-checkbox": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", @@ -2840,6 +3510,35 @@ } } }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", @@ -3012,6 +3711,91 @@ } } }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", + "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", @@ -3139,6 +3923,142 @@ } } }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", + "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", + "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", @@ -3387,6 +4307,39 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", @@ -3676,6 +4629,151 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", + "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", @@ -3804,6 +4902,25 @@ } } }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", @@ -3905,44 +5022,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@reduxjs/toolkit": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", - "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, - "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", - "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/@replit/codemirror-lang-nix": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@replit/codemirror-lang-nix/-/codemirror-lang-nix-6.0.1.tgz", @@ -3993,9 +5072,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", "cpu": [ "arm64" ], @@ -4010,9 +5089,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -4027,9 +5106,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", "cpu": [ "x64" ], @@ -4044,9 +5123,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", "cpu": [ "x64" ], @@ -4061,9 +5140,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", "cpu": [ "arm" ], @@ -4078,13 +5157,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4095,13 +5177,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4112,13 +5197,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4129,13 +5217,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4146,13 +5237,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4163,13 +5257,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4180,9 +5277,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", "cpu": [ "arm64" ], @@ -4197,9 +5294,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", "cpu": [ "wasm32" ], @@ -4216,9 +5313,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", "cpu": [ "arm64" ], @@ -4233,9 +5330,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", "cpu": [ "x64" ], @@ -4250,12 +5347,35 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.7", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", - "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@simple-libs/child-process-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", @@ -4285,64 +5405,302 @@ "url": "https://ko-fi.com/dangreen" } }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/core/node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } }, "node_modules/@tailwindcss/node": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", - "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", + "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.4" + "tailwindcss": "4.3.0" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", - "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-x64": "4.2.4", - "@tailwindcss/oxide-freebsd-x64": "4.2.4", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-x64-musl": "4.2.4", - "@tailwindcss/oxide-wasm32-wasi": "4.2.4", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", - "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", "cpu": [ "arm64" ], @@ -4357,9 +5715,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", - "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", "cpu": [ "arm64" ], @@ -4374,9 +5732,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", - "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", "cpu": [ "x64" ], @@ -4391,9 +5749,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", - "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", "cpu": [ "x64" ], @@ -4408,9 +5766,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", - "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", "cpu": [ "arm" ], @@ -4425,13 +5783,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", - "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4442,13 +5803,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", - "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4459,13 +5823,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", - "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4476,13 +5843,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", - "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4493,9 +5863,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", - "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -4511,10 +5881,10 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, @@ -4523,9 +5893,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", - "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", "cpu": [ "arm64" ], @@ -4540,9 +5910,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", - "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", "cpu": [ "x64" ], @@ -4557,24 +5927,51 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", - "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.4", - "@tailwindcss/oxide": "4.2.4", - "tailwindcss": "4.2.4" + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.26", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.26.tgz", + "integrity": "sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.16.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.16.0.tgz", + "integrity": "sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -4633,78 +6030,6 @@ "@types/node": "*" } }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", - "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -4716,9 +6041,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -4844,13 +6169,13 @@ } }, "node_modules/@types/node": { - "version": "24.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", - "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/plist": { @@ -4883,9 +6208,9 @@ } }, "node_modules/@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", "dev": true, "license": "MIT" }, @@ -4897,9 +6222,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4981,13 +6306,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -5018,17 +6336,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", - "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.0", - "@typescript-eslint/type-utils": "8.59.0", - "@typescript-eslint/utils": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -5041,7 +6359,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.0", + "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -5057,16 +6375,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", - "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.0", - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "engines": { @@ -5082,14 +6400,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", - "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.0", - "@typescript-eslint/types": "^8.59.0", + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", "debug": "^4.4.3" }, "engines": { @@ -5104,14 +6422,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", - "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0" + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5122,9 +6440,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", - "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", "dev": true, "license": "MIT", "engines": { @@ -5139,15 +6457,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", - "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0", - "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -5164,9 +6482,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", - "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", "dev": true, "license": "MIT", "engines": { @@ -5178,16 +6496,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", - "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.0", - "@typescript-eslint/tsconfig-utils": "8.59.0", - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0", + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -5206,16 +6524,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", - "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.0", - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0" + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5230,13 +6548,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", - "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -5247,23 +6565,10 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@uiw/codemirror-extensions-basic-setup": { - "version": "4.25.9", - "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.9.tgz", - "integrity": "sha512-QFAqr+pu6lDmNpAlecODcF49TlsrZ0bj15zPzfhiqSDl+Um3EsDLFLppixC7kFLn+rdDM2LTvVjn5CPvefpRgw==", + "version": "4.25.10", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.10.tgz", + "integrity": "sha512-P3vytLlpE62KYSWrMUnwDCv2lvaQDuDZzyj03mHntuHo5bSl34fRZpjTY3kQTPGuXHxkGSYpoPFFj+hMTqaaMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5289,9 +6594,9 @@ } }, "node_modules/@uiw/codemirror-extensions-langs": { - "version": "4.25.9", - "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-langs/-/codemirror-extensions-langs-4.25.9.tgz", - "integrity": "sha512-POcA4K5AQwjcHAebWTCRld4bUCMTGFdV8AFal1Pj6ymeKRqWc58cduryfjmAl8yfdTU0Kb3fF2bO08BeDzX7FQ==", + "version": "4.25.10", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-langs/-/codemirror-extensions-langs-4.25.10.tgz", + "integrity": "sha512-VsfENMb23HrcKG2z0n0RB0KY7d11k+8qzUlH6i36099QXa07D/FEfeffoSnP+QdghhvISNnuMTJsWKqYQq6qlA==", "dev": true, "license": "MIT", "dependencies": { @@ -5330,22 +6635,22 @@ } }, "node_modules/@uiw/codemirror-theme-github": { - "version": "4.25.9", - "resolved": "https://registry.npmjs.org/@uiw/codemirror-theme-github/-/codemirror-theme-github-4.25.9.tgz", - "integrity": "sha512-AGpTamNiySKNzq3Jc7QjpwgQRVaHUaBtmOKiUDghYSfEGjsc5uW4NUW70sSU3BnkGv+lCTUnF3175KM24BWZbw==", + "version": "4.25.10", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-theme-github/-/codemirror-theme-github-4.25.10.tgz", + "integrity": "sha512-iMM2QT4FaebJMO4W7lXmxNkRPIjKzgY26wL0QG0Ugy0gzsnxoNz4zgNeFIblPA8rvrN3vOIhNNh4nk9UOlFKxA==", "dev": true, "license": "MIT", "dependencies": { - "@uiw/codemirror-themes": "4.25.9" + "@uiw/codemirror-themes": "4.25.10" }, "funding": { "url": "https://jaywcjlove.github.io/#/sponsor" } }, "node_modules/@uiw/codemirror-themes": { - "version": "4.25.9", - "resolved": "https://registry.npmjs.org/@uiw/codemirror-themes/-/codemirror-themes-4.25.9.tgz", - "integrity": "sha512-DAHKb/L9ELwjY4nCf/MP/mIllHOn4GQe7RR4x8AMJuNeh9nGRRoo1uPxrxMmUL/bKqe6kDmDbIZ2AlhlqyIJuw==", + "version": "4.25.10", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-themes/-/codemirror-themes-4.25.10.tgz", + "integrity": "sha512-Fqiz1HIuDlDftcL+/O53V333UOH6MqQ84VbiQB5egn6u+uDwAqACp1FrdAoi4wgpR3b3TGW4Gr0wIYcrJSSz1A==", "dev": true, "license": "MIT", "dependencies": { @@ -5363,9 +6668,9 @@ } }, "node_modules/@uiw/react-codemirror": { - "version": "4.25.9", - "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.9.tgz", - "integrity": "sha512-HftqCBUYShAOH0pGi1CHP8vfm5L8fQ3+0j0VI6lQD6QpK+UBu3J7nxfEN5O/BXMilMNf9ZyFJRvRcuMMOLHMng==", + "version": "4.25.10", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.10.tgz", + "integrity": "sha512-DzgSMwM5qzB7v1FIb4gEeriYt67iiay756/HIOM9mAbeOVK0MO7rqefHf0O5c0269pJKMW7AH9FjclExD23V9w==", "dev": true, "license": "MIT", "dependencies": { @@ -5373,7 +6678,7 @@ "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", - "@uiw/codemirror-extensions-basic-setup": "4.25.9", + "@uiw/codemirror-extensions-basic-setup": "4.25.10", "codemirror": "^6.0.0" }, "funding": { @@ -5390,20 +6695,20 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", "dev": true, "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", - "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.7" + "@rolldown/pluginutils": "^1.0.0" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -5443,42 +6748,36 @@ } }, "node_modules/@xterm/addon-fit": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", - "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@xterm/xterm": "^5.0.0" - } - }, - "node_modules/@xterm/addon-unicode11": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.8.0.tgz", - "integrity": "sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@xterm/xterm": "^5.0.0" - } - }, - "node_modules/@xterm/addon-web-links": { "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz", - "integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@xterm/xterm": "^5.0.0" - } - }, - "node_modules/@xterm/xterm": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", - "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", "dev": true, "license": "MIT" }, + "node_modules/@xterm/addon-unicode11": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz", + "integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/7zip-bin": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", @@ -5573,6 +6872,16 @@ } } }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -5746,6 +7055,19 @@ "node": ">=18" } }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/app-builder-lib/node_modules/which": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", @@ -5859,20 +7181,46 @@ } }, "node_modules/axios": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", - "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/b4a": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", - "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -5905,9 +7253,9 @@ } }, "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.3.tgz", + "integrity": "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==", "license": "Apache-2.0", "peerDependencies": { "bare-abort-controller": "*" @@ -5943,9 +7291,9 @@ } }, "node_modules/bare-os": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.0.tgz", - "integrity": "sha512-JTjuZyNIDpw+GytMO4a6TK1VXdVKKJr6DRxEHasyuYyShV2deuiHJK/ahGZlebc+SG0/wJCB9XK8gprBGDFi/Q==", + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", + "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", "license": "Apache-2.0", "engines": { "bare": ">=1.14.0" @@ -5961,9 +7309,9 @@ } }, "node_modules/bare-stream": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.0.tgz", - "integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", + "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==", "license": "Apache-2.0", "dependencies": { "streamx": "^2.25.0", @@ -5987,9 +7335,9 @@ } }, "node_modules/bare-url": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.2.tgz", - "integrity": "sha512-/9a2j4ac6ckpmAHvod/ob7x439OAHst/drc2Clnq+reRYd/ovddwcF4LfoxHyNk5AuGBnPg+HqFjmE/Zpq6v0A==", + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz", + "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==", "license": "Apache-2.0", "dependencies": { "bare-path": "^3.0.0" @@ -6023,9 +7371,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.23", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", - "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6054,9 +7402,9 @@ } }, "node_modules/better-sqlite3": { - "version": "12.9.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", - "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -6064,7 +7412,7 @@ "prebuild-install": "^7.1.1" }, "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, "node_modules/bindings": { @@ -6101,9 +7449,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -6340,18 +7688,22 @@ } }, "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/caniuse-lite": { - "version": "1.0.30001791", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", - "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", "dev": true, "funding": [ { @@ -6645,13 +7997,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -6965,6 +8310,16 @@ "typescript": ">=5" } }, + "node_modules/cosmiconfig-typescript-loader/node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/cpu-features": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", @@ -7020,147 +8375,15 @@ "license": "MIT" }, "node_modules/cytoscape": { - "version": "3.33.2", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.2.tgz", - "integrity": "sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==", + "version": "3.33.4", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.4.tgz", + "integrity": "sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww==", "dev": true, "license": "MIT", "engines": { "node": ">=0.10" } }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "dev": true, - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", - "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "dev": true, - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "dev": true, - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "dev": true, - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -7187,13 +8410,6 @@ "node": ">=0.10.0" } }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "dev": true, - "license": "MIT" - }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -7322,9 +8538,9 @@ "license": "MIT" }, "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -7428,17 +8644,15 @@ "license": "MIT", "optional": true }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" + "no-case": "^3.0.4", + "tslib": "^2.0.3" } }, "node_modules/dot-prop": { @@ -7666,22 +8880,22 @@ } }, "node_modules/electron": { - "version": "41.3.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-41.3.0.tgz", - "integrity": "sha512-2Q5aeocmFdeheZGDUTrAvSR3t+n0c3d104AJWWEnt7syJU0tE4VdibMYaPtQ47QuXSoUf0/xSsfUUvu/uSXIfg==", + "version": "42.2.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.2.0.tgz", + "integrity": "sha512-b2Tc7sIKiZEl0tBVwFM5GJ+FT5KYhmy9QJHjx8BGVZPVW2SctXWEvrE959ElB56qw7H05dBkhlikDA1DmpaAMw==", "dev": true, - "hasInstallScript": true, "license": "MIT", "dependencies": { - "@electron/get": "^2.0.0", + "@electron/get": "^5.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { - "electron": "cli.js" + "electron": "cli.js", + "install-electron": "install.js" }, "engines": { - "node": ">= 12.20.55" + "node": ">= 22.12.0" } }, "node_modules/electron-builder": { @@ -7788,12 +9002,29 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.344", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", - "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", "dev": true, "license": "ISC" }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -7819,9 +9050,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.21.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", - "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", + "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", "dev": true, "license": "MIT", "dependencies": { @@ -7832,6 +9063,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -7891,9 +9135,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -7918,9 +9162,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.46.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.0.tgz", - "integrity": "sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==", + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", + "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", "dev": true, "license": "MIT", "workspaces": [ @@ -8080,6 +9324,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/eslint/node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -8105,9 +9362,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -8132,19 +9389,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -8196,19 +9440,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -8256,6 +9487,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -8405,13 +9643,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-equals": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", - "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -8433,9 +9664,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -8528,9 +9759,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -8702,13 +9933,13 @@ } }, "node_modules/framer-motion": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", - "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", + "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==", "license": "MIT", "dependencies": { - "motion-dom": "^12.38.0", - "motion-utils": "^12.36.0", + "motion-dom": "^12.40.0", + "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -8796,9 +10027,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", "engines": { @@ -8932,25 +10163,25 @@ } }, "node_modules/global-directory": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", - "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz", + "integrity": "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==", "dev": true, "license": "MIT", "dependencies": { - "ini": "4.1.1" + "ini": "6.0.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/globals": { - "version": "17.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", - "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "dev": true, "license": "MIT", "engines": { @@ -9274,9 +10505,9 @@ } }, "node_modules/i18next": { - "version": "26.0.8", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.8.tgz", - "integrity": "sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==", + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz", + "integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==", "dev": true, "funding": [ { @@ -9384,17 +10615,6 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, - "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -9422,17 +10642,6 @@ "node": ">=4" } }, - "node_modules/import-meta-resolve": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", - "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -9450,13 +10659,13 @@ "license": "ISC" }, "node_modules/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", "dev": true, "license": "ISC", "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/inline-style-parser": { @@ -9466,20 +10675,10 @@ "dev": true, "license": "MIT" }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -9671,9 +10870,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -9681,9 +10880,9 @@ } }, "node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -10020,6 +11219,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10041,6 +11243,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10062,6 +11267,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10083,6 +11291,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10146,45 +11357,62 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", - "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.5.tgz", + "integrity": "sha512-d12yC+/e8RhBjZtaxZn71FyrgU/P5e+uAPifhCLwdosQZP/zamSdKRWDC30ocVIbzDKiFG1McHc/LUgB92GIPw==", "dev": true, "license": "MIT", "dependencies": { - "commander": "^14.0.3", - "listr2": "^9.0.5", - "picomatch": "^4.0.3", + "listr2": "^10.2.1", + "picomatch": "^4.0.4", "string-argv": "^0.3.2", - "tinyexec": "^1.0.4", - "yaml": "^2.8.2" + "tinyexec": "^1.1.2" }, "bin": { "lint-staged": "bin/lint-staged.js" }, "engines": { - "node": ">=20.17" + "node": ">=22.22.1" }, "funding": { "url": "https://opencollective.com/lint-staged" + }, + "optionalDependencies": { + "yaml": "^2.8.4" + } + }, + "node_modules/lint-staged/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/listr2": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", - "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", + "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", "dev": true, "license": "MIT", "dependencies": { - "cli-truncate": "^5.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", + "cli-truncate": "^5.2.0", + "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" + "wrap-ansi": "^10.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.13.0" } }, "node_modules/listr2/node_modules/ansi-styles": { @@ -10251,9 +11479,9 @@ } }, "node_modules/listr2/node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { @@ -10290,13 +11518,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -10333,13 +11554,6 @@ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, - "node_modules/lodash.kebabcase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", - "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -10347,40 +11561,12 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.startcase": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", - "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.upperfirst": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", - "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", - "dev": true, - "license": "MIT" - }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -10414,6 +11600,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/log-update/node_modules/is-fullwidth-code-point": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", @@ -10447,6 +11640,42 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -10471,6 +11700,16 @@ "loose-envify": "cli.js" } }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, "node_modules/lowlight": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", @@ -10497,9 +11736,9 @@ } }, "node_modules/lucide-react": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.11.0.tgz", - "integrity": "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", + "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==", "dev": true, "license": "ISC", "peerDependencies": { @@ -11615,12 +12854,12 @@ "license": "MIT" }, "node_modules/motion": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", - "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.40.0.tgz", + "integrity": "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==", "license": "MIT", "dependencies": { - "framer-motion": "^12.38.0", + "framer-motion": "^12.40.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -11641,18 +12880,18 @@ } }, "node_modules/motion-dom": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", - "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", + "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==", "license": "MIT", "dependencies": { - "motion-utils": "^12.36.0" + "motion-utils": "^12.39.0" } }, "node_modules/motion-utils": { - "version": "12.36.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", - "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", "license": "MIT" }, "node_modules/ms": { @@ -11724,16 +12963,16 @@ } }, "node_modules/nan": { - "version": "2.26.2", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", - "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", + "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", "license": "MIT", "optional": true }, "node_modules/nanoid": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.9.tgz", - "integrity": "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", "funding": [ { "type": "github", @@ -11770,21 +13009,21 @@ "node": ">= 0.6" } }, - "node_modules/next-themes": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", - "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", "dev": true, "license": "MIT", - "peerDependencies": { - "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" } }, "node_modules/node-abi": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz", - "integrity": "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g==", + "version": "4.31.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.31.0.tgz", + "integrity": "sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw==", "dev": true, "license": "MIT", "dependencies": { @@ -11848,9 +13087,9 @@ } }, "node_modules/node-gyp/node_modules/undici": { - "version": "6.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", - "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", + "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", "dev": true, "license": "MIT", "engines": { @@ -11874,11 +13113,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nopt": { "version": "9.0.0", @@ -12131,9 +13373,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -12150,6 +13392,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pdfjs-dist": { "version": "5.4.296", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", @@ -12230,9 +13482,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -12250,7 +13502,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -12259,9 +13511,9 @@ } }, "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -12305,9 +13557,9 @@ } }, "node_modules/prebuild-install/node_modules/node-abi": { - "version": "3.89.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", - "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", "license": "MIT", "dependencies": { "semver": "^7.3.5" @@ -12404,13 +13656,6 @@ "react-is": "^16.13.1" } }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, - "license": "MIT" - }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -12509,6 +13754,15 @@ "node": ">=8" } }, + "node_modules/qrcode/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qrcode/node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -12640,9 +13894,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -12654,6 +13908,176 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/radix-ui": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", + "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-accessible-icon": "1.1.7", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-alert-dialog": "1.1.15", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-aspect-ratio": "1.1.7", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-context-menu": "2.2.16", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-form": "0.1.8", + "@radix-ui/react-hover-card": "1.1.15", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-menubar": "1.1.16", + "@radix-ui/react-navigation-menu": "1.2.14", + "@radix-ui/react-one-time-password-field": "0.1.8", + "@radix-ui/react-password-toggle-field": "0.1.3", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-progress": "1.1.7", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.11", + "@radix-ui/react-toolbar": "1.1.11", + "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-escape-keydown": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -12699,10 +14123,19 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "dev": true, "license": "MIT", "engines": { @@ -12724,50 +14157,16 @@ } }, "node_modules/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.5" - } - }, - "node_modules/react-draggable": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", - "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==", - "dev": true, - "license": "MIT", - "dependencies": { - "clsx": "^2.1.1", - "prop-types": "^15.8.1" - }, - "peerDependencies": { - "react": ">= 16.3.0", - "react-dom": ">= 16.3.0" - } - }, - "node_modules/react-grid-layout": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.3.tgz", - "integrity": "sha512-OAEJHBxmfuxQfVtZwRzmsokijGlBgzYIJ7MUlLk/VSa43SaGzu15w5D0P2RDrfX5EvP9POMbL6bFrai/huDzbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "clsx": "^2.1.1", - "fast-equals": "^4.0.3", - "prop-types": "^15.8.1", - "react-draggable": "^4.4.6", - "react-resizable": "^3.1.3", - "resize-observer-polyfill": "^1.5.1" - }, - "peerDependencies": { - "react": ">= 16.3.0", - "react-dom": ">= 16.3.0" + "react": "^19.2.6" } }, "node_modules/react-h5-audio-player": { @@ -12789,9 +14188,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.73.1", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.73.1.tgz", - "integrity": "sha512-VAfVYOPcx3piiEVQy95vyFmBwbVUsP/AUIN+mpFG8h11yshDd444nn0VyfaGWSRnhOLVgiDu7HIuBtAIzxn9dA==", + "version": "7.76.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.76.1.tgz", + "integrity": "sha512-rYM7tPiWlu3nZchkR/ex7piyzui2vFPyaLnXnI/RnblB/L4qfMmyses8llJVtF1NpE9WBBsJlGtcSZzPCXW1qQ==", "dev": true, "license": "MIT", "engines": { @@ -12806,9 +14205,9 @@ } }, "node_modules/react-i18next": { - "version": "17.0.4", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.4.tgz", - "integrity": "sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==", + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz", + "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==", "dev": true, "license": "MIT", "dependencies": { @@ -12817,7 +14216,7 @@ "use-sync-external-store": "^1.6.0" }, "peerDependencies": { - "i18next": ">= 26.0.1", + "i18next": ">= 26.2.0", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, @@ -12844,9 +14243,10 @@ } }, "node_modules/react-is": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", - "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, "license": "MIT" }, "node_modules/react-markdown": { @@ -12918,30 +14318,6 @@ "react-dom": ">=16.8.0" } }, - "node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -12991,43 +14367,6 @@ } } }, - "node_modules/react-resizable": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.1.3.tgz", - "integrity": "sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==", - "dev": true, - "license": "MIT", - "dependencies": { - "prop-types": "15.x", - "react-draggable": "^4.5.0" - }, - "peerDependencies": { - "react": ">= 16.3", - "react-dom": ">= 16.3" - } - }, - "node_modules/react-resizable-panels": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.10.0.tgz", - "integrity": "sha512-frjewRQt7TCv/vCH1pJfjZ7RxAhr5pKuqVQtVgzFq/vherxBFOWyC3xMbryx5Ti2wylViGUFc93Etg4rB3E0UA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/react-simple-keyboard": { - "version": "3.8.196", - "resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.196.tgz", - "integrity": "sha512-rvBGzgmNAvzqqwE6V1xVuwaAjwdDfzANA600EjW5hGIN4EQS1Zr7SXl+6Vn9++zdigUr468wbMhF4oGo+xpNKg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -13073,9 +14412,9 @@ } }, "node_modules/react-xtermjs": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/react-xtermjs/-/react-xtermjs-1.0.10.tgz", - "integrity": "sha512-+xpKEKbmsypWzRKE0FR1LNIGcI8gx+R6VMHe8IQW7iTbgeqp3Qg7SbiVNOzR+Ovb1QK4DPA3KqsIQV+XP0iRUA==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/react-xtermjs/-/react-xtermjs-1.0.12.tgz", + "integrity": "sha512-8uXBzVF4vP6r2NABxt/9heH1Y5Vx2v2hFrAk1kre+HyY3CjM6Prbc8ViqBRia6EBzI57IFBWOicYV1/k05JBtw==", "dev": true, "license": "ISC", "peerDependencies": { @@ -13116,54 +14455,6 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, - "node_modules/recharts": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", - "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", - "dev": true, - "license": "MIT", - "workspaces": [ - "www" - ], - "dependencies": { - "@reduxjs/toolkit": "^1.9.0 || 2.x.x", - "clsx": "^2.1.1", - "decimal.js-light": "^2.5.1", - "es-toolkit": "^1.39.3", - "eventemitter3": "^5.0.1", - "immer": "^10.1.1", - "react-redux": "8.x.x || 9.x.x", - "reselect": "5.1.1", - "tiny-invariant": "^1.3.3", - "use-sync-external-store": "^1.2.2", - "victory-vendor": "^37.0.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "dev": true, - "license": "MIT" - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, "node_modules/refractor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", @@ -13294,20 +14585,6 @@ "url": "https://github.com/sponsors/jet2jet" } }, - "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/resize-observer-polyfill": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", - "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", - "dev": true, - "license": "MIT" - }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -13353,14 +14630,14 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -13369,30 +14646,23 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", - "dev": true, - "license": "MIT" - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -13473,9 +14743,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -13547,6 +14817,51 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -13752,13 +15067,24 @@ "npm": ">= 3.0.0" } }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "dev": true, "license": "MIT", "dependencies": { - "ip-address": "^10.0.1", + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -13883,9 +15209,9 @@ } }, "node_modules/streamx": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", - "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "version": "2.26.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.26.0.tgz", + "integrity": "sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A==", "license": "MIT", "dependencies": { "events-universal": "^1.0.0", @@ -13985,12 +15311,16 @@ } }, "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/style-mod": { @@ -14049,10 +15379,17 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", - "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", "dev": true, "license": "MIT", "funding": { @@ -14061,9 +15398,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", - "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", "dev": true, "license": "MIT" }, @@ -14082,9 +15419,9 @@ } }, "node_modules/tar": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", - "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -14113,9 +15450,9 @@ } }, "node_modules/tar-stream": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", - "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", "license": "MIT", "dependencies": { "b4a": "^1.6.4", @@ -14191,9 +15528,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", - "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", + "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", "dev": true, "license": "MIT", "engines": { @@ -14218,9 +15555,9 @@ } }, "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.6.tgz", + "integrity": "sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==", "dev": true, "license": "MIT", "engines": { @@ -14319,6 +15656,16 @@ "node": "*" } }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", @@ -14339,17 +15686,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typedarray": { @@ -14373,16 +15737,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz", - "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", + "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.0", - "@typescript-eslint/parser": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0", - "@typescript-eslint/utils": "8.59.0" + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -14397,18 +15761,18 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", "license": "MIT", "engines": { "node": ">=20.18.1" } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, @@ -14696,40 +16060,17 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/victory-vendor": { - "version": "37.3.6", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", - "dev": true, - "license": "MIT AND ISC", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", + "postcss": "^8.5.15", + "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "bin": { @@ -14746,7 +16087,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -14797,6 +16138,21 @@ } } }, + "node_modules/vite-plugin-svgr": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz", + "integrity": "sha512-qj2eAKF8C6PZWemVTvQA0xgQIcP1hHU6Buh7fl6BhvayWwnuxE+z417miKxeDvRWbDrupQ1oK99hfxElopJ3sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.3.0", + "@svgr/core": "^8.1.0", + "@svgr/plugin-jsx": "^8.1.0" + }, + "peerDependencies": { + "vite": ">=3.0.0" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -14857,18 +16213,18 @@ } }, "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -14887,26 +16243,18 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, "node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -14919,9 +16267,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -14967,19 +16315,13 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.0.0-4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-4.tgz", + "integrity": "sha512-MoQoNhTFI400tkaeod+X0Vety1KD2L9dUa6pa1CVcyfcATjC/iDxoMLvqZ6U3D8c5KzxBrU2HnJH+PfaXOqI7w==", "dev": true, "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" + "node": ">= 10" } }, "node_modules/yargs": { @@ -15036,9 +16378,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", "funding": { diff --git a/package.json b/package.json index aee21c22..1914fdd6 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,10 @@ { "name": "termix", "private": true, - "version": "2.2.1", + "version": "2.3.0", "description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities", "author": "Karmaa", "main": "electron/main.cjs", - "type": "module", "engines": { "node": ">=22.12.0", "npm": ">=11" @@ -13,18 +12,18 @@ "scripts": { "format": "prettier --write .", "format:check": "prettier --check .", - "postinstall": "node scripts/patch-app-builder-lib.cjs", + "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs", "prebuild": "node scripts/write-electron-build-info.cjs", "lint": "eslint .", "lint:fix": "eslint --fix .", "type-check": "tsc --noEmit", "dev": "vite", - "build": "vite build && tsc -p tsconfig.node.json", - "build:backend": "tsc -p tsconfig.node.json", - "dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js", + "build": "vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\"", + "build:backend": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\"", + "dev:backend": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node ./dist/backend/backend/starter.js", "dev:docker": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker build -f docker/Dockerfile -t termix:dev --no-cache . && docker run -d --name termix-dev -p 3000:3000 -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev", "dev:docker:restart": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker run -d --name termix-dev -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev", - "generate:openapi": "tsc -p tsconfig.node.json && node ./dist/backend/backend/swagger.js", + "generate:openapi": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node ./dist/backend/backend/swagger.js", "preview": "vite preview", "electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && electron .\"", "electron:patch-builder": "node scripts/patch-app-builder-lib.cjs", @@ -38,6 +37,7 @@ "build:mac-dev": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --mac dir --publish=never" }, "dependencies": { + "@tanstack/react-virtual": "^3.13.26", "axios": "^1.15.2", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.9.0", @@ -58,7 +58,6 @@ "multer": "^2.1.1", "nanoid": "^5.1.9", "qrcode": "^1.5.4", - "react-is": "^19.2.5", "socks": "^2.8.7", "speakeasy": "^2.0.0", "ssh2": "^1.17.0", @@ -71,13 +70,13 @@ "@codemirror/search": "^6.7.0", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.41.1", - "@commitlint/cli": "^20.5.0", - "@commitlint/config-conventional": "^20.5.0", + "@commitlint/cli": "^21.0.1", + "@commitlint/config-conventional": "^21.0.1", "@deadendjs/swagger-jsdoc": "^8.1.2", "@electron/notarize": "^3.1.1", "@electron/rebuild": "^4.0.4", "@eslint/js": "^9.0.0", - "@hookform/resolvers": "^5.2.2", + "@fontsource-variable/jetbrains-mono": "^5.2.8", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -104,7 +103,7 @@ "@types/js-yaml": "^4.0.9", "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.1.0", - "@types/node": "^24.12.2", + "@types/node": "^25.9.1", "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -116,16 +115,16 @@ "@uiw/react-codemirror": "^4.25.9", "@vitejs/plugin-react": "^6.0.1", "@xterm/addon-clipboard": "^0.2.0", - "@xterm/addon-fit": "^0.10.0", - "@xterm/addon-unicode11": "^0.8.0", - "@xterm/addon-web-links": "^0.11.0", - "@xterm/xterm": "^5.5.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "concurrently": "^9.2.1", "cytoscape": "^3.33.2", - "electron": "^41.3.0", + "electron": "^42.2.0", "electron-builder": "^26.8.1", "eslint": "^9.0.0", "eslint-plugin-react-hooks": "^7.1.1", @@ -136,14 +135,13 @@ "husky": "^9.1.7", "i18next": "^26.0.8", "i18next-browser-languagedetector": "^8.2.1", - "lint-staged": "^16.4.0", + "lint-staged": "^17.0.5", "lucide-react": "^1.11.0", - "next-themes": "^0.4.6", "prettier": "3.8.3", + "radix-ui": "^1.4.3", "react": "^19.2.5", "react-cytoscapejs": "^2.0.0", "react-dom": "^19.2.5", - "react-grid-layout": "^2.2.3", "react-h5-audio-player": "^3.10.2", "react-hook-form": "^7.73.1", "react-i18next": "^17.0.4", @@ -151,19 +149,18 @@ "react-markdown": "^10.1.0", "react-pdf": "^10.4.1", "react-photo-view": "^1.2.7", - "react-resizable-panels": "^4.10.0", - "react-simple-keyboard": "^3.8.196", "react-syntax-highlighter": "^16.1.1", "react-xtermjs": "^1.0.10", - "recharts": "^3.8.1", "remark-gfm": "^4.0.1", + "sharp": "^0.34.5", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", + "tw-animate-css": "^1.4.0", "typescript": "~6.0.3", "typescript-eslint": "^8.59.0", - "vite": "^8.0.10", - "zod": "^4.3.6" + "vite": "^8.0.13", + "vite-plugin-svgr": "^5.2.0" }, "lint-staged": { "*.{ts,tsx}": [ diff --git a/public/favicon.ico b/public/favicon.ico index 9b270324..438484bb 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/full-icon.png b/public/full-icon.png index bac3267a..574578cc 100644 Binary files a/public/full-icon.png and b/public/full-icon.png differ diff --git a/public/icon-mac.png b/public/icon-mac.png index 19f7681e..56ae3e4a 100644 Binary files a/public/icon-mac.png and b/public/icon-mac.png differ diff --git a/public/icon.icns b/public/icon.icns index 7e6b7329..1a3cc893 100644 Binary files a/public/icon.icns and b/public/icon.icns differ diff --git a/public/icon.ico b/public/icon.ico index 9b270324..00594ef7 100644 Binary files a/public/icon.ico and b/public/icon.ico differ diff --git a/public/icon.png b/public/icon.png index ecc08b71..574578cc 100644 Binary files a/public/icon.png and b/public/icon.png differ diff --git a/public/icon.svg b/public/icon.svg index 8ffb60cf..4ee301a7 100644 --- a/public/icon.svg +++ b/public/icon.svg @@ -1,29 +1,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + diff --git a/public/icons/1024x1024.png b/public/icons/1024x1024.png index 1cfe9ddc..574578cc 100644 Binary files a/public/icons/1024x1024.png and b/public/icons/1024x1024.png differ diff --git a/public/icons/128x128.png b/public/icons/128x128.png index 250df98e..ba734af5 100644 Binary files a/public/icons/128x128.png and b/public/icons/128x128.png differ diff --git a/public/icons/16x16.png b/public/icons/16x16.png index be540109..553660bb 100644 Binary files a/public/icons/16x16.png and b/public/icons/16x16.png differ diff --git a/public/icons/24x24.png b/public/icons/24x24.png index d3489969..6c083f00 100644 Binary files a/public/icons/24x24.png and b/public/icons/24x24.png differ diff --git a/public/icons/256x256.png b/public/icons/256x256.png index f65773cc..75666110 100644 Binary files a/public/icons/256x256.png and b/public/icons/256x256.png differ diff --git a/public/icons/32x32.png b/public/icons/32x32.png index b112caf3..87ac371a 100644 Binary files a/public/icons/32x32.png and b/public/icons/32x32.png differ diff --git a/public/icons/48x48.png b/public/icons/48x48.png index a8d6242b..9730d5c0 100644 Binary files a/public/icons/48x48.png and b/public/icons/48x48.png differ diff --git a/public/icons/512x512.png b/public/icons/512x512.png index cc795299..56ae3e4a 100644 Binary files a/public/icons/512x512.png and b/public/icons/512x512.png differ diff --git a/public/icons/64x64.png b/public/icons/64x64.png index 9fcfda6b..5a5a1e86 100644 Binary files a/public/icons/64x64.png and b/public/icons/64x64.png differ diff --git a/public/icons/icon.icns b/public/icons/icon.icns index b82c7ae7..1a3cc893 100644 Binary files a/public/icons/icon.icns and b/public/icons/icon.icns differ diff --git a/public/icons/icon.ico b/public/icons/icon.ico index 9b270324..00594ef7 100644 Binary files a/public/icons/icon.ico and b/public/icons/icon.ico differ diff --git a/readme/README-AR.md b/readme/README-AR.md index e1df56c2..5cc1df1d 100644 --- a/readme/README-AR.md +++ b/readme/README-AR.md @@ -1,93 +1,219 @@ -# إحصائيات المستودع +
-

-🇺🇸 English · 🇨🇳 中文 · 🇯🇵 日本語 · 🇰🇷 한국어 · 🇫🇷 Français · 🇩🇪 Deutsch · 🇪🇸 Español · 🇧🇷 Português · 🇷🇺 Русский · 🇸🇦 العربية · 🇮🇳 हिन्दी · 🇹🇷 Türkçe · 🇻🇳 Tiếng Việt · 🇮🇹 Italiano +Termix Logo + +

Termix

+ +

إدارة SSH ذاتية الاستضافة والوصول إلى سطح المكتب البعيد

+ +

+ English · + 中文 · + 日本語 · + 한국어 · + Français · + Deutsch · + Español · + Português · + Русский · + العربية · + हिन्दी · + Türkçe · + Tiếng Việt · + Italiano

-![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) -![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) -![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) -Discord - -

- Repo of the Day Achievement -
- تم تحقيقه في 1 سبتمبر 2025 +

+ + + + Discord


-

- - Termix Banner + +Termix Banner + +
+
+ +

+ Repo of the Day Achievement +
+ تم تحقيقه في 1 سبتمبر 2025

-# نظرة عامة +
-

- - Termix Banner -

+
+ +## نظرة عامة Termix هي منصة مفتوحة المصدر ومجانية للأبد وذاتية الاستضافة لإدارة الخوادم بشكل شامل. توفر حلاً متعدد المنصات لإدارة خوادمك وبنيتك التحتية من خلال واجهة واحدة وسهلة الاستخدام. يوفر Termix الوصول إلى طرفية SSH، والتحكم في سطح المكتب البعيد (RDP، VNC، Telnet)، وقدرات إنشاء أنفاق SSH، وإدارة ملفات SSH عن بُعد، والعديد من الأدوات الأخرى. يُعد Termix البديل المثالي المجاني وذاتي الاستضافة لـ Termius المتاح لجميع المنصات. -# الميزات +
-- **الوصول إلى طرفية SSH** - طرفية كاملة الميزات مع دعم تقسيم الشاشة (حتى 4 لوحات) مع نظام علامات تبويب شبيه بالمتصفح. يتضمن دعم تخصيص الطرفية بما في ذلك سمات الطرفية الشائعة والخطوط والمكونات الأخرى. -- **الوصول إلى سطح المكتب البعيد** - دعم RDP و VNC و Telnet عبر المتصفح مع تخصيص كامل وتقسيم الشاشة. -- **إدارة أنفاق SSH** - إنشاء وإدارة أنفاق SSH بين الخوادم مع إعادة الاتصال التلقائي ومراقبة الحالة وإعادة التوجيه المحلي أو البعيد أو SOCKS الديناميكي. يتم تخزين إعدادات نفق العميل-المكتبي إلى السيرفر محلياً لكل تثبيت مكتبي؛ يمكن حفظ لقطات C2S الاختيارية على الخادم وإعادة تسميتها وتحميلها أو حذفها لنقل تكوين النفق المحلي بين العملاء. -- **مدير الملفات عن بُعد** - إدارة الملفات مباشرة على الخوادم البعيدة مع دعم عرض وتحرير الكود والصور والصوت والفيديو. رفع وتنزيل وإعادة تسمية وحذف ونقل الملفات بسلاسة مع دعم sudo. -- **إدارة Docker** - تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها. -- **مدير مضيفات SSH** - حفظ وتنظيم وإدارة اتصالات SSH الخاصة بك باستخدام العلامات والمجلدات، وحفظ بيانات تسجيل الدخول القابلة لإعادة الاستخدام بسهولة مع إمكانية أتمتة نشر مفاتيح SSH. -- **إحصائيات الخادم** - عرض استخدام المعالج والذاكرة والقرص إلى جانب الشبكة ووقت التشغيل ومعلومات النظام وجدار الحماية ومراقب المنافذ على معظم الخوادم المبنية على Linux. -- **لوحة التحكم** - عرض معلومات الخادم بنظرة واحدة على لوحة التحكم. -- **RBAC** - إنشاء الأدوار ومشاركة المضيفات عبر المستخدمين/الأدوار. -- **مصادقة المستخدمين** - إدارة آمنة للمستخدمين مع ضوابط إدارية ودعم OIDC (مع التحكم في الوصول) و 2FA (TOTP). عرض جلسات المستخدمين النشطة عبر جميع المنصات وإلغاء الصلاحيات. ربط حسابات OIDC/المحلية معاً. -- **تشفير قاعدة البيانات** - يُخزَّن الخادم الخلفي كملفات قاعدة بيانات SQLite مشفرة. اطلع على [الوثائق](https://docs.termix.site/security) لمزيد من المعلومات. -- **مفاتيح API** - إنشاء مفاتيح API محددة النطاق للمستخدم مع تواريخ انتهاء صلاحية للاستخدام في الأتمتة/CI. -- **تصدير/استيراد البيانات** - تصدير واستيراد مضيفات SSH وبيانات الاعتماد وبيانات مدير الملفات. -- **إعداد SSL تلقائي** - إنشاء وإدارة شهادات SSL مدمجة مع إعادة التوجيه إلى HTTPS. +## الميزات + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +**الوصول إلى طرفية SSH:** +طرفية كاملة الميزات مع دعم تقسيم الشاشة (حتى 4 لوحات) مع نظام علامات تبويب شبيه بالمتصفح. يتضمن دعم تخصيص الطرفية بما في ذلك سمات الطرفية الشائعة والخطوط والمكونات الأخرى. + + + +**الوصول إلى سطح المكتب البعيد:** +دعم RDP و VNC و Telnet عبر المتصفح مع تخصيص كامل وتقسيم الشاشة. + +
+ +**إدارة أنفاق SSH:** +إنشاء وإدارة أنفاق SSH بين الخوادم مع إعادة الاتصال التلقائي ومراقبة الحالة وإعادة التوجيه المحلي أو البعيد أو SOCKS الديناميكي. يتم تخزين إعدادات نفق العميل-المكتبي إلى السيرفر محلياً لكل تثبيت مكتبي؛ يمكن حفظ لقطات C2S الاختيارية على الخادم وإعادة تسميتها وتحميلها أو حذفها لنقل تكوين النفق المحلي بين العملاء. + + + +**مدير الملفات عن بُعد:** +إدارة الملفات مباشرة على الخوادم البعيدة مع دعم عرض وتحرير الكود والصور والصوت والفيديو. رفع وتنزيل وإعادة تسمية وحذف ونقل الملفات بسلاسة مع دعم sudo. + +
+ +**إدارة Docker:** +تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها. + + + +**مدير مضيفات SSH:** +حفظ وتنظيم وإدارة اتصالات SSH الخاصة بك باستخدام العلامات والمجلدات، وحفظ بيانات تسجيل الدخول القابلة لإعادة الاستخدام بسهولة مع إمكانية أتمتة نشر مفاتيح SSH. + +
+ +**إحصائيات الخادم:** +عرض استخدام المعالج والذاكرة والقرص إلى جانب الشبكة ووقت التشغيل ومعلومات النظام وجدار الحماية ومراقب المنافذ على معظم الخوادم المبنية على Linux. + + + +**مصادقة المستخدمين:** +إدارة آمنة للمستخدمين مع ضوابط إدارية ودعم OIDC (مع التحكم في الوصول) و 2FA (TOTP). عرض جلسات المستخدمين النشطة عبر جميع المنصات وإلغاء الصلاحيات. ربط حسابات OIDC/المحلية معاً. + +
+ +**RBAC:** +إنشاء الأدوار ومشاركة المضيفات عبر المستخدمين/الأدوار. + + + +**تشفير قاعدة البيانات:** +يُخزَّن الخادم الخلفي كملفات قاعدة بيانات SQLite مشفرة. اطلع على [الوثائق](https://docs.termix.site/security) لمزيد من المعلومات. + +
+ +**الرسم البياني للشبكة:** +تخصيص لوحة التحكم لتصور مختبرك المنزلي بناءً على اتصالات SSH مع دعم الحالة. + + + +**أدوات SSH:** +إنشاء مقتطفات أوامر قابلة لإعادة الاستخدام تُنفَّذ بنقرة واحدة. تشغيل أمر واحد في وقت واحد عبر عدة طرفيات مفتوحة. + +
+ +**علامات التبويب الدائمة:** +تبقى جلسات SSH وعلامات التبويب مفتوحة عبر الأجهزة/التحديثات إذا تم تفعيلها في ملف تعريف المستخدم. + + + +**اللغات:** +دعم مدمج لحوالي 30 لغة (تُدار بواسطة [Crowdin](https://docs.termix.site/translations)). + +
+ +
+ +
+المزيد من الميزات +
+ +- **لوحة التحكم** - عرض معلومات الخادم بنظرة واحدة على لوحة التحكم +- **مفاتيح API** - إنشاء مفاتيح API محددة النطاق للمستخدم مع تواريخ انتهاء صلاحية للاستخدام في الأتمتة/CI +- **تصدير/استيراد البيانات** - تصدير واستيراد مضيفات SSH وبيانات الاعتماد وبيانات مدير الملفات +- **إعداد SSL تلقائي** - إنشاء وإدارة شهادات SSL مدمجة مع إعادة التوجيه إلى HTTPS - **واجهة مستخدم حديثة** - واجهة نظيفة متوافقة مع سطح المكتب والهاتف المحمول مبنية بـ React و Tailwind CSS و Shadcn. الاختيار بين العديد من سمات واجهة المستخدم بما في ذلك الفاتح والداكن و Dracula وغيرها. استخدام مسارات URL لفتح أي اتصال في وضع ملء الشاشة. -- **اللغات** - دعم مدمج لحوالي 30 لغة (تُدار بواسطة [Crowdin](https://docs.termix.site/translations)). -- **دعم المنصات** - متاح كتطبيق ويب، وتطبيق سطح مكتب (Windows و Linux و macOS، يمكن تشغيله بشكل مستقل بدون خادم Termix الخلفي)، و PWA، وتطبيق مخصص للهاتف المحمول/الجهاز اللوحي لـ iOS و Android. -- **أدوات SSH** - إنشاء مقتطفات أوامر قابلة لإعادة الاستخدام تُنفَّذ بنقرة واحدة. تشغيل أمر واحد في وقت واحد عبر عدة طرفيات مفتوحة. -- **سجل الأوامر** - الإكمال التلقائي وعرض أوامر SSH التي تم تنفيذها سابقاً. -- **الاتصال السريع** - الاتصال بخادم دون الحاجة إلى حفظ بيانات الاتصال. -- **لوحة الأوامر** - اضغط مرتين على Shift الأيسر للوصول السريع إلى اتصالات SSH باستخدام لوحة المفاتيح. +- **سجل الأوامر** - الإكمال التلقائي وعرض أوامر SSH التي تم تنفيذها سابقاً +- **الاتصال السريع** - الاتصال بخادم دون الحاجة إلى حفظ بيانات الاتصال +- **لوحة الأوامر** - اضغط مرتين على Shift الأيسر للوصول السريع إلى اتصالات SSH باستخدام لوحة المفاتيح - **ميزات SSH الغنية** - دعم مضيفات القفز، Warpgate، الاتصالات المبنية على TOTP، SOCKS5، التحقق من مفتاح المضيف، الملء التلقائي لكلمة المرور، [OPKSSH](https://github.com/openpubkey/opkssh)، tmux، port knocking، إلخ. -- **الرسم البياني للشبكة** - تخصيص لوحة التحكم لتصور مختبرك المنزلي بناءً على اتصالات SSH مع دعم الحالة. -- **علامات التبويب الدائمة** - تبقى جلسات SSH وعلامات التبويب مفتوحة عبر الأجهزة/التحديثات إذا تم تفعيلها في ملف تعريف المستخدم. -# الميزات المخططة +
-راجع [المشاريع](https://github.com/orgs/Termix-SSH/projects/2) لعرض جميع الميزات المخططة. إذا كنت تتطلع للمساهمة، راجع [المساهمة](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). +
-# التثبيت +## دعم المنصات -الأجهزة المدعومة: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
المنصةالتوزيع
Webأي متصفح حديث (Chrome، Safari، Firefox) · دعم PWA
Windows x64/ia32نسخة محمولة · مثبت MSI · Chocolatey
Linux x64/ia32نسخة محمولة · AUR · AppImage · Deb · Flatpak
macOS x64/ia32, v12.0+Apple App Store · DMG · Homebrew
iOS/iPadOS v15.1+Apple App Store · IPA
Android v7.0+Google Play Store · APK
-- الموقع الإلكتروني (أي متصفح حديث على أي منصة مثل Chrome و Safari و Firefox) (يتضمن دعم PWA) -- Windows (x64/ia32) - - نسخة محمولة - - مثبت MSI - - مدير حزم Chocolatey -- Linux (x64/ia32) - - نسخة محمولة - - AUR - - AppImage - - Deb - - Flatpak -- macOS (x64/ia32 على الإصدار 12.0+) - - Apple App Store - - DMG - - Homebrew -- iOS/iPadOS (الإصدار 15.1+) - - Apple App Store - - IPA -- Android (الإصدار 7.0+) - - Google Play Store - - APK +
-قم بزيارة [وثائق](https://docs.termix.site/install) Termix للحصول على مزيد من المعلومات حول كيفية تثبيت Termix على جميع المنصات. بخلاف ذلك، يمكنك الاطلاع على نموذج ملف Docker Compose هنا (يمكنك حذف guacd والشبكة إذا كنت لا تخطط لاستخدام ميزات سطح المكتب البعيد): +## التثبيت + +قم بزيارة [وثائق](https://docs.termix.site/install) Termix للحصول على مزيد من المعلومات حول كيفية تثبيت Termix على جميع المنصات. يمكنك الاطلاع على نموذج ملف Docker Compose هنا (يمكنك حذف guacd والشبكة إذا كنت لا تخطط لاستخدام ميزات سطح المكتب البعيد): ```yaml services: @@ -124,79 +250,108 @@ networks: driver: bridge ``` -# الرعاة +
-

- - DigitalOcean - -          - - Crowdin - -          - - Blacksmith - -          - - Cloudflare - -          - - TailScale - -          - - Akamai - -          - - AWS - -

+## لقطات الشاشة -# الدعم +
-إذا كنت بحاجة إلى مساعدة أو ترغب في طلب ميزة لـ Termix، قم بزيارة صفحة [المشكلات](https://github.com/Termix-SSH/Support/issues)، وسجل الدخول، واضغط على `New Issue`. -يرجى أن تكون مفصلاً قدر الإمكان في مشكلتك، ويُفضَّل كتابتها باللغة الإنجليزية. يمكنك أيضاً الانضمام إلى خادم [Discord](https://discord.gg/jVQGdvHDrf) وزيارة قناة الدعم، ومع ذلك قد تكون أوقات الاستجابة أطول. - -# لقطات الشاشة +
[![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos) -

- Termix Demo 1 - Termix Demo 2 -

+شاهد نظرة عامة على التحديثات على YouTube -

- Termix Demo 3 - Termix Demo 4 -

+
+
-

- Termix Demo 5 - Termix Demo 6 -

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Termix Screenshot 1Termix Screenshot 2
Termix Screenshot 3Termix Screenshot 4
Termix Screenshot 5Termix Screenshot 6
Termix Screenshot 7Termix Screenshot 8
Termix Screenshot 9Termix Screenshot 10
Termix Screenshot 11Termix Screenshot 12
Termix Screenshot 13Termix Screenshot 14
-

- Termix Demo 7 - Termix Demo 8 -

+قد تكون بعض مقاطع الفيديو والصور قديمة أو قد لا تعرض الميزات بشكل مثالي. -

- Termix Demo 9 - Termix Demo 10 -

+
-

- Termix Demo 11 - Termix Demo 12 -

+
-قد تكون بعض مقاطع الفيديو والصور قديمة أو قد لا تعرض الميزات بشكل مثالي. +## الميزات المخططة -# الترخيص +راجع [المشاريع](https://github.com/orgs/Termix-SSH/projects/2) لعرض جميع الميزات المخططة. إذا كنت تتطلع للمساهمة، راجع [المساهمة](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). -موزع بموجب رخصة Apache License الإصدار 2.0. راجع ملف LICENSE لمزيد من المعلومات. +
+ +## الرعاة + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + + +
+ +
+ +## الدعم + +إذا كنت بحاجة إلى مساعدة أو ترغب في طلب ميزة لـ Termix، قم بزيارة صفحة [المشكلات](https://github.com/Termix-SSH/Support/issues)، وسجل الدخول، واضغط على `New Issue`. يرجى أن تكون مفصلاً قدر الإمكان في مشكلتك، ويُفضَّل كتابتها باللغة الإنجليزية. يمكنك أيضاً الانضمام إلى خادم [Discord](https://discord.gg/jVQGdvHDrf) وزيارة قناة الدعم، ومع ذلك قد تكون أوقات الاستجابة أطول. + +
+ +## الترخيص + +موزع بموجب رخصة Apache License الإصدار 2.0. راجع ملف `LICENSE` لمزيد من المعلومات. diff --git a/readme/README-CN.md b/readme/README-CN.md index a1c05112..e71fcb3c 100644 --- a/readme/README-CN.md +++ b/readme/README-CN.md @@ -1,93 +1,219 @@ -# 仓库统计 +
-

-🇺🇸 English · 🇨🇳 中文 · 🇯🇵 日本語 · 🇰🇷 한국어 · 🇫🇷 Français · 🇩🇪 Deutsch · 🇪🇸 Español · 🇧🇷 Português · 🇷🇺 Русский · 🇸🇦 العربية · 🇮🇳 हिन्दी · 🇹🇷 Türkçe · 🇻🇳 Tiếng Việt · 🇮🇹 Italiano +Termix Logo + +

Termix

+ +

自托管 SSH 管理与远程桌面访问平台

+ +

+ English · + 中文 · + 日本語 · + 한국어 · + Français · + Deutsch · + Español · + Português · + Русский · + العربية · + हिन्दी · + Türkçe · + Tiếng Việt · + Italiano

-![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) -![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) -![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) -Discord - -

- Repo of the Day Achievement -
- 获得于 2025年9月1日 +

+ + + + Discord


-

- - Termix Banner + +Termix Banner + +
+
+ +

+ Repo of the Day Achievement +
+ 获得于 2025年9月1日

-# 概览 +
-

- - Termix Banner -

+
+ +## 概览 Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个多平台解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix 提供 SSH 终端访问、远程桌面控制(RDP、VNC、Telnet)、SSH 隧道功能、远程 SSH 文件管理以及许多其他工具。Termix 是适用于所有平台的完美免费自托管 Termius 替代品。 -# 功能 +
-- **SSH 终端访问** - 功能齐全的终端,支持分屏(最多 4 个面板),并配有类似浏览器的标签系统。包括对自定义终端的支持,如常用的终端主题、字体和其他组件。 -- **远程桌面访问** - 通过浏览器支持 RDP、VNC 和 Telnet,具有完整的自定义和分屏功能。 -- **SSH 隧道管理** - 创建和管理具有自动重连和健康监测功能的服务器间 SSH 隧道,支持本地、远程或动态 SOCKS 转发。桌面客户端到服务器的隧道设置按桌面安装本地存储,可选的 C2S 预设快照可保存到服务器、重命名、加载或删除,以便在客户端之间迁移本地隧道配置。 -- **远程文件管理器** - 直接在远程服务器上管理文件,支持查看和编辑代码、图像、音频和视频。支持通过 sudo 无缝上传、下载、重命名、删除和移动文件。 -- **Docker 管理** - 启动、停止、暂停、移除容器。查看容器统计信息。通过 docker exec 终端控制容器。它的初衷不是取代 Portainer 或 Dockge,而是为了比直接创建容器更简单地管理它们。 -- **SSH 主机管理器** - 通过标签和文件夹保存、组织和管理您的 SSH 连接,轻松保存可重用的登录信息,并能自动化部署 SSH 密钥。 -- **服务器统计** - 在大多数基于 Linux 的服务器上查看 CPU、内存、磁盘使用情况以及网络、运行时间、系统信息、防火墙和端口监控。 -- **仪表板** - 在仪表板上一目了然地查看服务器信息。 -- **RBAC** - 创建角色并在用户/角色之间共享主机。 -- **用户认证** - 安全的用户管理,具有管理员控制、OIDC(带访问控制)和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地账户链接在一起。 -- **数据库加密** - 后端存储为加密的 SQLite 数据库文件。查看[文档](https://docs.termix.site/security)了解更多。 -- **API 密钥** - 创建带有到期日期的用户范围 API 密钥,用于自动化/CI。 -- **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据。 -- **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向。 +## 功能 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +**SSH 终端访问:** +功能齐全的终端,支持分屏(最多 4 个面板),并配有类似浏览器的标签系统。包括对自定义终端的支持,如常用的终端主题、字体和其他组件。 + + + +**远程桌面访问:** +通过浏览器支持 RDP、VNC 和 Telnet,具有完整的自定义和分屏功能。 + +
+ +**SSH 隧道管理:** +创建和管理具有自动重连和健康监测功能的服务器间 SSH 隧道,支持本地、远程或动态 SOCKS 转发。桌面客户端到服务器的隧道设置按桌面安装本地存储,可选的 C2S 预设快照可保存到服务器、重命名、加载或删除,以便在客户端之间迁移本地隧道配置。 + + + +**远程文件管理器:** +直接在远程服务器上管理文件,支持查看和编辑代码、图像、音频和视频。支持通过 sudo 无缝上传、下载、重命名、删除和移动文件。 + +
+ +**Docker 管理:** +启动、停止、暂停、移除容器。查看容器统计信息。通过 docker exec 终端控制容器。它的初衷不是取代 Portainer 或 Dockge,而是为了比直接创建容器更简单地管理它们。 + + + +**SSH 主机管理器:** +通过标签和文件夹保存、组织和管理您的 SSH 连接,轻松保存可重用的登录信息,并能自动化部署 SSH 密钥。 + +
+ +**服务器统计:** +在大多数基于 Linux 的服务器上查看 CPU、内存、磁盘使用情况以及网络、运行时间、系统信息、防火墙和端口监控。 + + + +**用户认证:** +安全的用户管理,具有管理员控制、OIDC(带访问控制)和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地账户链接在一起。 + +
+ +**RBAC:** +创建角色并在用户/角色之间共享主机。 + + + +**数据库加密:** +后端存储为加密的 SQLite 数据库文件。查看[文档](https://docs.termix.site/security)了解更多。 + +
+ +**网络图:** +自定义您的仪表板,根据您的 SSH 连接可视化您的家庭实验室,并支持状态监测。 + + + +**SSH 工具:** +创建可重用的命令片段,只需点击一下即可执行。在多个打开的终端中同时运行一个命令。 + +
+ +**持久标签页:** +如果在用户个人资料中启用,SSH 会话和标签页将在设备/刷新后保持打开状态。 + + + +**语言:** +内置支持约 30 种语言(由 [Crowdin](https://docs.termix.site/translations) 管理)。 + +
+ +
+ +
+更多功能 +
+ +- **仪表板** - 在仪表板上一目了然地查看服务器信息 +- **API 密钥** - 创建带有到期日期的用户范围 API 密钥,用于自动化/CI +- **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据 +- **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向 - **现代 UI** - 使用 React、Tailwind CSS 和 Shadcn 构建的整洁的桌面/移动友好界面。有多种 UI 主题可选,包括浅色、深色、Dracula 等。使用 URL 路由全屏打开任何连接。 -- **语言** - 内置支持约 30 种语言(由 [Crowdin](https://docs.termix.site/translations) 管理)。 -- **平台支持** - 提供 Web 应用、桌面应用(Windows、Linux 和 macOS,可脱离 Termix 后端独立运行)、PWA 以及 iOS 和 Android 专用移动/平板应用。 -- **SSH 工具** - 创建可重用的命令片段,只需点击一下即可执行。在多个打开的终端中同时运行一个命令。 -- **命令历史** - 自动完成并查看之前运行过的 SSH 命令。 -- **快速连接** - 无需保存连接数据即可连接到服务器。 -- **命令面板** - 双击左 Shift 键即可通过键盘快速访问 SSH 连接。 -- **丰富的功能** - 支持跳转主机、Warpgate、基于 TOTP 的连接、SOCKS5、主机密钥验证、密码自动填充、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、端口敲击等。 -- **网络图** - 自定义您的仪表板,根据您的 SSH 连接可视化您的家庭实验室,并支持状态监测。 -- **持久标签页** - 如果在用户个人资料中启用,SSH 会话和标签页将在设备/刷新后保持打开状态。 +- **命令历史** - 自动完成并查看之前运行过的 SSH 命令 +- **快速连接** - 无需保存连接数据即可连接到服务器 +- **命令面板** - 双击左 Shift 键即可通过键盘快速访问 SSH 连接 +- **丰富的 SSH 功能** - 支持跳转主机、Warpgate、基于 TOTP 的连接、SOCKS5、主机密钥验证、密码自动填充、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、端口敲击等 -# 计划功能 +
-查看 [Projects](https://github.com/orgs/Termix-SSH/projects/2) 了解所有计划功能。如果您想贡献代码,请参阅 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)。 +
-# 安装 +## 平台支持 -支持的设备: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
平台发行版
Web任何现代浏览器(Chrome、Safari、Firefox)· PWA 支持
Windows x64/ia32便携版 · MSI 安装程序 · Chocolatey
Linux x64/ia32便携版 · AUR · AppImage · Deb · Flatpak
macOS x64/ia32, v12.0+Apple App Store · DMG · Homebrew
iOS/iPadOS v15.1+Apple App Store · IPA
Android v7.0+Google Play Store · APK
-- 网站(任何平台上的任何现代浏览器,如 Chrome、Safari 和 Firefox)(包括 PWA 支持) -- Windows (x64/ia32) - - 便携版 - - MSI 安装程序 - - Chocolatey 软件包管理器 -- Linux (x64/ia32) - - 便携版 - - AUR - - AppImage - - Deb - - Flatpak -- macOS (x64/ia32, v12.0+) - - Apple App Store - - DMG - - Homebrew -- iOS/iPadOS (v15.1+) - - Apple App Store - - IPA -- Android (v7.0+) - - Google Play 商店 - - APK +
-访问 Termix [文档](https://docs.termix.site/install) 了解有关如何在所有平台上安装 Termix 的更多信息。此外,这里有一个示例 Docker Compose 文件(如果您不打算使用远程桌面功能,可以省略 guacd 和网络部分): +## 安装 + +访问 [Termix 文档](https://docs.termix.site/install) 了解有关如何在所有平台上安装 Termix 的更多信息。此外,这里有一个示例 Docker Compose 文件(如果您不打算使用远程桌面功能,可以省略 guacd 和网络部分): ```yaml services: @@ -124,79 +250,108 @@ networks: driver: bridge ``` -# 赞助商 +
-

- - DigitalOcean - -          - - Crowdin - -          - - Blacksmith - -          - - Cloudflare - -          - - TailScale - -          - - Akamai - -          - - AWS - -

+## 展示 -# 支持 +
-如果您需要 Termix 的帮助或想要请求功能,请访问 [Issues](https://github.com/Termix-SSH/Support/issues) 页面,登录并点击 `New Issue`。 -请尽可能详细地描述您的问题,建议使用英语。您也可以加入 [Discord](https://discord.gg/jVQGdvHDrf) 服务器并访问支持频道,但响应时间可能较长。 - -# 展示 +
[![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos) -

- Termix Demo 1 - Termix Demo 2 -

+在 YouTube 上观看更新概览 -

- Termix Demo 3 - Termix Demo 4 -

+
+
-

- Termix Demo 5 - Termix Demo 6 -

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Termix Screenshot 1Termix Screenshot 2
Termix Screenshot 3Termix Screenshot 4
Termix Screenshot 5Termix Screenshot 6
Termix Screenshot 7Termix Screenshot 8
Termix Screenshot 9Termix Screenshot 10
Termix Screenshot 11Termix Screenshot 12
Termix Screenshot 13Termix Screenshot 14
-

- Termix Demo 7 - Termix Demo 8 -

+某些视频和图像可能已过时,或者可能无法完美展示功能。 -

- Termix Demo 9 - Termix Demo 10 -

+
-

- Termix Demo 11 - Termix Demo 12 -

+
-某些视频和图像可能已过时,或者可能无法完美展示功能。 +## 计划功能 -# 许可证 +查看 [Projects](https://github.com/orgs/Termix-SSH/projects/2) 了解所有计划功能。如果您想贡献代码,请参阅 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)。 -根据 Apache License Version 2.0 发布。更多信息请参见 LICENSE。 +
+ +## 赞助商 + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + + +
+ +
+ +## 支持 + +如果您需要 Termix 的帮助或想要请求功能,请访问 [Issues](https://github.com/Termix-SSH/Support/issues) 页面,登录并点击 `New Issue`。请尽可能详细地描述您的问题,建议使用英语。您也可以加入 [Discord](https://discord.gg/jVQGdvHDrf) 服务器并访问支持频道,但响应时间可能较长。 + +
+ +## 许可证 + +根据 Apache License Version 2.0 发布。更多信息请参见 `LICENSE`。 diff --git a/readme/README-DE.md b/readme/README-DE.md index 4080f9d4..387631db 100644 --- a/readme/README-DE.md +++ b/readme/README-DE.md @@ -1,93 +1,219 @@ -# Repo-Statistiken +
-

-🇺🇸 English · 🇨🇳 中文 · 🇯🇵 日本語 · 🇰🇷 한국어 · 🇫🇷 Français · 🇩🇪 Deutsch · 🇪🇸 Español · 🇧🇷 Português · 🇷🇺 Русский · 🇸🇦 العربية · 🇮🇳 हिन्दी · 🇹🇷 Türkçe · 🇻🇳 Tiếng Việt · 🇮🇹 Italiano +Termix Logo + +

Termix

+ +

Selbst gehostete SSH-Verwaltung und Remote-Desktop-Zugriff

+ +

+ English · + 中文 · + 日本語 · + 한국어 · + Français · + Deutsch · + Español · + Português · + Русский · + العربية · + हिन्दी · + Türkçe · + Tiếng Việt · + Italiano

-![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) -![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) -![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) -Discord - -

- Repo of the Day Achievement -
- Erreicht am 1. September 2025 +

+ + + + Discord


-

- - Termix Banner + +Termix Banner + +
+
+ +

+ Repo of the Day Achievement +
+ Erreicht am 1. September 2025

-# Überblick +
-

- - Termix Banner -

+
-Termix ist eine quelloffene, dauerhaft kostenlose, selbst gehostete All-in-One-Serververwaltungsplattform. Sie bietet eine plattformübergreifende Lösung zur Verwaltung Ihrer Server und Infrastruktur über eine einzige, intuitive Oberfläche. Termix bietet SSH-Terminalzugriff, Remote-Desktop-Steuerung (RDP, VNC, Telnet), SSH-Tunneling-Funktionen, Remote-SSH-Dateiverwaltung und viele weitere Werkzeuge. Termix ist die perfekte kostenlose und selbst gehostete Alternative zu Termius, verfügbar für alle Plattformen. +## Uberblick -# Funktionen +Termix ist eine quelloffene, dauerhaft kostenlose, selbst gehostete All-in-One-Serververwaltungsplattform. Sie bietet eine plattformubergreifende Losung zur Verwaltung Ihrer Server und Infrastruktur uber eine einzige, intuitive Oberflache. Termix bietet SSH-Terminalzugriff, Remote-Desktop-Steuerung (RDP, VNC, Telnet), SSH-Tunneling-Funktionen, Remote-SSH-Dateiverwaltung und viele weitere Werkzeuge. Termix ist die perfekte kostenlose und selbst gehostete Alternative zu Termius, verfugbar fur alle Plattformen. -- **SSH-Terminalzugriff** - Voll ausgestattetes Terminal mit Split-Screen-Unterstützung (bis zu 4 Panels) mit einem browserähnlichen Tab-System. Enthält Unterstützung für die Anpassung des Terminals einschließlich gängiger Terminal-Themes, Schriftarten und anderer Komponenten. -- **Remote-Desktop-Zugriff** - RDP-, VNC- und Telnet-Unterstützung über den Browser mit vollständiger Anpassung und Split-Screen. -- **SSH-Tunnelverwaltung** - Erstellen und verwalten Sie Server-zu-Server-SSH-Tunnel mit automatischer Wiederverbindung und Gesundheitsüberwachung sowie lokaler, entfernter oder dynamischer SOCKS-Weiterleitung. Desktop-Client-zu-Server-Tunneleinstellungen werden lokal pro Desktop-Installation gespeichert, optionale C2S-Preset-Snapshots können auf dem Server gespeichert, umbenannt, geladen oder gelöscht werden, um eine lokale Tunnelkonfiguration zwischen Clients zu übertragen. -- **Remote-Dateimanager** - Verwalten Sie Dateien direkt auf Remote-Servern mit Unterstützung für das Anzeigen und Bearbeiten von Code, Bildern, Audio und Video. Laden Sie Dateien hoch, herunter, benennen Sie sie um, löschen oder verschieben Sie sie nahtlos mit Sudo-Unterstützung. -- **Docker-Verwaltung** - Container starten, stoppen, pausieren, entfernen. Container-Statistiken anzeigen. Container über Docker-Exec-Terminal steuern. Es wurde nicht entwickelt, um Portainer oder Dockge zu ersetzen, sondern um Ihre Container einfach zu verwalten, anstatt sie zu erstellen. -- **SSH-Host-Manager** - Speichern, organisieren und verwalten Sie Ihre SSH-Verbindungen mit Tags und Ordnern und speichern Sie einfach wiederverwendbare Anmeldeinformationen mit der Möglichkeit, die Bereitstellung von SSH-Schlüsseln zu automatisieren. -- **Serverstatistiken** - CPU-, Arbeitsspeicher- und Festplattenauslastung sowie Netzwerk, Betriebszeit, Systeminformationen, Firewall, Port-Monitor auf den meisten Linux-basierten Servern anzeigen. -- **Dashboard** - Serverinformationen auf einen Blick auf Ihrem Dashboard anzeigen. -- **RBAC** - Rollen erstellen und Hosts über Benutzer/Rollen teilen. -- **Benutzerauthentifizierung** - Sichere Benutzerverwaltung mit Admin-Kontrollen und OIDC- (mit Zugriffskontrolle) sowie 2FA (TOTP)-Unterstützung. Aktive Benutzersitzungen über alle Plattformen anzeigen und Berechtigungen widerrufen. OIDC-/Lokale Konten miteinander verknüpfen. -- **Datenbankverschlüsselung** - Backend gespeichert als verschlüsselte SQLite-Datenbankdateien. Weitere Informationen in der [Dokumentation](https://docs.termix.site/security). -- **API-Schlüssel** - Erstellen Sie benutzerbezogene API-Schlüssel mit Ablaufdaten zur Verwendung für Automatisierung/CI. -- **Datenexport/-import** - SSH-Hosts, Anmeldeinformationen und Dateimanager-Daten exportieren und importieren. -- **Automatische SSL-Einrichtung** - Integrierte SSL-Zertifikatsgenerierung und -verwaltung mit HTTPS-Weiterleitungen. -- **Moderne Benutzeroberfläche** - Saubere desktop-/mobilfreundliche Oberfläche, erstellt mit React, Tailwind CSS und Shadcn. Wählen Sie zwischen vielen verschiedenen UI-Themes einschließlich Hell, Dunkel, Dracula usw. Verwenden Sie URL-Routen, um jede Verbindung im Vollbildmodus zu öffnen. -- **Sprachen** - Integrierte Unterstützung für ~30 Sprachen (verwaltet über [Crowdin](https://docs.termix.site/translations)). -- **Plattformunterstützung** - Verfügbar als Web-App, Desktop-Anwendung (Windows, Linux und macOS, kann eigenständig ohne Termix-Backend ausgeführt werden), PWA und dedizierte Mobil-/Tablet-App für iOS und Android. -- **SSH-Werkzeuge** - Erstellen Sie wiederverwendbare Befehlsvorlagen, die mit einem einzigen Klick ausgeführt werden. Führen Sie einen Befehl gleichzeitig in mehreren geöffneten Terminals aus. -- **Befehlsverlauf** - Autovervollständigung und Anzeige zuvor ausgeführter SSH-Befehle. -- **Schnellverbindung** - Verbinden Sie sich mit einem Server, ohne die Verbindungsdaten speichern zu müssen. -- **Befehlspalette** - Doppeltippen Sie die linke Umschalttaste, um schnell auf SSH-Verbindungen mit Ihrer Tastatur zuzugreifen. -- **SSH-Funktionsreich** - Unterstützt Jump-Hosts, Warpgate, TOTP-basierte Verbindungen, SOCKS5, Host-Key-Verifizierung, automatisches Ausfüllen von Passwörtern, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, Port Knocking usw. -- **Netzwerkgraph** - Passen Sie Ihr Dashboard an, um Ihr Homelab basierend auf Ihren SSH-Verbindungen mit Statusunterstützung zu visualisieren. -- **Persistente Tabs** - SSH-Sitzungen und Tabs bleiben über Geräte/Aktualisierungen hinweg offen, wenn im Benutzerprofil aktiviert. +
-# Geplante Funktionen +## Funktionen -Siehe [Projekte](https://github.com/orgs/Termix-SSH/projects/2) für alle geplanten Funktionen. Wenn Sie beitragen möchten, siehe [Mitwirken](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-# Installation +**SSH-Terminalzugriff:** +Voll ausgestattetes Terminal mit Split-Screen-Unterstutzung (bis zu 4 Panels) mit einem browserahnlichen Tab-System. Enthalt Unterstutzung fur die Anpassung des Terminals einschliesslich gangiger Terminal-Themes, Schriftarten und anderer Komponenten. -Unterstützte Geräte: + -- Website (jeder moderne Browser auf jeder Plattform wie Chrome, Safari und Firefox) (einschließlich PWA-Unterstützung) -- Windows (x64/ia32) - - Portabel - - MSI-Installationsprogramm - - Chocolatey-Paketmanager -- Linux (x64/ia32) - - Portabel - - AUR - - AppImage - - Deb - - Flatpak -- macOS (x64/ia32 ab v12.0+) - - Apple App Store - - DMG - - Homebrew -- iOS/iPadOS (v15.1+) - - Apple App Store - - IPA -- Android (v7.0+) - - Google Play Store - - APK +**Remote-Desktop-Zugriff:** +RDP-, VNC- und Telnet-Unterstutzung uber den Browser mit vollstandiger Anpassung und Split-Screen. -Besuchen Sie die Termix-[Dokumentation](https://docs.termix.site/install) für weitere Informationen zur Installation von Termix auf allen Plattformen. Alternativ finden Sie hier eine Docker Compose-Beispieldatei (Sie können guacd und das Netzwerk weglassen, wenn Sie keine Remote-Desktop-Funktionen nutzen möchten): +
+ +**SSH-Tunnelverwaltung:** +Erstellen und verwalten Sie Server-zu-Server-SSH-Tunnel mit automatischer Wiederverbindung und Gesundheitsuberwachung sowie lokaler, entfernter oder dynamischer SOCKS-Weiterleitung. Desktop-Client-zu-Server-Tunneleinstellungen werden lokal pro Desktop-Installation gespeichert, optionale C2S-Preset-Snapshots konnen auf dem Server gespeichert, umbenannt, geladen oder geloscht werden, um eine lokale Tunnelkonfiguration zwischen Clients zu ubertragen. + + + +**Remote-Dateimanager:** +Verwalten Sie Dateien direkt auf Remote-Servern mit Unterstutzung fur das Anzeigen und Bearbeiten von Code, Bildern, Audio und Video. Laden Sie Dateien hoch, herunter, benennen Sie sie um, loschen oder verschieben Sie sie nahtlos mit Sudo-Unterstutzung. + +
+ +**Docker-Verwaltung:** +Container starten, stoppen, pausieren, entfernen. Container-Statistiken anzeigen. Container uber Docker-Exec-Terminal steuern. Es wurde nicht entwickelt, um Portainer oder Dockge zu ersetzen, sondern um Ihre Container einfach zu verwalten, anstatt sie zu erstellen. + + + +**SSH-Host-Manager:** +Speichern, organisieren und verwalten Sie Ihre SSH-Verbindungen mit Tags und Ordnern und speichern Sie einfach wiederverwendbare Anmeldeinformationen mit der Moglichkeit, die Bereitstellung von SSH-Schlusseln zu automatisieren. + +
+ +**Serverstatistiken:** +CPU-, Arbeitsspeicher- und Festplattenauslastung sowie Netzwerk, Betriebszeit, Systeminformationen, Firewall, Port-Monitor auf den meisten Linux-basierten Servern anzeigen. + + + +**Benutzerauthentifizierung:** +Sichere Benutzerverwaltung mit Admin-Kontrollen und OIDC- (mit Zugriffskontrolle) sowie 2FA (TOTP)-Unterstutzung. Aktive Benutzersitzungen uber alle Plattformen anzeigen und Berechtigungen widerrufen. OIDC-/Lokale Konten miteinander verknupfen. + +
+ +**RBAC:** +Rollen erstellen und Hosts uber Benutzer/Rollen teilen. + + + +**Datenbankverschlusselung:** +Backend gespeichert als verschlusselte SQLite-Datenbankdateien. Weitere Informationen in der [Dokumentation](https://docs.termix.site/security). + +
+ +**Netzwerkgraph:** +Passen Sie Ihr Dashboard an, um Ihr Homelab basierend auf Ihren SSH-Verbindungen mit Statusunterstutzung zu visualisieren. + + + +**SSH-Werkzeuge:** +Erstellen Sie wiederverwendbare Befehlsvorlagen, die mit einem einzigen Klick ausgefuhrt werden. Fuhren Sie einen Befehl gleichzeitig in mehreren geoffneten Terminals aus. + +
+ +**Persistente Tabs:** +SSH-Sitzungen und Tabs bleiben uber Gerate/Aktualisierungen hinweg offen, wenn im Benutzerprofil aktiviert. + + + +**Sprachen:** +Integrierte Unterstutzung fur ca. 30 Sprachen (verwaltet uber [Crowdin](https://docs.termix.site/translations)). + +
+ +
+ +
+Weitere Funktionen +
+ +- **Dashboard** - Serverinformationen auf einen Blick auf Ihrem Dashboard anzeigen +- **API-Schlussel** - Erstellen Sie benutzerbezogene API-Schlussel mit Ablaufdaten zur Verwendung fur Automatisierung/CI +- **Datenexport/-import** - SSH-Hosts, Anmeldeinformationen und Dateimanager-Daten exportieren und importieren +- **Automatische SSL-Einrichtung** - Integrierte SSL-Zertifikatsgenerierung und -verwaltung mit HTTPS-Weiterleitungen +- **Moderne Benutzeroberflache** - Saubere desktop-/mobilfreundliche Oberflache, erstellt mit React, Tailwind CSS und Shadcn. Wahlen Sie zwischen vielen verschiedenen UI-Themes einschliesslich Hell, Dunkel, Dracula usw. Verwenden Sie URL-Routen, um jede Verbindung im Vollbildmodus zu offnen. +- **Befehlsverlauf** - Autovervollstandigung und Anzeige zuvor ausgefuhrter SSH-Befehle +- **Schnellverbindung** - Verbinden Sie sich mit einem Server, ohne die Verbindungsdaten speichern zu mussen +- **Befehlspalette** - Doppeltippen Sie die linke Umschalttaste, um schnell auf SSH-Verbindungen mit Ihrer Tastatur zuzugreifen +- **SSH-Funktionsreich** - Unterstutzt Jump-Hosts, Warpgate, TOTP-basierte Verbindungen, SOCKS5, Host-Key-Verifizierung, automatisches Ausfullen von Passwortern, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, Port Knocking usw. + +
+ +
+ +## Plattformunterstutzung + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PlattformDistribution
WebJeder moderne Browser (Chrome, Safari, Firefox) · PWA-Unterstutzung
Windows x64/ia32Portabel · MSI-Installationsprogramm · Chocolatey
Linux x64/ia32Portabel · AUR · AppImage · Deb · Flatpak
macOS x64/ia32, v12.0+Apple App Store · DMG · Homebrew
iOS/iPadOS v15.1+Apple App Store · IPA
Android v7.0+Google Play Store · APK
+ +
+ +## Installation + +Besuchen Sie die Termix-[Dokumentation](https://docs.termix.site/install) fur weitere Informationen zur Installation von Termix auf allen Plattformen. Alternativ finden Sie hier eine Docker Compose-Beispieldatei (Sie konnen guacd und das Netzwerk weglassen, wenn Sie keine Remote-Desktop-Funktionen nutzen mochten): ```yaml services: @@ -124,79 +250,108 @@ networks: driver: bridge ``` -# Sponsoren +
-

- - DigitalOcean - -          - - Crowdin - -          - - Blacksmith - -          - - Cloudflare - -          - - TailScale - -          - - Akamai - -          - - AWS - -

+## Screenshots -# Support +
-Wenn Sie Hilfe benötigen oder eine Funktion für Termix anfragen möchten, besuchen Sie die [Issues](https://github.com/Termix-SSH/Support/issues)-Seite, melden Sie sich an und klicken Sie auf `New Issue`. -Bitte beschreiben Sie Ihr Anliegen so detailliert wie möglich, vorzugsweise auf Englisch. Sie können auch dem [Discord](https://discord.gg/jVQGdvHDrf)-Server beitreten und den Support-Kanal besuchen, allerdings können die Antwortzeiten dort länger sein. - -# Screenshots +
[![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos) -

- Termix Demo 1 - Termix Demo 2 -

+Update-Ubersichten auf YouTube ansehen -

- Termix Demo 3 - Termix Demo 4 -

+
+
-

- Termix Demo 5 - Termix Demo 6 -

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Termix Screenshot 1Termix Screenshot 2
Termix Screenshot 3Termix Screenshot 4
Termix Screenshot 5Termix Screenshot 6
Termix Screenshot 7Termix Screenshot 8
Termix Screenshot 9Termix Screenshot 10
Termix Screenshot 11Termix Screenshot 12
Termix Screenshot 13Termix Screenshot 14
-

- Termix Demo 7 - Termix Demo 8 -

+Einige Videos und Bilder konnen veraltet sein oder Funktionen moglicherweise nicht perfekt darstellen. -

- Termix Demo 9 - Termix Demo 10 -

+
-

- Termix Demo 11 - Termix Demo 12 -

+
-Einige Videos und Bilder können veraltet sein oder Funktionen möglicherweise nicht perfekt darstellen. +## Geplante Funktionen -# Lizenz +Siehe [Projekte](https://github.com/orgs/Termix-SSH/projects/2) fur alle geplanten Funktionen. Wenn Sie beitragen mochten, siehe [Mitwirken](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). -Verteilt unter der Apache License Version 2.0. Siehe LICENSE für weitere Informationen. +
+ +## Sponsoren + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + + +
+ +
+ +## Support + +Wenn Sie Hilfe benotigen oder eine Funktion fur Termix anfragen mochten, besuchen Sie die [Issues](https://github.com/Termix-SSH/Support/issues)-Seite, melden Sie sich an und klicken Sie auf `New Issue`. Bitte beschreiben Sie Ihr Anliegen so detailliert wie moglich, vorzugsweise auf Englisch. Sie konnen auch dem [Discord](https://discord.gg/jVQGdvHDrf)-Server beitreten und den Support-Kanal besuchen, allerdings konnen die Antwortzeiten dort langer sein. + +
+ +## Lizenz + +Verteilt unter der Apache License Version 2.0. Siehe `LICENSE` fur weitere Informationen. diff --git a/readme/README-ES.md b/readme/README-ES.md index 70fbabe1..97e8def2 100644 --- a/readme/README-ES.md +++ b/readme/README-ES.md @@ -1,93 +1,219 @@ -# Estadísticas del Repositorio +
-

-🇺🇸 English · 🇨🇳 中文 · 🇯🇵 日本語 · 🇰🇷 한국어 · 🇫🇷 Français · 🇩🇪 Deutsch · 🇪🇸 Español · 🇧🇷 Português · 🇷🇺 Русский · 🇸🇦 العربية · 🇮🇳 हिन्दी · 🇹🇷 Türkçe · 🇻🇳 Tiếng Việt · 🇮🇹 Italiano +Termix Logo + +

Termix

+ +

Gestion SSH autoalojada y acceso a escritorio remoto

+ +

+ English · + 中文 · + 日本語 · + 한국어 · + Français · + Deutsch · + Español · + Português · + Русский · + العربية · + हिन्दी · + Türkçe · + Tiếng Việt · + Italiano

-![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) -![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) -![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) -Discord - -

- Repo of the Day Achievement -
- Logrado el 1 de septiembre de 2025 +

+ + + + Discord


-

- - Termix Banner + +Termix Banner + +
+
+ +

+ Repo of the Day Achievement +
+ Logrado el 1 de septiembre de 2025

-# Descripción General +
-

- - Termix Banner -

+
-Termix es una plataforma de gestión de servidores todo en uno, de código abierto, siempre gratuita y autoalojada. Proporciona una solución multiplataforma para gestionar sus servidores e infraestructura a través de una interfaz única e intuitiva. Termix ofrece acceso a terminal SSH, control de escritorio remoto (RDP, VNC, Telnet), capacidades de túneles SSH, gestión remota de archivos SSH y muchas otras herramientas. Termix es la alternativa perfecta, gratuita y autoalojada a Termius, disponible para todas las plataformas. +## Descripcion General -# Características +Termix es una plataforma de gestion de servidores todo en uno, de codigo abierto, siempre gratuita y autoalojada. Proporciona una solucion multiplataforma para gestionar sus servidores e infraestructura a traves de una interfaz unica e intuitiva. Termix ofrece acceso a terminal SSH, control de escritorio remoto (RDP, VNC, Telnet), capacidades de tuneles SSH, gestion remota de archivos SSH y muchas otras herramientas. Termix es la alternativa perfecta, gratuita y autoalojada a Termius, disponible para todas las plataformas. -- **Acceso a Terminal SSH** - Terminal completo con soporte de pantalla dividida (hasta 4 paneles) con un sistema de pestañas similar al navegador. Incluye soporte para personalizar el terminal incluyendo temas comunes de terminal, fuentes y otros componentes. -- **Acceso a Escritorio Remoto** - Soporte RDP, VNC y Telnet a través del navegador con personalización completa y pantalla dividida. -- **Gestión de Túneles SSH** - Cree y gestione túneles SSH de servidor a servidor con reconexión automática, monitoreo de estado y reenvío local, remoto o dinámico SOCKS. La configuración de túneles de cliente de escritorio a servidor se almacena localmente por instalación de escritorio; los snapshots de presets C2S opcionales pueden guardarse en el servidor, renombrarse, cargarse o eliminarse para mover una configuración de túnel local entre clientes. -- **Gestor Remoto de Archivos** - Gestione archivos directamente en servidores remotos con soporte para visualizar y editar código, imágenes, audio y video. Suba, descargue, renombre, elimine y mueva archivos sin problemas con soporte sudo. -- **Gestión de Docker** - Inicie, detenga, pause, elimine contenedores. Vea estadísticas de contenedores. Controle contenedores usando el terminal docker exec. No fue creado para reemplazar Portainer o Dockge, sino para simplemente gestionar sus contenedores en lugar de crearlos. -- **Gestor de Hosts SSH** - Guarde, organice y gestione sus conexiones SSH con etiquetas y carpetas, y guarde fácilmente información de inicio de sesión reutilizable con la capacidad de automatizar el despliegue de claves SSH. -- **Estadísticas del Servidor** - Vea el uso de CPU, memoria y disco junto con red, tiempo de actividad, información del sistema, firewall, monitor de puertos en la mayoría de los servidores basados en Linux. -- **Dashboard** - Vea la información del servidor de un vistazo en su dashboard. -- **RBAC** - Cree roles y comparta hosts entre usuarios/roles. -- **Autenticación de Usuarios** - Gestión segura de usuarios con controles de administrador y soporte para OIDC (con control de acceso) y 2FA (TOTP). Vea sesiones activas de usuarios en todas las plataformas y revoque permisos. Vincule sus cuentas OIDC/Locales entre sí. -- **Cifrado de Base de Datos** - Backend almacenado como archivos de base de datos SQLite cifrados. Consulte la [documentación](https://docs.termix.site/security) para más información. -- **Claves API** - Cree claves API con ámbito de usuario y fechas de vencimiento para usar en automatización/CI. -- **Exportación/Importación de Datos** - Exporte e importe hosts SSH, credenciales y datos del gestor de archivos. -- **Configuración Automática de SSL** - Generación y gestión integrada de certificados SSL con redirecciones HTTPS. -- **Interfaz Moderna** - Interfaz limpia compatible con escritorio/móvil construida con React, Tailwind CSS y Shadcn. Elija entre muchos temas de UI diferentes, incluyendo claro, oscuro, Dracula, etc. Use rutas URL para abrir cualquier conexión en pantalla completa. -- **Idiomas** - Soporte integrado para ~30 idiomas (gestionado por [Crowdin](https://docs.termix.site/translations)). -- **Soporte de Plataformas** - Disponible como aplicación web, aplicación de escritorio (Windows, Linux y macOS, se puede ejecutar de forma independiente sin el backend de Termix), PWA y aplicación dedicada para móviles/tablets en iOS y Android. -- **Herramientas SSH** - Cree fragmentos de comandos reutilizables que se ejecutan con un solo clic. Ejecute un comando simultáneamente en múltiples terminales abiertos. -- **Historial de Comandos** - Autocompletado y visualización de comandos SSH ejecutados anteriormente. -- **Conexión Rápida** - Conéctese a un servidor sin necesidad de guardar los datos de conexión. -- **Paleta de Comandos** - Pulse dos veces la tecla Shift izquierda para acceder rápidamente a las conexiones SSH con su teclado. -- **SSH Rico en Funciones** - Soporta jump hosts, Warpgate, conexiones basadas en TOTP, SOCKS5, verificación de clave de host, autocompletado de contraseñas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc. -- **Gráfico de Red** - Personalice su Dashboard para visualizar su homelab basado en sus conexiones SSH con soporte de estado. -- **Pestañas Persistentes** - Las sesiones SSH y pestañas permanecen abiertas entre dispositivos/actualizaciones si está habilitado en el perfil de usuario. +
-# Características Planeadas +## Caracteristicas -Consulte [Proyectos](https://github.com/orgs/Termix-SSH/projects/2) para todas las características planeadas. Si desea contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-# Instalación +**Acceso a Terminal SSH:** +Terminal completo con soporte de pantalla dividida (hasta 4 paneles) con un sistema de pestanas similar al navegador. Incluye soporte para personalizar el terminal incluyendo temas comunes de terminal, fuentes y otros componentes. -Dispositivos soportados: + -- Sitio web (cualquier navegador moderno en cualquier plataforma como Chrome, Safari y Firefox) (incluye soporte PWA) -- Windows (x64/ia32) - - Portable - - Instalador MSI - - Gestor de paquetes Chocolatey -- Linux (x64/ia32) - - Portable - - AUR - - AppImage - - Deb - - Flatpak -- macOS (x64/ia32 en v12.0+) - - Apple App Store - - DMG - - Homebrew -- iOS/iPadOS (v15.1+) - - Apple App Store - - IPA -- Android (v7.0+) - - Google Play Store - - APK +**Acceso a Escritorio Remoto:** +Soporte RDP, VNC y Telnet a traves del navegador con personalizacion completa y pantalla dividida. -Visite la [documentación](https://docs.termix.site/install) de Termix para más información sobre cómo instalar Termix en todas las plataformas. De lo contrario, vea un archivo Docker Compose de ejemplo aquí (puede omitir guacd y la red si no planea usar funciones de escritorio remoto): +
+ +**Gestion de Tuneles SSH:** +Cree y gestione tuneles SSH de servidor a servidor con reconexion automatica, monitoreo de estado y reenvio local, remoto o dinamico SOCKS. La configuracion de tuneles de cliente de escritorio a servidor se almacena localmente por instalacion de escritorio; los snapshots de presets C2S opcionales pueden guardarse en el servidor, renombrarse, cargarse o eliminarse para mover una configuracion de tunel local entre clientes. + + + +**Gestor Remoto de Archivos:** +Gestione archivos directamente en servidores remotos con soporte para visualizar y editar codigo, imagenes, audio y video. Suba, descargue, renombre, elimine y mueva archivos sin problemas con soporte sudo. + +
+ +**Gestion de Docker:** +Inicie, detenga, pause, elimine contenedores. Vea estadisticas de contenedores. Controle contenedores usando el terminal docker exec. No fue creado para reemplazar Portainer o Dockge, sino para simplemente gestionar sus contenedores en lugar de crearlos. + + + +**Gestor de Hosts SSH:** +Guarde, organice y gestione sus conexiones SSH con etiquetas y carpetas, y guarde facilmente informacion de inicio de sesion reutilizable con la capacidad de automatizar el despliegue de claves SSH. + +
+ +**Estadisticas del Servidor:** +Vea el uso de CPU, memoria y disco junto con red, tiempo de actividad, informacion del sistema, firewall, monitor de puertos en la mayoria de los servidores basados en Linux. + + + +**Autenticacion de Usuarios:** +Gestion segura de usuarios con controles de administrador y soporte para OIDC (con control de acceso) y 2FA (TOTP). Vea sesiones activas de usuarios en todas las plataformas y revoque permisos. Vincule sus cuentas OIDC/Locales entre si. + +
+ +**RBAC:** +Cree roles y comparta hosts entre usuarios/roles. + + + +**Cifrado de Base de Datos:** +Backend almacenado como archivos de base de datos SQLite cifrados. Consulte la [documentacion](https://docs.termix.site/security) para mas informacion. + +
+ +**Grafico de Red:** +Personalice su Dashboard para visualizar su homelab basado en sus conexiones SSH con soporte de estado. + + + +**Herramientas SSH:** +Cree fragmentos de comandos reutilizables que se ejecutan con un solo clic. Ejecute un comando simultaneamente en multiples terminales abiertos. + +
+ +**Pestanas Persistentes:** +Las sesiones SSH y pestanas permanecen abiertas entre dispositivos/actualizaciones si esta habilitado en el perfil de usuario. + + + +**Idiomas:** +Soporte integrado para aproximadamente 30 idiomas (gestionado por [Crowdin](https://docs.termix.site/translations)). + +
+ +
+ +
+Mas caracteristicas +
+ +- **Dashboard** - Vea la informacion del servidor de un vistazo en su dashboard +- **Claves API** - Cree claves API con ambito de usuario y fechas de vencimiento para usar en automatizacion/CI +- **Exportacion/Importacion de Datos** - Exporte e importe hosts SSH, credenciales y datos del gestor de archivos +- **Configuracion Automatica de SSL** - Generacion y gestion integrada de certificados SSL con redirecciones HTTPS +- **Interfaz Moderna** - Interfaz limpia compatible con escritorio/movil construida con React, Tailwind CSS y Shadcn. Elija entre muchos temas de UI diferentes, incluyendo claro, oscuro, Dracula, etc. Use rutas URL para abrir cualquier conexion en pantalla completa. +- **Historial de Comandos** - Autocompletado y visualizacion de comandos SSH ejecutados anteriormente +- **Conexion Rapida** - Conectese a un servidor sin necesidad de guardar los datos de conexion +- **Paleta de Comandos** - Pulse dos veces la tecla Shift izquierda para acceder rapidamente a las conexiones SSH con su teclado +- **SSH Rico en Funciones** - Soporta jump hosts, Warpgate, conexiones basadas en TOTP, SOCKS5, verificacion de clave de host, autocompletado de contrasenas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc. + +
+ +
+ +## Soporte de Plataformas + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PlataformaDistribucion
WebCualquier navegador moderno (Chrome, Safari, Firefox) · Soporte PWA
Windows x64/ia32Portable · Instalador MSI · Chocolatey
Linux x64/ia32Portable · AUR · AppImage · Deb · Flatpak
macOS x64/ia32, v12.0+Apple App Store · DMG · Homebrew
iOS/iPadOS v15.1+Apple App Store · IPA
Android v7.0+Google Play Store · APK
+ +
+ +## Instalacion + +Visite la [documentacion](https://docs.termix.site/install) de Termix para mas informacion sobre como instalar Termix en todas las plataformas. De lo contrario, vea un archivo Docker Compose de ejemplo aqui (puede omitir guacd y la red si no planea usar funciones de escritorio remoto): ```yaml services: @@ -124,79 +250,108 @@ networks: driver: bridge ``` -# Patrocinadores +
-

- - DigitalOcean - -          - - Crowdin - -          - - Blacksmith - -          - - Cloudflare - -          - - TailScale - -          - - Akamai - -          - - AWS - -

+## Capturas de Pantalla -# Soporte +
-Si necesita ayuda o desea solicitar una función para Termix, visite la página de [Issues](https://github.com/Termix-SSH/Support/issues), inicie sesión y pulse `New Issue`. -Por favor, sea lo más detallado posible en su reporte, preferiblemente escrito en inglés. También puede unirse al servidor de [Discord](https://discord.gg/jVQGdvHDrf) y visitar el canal de soporte, sin embargo, los tiempos de respuesta pueden ser más largos. - -# Capturas de Pantalla +
[![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos) -

- Termix Demo 1 - Termix Demo 2 -

+Ver resúmenes de actualizaciones en YouTube -

- Termix Demo 3 - Termix Demo 4 -

+
+
-

- Termix Demo 5 - Termix Demo 6 -

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Termix Screenshot 1Termix Screenshot 2
Termix Screenshot 3Termix Screenshot 4
Termix Screenshot 5Termix Screenshot 6
Termix Screenshot 7Termix Screenshot 8
Termix Screenshot 9Termix Screenshot 10
Termix Screenshot 11Termix Screenshot 12
Termix Screenshot 13Termix Screenshot 14
-

- Termix Demo 7 - Termix Demo 8 -

+Algunos videos e imagenes pueden estar desactualizados o no mostrar perfectamente las caracteristicas. -

- Termix Demo 9 - Termix Demo 10 -

+
-

- Termix Demo 11 - Termix Demo 12 -

+
-Algunos videos e imágenes pueden estar desactualizados o no mostrar perfectamente las características. +## Caracteristicas Planeadas -# Licencia +Consulte [Proyectos](https://github.com/orgs/Termix-SSH/projects/2) para todas las caracteristicas planeadas. Si desea contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). -Distribuido bajo la Licencia Apache Versión 2.0. Consulte LICENSE para más información. +
+ +## Patrocinadores + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + + +
+ +
+ +## Soporte + +Si necesita ayuda o desea solicitar una funcion para Termix, visite la pagina de [Issues](https://github.com/Termix-SSH/Support/issues), inicie sesion y pulse `New Issue`. Por favor, sea lo mas detallado posible en su reporte, preferiblemente escrito en ingles. Tambien puede unirse al servidor de [Discord](https://discord.gg/jVQGdvHDrf) y visitar el canal de soporte, sin embargo, los tiempos de respuesta pueden ser mas largos. + +
+ +## Licencia + +Distribuido bajo la Licencia Apache Version 2.0. Consulte `LICENSE` para mas informacion. diff --git a/readme/README-FR.md b/readme/README-FR.md index 41cbf8c4..8801ac46 100644 --- a/readme/README-FR.md +++ b/readme/README-FR.md @@ -1,93 +1,219 @@ -# Statistiques du dépôt +
-

-🇺🇸 English · 🇨🇳 中文 · 🇯🇵 日本語 · 🇰🇷 한국어 · 🇫🇷 Français · 🇩🇪 Deutsch · 🇪🇸 Español · 🇧🇷 Português · 🇷🇺 Русский · 🇸🇦 العربية · 🇮🇳 हिन्दी · 🇹🇷 Türkçe · 🇻🇳 Tiếng Việt · 🇮🇹 Italiano +Termix Logo + +

Termix

+ +

Gestion SSH auto-hebergee et acces bureau a distance

+ +

+ English · + 中文 · + 日本語 · + 한국어 · + Français · + Deutsch · + Español · + Português · + Русский · + العربية · + हिन्दी · + Türkçe · + Tiếng Việt · + Italiano

-![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) -![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) -![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) -Discord - -

- Repo of the Day Achievement -
- Obtenu le 1er septembre 2025 +

+ + + + Discord


-

- - Termix Banner + +Termix Banner + +
+
+ +

+ Repo of the Day Achievement +
+ Obtenu le 1er septembre 2025

-# Présentation +
-

- - Termix Banner -

+
-Termix est une plateforme de gestion de serveurs tout-en-un, open source, à jamais gratuite et auto-hébergée. Elle fournit une solution multiplateforme pour gérer vos serveurs et votre infrastructure à travers une interface unique et intuitive. Termix offre un accès terminal SSH, le contrôle de bureau à distance (RDP, VNC, Telnet), des capacités de tunneling SSH, la gestion de fichiers SSH à distance et de nombreux autres outils. Termix est l'alternative parfaite, gratuite et auto-hébergée à Termius, disponible sur toutes les plateformes. +## Presentation -# Fonctionnalités +Termix est une plateforme de gestion de serveurs tout-en-un, open source, a jamais gratuite et auto-hebergee. Elle fournit une solution multiplateforme pour gerer vos serveurs et votre infrastructure a travers une interface unique et intuitive. Termix offre un acces terminal SSH, le controle de bureau a distance (RDP, VNC, Telnet), des capacites de tunneling SSH, la gestion de fichiers SSH a distance et de nombreux autres outils. Termix est l'alternative parfaite, gratuite et auto-hebergee a Termius, disponible sur toutes les plateformes. -- **Accès terminal SSH** - Terminal complet avec support d'écran partagé (jusqu'à 4 panneaux) et un système d'onglets inspiré des navigateurs. Inclut la personnalisation du terminal avec des thèmes courants, des polices et d'autres composants. -- **Accès Bureau à Distance** - Support RDP, VNC et Telnet via navigateur avec personnalisation complète et écran partagé. -- **Gestion des tunnels SSH** - Créez et gérez des tunnels SSH de serveur à serveur avec reconnexion automatique, surveillance de l'état et transfert local, distant ou SOCKS dynamique. Les paramètres de tunnel client-bureau-vers-serveur sont stockés localement par installation bureau ; des instantanés de préréglages C2S optionnels peuvent être sauvegardés sur le serveur, renommés, chargés ou supprimés pour déplacer une configuration de tunnel locale entre clients. -- **Gestionnaire de fichiers distant** - Gérez les fichiers directement sur les serveurs distants avec support de la visualisation et de l'édition de code, images, audio et vidéo. Téléversez, téléchargez, renommez, supprimez et déplacez des fichiers de manière fluide avec support sudo. -- **Gestion Docker** - Démarrez, arrêtez, mettez en pause, supprimez des conteneurs. Consultez les statistiques des conteneurs. Contrôlez les conteneurs via le terminal docker exec. Non conçu pour remplacer Portainer ou Dockge, mais plutôt pour gérer simplement vos conteneurs plutôt que de les créer. -- **Gestionnaire d'hôtes SSH** - Enregistrez, organisez et gérez vos connexions SSH avec des tags et des dossiers, et sauvegardez facilement les informations de connexion réutilisables tout en automatisant le déploiement des clés SSH. -- **Statistiques serveur** - Visualisez l'utilisation du CPU, de la mémoire et du disque ainsi que le réseau, le temps de fonctionnement, les informations système, le pare-feu et le moniteur de ports sur la plupart des serveurs Linux. -- **Tableau de bord** - Consultez les informations de vos serveurs en un coup d'œil depuis votre tableau de bord. -- **RBAC** - Créez des rôles et partagez des hôtes entre utilisateurs/rôles. -- **Authentification des utilisateurs** - Gestion sécurisée des utilisateurs avec contrôles administrateur et support OIDC (avec contrôle d'accès) et 2FA (TOTP). Visualisez les sessions utilisateur actives sur toutes les plateformes et révoquez les permissions. Liez vos comptes OIDC/locaux ensemble. -- **Chiffrement de la base de données** - Le backend est stocké sous forme de fichiers de base de données SQLite chiffrés. Consultez la [documentation](https://docs.termix.site/security) pour plus de détails. -- **Clés API** - Créez des clés API à portée utilisateur avec des dates d'expiration pour une utilisation en automatisation/CI. -- **Export/Import de données** - Exportez et importez les hôtes SSH, les identifiants et les données du gestionnaire de fichiers. -- **Configuration SSL automatique** - Génération et gestion intégrées de certificats SSL avec redirections HTTPS. -- **Interface moderne** - Interface épurée compatible desktop/mobile construite avec React, Tailwind CSS et Shadcn. Choisissez parmi de nombreux thèmes d'interface utilisateur, notamment clair, sombre, Dracula, etc. Utilisez les routes URL pour ouvrir n'importe quelle connexion en plein écran. -- **Langues** - Support intégré d'environ 30 langues (géré par [Crowdin](https://docs.termix.site/translations)). -- **Support multiplateforme** - Disponible en tant qu'application web, application de bureau (Windows, Linux et macOS, peut être exécutée de manière autonome sans backend Termix), PWA, et application mobile/tablette dédiée pour iOS et Android. -- **Outils SSH** - Créez des extraits de commandes réutilisables exécutables en un seul clic. Exécutez une commande simultanément sur plusieurs terminaux ouverts. -- **Historique des commandes** - Auto-complétion et consultation des commandes SSH précédemment exécutées. -- **Connexion rapide** - Connectez-vous à un serveur sans avoir à sauvegarder les données de connexion. -- **Palette de commandes** - Appuyez deux fois sur Shift gauche pour accéder rapidement aux connexions SSH avec votre clavier. -- **SSH riche en fonctionnalités** - Support des hôtes de rebond, Warpgate, connexions basées sur TOTP, SOCKS5, vérification des clés d'hôte, remplissage automatique des mots de passe, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc. -- **Graphe réseau** - Personnalisez votre tableau de bord pour visualiser votre homelab basé sur vos connexions SSH avec support des statuts. -- **Onglets Persistants** - Les sessions SSH et les onglets restent ouverts sur tous les appareils/actualisations si activé dans le profil utilisateur. +
-# Fonctionnalités prévues +## Fonctionnalites -Consultez les [Projects](https://github.com/orgs/Termix-SSH/projects/2) pour toutes les fonctionnalités prévues. Si vous souhaitez contribuer, consultez [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-# Installation +**Acces terminal SSH:** +Terminal complet avec support d'ecran partage (jusqu'a 4 panneaux) et un systeme d'onglets inspire des navigateurs. Inclut la personnalisation du terminal avec des themes courants, des polices et d'autres composants. -Appareils supportés : + -- Site web (tout navigateur moderne sur toute plateforme comme Chrome, Safari et Firefox) (support PWA inclus) -- Windows (x64/ia32) - - Portable - - Installateur MSI - - Gestionnaire de paquets Chocolatey -- Linux (x64/ia32) - - Portable - - AUR - - AppImage - - Deb - - Flatpak -- macOS (x64/ia32 sur v12.0+) - - Apple App Store - - DMG - - Homebrew -- iOS/iPadOS (v15.1+) - - Apple App Store - - IPA -- Android (v7.0+) - - Google Play Store - - APK +**Acces Bureau a Distance:** +Support RDP, VNC et Telnet via navigateur avec personnalisation complete et ecran partage. -Visitez la [documentation](https://docs.termix.site/install) de Termix pour plus d'informations sur l'installation de Termix sur toutes les plateformes. Sinon, voici un exemple de fichier Docker Compose (vous pouvez omettre guacd et le réseau si vous ne prévoyez pas d'utiliser les fonctionnalités de bureau à distance) : +
+ +**Gestion des tunnels SSH:** +Creez et gerez des tunnels SSH de serveur a serveur avec reconnexion automatique, surveillance de l'etat et transfert local, distant ou SOCKS dynamique. Les parametres de tunnel client-bureau-vers-serveur sont stockes localement par installation bureau ; des instantanes de prereglages C2S optionnels peuvent etre sauvegardes sur le serveur, renommes, charges ou supprimes pour deplacer une configuration de tunnel locale entre clients. + + + +**Gestionnaire de fichiers distant:** +Gerez les fichiers directement sur les serveurs distants avec support de la visualisation et de l'edition de code, images, audio et video. Televersez, telechargez, renommez, supprimez et deplacez des fichiers de maniere fluide avec support sudo. + +
+ +**Gestion Docker:** +Demarrez, arretez, mettez en pause, supprimez des conteneurs. Consultez les statistiques des conteneurs. Controlez les conteneurs via le terminal docker exec. Non concu pour remplacer Portainer ou Dockge, mais plutot pour gerer simplement vos conteneurs plutot que de les creer. + + + +**Gestionnaire d'hotes SSH:** +Enregistrez, organisez et gerez vos connexions SSH avec des tags et des dossiers, et sauvegardez facilement les informations de connexion reutilisables tout en automatisant le deploiement des cles SSH. + +
+ +**Statistiques serveur:** +Visualisez l'utilisation du CPU, de la memoire et du disque ainsi que le reseau, le temps de fonctionnement, les informations systeme, le pare-feu et le moniteur de ports sur la plupart des serveurs Linux. + + + +**Authentification des utilisateurs:** +Gestion securisee des utilisateurs avec controles administrateur et support OIDC (avec controle d'acces) et 2FA (TOTP). Visualisez les sessions utilisateur actives sur toutes les plateformes et revoquez les permissions. Liez vos comptes OIDC/locaux ensemble. + +
+ +**RBAC:** +Creez des roles et partagez des hotes entre utilisateurs/roles. + + + +**Chiffrement de la base de donnees:** +Le backend est stocke sous forme de fichiers de base de donnees SQLite chiffres. Consultez la [documentation](https://docs.termix.site/security) pour plus de details. + +
+ +**Graphe reseau:** +Personnalisez votre tableau de bord pour visualiser votre homelab base sur vos connexions SSH avec support des statuts. + + + +**Outils SSH:** +Creez des extraits de commandes reutilisables executables en un seul clic. Executez une commande simultanement sur plusieurs terminaux ouverts. + +
+ +**Onglets Persistants:** +Les sessions SSH et les onglets restent ouverts sur tous les appareils/actualisations si active dans le profil utilisateur. + + + +**Langues:** +Support integre d'environ 30 langues (gere par [Crowdin](https://docs.termix.site/translations)). + +
+ +
+ +
+Plus de fonctionnalites +
+ +- **Tableau de bord** - Consultez les informations de vos serveurs en un coup d'oeil depuis votre tableau de bord +- **Cles API** - Creez des cles API a portee utilisateur avec des dates d'expiration pour une utilisation en automatisation/CI +- **Export/Import de donnees** - Exportez et importez les hotes SSH, les identifiants et les donnees du gestionnaire de fichiers +- **Configuration SSL automatique** - Generation et gestion integrees de certificats SSL avec redirections HTTPS +- **Interface moderne** - Interface epuree compatible desktop/mobile construite avec React, Tailwind CSS et Shadcn. Choisissez parmi de nombreux themes d'interface utilisateur, notamment clair, sombre, Dracula, etc. Utilisez les routes URL pour ouvrir n'importe quelle connexion en plein ecran. +- **Historique des commandes** - Auto-completion et consultation des commandes SSH precedemment executees +- **Connexion rapide** - Connectez-vous a un serveur sans avoir a sauvegarder les donnees de connexion +- **Palette de commandes** - Appuyez deux fois sur Shift gauche pour acceder rapidement aux connexions SSH avec votre clavier +- **SSH riche en fonctionnalites** - Support des hotes de rebond, Warpgate, connexions basees sur TOTP, SOCKS5, verification des cles d'hote, remplissage automatique des mots de passe, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc. + +
+ +
+ +## Support des plateformes + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PlateformeDistribution
WebTout navigateur moderne (Chrome, Safari, Firefox) · Support PWA
Windows x64/ia32Portable · MSI Installateur · Chocolatey
Linux x64/ia32Portable · AUR · AppImage · Deb · Flatpak
macOS x64/ia32, v12.0+Apple App Store · DMG · Homebrew
iOS/iPadOS v15.1+Apple App Store · IPA
Android v7.0+Google Play Store · APK
+ +
+ +## Installation + +Visitez la [documentation](https://docs.termix.site/install) de Termix pour plus d'informations sur l'installation de Termix sur toutes les plateformes. Voici un exemple de fichier Docker Compose (vous pouvez omettre guacd et le reseau si vous ne prevoyez pas d'utiliser les fonctionnalites de bureau a distance) : ```yaml services: @@ -124,78 +250,108 @@ networks: driver: bridge ``` -# Sponsors +
-

- - DigitalOcean - -          - - Crowdin - -          - - Blacksmith - -          - - Cloudflare - -          - - TailScale - -          - - Akamai - -          - - AWS - -

+## Captures d'ecran -# Support +
-Si vous avez besoin d'aide ou souhaitez demander une fonctionnalité pour Termix, visitez la page [Issues](https://github.com/Termix-SSH/Support/issues), connectez-vous et appuyez sur `New Issue`. Veuillez être aussi détaillé que possible dans votre issue, de préférence rédigée en anglais. Vous pouvez également rejoindre le serveur [Discord](https://discord.gg/jVQGdvHDrf) et visiter le canal de support, cependant les temps de réponse peuvent être plus longs. - -# Captures d'écran +
[![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos) -

- Termix Demo 1 - Termix Demo 2 -

+Regarder les aperçus des mises a jour sur YouTube -

- Termix Demo 3 - Termix Demo 4 -

+
+
-

- Termix Demo 5 - Termix Demo 6 -

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Termix Screenshot 1Termix Screenshot 2
Termix Screenshot 3Termix Screenshot 4
Termix Screenshot 5Termix Screenshot 6
Termix Screenshot 7Termix Screenshot 8
Termix Screenshot 9Termix Screenshot 10
Termix Screenshot 11Termix Screenshot 12
Termix Screenshot 13Termix Screenshot 14
-

- Termix Demo 7 - Termix Demo 8 -

+Certaines videos et images peuvent etre obsoletes ou ne pas presenter parfaitement les fonctionnalites. -

- Termix Demo 9 - Termix Demo 10 -

+
-

- Termix Demo 11 - Termix Demo 12 -

+
-Certaines vidéos et images peuvent être obsolètes ou ne pas présenter parfaitement les fonctionnalités. +## Fonctionnalites prevues -# Licence +Consultez les [Projects](https://github.com/orgs/Termix-SSH/projects/2) pour toutes les fonctionnalites prevues. Si vous souhaitez contribuer, consultez [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). -Distribué sous la licence Apache Version 2.0. Consultez LICENSE pour plus d'informations. +
+ +## Sponsors + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + + +
+ +
+ +## Support + +Si vous avez besoin d'aide ou souhaitez demander une fonctionnalite pour Termix, visitez la page [Issues](https://github.com/Termix-SSH/Support/issues), connectez-vous et appuyez sur `New Issue`. Veuillez etre aussi detaille que possible dans votre issue, de preference redigee en anglais. Vous pouvez egalement rejoindre le serveur [Discord](https://discord.gg/jVQGdvHDrf) et visiter le canal de support, cependant les temps de reponse peuvent etre plus longs. + +
+ +## Licence + +Distribue sous la licence Apache Version 2.0. Consultez `LICENSE` pour plus d'informations. diff --git a/readme/README-HI.md b/readme/README-HI.md index e99eac33..df109c0a 100644 --- a/readme/README-HI.md +++ b/readme/README-HI.md @@ -1,93 +1,219 @@ -# रिपॉजिटरी आँकड़े +
-

-🇺🇸 English · 🇨🇳 中文 · 🇯🇵 日本語 · 🇰🇷 한국어 · 🇫🇷 Français · 🇩🇪 Deutsch · 🇪🇸 Español · 🇧🇷 Português · 🇷🇺 Русский · 🇸🇦 العربية · 🇮🇳 हिन्दी · 🇹🇷 Türkçe · 🇻🇳 Tiếng Việt · 🇮🇹 Italiano +Termix Logo + +

Termix

+ +

स्व-होस्टेड SSH प्रबंधन और रिमोट डेस्कटॉप एक्सेस

+ +

+ English · + 中文 · + 日本語 · + 한국어 · + Français · + Deutsch · + Español · + Português · + Русский · + العربية · + हिन्दी · + Türkçe · + Tiếng Việt · + Italiano

-![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) -![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) -![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) -Discord - -

- Repo of the Day Achievement -
- 1 सितंबर, 2025 को प्राप्त +

+ + + + Discord


-

- - Termix Banner + +Termix Banner + +
+
+ +

+ Repo of the Day Achievement +
+ 1 सितंबर, 2025 को प्राप्त

-# अवलोकन +
-

- - Termix Banner -

+
+ +## अवलोकन Termix एक ओपन-सोर्स, हमेशा के लिए मुफ़्त, सेल्फ-होस्टेड ऑल-इन-वन सर्वर प्रबंधन प्लेटफ़ॉर्म है। यह एक एकल, सहज इंटरफ़ेस के माध्यम से आपके सर्वर और बुनियादी ढाँचे के प्रबंधन के लिए एक मल्टी-प्लेटफ़ॉर्म समाधान प्रदान करता है। Termix SSH टर्मिनल एक्सेस, रिमोट डेस्कटॉप कंट्रोल (RDP, VNC, Telnet), SSH टनलिंग क्षमताएँ, रिमोट SSH फ़ाइल प्रबंधन, और कई अन्य उपकरण प्रदान करता है। Termix सभी प्लेटफ़ॉर्म पर उपलब्ध Termius का सही मुफ़्त और सेल्फ-होस्टेड विकल्प है। -# विशेषताएँ +
-- **SSH टर्मिनल एक्सेस** - ब्राउज़र जैसी टैब प्रणाली के साथ स्प्लिट-स्क्रीन सपोर्ट (4 पैनल तक) वाला पूर्ण-विशेषता वाला टर्मिनल। इसमें लोकप्रिय टर्मिनल थीम, फ़ॉन्ट और अन्य कंपोनेंट सहित टर्मिनल को कस्टमाइज़ करने का सपोर्ट शामिल है। -- **रिमोट डेस्कटॉप एक्सेस** - ब्राउज़र पर RDP, VNC और Telnet सपोर्ट, पूर्ण कस्टमाइज़ेशन और स्प्लिट स्क्रीन के साथ। -- **SSH टनल प्रबंधन** - ऑटोमैटिक रीकनेक्शन, हेल्थ मॉनिटरिंग और लोकल, रिमोट या डायनेमिक SOCKS फॉरवर्डिंग के साथ सर्वर-टु-सर्वर SSH टनल बनाएँ और प्रबंधित करें। डेस्कटॉप क्लाइंट-टु-सर्वर टनल सेटिंग्स प्रत्येक डेस्कटॉप इंस्टॉल में स्थानीय रूप से संग्रहीत होती हैं; वैकल्पिक C2S प्रीसेट स्नैपशॉट सर्वर पर सेव, रीनेम, लोड या डिलीट किए जा सकते हैं। -- **रिमोट फ़ाइल मैनेजर** - कोड, इमेज, ऑडियो और वीडियो देखने और संपादित करने के सपोर्ट के साथ रिमोट सर्वर पर सीधे फ़ाइलें प्रबंधित करें। sudo सपोर्ट के साथ फ़ाइलें अपलोड, डाउनलोड, रीनेम, डिलीट और मूव करें। -- **Docker प्रबंधन** - कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है। -- **SSH होस्ट मैनेजर** - टैग और फ़ोल्डर के साथ अपने SSH कनेक्शन सहेजें, व्यवस्थित करें और प्रबंधित करें, और SSH कुंजियों की तैनाती को स्वचालित करने की क्षमता के साथ पुन: उपयोग योग्य लॉगिन जानकारी आसानी से सहेजें। -- **सर्वर आँकड़े** - अधिकांश Linux आधारित सर्वर पर नेटवर्क, अपटाइम, सिस्टम जानकारी, फ़ायरवॉल, पोर्ट मॉनिटर के साथ CPU, मेमोरी और डिस्क उपयोग देखें। -- **डैशबोर्ड** - अपने डैशबोर्ड पर एक नज़र में सर्वर की जानकारी देखें। -- **RBAC** - भूमिकाएँ बनाएँ और उपयोगकर्ताओं/भूमिकाओं में होस्ट साझा करें। -- **उपयोगकर्ता प्रमाणीकरण** - व्यवस्थापक नियंत्रण और OIDC (एक्सेस कंट्रोल के साथ) और 2FA (TOTP) सपोर्ट के साथ सुरक्षित उपयोगकर्ता प्रबंधन। सभी प्लेटफ़ॉर्म पर सक्रिय उपयोगकर्ता सत्र देखें और अनुमतियाँ रद्द करें। अपने OIDC/स्थानीय खातों को एक साथ जोड़ें। -- **डेटाबेस एन्क्रिप्शन** - बैकएंड एन्क्रिप्टेड SQLite डेटाबेस फ़ाइलों के रूप में संग्रहीत। अधिक जानकारी के लिए [डॉक्स](https://docs.termix.site/security) देखें। -- **API कुंजियाँ** - ऑटोमेशन/CI के लिए उपयोग हेतु समाप्ति तिथियों के साथ उपयोगकर्ता-स्कोप्ड API कुंजियाँ बनाएँ। -- **डेटा एक्सपोर्ट/इम्पोर्ट** - SSH होस्ट, क्रेडेंशियल और फ़ाइल मैनेजर डेटा एक्सपोर्ट और इम्पोर्ट करें। -- **स्वचालित SSL सेटअप** - HTTPS रीडायरेक्ट के साथ बिल्ट-इन SSL सर्टिफ़िकेट जनरेशन और प्रबंधन। +## विशेषताएँ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +**SSH टर्मिनल एक्सेस:** +ब्राउज़र जैसी टैब प्रणाली के साथ स्प्लिट-स्क्रीन सपोर्ट (4 पैनल तक) वाला पूर्ण-विशेषता वाला टर्मिनल। इसमें लोकप्रिय टर्मिनल थीम, फ़ॉन्ट और अन्य कंपोनेंट सहित टर्मिनल को कस्टमाइज़ करने का सपोर्ट शामिल है। + + + +**रिमोट डेस्कटॉप एक्सेस:** +ब्राउज़र पर RDP, VNC और Telnet सपोर्ट, पूर्ण कस्टमाइज़ेशन और स्प्लिट स्क्रीन के साथ। + +
+ +**SSH टनल प्रबंधन:** +ऑटोमैटिक रीकनेक्शन, हेल्थ मॉनिटरिंग और लोकल, रिमोट या डायनेमिक SOCKS फॉरवर्डिंग के साथ सर्वर-टु-सर्वर SSH टनल बनाएँ और प्रबंधित करें। डेस्कटॉप क्लाइंट-टु-सर्वर टनल सेटिंग्स प्रत्येक डेस्कटॉप इंस्टॉल में स्थानीय रूप से संग्रहीत होती हैं; वैकल्पिक C2S प्रीसेट स्नैपशॉट सर्वर पर सेव, रीनेम, लोड या डिलीट किए जा सकते हैं। + + + +**रिमोट फ़ाइल मैनेजर:** +कोड, इमेज, ऑडियो और वीडियो देखने और संपादित करने के सपोर्ट के साथ रिमोट सर्वर पर सीधे फ़ाइलें प्रबंधित करें। sudo सपोर्ट के साथ फ़ाइलें अपलोड, डाउनलोड, रीनेम, डिलीट और मूव करें। + +
+ +**Docker प्रबंधन:** +कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है। + + + +**SSH होस्ट मैनेजर:** +टैग और फ़ोल्डर के साथ अपने SSH कनेक्शन सहेजें, व्यवस्थित करें और प्रबंधित करें, और SSH कुंजियों की तैनाती को स्वचालित करने की क्षमता के साथ पुन: उपयोग योग्य लॉगिन जानकारी आसानी से सहेजें। + +
+ +**सर्वर आँकड़े:** +अधिकांश Linux आधारित सर्वर पर नेटवर्क, अपटाइम, सिस्टम जानकारी, फ़ायरवॉल, पोर्ट मॉनिटर के साथ CPU, मेमोरी और डिस्क उपयोग देखें। + + + +**उपयोगकर्ता प्रमाणीकरण:** +व्यवस्थापक नियंत्रण और OIDC (एक्सेस कंट्रोल के साथ) और 2FA (TOTP) सपोर्ट के साथ सुरक्षित उपयोगकर्ता प्रबंधन। सभी प्लेटफ़ॉर्म पर सक्रिय उपयोगकर्ता सत्र देखें और अनुमतियाँ रद्द करें। अपने OIDC/स्थानीय खातों को एक साथ जोड़ें। + +
+ +**RBAC:** +भूमिकाएँ बनाएँ और उपयोगकर्ताओं/भूमिकाओं में होस्ट साझा करें। + + + +**डेटाबेस एन्क्रिप्शन:** +बैकएंड एन्क्रिप्टेड SQLite डेटाबेस फ़ाइलों के रूप में संग्रहीत। अधिक जानकारी के लिए [डॉक्स](https://docs.termix.site/security) देखें। + +
+ +**नेटवर्क ग्राफ़:** +स्थिति सपोर्ट के साथ अपने SSH कनेक्शन के आधार पर अपने होमलैब को विज़ुअलाइज़ करने के लिए अपना डैशबोर्ड कस्टमाइज़ करें। + + + +**SSH टूल्स:** +एक क्लिक से निष्पादित होने वाले पुन: उपयोग योग्य कमांड स्निपेट बनाएँ। एक साथ कई खुले टर्मिनलों में एक कमांड चलाएँ। + +
+ +**परसिस्टेंट टैब:** +उपयोगकर्ता प्रोफ़ाइल में सक्षम होने पर SSH सेशन और टैब डिवाइस/रीफ्रेश के पार खुले रहते हैं। + + + +**भाषाएँ:** +लगभग 30 भाषाओं का बिल्ट-इन सपोर्ट ([Crowdin](https://docs.termix.site/translations) द्वारा प्रबंधित)। + +
+ +
+ +
+अधिक विशेषताएँ +
+ +- **डैशबोर्ड** - अपने डैशबोर्ड पर एक नज़र में सर्वर की जानकारी देखें +- **API कुंजियाँ** - ऑटोमेशन/CI के लिए उपयोग हेतु समाप्ति तिथियों के साथ उपयोगकर्ता-स्कोप्ड API कुंजियाँ बनाएँ +- **डेटा एक्सपोर्ट/इम्पोर्ट** - SSH होस्ट, क्रेडेंशियल और फ़ाइल मैनेजर डेटा एक्सपोर्ट और इम्पोर्ट करें +- **स्वचालित SSL सेटअप** - HTTPS रीडायरेक्ट के साथ बिल्ट-इन SSL सर्टिफ़िकेट जनरेशन और प्रबंधन - **आधुनिक UI** - React, Tailwind CSS, और Shadcn से बना साफ़ डेस्कटॉप/मोबाइल-फ़्रेंडली इंटरफ़ेस। लाइट, डार्क, ड्रैकुला आदि सहित कई अलग-अलग UI थीम के बीच चुनें। किसी भी कनेक्शन को फ़ुल-स्क्रीन में खोलने के लिए URL रूट का उपयोग करें। -- **भाषाएँ** - लगभग 30 भाषाओं का बिल्ट-इन सपोर्ट ([Crowdin](https://docs.termix.site/translations) द्वारा प्रबंधित)। -- **प्लेटफ़ॉर्म सपोर्ट** - वेब ऐप, डेस्कटॉप एप्लिकेशन (Windows, Linux, और macOS, Termix बैकएंड के बिना स्टैंडअलोन चलाया जा सकता है), PWA, और iOS और Android के लिए समर्पित मोबाइल/टैबलेट ऐप के रूप में उपलब्ध। -- **SSH टूल्स** - एक क्लिक से निष्पादित होने वाले पुन: उपयोग योग्य कमांड स्निपेट बनाएँ। एक साथ कई खुले टर्मिनलों में एक कमांड चलाएँ। -- **कमांड इतिहास** - पहले चलाए गए SSH कमांड का ऑटो-कम्प्लीट और दृश्य। -- **क्विक कनेक्ट** - कनेक्शन डेटा सहेजे बिना सर्वर से कनेक्ट करें। -- **कमांड पैलेट** - अपने कीबोर्ड से SSH कनेक्शन तक त्वरित पहुँच के लिए बाएँ Shift को दो बार टैप करें। -- **SSH सुविधाओं से भरपूर** - जम्प होस्ट, Warpgate, TOTP आधारित कनेक्शन, SOCKS5, होस्ट की वेरिफ़िकेशन, पासवर्ड ऑटोफ़िल, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, पोर्ट नॉकिंग आदि का सपोर्ट। -- **नेटवर्क ग्राफ़** - स्थिति सपोर्ट के साथ अपने SSH कनेक्शन के आधार पर अपने होमलैब को विज़ुअलाइज़ करने के लिए अपना डैशबोर्ड कस्टमाइज़ करें। -- **परसिस्टेंट टैब** - उपयोगकर्ता प्रोफ़ाइल में सक्षम होने पर SSH सेशन और टैब डिवाइस/रीफ्रेश के पार खुले रहते हैं। +- **कमांड इतिहास** - पहले चलाए गए SSH कमांड का ऑटो-कम्प्लीट और दृश्य +- **क्विक कनेक्ट** - कनेक्शन डेटा सहेजे बिना सर्वर से कनेक्ट करें +- **कमांड पैलेट** - अपने कीबोर्ड से SSH कनेक्शन तक त्वरित पहुँच के लिए बाएँ Shift को दो बार टैप करें +- **SSH सुविधाओं से भरपूर** - जम्प होस्ट, Warpgate, TOTP आधारित कनेक्शन, SOCKS5, होस्ट की वेरिफ़िकेशन, पासवर्ड ऑटोफ़िल, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, पोर्ट नॉकिंग आदि का सपोर्ट -# नियोजित विशेषताएँ +
-सभी नियोजित विशेषताओं के लिए [प्रोजेक्ट्स](https://github.com/orgs/Termix-SSH/projects/2) देखें। यदि आप योगदान देना चाहते हैं, तो [योगदान](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) देखें। +
-# इंस्टॉलेशन +## प्लेटफ़ॉर्म सपोर्ट -समर्थित डिवाइस: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
प्लेटफ़ॉर्मवितरण
Webकोई भी आधुनिक ब्राउज़र (Chrome, Safari, Firefox) · PWA सपोर्ट
Windows x64/ia32पोर्टेबल · MSI इंस्टॉलर · Chocolatey
Linux x64/ia32पोर्टेबल · AUR · AppImage · Deb · Flatpak
macOS x64/ia32, v12.0+Apple App Store · DMG · Homebrew
iOS/iPadOS v15.1+Apple App Store · IPA
Android v7.0+Google Play Store · APK
-- वेबसाइट (किसी भी प्लेटफ़ॉर्म पर कोई भी आधुनिक ब्राउज़र जैसे Chrome, Safari, और Firefox) (PWA सपोर्ट सहित) -- Windows (x64/ia32) - - पोर्टेबल - - MSI इंस्टॉलर - - Chocolatey पैकेज मैनेजर -- Linux (x64/ia32) - - पोर्टेबल - - AUR - - AppImage - - Deb - - Flatpak -- macOS (v12.0+ पर x64/ia32) - - Apple App Store - - DMG - - Homebrew -- iOS/iPadOS (v15.1+) - - Apple App Store - - IPA -- Android (v7.0+) - - Google Play Store - - APK +
-सभी प्लेटफ़ॉर्म पर Termix इंस्टॉल करने के बारे में अधिक जानकारी के लिए Termix [डॉक्स](https://docs.termix.site/install) पर जाएँ। अन्यथा, यहाँ एक नमूना Docker Compose फ़ाइल देखें (यदि आप रिमोट डेस्कटॉप सुविधाओं का उपयोग करने की योजना नहीं बना रहे हैं तो आप guacd और नेटवर्क को हटा सकते हैं): +## इंस्टॉलेशन + +सभी प्लेटफ़ॉर्म पर Termix इंस्टॉल करने के बारे में अधिक जानकारी के लिए Termix [डॉक्स](https://docs.termix.site/install) पर जाएँ। यहाँ एक नमूना Docker Compose फ़ाइल देखें (यदि आप रिमोट डेस्कटॉप सुविधाओं का उपयोग करने की योजना नहीं बना रहे हैं तो आप guacd और नेटवर्क को हटा सकते हैं): ```yaml services: @@ -124,79 +250,108 @@ networks: driver: bridge ``` -# प्रायोजक +
-

- - DigitalOcean - -          - - Crowdin - -          - - Blacksmith - -          - - Cloudflare - -          - - TailScale - -          - - Akamai - -          - - AWS - -

+## स्क्रीनशॉट -# सहायता +
-यदि आपको सहायता चाहिए या Termix के लिए किसी विशेषता का अनुरोध करना चाहते हैं, तो [इश्यूज़](https://github.com/Termix-SSH/Support/issues) पेज पर जाएँ, लॉग इन करें, और `New Issue` दबाएँ। -कृपया अपने इश्यू में यथासंभव विस्तृत विवरण दें, अधिमानतः अंग्रेज़ी में लिखें। आप [Discord](https://discord.gg/jVQGdvHDrf) सर्वर में भी शामिल हो सकते हैं और सहायता चैनल पर जा सकते हैं, हालाँकि, प्रतिक्रिया समय अधिक हो सकता है। - -# स्क्रीनशॉट +
[![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos) -

- Termix Demo 1 - Termix Demo 2 -

+YouTube पर अपडेट की समीक्षाएँ देखें -

- Termix Demo 3 - Termix Demo 4 -

+
+
-

- Termix Demo 5 - Termix Demo 6 -

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Termix Screenshot 1Termix Screenshot 2
Termix Screenshot 3Termix Screenshot 4
Termix Screenshot 5Termix Screenshot 6
Termix Screenshot 7Termix Screenshot 8
Termix Screenshot 9Termix Screenshot 10
Termix Screenshot 11Termix Screenshot 12
Termix Screenshot 13Termix Screenshot 14
-

- Termix Demo 7 - Termix Demo 8 -

+कुछ वीडियो और छवियाँ पुरानी हो सकती हैं या विशेषताओं को पूरी तरह से प्रदर्शित नहीं कर सकती हैं। -

- Termix Demo 9 - Termix Demo 10 -

+
-

- Termix Demo 11 - Termix Demo 12 -

+
-कुछ वीडियो और छवियाँ पुरानी हो सकती हैं या विशेषताओं को पूरी तरह से प्रदर्शित नहीं कर सकती हैं। +## नियोजित विशेषताएँ -# लाइसेंस +सभी नियोजित विशेषताओं के लिए [प्रोजेक्ट्स](https://github.com/orgs/Termix-SSH/projects/2) देखें। यदि आप योगदान देना चाहते हैं, तो [योगदान](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) देखें। -Apache License Version 2.0 के तहत वितरित। अधिक जानकारी के लिए LICENSE देखें। +
+ +## प्रायोजक + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + + +
+ +
+ +## सहायता + +यदि आपको सहायता चाहिए या Termix के लिए किसी विशेषता का अनुरोध करना चाहते हैं, तो [इश्यूज़](https://github.com/Termix-SSH/Support/issues) पेज पर जाएँ, लॉग इन करें, और `New Issue` दबाएँ। कृपया अपने इश्यू में यथासंभव विस्तृत विवरण दें, अधिमानतः अंग्रेज़ी में लिखें। आप [Discord](https://discord.gg/jVQGdvHDrf) सर्वर में भी शामिल हो सकते हैं और सहायता चैनल पर जा सकते हैं, हालाँकि, प्रतिक्रिया समय अधिक हो सकता है। + +
+ +## लाइसेंस + +Apache License Version 2.0 के तहत वितरित। अधिक जानकारी के लिए `LICENSE` देखें। diff --git a/readme/README-IT.md b/readme/README-IT.md index b46ec0d1..b71d30ac 100644 --- a/readme/README-IT.md +++ b/readme/README-IT.md @@ -1,91 +1,217 @@ -# Statistiche Repo +
-

-🇺🇸 English · 🇨🇳 中文 · 🇯🇵 日本語 · 🇰🇷 한국어 · 🇫🇷 Français · 🇩🇪 Deutsch · 🇪🇸 Español · 🇧🇷 Português · 🇷🇺 Русский · 🇸🇦 العربية · 🇮🇳 हिन्दी · 🇹🇷 Türkçe · 🇻🇳 Tiếng Việt · 🇮🇹 Italiano +Termix Logo + +

Termix

+ +

Gestione SSH self-hosted e accesso al desktop remoto

+ +

+ English · + 中文 · + 日本語 · + 한국어 · + Français · + Deutsch · + Español · + Português · + Русский · + العربية · + हिन्दी · + Türkçe · + Tiếng Việt · + Italiano

-![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) -![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) -![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) -Discord - -

- Repo of the Day Achievement -
- Ottenuto il 1 settembre 2025 +

+ + + + Discord


-

- - Termix Banner + +Termix Banner + +
+
+ +

+ Repo of the Day Achievement +
+ Ottenuto il 1 settembre 2025

-# Panoramica +
-

- - Termix Banner -

+
-Termix è una piattaforma di gestione server tutto-in-uno, open-source, per sempre gratuita e self-hosted. Fornisce una soluzione multipiattaforma per gestire i tuoi server e la tua infrastruttura attraverso un'unica interfaccia intuitiva. Termix offre accesso al terminale SSH, controllo remoto del desktop (RDP, VNC, Telnet), funzionalità di tunneling SSH, gestione remota dei file SSH e molti altri strumenti. Termix è la perfetta alternativa gratuita e self-hosted a Termius, disponibile per tutte le piattaforme. +## Panoramica -# Funzionalità +Termix e una piattaforma di gestione server tutto-in-uno, open-source, per sempre gratuita e self-hosted. Fornisce una soluzione multipiattaforma per gestire i tuoi server e la tua infrastruttura attraverso un'unica interfaccia intuitiva. Termix offre accesso al terminale SSH, controllo remoto del desktop (RDP, VNC, Telnet), funzionalita di tunneling SSH, gestione remota dei file SSH e molti altri strumenti. Termix e la perfetta alternativa gratuita e self-hosted a Termius, disponibile per tutte le piattaforme. -- **Accesso Terminale SSH** - Terminale completo con supporto schermo diviso (fino a 4 pannelli) con un sistema di schede in stile browser. Include il supporto per la personalizzazione del terminale, inclusi temi, font e altri componenti comuni. -- **Accesso Desktop Remoto** - Supporto RDP, VNC e Telnet tramite browser con personalizzazione completa e schermo diviso. -- **Gestione Tunnel SSH** - Crea e gestisci tunnel SSH da server a server con riconnessione automatica, monitoraggio dello stato e inoltro locale, remoto o SOCKS dinamico. Le impostazioni del tunnel da client desktop a server sono archiviate localmente per ogni installazione desktop; gli snapshot di preset C2S opzionali possono essere salvati sul server, rinominati, caricati o eliminati per spostare una configurazione di tunnel locale tra i client. -- **Gestore File Remoto** - Gestisci i file direttamente sui server remoti con supporto per la visualizzazione e la modifica di codice, immagini, audio e video. Carica, scarica, rinomina, elimina e sposta file senza problemi con supporto sudo. -- **Gestione Docker** - Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei container. Controlla i container tramite terminale docker exec. Non è stato creato per sostituire Portainer o Dockge, ma piuttosto per gestire semplicemente i tuoi container rispetto alla loro creazione. -- **Gestore Host SSH** - Salva, organizza e gestisci le tue connessioni SSH con tag e cartelle, salva facilmente le informazioni di accesso riutilizzabili e automatizza il deployment delle chiavi SSH. -- **Statistiche Server** - Visualizza l'utilizzo di CPU, memoria e disco insieme a rete, uptime, informazioni di sistema, firewall, monitoraggio porte sulla maggior parte dei server basati su Linux. -- **Dashboard** - Visualizza le informazioni del server a colpo d'occhio sulla tua dashboard. -- **RBAC** - Crea ruoli e condividi host tra utenti/ruoli. -- **Autenticazione Utente** - Gestione utenti sicura con controlli amministrativi e supporto OIDC (con controllo degli accessi) e 2FA (TOTP). Visualizza le sessioni utente attive su tutte le piattaforme e revoca i permessi. Collega i tuoi account OIDC/Locali tra loro. -- **Crittografia Database** - Il backend è archiviato come file di database SQLite crittografati. Consulta la [documentazione](https://docs.termix.site/security) per maggiori informazioni. -- **Chiavi API** - Crea chiavi API con ambito utente e date di scadenza da utilizzare per automazione/CI. -- **Esportazione/Importazione Dati** - Esporta e importa host SSH, credenziali e dati del gestore file. -- **Configurazione SSL Automatica** - Generazione e gestione integrata dei certificati SSL con reindirizzamenti HTTPS. +
+ +## Funzionalita + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +**Accesso Terminale SSH:** +Terminale completo con supporto schermo diviso (fino a 4 pannelli) con un sistema di schede in stile browser. Include il supporto per la personalizzazione del terminale, inclusi temi, font e altri componenti comuni. + + + +**Accesso Desktop Remoto:** +Supporto RDP, VNC e Telnet tramite browser con personalizzazione completa e schermo diviso. + +
+ +**Gestione Tunnel SSH:** +Crea e gestisci tunnel SSH da server a server con riconnessione automatica, monitoraggio dello stato e inoltro locale, remoto o SOCKS dinamico. Le impostazioni del tunnel da client desktop a server sono archiviate localmente per ogni installazione desktop; gli snapshot di preset C2S opzionali possono essere salvati sul server, rinominati, caricati o eliminati per spostare una configurazione di tunnel locale tra i client. + + + +**Gestore File Remoto:** +Gestisci i file direttamente sui server remoti con supporto per la visualizzazione e la modifica di codice, immagini, audio e video. Carica, scarica, rinomina, elimina e sposta file senza problemi con supporto sudo. + +
+ +**Gestione Docker:** +Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei container. Controlla i container tramite terminale docker exec. Non e stato creato per sostituire Portainer o Dockge, ma piuttosto per gestire semplicemente i tuoi container rispetto alla loro creazione. + + + +**Gestore Host SSH:** +Salva, organizza e gestisci le tue connessioni SSH con tag e cartelle, salva facilmente le informazioni di accesso riutilizzabili e automatizza il deployment delle chiavi SSH. + +
+ +**Statistiche Server:** +Visualizza l'utilizzo di CPU, memoria e disco insieme a rete, uptime, informazioni di sistema, firewall, monitoraggio porte sulla maggior parte dei server basati su Linux. + + + +**Autenticazione Utente:** +Gestione utenti sicura con controlli amministrativi e supporto OIDC (con controllo degli accessi) e 2FA (TOTP). Visualizza le sessioni utente attive su tutte le piattaforme e revoca i permessi. Collega i tuoi account OIDC/Locali tra loro. + +
+ +**RBAC:** +Crea ruoli e condividi host tra utenti/ruoli. + + + +**Crittografia Database:** +Il backend e archiviato come file di database SQLite crittografati. Consulta la [documentazione](https://docs.termix.site/security) per maggiori informazioni. + +
+ +**Grafico di Rete:** +Personalizza la tua Dashboard per visualizzare il tuo homelab basato sulle connessioni SSH con supporto dello stato. + + + +**Strumenti SSH:** +Crea snippet di comandi riutilizzabili che si eseguono con un singolo clic. Esegui un comando simultaneamente su piu terminali aperti. + +
+ +**Schede Persistenti:** +Le sessioni SSH e le schede rimangono aperte tra dispositivi/aggiornamenti se abilitato nel profilo utente. + + + +**Lingue:** +Supporto integrato per circa 30 lingue (gestito da [Crowdin](https://docs.termix.site/translations)). + +
+ +
+ +
+Altre funzionalita +
+ +- **Dashboard** - Visualizza le informazioni del server a colpo d'occhio sulla tua dashboard +- **Chiavi API** - Crea chiavi API con ambito utente e date di scadenza da utilizzare per automazione/CI +- **Esportazione/Importazione Dati** - Esporta e importa host SSH, credenziali e dati del gestore file +- **Configurazione SSL Automatica** - Generazione e gestione integrata dei certificati SSL con reindirizzamenti HTTPS - **Interfaccia Moderna** - Interfaccia pulita e responsive per desktop/mobile costruita con React, Tailwind CSS e Shadcn. Scegli tra molti temi UI diversi, inclusi chiaro, scuro, Dracula, ecc. Usa i percorsi URL per aprire qualsiasi connessione a schermo intero. -- **Lingue** - Supporto integrato per ~30 lingue (gestito da [Crowdin](https://docs.termix.site/translations)). -- **Supporto Piattaforme** - Disponibile come app web, applicazione desktop (Windows, Linux e macOS, può essere eseguito in modo autonomo senza il backend Termix), PWA e app dedicata per mobile/tablet su iOS e Android. -- **Strumenti SSH** - Crea snippet di comandi riutilizzabili che si eseguono con un singolo clic. Esegui un comando simultaneamente su più terminali aperti. -- **Cronologia Comandi** - Autocompletamento e visualizzazione dei comandi SSH eseguiti in precedenza. -- **Connessione Rapida** - Connettiti a un server senza dover salvare i dati di connessione. -- **Palette Comandi** - Premi due volte shift sinistro per accedere rapidamente alle connessioni SSH con la tastiera. -- **SSH Ricco di Funzionalità** - Supporta jump host, Warpgate, connessioni basate su TOTP, SOCKS5, verifica chiave host, compilazione automatica password, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, ecc. -- **Grafico di Rete** - Personalizza la tua Dashboard per visualizzare il tuo homelab basato sulle connessioni SSH con supporto dello stato. -- **Schede Persistenti** - Le sessioni SSH e le schede rimangono aperte tra dispositivi/aggiornamenti se abilitato nel profilo utente. +- **Cronologia Comandi** - Autocompletamento e visualizzazione dei comandi SSH eseguiti in precedenza +- **Connessione Rapida** - Connettiti a un server senza dover salvare i dati di connessione +- **Palette Comandi** - Premi due volte shift sinistro per accedere rapidamente alle connessioni SSH con la tastiera +- **SSH Ricco di Funzionalita** - Supporta jump host, Warpgate, connessioni basate su TOTP, SOCKS5, verifica chiave host, compilazione automatica password, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, ecc. -# Funzionalità Pianificate +
-Consulta [Progetti](https://github.com/orgs/Termix-SSH/projects/2) per tutte le funzionalità pianificate. Se desideri contribuire, consulta [Contribuire](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). +
-# Installazione +## Supporto Piattaforme -Dispositivi Supportati: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PiattaformaDistribuzione
WebQualsiasi browser moderno (Chrome, Safari, Firefox) · Supporto PWA
Windows x64/ia32Portable · MSI Installer · Chocolatey
Linux x64/ia32Portable · AUR · AppImage · Deb · Flatpak
macOS x64/ia32, v12.0+Apple App Store · DMG · Homebrew
iOS/iPadOS v15.1+Apple App Store · IPA
Android v7.0+Google Play Store · APK
-- Sito web (qualsiasi browser moderno su qualsiasi piattaforma come Chrome, Safari e Firefox) (include supporto PWA) -- Windows (x64/ia32) - - Portable - - MSI Installer - - Chocolatey Package Manager -- Linux (x64/ia32) - - Portable - - AUR - - AppImage - - Deb - - Flatpak -- macOS (x64/ia32 su v12.0+) - - Apple App Store - - DMG - - Homebrew -- iOS/iPadOS (v15.1+) - - Apple App Store - - IPA -- Android (v7.0+) - - Google Play Store - - APK +
+ +## Installazione Visita la [Documentazione](https://docs.termix.site/install) di Termix per maggiori informazioni su come installare Termix su tutte le piattaforme. In alternativa, visualizza un file Docker Compose di esempio qui (puoi omettere guacd e la rete se non prevedi di utilizzare le funzioni di desktop remoto): @@ -124,79 +250,108 @@ networks: driver: bridge ``` -# Sponsor +
-

- - DigitalOcean - -          - - Crowdin - -          - - Blacksmith - -          - - Cloudflare - -          - - TailScale - -          - - Akamai - -          - - AWS - -

+## Screenshot -# Supporto +
-Se hai bisogno di aiuto o vuoi richiedere una funzionalità per Termix, visita la pagina [Segnalazioni](https://github.com/Termix-SSH/Support/issues), accedi e premi `New Issue`. -Per favore, sii il più dettagliato possibile nella tua segnalazione, preferibilmente scritta in inglese. Puoi anche unirti al server [Discord](https://discord.gg/jVQGdvHDrf) e visitare il canale di supporto, tuttavia i tempi di risposta potrebbero essere più lunghi. - -# Screenshot +
[![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos) -

- Termix Demo 1 - Termix Demo 2 -

+Guarda le panoramiche degli aggiornamenti su YouTube -

- Termix Demo 3 - Termix Demo 4 -

+
+
-

- Termix Demo 5 - Termix Demo 6 -

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Termix Screenshot 1Termix Screenshot 2
Termix Screenshot 3Termix Screenshot 4
Termix Screenshot 5Termix Screenshot 6
Termix Screenshot 7Termix Screenshot 8
Termix Screenshot 9Termix Screenshot 10
Termix Screenshot 11Termix Screenshot 12
Termix Screenshot 13Termix Screenshot 14
-

- Termix Demo 7 - Termix Demo 8 -

+Alcuni video e immagini potrebbero non essere aggiornati o potrebbero non mostrare perfettamente le funzionalita. -

- Termix Demo 9 - Termix Demo 10 -

+
-

- Termix Demo 11 - Termix Demo 12 -

+
-Alcuni video e immagini potrebbero non essere aggiornati o potrebbero non mostrare perfettamente le funzionalità. +## Funzionalita Pianificate -# Licenza +Consulta [Progetti](https://github.com/orgs/Termix-SSH/projects/2) per tutte le funzionalita pianificate. Se desideri contribuire, consulta [Contribuire](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). -Distribuito sotto la Licenza Apache Versione 2.0. Consulta LICENSE per maggiori informazioni. +
+ +## Sponsor + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + + +
+ +
+ +## Supporto + +Se hai bisogno di aiuto o vuoi richiedere una funzionalita per Termix, visita la pagina [Segnalazioni](https://github.com/Termix-SSH/Support/issues), accedi e premi `New Issue`. Per favore, sii il piu dettagliato possibile nella tua segnalazione, preferibilmente scritta in inglese. Puoi anche unirti al server [Discord](https://discord.gg/jVQGdvHDrf) e visitare il canale di supporto, tuttavia i tempi di risposta potrebbero essere piu lunghi. + +
+ +## Licenza + +Distribuito sotto la Licenza Apache Versione 2.0. Consulta `LICENSE` per maggiori informazioni. diff --git a/readme/README-JA.md b/readme/README-JA.md index 83a89359..1f641a06 100644 --- a/readme/README-JA.md +++ b/readme/README-JA.md @@ -1,93 +1,219 @@ -# リポジトリ統計 +
-

-🇺🇸 English · 🇨🇳 中文 · 🇯🇵 日本語 · 🇰🇷 한국어 · 🇫🇷 Français · 🇩🇪 Deutsch · 🇪🇸 Español · 🇧🇷 Português · 🇷🇺 Русский · 🇸🇦 العربية · 🇮🇳 हिन्दी · 🇹🇷 Türkçe · 🇻🇳 Tiếng Việt · 🇮🇹 Italiano +Termix Logo + +

Termix

+ +

セルフホスト型 SSH 管理とリモートデスクトップアクセス

+ +

+ English · + 中文 · + 日本語 · + 한국어 · + Français · + Deutsch · + Español · + Português · + Русский · + العربية · + हिन्दी · + Türkçe · + Tiếng Việt · + Italiano

-![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) -![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) -![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) -Discord - -

- Repo of the Day Achievement -
- 2025年9月1日に達成 +

+ + + + Discord


-

- - Termix Banner + +Termix Banner + +
+
+ +

+ Repo of the Day Achievement +
+ 2025年9月1日に達成

-# 概要 +
-

- - Termix Banner -

+
+ +## 概要 Termixは、オープンソースで永久無料のセルフホスト型オールインワンサーバー管理プラットフォームです。単一の直感的なインターフェースを通じて、サーバーとインフラストラクチャを管理するマルチプラットフォームソリューションを提供します。Termixは、SSHターミナルアクセス、リモートデスクトップ制御(RDP、VNC、Telnet)、SSHトンネリング機能、リモートSSHファイル管理、およびその他多くのツールを提供します。Termixは、すべてのプラットフォームで利用可能なTermiusの完全無料でセルフホスト可能な代替ソリューションです。 -# 機能 +
-- **SSHターミナルアクセス** - ブラウザ風タブシステムによる分割画面対応(最大4パネル)のフル機能ターミナル。一般的なターミナルテーマ、フォント、その他のコンポーネントを含むターミナルカスタマイズに対応しています。 -- **リモートデスクトップアクセス** - ブラウザ上でRDP、VNC、Telnetをサポート、完全なカスタマイズと分割画面に対応しています。 -- **SSHトンネル管理** - 自動再接続とヘルスモニタリング、ローカル・リモート・ダイナミックSOCKSフォワーディングを備えたサーバー間SSHトンネルの作成・管理が可能です。デスクトップクライアント対サーバーのトンネル設定はデスクトップインストールごとにローカルに保存され、オプションのC2Sプリセットスナップショットをサーバーに保存・名前変更・読み込み・削除してクライアント間でローカルトンネル設定を移動できます。 -- **リモートファイルマネージャー** - コード、画像、音声、動画の表示・編集に対応し、リモートサーバー上のファイルを直接管理できます。sudo対応でファイルのアップロード、ダウンロード、名前変更、削除、移動をシームレスに実行できます。 -- **Docker管理** - コンテナの起動、停止、一時停止、削除。コンテナの統計情報を表示。docker execターミナルでコンテナを操作。PortainerやDockgeの代替ではなく、コンテナの作成よりも簡易的な管理を目的としています。 -- **SSHホストマネージャー** - タグやフォルダでSSH接続を保存、整理、管理し、再利用可能なログイン情報を簡単に保存しながらSSHキーのデプロイを自動化できます。 -- **サーバー統計** - ほとんどのLinuxベースのサーバーで、CPU、メモリ、ディスク使用量、ネットワーク、アップタイム、システム情報、ファイアウォール、ポートモニターを表示できます。 -- **ダッシュボード** - ダッシュボードでサーバー情報を一目で確認できます。 -- **RBAC** - ロールを作成し、ユーザー/ロール間でホストを共有できます。 -- **ユーザー認証** - 管理者コントロールとOIDC(アクセス制御付き)および2FA(TOTP)対応による安全なユーザー管理。すべてのプラットフォームでアクティブなユーザーセッションを表示し、権限を取り消し可能。OIDC/ローカルアカウントの連携が可能です。 -- **データベース暗号化** - バックエンドは暗号化されたSQLiteデータベースファイルとして保存されます。詳細は[ドキュメント](https://docs.termix.site/security)をご覧ください。 -- **APIキー** - 自動化/CI用に有効期限付きのユーザースコープAPIキーを作成できます。 -- **データのエクスポート/インポート** - SSHホスト、認証情報、ファイルマネージャーデータのエクスポートとインポートが可能です。 -- **自動SSL設定** - HTTPSリダイレクト付きの組み込みSSL証明書生成・管理が可能です。 +## 機能 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +**SSHターミナルアクセス:** +ブラウザ風タブシステムによる分割画面対応(最大4パネル)のフル機能ターミナル。一般的なターミナルテーマ、フォント、その他のコンポーネントを含むターミナルカスタマイズに対応しています。 + + + +**リモートデスクトップアクセス:** +ブラウザ上でRDP、VNC、Telnetをサポート、完全なカスタマイズと分割画面に対応しています。 + +
+ +**SSHトンネル管理:** +自動再接続とヘルスモニタリング、ローカル・リモート・ダイナミックSOCKSフォワーディングを備えたサーバー間SSHトンネルの作成・管理が可能です。デスクトップクライアント対サーバーのトンネル設定はデスクトップインストールごとにローカルに保存され、オプションのC2Sプリセットスナップショットをサーバーに保存・名前変更・読み込み・削除してクライアント間でローカルトンネル設定を移動できます。 + + + +**リモートファイルマネージャー:** +コード、画像、音声、動画の表示・編集に対応し、リモートサーバー上のファイルを直接管理できます。sudo対応でファイルのアップロード、ダウンロード、名前変更、削除、移動をシームレスに実行できます。 + +
+ +**Docker管理:** +コンテナの起動、停止、一時停止、削除。コンテナの統計情報を表示。docker execターミナルでコンテナを操作。PortainerやDockgeの代替ではなく、コンテナの作成よりも簡易的な管理を目的としています。 + + + +**SSHホストマネージャー:** +タグやフォルダでSSH接続を保存、整理、管理し、再利用可能なログイン情報を簡単に保存しながらSSHキーのデプロイを自動化できます。 + +
+ +**サーバー統計:** +ほとんどのLinuxベースのサーバーで、CPU、メモリ、ディスク使用量、ネットワーク、アップタイム、システム情報、ファイアウォール、ポートモニターを表示できます。 + + + +**ユーザー認証:** +管理者コントロールとOIDC(アクセス制御付き)および2FA(TOTP)対応による安全なユーザー管理。すべてのプラットフォームでアクティブなユーザーセッションを表示し、権限を取り消し可能。OIDC/ローカルアカウントの連携が可能です。 + +
+ +**RBAC:** +ロールを作成し、ユーザー/ロール間でホストを共有できます。 + + + +**データベース暗号化:** +バックエンドは暗号化されたSQLiteデータベースファイルとして保存されます。詳細は[ドキュメント](https://docs.termix.site/security)をご覧ください。 + +
+ +**ネットワークグラフ:** +ダッシュボードをカスタマイズして、SSH接続に基づくホームラボのネットワークをステータス表示付きで可視化できます。 + + + +**SSHツール:** +ワンクリックで実行できる再利用可能なコマンドスニペットの作成。複数の開いているターミナルに対して同時にコマンドを実行できます。 + +
+ +**永続タブ:** +ユーザープロフィールで有効にすると、SSHセッションとタブがデバイス/更新をまたいで開いたまま保持されます。 + + + +**多言語対応:** +約30言語の組み込みサポート([Crowdin](https://docs.termix.site/translations)で管理されています)。 + +
+ +
+ +
+その他の機能 +
+ +- **ダッシュボード** - ダッシュボードでサーバー情報を一目で確認できます +- **APIキー** - 自動化/CI用に有効期限付きのユーザースコープAPIキーを作成できます +- **データのエクスポート/インポート** - SSHホスト、認証情報、ファイルマネージャーデータのエクスポートとインポートが可能です +- **自動SSL設定** - HTTPSリダイレクト付きの組み込みSSL証明書生成・管理が可能です - **モダンUI** - React、Tailwind CSS、Shadcnで構築された、デスクトップ/モバイル対応のクリーンなインターフェース。ライト、ダーク、Draculaなど、多くの異なるUIテーマから選択可能。URLルートで任意の接続をフルスクリーンで開くことができます。 -- **多言語対応** - 約30言語の組み込みサポート([Crowdin](https://docs.termix.site/translations)で管理されています)。 -- **プラットフォーム対応** - Webアプリ、デスクトップアプリケーション(Windows、Linux、macOS。Termixバックエンドなしでスタンドアロン動作可能)、PWA、iOS・Android専用モバイル/タブレットアプリとして利用可能です。 -- **SSHツール** - ワンクリックで実行できる再利用可能なコマンドスニペットの作成。複数の開いているターミナルに対して同時にコマンドを実行できます。 -- **コマンド履歴** - 過去に実行したSSHコマンドの自動補完と表示が可能です。 -- **クイック接続** - 接続データを保存せずにサーバーに接続できます。 -- **コマンドパレット** - 左Shiftキーを2回押すことで、キーボードからSSH接続に素早くアクセスできます。 -- **SSH機能充実** - ジャンプホスト、Warpgate、TOTPベースの接続、SOCKS5、ホストキー検証、パスワード自動入力、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、ポート敲き(port knocking)などに対応しています。 -- **ネットワークグラフ** - ダッシュボードをカスタマイズして、SSH接続に基づくホームラボのネットワークをステータス表示付きで可視化できます。 -- **永続タブ** - ユーザープロフィールで有効にすると、SSHセッションとタブがデバイス/更新をまたいで開いたまま保持されます。 +- **コマンド履歴** - 過去に実行したSSHコマンドの自動補完と表示が可能です +- **クイック接続** - 接続データを保存せずにサーバーに接続できます +- **コマンドパレット** - 左Shiftキーを2回押すことで、キーボードからSSH接続に素早くアクセスできます +- **SSH機能充実** - ジャンプホスト、Warpgate、TOTPベースの接続、SOCKS5、ホストキー検証、パスワード自動入力、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、ポート敲き(port knocking)などに対応しています -# 予定されている機能 +
-すべての予定機能については[Projects](https://github.com/orgs/Termix-SSH/projects/2)をご覧ください。コントリビュートをご希望の方は[Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)をご覧ください。 +
-# インストール +## プラットフォーム対応 -対応デバイス: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
プラットフォーム配布形式
Webあらゆる最新ブラウザ(Chrome、Safari、Firefox)· PWA対応
Windows x64/ia32ポータブル版 · MSIインストーラー · Chocolatey
Linux x64/ia32ポータブル版 · AUR · AppImage · Deb · Flatpak
macOS x64/ia32, v12.0+Apple App Store · DMG · Homebrew
iOS/iPadOS v15.1+Apple App Store · IPA
Android v7.0+Google Play Store · APK
-- Webサイト(Chrome、Safari、Firefoxなど、あらゆるプラットフォームのモダンブラウザ)(PWA対応) -- Windows (x64/ia32) - - ポータブル版 - - MSIインストーラー - - Chocolateyパッケージマネージャー -- Linux (x64/ia32) - - ポータブル版 - - AUR - - AppImage - - Deb - - Flatpak -- macOS (x64/ia32、v12.0以降) - - Apple App Store - - DMG - - Homebrew -- iOS/iPadOS (v15.1以降) - - Apple App Store - - IPA -- Android (v7.0以降) - - Google Play Store - - APK +
-すべてのプラットフォームへのTermixのインストール方法については、Termixの[ドキュメント](https://docs.termix.site/install)をご覧ください。または、以下のサンプルDocker Composeファイルをご覧ください(リモートデスクトップ機能を使用する予定がない場合は、guacdとネットワークの設定を省略できます): +## インストール + +すべてのプラットフォームへのTermixのインストール方法については、Termixの[ドキュメント](https://docs.termix.site/install)をご覧ください。また、以下のサンプルDocker Composeファイルをご覧ください(リモートデスクトップ機能を使用する予定がない場合は、guacdとネットワークの設定を省略できます): ```yaml services: @@ -124,78 +250,108 @@ networks: driver: bridge ``` -# スポンサー +
-

- - DigitalOcean - -          - - Crowdin - -          - - Blacksmith - -          - - Cloudflare - -          - - TailScale - -          - - Akamai - -          - - AWS - -

+## スクリーンショット -# サポート +
-Termixに関するヘルプや機能リクエストが必要な場合は、[Issues](https://github.com/Termix-SSH/Support/issues)ページにアクセスし、ログインして`New Issue`を押してください。Issueはできるだけ詳細に記述し、英語での記述が望ましいです。また、[Discord](https://discord.gg/jVQGdvHDrf)サーバーに参加してサポートチャンネルを利用することもできますが、応答時間が長くなる場合があります。 - -# スクリーンショット +
[![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos) -

- Termix Demo 1 - Termix Demo 2 -

+YouTubeでアップデートの概要を視聴する -

- Termix Demo 3 - Termix Demo 4 -

+
+
-

- Termix Demo 5 - Termix Demo 6 -

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Termix Screenshot 1Termix Screenshot 2
Termix Screenshot 3Termix Screenshot 4
Termix Screenshot 5Termix Screenshot 6
Termix Screenshot 7Termix Screenshot 8
Termix Screenshot 9Termix Screenshot 10
Termix Screenshot 11Termix Screenshot 12
Termix Screenshot 13Termix Screenshot 14
-

- Termix Demo 7 - Termix Demo 8 -

+動画や画像の一部は最新ではない場合や、機能を完全に紹介できていない場合があります。 -

- Termix Demo 9 - Termix Demo 10 -

+
-

- Termix Demo 11 - Termix Demo 12 -

+
-動画や画像の一部は最新ではない場合や、機能を完全に紹介できていない場合があります。 +## 予定されている機能 -# ライセンス +すべての予定機能については[Projects](https://github.com/orgs/Termix-SSH/projects/2)をご覧ください。コントリビュートをご希望の方は[Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)をご覧ください。 -Apache License Version 2.0のもとで配布されています。詳細はLICENSEをご覧ください。 +
+ +## スポンサー + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + + +
+ +
+ +## サポート + +Termixに関するヘルプや機能リクエストが必要な場合は、[Issues](https://github.com/Termix-SSH/Support/issues)ページにアクセスし、ログインして`New Issue`を押してください。Issueはできるだけ詳細に記述し、英語での記述が望ましいです。また、[Discord](https://discord.gg/jVQGdvHDrf)サーバーに参加してサポートチャンネルを利用することもできますが、応答時間が長くなる場合があります。 + +
+ +## ライセンス + +Apache License Version 2.0のもとで配布されています。詳細は`LICENSE`をご覧ください。 diff --git a/readme/README-KO.md b/readme/README-KO.md index 7f45ffcd..079a8384 100644 --- a/readme/README-KO.md +++ b/readme/README-KO.md @@ -1,91 +1,217 @@ -# 리포지토리 통계 +
-

-🇺🇸 English · 🇨🇳 中文 · 🇯🇵 日本語 · 🇰🇷 한국어 · 🇫🇷 Français · 🇩🇪 Deutsch · 🇪🇸 Español · 🇧🇷 Português · 🇷🇺 Русский · 🇸🇦 العربية · 🇮🇳 हिन्दी · 🇹🇷 Türkçe · 🇻🇳 Tiếng Việt · 🇮🇹 Italiano +Termix Logo + +

Termix

+ +

셀프 호스팅 SSH 관리 및 원격 데스크톱 액세스

+ +

+ English · + 中文 · + 日本語 · + 한국어 · + Français · + Deutsch · + Español · + Português · + Русский · + العربية · + हिन्दी · + Türkçe · + Tiếng Việt · + Italiano

-![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) -![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) -![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) -Discord - -

- Repo of the Day Achievement -
- 2025년 9월 1일 달성 +

+ + + + Discord


-

- - Termix Banner + +Termix Banner + +
+
+ +

+ Repo of the Day Achievement +
+ 2025년 9월 1일 달성

-# 개요 +
-

- - Termix Banner -

+
+ +## 개요 Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버 관리 플랫폼입니다. 단일 직관적인 인터페이스를 통해 서버와 인프라를 관리할 수 있는 멀티 플랫폼 솔루션을 제공합니다. Termix는 SSH 터미널 접속, 원격 데스크톱 제어(RDP, VNC, Telnet), SSH 터널링 기능, 원격 SSH 파일 관리 및 기타 다양한 도구를 제공합니다. Termix는 모든 플랫폼에서 사용 가능한 Termius의 완벽한 무료 셀프 호스팅 대안입니다. -# 기능 +
-- **SSH 터미널 접속** - 브라우저 스타일 탭 시스템과 분할 화면 지원(최대 4개 패널)을 갖춘 완전한 기능의 터미널. 일반 터미널 테마, 글꼴 및 기타 구성 요소를 포함한 터미널 사용자 정의 지원. -- **원격 데스크톱 접속** - 완전한 사용자 정의와 분할 화면을 지원하는 브라우저 기반 RDP, VNC, Telnet 지원. -- **SSH 터널 관리** - 자동 재연결, 상태 모니터링, 로컬·원격·동적 SOCKS 포워딩을 지원하는 서버 간 SSH 터널 생성 및 관리. 데스크톱 클라이언트-서버 터널 설정은 데스크톱 설치별로 로컬에 저장되며, 선택적 C2S 사전 설정 스냅샷을 서버에 저장·이름 변경·불러오기·삭제하여 클라이언트 간 로컬 터널 구성을 이동할 수 있습니다. -- **원격 파일 관리자** - 코드, 이미지, 오디오, 비디오의 보기 및 편집을 지원하여 원격 서버에서 파일을 직접 관리. sudo 지원으로 파일 업로드, 다운로드, 이름 변경, 삭제, 이동을 원활하게 수행. -- **Docker 관리** - 컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다. -- **SSH 호스트 관리자** - 태그와 폴더로 SSH 연결을 저장, 정리, 관리하고, 재사용 가능한 로그인 정보를 쉽게 저장하면서 SSH 키 배포를 자동화. -- **서버 통계** - 대부분의 Linux 기반 서버에서 CPU, 메모리, 디스크 사용량과 함께 네트워크, 업타임, 시스템 정보, 방화벽, 포트 모니터를 표시. -- **대시보드** - 대시보드에서 서버 정보를 한눈에 확인. -- **RBAC** - 역할을 생성하고 사용자/역할 간에 호스트 공유. -- **사용자 인증** - 관리자 제어와 OIDC(액세스 제어 포함) 및 2FA(TOTP) 지원을 통한 안전한 사용자 관리. 모든 플랫폼에서 활성 사용자 세션을 보고 권한을 취소 가능. OIDC/로컬 계정 연동. -- **데이터베이스 암호화** - 백엔드가 암호화된 SQLite 데이터베이스 파일로 저장됨. 자세한 내용은 [문서](https://docs.termix.site/security)를 참조하세요. -- **API 키** - 자동화/CI에 사용할 만료일이 있는 사용자 범위 API 키 생성. -- **데이터 내보내기/가져오기** - SSH 호스트, 자격 증명, 파일 관리자 데이터의 내보내기 및 가져오기. -- **자동 SSL 설정** - HTTPS 리디렉션을 포함한 내장 SSL 인증서 생성 및 관리. +## 기능 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +**SSH 터미널 접속:** +브라우저 스타일 탭 시스템과 분할 화면 지원(최대 4개 패널)을 갖춘 완전한 기능의 터미널. 일반 터미널 테마, 글꼴 및 기타 구성 요소를 포함한 터미널 사용자 정의 지원. + + + +**원격 데스크톱 접속:** +완전한 사용자 정의와 분할 화면을 지원하는 브라우저 기반 RDP, VNC, Telnet 지원. + +
+ +**SSH 터널 관리:** +자동 재연결, 상태 모니터링, 로컬·원격·동적 SOCKS 포워딩을 지원하는 서버 간 SSH 터널 생성 및 관리. 데스크톱 클라이언트-서버 터널 설정은 데스크톱 설치별로 로컬에 저장되며, 선택적 C2S 사전 설정 스냅샷을 서버에 저장·이름 변경·불러오기·삭제하여 클라이언트 간 로컬 터널 구성을 이동할 수 있습니다. + + + +**원격 파일 관리자:** +코드, 이미지, 오디오, 비디오의 보기 및 편집을 지원하여 원격 서버에서 파일을 직접 관리. sudo 지원으로 파일 업로드, 다운로드, 이름 변경, 삭제, 이동을 원활하게 수행. + +
+ +**Docker 관리:** +컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다. + + + +**SSH 호스트 관리자:** +태그와 폴더로 SSH 연결을 저장, 정리, 관리하고, 재사용 가능한 로그인 정보를 쉽게 저장하면서 SSH 키 배포를 자동화. + +
+ +**서버 통계:** +대부분의 Linux 기반 서버에서 CPU, 메모리, 디스크 사용량과 함께 네트워크, 업타임, 시스템 정보, 방화벽, 포트 모니터를 표시. + + + +**사용자 인증:** +관리자 제어와 OIDC(액세스 제어 포함) 및 2FA(TOTP) 지원을 통한 안전한 사용자 관리. 모든 플랫폼에서 활성 사용자 세션을 보고 권한을 취소 가능. OIDC/로컬 계정 연동. + +
+ +**RBAC:** +역할을 생성하고 사용자/역할 간에 호스트 공유. + + + +**데이터베이스 암호화:** +백엔드가 암호화된 SQLite 데이터베이스 파일로 저장됨. 자세한 내용은 [문서](https://docs.termix.site/security)를 참조하세요. + +
+ +**네트워크 그래프:** +대시보드를 사용자 정의하여 SSH 연결 기반의 홈랩 네트워크를 상태 표시와 함께 시각화. + + + +**SSH 도구:** +한 번의 클릭으로 실행 가능한 재사용 가능 명령어 스니펫 생성. 여러 열린 터미널에서 동시에 하나의 명령어 실행. + +
+ +**지속 탭:** +사용자 프로필에서 활성화된 경우 SSH 세션 및 탭이 기기/새로 고침 간에 열린 상태 유지. + + + +**다국어 지원:** +약 30개 언어 내장 지원([Crowdin](https://docs.termix.site/translations)으로 관리). + +
+ +
+ +
+더 많은 기능 +
+ +- **대시보드** - 대시보드에서 서버 정보를 한눈에 확인 +- **API 키** - 자동화/CI에 사용할 만료일이 있는 사용자 범위 API 키 생성 +- **데이터 내보내기/가져오기** - SSH 호스트, 자격 증명, 파일 관리자 데이터의 내보내기 및 가져오기 +- **자동 SSL 설정** - HTTPS 리디렉션을 포함한 내장 SSL 인증서 생성 및 관리 - **모던 UI** - React, Tailwind CSS, Shadcn으로 구축된 깔끔한 데스크톱/모바일 친화적 인터페이스. 라이트, 다크, 드라큘라 등 다양한 UI 테마 선택 가능. URL 라우트를 사용하여 모든 연결을 전체 화면으로 열기 가능. -- **다국어 지원** - 약 30개 언어 내장 지원([Crowdin](https://docs.termix.site/translations)으로 관리). -- **플랫폼 지원** - 웹 앱, 데스크톱 애플리케이션(Windows, Linux, macOS, Termix 백엔드 없이 독립 실행 가능), PWA, iOS 및 Android 전용 모바일/태블릿 앱으로 제공. -- **SSH 도구** - 한 번의 클릭으로 실행 가능한 재사용 가능 명령어 스니펫 생성. 여러 열린 터미널에서 동시에 하나의 명령어 실행. -- **명령어 기록** - 이전에 실행한 SSH 명령어의 자동 완성 및 조회. -- **빠른 연결** - 연결 데이터를 저장하지 않고 서버에 접속. -- **명령어 팔레트** - 왼쪽 Shift 키를 두 번 눌러 키보드로 SSH 연결에 빠르게 접근. -- **풍부한 SSH 기능** - 점프 호스트, Warpgate, TOTP 기반 연결, SOCKS5, 호스트 키 검증, 비밀번호 자동 입력, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, 포트 노킹 등 지원. -- **네트워크 그래프** - 대시보드를 사용자 정의하여 SSH 연결 기반의 홈랩 네트워크를 상태 표시와 함께 시각화. -- **지속 탭** - 사용자 프로필에서 활성화된 경우 SSH 세션 및 탭이 기기/새로 고침 간에 열린 상태 유지. +- **명령어 기록** - 이전에 실행한 SSH 명령어의 자동 완성 및 조회 +- **빠른 연결** - 연결 데이터를 저장하지 않고 서버에 접속 +- **명령어 팔레트** - 왼쪽 Shift 키를 두 번 눌러 키보드로 SSH 연결에 빠르게 접근 +- **풍부한 SSH 기능** - 점프 호스트, Warpgate, TOTP 기반 연결, SOCKS5, 호스트 키 검증, 비밀번호 자동 입력, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, 포트 노킹 등 지원 -# 계획된 기능 +
-모든 계획된 기능은 [Projects](https://github.com/orgs/Termix-SSH/projects/2)를 참조하세요. 기여를 원하시면 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)을 참조하세요. +
-# 설치 +## 플랫폼 지원 -지원 기기: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
플랫폼배포판
Web모든 최신 브라우저(Chrome, Safari, Firefox) · PWA 지원
Windows x64/ia32포터블 · MSI 설치 프로그램 · Chocolatey
Linux x64/ia32포터블 · AUR · AppImage · Deb · Flatpak
macOS x64/ia32, v12.0+Apple App Store · DMG · Homebrew
iOS/iPadOS v15.1+Apple App Store · IPA
Android v7.0+Google Play Store · APK
-- 웹사이트 (Chrome, Safari, Firefox 등 모든 플랫폼의 최신 브라우저) (PWA 지원 포함) -- Windows (x64/ia32) - - 포터블 - - MSI 설치 프로그램 - - Chocolatey 패키지 관리자 -- Linux (x64/ia32) - - 포터블 - - AUR - - AppImage - - Deb - - Flatpak -- macOS (x64/ia32, v12.0 이상) - - Apple App Store - - DMG - - Homebrew -- iOS/iPadOS (v15.1 이상) - - Apple App Store - - IPA -- Android (v7.0 이상) - - Google Play Store - - APK +
+ +## 설치 모든 플랫폼에 Termix를 설치하는 방법에 대한 자세한 내용은 Termix [문서](https://docs.termix.site/install)를 방문하세요. 다음은 Docker Compose 파일 예시입니다(원격 데스크톱 기능을 사용할 계획이 없다면 guacd와 네트워크를 생략할 수 있습니다): @@ -124,78 +250,108 @@ networks: driver: bridge ``` -# 스폰서 +
-

- - DigitalOcean - -          - - Crowdin - -          - - Blacksmith - -          - - Cloudflare - -          - - TailScale - -          - - Akamai - -          - - AWS - -

+## 스크린샷 -# 지원 +
-Termix에 대한 도움이 필요하거나 기능을 요청하려면 [Issues](https://github.com/Termix-SSH/Support/issues) 페이지를 방문하여 로그인하고 `New Issue`를 누르세요. 이슈는 가능한 한 상세하게 작성하고, 영어로 작성하는 것이 좋습니다. [Discord](https://discord.gg/jVQGdvHDrf) 서버에 참여하여 지원 채널을 이용할 수도 있지만, 응답 시간이 더 길 수 있습니다. - -# 스크린샷 +
[![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos) -

- Termix Demo 1 - Termix Demo 2 -

+YouTube에서 업데이트 개요 시청하기 -

- Termix Demo 3 - Termix Demo 4 -

+
+
-

- Termix Demo 5 - Termix Demo 6 -

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Termix Screenshot 1Termix Screenshot 2
Termix Screenshot 3Termix Screenshot 4
Termix Screenshot 5Termix Screenshot 6
Termix Screenshot 7Termix Screenshot 8
Termix Screenshot 9Termix Screenshot 10
Termix Screenshot 11Termix Screenshot 12
Termix Screenshot 13Termix Screenshot 14
-

- Termix Demo 7 - Termix Demo 8 -

+일부 비디오 및 이미지는 최신이 아니거나 기능을 완벽하게 보여주지 않을 수 있습니다. -

- Termix Demo 9 - Termix Demo 10 -

+
-

- Termix Demo 11 - Termix Demo 12 -

+
-일부 비디오 및 이미지는 최신이 아니거나 기능을 완벽하게 보여주지 않을 수 있습니다. +## 계획된 기능 -# 라이선스 +모든 계획된 기능은 [Projects](https://github.com/orgs/Termix-SSH/projects/2)를 참조하세요. 기여를 원하시면 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)을 참조하세요. -Apache License Version 2.0에 따라 배포됩니다. 자세한 내용은 LICENSE를 참조하세요. +
+ +## 스폰서 + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + + +
+ +
+ +## 지원 + +Termix에 대한 도움이 필요하거나 기능을 요청하려면 [Issues](https://github.com/Termix-SSH/Support/issues) 페이지를 방문하여 로그인하고 `New Issue`를 누르세요. 이슈는 가능한 한 상세하게 작성하고, 영어로 작성하는 것이 좋습니다. [Discord](https://discord.gg/jVQGdvHDrf) 서버에 참여하여 지원 채널을 이용할 수도 있지만, 응답 시간이 더 길 수 있습니다. + +
+ +## 라이선스 + +Apache License Version 2.0에 따라 배포됩니다. 자세한 내용은 `LICENSE`를 참조하세요. diff --git a/readme/README-PT.md b/readme/README-PT.md index 3af83736..aeabb638 100644 --- a/readme/README-PT.md +++ b/readme/README-PT.md @@ -1,93 +1,219 @@ -# Estatísticas do Repositório +
-

-🇺🇸 English · 🇨🇳 中文 · 🇯🇵 日本語 · 🇰🇷 한국어 · 🇫🇷 Français · 🇩🇪 Deutsch · 🇪🇸 Español · 🇧🇷 Português · 🇷🇺 Русский · 🇸🇦 العربية · 🇮🇳 हिन्दी · 🇹🇷 Türkçe · 🇻🇳 Tiếng Việt · 🇮🇹 Italiano +Termix Logo + +

Termix

+ +

Gerenciamento SSH auto-hospedado e acesso a area de trabalho remota

+ +

+ English · + 中文 · + 日本語 · + 한국어 · + Français · + Deutsch · + Español · + Português · + Русский · + العربية · + हिन्दी · + Türkçe · + Tiếng Việt · + Italiano

-![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) -![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) -![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) -Discord - -

- Repo of the Day Achievement -
- Conquistado em 1 de setembro de 2025 +

+ + + + Discord


-

- - Termix Banner + +Termix Banner + +
+
+ +

+ Repo of the Day Achievement +
+ Conquistado em 1 de setembro de 2025

-# Visão Geral +
-

- - Termix Banner -

+
-Termix é uma plataforma de gerenciamento de servidores tudo-em-um, de código aberto, sempre gratuita e auto-hospedada. Ela fornece uma solução multiplataforma para gerenciar seus servidores e infraestrutura através de uma interface única e intuitiva. Termix oferece acesso a terminal SSH, controle de desktop remoto (RDP, VNC, Telnet), capacidades de tunelamento SSH, gerenciamento remoto de arquivos SSH e muitas outras ferramentas. Termix é a alternativa perfeita, gratuita e auto-hospedada ao Termius, disponível para todas as plataformas. +## Visao Geral -# Funcionalidades +Termix e uma plataforma de gerenciamento de servidores tudo-em-um, de codigo aberto, sempre gratuita e auto-hospedada. Ela fornece uma solucao multiplataforma para gerenciar seus servidores e infraestrutura atraves de uma interface unica e intuitiva. Termix oferece acesso a terminal SSH, controle de desktop remoto (RDP, VNC, Telnet), capacidades de tunelamento SSH, gerenciamento remoto de arquivos SSH e muitas outras ferramentas. Termix e a alternativa perfeita, gratuita e auto-hospedada ao Termius, disponivel para todas as plataformas. -- **Acesso ao Terminal SSH** - Terminal completo com suporte a tela dividida (até 4 painéis) com um sistema de abas similar ao navegador. Inclui suporte para personalização do terminal incluindo temas comuns de terminal, fontes e outros componentes. -- **Acesso à Área de Trabalho Remota** - Suporte a RDP, VNC e Telnet pelo navegador com personalização completa e tela dividida -- **Gerenciamento de Túneis SSH** - Crie e gerencie túneis SSH de servidor para servidor com reconexão automática, monitoramento de saúde e encaminhamento local, remoto ou SOCKS dinâmico. As configurações de túnel de cliente desktop para servidor são armazenadas localmente por instalação de desktop; snapshots de predefinições C2S opcionais podem ser salvos no servidor, renomeados, carregados ou excluídos para mover uma configuração de túnel local entre clientes. -- **Gerenciador Remoto de Arquivos** - Gerencie arquivos diretamente em servidores remotos com suporte para visualizar e editar código, imagens, áudio e vídeo. Faça upload, download, renomeie, exclua e mova arquivos facilmente com suporte sudo. -- **Gerenciamento de Docker** - Inicie, pare, pause, remova contêineres. Visualize estatísticas de contêineres. Controle contêineres usando o terminal Docker Exec. Não foi feito para substituir Portainer ou Dockge, mas sim para simplesmente gerenciar seus contêineres em vez de criá-los. -- **Gerenciador de Hosts SSH** - Salve, organize e gerencie suas conexões SSH com tags e pastas, e salve facilmente informações de login reutilizáveis com a capacidade de automatizar a implantação de chaves SSH -- **Estatísticas do Servidor** - Visualize o uso de CPU, memória e disco junto com rede, tempo de atividade, informações do sistema, firewall, monitor de portas na maioria dos servidores baseados em Linux -- **Dashboard** - Visualize informações do servidor de relance no seu dashboard -- **RBAC** - Crie funções e compartilhe hosts entre usuários/funções -- **Autenticação de Usuários** - Gerenciamento seguro de usuários com controles de administrador e suporte para OIDC (com controle de acesso) e 2FA (TOTP). Visualize sessões ativas de usuários em todas as plataformas e revogue permissões. Vincule suas contas OIDC/Locais entre si. -- **Criptografia de Banco de Dados** - Backend armazenado como arquivos de banco de dados SQLite criptografados. Consulte a [documentação](https://docs.termix.site/security) para mais informações. -- **Chaves de API** - Crie chaves de API com escopo de usuário e datas de expiração para uso em automação/CI. -- **Exportação/Importação de Dados** - Exporte e importe hosts SSH, credenciais e dados do gerenciador de arquivos -- **Configuração Automática de SSL** - Geração e gerenciamento integrado de certificados SSL com redirecionamentos HTTPS -- **Interface Moderna** - Interface limpa compatível com desktop/mobile construída com React, Tailwind CSS e Shadcn. Escolha entre muitos temas de interface diferentes, incluindo claro, escuro, Drácula, etc. Use rotas de URL para abrir qualquer conexão em tela cheia. -- **Idiomas** - Suporte integrado para ~30 idiomas (gerenciado pelo [Crowdin](https://docs.termix.site/translations)) -- **Suporte a Plataformas** - Disponível como aplicação web, aplicação desktop (Windows, Linux e macOS, pode ser executado de forma independente sem o backend Termix), PWA e aplicativo dedicado para celular/tablet para iOS e Android. -- **Ferramentas SSH** - Crie trechos de comandos reutilizáveis que são executados com um único clique. Execute um comando simultaneamente em múltiplos terminais abertos. -- **Histórico de Comandos** - Autocompletar e visualizar comandos SSH executados anteriormente -- **Conexão Rápida** - Conecte-se a um servidor sem precisar salvar os dados de conexão -- **Paleta de Comandos** - Pressione duas vezes a tecla Shift esquerda para acessar rapidamente as conexões SSH com seu teclado -- **SSH Rico em Funcionalidades** - Suporta jump hosts, Warpgate, conexões baseadas em TOTP, SOCKS5, verificação de chave do host, preenchimento automático de senhas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc. -- **Gráfico de Rede** - Personalize seu Dashboard para visualizar seu homelab baseado nas suas conexões SSH com suporte de status -- **Abas Persistentes** - Sessões SSH e abas permanecem abertas entre dispositivos/atualizações se habilitado no perfil do usuário +
-# Funcionalidades Planejadas +## Funcionalidades -Consulte [Projetos](https://github.com/orgs/Termix-SSH/projects/2) para todas as funcionalidades planejadas. Se você deseja contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-# Instalação +**Acesso ao Terminal SSH:** +Terminal completo com suporte a tela dividida (ate 4 paineis) com um sistema de abas similar ao navegador. Inclui suporte para personalizacao do terminal incluindo temas comuns de terminal, fontes e outros componentes. -Dispositivos suportados: + -- Website (qualquer navegador moderno em qualquer plataforma como Chrome, Safari e Firefox) (inclui suporte PWA) -- Windows (x64/ia32) - - Portátil - - Instalador MSI - - Gerenciador de pacotes Chocolatey -- Linux (x64/ia32) - - Portátil - - AUR - - AppImage - - Deb - - Flatpak -- macOS (x64/ia32 em v12.0+) - - Apple App Store - - DMG - - Homebrew -- iOS/iPadOS (v15.1+) - - Apple App Store - - IPA -- Android (v7.0+) - - Google Play Store - - APK +**Acesso a Area de Trabalho Remota:** +Suporte a RDP, VNC e Telnet pelo navegador com personalizacao completa e tela dividida. -Visite a [documentação](https://docs.termix.site/install) do Termix para mais informações sobre como instalar o Termix em todas as plataformas. Caso contrário, veja um arquivo Docker Compose de exemplo aqui (você pode omitir o guacd e a rede se não planeja usar recursos de área de trabalho remota): +
+ +**Gerenciamento de Tuneis SSH:** +Crie e gerencie tuneis SSH de servidor para servidor com reconexao automatica, monitoramento de saude e encaminhamento local, remoto ou SOCKS dinamico. As configuracoes de tunel de cliente desktop para servidor sao armazenadas localmente por instalacao de desktop; snapshots de predefinicoes C2S opcionais podem ser salvos no servidor, renomeados, carregados ou excluidos para mover uma configuracao de tunel local entre clientes. + + + +**Gerenciador Remoto de Arquivos:** +Gerencie arquivos diretamente em servidores remotos com suporte para visualizar e editar codigo, imagens, audio e video. Faca upload, download, renomeie, exclua e mova arquivos facilmente com suporte sudo. + +
+ +**Gerenciamento de Docker:** +Inicie, pare, pause, remova conteineres. Visualize estatisticas de conteineres. Controle conteineres usando o terminal Docker Exec. Nao foi feito para substituir Portainer ou Dockge, mas sim para simplesmente gerenciar seus conteineres em vez de cria-los. + + + +**Gerenciador de Hosts SSH:** +Salve, organize e gerencie suas conexoes SSH com tags e pastas, e salve facilmente informacoes de login reutilizaveis com a capacidade de automatizar a implantacao de chaves SSH. + +
+ +**Estatisticas do Servidor:** +Visualize o uso de CPU, memoria e disco junto com rede, tempo de atividade, informacoes do sistema, firewall, monitor de portas na maioria dos servidores baseados em Linux. + + + +**Autenticacao de Usuarios:** +Gerenciamento seguro de usuarios com controles de administrador e suporte para OIDC (com controle de acesso) e 2FA (TOTP). Visualize sessoes ativas de usuarios em todas as plataformas e revogue permissoes. Vincule suas contas OIDC/Locais entre si. + +
+ +**RBAC:** +Crie funcoes e compartilhe hosts entre usuarios/funcoes. + + + +**Criptografia de Banco de Dados:** +Backend armazenado como arquivos de banco de dados SQLite criptografados. Consulte a [documentacao](https://docs.termix.site/security) para mais informacoes. + +
+ +**Grafico de Rede:** +Personalize seu Dashboard para visualizar seu homelab baseado nas suas conexoes SSH com suporte de status. + + + +**Ferramentas SSH:** +Crie trechos de comandos reutilizaveis que sao executados com um unico clique. Execute um comando simultaneamente em multiplos terminais abertos. + +
+ +**Abas Persistentes:** +Sessoes SSH e abas permanecem abertas entre dispositivos/atualizacoes se habilitado no perfil do usuario. + + + +**Idiomas:** +Suporte integrado para aproximadamente 30 idiomas (gerenciado pelo [Crowdin](https://docs.termix.site/translations)). + +
+ +
+ +
+Mais funcionalidades +
+ +- **Dashboard** - Visualize informacoes do servidor de relance no seu dashboard +- **Chaves de API** - Crie chaves de API com escopo de usuario e datas de expiracao para uso em automacao/CI +- **Exportacao/Importacao de Dados** - Exporte e importe hosts SSH, credenciais e dados do gerenciador de arquivos +- **Configuracao Automatica de SSL** - Geracao e gerenciamento integrado de certificados SSL com redirecionamentos HTTPS +- **Interface Moderna** - Interface limpa compativel com desktop/mobile construida com React, Tailwind CSS e Shadcn. Escolha entre muitos temas de interface diferentes, incluindo claro, escuro, Dracula, etc. Use rotas de URL para abrir qualquer conexao em tela cheia. +- **Historico de Comandos** - Autocompletar e visualizar comandos SSH executados anteriormente +- **Conexao Rapida** - Conecte-se a um servidor sem precisar salvar os dados de conexao +- **Paleta de Comandos** - Pressione duas vezes a tecla Shift esquerda para acessar rapidamente as conexoes SSH com seu teclado +- **SSH Rico em Funcionalidades** - Suporta jump hosts, Warpgate, conexoes baseadas em TOTP, SOCKS5, verificacao de chave do host, preenchimento automatico de senhas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc. + +
+ +
+ +## Suporte a Plataformas + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PlataformaDistribuicao
WebQualquer navegador moderno (Chrome, Safari, Firefox) · Suporte PWA
Windows x64/ia32Portatil · Instalador MSI · Chocolatey
Linux x64/ia32Portatil · AUR · AppImage · Deb · Flatpak
macOS x64/ia32, v12.0+Apple App Store · DMG · Homebrew
iOS/iPadOS v15.1+Apple App Store · IPA
Android v7.0+Google Play Store · APK
+ +
+ +## Instalacao + +Visite a [documentacao](https://docs.termix.site/install) do Termix para mais informacoes sobre como instalar o Termix em todas as plataformas. Caso contrario, veja um arquivo Docker Compose de exemplo aqui (voce pode omitir o guacd e a rede se nao planeja usar recursos de area de trabalho remota): ```yaml services: @@ -124,79 +250,108 @@ networks: driver: bridge ``` -# Patrocinadores +
-

- - DigitalOcean - -          - - Crowdin - -          - - Blacksmith - -          - - Cloudflare - -          - - TailScale - -          - - Akamai - -          - - AWS - -

+## Capturas de Tela -# Suporte +
-Se você precisa de ajuda ou deseja solicitar uma funcionalidade para o Termix, visite a página de [Issues](https://github.com/Termix-SSH/Support/issues), faça login e clique em `New Issue`. -Por favor, seja o mais detalhado possível no seu relato, preferencialmente escrito em inglês. Você também pode entrar no servidor do [Discord](https://discord.gg/jVQGdvHDrf) e visitar o canal de suporte, porém, os tempos de resposta podem ser mais longos. - -# Capturas de Tela +
[![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos) -

- Termix Demo 1 - Termix Demo 2 -

+Assista resumos de atualizacoes no YouTube -

- Termix Demo 3 - Termix Demo 4 -

+
+
-

- Termix Demo 5 - Termix Demo 6 -

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Termix Screenshot 1Termix Screenshot 2
Termix Screenshot 3Termix Screenshot 4
Termix Screenshot 5Termix Screenshot 6
Termix Screenshot 7Termix Screenshot 8
Termix Screenshot 9Termix Screenshot 10
Termix Screenshot 11Termix Screenshot 12
Termix Screenshot 13Termix Screenshot 14
-

- Termix Demo 7 - Termix Demo 8 -

+Alguns videos e imagens podem estar desatualizados ou podem nao mostrar perfeitamente as funcionalidades. -

- Termix Demo 9 - Termix Demo 10 -

+
-

- Termix Demo 11 - Termix Demo 12 -

+
-Alguns vídeos e imagens podem estar desatualizados ou podem não mostrar perfeitamente as funcionalidades. +## Funcionalidades Planejadas -# Licença +Consulte [Projetos](https://github.com/orgs/Termix-SSH/projects/2) para todas as funcionalidades planejadas. Se voce deseja contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). -Distribuído sob a Licença Apache Versão 2.0. Consulte LICENSE para mais informações. +
+ +## Patrocinadores + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + + +
+ +
+ +## Suporte + +Se voce precisa de ajuda ou deseja solicitar uma funcionalidade para o Termix, visite a pagina de [Issues](https://github.com/Termix-SSH/Support/issues), faca login e clique em `New Issue`. Por favor, seja o mais detalhado possivel no seu relato, preferencialmente escrito em ingles. Voce tambem pode entrar no servidor do [Discord](https://discord.gg/jVQGdvHDrf) e visitar o canal de suporte, porem, os tempos de resposta podem ser mais longos. + +
+ +## Licenca + +Distribuido sob a Licenca Apache Versao 2.0. Consulte `LICENSE` para mais informacoes. diff --git a/readme/README-RU.md b/readme/README-RU.md index b846a52e..4cc1246c 100644 --- a/readme/README-RU.md +++ b/readme/README-RU.md @@ -1,91 +1,217 @@ -# Статистика репозитория +
-

-🇺🇸 English · 🇨🇳 中文 · 🇯🇵 日本語 · 🇰🇷 한국어 · 🇫🇷 Français · 🇩🇪 Deutsch · 🇪🇸 Español · 🇧🇷 Português · 🇷🇺 Русский · 🇸🇦 العربية · 🇮🇳 हिन्दी · 🇹🇷 Türkçe · 🇻🇳 Tiếng Việt · 🇮🇹 Italiano +Termix Logo + +

Termix

+ +

Самостоятельно размещаемое управление SSH и доступ к удалённому рабочему столу

+ +

+ English · + 中文 · + 日本語 · + 한국어 · + Français · + Deutsch · + Español · + Português · + Русский · + العربية · + हिन्दी · + Türkçe · + Tiếng Việt · + Italiano

-![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) -![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) -![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) -Discord - -

- Repo of the Day Achievement -
- Достигнуто 1 сентября 2025 года +

+ + + + Discord


-

- - Termix Banner + +Termix Banner + +
+
+ +

+ Repo of the Day Achievement +
+ Достигнуто 1 сентября 2025 года

-# Обзор +
-

- - Termix Banner -

+
-Termix — это платформа для управления серверами с открытым исходным кодом, навсегда бесплатная и размещаемая на собственном сервере. Она предоставляет мультиплатформенное решение для управления вашими серверами и инфраструктурой через единый интуитивно понятный интерфейс. Termix предлагает доступ к SSH-терминалу, управление удаленным рабочим столом (RDP, VNC, Telnet), возможности SSH-туннелирования, удаленное управление файлами SSH и множество других инструментов. Termix — это идеальная бесплатная альтернатива Termius с возможностью размещения на собственном сервере, доступная для всех платформ. +## Обзор -# Возможности +Termix - это платформа для управления серверами с открытым исходным кодом, навсегда бесплатная и размещаемая на собственном сервере. Она предоставляет мультиплатформенное решение для управления вашими серверами и инфраструктурой через единый интуитивно понятный интерфейс. Termix предлагает доступ к SSH-терминалу, управление удаленным рабочим столом (RDP, VNC, Telnet), возможности SSH-туннелирования, удаленное управление файлами SSH и множество других инструментов. Termix - это идеальная бесплатная альтернатива Termius с возможностью размещения на собственном сервере, доступная для всех платформ. -- **Доступ к SSH-терминалу** — Полнофункциональный терминал с поддержкой разделения экрана (до 4 панелей) и системой вкладок, как в браузере. Включает поддержку настройки терминала, включая популярные темы, шрифты и другие компоненты. -- **Доступ к удалённому рабочему столу** — Поддержка RDP, VNC и Telnet через браузер с полной настройкой и разделением экрана -- **Управление SSH-туннелями** — Создание и управление межсерверными SSH-туннелями с автоматическим переподключением, мониторингом состояния и локальной, удалённой или динамической SOCKS-переадресацией. Настройки туннелей «десктопный клиент — сервер» хранятся локально для каждой установки; опциональные снимки C2S-пресетов можно сохранять на сервере, переименовывать, загружать или удалять для переноса конфигурации между клиентами. -- **Удалённый файловый менеджер** — Управление файлами непосредственно на удалённых серверах с поддержкой просмотра и редактирования кода, изображений, аудио и видео. Загрузка, скачивание, переименование, удаление и перемещение файлов с поддержкой sudo. -- **Управление Docker** — Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием. -- **Менеджер SSH-хостов** — Сохранение, организация и управление SSH-подключениями с помощью тегов и папок, с возможностью сохранения данных для повторного входа и автоматизации развёртывания SSH-ключей -- **Статистика сервера** — Просмотр использования CPU, памяти и диска, а также сети, времени работы, информации о системе, файрвола и монитора портов на большинстве серверов на базе Linux -- **Панель управления** — Просмотр информации о сервере на панели управления одним взглядом -- **RBAC** — Создание ролей и предоставление общего доступа к хостам для пользователей/ролей -- **Аутентификация пользователей** — Безопасное управление пользователями с административным контролем и поддержкой OIDC (с контролем доступа) и 2FA (TOTP). Просмотр активных сессий пользователей на всех платформах и отзыв прав доступа. Связывание аккаунтов OIDC/локальных аккаунтов. -- **Шифрование базы данных** — Бэкенд хранится в виде зашифрованных файлов базы данных SQLite. Подробнее в [документации](https://docs.termix.site/security) -- **API-ключи** — Создание API-ключей с областью видимости пользователя и сроками действия для использования в автоматизации/CI. -- **Экспорт/импорт данных** — Экспорт и импорт SSH-хостов, учётных данных и данных файлового менеджера -- **Автоматическая настройка SSL** — Встроенная генерация и управление SSL-сертификатами с перенаправлением на HTTPS -- **Современный интерфейс** — Чистый интерфейс для десктопа и мобильных устройств, построенный на React, Tailwind CSS и Shadcn. Выбор между множеством различных тем интерфейса, включая светлую, тёмную, Dracula и т. д. Использование URL-маршрутов для открытия любого подключения в полноэкранном режиме. -- **Языки** — Встроенная поддержка ~30 языков (управляется через [Crowdin](https://docs.termix.site/translations)) -- **Поддержка платформ** — Доступен как веб-приложение, настольное приложение (Windows, Linux и macOS, может работать автономно без бэкенда Termix), PWA и специализированное мобильное/планшетное приложение для iOS и Android. -- **Инструменты SSH** — Создание переиспользуемых фрагментов команд, выполняемых одним нажатием. Запуск одной команды одновременно в нескольких открытых терминалах. -- **История команд** — Автодополнение и просмотр ранее выполненных SSH-команд -- **Быстрое подключение** — Подключение к серверу без необходимости сохранения данных подключения -- **Командная палитра** — Двойное нажатие левого Shift для быстрого доступа к SSH-подключениям с клавиатуры -- **Богатый функционал SSH** — Поддержка jump-хостов, Warpgate, подключений на основе TOTP, SOCKS5, верификации ключей хоста, автозаполнения паролей, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking и др. -- **Сетевой граф** — Настройте панель управления для визуализации вашей домашней лаборатории на основе SSH-подключений с поддержкой статусов -- **Постоянные вкладки** — SSH-сессии и вкладки остаются открытыми на всех устройствах/при обновлении страницы, если включено в профиле пользователя +
-# Запланированные функции +## Возможности -Смотрите [Проекты](https://github.com/orgs/Termix-SSH/projects/2) для просмотра всех запланированных функций. Если вы хотите внести вклад, смотрите [Участие в разработке](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-# Установка +**Доступ к SSH-терминалу:** +Полнофункциональный терминал с поддержкой разделения экрана (до 4 панелей) и системой вкладок, как в браузере. Включает поддержку настройки терминала, включая популярные темы, шрифты и другие компоненты. -Поддерживаемые устройства: + -- Веб-сайт (любой современный браузер на любой платформе, включая Chrome, Safari и Firefox) (включая поддержку PWA) -- Windows (x64/ia32) - - Портативная версия - - Установщик MSI - - Менеджер пакетов Chocolatey -- Linux (x64/ia32) - - Портативная версия - - AUR - - AppImage - - Deb - - Flatpak -- macOS (x64/ia32, версия 12.0+) - - Apple App Store - - DMG - - Homebrew -- iOS/iPadOS (версия 15.1+) - - Apple App Store - - IPA -- Android (версия 7.0+) - - Google Play Store - - APK +**Доступ к удалённому рабочему столу:** +Поддержка RDP, VNC и Telnet через браузер с полной настройкой и разделением экрана. + +
+ +**Управление SSH-туннелями:** +Создание и управление межсерверными SSH-туннелями с автоматическим переподключением, мониторингом состояния и локальной, удалённой или динамической SOCKS-переадресацией. Настройки туннелей «десктопный клиент - сервер» хранятся локально для каждой установки; опциональные снимки C2S-пресетов можно сохранять на сервере, переименовывать, загружать или удалять для переноса конфигурации между клиентами. + + + +**Удалённый файловый менеджер:** +Управление файлами непосредственно на удалённых серверах с поддержкой просмотра и редактирования кода, изображений, аудио и видео. Загрузка, скачивание, переименование, удаление и перемещение файлов с поддержкой sudo. + +
+ +**Управление Docker:** +Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием. + + + +**Менеджер SSH-хостов:** +Сохранение, организация и управление SSH-подключениями с помощью тегов и папок, с возможностью сохранения данных для повторного входа и автоматизации развёртывания SSH-ключей. + +
+ +**Статистика сервера:** +Просмотр использования CPU, памяти и диска, а также сети, времени работы, информации о системе, файрвола и монитора портов на большинстве серверов на базе Linux. + + + +**Аутентификация пользователей:** +Безопасное управление пользователями с административным контролем и поддержкой OIDC (с контролем доступа) и 2FA (TOTP). Просмотр активных сессий пользователей на всех платформах и отзыв прав доступа. Связывание аккаунтов OIDC/локальных аккаунтов. + +
+ +**RBAC:** +Создание ролей и предоставление общего доступа к хостам для пользователей/ролей. + + + +**Шифрование базы данных:** +Бэкенд хранится в виде зашифрованных файлов базы данных SQLite. Подробнее в [документации](https://docs.termix.site/security). + +
+ +**Сетевой граф:** +Настройте панель управления для визуализации вашей домашней лаборатории на основе SSH-подключений с поддержкой статусов. + + + +**Инструменты SSH:** +Создание переиспользуемых фрагментов команд, выполняемых одним нажатием. Запуск одной команды одновременно в нескольких открытых терминалах. + +
+ +**Постоянные вкладки:** +SSH-сессии и вкладки остаются открытыми на всех устройствах/при обновлении страницы, если включено в профиле пользователя. + + + +**Языки:** +Встроенная поддержка около 30 языков (управляется через [Crowdin](https://docs.termix.site/translations)). + +
+ +
+ +
+Больше возможностей +
+ +- **Панель управления** - Просмотр информации о сервере на панели управления одним взглядом +- **API-ключи** - Создание API-ключей с областью видимости пользователя и сроками действия для использования в автоматизации/CI +- **Экспорт/импорт данных** - Экспорт и импорт SSH-хостов, учётных данных и данных файлового менеджера +- **Автоматическая настройка SSL** - Встроенная генерация и управление SSL-сертификатами с перенаправлением на HTTPS +- **Современный интерфейс** - Чистый интерфейс для десктопа и мобильных устройств, построенный на React, Tailwind CSS и Shadcn. Выбор между множеством различных тем интерфейса, включая светлую, тёмную, Dracula и т. д. Использование URL-маршрутов для открытия любого подключения в полноэкранном режиме. +- **История команд** - Автодополнение и просмотр ранее выполненных SSH-команд +- **Быстрое подключение** - Подключение к серверу без необходимости сохранения данных подключения +- **Командная палитра** - Двойное нажатие левого Shift для быстрого доступа к SSH-подключениям с клавиатуры +- **Богатый функционал SSH** - Поддержка jump-хостов, Warpgate, подключений на основе TOTP, SOCKS5, верификации ключей хоста, автозаполнения паролей, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking и др. + +
+ +
+ +## Поддержка платформ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ПлатформаДистрибутив
WebЛюбой современный браузер (Chrome, Safari, Firefox) · Поддержка PWA
Windows x64/ia32Портативная версия · Установщик MSI · Chocolatey
Linux x64/ia32Портативная версия · AUR · AppImage · Deb · Flatpak
macOS x64/ia32, v12.0+Apple App Store · DMG · Homebrew
iOS/iPadOS v15.1+Apple App Store · IPA
Android v7.0+Google Play Store · APK
+ +
+ +## Установка Посетите [документацию](https://docs.termix.site/install) Termix для получения дополнительной информации об установке Termix на всех платформах. Также вы можете ознакомиться с примером файла Docker Compose здесь (вы можете опустить guacd и сеть, если не планируете использовать функции удаленного рабочего стола): @@ -124,79 +250,108 @@ networks: driver: bridge ``` -# Спонсоры +
-

- - DigitalOcean - -          - - Crowdin - -          - - Blacksmith - -          - - Cloudflare - -          - - TailScale - -          - - Akamai - -          - - AWS - -

+## Скриншоты -# Поддержка +
-Если вам нужна помощь или вы хотите запросить новую функцию для Termix, посетите страницу [Проблемы](https://github.com/Termix-SSH/Support/issues), войдите в систему и нажмите `New Issue`. -Пожалуйста, опишите вашу проблему как можно подробнее, предпочтительно на английском языке. Вы также можете присоединиться к серверу [Discord](https://discord.gg/jVQGdvHDrf) и обратиться в канал поддержки, однако время ответа может быть дольше. - -# Скриншоты +
[![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos) -

- Termix Demo 1 - Termix Demo 2 -

+Смотрите обзоры обновлений на YouTube -

- Termix Demo 3 - Termix Demo 4 -

+
+
-

- Termix Demo 5 - Termix Demo 6 -

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Termix Screenshot 1Termix Screenshot 2
Termix Screenshot 3Termix Screenshot 4
Termix Screenshot 5Termix Screenshot 6
Termix Screenshot 7Termix Screenshot 8
Termix Screenshot 9Termix Screenshot 10
Termix Screenshot 11Termix Screenshot 12
Termix Screenshot 13Termix Screenshot 14
-

- Termix Demo 7 - Termix Demo 8 -

+Некоторые видео и изображения могут быть устаревшими или не полностью отражать функциональность. -

- Termix Demo 9 - Termix Demo 10 -

+
-

- Termix Demo 11 - Termix Demo 12 -

+
-Некоторые видео и изображения могут быть устаревшими или не полностью отражать функциональность. +## Запланированные функции -# Лицензия +Смотрите [Проекты](https://github.com/orgs/Termix-SSH/projects/2) для просмотра всех запланированных функций. Если вы хотите внести вклад, смотрите [Участие в разработке](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). -Распространяется по лицензии Apache License Version 2.0. Подробнее см. в файле LICENSE. +
+ +## Спонсоры + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + + +
+ +
+ +## Поддержка + +Если вам нужна помощь или вы хотите запросить новую функцию для Termix, посетите страницу [Проблемы](https://github.com/Termix-SSH/Support/issues), войдите в систему и нажмите `New Issue`. Пожалуйста, опишите вашу проблему как можно подробнее, предпочтительно на английском языке. Вы также можете присоединиться к серверу [Discord](https://discord.gg/jVQGdvHDrf) и обратиться в канал поддержки, однако время ответа может быть дольше. + +
+ +## Лицензия + +Распространяется по лицензии Apache License Version 2.0. Подробнее см. в файле `LICENSE`. diff --git a/readme/README-TR.md b/readme/README-TR.md index 33a0d6cf..779674d9 100644 --- a/readme/README-TR.md +++ b/readme/README-TR.md @@ -1,93 +1,219 @@ -# Repo İstatistikleri +
-

-🇺🇸 English · 🇨🇳 中文 · 🇯🇵 日本語 · 🇰🇷 한국어 · 🇫🇷 Français · 🇩🇪 Deutsch · 🇪🇸 Español · 🇧🇷 Português · 🇷🇺 Русский · 🇸🇦 العربية · 🇮🇳 हिन्दी · 🇹🇷 Türkçe · 🇻🇳 Tiếng Việt · 🇮🇹 Italiano +Termix Logo + +

Termix

+ +

Kendi sunucunuzda barindirilan SSH yonetimi ve uzak masaustu erisimi

+ +

+ English · + 中文 · + 日本語 · + 한국어 · + Français · + Deutsch · + Español · + Português · + Русский · + العربية · + हिन्दी · + Türkçe · + Tiếng Việt · + Italiano

-![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) -![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) -![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) -Discord - -

- Repo of the Day Achievement -
- 1 Eylül 2025'te kazanıldı +

+ + + + Discord


-

- - Termix Banner + +Termix Banner + +
+
+ +

+ Repo of the Day Achievement +
+ 1 Eylül 2025'te kazanildi

-# Genel Bakış +
-

- - Termix Banner -

+
-Termix, açık kaynaklı, sonsuza kadar ücretsiz, kendi sunucunuzda barındırabileceğiniz hepsi bir arada sunucu yönetim platformudur. Sunucularınızı ve altyapınızı tek bir sezgisel arayüz üzerinden yönetmek için çok platformlu bir çözüm sunar. Termix, SSH terminal erişimi, uzak masaüstü kontrolü (RDP, VNC, Telnet), SSH tünelleme yetenekleri, uzak SSH dosya yönetimi ve daha birçok araç sağlar. Termix, tüm platformlarda kullanılabilen Termius'un mükemmel ücretsiz ve kendi barındırmalı alternatifidir. +## Genel Bakis -# Özellikler +Termix, acik kaynakli, sonsuza kadar ucretsiz, kendi sunucunuzda barindirabileceginez hepsi bir arada sunucu yonetim platformudur. Sunucularinizi ve altyapinizi tek bir sezgisel arayuz uzerinden yonetmek icin cok platformlu bir cozum sunar. Termix, SSH terminal erisimi, uzak masaustu kontrolu (RDP, VNC, Telnet), SSH tunelleme yetenekleri, uzak SSH dosya yonetimi ve daha bircok arac saglar. Termix, tum platformlarda kullanilabilen Termius'un mukemmel ucretsiz ve kendi barindirmali alternatifidir. -- **SSH Terminal Erişimi** - Tarayıcı benzeri sekme sistemiyle bölünmüş ekran desteğine sahip (4 panele kadar) tam özellikli terminal. Yaygın terminal temaları, yazı tipleri ve diğer bileşenler dahil olmak üzere terminal özelleştirme desteği içerir. -- **Uzak Masaüstü Erişimi** - Tam özelleştirme ve bölünmüş ekran ile tarayıcı üzerinden RDP, VNC ve Telnet desteği -- **SSH Tünel Yönetimi** - Otomatik yeniden bağlanma, sağlık izleme ve yerel, uzak veya dinamik SOCKS yönlendirme desteğiyle sunucular arası SSH tünelleri oluşturun ve yönetin. Masaüstü istemci-sunucu tünel ayarları her masaüstü kurulumu için yerel olarak depolanır; isteğe bağlı C2S hazır ayar anlık görüntüleri sunucuya kaydedilebilir, yeniden adlandırılabilir, yüklenebilir veya silinebilir. -- **Uzak Dosya Yöneticisi** - Uzak sunuculardaki dosyaları doğrudan yönetin; kod, görüntü, ses ve video görüntüleme ve düzenleme desteğiyle. Sudo desteğiyle dosyaları sorunsuzca yükleyin, indirin, yeniden adlandırın, silin ve taşıyın. -- **Docker Yönetimi** - Konteynerleri başlatın, durdurun, duraklatın, kaldırın. Konteyner istatistiklerini görüntüleyin. Docker exec terminali kullanarak konteyneri kontrol edin. Portainer veya Dockge'nin yerini almak için değil, konteynerlerinizi oluşturmak yerine basitçe yönetmek için tasarlanmıştır. -- **SSH Ana Bilgisayar Yöneticisi** - SSH bağlantılarınızı etiketler ve klasörlerle kaydedin, düzenleyin ve yönetin; yeniden kullanılabilir giriş bilgilerini kolayca kaydedin ve SSH anahtarlarının dağıtımını otomatikleştirin -- **Sunucu İstatistikleri** - Çoğu Linux tabanlı sunucularda CPU, bellek ve disk kullanımını ağ, çalışma süresi, sistem bilgisi, güvenlik duvarı, port izleme ile birlikte görüntüleyin -- **Kontrol Paneli** - Kontrol panelinizde sunucu bilgilerini bir bakışta görüntüleyin -- **RBAC** - Roller oluşturun ve ana bilgisayarları kullanıcılar/roller arasında paylaşın -- **Kullanıcı Kimlik Doğrulama** - Yönetici kontrolleri, OIDC (erişim kontrollü) ve 2FA (TOTP) desteğiyle güvenli kullanıcı yönetimi. Tüm platformlardaki aktif kullanıcı oturumlarını görüntüleyin ve izinleri iptal edin. OIDC/Yerel hesaplarınızı birbirine bağlayın. -- **Veritabanı Şifreleme** - Arka uç, şifrelenmiş SQLite veritabanı dosyaları olarak depolanır. Daha fazla bilgi için [belgelere](https://docs.termix.site/security) bakın. -- **API Anahtarları** - Otomasyon/CI için kullanılmak üzere son kullanma tarihleriyle kullanıcı kapsamlı API anahtarları oluşturun. -- **Veri Dışa/İçe Aktarma** - SSH ana bilgisayarlarını, kimlik bilgilerini ve dosya yöneticisi verilerini dışa ve içe aktarın -- **Otomatik SSL Kurulumu** - HTTPS yönlendirmeleriyle yerleşik SSL sertifika oluşturma ve yönetimi -- **Modern Arayüz** - React, Tailwind CSS ve Shadcn ile oluşturulmuş temiz masaüstü/mobil uyumlu arayüz. Işık, karanlık, Dracula vb. dahil olmak üzere birçok farklı UI teması arasından seçim yapın. Herhangi bir bağlantıyı tam ekranda açmak için URL yollarını kullanın. -- **Diller** - ~30 dil için yerleşik destek ([Crowdin](https://docs.termix.site/translations) tarafından yönetilir) -- **Platform Desteği** - Web uygulaması, masaüstü uygulaması (Windows, Linux ve macOS, Termix arka ucu olmadan tek başına çalıştırılabilir), PWA ve iOS ile Android için özel mobil/tablet uygulaması olarak kullanılabilir. -- **SSH Araçları** - Tek tıklamayla çalıştırılan yeniden kullanılabilir komut parçacıkları oluşturun. Birden fazla açık terminalde aynı anda tek bir komut çalıştırın. -- **Komut Geçmişi** - Daha önce çalıştırılan SSH komutlarını otomatik tamamlayın ve görüntüleyin -- **Hızlı Bağlantı** - Bağlantı verilerini kaydetmeden bir sunucuya bağlanın -- **Komut Paleti** - Sol shift tuşuna iki kez basarak SSH bağlantılarına klavyenizle hızlıca erişin -- **SSH Zengin Özellikler** - Atlama ana bilgisayarları, Warpgate, TOTP tabanlı bağlantılar, SOCKS5, ana bilgisayar anahtar doğrulama, otomatik şifre doldurma, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking vb. destekler. -- **Ağ Grafiği** - Kontrol panelinizi, SSH bağlantılarınıza dayalı olarak ev laboratuvarınızı durum desteğiyle görselleştirmek için özelleştirin -- **Kalıcı Sekmeler** - Kullanıcı profilinde etkinleştirilmişse SSH oturumları ve sekmeler cihazlar/yenilemeler arasında açık kalır +
-# Planlanan Özellikler +## Ozellikler -Tüm planlanan özellikler için [Projeler](https://github.com/orgs/Termix-SSH/projects/2) sayfasına bakın. Katkıda bulunmak istiyorsanız, [Katkıda Bulunma](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) sayfasına bakın. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-# Kurulum +**SSH Terminal Erisimi:** +Tarayici benzeri sekme sistemiyle bolunmus ekran destegine sahip (4 panele kadar) tam ozellikli terminal. Yaygin terminal temalari, yazi tipleri ve diger bilesenleri iceren terminal ozellestirme destegi. -Desteklenen Cihazlar: + -- Web sitesi (Chrome, Safari ve Firefox gibi herhangi bir platformda herhangi bir modern tarayıcı) (PWA desteği dahil) -- Windows (x64/ia32) - - Taşınabilir - - MSI Yükleyici - - Chocolatey Paket Yöneticisi -- Linux (x64/ia32) - - Taşınabilir - - AUR - - AppImage - - Deb - - Flatpak -- macOS (v12.0+ üzerinde x64/ia32) - - Apple App Store - - DMG - - Homebrew -- iOS/iPadOS (v15.1+) - - Apple App Store - - IPA -- Android (v7.0+) - - Google Play Store - - APK +**Uzak Masaustu Erisimi:** +Tam ozellestirme ve bolunmus ekran ile tarayici uzerinden RDP, VNC ve Telnet destegi. -Termix'i tüm platformlara nasıl kuracağınız hakkında daha fazla bilgi için Termix [Belgelerine](https://docs.termix.site/install) bakın. Aksi takdirde, örnek bir Docker Compose dosyasını burada görüntüleyin (uzak masaüstü özelliklerini kullanmayı planlamıyorsanız guacd'yi ve ağı çıkarabilirsiniz): +
+ +**SSH Tunel Yonetimi:** +Otomatik yeniden baglantiya, saglik izleme ve yerel, uzak veya dinamik SOCKS yonlendirme destegi ile sunucular arasi SSH tunelleri olusturun ve yonetin. Masaustu istemci-sunucu tunel ayarlari her masaustu kurulumu icin yerel olarak depolanir; istege bagli C2S hazir ayar anlik goruntuleri sunucuya kaydedilebilir, yeniden adlandirilabilir, yuklenebilir veya silinebilir. + + + +**Uzak Dosya Yoneticisi:** +Uzak sunuculardaki dosyalari dogrudan yonetin; kod, goruntu, ses ve video goruntuleme ve duzenleme destegi ile. Sudo destegi ile dosyalari sorunsuzca yukleyin, indirin, yeniden adlandirin, silin ve tasiyin. + +
+ +**Docker Yonetimi:** +Konteynerleri baslatın, durdurun, duraklatın, kaldirin. Konteyner istatistiklerini goruntuleyin. Docker exec terminali kullanarak konteyneri kontrol edin. Portainer veya Dockge'nin yerini almak icin degil, konteynerlerinizi olusturmak yerine basitce yonetmek icin tasarlanmistir. + + + +**SSH Ana Bilgisayar Yoneticisi:** +SSH baglantilarinizi etiketler ve klasorlerle kaydedin, duzenleyin ve yonetin; yeniden kullanilabilir giris bilgilerini kolayca kaydedin ve SSH anahtarlarinin dagitimini otomatiklestirin. + +
+ +**Sunucu Istatistikleri:** +Cogu Linux tabanli sunucularda CPU, bellek ve disk kullanimini ag, calisma suresi, sistem bilgisi, guvenlik duvari, port izleme ile birlikte goruntuleyin. + + + +**Kullanici Kimlik Dogrulama:** +Yonetici kontrolleri, OIDC (erisim kontrollu) ve 2FA (TOTP) destegi ile guvenli kullanici yonetimi. Tum platformlardaki aktif kullanici oturumlarini goruntuleyin ve izinleri iptal edin. OIDC/Yerel hesaplarinizi birbirine baglayin. + +
+ +**RBAC:** +Roller olusturun ve ana bilgisayarlari kullanicilar/roller arasinda paylasin. + + + +**Veritabani Sifreleme:** +Arka uc, sifrelenmis SQLite veritabani dosyalari olarak depolanir. Daha fazla bilgi icin [belgelere](https://docs.termix.site/security) bakin. + +
+ +**Ag Grafigi:** +Kontrol panelinizi, SSH baglantilariniza dayali olarak ev laboratuvarinizi durum destegi ile gorselletirmek icin ozellestirin. + + + +**SSH Araclari:** +Tek tiklamayla calistirilan yeniden kullanilabilir komut parcaciklari olusturun. Birden fazla acik terminalde ayni anda tek bir komut calistirin. + +
+ +**Kalici Sekmeler:** +Kullanici profilinde etkinlestirilmisse SSH oturumlari ve sekmeler cihazlar/yenilemeler arasinda acik kalir. + + + +**Diller:** +Yaklasik 30 dil icin yerlesik destek ([Crowdin](https://docs.termix.site/translations) tarafindan yonetilir). + +
+ +
+ +
+Daha fazla ozellik +
+ +- **Kontrol Paneli** - Kontrol panelinizde sunucu bilgilerini bir bakista goruntuleyin +- **API Anahtarlari** - Otomasyon/CI icin kullanilmak uzere son kullanma tarihleriyle kullanici kapsamli API anahtarlari olusturun +- **Veri Disa/Ice Aktarma** - SSH ana bilgisayarlarini, kimlik bilgilerini ve dosya yoneticisi verilerini disa ve ice aktarin +- **Otomatik SSL Kurulumu** - HTTPS yonlendirmeleriyle yerlesik SSL sertifika olusturma ve yonetimi +- **Modern Arayuz** - React, Tailwind CSS ve Shadcn ile olusturulmus temiz masaustu/mobil uyumlu arayuz. Isik, karanlik, Dracula vb. dahil olmak uzere bircok farkli UI temasi arasından secim yapin. Herhangi bir baglantıyı tam ekranda acmak icin URL yollarini kullanin. +- **Komut Gecmisi** - Daha once calistirilan SSH komutlarini otomatik tamamlayin ve goruntuleyin +- **Hizli Baglanti** - Baglanti verilerini kaydetmeden bir sunucuya baglanin +- **Komut Paleti** - Sol shift tusuna iki kez basarak SSH baglantilariniza klavyenizle hizlica erisin +- **SSH Zengin Ozellikler** - Atlama ana bilgisayarlari, Warpgate, TOTP tabanli baglantilar, SOCKS5, ana bilgisayar anahtar dogrulama, otomatik sifre doldurma, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking vb. destekler. + +
+ +
+ +## Platform Destegi + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PlatformDagitim
WebHerhangi bir modern tarayici (Chrome, Safari, Firefox) · PWA destegi
Windows x64/ia32Tasınabilir · MSI Yukleyici · Chocolatey
Linux x64/ia32Tasınabilir · AUR · AppImage · Deb · Flatpak
macOS x64/ia32, v12.0+Apple App Store · DMG · Homebrew
iOS/iPadOS v15.1+Apple App Store · IPA
Android v7.0+Google Play Store · APK
+ +
+ +## Kurulum + +Termix'i tum platformlara nasil kuracaginiz hakkinda daha fazla bilgi icin Termix [Belgelerine](https://docs.termix.site/install) bakin. Ornek bir Docker Compose dosyasini asagida inceleyebilirsiniz (uzak masaustu ozelliklerini kullanmayi planlamiyorsaniz guacd'yi ve agi cikarabilirsiniz): ```yaml services: @@ -124,79 +250,108 @@ networks: driver: bridge ``` -# Sponsorlar +
-

- - DigitalOcean - -          - - Crowdin - -          - - Blacksmith - -          - - Cloudflare - -          - - TailScale - -          - - Akamai - -          - - AWS - -

+## Ekran Goruntuleri -# Destek +
-Termix ile ilgili yardıma ihtiyacınız varsa veya bir özellik talep etmek istiyorsanız, [Sorunlar](https://github.com/Termix-SSH/Support/issues) sayfasını ziyaret edin, giriş yapın ve `New Issue` butonuna basın. -Lütfen sorununuzu mümkün olduğunca ayrıntılı yazın, tercihen İngilizce olarak. Ayrıca [Discord](https://discord.gg/jVQGdvHDrf) sunucusuna katılabilir ve destek kanalını ziyaret edebilirsiniz, ancak yanıt süreleri daha uzun olabilir. - -# Ekran Görüntüleri +
[![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos) -

- Termix Demo 1 - Termix Demo 2 -

+YouTube'da guncelleme ozetlerini izleyin -

- Termix Demo 3 - Termix Demo 4 -

+
+
-

- Termix Demo 5 - Termix Demo 6 -

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Termix Screenshot 1Termix Screenshot 2
Termix Screenshot 3Termix Screenshot 4
Termix Screenshot 5Termix Screenshot 6
Termix Screenshot 7Termix Screenshot 8
Termix Screenshot 9Termix Screenshot 10
Termix Screenshot 11Termix Screenshot 12
Termix Screenshot 13Termix Screenshot 14
-

- Termix Demo 7 - Termix Demo 8 -

+Bazi videolar ve gorseller guncel olmayabilir veya ozellikleri tam olarak yansitmayabilir. -

- Termix Demo 9 - Termix Demo 10 -

+
-

- Termix Demo 11 - Termix Demo 12 -

+
-Bazı videolar ve görseller güncel olmayabilir veya özellikleri tam olarak yansıtmayabilir. +## Planlanan Ozellikler -# Lisans +Tum planlanan ozellikler icin [Projeler](https://github.com/orgs/Termix-SSH/projects/2) sayfasina bakin. Katkida bulunmak istiyorsaniz, [Katkida Bulunma](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) sayfasina bakin. -Apache Lisansı Sürüm 2.0 altında dağıtılmaktadır. Daha fazla bilgi için LICENSE dosyasına bakın. +
+ +## Sponsorlar + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + + +
+ +
+ +## Destek + +Termix ile ilgili yardima ihtiyaciniz varsa veya bir ozellik talep etmek istiyorsaniz, [Sorunlar](https://github.com/Termix-SSH/Support/issues) sayfasini ziyaret edin, giris yapin ve `New Issue` butonuna basin. Lutfen sorununuzu mumkun oldugunca ayrintili yazin, tercihen Ingilizce olarak. Ayrica [Discord](https://discord.gg/jVQGdvHDrf) sunucusuna katilabilir ve destek kanalini ziyaret edebilirsiniz, ancak yanit sureleri daha uzun olabilir. + +
+ +## Lisans + +Apache Lisansi Surumu 2.0 altinda dagitilmaktadir. Daha fazla bilgi icin `LICENSE` dosyasina bakin. diff --git a/readme/README-VI.md b/readme/README-VI.md index 3ce7512c..a5850e40 100644 --- a/readme/README-VI.md +++ b/readme/README-VI.md @@ -1,93 +1,219 @@ -# Thống Kê Repo +
-

-🇺🇸 English · 🇨🇳 中文 · 🇯🇵 日本語 · 🇰🇷 한국어 · 🇫🇷 Français · 🇩🇪 Deutsch · 🇪🇸 Español · 🇧🇷 Português · 🇷🇺 Русский · 🇸🇦 العربية · 🇮🇳 हिन्दी · 🇹🇷 Türkçe · 🇻🇳 Tiếng Việt · 🇮🇹 Italiano +Termix Logo + +

Termix

+ +

Quan ly SSH tu luu tru va truy cap may tinh tu xa

+ +

+ English · + 中文 · + 日本語 · + 한국어 · + Français · + Deutsch · + Español · + Português · + Русский · + العربية · + हिन्दी · + Türkçe · + Tiếng Việt · + Italiano

-![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) -![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) -![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) -Discord - -

- Repo of the Day Achievement -
- Đạt được vào ngày 1 tháng 9 năm 2025 +

+ + + + Discord


-

- - Termix Banner + +Termix Banner + +
+
+ +

+ Repo of the Day Achievement +
+ Dat duoc vao ngay 1 thang 9 nam 2025

-# Tổng Quan +
-

- - Termix Banner -

+
-Termix là nền tảng quản lý máy chủ tất cả trong một, mã nguồn mở, miễn phí vĩnh viễn, tự lưu trữ. Nó cung cấp giải pháp đa nền tảng để quản lý máy chủ và cơ sở hạ tầng của bạn thông qua một giao diện trực quan duy nhất. Termix cung cấp quyền truy cập terminal SSH, điều khiển máy tính từ xa (RDP, VNC, Telnet), khả năng tạo đường hầm SSH, quản lý tệp SSH từ xa và nhiều công cụ khác. Termix là giải pháp thay thế miễn phí và tự lưu trữ hoàn hảo cho Termius, khả dụng trên tất cả các nền tảng. +## Tong Quan -# Tính Năng +Termix la nen tang quan ly may chu tat ca trong mot, ma nguon mo, mien phi vinh vien, tu luu tru. No cung cap giai phap da nen tang de quan ly may chu va co so ha tang cua ban thong qua mot giao dien truc quan duy nhat. Termix cung cap quyen truy cap terminal SSH, dieu khien may tinh tu xa (RDP, VNC, Telnet), kha nang tao duong ham SSH, quan ly tep SSH tu xa va nhieu cong cu khac. Termix la giai phap thay the mien phi va tu luu tru hoan hao cho Termius, kha dung tren tat ca cac nen tang. -- **Truy Cập Terminal SSH** - Terminal đầy đủ tính năng với hỗ trợ chia màn hình (lên đến 4 bảng) với hệ thống tab kiểu trình duyệt. Bao gồm hỗ trợ tùy chỉnh terminal bao gồm các chủ đề terminal phổ biến, phông chữ và các thành phần khác. -- **Truy Cập Màn Hình Từ Xa** - Hỗ trợ RDP, VNC và Telnet qua trình duyệt với đầy đủ tùy chỉnh và chia màn hình -- **Quản Lý Đường Hầm SSH** - Tạo và quản lý đường hầm SSH giữa các máy chủ với tự động kết nối lại, giám sát sức khỏe và chuyển tiếp cục bộ, từ xa hoặc SOCKS động. Cài đặt đường hầm từ máy khách desktop đến máy chủ được lưu trữ cục bộ cho mỗi bản cài đặt desktop; các snapshot C2S preset tùy chọn có thể được lưu trên máy chủ, đổi tên, tải hoặc xóa để di chuyển cấu hình đường hầm cục bộ giữa các máy khách. -- **Trình Quản Lý Tệp Từ Xa** - Quản lý tệp trực tiếp trên máy chủ từ xa với hỗ trợ xem và chỉnh sửa mã, hình ảnh, âm thanh và video. Tải lên, tải xuống, đổi tên, xóa và di chuyển tệp liền mạch với hỗ trợ sudo. -- **Quản Lý Docker** - Khởi động, dừng, tạm dừng, xóa container. Xem thống kê container. Điều khiển container bằng terminal docker exec. Không được tạo ra để thay thế Portainer hay Dockge mà đơn giản là để quản lý container của bạn thay vì tạo mới chúng. -- **Trình Quản Lý Máy Chủ SSH** - Lưu, sắp xếp và quản lý các kết nối SSH của bạn với thẻ và thư mục, dễ dàng lưu thông tin đăng nhập có thể tái sử dụng đồng thời có thể tự động hóa việc triển khai khóa SSH -- **Thống Kê Máy Chủ** - Xem mức sử dụng CPU, bộ nhớ và ổ đĩa cùng với mạng, thời gian hoạt động, thông tin hệ thống, tường lửa, giám sát cổng trên hầu hết các máy chủ chạy Linux -- **Bảng Điều Khiển** - Xem thông tin máy chủ trong nháy mắt trên bảng điều khiển của bạn -- **RBAC** - Tạo vai trò và chia sẻ máy chủ giữa người dùng/vai trò -- **Xác Thực Người Dùng** - Quản lý người dùng an toàn với quyền quản trị và hỗ trợ OIDC (có kiểm soát truy cập) và 2FA (TOTP). Xem phiên hoạt động của người dùng trên tất cả các nền tảng và thu hồi quyền. Liên kết tài khoản OIDC/Nội bộ của bạn với nhau. -- **Mã Hóa Cơ Sở Dữ Liệu** - Backend được lưu trữ dưới dạng tệp cơ sở dữ liệu SQLite được mã hóa. Xem [tài liệu](https://docs.termix.site/security) để biết thêm. -- **Khóa API** - Tạo khóa API theo phạm vi người dùng với ngày hết hạn để sử dụng cho tự động hóa/CI. -- **Xuất/Nhập Dữ Liệu** - Xuất và nhập máy chủ SSH, thông tin xác thực và dữ liệu trình quản lý tệp -- **Thiết Lập SSL Tự Động** - Tạo và quản lý chứng chỉ SSL tích hợp với chuyển hướng HTTPS -- **Giao Diện Hiện Đại** - Giao diện sạch sẽ, thân thiện với máy tính/di động được xây dựng bằng React, Tailwind CSS và Shadcn. Chọn giữa nhiều chủ đề UI khác nhau bao gồm sáng, tối, Dracula, v.v. Sử dụng đường dẫn URL để mở bất kỳ kết nối nào ở chế độ toàn màn hình. -- **Ngôn Ngữ** - Hỗ trợ tích hợp ~30 ngôn ngữ (được quản lý bởi [Crowdin](https://docs.termix.site/translations)) -- **Hỗ Trợ Nền Tảng** - Khả dụng dưới dạng ứng dụng web, ứng dụng máy tính (Windows, Linux và macOS, có thể chạy độc lập mà không cần backend Termix), PWA và ứng dụng di động/máy tính bảng chuyên dụng cho iOS và Android. -- **Công Cụ SSH** - Tạo đoạn lệnh có thể tái sử dụng, thực thi chỉ với một cú nhấp chuột. Chạy một lệnh đồng thời trên nhiều terminal đang mở. -- **Lịch Sử Lệnh** - Tự động hoàn thành và xem các lệnh SSH đã chạy trước đó -- **Kết Nối Nhanh** - Kết nối đến máy chủ mà không cần lưu dữ liệu kết nối -- **Bảng Lệnh** - Nhấn đúp phím shift trái để truy cập nhanh các kết nối SSH bằng bàn phím -- **SSH Giàu Tính Năng** - Hỗ trợ jump host, Warpgate, kết nối dựa trên TOTP, SOCKS5, xác minh khóa máy chủ, tự động điền mật khẩu, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, v.v. -- **Biểu Đồ Mạng** - Tùy chỉnh Bảng Điều Khiển để trực quan hóa homelab của bạn dựa trên các kết nối SSH với hỗ trợ trạng thái -- **Tab Liên Tục** - Các phiên SSH và tab vẫn mở trên các thiết bị/lần làm mới nếu được bật trong hồ sơ người dùng +
-# Tính Năng Dự Kiến +## Tinh Nang -Xem [Dự Án](https://github.com/orgs/Termix-SSH/projects/2) để biết tất cả các tính năng dự kiến. Nếu bạn muốn đóng góp, xem [Đóng Góp](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-# Cài Đặt +**Truy Cap Terminal SSH:** +Terminal day du tinh nang voi ho tro chia man hinh (len den 4 bang) voi he thong tab kieu trinh duyet. Bao gom ho tro tuy chinh terminal bao gom cac chu de terminal pho bien, phong chu va cac thanh phan khac. -Thiết Bị Được Hỗ Trợ: + -- Trang web (bất kỳ trình duyệt hiện đại nào trên bất kỳ nền tảng nào như Chrome, Safari và Firefox) (bao gồm hỗ trợ PWA) -- Windows (x64/ia32) - - Portable - - MSI Installer - - Chocolatey Package Manager -- Linux (x64/ia32) - - Portable - - AUR - - AppImage - - Deb - - Flatpak -- macOS (x64/ia32 trên v12.0+) - - Apple App Store - - DMG - - Homebrew -- iOS/iPadOS (v15.1+) - - Apple App Store - - IPA -- Android (v7.0+) - - Google Play Store - - APK +**Truy Cap Man Hinh Tu Xa:** +Ho tro RDP, VNC va Telnet qua trinh duyet voi day du tuy chinh va chia man hinh. -Truy cập [Tài Liệu](https://docs.termix.site/install) Termix để biết thêm thông tin về cách cài đặt Termix trên tất cả các nền tảng. Ngoài ra, xem tệp Docker Compose mẫu tại đây (bạn có thể bỏ qua guacd và mạng nếu không có ý định sử dụng các tính năng điều khiển máy tính từ xa): +
+ +**Quan Ly Duong Ham SSH:** +Tao va quan ly duong ham SSH giua cac may chu voi tu dong ket noi lai, giam sat suc khoe va chuyen tiep cuc bo, tu xa hoac SOCKS dong. Cai dat duong ham tu may khach desktop den may chu duoc luu tru cuc bo cho moi ban cai dat desktop; cac snapshot C2S preset tuy chon co the duoc luu tren may chu, doi ten, tai hoac xoa de di chuyen cau hinh duong ham cuc bo giua cac may khach. + + + +**Trinh Quan Ly Tep Tu Xa:** +Quan ly tep truc tiep tren may chu tu xa voi ho tro xem va chinh sua ma, hinh anh, am thanh va video. Tai len, tai xuong, doi ten, xoa va di chuyen tep lien mach voi ho tro sudo. + +
+ +**Quan Ly Docker:** +Khoi dong, dung, tam dung, xoa container. Xem thong ke container. Dieu khien container bang terminal docker exec. Khong duoc tao ra de thay the Portainer hay Dockge ma don gian la de quan ly container cua ban thay vi tao moi chung. + + + +**Trinh Quan Ly May Chu SSH:** +Luu, sap xep va quan ly cac ket noi SSH cua ban voi the va thu muc, de dang luu thong tin dang nhap co the tai su dung dong thoi co the tu dong hoa viec trien khai khoa SSH. + +
+ +**Thong Ke May Chu:** +Xem muc su dung CPU, bo nho va o dia cung voi mang, thoi gian hoat dong, thong tin he thong, tuong lua, giam sat cong tren hau het cac may chu chay Linux. + + + +**Xac Thuc Nguoi Dung:** +Quan ly nguoi dung an toan voi quyen quan tri va ho tro OIDC (co kiem soat truy cap) va 2FA (TOTP). Xem phien hoat dong cua nguoi dung tren tat ca cac nen tang va thu hoi quyen. Lien ket tai khoan OIDC/Noi bo cua ban voi nhau. + +
+ +**RBAC:** +Tao vai tro va chia se may chu giua nguoi dung/vai tro. + + + +**Ma Hoa Co So Du Lieu:** +Backend duoc luu tru duoi dang tep co so du lieu SQLite duoc ma hoa. Xem [tai lieu](https://docs.termix.site/security) de biet them. + +
+ +**Bieu Do Mang:** +Tuy chinh Bang Dieu Khien de truc quan hoa homelab cua ban dua tren cac ket noi SSH voi ho tro trang thai. + + + +**Cong Cu SSH:** +Tao doan lenh co the tai su dung, thuc thi chi voi mot cu nhap chuot. Chay mot lenh dong thoi tren nhieu terminal dang mo. + +
+ +**Tab Lien Tuc:** +Cac phien SSH va tab van mo tren cac thiet bi/lan lam moi neu duoc bat trong ho so nguoi dung. + + + +**Ngon Ngu:** +Ho tro tich hop khoang 30 ngon ngu (duoc quan ly boi [Crowdin](https://docs.termix.site/translations)). + +
+ +
+ +
+Them tinh nang +
+ +- **Bang Dieu Khien** - Xem thong tin may chu trong nháy mat tren bang dieu khien cua ban +- **Khoa API** - Tao khoa API theo pham vi nguoi dung voi ngay het han de su dung cho tu dong hoa/CI +- **Xuat/Nhap Du Lieu** - Xuat va nhap may chu SSH, thong tin xac thuc va du lieu trinh quan ly tep +- **Thiet Lap SSL Tu Dong** - Tao va quan ly chung chi SSL tich hop voi chuyen huong HTTPS +- **Giao Dien Hien Dai** - Giao dien sach se, than thien voi may tinh/di dong duoc xay dung bang React, Tailwind CSS va Shadcn. Chon giua nhieu chu de UI khac nhau bao gom sang, toi, Dracula, v.v. Su dung duong dan URL de mo bat ky ket noi nao o che do toan man hinh. +- **Lich Su Lenh** - Tu dong hoan thanh va xem cac lenh SSH da chay truoc do +- **Ket Noi Nhanh** - Ket noi den may chu ma khong can luu du lieu ket noi +- **Bang Lenh** - Nhan dup phim shift trai de truy cap nhanh cac ket noi SSH bang ban phim +- **SSH Giau Tinh Nang** - Ho tro jump host, Warpgate, ket noi dua tren TOTP, SOCKS5, xac minh khoa may chu, tu dong dien mat khau, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, v.v. + +
+ +
+ +## Ho Tro Nen Tang + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Nen tangPhan phoi
WebBat ky trinh duyet hien dai nao (Chrome, Safari, Firefox) · Ho tro PWA
Windows x64/ia32Portable · MSI Installer · Chocolatey
Linux x64/ia32Portable · AUR · AppImage · Deb · Flatpak
macOS x64/ia32, v12.0+Apple App Store · DMG · Homebrew
iOS/iPadOS v15.1+Apple App Store · IPA
Android v7.0+Google Play Store · APK
+ +
+ +## Cai Dat + +Truy cap [Tai Lieu](https://docs.termix.site/install) Termix de biet them thong tin ve cach cai dat Termix tren tat ca cac nen tang. Ngoai ra, xem tep Docker Compose mau tai day (ban co the bo qua guacd va mang neu khong co y dinh su dung cac tinh nang dieu khien may tinh tu xa): ```yaml services: @@ -124,79 +250,108 @@ networks: driver: bridge ``` -# Nhà Tài Trợ +
-

- - DigitalOcean - -          - - Crowdin - -          - - Blacksmith - -          - - Cloudflare - -          - - TailScale - -          - - Akamai - -          - - AWS - -

+## Anh Chup Man Hinh -# Hỗ Trợ +
-Nếu bạn cần trợ giúp hoặc muốn yêu cầu tính năng với Termix, hãy truy cập trang [Vấn Đề](https://github.com/Termix-SSH/Support/issues), đăng nhập và nhấn `New Issue`. -Vui lòng mô tả vấn đề càng chi tiết càng tốt, ưu tiên viết bằng tiếng Anh. Bạn cũng có thể tham gia máy chủ [Discord](https://discord.gg/jVQGdvHDrf) và truy cập kênh hỗ trợ, tuy nhiên thời gian phản hồi có thể lâu hơn. - -# Ảnh Chụp Màn Hình +
[![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos) -

- Termix Demo 1 - Termix Demo 2 -

+Xem tong quan cap nhat tren YouTube -

- Termix Demo 3 - Termix Demo 4 -

+
+
-

- Termix Demo 5 - Termix Demo 6 -

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Termix Screenshot 1Termix Screenshot 2
Termix Screenshot 3Termix Screenshot 4
Termix Screenshot 5Termix Screenshot 6
Termix Screenshot 7Termix Screenshot 8
Termix Screenshot 9Termix Screenshot 10
Termix Screenshot 11Termix Screenshot 12
Termix Screenshot 13Termix Screenshot 14
-

- Termix Demo 7 - Termix Demo 8 -

+Mot so video va hinh anh co the da loi thoi hoac khong the hien chinh xac hoan toan cac tinh nang. -

- Termix Demo 9 - Termix Demo 10 -

+
-

- Termix Demo 11 - Termix Demo 12 -

+
-Một số video và hình ảnh có thể đã lỗi thời hoặc không thể hiện chính xác hoàn toàn các tính năng. +## Tinh Nang Du Kien -# Giấy Phép +Xem [Du An](https://github.com/orgs/Termix-SSH/projects/2) de biet tat ca cac tinh nang du kien. Neu ban muon dong gop, xem [Dong Gop](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). -Được phân phối theo Giấy Phép Apache Phiên Bản 2.0. Xem LICENSE để biết thêm thông tin. +
+ +## Nha Tai Tro + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + + +
+ +
+ +## Ho Tro + +Neu ban can tro giup hoac muon yeu cau tinh nang voi Termix, hay truy cap trang [Van De](https://github.com/Termix-SSH/Support/issues), dang nhap va nhan `New Issue`. Vui long mo ta van de cang chi tiet cang tot, uu tien viet bang tieng Anh. Ban cung co the tham gia may chu [Discord](https://discord.gg/jVQGdvHDrf) va truy cap kenh ho tro, tuy nhien thoi gian phan hoi co the lau hon. + +
+ +## Giay Phep + +Duoc phan phoi theo Giay Phep Apache Phien Ban 2.0. Xem `LICENSE` de biet them thong tin. diff --git a/repo-images/HeaderImage.png b/repo-images/HeaderImage.png deleted file mode 100644 index 74892b03..00000000 Binary files a/repo-images/HeaderImage.png and /dev/null differ diff --git a/repo-images/Image 1.png b/repo-images/Image 1.png index fea7eb76..0a31a317 100644 Binary files a/repo-images/Image 1.png and b/repo-images/Image 1.png differ diff --git a/repo-images/Image 10.png b/repo-images/Image 10.png index efc0c012..d3c8fc78 100644 Binary files a/repo-images/Image 10.png and b/repo-images/Image 10.png differ diff --git a/repo-images/Image 11.png b/repo-images/Image 11.png index 3db821d8..6c2894fa 100644 Binary files a/repo-images/Image 11.png and b/repo-images/Image 11.png differ diff --git a/repo-images/Image 12.png b/repo-images/Image 12.png index b79011b5..3ca62680 100644 Binary files a/repo-images/Image 12.png and b/repo-images/Image 12.png differ diff --git a/repo-images/Image 13.png b/repo-images/Image 13.png new file mode 100644 index 00000000..b611fc59 Binary files /dev/null and b/repo-images/Image 13.png differ diff --git a/repo-images/Image 14.png b/repo-images/Image 14.png new file mode 100644 index 00000000..a4779bc5 Binary files /dev/null and b/repo-images/Image 14.png differ diff --git a/repo-images/Image 2.png b/repo-images/Image 2.png index 9a47b331..56dd4bc2 100644 Binary files a/repo-images/Image 2.png and b/repo-images/Image 2.png differ diff --git a/repo-images/Image 3.png b/repo-images/Image 3.png index 4179a57a..d8bab622 100644 Binary files a/repo-images/Image 3.png and b/repo-images/Image 3.png differ diff --git a/repo-images/Image 4.png b/repo-images/Image 4.png index a47da14f..bb081907 100644 Binary files a/repo-images/Image 4.png and b/repo-images/Image 4.png differ diff --git a/repo-images/Image 5.png b/repo-images/Image 5.png index e44011e0..16470066 100644 Binary files a/repo-images/Image 5.png and b/repo-images/Image 5.png differ diff --git a/repo-images/Image 6.png b/repo-images/Image 6.png index f6f70ba1..c4bdc37a 100644 Binary files a/repo-images/Image 6.png and b/repo-images/Image 6.png differ diff --git a/repo-images/Image 7.png b/repo-images/Image 7.png index f77d750e..bd6c08e9 100644 Binary files a/repo-images/Image 7.png and b/repo-images/Image 7.png differ diff --git a/repo-images/Image 8.png b/repo-images/Image 8.png index 03bf9daf..d4d1ec38 100644 Binary files a/repo-images/Image 8.png and b/repo-images/Image 8.png differ diff --git a/repo-images/Image 9.png b/repo-images/Image 9.png index 28d9e70f..926710ad 100644 Binary files a/repo-images/Image 9.png and b/repo-images/Image 9.png differ diff --git a/repo-images/RepoOfTheDay.png b/repo-images/Repo of the Day.png similarity index 100% rename from repo-images/RepoOfTheDay.png rename to repo-images/Repo of the Day.png diff --git a/repo-images/Termix Header.png b/repo-images/Termix Header.png new file mode 100644 index 00000000..249637ea Binary files /dev/null and b/repo-images/Termix Header.png differ diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs new file mode 100644 index 00000000..fac5e9f1 --- /dev/null +++ b/scripts/generate-icons.mjs @@ -0,0 +1,133 @@ +import sharp from "sharp"; +import { readFileSync, writeFileSync, mkdirSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, ".."); +const publicDir = join(root, "public"); +const iconsDir = join(publicDir, "icons"); + +mkdirSync(iconsDir, { recursive: true }); + +const svgBuffer = readFileSync(join(publicDir, "icon.svg")); + +const pngSizes = [16, 24, 32, 48, 64, 128, 256, 512, 1024]; + +console.log("Generating PNG icons..."); +await Promise.all( + pngSizes.map((size) => + sharp(svgBuffer) + .resize(size, size) + .png() + .toFile(join(iconsDir, `${size}x${size}.png`)) + .then(() => console.log(` ✓ icons/${size}x${size}.png`)), + ), +); + +// icon.png (1024x1024) for Linux electron-builder +await sharp(svgBuffer) + .resize(1024, 1024) + .png() + .toFile(join(publicDir, "icon.png")); +console.log(" ✓ icon.png"); + +// icon-mac.png (512x512) for macOS +await sharp(svgBuffer) + .resize(512, 512) + .png() + .toFile(join(publicDir, "icon-mac.png")); +console.log(" ✓ icon-mac.png"); + +// full-icon.png (1024x1024) used in app UI +await sharp(svgBuffer) + .resize(1024, 1024) + .png() + .toFile(join(publicDir, "full-icon.png")); +console.log(" ✓ full-icon.png"); + +// favicon.ico — embed 16, 32, 48 px layers +console.log("Generating favicon.ico..."); +const icoSizes = [16, 32, 48]; +const icoBuffers = await Promise.all( + icoSizes.map((size) => sharp(svgBuffer).resize(size, size).png().toBuffer()), +); +writeFileSync(join(publicDir, "favicon.ico"), buildIco(icoBuffers, icoSizes)); +console.log(" ✓ favicon.ico"); + +// icon.ico — embed 16, 32, 48, 64, 128, 256 px layers +console.log("Generating icon.ico..."); +const winSizes = [16, 32, 48, 64, 128, 256]; +const winBuffers = await Promise.all( + winSizes.map((size) => sharp(svgBuffer).resize(size, size).png().toBuffer()), +); +writeFileSync(join(publicDir, "icon.ico"), buildIco(winBuffers, winSizes)); +console.log(" ✓ icon.ico"); + +// icons/icon.ico and icons/icon.icns placeholders (stubs pointing to source) +// electron-builder generates .icns; copy the 1024 PNG as icons/icon.png for reference +await sharp(svgBuffer) + .resize(1024, 1024) + .png() + .toFile(join(iconsDir, "icon.ico").replace("icon.ico", "1024x1024.png")); +// Copy icon.ico and icon.icns into icons/ as well +import { copyFileSync } from "fs"; +copyFileSync(join(publicDir, "icon.ico"), join(iconsDir, "icon.ico")); +console.log(" ✓ icons/icon.ico"); + +console.log( + "\nDone! Note: icon.icns requires macOS tools (iconutil). Use electron-builder on macOS to generate it.", +); + +/** + * Builds a minimal ICO file from PNG buffers. + * @param {Buffer[]} pngBuffers + * @param {number[]} sizes + * @returns {Buffer} + */ +function buildIco(pngBuffers, sizes) { + const count = pngBuffers.length; + const headerSize = 6; + const dirEntrySize = 16; + const dirSize = headerSize + count * dirEntrySize; + + let offset = dirSize; + const entries = pngBuffers.map((buf, i) => { + const size = sizes[i]; + const entry = { + size, + width: size > 255 ? 0 : size, + height: size > 255 ? 0 : size, + buf, + offset, + }; + offset += buf.length; + return entry; + }); + + const totalSize = offset; + const ico = Buffer.alloc(totalSize); + + // ICO header + ico.writeUInt16LE(0, 0); // reserved + ico.writeUInt16LE(1, 2); // type: 1 = ICO + ico.writeUInt16LE(count, 4); // image count + + // Directory entries + entries.forEach((e, i) => { + const base = headerSize + i * dirEntrySize; + ico.writeUInt8(e.width, base); // width (0 = 256) + ico.writeUInt8(e.height, base + 1); // height (0 = 256) + ico.writeUInt8(0, base + 2); // color count + ico.writeUInt8(0, base + 3); // reserved + ico.writeUInt16LE(1, base + 4); // planes + ico.writeUInt16LE(32, base + 6); // bit count + ico.writeUInt32LE(e.buf.length, base + 8); // size of image data + ico.writeUInt32LE(e.offset, base + 12); // offset of image data + }); + + // Image data + entries.forEach((e) => e.buf.copy(ico, e.offset)); + + return ico; +} diff --git a/scripts/patch-better-sqlite3.cjs b/scripts/patch-better-sqlite3.cjs new file mode 100644 index 00000000..b6ac303a --- /dev/null +++ b/scripts/patch-better-sqlite3.cjs @@ -0,0 +1,106 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const betterSqlite3Dir = path.join( + __dirname, + "..", + "node_modules", + "better-sqlite3", +); +const macrosPath = path.join(betterSqlite3Dir, "src", "util", "macros.cpp"); +const helpersPath = path.join(betterSqlite3Dir, "src", "util", "helpers.cpp"); +const entryPath = path.join(betterSqlite3Dir, "src", "better_sqlite3.cpp"); + +function patchFile(filePath, replacements) { + if (!fs.existsSync(filePath)) { + return false; + } + + let source = fs.readFileSync(filePath, "utf8"); + let changed = false; + + for (const { original, patched } of replacements) { + if (source.includes(patched)) { + continue; + } + if (!source.includes(original)) { + continue; + } + source = source.replace(original, patched); + changed = true; + } + + if (changed) { + fs.writeFileSync(filePath, source); + } + + return changed; +} + +if (!fs.existsSync(betterSqlite3Dir)) { + console.log("[patch-better-sqlite3] better-sqlite3 not found, skipping"); + process.exit(0); +} + +const macrosPatched = patchFile(macrosPath, [ + { + original: `#define OnlyContext isolate->GetCurrentContext() +#define OnlyAddon static_cast(info.Data().As()->Value()) +#define UseIsolate v8::Isolate* isolate = OnlyIsolate`, + patched: `#define OnlyContext isolate->GetCurrentContext() +#if defined(V8_MAJOR_VERSION) && V8_MAJOR_VERSION >= 14 +#define BETTER_SQLITE3_EXTERNAL_POINTER_TAG static_cast(0) +#define EXTERNAL_VALUE(external) ((external)->Value(BETTER_SQLITE3_EXTERNAL_POINTER_TAG)) +#define EXTERNAL_NEW(isolate, value) v8::External::New((isolate), (value), BETTER_SQLITE3_EXTERNAL_POINTER_TAG) +#else +#define EXTERNAL_VALUE(external) ((external)->Value()) +#define EXTERNAL_NEW(isolate, value) v8::External::New((isolate), (value)) +#endif +#define OnlyAddon static_cast(EXTERNAL_VALUE(info.Data().As())) +#define UseIsolate v8::Isolate* isolate = OnlyIsolate`, + }, +]); + +const helpersPatched = patchFile(helpersPath, [ + { + original: `\trecv->InstanceTemplate()->SetNativeDataProperty( +\t\tInternalizedFromLatin1(isolate, name), +\t\tfunc, +\t\t0, +\t\tdata +\t);`, + patched: `\trecv->InstanceTemplate()->SetNativeDataProperty( +\t\tInternalizedFromLatin1(isolate, name), +\t\tfunc, +\t\tstatic_cast(nullptr), +\t\tdata +\t);`, + }, +]); + +const entryPatched = patchFile(entryPath, [ + { + original: `#include +#include `, + patched: `#include +#if defined(_MSC_VER) && !defined(__clang__) && !defined(__builtin_frame_address) +#include +#define __builtin_frame_address(level) _AddressOfReturnAddress() +#endif +#include `, + }, + { + original: `\tv8::Local data = v8::External::New(isolate, addon);`, + patched: `\tv8::Local data = EXTERNAL_NEW(isolate, addon);`, + }, +]); + +if (macrosPatched || helpersPatched || entryPatched) { + console.log( + "[patch-better-sqlite3] Applied compatibility patches for newer Electron/V8", + ); +} else { + console.log( + "[patch-better-sqlite3] Already patched or target code not found", + ); +} diff --git a/scripts/patch-guacamole-lite.cjs b/scripts/patch-guacamole-lite.cjs new file mode 100644 index 00000000..e0a6704a --- /dev/null +++ b/scripts/patch-guacamole-lite.cjs @@ -0,0 +1,38 @@ +const fs = require("fs"); +const path = require("path"); + +const filePath = path.join( + __dirname, + "..", + "node_modules", + "guacamole-lite", + "lib", + "GuacdClient.js", +); + +if (!fs.existsSync(filePath)) { + console.log("[patch-guacamole-lite] File not found, skipping"); + process.exit(0); +} + +let content = fs.readFileSync(filePath, "utf8"); + +const oldCheck = "if (version === '1_0_0' || version === '1_1_0') {"; +const newCheck = + "if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {"; + +if (content.includes(newCheck)) { + console.log("[patch-guacamole-lite] Already patched"); + process.exit(0); +} + +if (!content.includes(oldCheck)) { + console.log("[patch-guacamole-lite] Target code not found, skipping"); + process.exit(0); +} + +content = content.replace(oldCheck, newCheck); +fs.writeFileSync(filePath, content); +console.log( + "[patch-guacamole-lite] Patched to support protocol VERSION_1_3_0 and VERSION_1_5_0", +); diff --git a/scripts/patch-nan.cjs b/scripts/patch-nan.cjs new file mode 100644 index 00000000..5fec17f9 --- /dev/null +++ b/scripts/patch-nan.cjs @@ -0,0 +1,121 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const nanDir = path.join(__dirname, "..", "node_modules", "nan"); + +if (!fs.existsSync(nanDir)) { + console.log("[patch-nan] nan not found, skipping"); + process.exit(0); +} + +function patchFile(filePath, replacements) { + if (!fs.existsSync(filePath)) return false; + + let source = fs.readFileSync(filePath, "utf8"); + let changed = false; + + for (const { original, patched } of replacements) { + if (source.includes(patched)) continue; + if (!source.includes(original)) continue; + source = source.replace(original, patched); + changed = true; + } + + if (changed) fs.writeFileSync(filePath, source); + return changed; +} + +// 1. nan.h: inject MSVC __builtin_frame_address compat before node.h is included. +const nanHeaderPatched = patchFile(path.join(nanDir, "nan.h"), [ + { + original: `#include + +#define NODE_0_10_MODULE_VERSION 11`, + patched: `#include + +// MSVC lacks __builtin_frame_address; cppgc/heap.h (pulled by node.h) uses it. +#if defined(_MSC_VER) && !defined(__clang__) && !defined(__builtin_frame_address) +# include +# define __builtin_frame_address(level) _AddressOfReturnAddress() +#endif + +#define NODE_0_10_MODULE_VERSION 11`, + }, +]); + +// cpu-features binding.cc includes before , so the nan.h patch +// above is too late. Patch binding.cc directly to inject the compat define first. +const cpuFeaturesDir = path.join( + __dirname, + "..", + "node_modules", + "cpu-features", +); +const bindingPath = path.join(cpuFeaturesDir, "src", "binding.cc"); +const bindingPatched = patchFile(bindingPath, [ + { + original: `#include `, + patched: `#if defined(_MSC_VER) && !defined(__clang__) && !defined(__builtin_frame_address) +#include +#define __builtin_frame_address(level) _AddressOfReturnAddress() +#endif +#include `, + }, +]); + +// 2. nan_implementation_12_inl.h: replace v8::External::New() with the 3-arg form. +// Electron 42 / V8 13+ requires an ExternalPointerTypeTag as the third argument. +const implPath = path.join(nanDir, "nan_implementation_12_inl.h"); +let implPatched = false; +if (fs.existsSync(implPath)) { + let src = fs.readFileSync(implPath, "utf8"); + const before = src; + + const TAG = "static_cast(0)"; + if (!src.includes(TAG)) { + src = src.replace( + /v8::External::New\(v8::Isolate::GetCurrent\(\),\s*value\)/g, + `v8::External::New(v8::Isolate::GetCurrent(), value, ${TAG})`, + ); + src = src.replace( + /v8::External::New\(isolate,\s*reinterpret_cast\(callback\)\)/g, + `v8::External::New(isolate, reinterpret_cast(callback), ${TAG})`, + ); + } + + if (src !== before) { + fs.writeFileSync(implPath, src); + implPatched = true; + } +} + +// 3. nan_callbacks_12_inl.h: replace ->Value() with ->Value(tag) on v8::External. +// The new API requires an ExternalPointerTypeTag argument. +const callbacksPath = path.join(nanDir, "nan_callbacks_12_inl.h"); +let callbacksPatched = false; +if (fs.existsSync(callbacksPath)) { + let src = fs.readFileSync(callbacksPath, "utf8"); + const before = src; + + const TAG = "static_cast(0)"; + if (!src.includes(TAG)) { + // Pattern: .As()->Value()) — always followed by )) + src = src.replace( + /\.As\(\)->Value\(\)\)/g, + `.As()->Value(${TAG}))`, + ); + } + + if (src !== before) { + fs.writeFileSync(callbacksPath, src); + callbacksPatched = true; + } +} + +if (nanHeaderPatched || bindingPatched || implPatched || callbacksPatched) { + console.log( + "[patch-nan] Applied compatibility patches for Electron 42 / V8 13+", + ); +} else { + console.log("[patch-nan] Already patched or target code not found"); +} diff --git a/src/backend/dashboard.ts b/src/backend/dashboard.ts index f8963b7c..77314262 100644 --- a/src/backend/dashboard.ts +++ b/src/backend/dashboard.ts @@ -470,7 +470,7 @@ app.post("/dashboard/preferences", async (req, res) => { }); } - const { cards } = req.body; + const { cards, mainWidthPct } = req.body; if (!cards || !Array.isArray(cards)) { return res.status(400).json({ @@ -478,7 +478,11 @@ app.post("/dashboard/preferences", async (req, res) => { }); } - const layout = JSON.stringify({ cards }); + const layoutObj: Record = { cards }; + if (typeof mainWidthPct === "number") { + layoutObj.mainWidthPct = mainWidthPct; + } + const layout = JSON.stringify(layoutObj); const existing = await getDb() .select() diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index a0e95ece..0c1ec5f7 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -1251,6 +1251,7 @@ app.post( }; try { + mainDb.$client.exec("PRAGMA foreign_keys = OFF"); try { const importedHosts = importDb .prepare("SELECT * FROM ssh_data") @@ -1561,6 +1562,7 @@ app.post( ); } + mainDb.$client.exec("PRAGMA foreign_keys = ON"); result.success = true; try { @@ -2019,12 +2021,15 @@ httpServer.on("error", (err: NodeJS.ErrnoException) => { throw err; }); -httpServer.listen(HTTP_PORT, async () => { - if (!fs.existsSync(uploadsDir)) { - fs.mkdirSync(uploadsDir, { recursive: true }); - } +export const serverReady = new Promise((resolve) => { + httpServer.listen(HTTP_PORT, async () => { + if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); + } - await initializeSecurity(); + await initializeSecurity(); + resolve(); + }); }); const sslConfig = AutoSSLSetup.getSSLConfig(); diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 5acce909..d39dadad 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -710,6 +710,8 @@ const migrateSchema = () => { addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT"); + addColumnIfNotExists("ssh_credentials", "cert_public_key", "TEXT"); + addColumnIfNotExists("ssh_credentials", "system_password", "TEXT"); addColumnIfNotExists("ssh_credentials", "system_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "system_key_password", "TEXT"); @@ -782,6 +784,7 @@ const migrateSchema = () => { addColumnIfNotExists("snippets", "folder", "TEXT"); addColumnIfNotExists("snippets", "order", "INTEGER NOT NULL DEFAULT 0"); + addColumnIfNotExists("snippets", "host_filter", "TEXT"); try { sqlite @@ -1007,6 +1010,19 @@ const migrateSchema = () => { } } + try { + sqlite.prepare("SELECT override_credential_id FROM host_access LIMIT 1").get(); + } catch { + try { + sqlite.exec("ALTER TABLE host_access ADD COLUMN override_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL"); + } catch (alterError) { + databaseLogger.warn("Failed to add override_credential_id column", { + operation: "schema_migration", + error: alterError, + }); + } + } + try { sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get(); } catch { @@ -1044,6 +1060,23 @@ const migrateSchema = () => { { column: "host_key_changed_count", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_changed_count INTEGER NOT NULL DEFAULT 0" }, { column: "mac_address", sql: "ALTER TABLE ssh_data ADD COLUMN mac_address TEXT" }, { column: "port_knock_sequence", sql: "ALTER TABLE ssh_data ADD COLUMN port_knock_sequence TEXT" }, + { column: "enable_ssh", sql: "ALTER TABLE ssh_data ADD COLUMN enable_ssh INTEGER NOT NULL DEFAULT 1" }, + { column: "enable_rdp", sql: "ALTER TABLE ssh_data ADD COLUMN enable_rdp INTEGER NOT NULL DEFAULT 0" }, + { column: "enable_vnc", sql: "ALTER TABLE ssh_data ADD COLUMN enable_vnc INTEGER NOT NULL DEFAULT 0" }, + { column: "enable_telnet", sql: "ALTER TABLE ssh_data ADD COLUMN enable_telnet INTEGER NOT NULL DEFAULT 0" }, + { column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" }, + { column: "rdp_port", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_port INTEGER DEFAULT 3389" }, + { column: "vnc_port", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_port INTEGER DEFAULT 5900" }, + { column: "telnet_port", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_port INTEGER DEFAULT 23" }, + { column: "rdp_user", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_user TEXT" }, + { column: "rdp_password", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_password TEXT" }, + { column: "rdp_domain", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_domain TEXT" }, + { column: "rdp_security", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_security TEXT" }, + { column: "rdp_ignore_cert", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_ignore_cert INTEGER DEFAULT 0" }, + { column: "vnc_password", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_password TEXT" }, + { column: "vnc_user", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_user TEXT" }, + { column: "telnet_user", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_user TEXT" }, + { column: "telnet_password", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_password TEXT" }, ]; for (const migration of sshDataMigrations) { @@ -1061,6 +1094,39 @@ const migrateSchema = () => { } } + // Copy unencrypted username/domain into protocol-specific columns for old guac hosts. + // Passwords are handled via the legacy field name fallback in lazy-field-encryption.ts. + const usernameDomainBackfills = [ + { + protocol: "rdp", + sql: "UPDATE ssh_data SET rdp_user = username, rdp_password = password, rdp_domain = domain WHERE connection_type = 'rdp' AND rdp_user IS NULL AND rdp_password IS NULL", + }, + { + protocol: "vnc", + sql: "UPDATE ssh_data SET vnc_user = username, vnc_password = password WHERE connection_type = 'vnc' AND vnc_user IS NULL AND vnc_password IS NULL", + }, + { + protocol: "telnet", + sql: "UPDATE ssh_data SET telnet_user = username, telnet_password = password WHERE connection_type = 'telnet' AND telnet_user IS NULL AND telnet_password IS NULL", + }, + ]; + for (const backfill of usernameDomainBackfills) { + try { + const result = sqlite.prepare(backfill.sql).run(); + if (result.changes > 0) { + databaseLogger.info( + `Backfilled ${result.changes} ${backfill.protocol} host credential(s)`, + { operation: "guac_credential_backfill" }, + ); + } + } catch (e) { + databaseLogger.warn(`Failed to backfill ${backfill.protocol} host credentials`, { + operation: "guac_credential_backfill", + error: e, + }); + } + } + try { sqlite.prepare("SELECT id FROM roles LIMIT 1").get(); } catch { diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 0e0d89d9..b96a2441 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -129,6 +129,28 @@ export const hosts = sqliteTable("ssh_data", { terminalConfig: text("terminal_config"), quickActions: text("quick_actions"), notes: text("notes"), + enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true), + enableRdp: integer("enable_rdp", { mode: "boolean" }).notNull().default(false), + enableVnc: integer("enable_vnc", { mode: "boolean" }).notNull().default(false), + enableTelnet: integer("enable_telnet", { mode: "boolean" }).notNull().default(false), + + sshPort: integer("ssh_port").default(22), + rdpPort: integer("rdp_port").default(3389), + vncPort: integer("vnc_port").default(5900), + telnetPort: integer("telnet_port").default(23), + + rdpUser: text("rdp_user"), + rdpPassword: text("rdp_password"), + rdpDomain: text("rdp_domain"), + rdpSecurity: text("rdp_security"), + rdpIgnoreCert: integer("rdp_ignore_cert", { mode: "boolean" }).default(false), + + vncPassword: text("vnc_password"), + vncUser: text("vnc_user"), + + telnetUser: text("telnet_user"), + telnetPassword: text("telnet_password"), + domain: text("domain"), security: text("security"), ignoreCert: integer("ignore_cert", { mode: "boolean" }).default(false), @@ -234,6 +256,8 @@ export const sshCredentials = sqliteTable("ssh_credentials", { keyType: text("key_type"), detectedKeyType: text("detected_key_type"), + certPublicKey: text("cert_public_key", { length: 8192 }), + systemPassword: text("system_password"), systemKey: text("system_key", { length: 16384 }), systemKeyPassword: text("system_key_password"), @@ -280,6 +304,7 @@ export const snippets = sqliteTable("snippets", { updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), + hostFilter: text("host_filter"), }); export const snippetFolders = sqliteTable("snippet_folders", { @@ -439,6 +464,10 @@ export const hostAccess = sqliteTable("host_access", { .default(sql`CURRENT_TIMESTAMP`), lastAccessedAt: text("last_accessed_at"), accessCount: integer("access_count").notNull().default(0), + overrideCredentialId: integer("override_credential_id").references( + () => sshCredentials.id, + { onDelete: "set null" }, + ), }); export const sharedCredentials = sqliteTable("shared_credentials", { diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index 58e13c97..a04fbdc7 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -147,6 +147,7 @@ router.post( key, keyPassword, keyType, + certPublicKey, } = req.body; if (!isNonEmptyString(userId) || !isNonEmptyString(name)) { @@ -230,6 +231,8 @@ router.post( keyPassword: plainKeyPassword, keyType: keyType || null, detectedKeyType: keyInfo?.keyType || null, + certPublicKey: + authType === "key" && certPublicKey ? certPublicKey.trim() : null, usageCount: 0, lastUsed: null, }; @@ -431,15 +434,14 @@ router.get( if (credential.password) { output.password = credential.password; } - if (credential.key) { - output.key = credential.key; - } - if (credential.privateKey) { - output.privateKey = credential.privateKey; - } + output.hasKey = !!credential.key; + output.hasKeyPassword = !!credential.keyPassword; if (credential.publicKey) { output.publicKey = credential.publicKey; } + if (credential.certPublicKey) { + output.certPublicKey = credential.certPublicKey; + } if (credential.keyPassword) { output.keyPassword = credential.keyPassword; } @@ -572,6 +574,9 @@ router.put( if (updateData.keyPassword !== undefined) { updateFields.keyPassword = updateData.keyPassword || null; } + if (updateData.certPublicKey !== undefined) { + updateFields.certPublicKey = updateData.certPublicKey?.trim() || null; + } if (Object.keys(updateFields).length === 0) { const existing = await SimpleDBOps.select( @@ -947,6 +952,7 @@ function formatCredentialOutput( authType: credential.authType, username: credential.username || null, publicKey: credential.publicKey, + hasCertPublicKey: !!credential.certPublicKey, keyType: credential.keyType, detectedKeyType: credential.detectedKeyType, usageCount: credential.usageCount || 0, @@ -1445,7 +1451,7 @@ router.post( const publicKeyString = typeof publicKeyPem === "string" ? publicKeyPem - : publicKeyPem.toString("utf8"); + : (publicKeyPem as Buffer).toString("utf8"); let keyType = "unknown"; const asymmetricKeyType = privateKeyObj.asymmetricKeyType; @@ -1617,9 +1623,12 @@ async function deploySSHKeyToHost( const escapedKey = actualPublicKey .replace(/\\/g, "\\\\") .replace(/'/g, "'\\''"); + const escapedName = credData.name + .replace(/\\/g, "\\\\") + .replace(/'/g, "'\\''"); conn.exec( - `printf '%s\n' '${escapedKey} ${credData.name}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, + `printf '%s\n' '${escapedKey} ${escapedName}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, (err, stream) => { if (err) { clearTimeout(addTimeout); diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 5a5b371c..a758dbba 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -43,6 +43,8 @@ const router = express.Router(); const upload = multer({ storage: multer.memoryStorage() }); +const STATS_SERVER_URL = "http://localhost:30005"; + function notifyStatsHostUpdated( hostId: number, headers: Pick, @@ -50,7 +52,7 @@ function notifyStatsHostUpdated( ): void { axios .post( - "http://localhost:30005/host-updated", + `${STATS_SERVER_URL}/host-updated`, { hostId }, { headers: { @@ -244,23 +246,18 @@ function normalizeImportedHost( } const SENSITIVE_FIELDS = [ - "password", "key", "keyPassword", - "sudoPassword", - "autostartPassword", "autostartKey", "autostartKeyPassword", - "socks5Password", ]; function stripSensitiveFields( host: Record, ): Record { const result = { ...host }; - result.hasPassword = !!host.password; result.hasKey = !!host.key; - result.hasSudoPassword = !!host.sudoPassword; + result.hasKeyPassword = !!host.keyPassword; for (const field of SENSITIVE_FIELDS) { delete result[field]; } @@ -288,6 +285,33 @@ function transformHostResponse( showTunnelInSidebar: !!host.showTunnelInSidebar, showDockerInSidebar: !!host.showDockerInSidebar, showServerStatsInSidebar: !!host.showServerStatsInSidebar, + // Old hosts only had connection_type set; the per-protocol enable flags didn't exist yet. + // The schema defaults (enableSsh=true, others=false) wrongly mark every old host as SSH. + // Detect this migration case: if no non-SSH protocol is explicitly enabled AND + // connectionType is set to a non-SSH value, fall back to inferring from connectionType. + ...(() => { + const ct = host.connectionType; + const rdp = !!host.enableRdp; + const vnc = !!host.enableVnc; + const tel = !!host.enableTelnet; + const isMigratedNonSsh = !rdp && !vnc && !tel && ct && ct !== "ssh"; + return { + enableSsh: isMigratedNonSsh ? false : !!host.enableSsh, + enableRdp: isMigratedNonSsh ? ct === "rdp" : rdp, + enableVnc: isMigratedNonSsh ? ct === "vnc" : vnc, + enableTelnet: isMigratedNonSsh ? ct === "telnet" : tel, + }; + })(), + sshPort: host.sshPort ?? host.port ?? 22, + rdpPort: host.rdpPort ?? 3389, + vncPort: host.vncPort ?? 5900, + telnetPort: host.telnetPort ?? 23, + rdpUser: host.rdpUser || undefined, + rdpDomain: host.rdpDomain || undefined, + rdpSecurity: host.rdpSecurity || undefined, + rdpIgnoreCert: !!host.rdpIgnoreCert, + vncUser: host.vncUser || undefined, + telnetUser: host.telnetUser || undefined, tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections as string) : [], @@ -593,6 +617,23 @@ router.post( portKnockSequence, overrideCredentialUsername, macAddress, + enableSsh, + enableRdp, + enableVnc, + enableTelnet, + sshPort, + rdpPort, + vncPort, + telnetPort, + rdpUser, + rdpPassword, + rdpDomain, + rdpSecurity, + rdpIgnoreCert, + vncPassword, + vncUser, + telnetUser, + telnetPassword, } = hostData; databaseLogger.info("Creating SSH host", { operation: "host_create", @@ -621,15 +662,19 @@ router.post( authType || authMethod || (effectiveConnectionType !== "ssh" ? "password" : undefined); + const effectiveUsername = + username || rdpUser || vncUser || telnetUser || ""; + const effectiveName = + name || (effectiveUsername ? `${effectiveUsername}@${ip}` : String(ip)); const sshDataObj: Record = { userId: userId, connectionType: effectiveConnectionType, - name, + name: effectiveName, folder: folder || null, tags: Array.isArray(tags) ? tags.join(",") : tags || "", ip, port, - username, + username: effectiveUsername, authType: effectiveAuthType, credentialId: credentialId || null, overrideCredentialUsername: overrideCredentialUsername ? 1 : 0, @@ -685,6 +730,20 @@ router.post( portKnockSequence: portKnockSequence ? JSON.stringify(portKnockSequence) : null, + enableSsh: enableSsh ? 1 : 0, + enableRdp: enableRdp ? 1 : 0, + enableVnc: enableVnc ? 1 : 0, + enableTelnet: enableTelnet ? 1 : 0, + sshPort: sshPort || port || 22, + rdpPort: rdpPort || 3389, + vncPort: vncPort || 5900, + telnetPort: telnetPort || 23, + rdpUser: rdpUser || null, + rdpDomain: rdpDomain || null, + rdpSecurity: rdpSecurity || null, + rdpIgnoreCert: rdpIgnoreCert ? 1 : 0, + vncUser: vncUser || null, + telnetUser: telnetUser || null, }; // For non-SSH hosts (RDP, VNC, Telnet), always save password if provided @@ -743,6 +802,10 @@ router.post( sshDataObj.keyType = null; } + sshDataObj.rdpPassword = rdpPassword || null; + sshDataObj.vncPassword = vncPassword || null; + sshDataObj.telnetPassword = telnetPassword || null; + try { const result = await SimpleDBOps.insert( hosts, @@ -1092,6 +1155,23 @@ router.put( portKnockSequence, overrideCredentialUsername, macAddress, + enableSsh, + enableRdp, + enableVnc, + enableTelnet, + sshPort, + rdpPort, + vncPort, + telnetPort, + rdpUser, + rdpPassword, + rdpDomain, + rdpSecurity, + rdpIgnoreCert, + vncPassword, + vncUser, + telnetUser, + telnetPassword, } = hostData; databaseLogger.info("Updating SSH host", { operation: "host_update", @@ -1118,14 +1198,18 @@ router.put( } const effectiveAuthType = authType || authMethod; + const effectiveUsername = + username || rdpUser || vncUser || telnetUser || ""; + const effectiveName = + name || (effectiveUsername ? `${effectiveUsername}@${ip}` : String(ip)); const sshDataObj: Record = { connectionType: connectionType || "ssh", - name, + name: effectiveName, folder, tags: Array.isArray(tags) ? tags.join(",") : tags || "", ip, port, - username, + username: effectiveUsername, authType: effectiveAuthType, credentialId: credentialId || null, overrideCredentialUsername: overrideCredentialUsername ? 1 : 0, @@ -1181,6 +1265,20 @@ router.put( portKnockSequence: portKnockSequence ? JSON.stringify(portKnockSequence) : null, + enableSsh: enableSsh ? 1 : 0, + enableRdp: enableRdp ? 1 : 0, + enableVnc: enableVnc ? 1 : 0, + enableTelnet: enableTelnet ? 1 : 0, + sshPort: sshPort || port || 22, + rdpPort: rdpPort || 3389, + vncPort: vncPort || 5900, + telnetPort: telnetPort || 23, + rdpUser: rdpUser || null, + rdpDomain: rdpDomain || null, + rdpSecurity: rdpSecurity || null, + rdpIgnoreCert: rdpIgnoreCert ? 1 : 0, + vncUser: vncUser || null, + telnetUser: telnetUser || null, }; // For non-SSH hosts (RDP, VNC, Telnet), always save password if provided @@ -1249,6 +1347,11 @@ router.put( sshDataObj.keyType = null; } + if (rdpPassword !== undefined) sshDataObj.rdpPassword = rdpPassword || null; + if (vncPassword !== undefined) sshDataObj.vncPassword = vncPassword || null; + if (telnetPassword !== undefined) + sshDataObj.telnetPassword = telnetPassword || null; + try { const accessInfo = await permissionManager.canAccessHost( userId, @@ -1475,6 +1578,23 @@ router.get( guacamoleConfig: hosts.guacamoleConfig, macAddress: hosts.macAddress, dockerConfig: hosts.dockerConfig, + enableSsh: hosts.enableSsh, + enableRdp: hosts.enableRdp, + enableVnc: hosts.enableVnc, + enableTelnet: hosts.enableTelnet, + sshPort: hosts.sshPort, + rdpPort: hosts.rdpPort, + vncPort: hosts.vncPort, + telnetPort: hosts.telnetPort, + rdpUser: hosts.rdpUser, + rdpPassword: hosts.rdpPassword, + rdpDomain: hosts.rdpDomain, + rdpSecurity: hosts.rdpSecurity, + rdpIgnoreCert: hosts.rdpIgnoreCert, + vncUser: hosts.vncUser, + vncPassword: hosts.vncPassword, + telnetUser: hosts.telnetUser, + telnetPassword: hosts.telnetPassword, ownerId: hosts.userId, isShared: sql`${hostAccess.id} IS NOT NULL AND ${hosts.userId} != ${userId}`, @@ -1800,9 +1920,21 @@ router.get( const exportData = isRemoteDesktop ? { ...baseExportData, - domain: resolvedHost.domain || null, - security: resolvedHost.security || null, - ignoreCert: !!resolvedHost.ignoreCert, + enableRdp: !!resolvedHost.enableRdp, + enableVnc: !!resolvedHost.enableVnc, + enableTelnet: !!resolvedHost.enableTelnet, + rdpPort: resolvedHost.rdpPort || 3389, + vncPort: resolvedHost.vncPort || 5900, + telnetPort: resolvedHost.telnetPort || 23, + rdpUser: resolvedHost.rdpUser || null, + rdpPassword: resolvedHost.rdpPassword || null, + rdpDomain: resolvedHost.rdpDomain || null, + rdpSecurity: resolvedHost.rdpSecurity || null, + rdpIgnoreCert: !!resolvedHost.rdpIgnoreCert, + vncUser: resolvedHost.vncUser || null, + vncPassword: resolvedHost.vncPassword || null, + telnetUser: resolvedHost.telnetUser || null, + telnetPassword: resolvedHost.telnetPassword || null, guacamoleConfig: resolvedHost.guacamoleConfig ? JSON.parse(resolvedHost.guacamoleConfig as string) : null, @@ -2126,9 +2258,8 @@ router.delete( try { const axios = (await import("axios")).default; - const statsPort = 30005; await axios.post( - `http://localhost:${statsPort}/host-deleted`, + `${STATS_SERVER_URL}/host-deleted`, { hostId: numericHostId }, { headers: { @@ -3303,11 +3434,10 @@ router.delete( try { const axios = (await import("axios")).default; - const statsPort = 30005; for (const host of hostsToDelete) { try { await axios.post( - `http://localhost:${statsPort}/host-deleted`, + `${STATS_SERVER_URL}/host-deleted`, { hostId: host.id }, { headers: { @@ -3713,6 +3843,10 @@ router.post( overrideCredentialUsername: hostData.overrideCredentialUsername ? 1 : 0, + enableSsh: hostData.enableSsh ?? false, + enableRdp: hostData.enableRdp ?? false, + enableVnc: hostData.enableVnc ?? false, + enableTelnet: hostData.enableTelnet ?? false, updatedAt: new Date().toISOString(), }; @@ -3723,9 +3857,21 @@ router.post( sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; - sshDataObj.domain = hostData.domain || null; - sshDataObj.security = hostData.security || null; - sshDataObj.ignoreCert = hostData.ignoreCert ? 1 : 0; + sshDataObj.rdpUser = hostData.rdpUser || null; + sshDataObj.rdpPassword = hostData.rdpPassword || null; + sshDataObj.rdpDomain = hostData.rdpDomain || null; + sshDataObj.rdpSecurity = hostData.rdpSecurity || null; + sshDataObj.rdpIgnoreCert = hostData.rdpIgnoreCert ? 1 : 0; + sshDataObj.rdpPort = hostData.rdpPort || 3389; + sshDataObj.vncUser = hostData.vncUser || null; + sshDataObj.vncPassword = hostData.vncPassword || null; + sshDataObj.vncPort = hostData.vncPort || 5900; + sshDataObj.telnetUser = hostData.telnetUser || null; + sshDataObj.telnetPassword = hostData.telnetPassword || null; + sshDataObj.telnetPort = hostData.telnetPort || 23; + sshDataObj.enableRdp = hostData.enableRdp ? 1 : 0; + sshDataObj.enableVnc = hostData.enableVnc ? 1 : 0; + sshDataObj.enableTelnet = hostData.enableTelnet ? 1 : 0; sshDataObj.guacamoleConfig = hostData.guacamoleConfig ? JSON.stringify(hostData.guacamoleConfig) : null; diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index 4d4eda7b..c7a7ca8b 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -10,6 +10,7 @@ import { sharedCredentials, snippets, snippetAccess, + sshCredentials, } from "../db/schema.js"; import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm"; import type { Response } from "express"; @@ -128,10 +129,10 @@ router.post( return res.status(403).json({ error: "Not host owner" }); } - if (!host[0].credentialId) { + if (!host[0].credentialId && host[0].authType !== "opkssh") { return res.status(400).json({ error: - "Only hosts using credentials can be shared. Please create a credential and assign it to this host before sharing.", + "Only hosts using credentials or OPKSSH can be shared. Please create a credential and assign it to this host before sharing.", code: "CREDENTIAL_REQUIRED_FOR_SHARING", }); } @@ -203,23 +204,25 @@ router.post( .delete(sharedCredentials) .where(eq(sharedCredentials.hostAccessId, existing[0].id)); - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - if (targetType === "user") { - await sharedCredManager.createSharedCredentialForUser( - existing[0].id, - host[0].credentialId, - targetUserId!, - userId, - ); - } else { - await sharedCredManager.createSharedCredentialsForRole( - existing[0].id, - host[0].credentialId, - targetRoleId!, - userId, - ); + if (host[0].credentialId) { + const { SharedCredentialManager } = + await import("../../utils/shared-credential-manager.js"); + const sharedCredManager = SharedCredentialManager.getInstance(); + if (targetType === "user") { + await sharedCredManager.createSharedCredentialForUser( + existing[0].id, + host[0].credentialId, + targetUserId!, + userId, + ); + } else { + await sharedCredManager.createSharedCredentialsForRole( + existing[0].id, + host[0].credentialId, + targetRoleId!, + userId, + ); + } } databaseLogger.info("Permission granted", { operation: "rbac_permission_grant", @@ -249,20 +252,22 @@ router.post( await import("../../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); - if (targetType === "user") { - await sharedCredManager.createSharedCredentialForUser( - result.lastInsertRowid as number, - host[0].credentialId, - targetUserId!, - userId, - ); - } else { - await sharedCredManager.createSharedCredentialsForRole( - result.lastInsertRowid as number, - host[0].credentialId, - targetRoleId!, - userId, - ); + if (host[0].credentialId) { + if (targetType === "user") { + await sharedCredManager.createSharedCredentialForUser( + result.lastInsertRowid as number, + host[0].credentialId, + targetUserId!, + userId, + ); + } else { + await sharedCredManager.createSharedCredentialsForRole( + result.lastInsertRowid as number, + host[0].credentialId, + targetRoleId!, + userId, + ); + } } databaseLogger.success("Host shared successfully", { operation: "rbac_host_share_success", @@ -1520,4 +1525,58 @@ router.get( }, ); +router.put( + "/host-access/:hostId/credential", + async (req: express.Request, res: express.Response) => { + try { + const userId = (req as AuthenticatedRequest).userId!; + const hostId = Number.parseInt(String(req.params.hostId), 10); + const { credentialId } = req.body; + + if (!hostId || isNaN(hostId)) { + return res.status(400).json({ error: "Invalid host ID" }); + } + + const access = await db + .select() + .from(hostAccess) + .where( + and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)), + ) + .limit(1); + + if (access.length === 0) { + return res.status(403).json({ error: "No access to this host" }); + } + + if (credentialId) { + const cred = await db + .select({ id: sshCredentials.id }) + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ) + .limit(1); + + if (cred.length === 0) { + return res.status(404).json({ error: "Credential not found" }); + } + } + + await db + .update(hostAccess) + .set({ overrideCredentialId: credentialId || null }) + .where(eq(hostAccess.id, access[0].id)); + + res.json({ success: true }); + } catch (error) { + databaseLogger.error("Failed to set override credential", error); + res.status(500).json({ error: "Failed to update credential" }); + } + }, +); + export default router; diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts index 306c9488..f09a40b3 100644 --- a/src/backend/database/routes/snippets.ts +++ b/src/backend/database/routes/snippets.ts @@ -93,6 +93,7 @@ async function getAccessibleSnippet(snippetId: number, userId: string) { order: snippets.order, createdAt: snippets.createdAt, updatedAt: snippets.updatedAt, + hostFilter: snippets.hostFilter, }) .from(snippetAccess) .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) @@ -1104,7 +1105,7 @@ router.post( requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; - const { name, content, description, folder, order } = req.body; + const { name, content, description, folder, order, hostFilter } = req.body; if ( !isNonEmptyString(userId) || @@ -1146,6 +1147,7 @@ router.post( description: description?.trim() || null, folder: folder?.trim() || null, order: snippetOrder, + hostFilter: hostFilter ? JSON.stringify(hostFilter) : null, }; const result = await db.insert(snippets).values(insertData).returning(); @@ -1238,6 +1240,7 @@ router.put( description: string | null; folder: string | null; order: number; + hostFilter: string | null; }> = { updatedAt: sql`CURRENT_TIMESTAMP`, }; @@ -1251,6 +1254,10 @@ router.put( if (updateData.folder !== undefined) updateFields.folder = updateData.folder?.trim() || null; if (updateData.order !== undefined) updateFields.order = updateData.order; + if (updateData.hostFilter !== undefined) + updateFields.hostFilter = updateData.hostFilter + ? JSON.stringify(updateData.hostFilter) + : null; await db .update(snippets) diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index cb4912cc..965170ba 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -521,6 +521,7 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => { name_path, scopes, allowed_users, + admin_group, } = req.body; const isDisableRequest = @@ -579,6 +580,7 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => { name_path, scopes: scopes || "openid email profile", allowed_users: allowed_users || "", + admin_group: admin_group || "", }; let encryptedConfig; @@ -817,7 +819,7 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => { */ router.get("/oidc/authorize", async (req, res) => { try { - const { rememberMe } = req.query; + const { rememberMe, desktopCallbackPort } = req.query; const origin = getRequestOriginWithForceHTTPS(req); const backendCallbackUri = `${origin}/users/oidc/callback`; @@ -840,7 +842,9 @@ router.get("/oidc/authorize", async (req, res) => { const referer = req.get("Referer"); let frontendOrigin; - if (referer) { + if (desktopCallbackPort) { + frontendOrigin = `http://127.0.0.1:${desktopCallbackPort}/oidc-callback`; + } else if (referer) { const refererUrl = new URL(referer); frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`; } else { @@ -949,6 +953,18 @@ router.get("/oidc/callback", async (req, res) => { config = JSON.parse( (configRow as Record).value as string, ); + + if (config.client_secret?.startsWith("encrypted:")) { + config.client_secret = Buffer.from( + config.client_secret.substring(10), + "base64", + ).toString("utf8"); + } else if (config.client_secret?.startsWith("encoded:")) { + config.client_secret = Buffer.from( + config.client_secret.substring(8), + "base64", + ).toString("utf8"); + } } const tokenResponse = await fetch(config.token_url, { @@ -1150,11 +1166,28 @@ router.get("/oidc/callback", async (req, res) => { } } - const oidcAllowRegistration = - (process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() === - "true"; + let oidcAutoProvision = false; + try { + const oidcProvRow = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'oidc_auto_provision'", + ) + .get(); + if (oidcProvRow) { + oidcAutoProvision = + (oidcProvRow as Record).value === "true"; + } + } catch { + // fall through to env var check + } - if (!isFirstUser && !oidcAllowRegistration) { + if (!oidcAutoProvision) { + oidcAutoProvision = + (process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() === + "true"; + } + + if (!isFirstUser && !oidcAutoProvision) { try { const regRow = db.$client .prepare( @@ -1300,8 +1333,26 @@ router.get("/oidc/callback", async (req, res) => { const userRecord = user[0]; + // Sync admin status based on OIDC group membership + if (config.admin_group) { + const groups = (userInfo.groups || userInfo.roles || []) as string[]; + const shouldBeAdmin = groups.includes(config.admin_group); + if (!!userRecord.isAdmin !== shouldBeAdmin) { + await db + .update(users) + .set({ isAdmin: shouldBeAdmin }) + .where(eq(users.id, userRecord.id)); + userRecord.isAdmin = shouldBeAdmin; + authLogger.info("OIDC admin status synced", { + operation: "oidc_admin_group_sync", + userId: userRecord.id, + group: config.admin_group, + isAdmin: shouldBeAdmin, + }); + } + } + try { - await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type); } catch (setupError) { authLogger.error("Failed to setup OIDC user encryption", setupError, { operation: "oidc_user_encryption_setup_failed", @@ -1333,6 +1384,8 @@ router.get("/oidc/callback", async (req, res) => { const redirectUrl = new URL(frontendOrigin); redirectUrl.searchParams.set("success", "true"); + const isDesktopCallback = frontendOrigin.startsWith("http://127.0.0.1:"); + const maxAge = deviceInfo.type === "desktop" || deviceInfo.type === "mobile" ? 30 * 24 * 60 * 60 * 1000 @@ -1342,6 +1395,11 @@ router.get("/oidc/callback", async (req, res) => { res.clearCookie("jwt", authManager.getClearCookieOptions(req)); + if (isDesktopCallback) { + redirectUrl.searchParams.set("token", token); + return res.redirect(redirectUrl.toString()); + } + return res .cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)) .redirect(redirectUrl.toString()); @@ -1883,6 +1941,56 @@ router.patch("/registration-allowed", authenticateJWT, async (req, res) => { } }); +router.get("/oidc-auto-provision", async (_req, res) => { + try { + const row = db.$client + .prepare("SELECT value FROM settings WHERE key = 'oidc_auto_provision'") + .get(); + res.json({ + enabled: row ? (row as Record).value === "true" : false, + }); + } catch (err) { + authLogger.error("Failed to get OIDC auto-provision setting", err); + res + .status(500) + .json({ error: "Failed to get OIDC auto-provision setting" }); + } +}); + +router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0 || !user[0].isAdmin) { + return res.status(403).json({ error: "Not authorized" }); + } + const { enabled } = req.body; + if (typeof enabled !== "boolean") { + return res.status(400).json({ error: "Invalid value for enabled" }); + } + const existing = db.$client + .prepare("SELECT value FROM settings WHERE key = 'oidc_auto_provision'") + .get(); + if (existing) { + db.$client + .prepare( + "UPDATE settings SET value = ? WHERE key = 'oidc_auto_provision'", + ) + .run(enabled ? "true" : "false"); + } else { + db.$client + .prepare( + "INSERT INTO settings (key, value) VALUES ('oidc_auto_provision', ?)", + ) + .run(enabled ? "true" : "false"); + } + res.json({ enabled }); + } catch (err) { + authLogger.error("Failed to set OIDC auto-provision", err); + res.status(500).json({ error: "Failed to set OIDC auto-provision" }); + } +}); + /** * @openapi * /users/password-login-allowed: diff --git a/src/backend/guacamole/guacamole-server.ts b/src/backend/guacamole/guacamole-server.ts index b57051ae..61dbb147 100644 --- a/src/backend/guacamole/guacamole-server.ts +++ b/src/backend/guacamole/guacamole-server.ts @@ -75,6 +75,7 @@ const clientOptions = { vnc: { "swap-red-blue": false, cursor: "remote", + security: "any", width: 1280, height: 720, }, diff --git a/src/backend/guacamole/routes.ts b/src/backend/guacamole/routes.ts index 84f8c2a6..bd8cae6e 100644 --- a/src/backend/guacamole/routes.ts +++ b/src/backend/guacamole/routes.ts @@ -7,6 +7,8 @@ import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { getDb } from "../database/db/index.js"; import { hosts } from "../database/db/schema.js"; import { eq } from "drizzle-orm"; +import { Client } from "ssh2"; +import net from "net"; import type { AuthenticatedRequest } from "../../types/index.js"; const router = express.Router(); @@ -16,18 +18,52 @@ const authManager = AuthManager.getInstance(); router.use(authManager.createAuthMiddleware()); /** - * POST /guacamole/token - * Generate an encrypted connection token for guacamole-lite - * - * Body: { - * type: "rdp" | "vnc" | "telnet", - * hostname: string, - * port?: number, - * username?: string, - * password?: string, - * domain?: string, - * // Additional protocol-specific options - * } + * @openapi + * /guacamole/token: + * post: + * summary: Generate an encrypted Guacamole connection token + * description: Creates an AES-256-CBC encrypted token for guacamole-lite with the given connection parameters + * tags: + * - Guacamole + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - type + * - hostname + * properties: + * type: + * type: string + * enum: [rdp, vnc, telnet] + * hostname: + * type: string + * port: + * type: integer + * username: + * type: string + * password: + * type: string + * domain: + * type: string + * responses: + * 200: + * description: Encrypted connection token + * content: + * application/json: + * schema: + * type: object + * properties: + * token: + * type: string + * 400: + * description: Invalid request + * 500: + * description: Server error */ router.post("/token", async (req, res) => { try { @@ -192,20 +228,149 @@ router.post( } } + if (guacConfig.dpi != null) { + const parsed = parseInt(String(guacConfig.dpi), 10); + guacConfig.dpi = + Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; + } + let token: string; - const hostname = host.ip as string; - const port = host.port as number; - const username = (host.username as string) || ""; - const password = (host.password as string) || ""; - const domain = (host.domain as string) || ""; + let hostname = host.ip as string; + let port = host.port as number; + let username: string; + let password: string; switch (connectionType) { case "rdp": + username = + (host.rdpUser as string) || (host.username as string) || ""; + password = + (host.rdpPassword as string) || (host.password as string) || ""; + port = (host.rdpPort as number) || port || 3389; + break; + case "vnc": + username = (host.vncUser as string) || ""; + password = + (host.vncPassword as string) || (host.password as string) || ""; + port = (host.vncPort as number) || port || 5900; + break; + case "telnet": + username = (host.telnetUser as string) || ""; + password = + (host.telnetPassword as string) || (host.password as string) || ""; + port = (host.telnetPort as number) || port || 23; + break; + default: + username = ""; + password = ""; + } + const domain = + (host.rdpDomain as string) || (host.domain as string) || ""; + + // Establish SSH tunnel if jump hosts are configured + let jumpHosts: Array<{ hostId: number }> = []; + if (host.jumpHosts) { + try { + jumpHosts = + typeof host.jumpHosts === "string" + ? JSON.parse(host.jumpHosts as string) + : (host.jumpHosts as Array<{ hostId: number }>); + } catch { + jumpHosts = []; + } + } + + if (jumpHosts.length > 0) { + try { + const { resolveHostById } = await import("../ssh/host-resolver.js"); + const jumpHost = await resolveHostById(jumpHosts[0].hostId, userId); + if (jumpHost) { + const tunnelPort = await new Promise((resolve, reject) => { + const sshClient = new Client(); + sshClient.on("ready", () => { + const server = net.createServer((sock) => { + sshClient.forwardOut( + "127.0.0.1", + 0, + hostname, + port, + (err, stream) => { + if (err) { + sock.destroy(); + return; + } + sock.pipe(stream).pipe(sock); + }, + ); + }); + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as net.AddressInfo; + // Auto-cleanup after 1 hour + setTimeout( + () => { + server.close(); + sshClient.end(); + }, + 60 * 60 * 1000, + ); + resolve(addr.port); + }); + }); + sshClient.on("error", reject); + + const connectOpts: Record = { + host: jumpHost.ip, + port: jumpHost.port || 22, + username: jumpHost.username, + readyTimeout: 30000, + }; + if (jumpHost.key) { + connectOpts.privateKey = jumpHost.key; + if (jumpHost.keyPassword) + connectOpts.passphrase = jumpHost.keyPassword; + } else if (jumpHost.password) { + connectOpts.password = jumpHost.password; + } + sshClient.connect(connectOpts); + }); + hostname = "127.0.0.1"; + port = tunnelPort; + guacLogger.info("SSH tunnel established for guacamole", { + operation: "guac_ssh_tunnel", + hostId, + tunnelPort, + }); + } + } catch (tunnelError) { + guacLogger.error("Failed to establish SSH tunnel", tunnelError, { + operation: "guac_ssh_tunnel_error", + hostId, + }); + return res.status(500).json({ + error: "Failed to establish SSH tunnel to remote host", + }); + } + } + + switch (connectionType) { + case "rdp": + if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) { + guacConfig["drive-path"] = "/drive"; + guacConfig["create-drive-path"] = true; + } token = tokenService.createRdpToken(hostname, username, password, { - port: port || 3389, + port, domain, - security: (host.security as string) || undefined, - "ignore-cert": (host.ignoreCert as boolean) || false, + security: + (host.rdpSecurity as string) || + (host.security as string) || + undefined, + "ignore-cert": + host.rdpIgnoreCert !== undefined + ? !!host.rdpIgnoreCert + : host.ignoreCert !== undefined + ? !!host.ignoreCert + : true, ...guacConfig, }); break; @@ -215,14 +380,15 @@ router.post( username || undefined, password, { - port: port || 5900, + port, + security: "any", ...guacConfig, }, ); break; case "telnet": token = tokenService.createTelnetToken(hostname, username, password, { - port: port || 23, + port, ...guacConfig, }); break; diff --git a/src/backend/package.json b/src/backend/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/src/backend/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/src/backend/ssh/auth-manager.ts b/src/backend/ssh/auth-manager.ts index 46a50654..e79e2db8 100644 --- a/src/backend/ssh/auth-manager.ts +++ b/src/backend/ssh/auth-manager.ts @@ -10,6 +10,7 @@ interface ResolvedCredentials { key?: Buffer; keyPassword?: string; authType?: string; + certPublicKey?: string; } interface HostConfig { @@ -76,11 +77,13 @@ export class SSHAuthManager { resolvedCredentials = { username: (cred.username as string) || hostConfig.username, password: (cred.password as string) || undefined, - key: cred.privateKey - ? Buffer.from(cred.privateKey as string) - : undefined, + key: + cred.key || cred.privateKey + ? Buffer.from((cred.key || cred.privateKey) as string) + : undefined, keyPassword: (cred.keyPassword as string) || undefined, authType: (cred.authType as string) || "none", + certPublicKey: (cred.certPublicKey as string) || undefined, }; } } else { diff --git a/src/backend/ssh/docker-console.ts b/src/backend/ssh/docker-console.ts index 2532c69a..9b846b80 100644 --- a/src/backend/ssh/docker-console.ts +++ b/src/backend/ssh/docker-console.ts @@ -24,39 +24,6 @@ const activeSessions = new Map(); const wss = new WebSocketServer({ host: "0.0.0.0", port: 30009, - verifyClient: async (info) => { - try { - let token: string | undefined; - - const cookieHeader = info.req.headers.cookie; - if (cookieHeader) { - const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/); - if (match) token = decodeURIComponent(match[1]); - } - - if (!token) { - const authHeader = info.req.headers.authorization; - if (authHeader?.startsWith("Bearer ")) { - token = authHeader.slice("Bearer ".length); - } - } - - if (!token) { - return false; - } - - const authManager = AuthManager.getInstance(); - const decoded = await authManager.verifyJWTToken(token); - - if (!decoded || !decoded.userId) { - return false; - } - - return true; - } catch { - return false; - } - }, }); async function detectShell( @@ -173,7 +140,9 @@ async function createJumpHostChain( const credential = credentials[0]; resolvedCredentials = { password: credential.password as string | undefined, - sshKey: credential.privateKey as string | undefined, + sshKey: (credential.key || credential.privateKey) as + | string + | undefined, keyPassword: credential.keyPassword as string | undefined, authType: credential.authType as string | undefined, }; diff --git a/src/backend/ssh/docker.ts b/src/backend/ssh/docker.ts index 7bc5d57e..0122e5fe 100644 --- a/src/backend/ssh/docker.ts +++ b/src/backend/ssh/docker.ts @@ -139,10 +139,7 @@ async function resolveJumpHost( ): Promise { try { const hostResults = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))), + getDb().select().from(hosts).where(eq(hosts.id, hostId)), "ssh_data", userId, ); @@ -152,8 +149,37 @@ async function resolveJumpHost( } const host = hostResults[0]; + const ownerId = (host.userId || userId) as string; if (host.credentialId) { + if (userId !== ownerId) { + try { + const { SharedCredentialManager } = + await import("../utils/shared-credential-manager.js"); + const sharedCredManager = SharedCredentialManager.getInstance(); + const sharedCred = await sharedCredManager.getSharedCredentialForUser( + hostId, + userId, + ); + if (sharedCred) { + return { + ...host, + password: sharedCred.password, + key: sharedCred.key, + keyPassword: sharedCred.keyPassword, + keyType: sharedCred.keyType, + authType: sharedCred.key + ? "key" + : sharedCred.password + ? "password" + : "none", + } as JumpHostConfig; + } + } catch { + // fall through to owner credential lookup + } + } + const credentials = await SimpleDBOps.select( getDb() .select() @@ -161,11 +187,11 @@ async function resolveJumpHost( .where( and( eq(sshCredentials.id, host.credentialId as number), - eq(sshCredentials.userId, userId), + eq(sshCredentials.userId, ownerId), ), ), "ssh_credentials", - userId, + ownerId, ); if (credentials.length > 0) { @@ -173,7 +199,7 @@ async function resolveJumpHost( return { ...host, password: credential.password as string | undefined, - key: credential.privateKey as string | undefined, + key: (credential.key || credential.privateKey) as string | undefined, keyPassword: credential.keyPassword as string | undefined, keyType: credential.keyType as string | undefined, authType: credential.authType as string | undefined, @@ -714,7 +740,9 @@ app.post("/docker/ssh/connect", async (req, res) => { const credential = credentials[0]; resolvedCredentials = { password: credential.password as string | undefined, - sshKey: credential.privateKey as string | undefined, + sshKey: (credential.key || credential.privateKey) as + | string + | undefined, keyPassword: credential.keyPassword as string | undefined, authType: credential.authType as string | undefined, }; @@ -2970,7 +2998,7 @@ app.get("/docker/containers/:sessionId/:containerId/logs", async (req, res) => { session.activeOperations++; try { - let command = `docker logs ${containerId}`; + let command = `docker logs ${containerId} 2>&1`; if (tail && tail > 0) { command += ` --tail ${Math.floor(tail)}`; diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 4bed2271..d488a320 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -151,10 +151,7 @@ async function resolveJumpHost( ): Promise { try { const hostResults = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))), + getDb().select().from(hosts).where(eq(hosts.id, hostId)), "ssh_data", userId, ); @@ -164,8 +161,37 @@ async function resolveJumpHost( } const host = hostResults[0]; + const ownerId = (host.userId || userId) as string; if (host.credentialId) { + if (userId !== ownerId) { + try { + const { SharedCredentialManager } = + await import("../utils/shared-credential-manager.js"); + const sharedCredManager = SharedCredentialManager.getInstance(); + const sharedCred = await sharedCredManager.getSharedCredentialForUser( + hostId, + userId, + ); + if (sharedCred) { + return { + ...host, + password: sharedCred.password, + key: sharedCred.key, + keyPassword: sharedCred.keyPassword, + keyType: sharedCred.keyType, + authType: sharedCred.key + ? "key" + : sharedCred.password + ? "password" + : "none", + } as JumpHostConfig; + } + } catch { + // fall through to owner credential lookup + } + } + const credentials = await SimpleDBOps.select( getDb() .select() @@ -173,11 +199,11 @@ async function resolveJumpHost( .where( and( eq(sshCredentials.id, host.credentialId as number), - eq(sshCredentials.userId, userId), + eq(sshCredentials.userId, ownerId), ), ), "ssh_credentials", - userId, + ownerId, ); if (credentials.length > 0) { @@ -185,7 +211,7 @@ async function resolveJumpHost( return { ...host, password: credential.password as string | undefined, - key: credential.privateKey as string | undefined, + key: (credential.key || credential.privateKey) as string | undefined, keyPassword: credential.keyPassword as string | undefined, keyType: credential.keyType as string | undefined, authType: credential.authType as string | undefined, @@ -428,21 +454,33 @@ function execWithSudo( command: string, sudoPassword: string, ): Promise<{ stdout: string; stderr: string; code: number }> { + return execWithSudoBuffer(session, command, sudoPassword).then((result) => ({ + stdout: result.stdout.toString("utf8"), + stderr: result.stderr, + code: result.code, + })); +} + +function execWithSudoBuffer( + session: SSHSession, + command: string, + sudoPassword: string, +): Promise<{ stdout: Buffer; stderr: string; code: number }> { return new Promise((resolve) => { const escapedPassword = sudoPassword.replace(/'/g, "'\"'\"'"); const sudoCommand = `echo '${escapedPassword}' | sudo -S ${command} 2>&1`; execChannel(session, sudoCommand, (err, stream) => { if (err) { - resolve({ stdout: "", stderr: err.message, code: 1 }); + resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 }); return; } - let stdout = ""; + const stdoutChunks: Buffer[] = []; let stderr = ""; stream.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); + stdoutChunks.push(chunk); }); stream.stderr.on("data", (chunk: Buffer) => { @@ -450,12 +488,22 @@ function execWithSudo( }); stream.on("close", (code: number) => { - stdout = stdout.replace(/\[sudo\] password for .+?:\s*/g, ""); + let stdout = Buffer.concat(stdoutChunks); + const sudoPromptMatch = stdout + .toString("utf8", 0, Math.min(stdout.length, 256)) + .match(/^\[sudo\] password for .+?:\s*/); + if (sudoPromptMatch) { + stdout = stdout.subarray(Buffer.byteLength(sudoPromptMatch[0])); + } resolve({ stdout, stderr, code: code || 0 }); }); stream.on("error", (streamErr: Error) => { - resolve({ stdout, stderr: streamErr.message, code: 1 }); + resolve({ + stdout: Buffer.concat(stdoutChunks), + stderr: streamErr.message, + code: 1, + }); }); }); }); @@ -862,7 +910,13 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { ); // Resolve credentials server-side when frontend doesn't provide them - let resolvedCredentials = { password, sshKey, keyPassword, authType }; + let resolvedCredentials = { + password, + sshKey, + keyPassword, + authType, + sudoPassword: undefined as string | undefined, + }; if (hostId && userId && !password && !sshKey) { try { const { resolveHostById } = await import("./host-resolver.js"); @@ -873,6 +927,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { sshKey: resolvedHost.key, keyPassword: resolvedHost.keyPassword, authType: resolvedHost.authType, + sudoPassword: resolvedHost.sudoPassword as string | undefined, }; connectionLogs.push( createConnectionLog( @@ -900,6 +955,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { sshKey: resolvedHost.key, keyPassword: resolvedHost.keyPassword, authType: resolvedHost.authType, + sudoPassword: resolvedHost.sudoPassword as string | undefined, }; connectionLogs.push( createConnectionLog( @@ -1194,6 +1250,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { activeOperations: 0, channelOpener: new ChannelOpenSerializer(), userId, + sudoPassword: resolvedCredentials.sudoPassword, }; scheduleSessionCleanup(sessionId); res.json({ @@ -3058,6 +3115,42 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => { stream.on("close", (code) => { if (code !== 0) { + const isPermissionDenied = errorData + .toLowerCase() + .includes("permission denied"); + + if (isPermissionDenied && sshConn.sudoPassword) { + execWithSudoBuffer( + sshConn, + `cat '${escapedPath}'`, + sshConn.sudoPassword, + ) + .then((result) => { + if (result.code !== 0) { + return res.status(403).json({ + error: `Permission denied: ${result.stderr || result.stdout.toString("utf8")}`, + needsSudo: true, + }); + } + + const sudoData = result.stdout; + const isBinary = detectBinary(sudoData); + res.json({ + content: isBinary + ? sudoData.toString("base64") + : sudoData.toString("utf8"), + isBinary, + size: sudoData.length, + }); + }) + .catch(() => { + res + .status(403) + .json({ error: "Permission denied", needsSudo: true }); + }); + return; + } + fileLogger.error( `SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); @@ -3490,12 +3583,47 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => { } }); } else { + const isPermDenied = errorData + .toLowerCase() + .includes("permission denied"); + if (isPermDenied && sshConn.sudoPassword) { + execWithSudo( + sshConn, + `bash -c "echo '${base64Content}' | base64 -d > '${escapedPath}' && echo SUCCESS"`, + sshConn.sudoPassword, + ) + .then(({ stdout, code: sudoCode }) => { + if (sudoCode === 0 && stdout.includes("SUCCESS")) { + restoreOriginalMode(null, () => { + if (!res.headersSent) { + res.json({ + message: "File written successfully", + path: filePath, + }); + } + }); + } else if (!res.headersSent) { + res + .status(403) + .json({ error: "Permission denied", needsSudo: true }); + } + }) + .catch(() => { + if (!res.headersSent) { + res + .status(403) + .json({ error: "Permission denied", needsSudo: true }); + } + }); + return; + } fileLogger.error( `Fallback write failed with code ${code}: ${errorData}`, ); if (!res.headersSent) { res.status(500).json({ error: `Write failed: ${errorData}`, + needsSudo: isPermDenied, toast: { type: "error", message: `Write failed: ${errorData}` }, }); } @@ -4870,6 +4998,64 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => { }); }); +app.post("/ssh/file_manager/ssh/downloadFileStream", async (req, res) => { + const { sessionId, path: filePath } = req.body; + const userId = (req as AuthenticatedRequest).userId; + + if (!sessionId || !filePath) { + return res.status(400).json({ error: "Missing download parameters" }); + } + + const sshConn = sshSessions[sessionId]; + if (!sshConn?.isConnected) { + return res + .status(400) + .json({ error: "SSH session not found or not connected" }); + } + if (!verifySessionOwnership(sshConn, userId)) { + return res.status(403).json({ error: "Session access denied" }); + } + + sshConn.lastActive = Date.now(); + + try { + const sftp = await getSessionSftp(sshConn); + const stats = await new Promise<{ size: number; isFile: () => boolean }>( + (resolve, reject) => { + sftp.stat(filePath, (err, s) => (err ? reject(err) : resolve(s))); + }, + ); + + if (!stats.isFile()) { + return res.status(400).json({ error: "Cannot download directories" }); + } + + const fileName = filePath.split("/").pop() || "download"; + res.setHeader("Content-Type", "application/octet-stream"); + res.setHeader( + "Content-Disposition", + `attachment; filename="${encodeURIComponent(fileName)}"`, + ); + res.setHeader("Content-Length", String(stats.size)); + + const readStream = sftp.createReadStream(filePath); + readStream.on("error", (err) => { + if (!res.headersSent) { + res.status(500).json({ error: `Download failed: ${err.message}` }); + } else { + res.destroy(); + } + }); + readStream.pipe(res); + } catch (err) { + if (!res.headersSent) { + res + .status(500) + .json({ error: `Download failed: ${(err as Error).message}` }); + } + } +}); + /** * @openapi * /ssh/file_manager/ssh/copyItem: diff --git a/src/backend/ssh/host-resolver.ts b/src/backend/ssh/host-resolver.ts index 94d2f793..b741794a 100644 --- a/src/backend/ssh/host-resolver.ts +++ b/src/backend/ssh/host-resolver.ts @@ -75,8 +75,55 @@ export async function resolveHostById( if (host.credentialId) { const ownerId = (host.userId || userId) as string; try { - // Try shared credential first for non-owner users + // Try user's own override credential first if (userId !== ownerId) { + try { + const { hostAccess } = await import("../database/db/schema.js"); + const accessRecords = await db + .select() + .from(hostAccess) + .where( + and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)), + ) + .limit(1); + const overrideCredId = accessRecords[0]?.overrideCredentialId as + | number + | null; + if (overrideCredId) { + const userCreds = await SimpleDBOps.select( + db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, overrideCredId), + eq(sshCredentials.userId, userId), + ), + ), + "ssh_credentials", + userId, + ); + if (userCreds.length > 0) { + const cred = userCreds[0] as Record; + host.password = cred.password; + host.key = cred.key; + host.keyPassword = cred.keyPassword; + host.keyType = cred.keyType; + if (!host.overrideCredentialUsername) { + host.username = cred.username; + } + host.authType = cred.key + ? "key" + : cred.password + ? "password" + : "none"; + return host as unknown as SSHHost; + } + } + } catch { + // fall through to shared credential + } + try { const { SharedCredentialManager } = await import("../utils/shared-credential-manager.js"); @@ -126,13 +173,17 @@ export async function resolveHostById( if (credentials.length > 0) { const cred = credentials[0] as Record; host.password = cred.password; - host.key = cred.key; + // Prefer the normalised private key; fall back to raw key field + host.key = (cred.privateKey || cred.key) as string | null; host.keyPassword = cred.keyPassword; host.keyType = cred.keyType; + // CA-signed certificate for cert-based auth + (host as Record).certPublicKey = + cred.certPublicKey || null; if (!host.overrideCredentialUsername) { host.username = cred.username; } - host.authType = cred.key ? "key" : cred.password ? "password" : "none"; + host.authType = host.key ? "key" : host.password ? "password" : "none"; } } catch (e) { sshLogger.warn("Failed to resolve credential for host", { diff --git a/src/backend/ssh/opkssh-cert-auth.ts b/src/backend/ssh/opkssh-cert-auth.ts index 33bdcb82..6b8eb65b 100644 --- a/src/backend/ssh/opkssh-cert-auth.ts +++ b/src/backend/ssh/opkssh-cert-auth.ts @@ -1,8 +1,11 @@ -// OPKSSH certificate authentication workarounds for ssh2. +// SSH certificate authentication workarounds for ssh2. // ssh2 doesn't support OpenSSH cert auth natively — this module grafts // the certificate onto the parsed key, wraps ECDSA signing to convert // DER → SSH wire format, and patches Protocol.authPK to use the base // algorithm in the signature wrapper (required by OpenSSH's sshkey_check_sigtype). +// +// setupOPKSSHCertAuth: for OPKSSH-issued ephemeral certificates (no passphrase) +// setupCACertAuth: for user-managed CA-signed -cert.pub files (passphrase supported) import type { AnyAuthMethod, @@ -62,44 +65,39 @@ type OPKSSHNextAuthHandler = ( authInfo: AuthenticationType | AnyAuthMethod | false, ) => void; -export async function setupOPKSSHCertAuth( +// ── Internal implementation ────────────────────────────────────────────────── +// Grafts an OpenSSH certificate onto an already-parsed private key object and +// patches the ssh2 client so that certificate-based publickey auth succeeds. + +async function _applyCertToConnection( config: ConnectConfig, client: Client, - token: OPKSSHToken, - username: string, + privKey: ParsedPrivateKey, + certStr: string, ): Promise { - const { createRequire } = await import("node:module"); - const esmRequire = createRequire(import.meta.url); - const { - utils: { parseKey }, - } = esmRequire("ssh2"); - - const parsed = parseKey(Buffer.from(token.privateKey)); - if (parsed instanceof Error || !parsed) { - throw new Error("Failed to parse OPKSSH private key"); - } - const privKey = ( - Array.isArray(parsed) ? parsed[0] : parsed - ) as ParsedPrivateKey; - // Extract cert type and blob from the stored certificate - const certParts = token.sshCert.trim().split(/\s+/); + const certParts = certStr.trim().split(/\s+/); + if (certParts.length < 2) { + throw new Error( + "Invalid certificate format: expected ' ' string", + ); + } const certType = certParts[0]; - privKey.type = certType; const certBlob = Buffer.from(certParts[1], "base64"); - // Replace the internal public SSH blob with the full certificate + // Graft cert type and blob onto the parsed private key + privKey.type = certType; const pubSSHSym = Object.getOwnPropertySymbols(privKey).find( (s) => String(s) === "Symbol(Public key SSH)", ); if (!pubSSHSym) { throw new Error( - "Cannot find public SSH symbol on parsed key, ssh2 internals may have changed", + "Cannot find public SSH symbol on parsed key; ssh2 internals may have changed", ); } privKey[pubSSHSym] = certBlob; - // Wrap sign() for ECDSA cert keys + // Wrap sign() for ECDSA cert keys (DER → SSH wire format) if (privKey.type.startsWith("ecdsa-")) { const origSign = privKey.sign.bind(privKey); privKey.sign = (data: Buffer, algo?: string) => { @@ -145,7 +143,7 @@ export async function setupOPKSSHCertAuth( certAuthAttempted = true; next({ type: "publickey", - username, + username: (config as Record).username as string, key: privKey as unknown as PublicKeyAuthMethod["key"], }); } else { @@ -296,3 +294,75 @@ export async function setupOPKSSHCertAuth( return connectedClient; }; } + +// ── Public API ─────────────────────────────────────────────────────────────── + +/** + * Set up OPKSSH certificate authentication on an ssh2 Client. + * The OPKSSH private key is assumed to be unencrypted (no passphrase). + */ +export async function setupOPKSSHCertAuth( + config: ConnectConfig, + client: Client, + token: OPKSSHToken, + username: string, +): Promise { + const { createRequire } = await import("node:module"); + const esmRequire = createRequire(import.meta.url); + const { + utils: { parseKey }, + } = esmRequire("ssh2"); + + // Store username in config so the authHandler can access it + (config as Record).username = username; + + const parsed = parseKey(Buffer.from(token.privateKey)); + if (parsed instanceof Error || !parsed) { + throw new Error("Failed to parse OPKSSH private key"); + } + const privKey = ( + Array.isArray(parsed) ? parsed[0] : parsed + ) as ParsedPrivateKey; + + await _applyCertToConnection(config, client, privKey, token.sshCert); +} + +/** + * Set up CA-signed certificate authentication on an ssh2 Client. + * Supports passphrase-protected private keys. + * The cert content is the full text of the -cert.pub file + * (e.g. "ssh-ed25519-cert-v01@openssh.com AAAA..."). + */ +export async function setupCACertAuth( + config: ConnectConfig, + client: Client, + privateKey: Buffer | string, + certPublicKey: string, + username: string, + passphrase?: string, +): Promise { + const { createRequire } = await import("node:module"); + const esmRequire = createRequire(import.meta.url); + const { + utils: { parseKey }, + } = esmRequire("ssh2"); + + // Store username in config so the authHandler can access it + (config as Record).username = username; + + const keyBuf = Buffer.isBuffer(privateKey) + ? privateKey + : Buffer.from(privateKey, "utf8"); + + const parsed = passphrase ? parseKey(keyBuf, passphrase) : parseKey(keyBuf); + + if (parsed instanceof Error || !parsed) { + const errMsg = parsed instanceof Error ? parsed.message : "unknown error"; + throw new Error(`Failed to parse private key for CA cert auth: ${errMsg}`); + } + const privKey = ( + Array.isArray(parsed) ? parsed[0] : parsed + ) as ParsedPrivateKey; + + await _applyCertToConnection(config, client, privKey, certPublicKey); +} diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index c0bf3805..84b25433 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -75,10 +75,7 @@ async function resolveJumpHost( ): Promise { try { const hostResults = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))), + getDb().select().from(hosts).where(eq(hosts.id, hostId)), "ssh_data", userId, ); @@ -88,8 +85,37 @@ async function resolveJumpHost( } const host = hostResults[0]; + const ownerId = (host.userId || userId) as string; if (host.credentialId) { + if (userId !== ownerId) { + try { + const { SharedCredentialManager } = + await import("../utils/shared-credential-manager.js"); + const sharedCredManager = SharedCredentialManager.getInstance(); + const sharedCred = await sharedCredManager.getSharedCredentialForUser( + hostId, + userId, + ); + if (sharedCred) { + return { + ...host, + password: sharedCred.password, + key: sharedCred.key, + keyPassword: sharedCred.keyPassword, + keyType: sharedCred.keyType, + authType: sharedCred.key + ? "key" + : sharedCred.password + ? "password" + : "none", + } as JumpHostConfig; + } + } catch { + // fall through to owner credential lookup + } + } + const credentials = await SimpleDBOps.select( getDb() .select() @@ -97,11 +123,11 @@ async function resolveJumpHost( .where( and( eq(sshCredentials.id, host.credentialId as number), - eq(sshCredentials.userId, userId), + eq(sshCredentials.userId, ownerId), ), ), "ssh_credentials", - userId, + ownerId, ); if (credentials.length > 0) { @@ -109,7 +135,7 @@ async function resolveJumpHost( return { ...host, password: credential.password as string | undefined, - key: credential.privateKey as string | undefined, + key: (credential.key || credential.privateKey) as string | undefined, keyPassword: credential.keyPassword as string | undefined, keyType: credential.keyType as string | undefined, authType: credential.authType as string | undefined, @@ -676,7 +702,18 @@ interface StatsConfig { } const DEFAULT_STATS_CONFIG: StatsConfig = { - enabledWidgets: ["cpu", "memory", "disk", "network", "uptime", "system"], + enabledWidgets: [ + "cpu", + "memory", + "disk", + "network", + "uptime", + "system", + "login_stats", + "processes", + "ports", + "firewall", + ], statusCheckEnabled: true, statusCheckInterval: 60, metricsEnabled: true, diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 90fa717d..65b1705a 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -1,5 +1,14 @@ import { WebSocketServer, WebSocket, type RawData } from "ws"; -import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2"; +import ssh2Pkg, { + type Client as SSHClientType, + type ClientChannel, + type PseudoTtyOptions, + type ParsedKey, + type SignCallback, + type SigningRequestOptions, + type IdentityCallback, +} from "ssh2"; +const { Client, BaseAgent, utils: ssh2Utils } = ssh2Pkg; import net from "net"; import dgram from "dgram"; import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; @@ -22,9 +31,44 @@ import { sessionManager } from "./terminal-session-manager.js"; import { detectTmux, attachOrCreateTmuxSession, - queryNewestTmuxSession, + waitForTmuxSession, } from "./tmux-helper.js"; +class MemoryAgent extends BaseAgent { + private key: ParsedKey; + + constructor(key: ParsedKey) { + super(); + this.key = key; + } + + getIdentities(cb: IdentityCallback): void { + cb(null, [this.key]); + } + + sign( + pubKey: ParsedKey | Buffer | string, + data: Buffer, + optionsOrCb: SigningRequestOptions | SignCallback, + cb?: SignCallback, + ): void { + const callback = typeof optionsOrCb === "function" ? optionsOrCb : cb!; + const options = typeof optionsOrCb === "function" ? {} : optionsOrCb; + try { + const algo = + options.hash === "sha256" + ? "rsa-sha2-256" + : options.hash === "sha512" + ? "rsa-sha2-512" + : undefined; + const signature = this.key.sign(data, algo); + callback(null, signature); + } catch (err) { + callback(err instanceof Error ? err : new Error(String(err))); + } + } +} + async function performPortKnocking( host: string, sequence: Array<{ port: number; protocol?: string; delay?: number }>, @@ -145,10 +189,7 @@ async function resolveJumpHost( }); try { const hostResults = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))), + getDb().select().from(hosts).where(eq(hosts.id, hostId)), "ssh_data", userId, ); @@ -158,8 +199,37 @@ async function resolveJumpHost( } const host = hostResults[0]; + const ownerId = (host.userId || userId) as string; if (host.credentialId) { + if (userId !== ownerId) { + try { + const { SharedCredentialManager } = + await import("../utils/shared-credential-manager.js"); + const sharedCredManager = SharedCredentialManager.getInstance(); + const sharedCred = await sharedCredManager.getSharedCredentialForUser( + hostId, + userId, + ); + if (sharedCred) { + return { + ...host, + password: sharedCred.password, + key: sharedCred.key, + keyPassword: sharedCred.keyPassword, + keyType: sharedCred.keyType, + authType: sharedCred.key + ? "key" + : sharedCred.password + ? "password" + : "none", + } as JumpHostConfig; + } + } catch { + // fall through to owner credential lookup + } + } + const credentials = await SimpleDBOps.select( getDb() .select() @@ -167,11 +237,11 @@ async function resolveJumpHost( .where( and( eq(sshCredentials.id, host.credentialId as number), - eq(sshCredentials.userId, userId), + eq(sshCredentials.userId, ownerId), ), ), "ssh_credentials", - userId, + ownerId, ); if (credentials.length > 0) { @@ -179,7 +249,7 @@ async function resolveJumpHost( return { ...host, password: credential.password as string | undefined, - key: credential.privateKey as string | undefined, + key: (credential.key || credential.privateKey) as string | undefined, keyPassword: credential.keyPassword as string | undefined, keyType: credential.keyType as string | undefined, authType: credential.authType as string | undefined, @@ -202,13 +272,13 @@ async function createJumpHostChain( jumpHosts: Array<{ hostId: number }>, userId: string, socks5Config?: SOCKS5Config | null, -): Promise { +): Promise { if (!jumpHosts || jumpHosts.length === 0) { return null; } - let currentClient: Client | null = null; - const clients: Client[] = []; + let currentClient: SSHClientType | null = null; + const clients: SSHClientType[] = []; try { const jumpHostConfigs = await Promise.all( @@ -364,52 +434,6 @@ async function createJumpHostChain( const wss = new WebSocketServer({ port: 30002, - verifyClient: async (info) => { - try { - let token: string | undefined; - - const cookieHeader = info.req.headers.cookie; - if (cookieHeader) { - const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/); - if (match) token = decodeURIComponent(match[1]); - } - - if (!token) { - const authHeader = info.req.headers.authorization; - if (authHeader?.startsWith("Bearer ")) { - token = authHeader.slice("Bearer ".length); - } - } - - if (!token) { - return false; - } - - const payload = await authManager.verifyJWTToken(token); - - if (!payload) { - return false; - } - - if (payload.pendingTOTP) { - return false; - } - - const existingConnections = userConnections.get(payload.userId); - - if (existingConnections && existingConnections.size >= 10) { - return false; - } - - return true; - } catch (error) { - sshLogger.error("WebSocket authentication error", error, { - operation: "websocket_auth_error", - ip: info.req.socket.remoteAddress, - }); - return false; - } - }, }); wss.on("connection", async (ws: WebSocket, req) => { @@ -489,9 +513,9 @@ wss.on("connection", async (ws: WebSocket, req) => { }); let currentSessionId: string | null = null; - let sshConn: Client | null = null; + let sshConn: SSHClientType | null = null; let sshStream: ClientChannel | null = null; - let lastJumpClient: Client | null = null; + let lastJumpClient: SSHClientType | null = null; let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null; let totpPromptSent = false; let totpTimeout: NodeJS.Timeout | null = null; @@ -812,8 +836,8 @@ wss.on("connection", async (ws: WebSocket, req) => { : null; if (session?.sshStream) { const existingName = tmuxData.sessionName || undefined; - attachOrCreateTmuxSession(session.sshStream, existingName); if (existingName) { + attachOrCreateTmuxSession(session.sshStream, existingName); session.tmuxSessionName = existingName; sshLogger.info("User selected tmux session to attach", { operation: "tmux_user_attach", @@ -827,30 +851,51 @@ wss.on("connection", async (ws: WebSocket, req) => { }), ); } else { - // New session from picker -- query name after startup + const newName = `termix-${session.hostId}-${Date.now().toString(36).slice(-4)}`; + attachOrCreateTmuxSession(session.sshStream, undefined, newName); const sshConn = session.sshConn; - setTimeout(async () => { - const sessionName = sshConn - ? await queryNewestTmuxSession(sshConn) - : null; - session.tmuxSessionName = sessionName; - sshLogger.info("User requested new tmux session", { - operation: "tmux_user_create", - sessionName, - hostId: session.hostId, - }); - ws.send( - JSON.stringify({ - type: "tmux_session_created", - sessionName, - }), - ); - }, 500); + if (sshConn) { + (async () => { + const confirmed = await waitForTmuxSession(sshConn, newName); + session.tmuxSessionName = confirmed; + sshLogger.info("User requested new tmux session", { + operation: "tmux_user_create", + sessionName: confirmed, + hostId: session.hostId, + }); + ws.send( + JSON.stringify({ + type: "tmux_session_created", + sessionName: confirmed, + }), + ); + })(); + } } } break; } + case "tmux_detach": { + const session = currentSessionId + ? sessionManager.getSession(currentSessionId) + : null; + if (session?.sshConn && session.tmuxSessionName) { + const tmuxName = session.tmuxSessionName; + session.sshStream?.write("\x02d"); + session.tmuxSessionName = null; + sshLogger.info("User detached from tmux session", { + operation: "tmux_user_detach", + sessionName: tmuxName, + hostId: session.hostId, + }); + ws.send( + JSON.stringify({ type: "tmux_detached", sessionName: tmuxName }), + ); + } + break; + } + case "totp_response": { const totpData = data as TOTPResponseData; if (keyboardInteractiveFinish && totpData?.code) { @@ -1245,6 +1290,7 @@ wss.on("connection", async (ws: WebSocket, req) => { keyPassword, keyType, authType, + certPublicKey: undefined as string | undefined, }; const authMethodNotAvailable = false; if (id && userId && !password && !key) { @@ -1259,6 +1305,8 @@ wss.on("connection", async (ws: WebSocket, req) => { keyPassword: keyPassword || resolvedHost.keyPassword, keyType: resolvedHost.keyType, authType: resolvedHost.authType, + certPublicKey: (resolvedHost as unknown as Record) + .certPublicKey as string | undefined, }; sendLog( "auth", @@ -1286,6 +1334,8 @@ wss.on("connection", async (ws: WebSocket, req) => { keyPassword: keyPassword || resolvedHost.keyPassword, keyType: resolvedHost.keyType, authType: resolvedHost.authType, + certPublicKey: (resolvedHost as unknown as Record) + .certPublicKey as string | undefined, }; } } catch (error) { @@ -1586,6 +1636,7 @@ wss.on("connection", async (ws: WebSocket, req) => { JSON.stringify({ type: "disconnected", message: "Connection lost", + graceful: true, }), ); } @@ -1651,31 +1702,27 @@ wss.on("connection", async (ws: WebSocket, req) => { "tmux is not installed on the remote host. Falling back to standard shell.", }), ); - // tmux unavailable, run commands in plain shell runPostShellCommands(0); } else if (detection.sessions.length === 0) { - attachOrCreateTmuxSession(stream); - // Query the name tmux assigned after a short delay - setTimeout(async () => { - const sessionName = await queryNewestTmuxSession(conn); - const session = sessionManager.getSession(boundSessionId); - if (session) { - session.tmuxSessionName = sessionName; - } - sshLogger.info("Created new tmux session", { - operation: "tmux_new_session", - sessionName, - hostId: id, - }); - ws.send( - JSON.stringify({ - type: "tmux_session_created", - sessionName, - }), - ); - }, 500); - // Wait for tmux to start before running commands inside it - runPostShellCommands(500); + const newName = `termix-${id}-${Date.now().toString(36).slice(-4)}`; + attachOrCreateTmuxSession(stream, undefined, newName); + const confirmed = await waitForTmuxSession(conn, newName); + const session = sessionManager.getSession(boundSessionId); + if (session) { + session.tmuxSessionName = confirmed; + } + sshLogger.info("Created new tmux session", { + operation: "tmux_new_session", + sessionName: confirmed, + hostId: id, + }); + ws.send( + JSON.stringify({ + type: "tmux_session_created", + sessionName: confirmed, + }), + ); + runPostShellCommands(0); } else if (detection.sessions.length === 1) { attachOrCreateTmuxSession(stream, detection.sessions[0].name); const sessionName = detection.sessions[0].name; @@ -2239,6 +2286,45 @@ wss.on("connection", async (ws: WebSocket, req) => { if (resolvedCredentials.password) { connectConfig.password = resolvedCredentials.password; } + + // Apply CA-signed certificate if one is stored in the credential + if ( + resolvedCredentials.certPublicKey && + resolvedCredentials.certPublicKey.trim() + ) { + try { + const { setupCACertAuth } = await import("./opkssh-cert-auth.js"); + await setupCACertAuth( + connectConfig, + sshConn, + connectConfig.privateKey as Buffer, + resolvedCredentials.certPublicKey, + username, + resolvedCredentials.keyPassword, + ); + sendLog("auth", "info", "CA certificate authentication configured"); + sshLogger.info("CA cert auth configured", { + operation: "ca_cert_auth_configured", + userId, + hostId: id, + }); + } catch (certError) { + sendLog( + "auth", + "warning", + "CA certificate setup failed – falling back to key-only auth", + ); + sshLogger.warn("CA cert auth setup failed", { + operation: "ca_cert_auth_setup_failed", + userId, + hostId: id, + error: + certError instanceof Error + ? certError.message + : String(certError), + }); + } + } } catch (keyError) { sshLogger.error("SSH key format error: " + keyError.message); ws.send( @@ -2318,6 +2404,28 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } + if ( + hostConfig.terminalConfig?.agentForwarding && + connectConfig.privateKey + ) { + try { + const parsed = ssh2Utils.parseKey( + connectConfig.privateKey as Buffer, + connectConfig.passphrase as string | undefined, + ); + if (parsed && !(parsed instanceof Error)) { + connectConfig.agent = new MemoryAgent(parsed); + connectConfig.agentForward = true; + sendLog("auth", "info", "SSH agent forwarding enabled"); + } + } catch { + sshLogger.warn("Failed to set up agent forwarding", { + operation: "agent_forward_setup", + hostId: id, + }); + } + } + if ( hostConfig.portKnockSequence && hostConfig.portKnockSequence.length > 0 @@ -2356,6 +2464,62 @@ wss.on("connection", async (ws: WebSocket, req) => { hostConfig.jumpHosts.length > 0 && hostConfig.userId; + // Cloudflare Tunnel: connect via WebSocket proxy + const cfConfig = hostConfig.terminalConfig as + | Record + | undefined; + if (cfConfig?.cfAccessClientId && cfConfig?.cfAccessClientSecret) { + try { + const WebSocket = (await import("ws")).default; + const cfHostname = (cfConfig.cfTunnelHostname as string) || ip; + const wsUrl = `wss://${cfHostname}/cdn-cgi/access/ssh-connect`; + const cfWs = new WebSocket(wsUrl, { + headers: { + "CF-Access-Client-Id": cfConfig.cfAccessClientId as string, + "CF-Access-Client-Secret": cfConfig.cfAccessClientSecret as string, + }, + }); + + await new Promise((resolve, reject) => { + cfWs.on("open", () => resolve()); + cfWs.on("error", (err) => reject(err)); + setTimeout( + () => reject(new Error("Cloudflare tunnel timeout")), + 30000, + ); + }); + + const { Duplex } = await import("stream"); + const duplexStream = new Duplex({ + read() {}, + write(chunk, _encoding, callback) { + cfWs.send(chunk, callback); + }, + }); + cfWs.on("message", (data) => duplexStream.push(data)); + cfWs.on("close", () => duplexStream.push(null)); + + connectConfig.sock = + duplexStream as unknown as typeof connectConfig.sock; + sendLog("handshake", "info", "Connected via Cloudflare Tunnel"); + } catch (cfError) { + sshLogger.error("Cloudflare tunnel connection failed", cfError, { + operation: "cf_tunnel_connect", + hostId: id, + }); + ws.send( + JSON.stringify({ + type: "error", + message: + "Cloudflare tunnel connection failed: " + + (cfError instanceof Error ? cfError.message : "Unknown error"), + }), + ); + cleanupAuthState(connectionTimeout); + return; + } + } + if (hasJumpHosts) { try { const jumpClient = await createJumpHostChain( diff --git a/src/backend/ssh/tmux-helper.ts b/src/backend/ssh/tmux-helper.ts index 2f3023c4..31677ff6 100644 --- a/src/backend/ssh/tmux-helper.ts +++ b/src/backend/ssh/tmux-helper.ts @@ -103,6 +103,7 @@ const TMUX_OPTS = `set -gq mouse on` + ` \\; set -gq history-limit 50000` + ` \\; set -gq set-clipboard on` + + ` \\; set -gq aggressive-resize on` + ` \\; set -gq mode-keys vi` + ` \\; bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X stop-selection` + ` \\; bind-key -T copy-mode-vi Enter send-keys -X copy-selection-and-cancel` + @@ -110,6 +111,32 @@ const TMUX_OPTS = ` 'if -F "#{pane_in_mode}"` + ` "display-message -d 2500 \\"Adjust selection and press Enter to copy\\""'`; +/** + * Wait for a tmux session to appear by polling via exec channel. + * Returns the session name once found, or null on timeout. + */ +export async function waitForTmuxSession( + conn: Client, + sessionName: string, + timeoutMs = 5000, + intervalMs = 100, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await execCommand( + conn, + `tmux has-session -t ${shellEscape(sessionName)} 2>/dev/null`, + ); + return sessionName; + } catch { + // session not ready yet + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + return null; +} + /** * Write tmux attach or new-session command to the interactive shell stream. * Uses && exit so the shell only closes if tmux started successfully. @@ -117,12 +144,14 @@ const TMUX_OPTS = export function attachOrCreateTmuxSession( stream: ClientChannel, existingSessionName?: string, + newSessionName?: string, ): void { let command: string; if (existingSessionName) { command = `tmux ${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)} && exit\r`; } else { - command = `tmux ${TMUX_OPTS} \\; new-session && exit\r`; + const nameFlag = newSessionName ? ` -s ${shellEscape(newSessionName)}` : ""; + command = `tmux ${TMUX_OPTS} \\; new-session${nameFlag} && exit\r`; } sshLogger.info("Writing tmux command to shell", { diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index d9c1a736..1574bddc 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -1674,7 +1674,9 @@ async function connectSSHTunnel( const credential = credentials[0]; resolvedEndpointCredentials = { password: credential.password as string | undefined, - sshKey: credential.privateKey as string | undefined, + sshKey: (credential.key || credential.privateKey) as + | string + | undefined, keyPassword: credential.keyPassword as string | undefined, keyType: credential.keyType as string | undefined, authMethod: credential.authType as string, @@ -2695,9 +2697,22 @@ app.post( res.json({ message: "Connection request received", tunnelName }); - operation.finally(() => { - pendingTunnelOperations.delete(tunnelName); - }); + operation + .catch((err) => { + tunnelLogger.error("Tunnel operation failed", err, { + operation: "tunnel_operation_failed", + tunnelName, + }); + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.FAILED, + reason: err instanceof Error ? err.message : "Unknown error", + }); + tunnelConnecting.delete(tunnelName); + }) + .finally(() => { + pendingTunnelOperations.delete(tunnelName); + }); } catch (error) { tunnelLogger.error("Failed to process tunnel connect", error, { operation: "tunnel_connect", diff --git a/src/backend/starter.ts b/src/backend/starter.ts index e6464b6e..a6d8aed8 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -137,7 +137,8 @@ import { }, ); - await import("./database/database.js"); + const dbServer = await import("./database/database.js"); + await (dbServer as unknown as { serverReady: Promise }).serverReady; await import("./ssh/terminal.js"); await import("./ssh/tunnel.js"); await import("./ssh/file-manager.js"); @@ -194,29 +195,31 @@ import { duration: Date.now() - initStartTime, }); - process.on("SIGINT", () => { - systemLogger.info( - "Received SIGINT signal, initiating graceful shutdown...", - { operation: "shutdown" }, - ); + const gracefulShutdown = async (signal: string) => { + systemLogger.info(`Received ${signal}, initiating graceful shutdown...`, { + operation: "shutdown", + }); + try { + const { saveMemoryDatabaseToFile } = + await import("./database/db/index.js"); + await saveMemoryDatabaseToFile(); + systemLogger.info("Database saved to disk before exit", { + operation: "shutdown_db_saved", + }); + } catch (error) { + systemLogger.error("Failed to save database during shutdown", error, { + operation: "shutdown_db_save_failed", + }); + } process.exit(0); - }); + }; - process.on("SIGTERM", () => { - systemLogger.info( - "Received SIGTERM signal, initiating graceful shutdown...", - { operation: "shutdown" }, - ); - process.exit(0); - }); + process.on("SIGINT", () => gracefulShutdown("SIGINT")); + process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); process.on("message", (msg: { type?: string }) => { if (msg?.type === "shutdown") { - systemLogger.info( - "Received IPC shutdown, initiating graceful shutdown...", - { operation: "shutdown" }, - ); - process.exit(0); + gracefulShutdown("IPC shutdown"); } }); diff --git a/src/backend/swagger.ts b/src/backend/swagger.ts deleted file mode 100644 index 8ed87a03..00000000 --- a/src/backend/swagger.ts +++ /dev/null @@ -1,161 +0,0 @@ -import swaggerJSDoc from "@deadendjs/swagger-jsdoc"; -import path from "path"; -import { fileURLToPath } from "url"; -import { promises as fs } from "fs"; -import { systemLogger } from "./utils/logger.js"; - -interface SwaggerOptions { - definition: object; - apis: string[]; -} - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const projectRoot = path.join(__dirname, "..", "..", ".."); - -const swaggerOptions: SwaggerOptions = { - definition: { - openapi: "3.0.3", - info: { - title: "Termix API", - version: "0.0.0", - description: "Termix Backend API Reference", - }, - servers: [ - { - url: "http://localhost:30001", - description: "Main database and authentication server", - }, - { - url: "http://localhost:30003", - description: "SSH tunnel management server", - }, - { - url: "http://localhost:30004", - description: "SSH file manager server", - }, - { - url: "http://localhost:30005", - description: "Server statistics and monitoring server", - }, - { - url: "http://localhost:30006", - description: "Dashboard server", - }, - { - url: "http://localhost:30007", - description: "Docker management server", - }, - ], - components: { - securitySchemes: { - bearerAuth: { - type: "http", - scheme: "bearer", - bearerFormat: "JWT", - }, - }, - schemas: { - Error: { - type: "object", - properties: { - error: { type: "string" }, - details: { type: "string" }, - }, - }, - }, - }, - security: [ - { - bearerAuth: [], - }, - ], - tags: [ - { - name: "Alerts", - description: "System alerts and notifications management", - }, - { - name: "Credentials", - description: "SSH credential management", - }, - { - name: "Network Topology", - description: "Network topology visualization and management", - }, - { - name: "RBAC", - description: "Role-based access control for host sharing", - }, - { - name: "Snippets", - description: "Command snippet management", - }, - { - name: "Terminal", - description: "Terminal command history", - }, - { - name: "Users", - description: "User management and authentication", - }, - { - name: "Dashboard", - description: "Dashboard statistics and activity", - }, - { - name: "Docker", - description: "Docker container management", - }, - { - name: "SSH Tunnels", - description: "SSH tunnel connection management", - }, - { - name: "Server Stats", - description: "Server status monitoring and metrics collection", - }, - { - name: "File Manager", - description: "SSH file management operations", - }, - ], - }, - apis: [ - path.join(projectRoot, "src", "backend", "database", "routes", "*.ts"), - path.join(projectRoot, "src", "backend", "dashboard.ts"), - path.join(projectRoot, "src", "backend", "ssh", "*.ts"), - ], -}; - -async function generateOpenAPISpec() { - try { - systemLogger.info("Generating OpenAPI specification", { - operation: "openapi_generate_start", - }); - - const swaggerSpec = await swaggerJSDoc(swaggerOptions); - - const outputPath = path.join(projectRoot, "openapi.json"); - - await fs.writeFile( - outputPath, - JSON.stringify(swaggerSpec, null, 2), - "utf-8", - ); - - systemLogger.success("OpenAPI specification generated", { - operation: "openapi_generate_success", - }); - } catch (error) { - systemLogger.error("Failed to generate OpenAPI specification", error, { - operation: "openapi_generation", - }); - process.exit(1); - } -} - -generateOpenAPISpec(); - -export { swaggerOptions, generateOpenAPISpec }; diff --git a/src/backend/utils/credential-system-encryption-migration.ts b/src/backend/utils/credential-system-encryption-migration.ts index 5fb48e92..a749ae29 100644 --- a/src/backend/utils/credential-system-encryption-migration.ts +++ b/src/backend/utils/credential-system-encryption-migration.ts @@ -1,5 +1,5 @@ import { db } from "../database/db/index.js"; -import { sshCredentials } from "../database/db/schema.js"; +import { sshCredentials, sharedCredentials } from "../database/db/schema.js"; import { eq, and, or, isNull } from "drizzle-orm"; import { DataCrypto } from "./data-crypto.js"; import { SystemCrypto } from "./system-crypto.js"; @@ -64,7 +64,7 @@ export class CredentialSystemEncryptionMigration { cred.keyPassword, userDEK, cred.id.toString(), - "key_password", + "keyPassword", ) : null; @@ -105,6 +105,11 @@ export class CredentialSystemEncryptionMigration { }) .where(eq(sshCredentials.id, cred.id)); + await db + .update(sharedCredentials) + .set({ needsReEncryption: true }) + .where(eq(sharedCredentials.originalCredentialId, cred.id)); + migrated++; } catch (error) { databaseLogger.warn( diff --git a/src/backend/utils/field-crypto.ts b/src/backend/utils/field-crypto.ts index 52527597..b3fbf50d 100644 --- a/src/backend/utils/field-crypto.ts +++ b/src/backend/utils/field-crypto.ts @@ -30,6 +30,10 @@ class FieldCrypto { "autostartPassword", "autostartKey", "autostartKeyPassword", + "socks5Password", + "rdpPassword", + "vncPassword", + "telnetPassword", ]), ssh_credentials: new Set([ "password", diff --git a/src/backend/utils/lazy-field-encryption.ts b/src/backend/utils/lazy-field-encryption.ts index 5d59ba96..e831ea9f 100644 --- a/src/backend/utils/lazy-field-encryption.ts +++ b/src/backend/utils/lazy-field-encryption.ts @@ -103,6 +103,27 @@ export class LazyFieldEncryption { } } + // Guac hosts migrated from single-protocol: rdpPassword/vncPassword/telnetPassword + // columns were populated by copying the encrypted `password` blob. Try decrypting + // under the original field name before giving up. + if ( + fieldName === "rdpPassword" || + fieldName === "vncPassword" || + fieldName === "telnetPassword" + ) { + try { + const decrypted = FieldCrypto.decryptField( + fieldValue, + userKEK, + recordId, + "password", + ); + return decrypted; + } catch { + // not encrypted as "password" either + } + } + const sensitiveFields = [ "totpSecret", "totpBackupCodes", @@ -267,6 +288,10 @@ export class LazyFieldEncryption { autostartPassword: "autostart_password", autostartKey: "autostart_key", autostartKeyPassword: "autostart_key_password", + socks5Password: "socks5_password", + rdpPassword: "rdp_password", + vncPassword: "vnc_password", + telnetPassword: "telnet_password", totpSecret: "totp_secret", totpBackupCodes: "totp_backup_codes", clientSecret: "client_secret", diff --git a/src/backend/utils/ssh-algorithms.ts b/src/backend/utils/ssh-algorithms.ts index d2d5b273..d13ed8b1 100644 --- a/src/backend/utils/ssh-algorithms.ts +++ b/src/backend/utils/ssh-algorithms.ts @@ -1,7 +1,9 @@ import crypto from "crypto"; import type { ConnectConfig, CipherAlgorithm } from "ssh2"; -// Maps SSH cipher names to their OpenSSL equivalents (as used by ssh2 internally) +const availableCiphers = new Set(crypto.getCiphers()); + +// Maps SSH cipher names to their OpenSSL equivalents const SSH_CIPHER_SSL_NAME: Partial> = { "chacha20-poly1305@openssh.com": "chacha20", "aes256-gcm@openssh.com": "aes-256-gcm", @@ -15,12 +17,31 @@ const SSH_CIPHER_SSL_NAME: Partial> = { "3des-cbc": "des-ede3-cbc", }; -const availableCiphers = new Set(crypto.getCiphers()); +// Check if ssh2's native crypto binding is available (needed for chacha20-poly1305) +let ssh2BindingAvailable = false; +try { + require("ssh2/lib/protocol/crypto/build/Release/sshcrypto.node"); + ssh2BindingAvailable = true; +} catch { + try { + // ESM fallback: check if chacha20 works via OpenSSL createCipheriv + crypto.createCipheriv("chacha20", Buffer.alloc(32), Buffer.alloc(16)); + ssh2BindingAvailable = true; + } catch { + ssh2BindingAvailable = false; + } +} function filterCiphers(list: CipherAlgorithm[]): CipherAlgorithm[] { return list.filter((name) => { const sslName = SSH_CIPHER_SSL_NAME[name]; - return !sslName || availableCiphers.has(sslName); + if (!sslName) return true; + if (!availableCiphers.has(sslName)) return false; + // chacha20-poly1305 requires either native binding or working OpenSSL chacha20 + if (name === "chacha20-poly1305@openssh.com" && !ssh2BindingAvailable) { + return false; + } + return true; }); } diff --git a/src/backend/utils/ssh-key-utils.ts b/src/backend/utils/ssh-key-utils.ts index a4c9efc4..3addc2d8 100644 --- a/src/backend/utils/ssh-key-utils.ts +++ b/src/backend/utils/ssh-key-utils.ts @@ -103,6 +103,27 @@ function detectKeyTypeFromContent(keyContent: string): string { function detectPublicKeyTypeFromContent(publicKeyContent: string): string { const content = publicKeyContent.trim(); + // OpenSSH certificate types (must be checked before plain key types) + if (content.startsWith("ssh-ed25519-cert-v01@openssh.com ")) { + return "ssh-ed25519-cert-v01@openssh.com"; + } + if (content.startsWith("ssh-rsa-cert-v01@openssh.com ")) { + return "ssh-rsa-cert-v01@openssh.com"; + } + if (content.startsWith("ecdsa-sha2-nistp256-cert-v01@openssh.com ")) { + return "ecdsa-sha2-nistp256-cert-v01@openssh.com"; + } + if (content.startsWith("ecdsa-sha2-nistp384-cert-v01@openssh.com ")) { + return "ecdsa-sha2-nistp384-cert-v01@openssh.com"; + } + if (content.startsWith("ecdsa-sha2-nistp521-cert-v01@openssh.com ")) { + return "ecdsa-sha2-nistp521-cert-v01@openssh.com"; + } + if (content.startsWith("sk-ssh-ed25519-cert-v01@openssh.com ")) { + return "sk-ssh-ed25519-cert-v01@openssh.com"; + } + + // Plain public keys if (content.startsWith("ssh-rsa ")) { return "ssh-rsa"; } diff --git a/src/backend/utils/user-data-import.ts b/src/backend/utils/user-data-import.ts deleted file mode 100644 index 7ee07c49..00000000 --- a/src/backend/utils/user-data-import.ts +++ /dev/null @@ -1,492 +0,0 @@ -import { getDb } from "../database/db/index.js"; -import { - users, - hosts, - sshCredentials, - fileManagerRecent, - fileManagerPinned, - fileManagerShortcuts, - dismissedAlerts, -} from "../database/db/schema.js"; -import { eq, and } from "drizzle-orm"; -import { DataCrypto } from "./data-crypto.js"; -import { UserDataExport, type UserExportData } from "./user-data-export.js"; -import { databaseLogger } from "./logger.js"; - -interface ImportOptions { - replaceExisting?: boolean; - skipCredentials?: boolean; - skipFileManagerData?: boolean; - dryRun?: boolean; -} - -interface ImportResult { - success: boolean; - summary: { - sshHostsImported: number; - sshCredentialsImported: number; - fileManagerItemsImported: number; - dismissedAlertsImported: number; - skippedItems: number; - errors: string[]; - }; - dryRun: boolean; -} - -class UserDataImport { - static async importUserData( - targetUserId: string, - exportData: UserExportData, - options: ImportOptions = {}, - ): Promise { - const { - replaceExisting = false, - skipCredentials = false, - skipFileManagerData = false, - dryRun = false, - } = options; - - try { - const targetUser = await getDb() - .select() - .from(users) - .where(eq(users.id, targetUserId)); - if (!targetUser || targetUser.length === 0) { - throw new Error(`Target user not found: ${targetUserId}`); - } - - const validation = UserDataExport.validateExportData(exportData); - if (!validation.valid) { - throw new Error(`Invalid export data: ${validation.errors.join(", ")}`); - } - - let userDataKey: Buffer | null = null; - if (exportData.metadata.encrypted) { - userDataKey = DataCrypto.getUserDataKey(targetUserId); - if (!userDataKey) { - throw new Error( - "Target user data not unlocked - password required for encrypted import", - ); - } - } - - const result: ImportResult = { - success: false, - summary: { - sshHostsImported: 0, - sshCredentialsImported: 0, - fileManagerItemsImported: 0, - dismissedAlertsImported: 0, - skippedItems: 0, - errors: [], - }, - dryRun, - }; - - if ( - exportData.userData.sshHosts && - exportData.userData.sshHosts.length > 0 - ) { - const importStats = await this.importSshHosts( - targetUserId, - exportData.userData.sshHosts as Record[], - { replaceExisting, dryRun, userDataKey }, - ); - result.summary.sshHostsImported = importStats.imported; - result.summary.skippedItems += importStats.skipped; - result.summary.errors.push(...importStats.errors); - } - - if ( - !skipCredentials && - exportData.userData.sshCredentials && - exportData.userData.sshCredentials.length > 0 - ) { - const importStats = await this.importSshCredentials( - targetUserId, - exportData.userData.sshCredentials as Record[], - { replaceExisting, dryRun, userDataKey }, - ); - result.summary.sshCredentialsImported = importStats.imported; - result.summary.skippedItems += importStats.skipped; - result.summary.errors.push(...importStats.errors); - } - - if (!skipFileManagerData && exportData.userData.fileManagerData) { - const importStats = await this.importFileManagerData( - targetUserId, - exportData.userData.fileManagerData, - { replaceExisting, dryRun }, - ); - result.summary.fileManagerItemsImported = importStats.imported; - result.summary.skippedItems += importStats.skipped; - result.summary.errors.push(...importStats.errors); - } - - if ( - exportData.userData.dismissedAlerts && - exportData.userData.dismissedAlerts.length > 0 - ) { - const importStats = await this.importDismissedAlerts( - targetUserId, - exportData.userData.dismissedAlerts as Record[], - { replaceExisting, dryRun }, - ); - result.summary.dismissedAlertsImported = importStats.imported; - result.summary.skippedItems += importStats.skipped; - result.summary.errors.push(...importStats.errors); - } - - result.success = result.summary.errors.length === 0; - - databaseLogger.success("User data import completed", { - operation: "user_data_import_complete", - targetUserId, - dryRun, - ...result.summary, - }); - - return result; - } catch (error) { - databaseLogger.error("User data import failed", error, { - operation: "user_data_import_failed", - targetUserId, - dryRun, - }); - throw error; - } - } - - private static async importSshHosts( - targetUserId: string, - sshHosts: Record[], - options: { - replaceExisting: boolean; - dryRun: boolean; - userDataKey: Buffer | null; - }, - ) { - let imported = 0; - let skipped = 0; - const errors: string[] = []; - - for (const host of sshHosts) { - try { - if (options.dryRun) { - imported++; - continue; - } - - const existing = await getDb() - .select() - .from(hosts) - .where( - and( - eq(hosts.userId, targetUserId), - eq(hosts.ip, host.ip as string), - eq(hosts.port, host.port as number), - eq(hosts.username, host.username as string), - ), - ); - - if (existing.length > 0 && !options.replaceExisting) { - skipped++; - continue; - } - - const newHostData: Record = { - ...host, - userId: targetUserId, - updatedAt: new Date().toISOString(), - }; - - if (existing.length === 0) { - newHostData.createdAt = new Date().toISOString(); - } - - let processedHostData: Record = newHostData; - if (options.userDataKey) { - processedHostData = DataCrypto.encryptRecord( - "ssh_data", - newHostData, - targetUserId, - options.userDataKey, - ) as Record; - } - - delete processedHostData.id; - - if (existing.length > 0 && options.replaceExisting) { - await getDb() - .update(hosts) - .set(processedHostData as unknown as typeof hosts.$inferInsert) - .where(eq(hosts.id, existing[0].id)); - } else { - await getDb() - .insert(hosts) - .values(processedHostData as unknown as typeof hosts.$inferInsert); - } - imported++; - } catch (error) { - errors.push( - `SSH host import failed: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - skipped++; - } - } - - return { imported, skipped, errors }; - } - - private static async importSshCredentials( - targetUserId: string, - credentials: Record[], - options: { - replaceExisting: boolean; - dryRun: boolean; - userDataKey: Buffer | null; - }, - ) { - let imported = 0; - let skipped = 0; - const errors: string[] = []; - - for (const credential of credentials) { - try { - if (options.dryRun) { - imported++; - continue; - } - - const existing = await getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.userId, targetUserId), - eq(sshCredentials.name, credential.name as string), - ), - ); - - if (existing.length > 0 && !options.replaceExisting) { - skipped++; - continue; - } - - const newCredentialData: Record = { - ...credential, - userId: targetUserId, - updatedAt: new Date().toISOString(), - }; - - if (existing.length === 0) { - newCredentialData.usageCount = 0; - newCredentialData.lastUsed = null; - newCredentialData.createdAt = new Date().toISOString(); - } - - let processedCredentialData: Record = - newCredentialData; - if (options.userDataKey) { - processedCredentialData = DataCrypto.encryptRecord( - "ssh_credentials", - newCredentialData, - targetUserId, - options.userDataKey, - ) as Record; - } - - delete processedCredentialData.id; - - if (existing.length > 0 && options.replaceExisting) { - await getDb() - .update(sshCredentials) - .set( - processedCredentialData as unknown as typeof sshCredentials.$inferInsert, - ) - .where(eq(sshCredentials.id, existing[0].id)); - } else { - await getDb() - .insert(sshCredentials) - .values( - processedCredentialData as unknown as typeof sshCredentials.$inferInsert, - ); - } - imported++; - } catch (error) { - errors.push( - `SSH credential import failed: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - skipped++; - } - } - - return { imported, skipped, errors }; - } - - private static async importFileManagerData( - targetUserId: string, - fileManagerData: Record, - options: { replaceExisting: boolean; dryRun: boolean }, - ) { - let imported = 0; - let skipped = 0; - const errors: string[] = []; - - try { - if (fileManagerData.recent && Array.isArray(fileManagerData.recent)) { - for (const item of fileManagerData.recent) { - try { - if (!options.dryRun) { - const newItem = { - ...item, - id: undefined, - userId: targetUserId, - lastOpened: new Date().toISOString(), - }; - await getDb().insert(fileManagerRecent).values(newItem); - } - imported++; - } catch (error) { - errors.push( - `Recent file import failed: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - skipped++; - } - } - } - - if (fileManagerData.pinned && Array.isArray(fileManagerData.pinned)) { - for (const item of fileManagerData.pinned) { - try { - if (!options.dryRun) { - const newItem = { - ...item, - id: undefined, - userId: targetUserId, - pinnedAt: new Date().toISOString(), - }; - await getDb().insert(fileManagerPinned).values(newItem); - } - imported++; - } catch (error) { - errors.push( - `Pinned file import failed: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - skipped++; - } - } - } - - if ( - fileManagerData.shortcuts && - Array.isArray(fileManagerData.shortcuts) - ) { - for (const item of fileManagerData.shortcuts) { - try { - if (!options.dryRun) { - const newItem = { - ...item, - id: undefined, - userId: targetUserId, - createdAt: new Date().toISOString(), - }; - await getDb().insert(fileManagerShortcuts).values(newItem); - } - imported++; - } catch (error) { - errors.push( - `Shortcut import failed: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - skipped++; - } - } - } - } catch (error) { - errors.push( - `File manager data import failed: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - } - - return { imported, skipped, errors }; - } - - private static async importDismissedAlerts( - targetUserId: string, - alerts: Record[], - options: { replaceExisting: boolean; dryRun: boolean }, - ) { - let imported = 0; - let skipped = 0; - const errors: string[] = []; - - for (const alert of alerts) { - try { - if (options.dryRun) { - imported++; - continue; - } - - const existing = await getDb() - .select() - .from(dismissedAlerts) - .where( - and( - eq(dismissedAlerts.userId, targetUserId), - eq(dismissedAlerts.alertId, alert.alertId as string), - ), - ); - - if (existing.length > 0 && !options.replaceExisting) { - skipped++; - continue; - } - - const newAlert = { - ...alert, - id: undefined, - userId: targetUserId, - dismissedAt: new Date().toISOString(), - }; - - if (existing.length > 0 && options.replaceExisting) { - await getDb() - .update(dismissedAlerts) - .set(newAlert as typeof dismissedAlerts.$inferInsert) - .where(eq(dismissedAlerts.id, existing[0].id)); - } else { - await getDb() - .insert(dismissedAlerts) - .values(newAlert as typeof dismissedAlerts.$inferInsert); - } - - imported++; - } catch (error) { - errors.push( - `Dismissed alert import failed: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - skipped++; - } - } - - return { imported, skipped, errors }; - } - - static async importUserDataFromJSON( - targetUserId: string, - jsonData: string, - options: ImportOptions = {}, - ): Promise { - try { - const exportData: UserExportData = JSON.parse(jsonData); - return await this.importUserData(targetUserId, exportData, options); - } catch (error) { - if (error instanceof SyntaxError) { - throw new Error("Invalid JSON format in import data", { cause: error }); - } - throw error; - } - } -} - -export { UserDataImport, type ImportOptions, type ImportResult }; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx deleted file mode 100644 index aeaadfce..00000000 --- a/src/components/ui/button.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* eslint-disable react-refresh/only-export-components */ -import * as React from "react"; -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; - -import { cn } from "@/lib/utils"; - -const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-100 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-95", - { - variants: { - variant: { - default: - "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", - destructive: - "bg-destructive text-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", - outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", - ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2 has-[>svg]:px-3", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", - lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - icon: "size-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - }, -); - -export interface ButtonProps - extends React.ComponentProps<"button">, VariantProps { - asChild?: boolean; -} - -function Button({ - className, - variant, - size, - asChild = false, - ...props -}: ButtonProps) { - const Comp = asChild ? Slot : "button"; - - return ( - - ); -} - -export { Button, buttonVariants, type ButtonProps }; diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx deleted file mode 100644 index c0479b5a..00000000 --- a/src/components/ui/chart.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from "react"; -import * as RechartsPrimitive from "recharts"; - -import { cn } from "@/lib/utils"; - -// Chart Container -const ChartContainer = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => { - return ( -
- ); -}); -ChartContainer.displayName = "ChartContainer"; - -export { ChartContainer, RechartsPrimitive }; diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx deleted file mode 100644 index fb265605..00000000 --- a/src/components/ui/command.tsx +++ /dev/null @@ -1,184 +0,0 @@ -"use client"; - -import * as React from "react"; -import { Command as CommandPrimitive } from "cmdk"; -import { SearchIcon } from "lucide-react"; - -import { cn } from "@/lib/utils"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; - -function Command({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function CommandDialog({ - title = "Command Palette", - description = "Search for a command to run...", - children, - className, - showCloseButton = true, - ...props -}: React.ComponentProps & { - title?: string; - description?: string; - className?: string; - showCloseButton?: boolean; -}) { - return ( - - - {title} - {description} - - - - {children} - - - - ); -} - -function CommandInput({ - className, - ...props -}: React.ComponentProps) { - return ( -
- - -
- ); -} - -function CommandList({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function CommandEmpty({ - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function CommandGroup({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function CommandSeparator({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function CommandItem({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function CommandShortcut({ - className, - ...props -}: React.ComponentProps<"span">) { - return ( - - ); -} - -export { - Command, - CommandDialog, - CommandInput, - CommandList, - CommandEmpty, - CommandGroup, - CommandItem, - CommandShortcut, - CommandSeparator, -}; diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx deleted file mode 100644 index 61ab08e9..00000000 --- a/src/components/ui/dropdown-menu.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import * as React from "react"; -import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; -import { CheckIcon, ChevronRightIcon, Circle } from "lucide-react"; - -import { cn } from "@/lib/utils"; - -const DropdownMenu = DropdownMenuPrimitive.Root; - -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; - -const DropdownMenuGroup = DropdownMenuPrimitive.Group; - -const DropdownMenuPortal = DropdownMenuPrimitive.Portal; - -const DropdownMenuSub = DropdownMenuPrimitive.Sub; - -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; - -const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, children, ...props }, ref) => ( - - {children} - - -)); -DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName; - -const DropdownMenuSubContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName; - -const DropdownMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - - - -)); -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; - -const DropdownMenuItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, ...props }, ref) => ( - -)); -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; - -const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, checked, ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName; - -const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; - -const DropdownMenuLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, ...props }, ref) => ( - -)); -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; - -const DropdownMenuSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; - -const DropdownMenuShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( - - ); -}; -DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; - -export { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, -}; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx deleted file mode 100644 index f7fb44e5..00000000 --- a/src/components/ui/input.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from "react"; - -import { cn } from "@/lib/utils"; - -function Input({ className, type, ...props }: React.ComponentProps<"input">) { - return ( - - ); -} - -export { Input }; diff --git a/src/components/ui/kbd.tsx b/src/components/ui/kbd.tsx deleted file mode 100644 index ab78efd3..00000000 --- a/src/components/ui/kbd.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { type ComponentProps, type ReactNode } from "react"; -import { cn } from "@/lib/utils"; - -export type KbdProps = ComponentProps<"span"> & { - children: ReactNode; -}; - -export const Kbd = ({ className, children, ...props }: KbdProps) => ( - - {children} - -); - -export type KbdKeyProps = ComponentProps<"span"> & { - "aria-label"?: string; - className?: string; -}; - -export const KbdKey = ({ className, children, ...props }: KbdKeyProps) => ( - - {children} - -); - -export type KbdSeparatorProps = ComponentProps<"span"> & { - children?: ReactNode; - className?: string; -}; - -export const KbdSeparator = ({ - className, - children = "+", - ...props -}: KbdSeparatorProps) => ( - - {children} - -); diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx deleted file mode 100644 index d683666e..00000000 --- a/src/components/ui/progress.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import * as React from "react"; -import * as ProgressPrimitive from "@radix-ui/react-progress"; - -import { cn } from "@/lib/utils"; - -function Progress({ - className, - value, - ...props -}: React.ComponentProps) { - return ( - - - - ); -} - -export { Progress }; diff --git a/src/components/ui/resizable.tsx b/src/components/ui/resizable.tsx deleted file mode 100644 index 6287ded1..00000000 --- a/src/components/ui/resizable.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import * as React from "react"; -import { GripVerticalIcon } from "lucide-react"; -import { - Group as ResizableGroup, - Panel as ResizablePrimitivePanel, - Separator as ResizableSeparator, -} from "react-resizable-panels"; - -import { cn } from "@/lib/utils"; - -function ResizablePanelGroup({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function ResizablePanel({ - ...props -}: React.ComponentProps) { - return ; -} - -function ResizableHandle({ - withHandle, - className, - ...props -}: React.ComponentProps & { - withHandle?: boolean; -}) { - return ( - div]:rotate-90 bg-edge-hover hover:bg-interact active:bg-pressed transition-colors duration-150", - className, - )} - {...props} - > - {withHandle && ( -
- -
- )} -
- ); -} - -export { ResizablePanelGroup, ResizablePanel, ResizableHandle }; diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx deleted file mode 100644 index 4c7e3c57..00000000 --- a/src/components/ui/sonner.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useTheme } from "@/components/theme-provider"; -import { Toaster as Sonner, type ToasterProps, toast } from "sonner"; -import { useRef } from "react"; - -const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme(); - const lastToastRef = useRef<{ text: string; timestamp: number } | null>(null); - - const originalToast = toast; - - const rateLimitedToast = ( - message: string, - options?: Record, - ) => { - const now = Date.now(); - const lastToast = lastToastRef.current; - - if ( - lastToast && - lastToast.text === message && - now - lastToast.timestamp < 1000 - ) { - return; - } - - lastToastRef.current = { text: message, timestamp: now }; - return originalToast(message, options); - }; - - Object.assign(toast, { - success: (message: string, options?: Record) => - rateLimitedToast(message, { ...options, type: "success" }), - error: (message: string, options?: Record) => - rateLimitedToast(message, { ...options, type: "error" }), - warning: (message: string, options?: Record) => - rateLimitedToast(message, { ...options, type: "warning" }), - info: (message: string, options?: Record) => - rateLimitedToast(message, { ...options, type: "info" }), - message: rateLimitedToast, - }); - - const darkCustomThemes = [ - "dracula", - "gentlemansChoice", - "midnightEspresso", - "catppuccinMocha", - ]; - const sonnerTheme: ToasterProps["theme"] = darkCustomThemes.includes(theme) - ? "dark" - : (theme as ToasterProps["theme"]); - - return ( - - ); -}; - -export { Toaster }; diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx deleted file mode 100644 index 2ddbbdd7..00000000 --- a/src/components/ui/tooltip.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -import * as React from "react"; -import * as TooltipPrimitive from "@radix-ui/react-tooltip"; - -import { cn } from "@/lib/utils"; - -function TooltipProvider({ - delayDuration = 0, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function Tooltip({ - ...props -}: React.ComponentProps) { - return ( - - - - ); -} - -function TooltipTrigger({ - ...props -}: React.ComponentProps) { - return ; -} - -function TooltipContent({ - className, - sideOffset = 4, - children, - ...props -}: React.ComponentProps) { - return ( - - - {children} - - - ); -} - -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/src/index.css b/src/index.css deleted file mode 100644 index 2e5cd2d2..00000000 --- a/src/index.css +++ /dev/null @@ -1,559 +0,0 @@ -@import "tailwindcss"; - -@custom-variant dark (&:is(.dark *)); - -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: var(--foreground); - background-color: var(--bg-base); - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - --radius: 0.625rem; - --background: #ffffff; - --foreground: #18181b; - --card: #ffffff; - --card-foreground: #18181b; - --popover: #ffffff; - --popover-foreground: #18181b; - --primary: #27272a; - --primary-foreground: #fafafa; - --secondary: #f4f4f5; - --secondary-foreground: #27272a; - --muted: #f4f4f5; - --muted-foreground: #71717a; - --accent: #f4f4f5; - --accent-foreground: #27272a; - --destructive: #dc2626; - --border: #e4e4e7; - --input: #e4e4e7; - --ring: #a1a1aa; - --chart-1: #e76e50; - --chart-2: #2a9d8f; - --chart-3: #264653; - --chart-4: #e9c46a; - --chart-5: #f4a261; - --sidebar: #f9f9f9; - --sidebar-foreground: #18181b; - --sidebar-primary: #27272a; - --sidebar-primary-foreground: #fafafa; - --sidebar-accent: #f4f4f5; - --sidebar-accent-foreground: #27272a; - --sidebar-border: #e4e4e7; - --sidebar-ring: #a1a1aa; - - --bg-base: #fcfcfc; - --bg-elevated: #ffffff; - --bg-surface: #f3f4f6; - --bg-surface-hover: #e5e7eb; - --bg-input: #ffffff; - --bg-deepest: #e5e7eb; - --bg-header: #eeeeef; - --bg-button: #f3f4f6; - --bg-active: #e5e7eb; - --bg-light: #fafafa; - --bg-subtle: #f5f5f5; - --bg-interact: #d1d5db; - --border-base: #e5e7eb; - --border-panel: #d1d5db; - --border-subtle: #f3f4f6; - --border-medium: #d1d5db; - --bg-hover: #f3f4f6; - --bg-hover-alt: #e5e7eb; - --bg-pressed: #d1d5db; - --border-hover: #d1d5db; - --border-active: #9ca3af; - - --foreground-secondary: #334155; - --foreground-subtle: #94a3b8; - - --scrollbar-thumb: #c1c1c3; - --scrollbar-thumb-hover: #a1a1a3; - --scrollbar-track: #f3f4f6; - - --bg-overlay: rgba(0, 0, 0, 0.5); -} - -@theme inline { - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); - - --color-dark-bg: #18181b; - --color-dark-bg-darker: #0e0e10; - --color-dark-bg-darkest: #09090b; - --color-dark-bg-input: #222225; - --color-dark-bg-button: #23232a; - --color-dark-bg-active: #1d1d1f; - --color-dark-bg-header: #131316; - --color-dark-border: #303032; - --color-dark-border-active: #2d2d30; - --color-dark-border-hover: #434345; - --color-dark-hover: #2d2d30; - --color-dark-active: #2a2a2c; - --color-dark-pressed: #1a1a1c; - --color-dark-hover-alt: #2a2a2d; - --color-dark-border-light: #5a5a5d; - --color-dark-bg-light: #141416; - --color-dark-border-medium: #373739; - --color-dark-bg-very-light: #101014; - --color-dark-bg-panel: #1b1b1e; - --color-dark-border-panel: #222224; - --color-dark-bg-panel-hover: #232327; - - --color-canvas: var(--bg-base); - --color-elevated: var(--bg-elevated); - --color-surface: var(--bg-surface); - --color-surface-hover: var(--bg-surface-hover); - --color-field: var(--bg-input); - --color-deepest: var(--bg-deepest); - --color-header: var(--bg-header); - --color-button: var(--bg-button); - --color-active: var(--bg-active); - --color-light: var(--bg-light); - --color-subtle: var(--bg-subtle); - --color-interact: var(--bg-interact); - --color-hover: var(--bg-hover); - --color-hover-alt: var(--bg-hover-alt); - --color-pressed: var(--bg-pressed); - - --color-edge: var(--border-base); - --color-edge-panel: var(--border-panel); - --color-edge-subtle: var(--border-subtle); - --color-edge-medium: var(--border-medium); - --color-edge-hover: var(--border-hover); - --color-edge-active: var(--border-active); - - --color-foreground-secondary: var(--foreground-secondary); - --color-foreground-subtle: var(--foreground-subtle); - - --color-overlay: var(--bg-overlay); -} - -.dark { - --background: #09090b; - --foreground: #fafafa; - --card: #18181b; - --card-foreground: #fafafa; - --popover: #27272a; - --popover-foreground: #fafafa; - --primary: #e4e4e7; - --primary-foreground: #27272a; - --secondary: #3f3f46; - --secondary-foreground: #fafafa; - --muted: #27272a; - --muted-foreground: #9ca3af; - --accent: #3f3f46; - --accent-foreground: #fafafa; - --destructive: #f87171; - --border: #ffffff1a; - --input: #ffffff26; - --ring: #71717a; - --chart-1: #3b82f6; - --chart-2: #34d399; - --chart-3: #f4a261; - --chart-4: #a855f7; - --chart-5: #f43f5e; - --sidebar: #18181b; - --sidebar-foreground: #fafafa; - --sidebar-primary: #3b82f6; - --sidebar-primary-foreground: #fafafa; - --sidebar-accent: #3f3f46; - --sidebar-accent-foreground: #fafafa; - --sidebar-border: #ffffff1a; - --sidebar-ring: #71717a; - - --bg-base: #18181b; - --bg-elevated: #0e0e10; - --bg-surface: #1b1b1e; - --bg-surface-hover: #232327; - --bg-input: #222225; - --bg-deepest: #09090b; - --bg-header: #131316; - --bg-button: #23232a; - --bg-active: #1d1d1f; - --bg-light: #141416; - --bg-subtle: #101014; - --bg-interact: #2a2a2c; - --border-base: #303032; - --border-panel: #222224; - --border-subtle: #5a5a5d; - --border-medium: #373739; - --bg-hover: #2d2d30; - --bg-hover-alt: #2a2a2d; - --bg-pressed: #1a1a1c; - --border-hover: #434345; - --border-active: #2d2d30; - - --foreground-secondary: #d1d5db; - --foreground-subtle: #6b7280; - - --scrollbar-thumb: #434345; - --scrollbar-thumb-hover: #5a5a5d; - --scrollbar-track: #18181b; - - --bg-overlay: rgba(0, 0, 0, 0.7); -} - -.dracula { - --background: #282a36; - --foreground: #ffffff; - --card: #343746; - --card-foreground: #ffffff; - --popover: #343746; - --popover-foreground: #ffffff; - --primary: #ffffff; - --primary-foreground: #282a36; - --secondary: #44475a; - --secondary-foreground: #ffffff; - --muted: #343746; - --muted-foreground: #a6accd; - --accent: #44475a; - --accent-foreground: #ffffff; - --destructive: #ff5555; - --border: #44475a; - --input: #343746; - --ring: #8be9fd; - --chart-1: #8be9fd; - --chart-2: #50fa7b; - --chart-3: #ffb86c; - --chart-4: #bd93f9; - --chart-5: #ff79c6; - --sidebar: #1e1f29; - --sidebar-foreground: #ffffff; - --sidebar-primary: #8be9fd; - --sidebar-primary-foreground: #1e1f29; - --sidebar-accent: #44475a; - --sidebar-accent-foreground: #ffffff; - --sidebar-border: #44475a; - --sidebar-ring: #8be9fd; - - --bg-base: #282a36; - --bg-elevated: #343746; - --bg-surface: #343746; - --bg-surface-hover: #44475a; - --bg-input: #343746; - --bg-deepest: #1e1f29; - --bg-header: #1e1f29; - --bg-button: #343746; - --bg-active: #44475a; - --bg-light: #343746; - --bg-subtle: #1e1f29; - --bg-interact: #44475a; - --border-base: #44475a; - --border-panel: #1e1f29; - --border-subtle: #44475a; - --border-medium: #44475a; - --bg-hover: #44475a; - --bg-hover-alt: #44475a; - --bg-pressed: #1e1f29; - --border-hover: #6272a4; - --border-active: #8be9fd; - - --foreground-secondary: #a6accd; - --foreground-subtle: #6272a4; - - --scrollbar-thumb: #44475a; - --scrollbar-thumb-hover: #6272a4; - --scrollbar-track: #282a36; - - --bg-overlay: rgba(0, 0, 0, 0.8); -} - -.gentlemansChoice { - --background: #1a1c1a; - --foreground: #d1c7a3; - --card: #2a2c2a; - --card-foreground: #d1c7a3; - --popover: #2a2c2a; - --popover-foreground: #d1c7a3; - --primary: #d1c7a3; - --primary-foreground: #1a1c1a; - --secondary: #3e4437; - --secondary-foreground: #d1c7a3; - --muted: #2a2c2a; - --muted-foreground: #8e8463; - --accent: #3e4437; - --accent-foreground: #d1c7a3; - --destructive: #9d3a3a; - --border: #3e4437; - --input: #2a2c2a; - --ring: #5a7a3a; - --chart-1: #5a7a3a; - --chart-2: #3a5a7a; - --chart-3: #b39a3a; - --chart-4: #7a3a5a; - --chart-5: #3a7a7a; - --sidebar: #151715; - --sidebar-foreground: #d1c7a3; - --sidebar-primary: #5a7a3a; - --sidebar-primary-foreground: #151715; - --sidebar-accent: #3e4437; - --sidebar-accent-foreground: #d1c7a3; - --sidebar-border: #3e4437; - --sidebar-ring: #5a7a3a; - - --bg-base: #1a1c1a; - --bg-elevated: #2a2c2a; - --bg-surface: #2a2c2a; - --bg-surface-hover: #3e4437; - --bg-input: #2a2c2a; - --bg-deepest: #151715; - --bg-header: #151715; - --bg-button: #2a2c2a; - --bg-active: #3e4437; - --bg-light: #2a2c2a; - --bg-subtle: #151715; - --bg-interact: #3e4437; - --border-base: #3e4437; - --border-panel: #151715; - --border-subtle: #3e4437; - --border-medium: #3e4437; - --bg-hover: #3e4437; - --bg-hover-alt: #3e4437; - --bg-pressed: #151715; - --border-hover: #5a7a3a; - --border-active: #d1c7a3; - - --foreground-secondary: #8e8463; - --foreground-subtle: #5e5841; - - --scrollbar-thumb: #3e4437; - --scrollbar-thumb-hover: #5e5841; - --scrollbar-track: #1a1c1a; - - --bg-overlay: rgba(0, 0, 0, 0.8); -} - -.midnightEspresso { - --background: #120f0d; - --foreground: #ceb195; - --card: #1f1a17; - --card-foreground: #ceb195; - --popover: #1f1a17; - --popover-foreground: #ceb195; - --primary: #ceb195; - --primary-foreground: #120f0d; - --secondary: #3d2b1f; - --secondary-foreground: #ceb195; - --muted: #1f1a17; - --muted-foreground: #9a8a7a; - --accent: #3d2b1f; - --accent-foreground: #ceb195; - --destructive: #a05a4a; - --border: #3d2b1f; - --input: #1f1a17; - --ring: #7a8a5a; - --chart-1: #7a8a5a; - --chart-2: #5a7a9a; - --chart-3: #b08a4a; - --chart-4: #8a5a7a; - --chart-5: #5a8a8a; - --sidebar: #0d0b0a; - --sidebar-foreground: #ceb195; - --sidebar-primary: #7a8a5a; - --sidebar-primary-foreground: #0d0b0a; - --sidebar-accent: #3d2b1f; - --sidebar-accent-foreground: #ceb195; - --sidebar-border: #3d2b1f; - --sidebar-ring: #7a8a5a; - - --bg-base: #120f0d; - --bg-elevated: #1f1a17; - --bg-surface: #1f1a17; - --bg-surface-hover: #3d2b1f; - --bg-input: #1f1a17; - --bg-deepest: #0d0b0a; - --bg-header: #0d0b0a; - --bg-button: #1f1a17; - --bg-active: #3d2b1f; - --bg-light: #1f1a17; - --bg-subtle: #0d0b0a; - --bg-interact: #3d2b1f; - --border-base: #3d2b1f; - --border-panel: #0d0b0a; - --border-subtle: #3d2b1f; - --border-medium: #3d2b1f; - --bg-hover: #3d2b1f; - --bg-hover-alt: #3d2b1f; - --bg-pressed: #0d0b0a; - --border-hover: #7a8a5a; - --border-active: #ceb195; - - --foreground-secondary: #9a8a7a; - --foreground-subtle: #6a5a4a; - - --scrollbar-thumb: #3d2b1f; - --scrollbar-thumb-hover: #6a5a4a; - --scrollbar-track: #120f0d; - - --bg-overlay: rgba(0, 0, 0, 0.8); -} - -.catppuccinMocha { - --background: #1e1e2e; - --foreground: #cdd6f4; - --card: #181825; - --card-foreground: #cdd6f4; - --popover: #181825; - --popover-foreground: #cdd6f4; - --primary: #cdd6f4; - --primary-foreground: #1e1e2e; - --secondary: #313244; - --secondary-foreground: #cdd6f4; - --muted: #181825; - --muted-foreground: #a6adc8; - --accent: #313244; - --accent-foreground: #cdd6f4; - --destructive: #f38ba8; - --border: #313244; - --input: #181825; - --ring: #89b4fa; - --chart-1: #89b4fa; - --chart-2: #a6e3a1; - --chart-3: #f9e2af; - --chart-4: #f5c2e7; - --chart-5: #94e2d5; - --sidebar: #11111b; - --sidebar-foreground: #cdd6f4; - --sidebar-primary: #89b4fa; - --sidebar-primary-foreground: #11111b; - --sidebar-accent: #313244; - --sidebar-accent-foreground: #cdd6f4; - --sidebar-border: #313244; - --sidebar-ring: #89b4fa; - - --bg-base: #1e1e2e; - --bg-elevated: #181825; - --bg-surface: #181825; - --bg-surface-hover: #313244; - --bg-input: #181825; - --bg-deepest: #11111b; - --bg-header: #11111b; - --bg-button: #181825; - --bg-active: #313244; - --bg-light: #181825; - --bg-subtle: #11111b; - --bg-interact: #313244; - --border-base: #313244; - --border-panel: #11111b; - --border-subtle: #313244; - --border-medium: #313244; - --bg-hover: #313244; - --bg-hover-alt: #313244; - --bg-pressed: #11111b; - --border-hover: #89b4fa; - --border-active: #cdd6f4; - - --foreground-secondary: #a6adc8; - --foreground-subtle: #7f849c; - - --scrollbar-thumb: #313244; - --scrollbar-thumb-hover: #45475a; - --scrollbar-track: #1e1e2e; - - --bg-overlay: rgba(0, 0, 0, 0.8); -} - -@layer base { - html, - body { - height: 100%; - } - - * { - @apply border-border outline-ring/50; - } - - body { - @apply bg-background text-foreground; - } -} - -.thin-scrollbar { - scrollbar-width: thin; - scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); -} - -.thin-scrollbar::-webkit-scrollbar { - width: 6px; - height: 6px; -} - -.thin-scrollbar::-webkit-scrollbar-track { - background: var(--scrollbar-track); -} - -.thin-scrollbar::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb); - border-radius: 3px; -} - -.thin-scrollbar::-webkit-scrollbar-thumb:hover { - background: var(--scrollbar-thumb-hover); -} - -.skinny-scrollbar { - scrollbar-width: thin; - scrollbar-color: var(--scrollbar-thumb) transparent; -} - -.skinny-scrollbar::-webkit-scrollbar { - width: 4px; - height: 4px; -} - -.skinny-scrollbar::-webkit-scrollbar-track { - background: transparent; -} - -.skinny-scrollbar::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb); - border-radius: 2px; -} - -.skinny-scrollbar::-webkit-scrollbar-thumb:hover { - background: var(--scrollbar-thumb-hover); -} - -.h-fade { - mask-image: linear-gradient(transparent, #000 6%, #000 94%, transparent); -} diff --git a/src/lib/base-path.ts b/src/lib/base-path.ts deleted file mode 100644 index 0352e12c..00000000 --- a/src/lib/base-path.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function getBasePath(): string { - const base = import.meta.env.BASE_URL || "/"; - if (base === "./" || base === "/") return ""; - return base.endsWith("/") ? base.slice(0, -1) : base; -} diff --git a/src/locales/en.json b/src/locales/en.json deleted file mode 100644 index aa4b82e2..00000000 --- a/src/locales/en.json +++ /dev/null @@ -1,3162 +0,0 @@ -{ - "credentials": { - "credentials": "Credentials", - "credentialsViewer": "Credentials Viewer", - "manageYourSSHCredentials": "Manage your SSH credentials securely", - "addCredential": "Add Credential", - "createCredential": "Create Credential", - "editCredential": "Edit Credential", - "viewCredential": "View Credential", - "duplicateCredential": "Duplicate Credential", - "deleteCredential": "Delete Credential", - "updateCredential": "Update Credential", - "credentialName": "Credential Name", - "credentialDescription": "Description", - "username": "Username", - "searchCredentials": "Search credentials...", - "selectFolder": "Select Folder", - "selectAuthType": "Select Auth Type", - "allFolders": "All Folders", - "allAuthTypes": "All Auth Types", - "uncategorized": "Uncategorized", - "totalCredentials": "Total", - "keyBased": "Key-based", - "passwordBased": "Password-based", - "folders": "Folders", - "noCredentialsMatchFilters": "No credentials match your filters", - "noCredentialsYet": "No credentials created yet", - "createFirstCredential": "Create your first credential", - "failedToFetchCredentials": "Failed to fetch credentials", - "credentialDeletedSuccessfully": "Credential deleted successfully", - "failedToDeleteCredential": "Failed to delete credential", - "confirmDeleteCredential": "Are you sure you want to delete credential \"{{name}}\"?", - "credentialCreatedSuccessfully": "Credential created successfully", - "credentialUpdatedSuccessfully": "Credential updated successfully", - "failedToSaveCredential": "Failed to save credential", - "failedToFetchCredentialDetails": "Failed to fetch credential details", - "failedToFetchHostsUsing": "Failed to fetch hosts using this credential", - "loadingCredentials": "Loading credentials...", - "retry": "Retry", - "noCredentials": "No Credentials", - "noCredentialsMessage": "You haven't added any credentials yet. Click \"Add Credential\" to get started.", - "sshCredentials": "SSH Credentials", - "credentialsCount": "{{count}} credentials", - "refresh": "Refresh", - "passwordRequired": "Password is required", - "sshKeyRequired": "SSH key is required", - "credentialAddedSuccessfully": "Credential \"{{name}}\" added successfully", - "savingCredential": "Saving credential...", - "updatingCredential": "Updating credential...", - "general": "General", - "description": "Description", - "folder": "Folder", - "tags": "Tags", - "addTagsSpaceToAdd": "Add tags (press space to add)", - "password": "Password", - "key": "Key", - "sshPrivateKey": "SSH Private Key", - "upload": "Upload", - "updateKey": "Update Key", - "keyPassword": "Key Password", - "keyType": "Key Type", - "keyTypeRSA": "RSA", - "keyTypeECDSA": "ECDSA", - "keyTypeEd25519": "Ed25519", - "basicInfo": "Basic Info", - "authentication": "Authentication", - "organization": "Organization", - "basicInformation": "Basic Information", - "basicInformationDescription": "Enter the basic information for this credential", - "authenticationMethod": "Authentication Method", - "authenticationMethodDescription": "Choose how you want to authenticate with SSH servers", - "organizationDescription": "Organize your credentials with folders and tags", - "enterCredentialName": "Enter credential name", - "enterCredentialDescription": "Enter description (optional)", - "enterUsername": "Enter username", - "nameIsRequired": "Credential name is required", - "usernameIsRequired": "Username is required", - "authenticationType": "Authentication Type", - "passwordAuthDescription": "Use password authentication", - "sshKeyAuthDescription": "Use SSH key authentication", - "passwordIsRequired": "Password is required", - "sshKeyIsRequired": "SSH key is required", - "sshKeyType": "SSH Key Type", - "privateKey": "Private Key", - "enterPassword": "Enter password", - "enterPrivateKey": "Enter private key", - "keyPassphrase": "Key Passphrase", - "enterKeyPassphrase": "Enter key passphrase (optional)", - "keyPassphraseOptional": "Optional: leave empty if your key has no passphrase", - "leaveEmptyToKeepCurrent": "Leave empty to keep current value", - "uploadKeyFile": "Upload Key File", - "generateKeyPairButton": "Generate Key Pair", - "generateKeyPair": "Generate Key Pair", - "generateKeyPairDescription": "Generate a new SSH key pair. If you want to protect the key with a passphrase, enter it in the Key Password field below first.", - "deploySSHKey": "Deploy SSH Key", - "deploySSHKeyDescription": "Deploy public key to target server", - "sourceCredential": "Source Credential", - "targetHost": "Target Host", - "deploymentProcess": "Deployment Process", - "deploymentProcessDescription": "This will safely add the public key to the target host's ~/.ssh/authorized_keys file without overwriting existing keys. The operation is reversible.", - "chooseHostToDeploy": "Choose a host to deploy to...", - "deploying": "Deploying...", - "copyDeployCommand": "Copy Deploy Command", - "copyDeployCommandDescription": "Copy a shell command to manually add the public key to a host's authorized_keys file. Useful when password authentication is disabled.", - "deployCommandCopied": "Deploy command copied to clipboard", - "manualDeployInfo": "Paste this command in the target host's terminal (e.g., Proxmox console, IPMI, or physical access) to add the SSH key.", - "name": "Name", - "noHostsAvailable": "No hosts available", - "noHostsMatchSearch": "No hosts match your search", - "sshKeyGenerationNotImplemented": "SSH key generation feature coming soon", - "connectionTestingNotImplemented": "Connection testing feature coming soon", - "testConnection": "Test Connection", - "selectOrCreateFolder": "Select or create folder", - "noFolder": "No folder", - "orCreateNewFolder": "Or create new folder", - "addTag": "Add tag", - "saving": "Saving...", - "credentialId": "Credential ID", - "overview": "Overview", - "security": "Security", - "usage": "Usage", - "securityDetails": "Security Details", - "securityDetailsDescription": "View encrypted credential information", - "credentialSecured": "Credential Secured", - "credentialSecuredDescription": "All sensitive data is encrypted with AES-256", - "passwordAuthentication": "Password Authentication", - "keyAuthentication": "Key Authentication", - "securityReminder": "Security Reminder", - "securityReminderText": "Never share your credentials. All data is encrypted at rest.", - "hostsUsingCredential": "Hosts Using This Credential", - "noHostsUsingCredential": "No hosts are currently using this credential", - "timesUsed": "Times Used", - "lastUsed": "Last Used", - "connectedHosts": "Connected Hosts", - "created": "Created", - "lastModified": "Last Modified", - "usageStatistics": "Usage Statistics", - "copiedToClipboard": "{{field}} copied to clipboard", - "failedToCopy": "Failed to copy to clipboard", - "sshKey": "SSH Key", - "createCredentialDescription": "Create a new SSH credential for secure access", - "editCredentialDescription": "Update the credential information", - "listView": "List", - "folderView": "Folders", - "unknownCredential": "Unknown", - "confirmRemoveFromFolder": "Are you sure you want to remove \"{{name}}\" from folder \"{{folder}}\"? The credential will be moved to \"Uncategorized\".", - "removedFromFolder": "Credential \"{{name}}\" removed from folder successfully", - "failedToRemoveFromFolder": "Failed to remove credential from folder", - "folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully", - "failedToRenameFolder": "Failed to rename folder", - "movedToFolder": "Credential \"{{name}}\" moved to \"{{folder}}\" successfully", - "failedToMoveToFolder": "Failed to move credential to folder", - "sshPublicKey": "SSH Public Key", - "publicKeyNote": "Public key is optional but recommended for key validation", - "publicKeyUploaded": "Public Key Uploaded", - "uploadPublicKey": "Upload Public Key", - "uploadPrivateKeyFile": "Upload Private Key File", - "uploadPublicKeyFile": "Upload Public Key File", - "privateKeyRequiredForGeneration": "Private key is required to generate public key", - "failedToGeneratePublicKey": "Failed to generate public key", - "generatePublicKey": "Generate from Private Key", - "publicKeyGeneratedSuccessfully": "Public key generated successfully", - "detectedKeyType": "Detected key type", - "detectingKeyType": "detecting...", - "optional": "Optional", - "generateKeyPairNew": "Generate New Key Pair", - "generateEd25519": "Generate Ed25519", - "generateECDSA": "Generate ECDSA", - "generateRSA": "Generate RSA", - "keyTypeEcdsaP256": "ECDSA P-256 (SSH)", - "keyTypeEcdsaP384": "ECDSA P-384 (SSH)", - "keyTypeEcdsaP521": "ECDSA P-521 (SSH)", - "keyTypeDsa": "DSA (SSH)", - "keyTypeRsaSha256": "RSA-SHA2-256", - "keyTypeRsaSha512": "RSA-SHA2-512", - "keyPairGeneratedSuccessfully": "{{keyType}} key pair generated successfully", - "failedToGenerateKeyPair": "Failed to generate key pair", - "generateKeyPairNote": "Generate a new SSH key pair directly. This will replace any existing keys in the form.", - "invalidKey": "Invalid Key", - "detectionError": "Detection Error", - "removing": "Removing:", - "clickToEditCredential": "Click to edit credential", - "dragToMoveBetweenFolders": "Drag to move between folders", - "keyBasedOnlyForDeployment": "Only SSH key-based credentials can be deployed", - "publicKeyRequiredForDeployment": "Public key is required for deployment", - "selectTargetHost": "Please select a target host", - "keyDeployedSuccessfully": "SSH key deployed successfully", - "deploymentFailed": "Deployment failed", - "failedToDeployKey": "Failed to deploy SSH key", - "clickToRenameFolder": "Click to rename folder", - "renameFolder": "Rename folder", - "idLabel": "ID:" - }, - "quickConnect": { - "title": "Quick Connect", - "description": "Connect directly to a terminal or file manager session without saving a host configuration", - "ipAddress": "IP Address or Hostname", - "port": "Port", - "username": "Username", - "password": "Password", - "key": "SSH Private Key", - "keyPassword": "Key Password (Optional)", - "keyType": "Key Type", - "uploadFile": "Upload File", - "pasteKey": "Paste Key", - "credential": "Credential", - "overrideUsername": "Override Credential Username", - "overrideUsernameDesc": "Use a different username than the one stored in the credential", - "connectTerminal": "Connect to Terminal", - "connectFileManager": "Connect to File Manager", - "cancel": "Cancel", - "passwordRequired": "Password is required", - "keyRequired": "SSH key is required", - "credentialRequired": "Credential selection is required", - "connectionEstablished": "Connection established successfully", - "connectionFailed": "Failed to establish connection", - "autoDetect": "Auto Detect", - "authentication": "Authentication" - }, - "dragIndicator": { - "error": "Error: {{error}}", - "dragging": "Dragging {{fileName}}", - "preparing": "Preparing {{fileName}}", - "readySingle": "Ready to download {{fileName}}", - "readyMultiple": "Ready to download {{count}} files", - "batchDrag": "Drag {{count}} files to desktop", - "dragToDesktop": "Drag to desktop", - "canDragAnywhere": "You can drag files anywhere on your desktop" - }, - "sshTools": { - "title": "SSH Tools", - "closeTools": "Close SSH Tools", - "keyRecording": "Key Recording", - "startKeyRecording": "Start Key Recording", - "stopKeyRecording": "Stop Key Recording", - "selectTerminals": "Select terminals:", - "typeCommands": "Type commands (all keys supported):", - "commandsWillBeSent": "Commands will be sent to {{count}} selected terminal(s).", - "settings": "Settings", - "enableRightClickCopyPaste": "Enable right‑click copy/paste", - "shareIdeas": "Have ideas for what should come next for ssh tools? Share them on", - "scripts": { - "inputPlaceholder": "e.g., System Commands, Docker Scripts" - } - }, - "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", - "searchSnippets": "Search snippets...", - "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", - "shareTooltip": "Share this snippet", - "sharedWithYou": "Shared", - "sharedBy": "Shared by {{username}}", - "shareSnippet": "Share Snippet", - "user": "User", - "role": "Role", - "selectTarget": "Select...", - "currentAccess": "Current Access", - "shareSuccess": "Snippet shared successfully", - "shareFailed": "Failed to share snippet", - "revokeSuccess": "Access revoked", - "revokeFailed": "Failed to revoke access", - "failedToLoadShareData": "Failed to load sharing data", - "newFolder": "New Folder", - "reorderSameFolder": "Can only reorder snippets within the same folder", - "reorderSuccess": "Snippets reordered successfully", - "reorderFailed": "Failed to reorder snippets", - "deleteFolderConfirm": "Delete folder \"{{name}}\"? All snippets will be moved to Uncategorized.", - "deleteFolderSuccess": "Folder deleted successfully", - "deleteFolderFailed": "Failed to delete folder", - "updateFolderSuccess": "Folder updated successfully", - "createFolderSuccess": "Folder created successfully", - "updateFolderFailed": "Failed to update folder", - "createFolderFailed": "Failed to create folder", - "selectTerminals": "Select Terminals (optional)", - "executeOnSelected": "Execute on {{count}} selected terminal(s)", - "executeOnCurrent": "Execute on current terminal (click to select multiple)", - "folder": "Folder", - "selectFolder": "Select a folder or leave empty", - "noFolder": "No folder (Uncategorized)", - "folderName": "Folder Name", - "folderNameRequired": "Folder name is required", - "folderColor": "Folder Color", - "folderIcon": "Folder Icon", - "preview": "Preview", - "updateFolder": "Update Folder", - "createFolder": "Create Folder", - "editFolder": "Edit Folder", - "editFolderDescription": "Customize your snippet folder", - "createFolderDescription": "Organize your snippets into folders", - "confirmExecution": "Execute \"{{name}}\"?", - "confirmExecutionDesc": "Are you sure you want to execute this snippet?" - }, - "commandHistory": { - "title": "History", - "searchPlaceholder": "Search commands...", - "noTerminal": "No active terminal", - "noTerminalHint": "Open a terminal to see its command history.", - "empty": "No command history yet", - "emptyHint": "Execute commands in the active terminal to build its history.", - "noResults": "No commands found", - "noResultsHint": "No commands matching \"{{query}}\"", - "deleteSuccess": "Command deleted from history", - "deleteFailed": "Failed to delete command.", - "deleteTooltip": "Delete command", - "tabHint": "Use Tab in Terminal to autocomplete from command history if enabled in User Profile", - "authRequiredRefresh": "Authentication required. Please refresh the page.", - "dataAccessLockedReauth": "Data access locked. Please re-authenticate.", - "loading": "Loading command history...", - "error": "Error Loading History", - "disabledTitle": "Command History is Disabled", - "disabledDescription": "Enable Command History Tracking in your profile under Appearance settings." - }, - "splitScreen": { - "title": "Split Screen", - "none": "None", - "twoSplit": "2-Way", - "threeSplit": "3-Way", - "fourSplit": "4-Way", - "fiveSplit": "5-Way", - "sixSplit": "6-Way", - "availableTabs": "Available Tabs", - "dragTabsHint": "Drag tabs to the layout cells below to assign them", - "layout": "Split Screen Layout", - "dropHere": "Drop tab here", - "apply": "Apply Split", - "clear": "Clear Split", - "selectMode": "Select a split screen mode", - "helpText": "Choose how many tabs you want to view at once", - "success": "Split screen applied successfully", - "cleared": "Split screen cleared", - "error": { - "noAssignments": "Please assign at least one tab to the layout", - "fillAllSlots": "Please fill all {{count}} slots before applying" - } - }, - "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.", - "failedToLoadAlerts": "Failed to load alerts", - "failedToDismissAlert": "Failed to dismiss alert" - }, - "serverConfig": { - "title": "Server Configuration", - "description": "Configure the Termix server URL to connect to your backend services", - "serverUrl": "Server URL", - "enterServerUrl": "Please enter a server URL", - "testConnectionFirst": "Please test the connection first", - "connectionSuccess": "Connection successful!", - "connectionFailed": "Connection failed", - "connectionError": "Connection error occurred", - "connected": "Connected", - "disconnected": "Disconnected", - "configSaved": "Configuration saved successfully", - "saveFailed": "Failed to save configuration", - "saveError": "Error saving configuration", - "saving": "Saving...", - "saveConfig": "Save Configuration", - "helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:30001 or https://your-server.com)", - "warning": "Warning", - "notValidatedWarning": "URL not validated - ensure it's correct", - "changeServer": "Change Server", - "mustIncludeProtocol": "Server URL must start with http:// or https://", - "useEmbedded": "Use Local Server", - "embeddedDesc": "Run Termix with the built-in local server (no remote server needed)", - "embeddedConnecting": "Connecting to local server...", - "embeddedNotReady": "Local server is not ready yet. Please wait a moment and try again." - }, - "versionCheck": { - "error": "Version Check Error", - "checkFailed": "Failed to check for updates", - "upToDate": "App is Up to Date", - "currentVersion": "You are running version {{version}}", - "updateAvailable": "Update Available", - "newVersionAvailable": "A new version is available! You are running {{current}}, but {{latest}} is available.", - "betaVersion": "Beta Version", - "betaVersionDesc": "You are running {{current}}, which is newer than the latest stable release {{latest}}.", - "releasedOn": "Released on {{date}}", - "downloadUpdate": "Download Update", - "dismiss": "Dismiss", - "checking": "Checking for updates...", - "checkUpdates": "Check for Updates", - "checkingUpdates": "Checking for updates...", - "refresh": "Refresh", - "updateRequired": "Update Required", - "updateDismissed": "Update notification dismissed", - "noUpdatesFound": "No updates found" - }, - "common": { - "close": "Close", - "minimize": "Minimize", - "online": "Online", - "offline": "Offline", - "continue": "Continue", - "maintenance": "Maintenance", - "degraded": "Degraded", - "discord": "Discord", - "error": "Error", - "warning": "Warning", - "info": "Info", - "success": "Success", - "unsavedChanges": "Unsaved changes", - "dismiss": "Dismiss", - "loading": "Loading...", - "required": "Required", - "optional": "Optional", - "connect": "Connect", - "copied": "Copied", - "connecting": "Connecting...", - "creating": "Creating...", - "clear": "Clear", - "toggleSidebar": "Toggle Sidebar", - "sidebar": "Sidebar", - "home": "Home", - "expired": "Expired", - "expiresToday": "Expires today", - "expiresTomorrow": "Expires in {{days}} days", - "updateAvailable": "Update Available", - "sshPath": "SSH Path", - "localPath": "Local Path", - "appName": "Termix", - "openInNewTab": "Open in New Tab", - "resetSidebarWidth": "Reset sidebar width", - "dragToResizeSidebar": "Drag to resize sidebar", - "noAuthCredentials": "No authentication credentials available for this SSH host", - "noReleases": "No Releases", - "updatesAndReleases": "Updates & Releases", - "newVersionAvailable": "A new version ({{version}}) is available.", - "failedToFetchUpdateInfo": "Failed to fetch update information", - "preRelease": "Pre-release", - "loginFailed": "Login failed", - "noReleasesFound": "No releases found.", - "yourBackupCodes": "Your Backup Codes", - "sendResetCode": "Send Reset Code", - "verifyCode": "Verify Code", - "resetPassword": "Reset Password", - "resetCode": "Reset Code", - "newPassword": "New Password", - "folder": "Folder", - "file": "File", - "renamedSuccessfully": "renamed successfully", - "deletedSuccessfully": "deleted successfully", - "noTunnelConnections": "No tunnel connections configured", - "sshTools": "SSH Tools", - "english": "English", - "chinese": "Chinese", - "german": "German", - "cancel": "Cancel", - "done": "Done", - "username": "Username", - "name": "Name", - "login": "Login", - "logout": "Logout", - "register": "Register", - "password": "Password", - "version": "Version", - "confirmPassword": "Confirm Password", - "back": "Back", - "email": "Email", - "submit": "Submit", - "change": "Change", - "save": "Save", - "saving": "Saving...", - "delete": "Delete", - "rename": "Rename", - "edit": "Edit", - "add": "Add", - "search": "Search", - "confirm": "Confirm", - "yes": "Yes", - "no": "No", - "or": "OR", - "ok": "OK", - "enabled": "Enabled", - "disabled": "Disabled", - "important": "Important", - "notEnabled": "Not Enabled", - "settingUp": "Setting up...", - "next": "Next", - "previous": "Previous", - "refresh": "Refresh", - "settings": "Settings", - "profile": "Profile", - "help": "Help", - "about": "About", - "language": "Language", - "autoDetect": "Auto-detect", - "changeAccountPassword": "Change your account password", - "passwordResetTitle": "Password Reset", - "passwordResetDescription": "You are about to reset your password. This will log you out of all active sessions.", - "enterSixDigitCode": "Enter the 6-digit code from the docker container logs for user:", - "enterNewPassword": "Enter your new password for user:", - "passwordsDoNotMatch": "Passwords do not match", - "passwordMinLength": "Password must be at least 6 characters long", - "passwordResetSuccess": "Password reset successfully! You can now log in with your new password.", - "failedToInitiatePasswordReset": "Failed to initiate password reset", - "failedToVerifyResetCode": "Failed to verify reset code", - "failedToCompletePasswordReset": "Failed to complete password reset", - "documentation": "Documentation", - "retry": "Retry", - "checking": "Checking...", - "checkingDatabase": "Checking database connection...", - "checkingAuthentication": "Checking authentication...", - "backendReconnected": "Server connection restored", - "connectionDegraded": "Server connection lost, recovering…", - "reload": "Reload", - "actions": "Actions", - "remove": "Remove", - "revoke": "Revoke", - "create": "Create", - "update": "Update", - "pageOutdated": "This page is outdated. Please refresh to load the latest version.", - "refreshPage": "Refresh Page" - }, - "nav": { - "home": "Home", - "hosts": "Hosts", - "credentials": "Credentials", - "terminal": "Terminal", - "docker": "Docker", - "tunnels": "Tunnels", - "fileManager": "File Manager", - "serverStats": "Server Stats", - "admin": "Admin", - "userProfile": "User Profile", - "tools": "Tools", - "snippets": "Snippets", - "newTab": "New Tab", - "splitScreen": "Split Screen", - "closeTab": "Close Tab", - "sshManager": "SSH Manager", - "hostManager": "Host Manager", - "cannotSplitTab": "Cannot split this tab", - "tabNavigation": "Tab Navigation", - "hostTabTitle": "{{username}}@{{ip}}:{{port}}", - "copyPassword": "Copy Password", - "copySudoPassword": "Copy Sudo Password", - "passwordCopied": "Password copied to clipboard", - "sudoPasswordCopied": "Sudo password copied to clipboard", - "noPasswordAvailable": "No password available", - "failedToCopyPassword": "Failed to copy password", - "openFileManager": "Open File Manager" - }, - "admin": { - "title": "Admin Settings", - "oidc": "OIDC", - "users": "Users", - "userManagement": "User Management", - "makeAdmin": "Make Admin", - "removeAdmin": "Remove Admin", - "deleteUser": "Delete user {{username}}? This cannot be undone.", - "allowRegistration": "Allow Registration", - "oidcSettings": "OIDC Settings", - "clientId": "Client ID", - "clientSecret": "Client Secret", - "issuerUrl": "Issuer URL", - "authorizationUrl": "Authorization URL", - "tokenUrl": "Token URL", - "updateSettings": "Update Settings", - "confirmDelete": "Are you sure you want to delete this user?", - "confirmMakeAdmin": "Are you sure you want to make this user an admin?", - "confirmRemoveAdmin": "Are you sure you want to remove admin privileges from this user?", - "externalAuthentication": "External Authentication (OIDC)", - "configureExternalProvider": "Configure external identity provider for OIDC/OAuth2 authentication.", - "userIdentifierPath": "User Identifier Path", - "displayNamePath": "Display Name Path", - "scopes": "Scopes", - "saving": "Saving...", - "saveConfiguration": "Save Configuration", - "reset": "Reset", - "success": "Success", - "loading": "Loading...", - "refresh": "Refresh", - "loadingUsers": "Loading users...", - "username": "Username", - "type": "Type", - "actions": "Actions", - "external": "External", - "local": "Local", - "adminManagement": "Admin Management", - "makeUserAdmin": "Make User Admin", - "adding": "Adding...", - "currentAdmins": "Current Admins", - "adminBadge": "Admin", - "removeAdminButton": "Remove Admin", - "general": "General", - "userRegistration": "User Registration", - "allowNewAccountRegistration": "Allow new account registration", - "allowPasswordLogin": "Allow username/password login", - "allowPasswordReset": "Allow password reset via reset code", - "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", - "failedToDisableOidcConfig": "Failed to disable OIDC configuration", - "enterUsernameToMakeAdmin": "Enter username to make admin", - "userIsNowAdmin": "User {{username}} is now an admin", - "failedToMakeUserAdmin": "Failed to make user admin", - "removeAdminStatus": "Remove admin status from {{username}}?", - "adminStatusRemoved": "Admin status removed from {{username}}", - "failedToRemoveAdminStatus": "Failed to remove admin status", - "userDeletedSuccessfully": "User {{username}} deleted successfully", - "failedToDeleteUser": "Failed to delete user", - "overrideUserInfoUrl": "Override User Info URL (not required)", - "allowedUsers": "Allowed Users", - "allowedUsersDescription": "Comma-separated list of allowed email patterns. Use exact emails (user@example.com), domain suffixes (@example.com), or leave empty to allow all users.", - "failedToFetchSessions": "Failed to fetch sessions", - "sessionRevokedSuccessfully": "Session revoked successfully", - "failedToRevokeSession": "Failed to revoke session", - "confirmRevokeSession": "Are you sure you want to revoke this session?", - "confirmRevokeAllSessions": "Are you sure you want to revoke all sessions for this user?", - "failedToRevokeSessions": "Failed to revoke sessions", - "sessionsRevokedSuccessfully": "Sessions revoked successfully", - "linkToPasswordAccount": "Link to Password Account", - "linkOIDCDialogTitle": "Link OIDC Account to Password Account", - "linkOIDCDialogDescription": "Link {{username}} (OIDC user) to an existing password account. This will enable dual authentication for the password account.", - "createUser": "Create User", - "createUserDescription": "Create a new local user with username and password", - "enterUsername": "Enter username", - "enterPassword": "Enter password", - "userCreatedSuccessfully": "User {{username}} created successfully", - "failedToCreateUser": "Failed to create user", - "manageUser": "Manage User", - "manageUserDescription": "Manage user settings, roles, and permissions", - "authType": "Authentication Type", - "adminStatus": "Admin Status", - "userId": "User ID", - "regularUser": "Regular User", - "adminPrivileges": "Administrator Privileges", - "administratorRole": "Administrator Role", - "administratorRoleDescription": "Grant full system access and management privileges", - "passwordManagement": "Password Management", - "passwordResetWarning": "Resetting a user's password will delete all their data (SSH hosts, credentials, settings). This action cannot be undone.", - "resetUserPassword": "Reset User Password", - "resettingPassword": "Resetting...", - "passwordResetInitiated": "Password reset initiated for {{username}}. Reset code sent.", - "failedToResetPassword": "Failed to initiate password reset", - "sessionManagement": "Session Management", - "revokeAllSessions": "Revoke All Sessions", - "revokeAllSessionsDescription": "Force logout from all devices and sessions", - "revoking": "Revoking...", - "revoke": "Revoke All", - "dangerZone": "Danger Zone", - "deleteUserTitle": "Delete User Account", - "deleteUserWarning": "Permanently delete this user account and all associated data. This action cannot be undone.", - "deleting": "Deleting...", - "cannotDeleteSelf": "You cannot delete your own account", - "cannotRemoveLastAdmin": "Cannot remove the last administrator", - "cannotRemoveOwnAdmin": "You cannot remove your own admin privileges", - "cannotModifyOwnAdminStatus": "You cannot modify your own admin status", - "dualAuth": "Dual Auth", - "externalOIDC": "External (OIDC)", - "localPassword": "Local Password", - "confirmRevokeOwnSessions": "Are you sure you want to revoke all your own sessions? You will be logged out.", - "confirmMakeAdmin": "Are you sure you want to make {{username}} an admin?", - "confirmRemoveAdmin": "Are you sure you want to remove admin status from {{username}}?", - "linkOIDCWarningTitle": "Warning: OIDC User Data Will Be Deleted", - "linkOIDCActionDeleteUser": "Delete the OIDC user account and all their data", - "linkOIDCActionAddCapability": "Add OIDC login capability to the target password account", - "linkOIDCActionDualAuth": "Allow the password account to login with both password and OIDC", - "linkTargetUsernameLabel": "Target Password Account Username", - "linkTargetUsernamePlaceholder": "Enter username of password account", - "linkAccountsButton": "Link Accounts", - "linkingAccounts": "Linking...", - "accountsLinkedSuccessfully": "OIDC user {{oidcUsername}} has been linked to {{targetUsername}}", - "failedToLinkAccounts": "Failed to link accounts", - "linkTargetUsernameRequired": "Target username is required", - "unlinkOIDCTitle": "Unlink OIDC Authentication", - "unlinkOIDCDescription": "Remove OIDC authentication from {{username}}? The user will only be able to login with username/password after this.", - "unlinkOIDCSuccess": "OIDC unlinked from {{username}}", - "failedToUnlinkOIDC": "Failed to unlink OIDC", - "databaseSecurity": "Database Security", - "encryptionStatus": "Encryption Status", - "encryptionEnabled": "Encryption Enabled", - "enabled": "Enabled", - "disabled": "Disabled", - "keyId": "Key ID", - "created": "Created", - "migrationStatus": "Migration Status", - "migrationCompleted": "Migration completed", - "migrationRequired": "Migration required", - "deviceProtectedMasterKey": "Environment-Protected Master Key", - "legacyKeyStorage": "Legacy Key Storage", - "masterKeyEncryptedWithDeviceFingerprint": "Master key encrypted with environment fingerprint (KEK protection active)", - "keyNotProtectedByDeviceBinding": "Key not protected by environment binding (upgrade recommended)", - "valid": "Valid", - "initializeDatabaseEncryption": "Initialize Database Encryption", - "enableAes256EncryptionWithDeviceBinding": "Enable AES-256 encryption with environment-bound master key protection. This creates enterprise-grade security for SSH keys, passwords, and authentication tokens.", - "featuresEnabled": "Features enabled:", - "aes256GcmAuthenticatedEncryption": "AES-256-GCM authenticated encryption", - "deviceFingerprintMasterKeyProtection": "Environment fingerprint master key protection (KEK)", - "pbkdf2KeyDerivation": "PBKDF2 key derivation with 100K iterations", - "automaticKeyManagement": "Automatic key management and rotation", - "initializing": "Initializing...", - "initializeEnterpriseEncryption": "Initialize Enterprise Encryption", - "migrateExistingData": "Migrate Existing Data", - "encryptExistingUnprotectedData": "Encrypt existing unprotected data in your database. This process is safe and creates automatic backups.", - "testMigrationDryRun": "Verify Encryption Compatibility", - "migrating": "Migrating...", - "migrateData": "Migrate Data", - "securityInformation": "Security Information", - "sshPrivateKeysEncryptedWithAes256": "SSH private keys and passwords are encrypted with AES-256-GCM", - "userAuthTokensProtected": "User authentication tokens and 2FA secrets are protected", - "masterKeysProtectedByDeviceFingerprint": "Master encryption keys are protected by device fingerprint (KEK)", - "keysBoundToServerInstance": "Keys are bound to current server environment (migratable via environment variables)", - "pbkdf2HkdfKeyDerivation": "PBKDF2 + HKDF key derivation with 100K iterations", - "backwardCompatibleMigration": "All data remains backward compatible during migration", - "enterpriseGradeSecurityActive": "Enterprise-Grade Security Active", - "masterKeysProtectedByDeviceBinding": "Your master encryption keys are protected by environment fingerprinting. This uses server hostname, paths, and other environment info to generate protection keys. To migrate servers, set the DB_ENCRYPTION_KEY environment variable on the new server.", - "important": "Important", - "keepEncryptionKeysSecure": "Ensure data security: regularly backup your database files and server configuration. To migrate to a new server, set the DB_ENCRYPTION_KEY environment variable on the new environment, or maintain the same hostname and directory structure.", - "loadingEncryptionStatus": "Loading encryption status...", - "testMigrationDescription": "Verify that existing data can be safely migrated to encrypted format without actually modifying any data", - "serverMigrationGuide": "Server Migration Guide", - "migrationInstructions": "To migrate encrypted data to a new server: 1) Backup database files, 2) Set environment variable DB_ENCRYPTION_KEY=\"your-key\" on new server, 3) Restore database files", - "environmentProtection": "Environment Protection", - "environmentProtectionDesc": "Protects encryption keys based on server environment info (hostname, paths, etc.), migratable via environment variables", - "verificationCompleted": "Compatibility verification completed - no data was changed", - "verificationInProgress": "Verification completed", - "dataMigrationCompleted": "Data migration completed successfully!", - "verificationFailed": "Compatibility verification failed", - "migrationFailed": "Migration failed", - "runningVerification": "Running compatibility verification...", - "startingMigration": "Starting migration...", - "hardwareFingerprintSecurity": "Hardware Fingerprint Security", - "hardwareBoundEncryption": "Hardware-Bound Encryption Active", - "masterKeysNowProtectedByHardwareFingerprint": "Master keys are now protected by real hardware fingerprinting instead of environment variables", - "cpuSerialNumberDetection": "CPU serial number detection", - "motherboardUuidIdentification": "Motherboard UUID identification", - "diskSerialNumberVerification": "Disk serial number verification", - "biosSerialNumberCheck": "BIOS serial number check", - "stableMacAddressFiltering": "Stable MAC address filtering", - "databaseFileEncryption": "Database File Encryption", - "dualLayerProtection": "Dual-Layer Protection Active", - "bothFieldAndFileEncryptionActive": "Both field-level and file-level encryption are now active for maximum security", - "fieldLevelAes256Encryption": "Field-level AES-256 encryption for sensitive data", - "fileLevelDatabaseEncryption": "File-level database encryption with hardware binding", - "hardwareBoundFileKeys": "Hardware-bound file encryption keys", - "automaticEncryptedBackups": "Automatic encrypted backup creation", - "createEncryptedBackup": "Create Encrypted Backup", - "creatingBackup": "Creating Backup...", - "backupCreated": "Backup Created", - "encryptedBackupCreatedSuccessfully": "Encrypted backup created successfully", - "backupCreationFailed": "Backup creation failed", - "databaseMigration": "Database Migration", - "exportForMigration": "Export for Migration", - "exportDatabaseForHardwareMigration": "Export database as SQLite file with decrypted data for migration to new hardware", - "exportDatabase": "Export SQLite Database", - "exporting": "Exporting...", - "exportCreated": "SQLite Export Created", - "exportContainsDecryptedData": "SQLite export contains decrypted data - keep secure!", - "databaseExportedSuccessfully": "SQLite database exported successfully", - "databaseExportFailed": "SQLite database export failed", - "importFromMigration": "Import from Migration", - "importDatabaseFromAnotherSystem": "Import SQLite database from another system or hardware", - "importDatabase": "Import SQLite Database", - "importing": "Importing...", - "selectedFile": "Selected SQLite File", - "importWillReplaceExistingData": "SQLite import will replace existing data - backup recommended!", - "pleaseSelectImportFile": "Please select a SQLite import file", - "databaseImportedSuccessfully": "SQLite database imported successfully", - "databaseImportFailed": "SQLite database import failed", - "manageEncryptionAndBackups": "Manage encryption keys, database security, and backup operations", - "activeSecurityFeatures": "Currently active security measures and protections", - "deviceBindingTechnology": "Advanced hardware-based key protection technology", - "backupAndRecovery": "Secure backup creation and database recovery options", - "crossSystemDataTransfer": "Export and import databases across different systems", - "noMigrationNeeded": "No migration needed", - "encryptionKey": "Encryption Key", - "keyProtection": "Key Protection", - "active": "Active", - "legacy": "Legacy", - "dataStatus": "Data Status", - "encrypted": "Encrypted", - "needsMigration": "Needs Migration", - "ready": "Ready", - "initializeEncryption": "Initialize Encryption", - "initialize": "Initialize", - "test": "Test", - "migrate": "Migrate", - "backup": "Backup", - "createBackup": "Create Backup", - "exportImport": "Export/Import", - "export": "Export", - "import": "Import", - "passwordRequired": "Password required", - "confirmExport": "Confirm Export", - "exportDescription": "Export SSH hosts and credentials as SQLite file", - "importDescription": "Import SQLite file with incremental merge (skips duplicates)", - "criticalWarning": "Critical Warning", - "cannotDisablePasswordLoginWithoutOIDC": "Cannot disable password login without OIDC configured! You must configure OIDC authentication before disabling password login, or you will lose access to Termix.", - "confirmDisablePasswordLogin": "Are you sure you want to disable password login? Make sure OIDC is properly configured and working before proceeding, or you will lose access to your Termix instance.", - "passwordLoginDisabled": "Password login disabled successfully", - "passwordLoginAndRegistrationDisabled": "Password login and new account registration disabled successfully", - "requiresPasswordLogin": "Requires password login enabled", - "passwordLoginDisabledWarning": "Password login is disabled. Ensure OIDC is properly configured or you will not be able to log in to Termix.", - "oidcRequiredWarning": "CRITICAL: Password login is disabled. If you reset or misconfigure OIDC, you will lose all access to Termix and brick your instance. Only proceed if you are absolutely certain.", - "confirmDisableOIDCWarning": "WARNING: You are about to disable OIDC while password login is also disabled. This will brick your Termix instance and you will lose all access. Are you absolutely sure you want to proceed?", - "failedToUpdatePasswordLoginStatus": "Failed to update password login status", - "monitoringDefaults": "Monitoring Defaults", - "monitoringDefaultsDesc": "Global default intervals for status checks and metrics collection. Per-host settings override these values.", - "globalStatusCheckInterval": "Default Status Check Interval", - "globalMetricsInterval": "Default Metrics Collection Interval", - "globalSettingsSaved": "Global monitoring settings saved", - "failedToSaveGlobalSettings": "Failed to save global monitoring settings", - "failedToLoadGlobalSettings": "Failed to load global monitoring settings", - "sessionTimeout": "Session Timeout", - "sessionTimeoutDesc": "Configure how long user sessions last before requiring re-authentication. 'Remember Me' sessions are unaffected (always 30 days).", - "sessionTimeoutHours": "Timeout Duration", - "hours": "hours", - "sessionTimeoutNote": "Valid range: 1–720 hours. Changes apply to new sessions only.", - "sessionTimeoutSaved": "Session timeout saved", - "failedToSaveSessionTimeout": "Failed to save session timeout", - "guacamoleIntegration": "Remote Desktop Integration (Guacamole)", - "guacamoleIntegrationDesc": "Enable RDP, VNC, and Telnet connections via guacd. Requires a guacd instance to be running.", - "enableGuacamole": "Enable RDP/VNC/Telnet support", - "guacdUrl": "guacd URL (host:port)", - "guacdUrlPlaceholder": "guacd:4822", - "guacdUrlNote": "Changes to the guacd host/port require a server restart to take effect.", - "guacamoleSettingsSaved": "Guacamole settings saved", - "failedToSaveGuacamoleSettings": "Failed to save guacamole settings", - "failedToLoadGuacamoleSettings": "Failed to load guacamole settings", - "logLevel": "Log Level", - "logLevelDesc": "Control the verbosity of server logs. Higher levels reduce log output. Can also be set via the LOG_LEVEL environment variable.", - "logVerbosity": "Verbosity", - "logLevelNote": "Changes take effect immediately. Debug level may impact performance.", - "logLevelSaved": "Log level saved", - "failedToSaveLogLevel": "Failed to save log level", - "clampedToValidRange": "was adjusted to valid range", - "sessionManagement": "Session Management", - "loadingSessions": "Loading sessions...", - "noActiveSessions": "No active sessions found.", - "device": "Device", - "user": "User", - "created": "Created", - "lastActive": "Last Active", - "expires": "Expires", - "revoked": "Revoked", - "revokeAllUserSessionsTitle": "Revoke all sessions for this user", - "revokeAll": "Revoke All", - "linkOidcToPasswordAccount": "Link OIDC Account to Password Account", - "linkOidcToPasswordAccountDescription": "Link {{username}} (OIDC user) to an existing password account. This will enable dual authentication for the password account.", - "linkOidcWarningTitle": "Warning: OIDC User Data Will Be Deleted", - "linkOidcWarningDescription": "This action will:", - "linkOidcActionDeleteUser": "Delete the OIDC user account and all their data", - "linkOidcActionAddCapability": "Add OIDC login capability to the target password account", - "linkOidcActionDualAuth": "Allow the password account to login with both password and OIDC", - "linkTargetUsernameLabel": "Target Password Account Username", - "linkTargetUsernamePlaceholder": "Enter username of password account", - "linkingAccounts": "Linking...", - "linkAccountsButton": "Link Accounts", - "passwordMinLength": "Password must be at least 6 characters", - "currentRoles": "Current Roles", - "noRolesAssigned": "No roles assigned", - "assignNewRole": "Assign New Role", - "apiKeys": { - "tabLabel": "API Keys", - "title": "API Keys", - "createApiKey": "Create API Key", - "createApiKeyDescription": "Create a new API key scoped to a specific user. The token is shown only once.", - "keyCreated": "API Key Created", - "keyCreatedDescription": "Copy this key now — it will not be shown again.", - "keyName": "Key Name", - "keyNamePlaceholder": "e.g. CI/CD Pipeline", - "scopedUser": "Scoped User", - "selectUser": "Select a user...", - "searchUsers": "Search users...", - "noUsersFound": "No users found.", - "expiresAt": "Expires At", - "optional": "optional", - "expiresAtHelp": "Leave empty for a key that never expires.", - "copyWarningTitle": "Save your API key", - "copyWarningDescription": "This key will only be shown once. Store it in a safe place.", - "apiKey": "API Key", - "tokenCopied": "Token copied to clipboard", - "creating": "Creating...", - "nameRequired": "Key name is required", - "userRequired": "Please select a user", - "failedToCreate": "Failed to create API key", - "noKeys": "No API keys found.", - "name": "Name", - "prefix": "Prefix", - "lastUsed": "Last Used", - "never": "Never", - "revokeKey": "Revoke key", - "confirmRevoke": "Are you sure you want to revoke the API key \"{{name}}\"? This cannot be undone.", - "revokedSuccessfully": "API key revoked successfully", - "failedToRevoke": "Failed to revoke API key", - "failedToFetch": "Failed to fetch API keys" - } - }, - "hosts": { - "title": "Host Manager", - "hosts": "Hosts", - "sshHosts": "SSH Hosts", - "noHosts": "No SSH Hosts", - "noHostsMessage": "You haven't added any SSH hosts yet. Click \"Add Host\" to get started.", - "loadingHosts": "Loading hosts...", - "failedToLoadHosts": "Failed to load hosts", - "retry": "Retry", - "refresh": "Refresh", - "optional": "Optional", - "hostsCount": "{{count}} hosts", - "importJson": "Import JSON", - "importing": "Importing...", - "exportAllJson": "Export All", - "exporting": "Exporting...", - "exportedAllHosts": "Exported {{count}} hosts", - "failedToExportAllHosts": "Failed to export hosts. Please ensure you're logged in and have access to the host data.", - "exportAllSensitiveWarning": "The exported file will contain sensitive authentication data (passwords/SSH keys) in plaintext. Please keep the file secure and delete it after use.", - "importJsonTitle": "Import SSH Hosts from JSON", - "importJsonDesc": "Upload a JSON file to bulk import multiple SSH hosts (max 100).", - "downloadSample": "Download Sample", - "formatGuide": "Format Guide", - "exportCredentialWarning": "Warning: Host \"{{name}}\" uses credential authentication. The exported file will not include the credential data and will need to be manually reconfigured after import. Do you want to continue?", - "exportSensitiveDataWarning": "Warning: Host \"{{name}}\" contains sensitive authentication data (password/SSH key). The exported file will include this data in plaintext. Please keep the file secure and delete it after use. Do you want to continue?", - "uncategorized": "Uncategorized", - "confirmDelete": "Are you sure you want to delete \"{{name}}\" ?", - "failedToDeleteHost": "Failed to delete host", - "failedToExportHost": "Failed to export host. Please ensure you're logged in and have access to the host data.", - "jsonMustContainHosts": "JSON must contain a \"hosts\" array or be an array of hosts", - "noHostsInJson": "No hosts found in JSON file", - "maxHostsAllowed": "Maximum 100 hosts allowed per import", - "importCompleted": "Import completed: {{success}} successful, {{failed}} failed", - "importCreated": "created", - "importUpdated": "updated", - "importFailedCount": "failed", - "importSkipExisting": "Import (skip existing)", - "importOverwriteExisting": "Import (overwrite existing)", - "importFailed": "Import failed", - "importError": "Import error", - "failedToImportJson": "Failed to import JSON file", - "connectionDetails": "Connection Details", - "connectionType": "Connection Type", - "ssh": "SSH", - "rdp": "Remote Desktop (RDP)", - "vnc": "VNC", - "telnet": "Telnet", - "remoteDesktop": "Remote Desktop", - "guacamoleSettings": "Remote Desktop Settings", - "organization": "Organization", - "ipAddress": "IP Address or Hostname", - "macAddress": "MAC Address", - "macAddressDesc": "For Wake-on-LAN. Format: AA:BB:CC:DD:EE:FF", - "wakeOnLan": "Wake on LAN", - "wolSent": "Wake-on-LAN packet sent", - "wolFailed": "Failed to send Wake-on-LAN packet", - "ipRequired": "IP address is required", - "portRequired": "Port is required", - "port": "Port", - "name": "Name", - "username": "Username", - "usernameRequired": "Username is required (SSH/Telnet only)", - "folder": "Folder", - "tags": "Tags", - "pin": "Pin", - "notes": "Notes", - "expirationDate": "Expiration Date", - "passwordRequired": "Password is required when using password authentication", - "sshKeyRequired": "SSH Private Key is required when using key authentication", - "keyTypeRequired": "Key Type is required when using key authentication", - "mustSelectValidSshConfig": "Must select a valid SSH configuration from the list", - "addHost": "Add Host", - "editHost": "Edit Host", - "cloneHost": "Clone Host", - "updateHost": "Update Host", - "hostUpdatedSuccessfully": "Host \"{{name}}\" updated successfully!", - "hostAddedSuccessfully": "Host \"{{name}}\" added successfully!", - "hostDeletedSuccessfully": "Host \"{{name}}\" deleted successfully!", - "failedToSaveHost": "Failed to save host. Please try again.", - "savingHost": "Saving host...", - "updatingHost": "Updating host...", - "cloningHost": "Cloning host...", - "enableTerminal": "Enable Terminal", - "enableTerminalDesc": "Enable/disable host visibility in Terminal tab", - "enableTunnel": "Enable Tunnel", - "enableTunnelDesc": "Enable/disable host visibility in Tunnel tab", - "enableFileManager": "Enable File Manager", - "enableFileManagerDesc": "Enable/disable host visibility in File Manager tab", - "enableDockerDesc": "Enable/disable host visibility in Docker tab", - "enableDocker": "Enable Docker", - "defaultPath": "Default Path", - "defaultPathDesc": "Default directory when opening file manager for this host", - "connection": "Connection", - "remove": "Remove", - "sshpassRequired": "Sshpass Required For Password Authentication", - "sshpassRequiredDesc": "For password authentication in tunnels, sshpass must be installed on the system.", - "otherInstallMethods": "Other installation methods:", - "debianUbuntuEquivalent": "(Debian/Ubuntu) or the equivalent for your OS.", - "or": "or", - "centosRhelFedora": "CentOS/RHEL/Fedora", - "macos": "macOS", - "windows": "Windows (WSL, no official binary)", - "sshServerConfigRequired": "SSH Server Configuration Required", - "sshServerConfigDesc": "For tunnel connections, the SSH server must be configured to allow port forwarding:", - "gatewayPortsYes": "to bind remote ports to all interfaces", - "allowTcpForwardingYes": "to enable port forwarding", - "permitRootLoginYes": "if using root user for tunneling", - "editSshConfig": "Edit the SSH configuration file:", - "sshConfigPath": "/etc/ssh/sshd_config", - "restartSshService": "Then restart the SSH service:", - "restartSshCommand": "sudo systemctl restart sshd", - "upload": "Upload", - "authentication": "Authentication", - "password": "Password", - "key": "Key", - "credential": "Credential", - "none": "None", - "opkssh": "OPKSSH", - "opksshAuthDescription": "Prompts you for OPKSSH web-auth upon every connect. Your auth token will last for 24 hours until it must be renewed.", - "selectCredential": "Select Credential", - "selectCredentialPlaceholder": "Choose a credential...", - "credentialRequired": "Credential is required when using credential authentication", - "credentialDescription": "Selecting a credential will overwrite the current username and use the credential's authentication details.", - "cannotChangeAuthAsSharedUser": "Cannot change authentication as shared user", - "sshPrivateKey": "SSH Private Key", - "keyPassword": "Key Password", - "keyType": "Key Type", - "autoDetect": "Auto-detect", - "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": "Upload File", - "pasteKey": "Paste Key", - "updateKey": "Update Key", - "existingKey": "Existing Key (click to change)", - "existingCredential": "Existing Credential (click to change)", - "addTagsSpaceToAdd": "add tags (space to add)", - "terminalBadge": "Terminal", - "tunnelBadge": "Tunnel", - "fileManagerBadge": "File Manager", - "general": "General", - "terminal": "Terminal", - "tunnel": "Tunnel", - "fileManager": "File Manager", - "serverStats": "Server Stats", - "status": "Status", - "statistics": "Statistics", - "hostViewer": "Host Viewer", - "enableServerStats": "Enable Server Stats", - "enableServerStatsDesc": "Enable/disable server statistics collection for this host", - "displayItems": "Display Items", - "displayItemsDesc": "Choose which metrics to display on the server stats page", - "enableCpu": "CPU Usage", - "enableMemory": "Memory Usage", - "enableDisk": "Disk Usage", - "enableNetwork": "Network Statistics (Coming Soon)", - "enableProcesses": "Process Count (Coming Soon)", - "enableUptime": "Uptime (Coming Soon)", - "enableHostname": "Hostname (Coming Soon)", - "enableOs": "Operating System (Coming Soon)", - "customCommands": "Custom Commands (Coming Soon)", - "customCommandsDesc": "Define custom shutdown and reboot commands for this server", - "shutdownCommand": "Shutdown Command", - "rebootCommand": "Reboot Command", - "confirmRemoveFromFolder": "Are you sure you want to remove \"{{name}}\" from folder \"{{folder}}\"? The host will be moved to \"No Folder\".", - "removedFromFolder": "Host \"{{name}}\" removed from folder successfully", - "failedToRemoveFromFolder": "Failed to remove host from folder", - "folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully", - "failedToRenameFolder": "Failed to rename folder", - "editFolderAppearance": "Edit Folder Appearance", - "editFolderAppearanceDesc": "Customize the color and icon for folder", - "folderColor": "Folder Color", - "folderIcon": "Folder Icon", - "preview": "Preview", - "folderAppearanceUpdated": "Folder appearance updated successfully", - "failedToUpdateFolderAppearance": "Failed to update folder appearance", - "deleteAllHostsInFolder": "Delete All Hosts in Folder", - "confirmDeleteAllHostsInFolder": "Are you sure you want to delete all {{count}} hosts in folder \"{{folder}}\"? This action cannot be undone.", - "allHostsInFolderDeleted": "Deleted {{count}} hosts from folder \"{{folder}}\" successfully", - "failedToDeleteHostsInFolder": "Failed to delete hosts in folder", - "movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully", - "failedToMoveToFolder": "Failed to move host to folder", - "clickToRenameFolder": "Click to rename folder", - "renameFolder": "Rename folder", - "removeFromFolder": "Remove from folder \"{{folder}}\"", - "editHostTooltip": "Edit host", - "deleteHostTooltip": "Delete host", - "exportHostTooltip": "Export host", - "cloneHostTooltip": "Clone host", - "clickToEditHost": "Click to edit host", - "dragToMoveBetweenFolders": "Drag to move between folders", - "exportedHostConfig": "Exported host configuration for {{name}}", - "openTerminal": "Open Terminal", - "openRdp": "Open RDP", - "openVnc": "Open VNC", - "openTelnet": "Open Telnet", - "openFileManager": "Open File Manager", - "openTunnels": "Open Tunnels", - "openServerDetails": "Open Server Details", - "statistics": "Statistics", - "enabledWidgets": "Enabled Widgets", - "openServerStats": "Open Server Stats", - "openFileManager": "Open File Manager", - "openTunnels": "Open Tunnels", - "enabledWidgetsDesc": "Select which statistics widgets to display for this host", - "monitoringConfiguration": "Monitoring Configuration", - "monitoringConfigurationDesc": "Configure how often server statistics and status are checked", - "statusCheckEnabled": "Enable Status Monitoring", - "statusCheckEnabledDesc": "Check if the server is online or offline", - "statusCheckInterval": "Status Check Interval", - "statusCheckIntervalDesc": "How often to check if host is online (5s - 1h)", - "statusChecks": "Status Checks", - "disableTcpPing": "Disable TCP Ping", - "disableTcpPingDescription": "Turn off status checks (online/offline detection) for this host", - "useGlobalStatusInterval": "Use global default", - "useGlobalMetricsInterval": "Use global default", - "usingGlobalDefault": "Using global default ({{value}}s)", - "metricsCollection": "Metrics Collection", - "metricsEnabled": "Enable Metrics Monitoring", - "metricsEnabledDesc": "Collect CPU, RAM, disk, and other system statistics", - "metricsInterval": "Metrics Collection Interval", - "metricsIntervalDesc": "How often to collect server statistics (5s - 1h)", - "metricsNotAvailableForConnectionType": "Server metrics are only available for SSH hosts. TCP ping status checks can still be enabled above.", - "intervalSeconds": "seconds", - "intervalMinutes": "minutes", - "intervalValidation": "Monitoring intervals must be between 5 seconds and 1 hour (3600 seconds)", - "monitoringDisabled": "Server monitoring is disabled for this host", - "enableMonitoring": "Enable monitoring in Host Manager → Statistics tab", - "monitoringDisabledBadge": "Monitoring Off", - "statusMonitoring": "Status", - "metricsMonitoring": "Metrics", - "terminalCustomization": "Terminal Customization", - "appearance": "Appearance", - "behavior": "Behavior", - "advanced": "Advanced", - "themePreview": "Theme Preview", - "theme": "Theme", - "selectTheme": "Select theme", - "chooseColorTheme": "Choose a color theme for the terminal", - "fontFamily": "Font Family", - "selectFont": "Select font", - "selectFontDesc": "Select the font to use in the terminal", - "fontSize": "Font Size", - "fontSizeValue": "Font Size: {{value}}px", - "adjustFontSize": "Adjust the terminal font size", - "letterSpacing": "Letter Spacing", - "letterSpacingValue": "Letter Spacing: {{value}}px", - "adjustLetterSpacing": "Adjust spacing between characters", - "lineHeight": "Line Height", - "lineHeightValue": "Line Height: {{value}}", - "adjustLineHeight": "Adjust spacing between lines", - "cursorStyle": "Cursor Style", - "selectCursorStyle": "Select cursor style", - "cursorStyleBlock": "Block", - "cursorStyleUnderline": "Underline", - "cursorStyleBar": "Bar", - "chooseCursorAppearance": "Choose the cursor appearance", - "cursorBlink": "Cursor Blink", - "enableCursorBlink": "Enable cursor blinking animation", - "scrollbackBuffer": "Scrollback Buffer", - "scrollbackBufferValue": "Scrollback Buffer: {{value}} lines", - "scrollbackBufferDesc": "Number of lines to keep in scrollback history", - "bellStyle": "Bell Style", - "selectBellStyle": "Select bell style", - "bellStyleNone": "None", - "bellStyleSound": "Sound", - "bellStyleVisual": "Visual", - "bellStyleBoth": "Both", - "bellStyleDesc": "How to handle terminal bell (BEL character, \\x07). Programs trigger this when completing tasks, encountering errors, or for notifications. \"Sound\" plays an audio beep, \"Visual\" flashes the screen briefly, \"Both\" does both, \"None\" disables bell alerts.", - "rightClickSelectsWord": "Right Click Selects Word", - "rightClickSelectsWordDesc": "Right-clicking selects the word under cursor", - "fastScrollModifier": "Fast Scroll Modifier", - "selectModifier": "Select modifier", - "modifierAlt": "Alt", - "modifierCtrl": "Ctrl", - "modifierShift": "Shift", - "fastScrollModifierDesc": "Modifier key for fast scrolling", - "fastScrollSensitivity": "Fast Scroll Sensitivity", - "fastScrollSensitivityValue": "Fast Scroll Sensitivity: {{value}}", - "fastScrollSensitivityDesc": "Scroll speed multiplier when modifier is held", - "minimumContrastRatio": "Minimum Contrast Ratio", - "minimumContrastRatioValue": "Minimum Contrast Ratio: {{value}}", - "minimumContrastRatioDesc": "Automatically adjust colors for better readability", - "sshAgentForwarding": "SSH Agent Forwarding", - "sshAgentForwardingDesc": "Forward SSH authentication agent to remote host", - "backspaceMode": "Backspace Mode", - "selectBackspaceMode": "Select backspace mode", - "backspaceModeNormal": "Normal (DEL)", - "backspaceModeControlH": "Control-H (^H)", - "backspaceModeDesc": "Backspace key behavior for compatibility", - "startupSnippet": "Startup Snippet", - "selectSnippet": "Select snippet", - "searchSnippets": "Search snippets...", - "snippetNone": "None", - "noneAuthTitle": "Keyboard-Interactive Authentication", - "noneAuthDescription": "This authentication method will use keyboard-interactive authentication when connecting to the SSH server.", - "noneAuthDetails": "Keyboard-interactive authentication allows the server to prompt you for credentials during connection. This is useful for servers that require multi-factor authentication or if you do not want to save credentials locally.", - "opksshAuthTitle": "OPKSSH Authentication", - "forceKeyboardInteractive": "Force Keyboard-Interactive", - "forceKeyboardInteractiveDesc": "Forces the use of keyboard-interactive authentication. This is sometimes required for servers that use Two-Factor Authentication (TOTP/2FA).", - "overrideCredentialUsername": "Override Credential Username", - "overrideCredentialUsernameDesc": "Use a different username than the one stored in the credential. This allows you to use the same credential with different usernames.", - "jumpHosts": "Jump Hosts", - "jumpHostsDescription": "Jump hosts (also known as bastion hosts) allow you to connect to a target server through one or more intermediate servers. This is useful for accessing servers behind firewalls or in private networks.", - "jumpHostChain": "Jump Host Chain", - "addJumpHost": "Add Jump Host", - "selectServer": "Select Server", - "searchServers": "Search servers...", - "noServerFound": "No server found", - "jumpHostsOrder": "Connections will be made in order: Jump Host 1 → Jump Host 2 → ... → Target Server", - "socks5Proxy": "SOCKS5 Proxy", - "portKnocking": "Port Knocking", - "portKnockingDesc": "Send a sequence of connection attempts to specified ports before connecting. Used to open firewall rules dynamically.", - "addKnock": "Add Port", - "delayMs": "Delay", - "socks5Description": "Configure SOCKS5 proxy for SSH connection. All traffic will be routed through the specified proxy server.", - "enableSocks5": "Enable SOCKS5 Proxy", - "enableSocks5Description": "Use SOCKS5 proxy for this SSH connection", - "socks5Host": "Proxy Host", - "socks5Port": "Proxy Port", - "socks5Username": "Proxy Username", - "socks5Password": "Proxy Password", - "socks5UsernameOptional": "Optional: leave empty if proxy doesn't require authentication", - "socks5PasswordOptional": "Optional: leave empty if proxy doesn't require authentication", - "socks5ProxyChain": "Proxy Chain", - "socks5ProxyChainDescription": "Configure a chain of SOCKS proxies. Each proxy in the chain will connect through the previous one.", - "socks5ProxyMode": "Proxy Mode", - "socks5UseSingleProxy": "Use Single Proxy", - "socks5UseProxyChain": "Use Proxy Chain", - "socks5UsePreset": "Use Saved Preset", - "socks5SelectPreset": "Select Preset", - "socks5ManagePresets": "Manage Presets", - "socks5ProxyNode": "Proxy {{number}}", - "socks5AddProxy": "Add Proxy to Chain", - "socks5RemoveProxy": "Remove Proxy", - "socks5ProxyType": "Proxy Type", - "socks5SaveAsPreset": "Save as Preset", - "socks5SavePresetTitle": "Save Proxy Chain as Preset", - "socks5SavePresetDescription": "Save the current proxy chain configuration as a reusable preset", - "socks5PresetName": "Preset Name", - "socks5PresetDescription": "Description (optional)", - "socks5PresetCreated": "Proxy chain preset created", - "socks5PresetUpdated": "Proxy chain preset updated", - "socks5PresetDeleted": "Proxy chain preset deleted", - "socks5PresetSaved": "Preset \"{{name}}\" saved successfully", - "socks5PresetSaveError": "Failed to save preset", - "socks5PresetNameRequired": "Preset name is required", - "socks5EmptyChainError": "Cannot save an empty proxy chain", - "socks5ProxyChainEmpty": "Add at least one proxy to the chain", - "socks5HostDescription": "Hostname or IP address of the SOCKS proxy server", - "socks5PortDescription": "Port number of the SOCKS proxy server (default: 1080)", - "addProxyNode": "Add Proxy Node", - "noProxyNodes": "No proxy nodes configured. Click 'Add Proxy Node' to add one.", - "proxyNode": "Proxy Node", - "proxyType": "Proxy Type", - "quickActions": "Quick Actions", - "quickActionsDescription": "Quick actions allow you to create custom buttons that execute SSH snippets on this server. These buttons will appear at the top of the Server Stats page for quick access.", - "quickActionsList": "Quick Actions List", - "addQuickAction": "Add Quick Action", - "quickActionName": "Action name", - "noSnippetFound": "No snippet found", - "quickActionsOrder": "Quick action buttons will appear in the order listed above on the Server Stats page", - "sidebarCustomization": "Sidebar Button Customization", - "sidebarCustomizationDesc": "Choose which actions appear as quick buttons in the sidebar. Actions not shown as buttons will appear in the dropdown menu.", - "showTerminalInSidebar": "Show Terminal Button", - "showTerminalInSidebarDesc": "Display terminal as a quick button in the sidebar", - "showFileManagerInSidebar": "Show File Manager Button", - "showFileManagerInSidebarDesc": "Display file manager as a quick button in the sidebar", - "showTunnelInSidebar": "Show Tunnel Button", - "showTunnelInSidebarDesc": "Display tunnel management as a quick button in the sidebar", - "showDockerInSidebar": "Show Docker Button", - "showDockerInSidebarDesc": "Display docker management as a quick button in the sidebar", - "showServerStatsInSidebar": "Show Server Stats Button", - "showServerStatsInSidebarDesc": "Display server statistics as a quick button in the sidebar", - "atLeastOneActionRequired": "At least one enabled action must be shown in the sidebar", - "advancedAuthSettings": "Advanced Authentication Settings", - "sudoPasswordAutoFill": "Sudo Password Auto-Fill", - "sudoPasswordAutoFillDesc": "Automatically offer to insert SSH password when sudo prompts for password", - "sudoPassword": "Sudo Password", - "sudoPasswordDesc": "Optional password for sudo commands (useful with key authentication)", - "keepaliveSettings": "SSH Keepalive", - "keepaliveSettingsDesc": "Configure SSH-level keepalive behavior. Set interval to 0 to disable SSH keepalives (useful for MikroTik and other devices that ignore keepalive requests).", - "keepaliveInterval": "Keepalive Interval (ms)", - "keepaliveIntervalDesc": "How often to send SSH keepalive pings. Default: 30000 (30s). Set to 0 to disable.", - "keepaliveCountMax": "Keepalive Max Failures", - "keepaliveCountMaxDesc": "Disconnect after this many unanswered keepalives. Default: 3.", - "socks4": "SOCKS4", - "socks5": "SOCKS5", - "httpConnect": "HTTP CONNECT", - "testProxy": "Test Connection", - "testingProxy": "Testing...", - "proxyTestSuccess": "Proxy connection successful ({{latency}}ms)", - "proxyTestFailed": "Proxy test failed: {{error}}", - "connectionPath": "Connection Path", - "executeSnippetOnConnect": "Execute a snippet when the terminal connects", - "autoMosh": "Auto-MOSH", - "autoMoshDesc": "Automatically run MOSH command on connect", - "moshCommand": "MOSH Command", - "moshCommandDesc": "The MOSH command to execute", - "autoTmux": "Auto-tmux", - "autoTmuxDesc": "Automatically attach to an existing (or start a new) tmux session for persistent sessions and cross-device continuity", - "environmentVariables": "Environment Variables", - "environmentVariablesDesc": "Set custom environment variables for the terminal session", - "variableName": "Variable name", - "variableValue": "Value", - "addVariable": "Add Variable", - "docker": "Docker", - "openDocker": "Open Docker", - "copyFullScreenUrl": "Copy Full-Screen URL", - "fullScreenUrlCopied": "Full-screen URL copied to clipboard", - "failedToCopyUrl": "Failed to copy URL to clipboard", - "copyTerminalUrl": "Copy Terminal URL", - "copyFileManagerUrl": "Copy File Manager URL", - "copyTunnelUrl": "Copy Tunnel URL", - "copyServerStatsUrl": "Copy Server Stats URL", - "copyDockerUrl": "Copy Docker URL", - "copyRemoteDesktopUrl": "Copy Remote Desktop URL", - "fullScreenUrlTooltip": "Copy URL to open this app in full-screen mode", - "notEnabled": "Docker is not enabled for this host. Enable it in Host Settings to use Docker features.", - "validating": "Validating Docker...", - "error": "Error", - "errorCode": "Error code: {{code}}", - "version": "Docker v{{version}}", - "current": "Current", - "used_limit": "Used / Limit", - "percentage": "Percentage", - "input": "Input", - "output": "Output", - "read": "Read", - "write": "Write", - "pids": "PIDs", - "name": "Name", - "id": "ID", - "state": "State", - "console": "Console", - "containerMustBeRunning": "Container must be running to connect to console", - "authenticationRequired": "Authentication required", - "connectedTo": "Connected to {{containerName}}", - "disconnected": "Disconnected", - "consoleError": "Console error", - "errorMessage": "Error: {{message}}", - "failedToConnect": "Failed to connect to console", - "disconnectedFromContainer": "Disconnected from container console.", - "containerNotRunning": "Container is not running", - "startContainerToAccess": "Start the container to access the console", - "selectShell": "Select shell", - "bash": "Bash", - "sh": "Sh", - "ash": "Ash", - "connecting": "Connecting...", - "connect": "Connect", - "disconnect": "Disconnect", - "notConnected": "Not connected", - "clickToConnect": "Click Connect to start an interactive shell", - "connectingTo": "Connecting to {{containerName}}...", - "containerMustBeRunningToViewStats": "Container must be running to view stats", - "failedToFetchStats": "Failed to fetch stats", - "noContainersFound": "No containers found", - "noContainersFoundHint": "Start by creating containers on your server", - "searchPlaceholder": "Search by name, image, or ID...", - "filterByStatusPlaceholder": "Filter by status", - "allContainersCount": "All ({{count}})", - "statusCount": "{{status}} ({{count}})", - "noContainersMatchFilters": "No containers match your filters", - "noContainersMatchFiltersHint": "Try adjusting your search or filter", - "containerStarted": "Container {{name}} started", - "failedToStartContainer": "Failed to start container: {{error}}", - "containerStopped": "Container {{name}} stopped", - "failedToStopContainer": "Failed to stop container: {{error}}", - "containerRestarted": "Container {{name}} restarted", - "failedToRestartContainer": "Failed to restart container: {{error}}", - "containerUnpaused": "Container {{name}} unpaused", - "containerPaused": "Container {{name}} paused", - "failedToTogglePauseContainer": "Failed to {{action}} container: {{error}}", - "containerRemoved": "Container {{name}} removed", - "failedToRemoveContainer": "Failed to remove container: {{error}}", - "image": "Image:", - "idLabel": "ID:", - "ports": "Ports:", - "noPorts": "None", - "created": "Created:", - "start": "Start", - "stop": "Stop", - "unpause": "Unpause", - "pause": "Pause", - "restart": "Restart", - "remove": "Remove", - "removeContainer": "Remove Container", - "confirmRemoveContainer": "Are you sure you want to remove container \"{{name}}\"?", - "runningContainerWarning": "Warning: This container is currently running and will be force-removed.", - "removing": "Removing:", - "containerNotFound": "Container not found", - "backToList": "Back to list", - "logs": "Logs", - "stats": "Stats", - "consoleTab": "Console", - "failedToFetchLogs": "Failed to fetch logs: {{error}}", - "failedToDownloadLogs": "Failed to download logs: {{error}}", - "linesToShow": "Lines to show", - "last50Lines": "Last 50 lines", - "last100Lines": "Last 100 lines", - "last500Lines": "Last 500 lines", - "last1000Lines": "Last 1000 lines", - "allLogs": "All logs", - "showTimestamps": "Show Timestamps", - "autoRefresh": "Auto Refresh", - "filterLogsPlaceholder": "Filter logs...", - "noLogsAvailable": "No logs available", - "selectMode": "Select", - "exitSelectMode": "Cancel", - "selectedCount": "{{count}} selected", - "bulkMonitoring": "Monitoring", - "bulkFeatures": "Features", - "bulkMoveFolder": "Move to Folder", - "bulkPin": "Pin", - "bulkUnpin": "Unpin", - "enableAllFeatures": "Enable All Features", - "disableAllFeatures": "Disable All Features", - "enableStatusCheck": "Enable Status Check", - "disableStatusCheck": "Disable Status Check", - "enableMetrics": "Enable Metrics", - "disableMetrics": "Disable Metrics", - "bulkEnableTerminal": "Enable Terminal", - "bulkDisableTerminal": "Disable Terminal", - "bulkEnableTunnel": "Enable Tunnel", - "bulkDisableTunnel": "Disable Tunnel", - "bulkEnableFileManager": "Enable File Manager", - "bulkDisableFileManager": "Disable File Manager", - "bulkEnableDocker": "Enable Docker", - "bulkDisableDocker": "Disable Docker", - "bulkUpdateSuccess": "Updated {{count}} host(s) successfully", - "bulkUpdateFailed": "Bulk update failed", - "selectAll": "Select all", - "deselectAll": "Deselect all", - "useGlobalStatusDefault": "Use Global Default (Status)", - "useGlobalMetricsDefault": "Use Global Default (Metrics)", - "connectionType": "Connection Type", - "rdp": "RDP", - "vnc": "VNC", - "telnet": "Telnet", - "ssh": "SSH", - "remoteDesktop": "Remote Desktop", - "remoteDesktopSettings": "Remote Desktop Settings", - "domain": "Domain", - "securityMode": "Security Mode", - "ignoreCert": "Ignore Certificate", - "ignoreCertDesc": "Skip TLS certificate verification for this connection", - "displaySettings": "Display Settings", - "colorDepth": "Color Depth", - "width": "Width", - "height": "Height", - "dpi": "DPI", - "resizeMethod": "Resize Method", - "forceLossless": "Force Lossless", - "audioSettings": "Audio Settings", - "disableAudio": "Disable Audio", - "enableAudioInput": "Enable Audio Input", - "rdpPerformance": "Performance", - "enableWallpaper": "Enable Wallpaper", - "enableTheming": "Enable Theming", - "enableFontSmoothing": "Enable Font Smoothing", - "enableFullWindowDrag": "Enable Full Window Drag", - "enableDesktopComposition": "Enable Desktop Composition", - "enableMenuAnimations": "Enable Menu Animations", - "disableBitmapCaching": "Disable Bitmap Caching", - "disableOffscreenCaching": "Disable Offscreen Caching", - "disableGlyphCaching": "Disable Glyph Caching", - "enableGfx": "Enable GFX", - "deviceRedirection": "Device Redirection", - "enablePrinting": "Enable Printing", - "enableDrive": "Enable Drive Redirection", - "driveName": "Drive Name", - "drivePath": "Drive Path", - "createDrivePath": "Create Drive Path", - "disableDownload": "Disable Download", - "disableUpload": "Disable Upload", - "enableTouch": "Enable Touch", - "rdpSession": "Session", - "clientName": "Client Name", - "consoleSession": "Console Session", - "initialProgram": "Initial Program", - "serverLayout": "Server Keyboard Layout", - "timezone": "Timezone", - "gatewaySettings": "Gateway", - "gatewayHostname": "Gateway Hostname", - "gatewayPort": "Gateway Port", - "gatewayUsername": "Gateway Username", - "gatewayPassword": "Gateway Password", - "gatewayDomain": "Gateway Domain", - "remoteApp": "RemoteApp", - "remoteAppProgram": "Remote Application", - "remoteAppDir": "Remote App Directory", - "remoteAppArgs": "Remote App Arguments", - "clipboardSettings": "Clipboard", - "normalizeClipboard": "Normalize Clipboard", - "disableCopy": "Disable Copy", - "disablePaste": "Disable Paste", - "vncSettings": "VNC Settings", - "cursorMode": "Cursor Mode", - "swapRedBlue": "Swap Red/Blue", - "readOnly": "Read Only", - "recordingSettings": "Recording", - "recordingPath": "Recording Path", - "recordingName": "Recording Name", - "createRecordingPath": "Create Recording Path", - "excludeOutput": "Exclude Output", - "excludeMouse": "Exclude Mouse", - "includeKeys": "Include Keys", - "wakeOnLan": "Wake-on-LAN", - "sendWolPacket": "Send WoL Packet", - "wolMacAddr": "MAC Address", - "wolBroadcastAddr": "Broadcast Address", - "wolUdpPort": "UDP Port", - "wolWaitTime": "Wait Time (seconds)", - "connectionSettings": "Connection Settings", - "rdpOnly": "RDP only", - "vncOnly": "VNC only", - "telnetTerminalSettings": "Terminal Settings", - "terminalType": "Terminal Type", - "guacFontName": "Font Name", - "guacFontSize": "Font Size", - "guacColorScheme": "Color Scheme", - "guacBackspaceKey": "Backspace Key" - }, - "guacamole": { - "connecting": "Connecting to {{type}} session...", - "rdpConnecting": "Connecting to RDP server...", - "vncConnecting": "Connecting to VNC server...", - "telnetConnecting": "Connecting to Telnet server...", - "connectionError": "Connection error", - "connectionFailed": "Connection failed", - "failedToConnect": "Failed to get connection token" - }, - "terminal": { - "title": "Split Screen", - "none": "None", - "twoSplit": "2-Split", - "threeSplit": "3-Split", - "fourSplit": "4-Split", - "fiveSplit": "5-Split", - "sixSplit": "6-Split", - "availableTabs": "Available Tabs", - "dragTabsHint": "Drag tabs into the grid below to position them", - "layout": "Layout", - "dropHere": "Drop tab here", - "apply": "Apply Split", - "clear": "Clear", - "selectMode": "Select a split mode to get started", - "helpText": "Choose how many tabs you want to display at once", - "error": { - "noAssignments": "Please drag tabs to cells before applying", - "fillAllSlots": "Please fill all {{count}} layout spots before applying" - }, - "success": "Split screen applied", - "cleared": "Split screen cleared" - }, - "terminal": { - "title": "Terminal", - "connect": "Connect to Host", - "disconnect": "Disconnect", - "clear": "Clear", - "copy": "Copy", - "paste": "Paste", - "find": "Find", - "fullscreen": "Fullscreen", - "splitHorizontal": "Split Horizontal", - "splitVertical": "Split Vertical", - "closePanel": "Close Panel", - "reconnect": "Reconnect", - "sessionEnded": "Session Ended", - "connectionLost": "Connection Lost", - "error": "ERROR: {{message}}", - "disconnected": "Disconnected", - "connectionClosed": "Connection closed", - "connectionError": "Connection error: {{message}}", - "connected": "Connected", - "clipboardWriteFailed": "Failed to copy to clipboard. Make sure the page is served over HTTPS or localhost.", - "clipboardReadFailed": "Failed to read from clipboard. Make sure clipboard permissions are granted.", - "clipboardHttpWarning": "Paste requires HTTPS. Use Ctrl+Shift+V or serve Termix over HTTPS.", - "sshConnected": "SSH connection established", - "authError": "Authentication failed: {{message}}", - "unknownError": "Unknown error occurred", - "messageParseError": "Failed to parse server message", - "websocketError": "WebSocket connection error", - "connecting": "Connecting...", - "reconnecting": "Reconnecting... ({{attempt}}/{{max}})", - "reconnected": "Reconnected successfully", - "tmuxSessionCreated": "tmux session created: {{name}}", - "tmuxSessionAttached": "tmux session attached: {{name}}", - "tmuxUnavailable": "tmux is not installed on the remote host, falling back to standard shell", - "tmuxSessionPickerTitle": "tmux Sessions", - "tmuxSessionPickerDesc": "Existing tmux sessions found on this host. Select one to reattach or create a new session.", - "tmuxWindows": "Windows", - "tmuxWindowCount": "{{count}} window", - "tmuxWindowCount_other": "{{count}} windows", - "tmuxAttached": "Attached clients", - "tmuxAttachedCount": "{{count}} attached", - "tmuxLastActivity": "Last activity", - "tmuxTimeJustNow": "just now", - "tmuxTimeMinutes": "{{count}}m ago", - "tmuxTimeHours": "{{count}}h ago", - "tmuxTimeDays": "{{count}}d ago", - "tmuxCreateNew": "Start new session", - "tmuxCopyHint": "Adjust selection and press Enter to copy to clipboard", - "maxReconnectAttemptsReached": "Maximum reconnection attempts reached", - "connectionLost": "Connection lost", - "reconnect": "Reconnect", - "closeTab": "Close", - "connectionTimeout": "Connection timeout", - "terminalTitle": "Terminal - {{host}}", - "terminalWithPath": "Terminal - {{host}}:{{path}}", - "runTitle": "Running {{command}} - {{host}}", - "totpRequired": "Two-Factor Authentication Required", - "totpCodeLabel": "Verification Code", - "totpPlaceholder": "000000", - "totpVerify": "Verify", - "warpgateAuthRequired": "Warpgate Authentication Required", - "warpgateSecurityKey": "Security Key", - "warpgateAuthUrl": "Authentication URL", - "warpgateOpenBrowser": "Open in Browser", - "warpgateContinue": "I've Completed Authentication", - "warpgateTimeout": "Warpgate authentication timeout. Please reconnect.", - "opksshAuthRequired": "OPKSSH Authentication Required", - "opksshAuthDescription": "Complete authentication in your browser to continue. This session will remain valid for 24 hours.", - "opksshAuthUrl": "Authentication URL", - "opksshOpenBrowser": "Open Browser to Authenticate", - "opksshWaitingForAuth": "Waiting for authentication in browser...", - "opksshAuthenticating": "Processing authentication...", - "opksshTimeout": "Authentication timed out. Please try again.", - "opksshAuthFailed": "Authentication failed. Please check your credentials and try again.", - "opksshConfigMissing": "OPKSSH configuration not found. Please create ~/.opk/config.yml with your OIDC provider settings. See documentation: https://github.com/openpubkey/opkssh#configuration", - "opksshSignInWith": "Sign in with {{provider}}", - "sudoPasswordPopupTitle": "Insert Password?", - "sudoPasswordPopupHint": "Press Enter to insert, Esc to dismiss", - "sudoPasswordPopupConfirm": "Insert", - "sudoPasswordPopupDismiss": "Dismiss", - "websocketAbnormalClose": "Connection closed unexpectedly. This may be due to a reverse proxy or SSL configuration issue. Please check server logs.", - "totpInvalid": "Invalid code. Please try again.", - "authMethodFallback": "{{method}} authentication failed. Trying alternative method...", - "authenticationRequired": "Authentication required", - "connectionLogTitle": "Connection Log", - "connectionLogCopy": "Copy logs to clipboard", - "connectionLogClear": "Clear logs", - "connectionLogEmpty": "No connection logs yet", - "connectionLogConnecting": "Connecting to host...", - "connectionLogCopied": "Connection logs copied to clipboard", - "connectionLogCopyFailed": "Failed to copy logs to clipboard", - "allAuthMethodsFailed": "All authentication methods failed. Please check your credentials.", - "methodNotSupported": "Server does not support {{method}} authentication. Available methods: {{available}}", - "retryWithMethod": "Retry with {{method}}", - "automaticFallback": "Automatically trying {{method}} authentication...", - "totpTimeout": "TOTP verification timeout. Please reconnect.", - "passwordTimeout": "Password verification timeout. Please reconnect.", - "sessionEnded": "Session ended.", - "connectionRejected": "Connection rejected by server. Please check your authentication and network configuration.", - "hostKeyRejected": "SSH host key verification rejected. Connection cancelled.", - "sessionTakenOver": "Session was opened in another tab. Reconnecting...", - "sessionAttachTimeout": "Session attachment timed out. Creating new connection...", - "openFileManagerHere": "Open File Manager Here" - }, - "fileManager": { - "title": "File Manager", - "file": "File", - "folder": "Folder", - "connectToSsh": "Connect to SSH to use file operations", - "uploadFile": "Upload File", - "downloadFile": "Download", - "extractArchive": "Extract Archive", - "extractingArchive": "Extracting {{name}}...", - "archiveExtractedSuccessfully": "{{name}} extracted successfully", - "extractFailed": "Extract failed", - "compressFile": "Compress File", - "compressFiles": "Compress Files", - "compressFilesDesc": "Compress {{count}} items into an archive", - "archiveName": "Archive Name", - "enterArchiveName": "Enter archive name...", - "compressionFormat": "Compression Format", - "selectedFiles": "Selected files", - "andMoreFiles": "and {{count}} more...", - "compress": "Compress", - "compressingFiles": "Compressing {{count}} items into {{name}}...", - "filesCompressedSuccessfully": "{{name}} created successfully", - "compressFailed": "Compression failed", - "edit": "Edit", - "preview": "Preview", - "previous": "Previous", - "next": "Next", - "pageXOfY": "Page {{current}} of {{total}}", - "zoomOut": "Zoom Out", - "zoomIn": "Zoom In", - "newFile": "New File", - "newFolder": "New Folder", - "rename": "Rename", - "renameItem": "Rename Item", - "deleteItem": "Delete Item", - "currentPath": "Current Path", - "uploadFileTitle": "Upload File", - "maxFileSize": "Max: 1GB (JSON) / 5GB (Binary) - Large files supported", - "removeFile": "Remove File", - "clickToSelectFile": "Click to select a file", - "chooseFile": "Choose File", - "uploading": "Uploading...", - "downloading": "Downloading...", - "uploadingFile": "Uploading {{name}}...", - "uploadingLargeFile": "Uploading large file {{name}} ({{size}})...", - "downloadingFile": "Downloading {{name}}...", - "creatingFile": "Creating {{name}}...", - "creatingFolder": "Creating {{name}}...", - "deletingItem": "Deleting {{type}} {{name}}...", - "renamingItem": "Renaming {{type}} {{oldName}} to {{newName}}...", - "createNewFile": "Create New File", - "fileName": "File Name", - "creating": "Creating...", - "createFile": "Create File", - "createNewFolder": "Create New Folder", - "folderName": "Folder Name", - "createFolder": "Create Folder", - "warningCannotUndo": "Warning: This action cannot be undone", - "itemPath": "Item Path", - "thisIsDirectory": "This is a directory (will delete recursively)", - "deleting": "Deleting...", - "currentPathLabel": "Current Path", - "newName": "New Name", - "thisIsDirectoryRename": "This is a directory", - "renaming": "Renaming...", - "fileUploadedSuccessfully": "File \"{{name}}\" uploaded successfully", - "failedToUploadFile": "Failed to upload file", - "fileDownloadedSuccessfully": "File \"{{name}}\" downloaded successfully", - "failedToDownloadFile": "Failed to download file", - "noFileContent": "No file content received", - "filePath": "File Path", - "fileCreatedSuccessfully": "File \"{{name}}\" created successfully", - "failedToCreateFile": "Failed to create file", - "folderCreatedSuccessfully": "Folder \"{{name}}\" created successfully", - "failedToCreateFolder": "Failed to create folder", - "failedToCreateItem": "Failed to create item", - "operationFailed": "{{operation}} operation failed for {{name}}: {{error}}", - "failedToResolveSymlink": "Failed to resolve symlink", - "itemDeletedSuccessfully": "{{type}} deleted successfully", - "itemsDeletedSuccessfully": "{{count}} items deleted successfully", - "failedToDeleteItems": "Failed to delete items", - "sudoPasswordRequired": "Administrator Password Required", - "enterSudoPassword": "Enter sudo password to continue this operation", - "sudoPassword": "Sudo password", - "sudoOperationFailed": "Sudo operation failed", - "sudoAuthFailed": "Sudo authentication failed", - "deleteOperation": "Delete files/folders", - "dragFilesToUpload": "Drop files here to upload", - "emptyFolder": "This folder is empty", - "itemCount": "{{count}} items", - "selectedCount": "{{count}} selected", - "searchFiles": "Search files...", - "upload": "Upload", - "selectHostToStart": "Select a host to start file management", - "failedToConnect": "Failed to connect to SSH", - "failedToLoadDirectory": "Failed to load directory", - "noSSHConnection": "No SSH connection available", - "enterFolderName": "Enter folder name:", - "enterFileName": "Enter file name:", - "copy": "Copy", - "cut": "Cut", - "paste": "Paste", - "copyPath": "Copy Path", - "copyPaths": "Copy Paths", - "delete": "Delete", - "properties": "Properties", - "refresh": "Refresh", - "downloadFiles": "Download {{count}} files to Browser", - "copyFiles": "Copy {{count}} items", - "cutFiles": "Cut {{count}} items", - "deleteFiles": "Delete {{count}} items", - "filesCopiedToClipboard": "{{count}} items copied to clipboard", - "filesCutToClipboard": "{{count}} items cut to clipboard", - "pathCopiedToClipboard": "Path copied to clipboard", - "pathsCopiedToClipboard": "{{count}} paths copied to clipboard", - "failedToCopyPath": "Failed to copy path to clipboard", - "movedItems": "Moved {{count}} items", - "failedToDeleteItem": "Failed to delete item", - "itemRenamedSuccessfully": "{{type}} renamed successfully", - "failedToRenameItem": "Failed to rename item", - "download": "Download", - "permissions": "Permissions", - "size": "Size", - "modified": "Modified", - "path": "Path", - "confirmDelete": "Are you sure you want to delete {{name}}?", - "uploadSuccess": "File uploaded successfully", - "uploadFailed": "File upload failed", - "downloadSuccess": "File downloaded successfully", - "downloadFailed": "File download failed", - "permissionDenied": "Permission denied", - "checkDockerLogs": "Check the Docker logs for detailed error information", - "internalServerError": "Internal server error occurred", - "serverError": "Server Error", - "error": "Error", - "requestFailed": "Request failed with status code", - "unknownFileError": "unknown", - "cannotReadFile": "Cannot read file", - "noSshSessionId": "No SSH session ID available", - "noFilePath": "No file path available", - "noCurrentHost": "No current host available", - "fileSavedSuccessfully": "File saved successfully", - "saveTimeout": "Save operation timed out. The file may have been saved successfully, but the operation took too long to complete. Check the Docker logs for confirmation.", - "failedToSaveFile": "Failed to save file", - "deletedSuccessfully": "deleted successfully", - "connectToServer": "Connect to a Server", - "selectServerToEdit": "Select a server from the sidebar to start editing files", - "fileOperations": "File Operations", - "confirmDeleteMessage": "Are you sure you want to delete {{name}}?", - "confirmDeleteSingleItem": "Are you sure you want to permanently delete \"{{name}}\"?", - "confirmDeleteMultipleItems": "Are you sure you want to permanently delete {{count}} items?", - "confirmDeleteMultipleItemsWithFolders": "Are you sure you want to permanently delete {{count}} items? This includes folders and their contents.", - "confirmDeleteFolder": "Are you sure you want to permanently delete the folder \"{{name}}\" and all its contents?", - "deleteDirectoryWarning": "This will delete the folder and all its contents.", - "actionCannotBeUndone": "This action cannot be undone.", - "permanentDeleteWarning": "This action cannot be undone. The item(s) will be permanently deleted from the server.", - "recent": "Recent", - "pinned": "Pinned", - "folderShortcuts": "Folder Shortcuts", - "noRecentFiles": "No recent files.", - "noPinnedFiles": "No pinned files.", - "enterFolderPath": "Enter folder path", - "noShortcuts": "No shortcuts.", - "searchFilesAndFolders": "Search files and folders...", - "noFilesOrFoldersFound": "No files or folders found.", - "failedToConnectSSH": "Failed to connect to SSH", - "failedToReconnectSSH": "Failed to reconnect SSH session", - "failedToListFiles": "Failed to list files", - "fetchHomeDataTimeout": "Fetch home data timed out", - "sshStatusCheckTimeout": "SSH status check timed out", - "sshReconnectionTimeout": "SSH reconnection timed out", - "saveOperationTimeout": "Save operation timed out", - "cannotSaveFile": "Cannot save file", - "dragSystemFilesToUpload": "Drag system files here to upload", - "dragFilesToWindowToDownload": "Drag files outside window to download", - "openTerminalHere": "Open Terminal Here", - "run": "Run", - "saveToSystem": "Save as...", - "selectLocationToSave": "Select Location to Save", - "openTerminalInFolder": "Open Terminal in This Folder", - "openTerminalInFileLocation": "Open Terminal at File Location", - "terminalWithPath": "Terminal - {{host}}:{{path}}", - "runningFile": "Running - {{file}}", - "onlyRunExecutableFiles": "Can only run executable files", - "noHostSelected": "No host selected", - "starred": "Starred", - "shortcuts": "Shortcuts", - "directories": "Directories", - "removedFromRecentFiles": "Removed \"{{name}}\" from recent files", - "removeFailed": "Remove failed", - "unpinnedSuccessfully": "Unpinned \"{{name}}\" successfully", - "unpinFailed": "Unpin failed", - "removedShortcut": "Removed shortcut \"{{name}}\"", - "removeShortcutFailed": "Remove shortcut failed", - "clearedAllRecentFiles": "Cleared all recent files", - "clearFailed": "Clear failed", - "removeFromRecentFiles": "Remove from recent files", - "clearAllRecentFiles": "Clear all recent files", - "unpinFile": "Unpin file", - "removeShortcut": "Remove shortcut", - "saveFilesToSystem": "Save {{count}} files as...", - "pinFile": "Pin file", - "addToShortcuts": "Add to shortcuts", - "downloadToDefaultLocation": "Download to default location", - "pasteFailed": "Paste failed", - "noUndoableActions": "No undoable actions", - "undoCopySuccess": "Undid copy operation: Deleted {{count}} copied files", - "undoCopyFailedDelete": "Undo failed: Could not delete any copied files", - "undoCopyFailedNoInfo": "Undo failed: Could not find copied file information", - "undoMoveSuccess": "Undid move operation: Moved {{count}} files back to original location", - "undoMoveFailedMove": "Undo failed: Could not move any files back", - "undoMoveFailedNoInfo": "Undo failed: Could not find moved file information", - "undoDeleteNotSupported": "Delete operation cannot be undone: Files have been permanently deleted from server", - "undoTypeNotSupported": "Unsupported undo operation type", - "undoOperationFailed": "Undo operation failed", - "unknownError": "Unknown error", - "enterPath": "Enter path...", - "editPath": "Edit path", - "confirm": "Confirm", - "cancel": "Cancel", - "find": "Find...", - "replaceWith": "Replace with...", - "replace": "Replace", - "replaceAll": "Replace All", - "downloadInstead": "Download Instead", - "keyboardShortcuts": "Keyboard Shortcuts", - "searchAndReplace": "Search & Replace", - "editing": "Editing", - "navigation": "Navigation", - "code": "Code", - "search": "Search", - "findNext": "Find Next", - "findPrevious": "Find Previous", - "save": "Save", - "selectAll": "Select All", - "undo": "Undo", - "redo": "Redo", - "goToLine": "Go to Line", - "moveLineUp": "Move Line Up", - "moveLineDown": "Move Line Down", - "toggleComment": "Toggle Comment", - "indent": "Indent", - "outdent": "Outdent", - "autoComplete": "Auto Complete", - "imageLoadError": "Failed to load image", - "rotate": "Rotate", - "originalSize": "Original Size", - "startTyping": "Start typing...", - "unknownSize": "Unknown size", - "fileIsEmpty": "File is empty", - "largeFileWarning": "Large File Warning", - "largeFileWarningDesc": "This file is {{size}} in size, which may cause performance issues when opened as text.", - "fileNotFoundAndRemoved": "File \"{{name}}\" not found and has been removed from recent/pinned files", - "failedToLoadFile": "Failed to load file: {{error}}", - "serverErrorOccurred": "Server error occurred. Please try again later.", - "autoSaveFailed": "Auto-save failed", - "fileAutoSaved": "File auto-saved", - "moveFileFailed": "Failed to move {{name}}", - "moveOperationFailed": "Move operation failed", - "canOnlyCompareFiles": "Can only compare two files", - "comparingFiles": "Comparing files: {{file1}} and {{file2}}", - "dragFailed": "Drag operation failed", - "filePinnedSuccessfully": "File \"{{name}}\" pinned successfully", - "pinFileFailed": "Failed to pin file", - "fileUnpinnedSuccessfully": "File \"{{name}}\" unpinned successfully", - "unpinFileFailed": "Failed to unpin file", - "shortcutAddedSuccessfully": "Folder shortcut \"{{name}}\" added successfully", - "addShortcutFailed": "Failed to add shortcut", - "operationCompletedSuccessfully": "{{operation}} {{count}} items successfully", - "operationCompleted": "{{operation}} {{count}} items", - "downloadFileSuccess": "File {{name}} downloaded successfully", - "downloadFileFailed": "Download failed", - "moveTo": "Move to {{name}}", - "diffCompareWith": "Diff compare with {{name}}", - "dragOutsideToDownload": "Drag outside window to download ({{count}} files)", - "newFolderDefault": "NewFolder", - "newFileDefault": "NewFile.txt", - "successfullyMovedItems": "Successfully moved {{count}} items to {{target}}", - "move": "Move", - "searchInFile": "Search in file (Ctrl+F)", - "showKeyboardShortcuts": "Show keyboard shortcuts", - "startWritingMarkdown": "Start writing your markdown content...", - "loadingFileComparison": "Loading file comparison...", - "reload": "Reload", - "compare": "Compare", - "sideBySide": "Side by Side", - "inline": "Inline", - "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}}", - "connecting": "Connecting...", - "connectedSuccessfully": "Connected successfully", - "totpVerificationFailed": "TOTP verification failed", - "warpgateVerificationFailed": "Warpgate authentication failed", - "authenticationFailed": "Authentication failed", - "verificationCodePrompt": "Verification code:", - "newFolderDefault": "NewFolder", - "newFileDefault": "NewFile.txt", - "successfullyMovedItems": "Successfully moved {{count}} items to {{target}}", - "move": "Move", - "searchInFile": "Search in file (Ctrl+F)", - "showKeyboardShortcuts": "Show keyboard shortcuts", - "startWritingMarkdown": "Start writing your markdown content...", - "loadingFileComparison": "Loading file comparison...", - "reload": "Reload", - "compare": "Compare", - "sideBySide": "Side by Side", - "inline": "Inline", - "fileComparison": "File Comparison: {{file1}} vs {{file2}}", - "fileTooLarge": "File too large: {{error}}", - "connectedSuccessfully": "Connected successfully", - "changePermissions": "Change Permissions", - "changePermissionsDesc": "Modify file permissions for", - "currentPermissions": "Current Permissions", - "newPermissions": "New Permissions", - "owner": "Owner", - "group": "Group", - "others": "Others", - "read": "Read", - "write": "Write", - "execute": "Execute", - "permissionsChangedSuccessfully": "Permissions changed successfully", - "failedToChangePermissions": "Failed to change permissions", - "name": "Name", - "sortByName": "Name", - "sortByDate": "Date Modified", - "sortBySize": "Size", - "ascending": "Ascending", - "descending": "Descending" - }, - "tunnels": { - "title": "SSH Tunnels", - "noTunnelsConfigured": "No Tunnels Configured", - "configureTunnelsInHostSettings": "Configure tunnel connections in the Host Manager to get started", - "noSshTunnels": "No SSH Tunnels", - "createFirstTunnelMessage": "You haven't created any SSH tunnels yet. Configure tunnel connections in the Host Manager to get started.", - "connected": "Connected", - "disconnected": "Disconnected", - "connecting": "Connecting...", - "disconnecting": "Disconnecting...", - "unknownTunnelStatus": "Unknown", - "statusUnknown": "Unknown", - "unknown": "Unknown", - "error": "Error", - "failed": "Failed", - "retrying": "Retrying", - "waiting": "Waiting", - "waitingForRetry": "Waiting for retry", - "retryingConnection": "Retrying connection", - "canceling": "Canceling...", - "connect": "Connect", - "disconnect": "Disconnect", - "cancel": "Cancel", - "port": "Port", - "localPort": "Local Port", - "remotePort": "Remote Port", - "currentHostPort": "Current Host Port", - "endpointPort": "Endpoint Port", - "bindIp": "Local IP", - "currentHostIp": "Current Host IP", - "endpointSshConfig": "Endpoint SSH Configuration", - "endpointSshConfigRequired": "Endpoint SSH configuration is required", - "endpointSshHost": "Endpoint SSH Host", - "endpointSshHostPlaceholder": "Select a configured host", - "endpointSshHostRequired": "Select an endpoint SSH host for each client tunnel.", - "attempt": "Attempt {{current}} of {{max}}", - "nextRetryIn": "Next retry in {{seconds}} seconds", - "checkDockerLogs": "Check your Docker logs for the error reason, join the", - "orCreate": "or create a ", - "noTunnelConnections": "No tunnel connections configured", - "tunnelConnections": "Tunnel Connections", - "serverTunnels": "Server Tunnels", - "serverTunnelsDesc": "Backend-managed tunnels stored with this host.", - "clientTunnels": "Client Tunnels", - "clientTunnelsUnavailable": "Client tunnels require a desktop client.", - "serverTunnel": "Server Tunnel", - "clientTunnel": "Client Tunnel", - "addServerTunnel": "Add Server Tunnel", - "addClientTunnel": "Add Client Tunnel", - "noServerTunnels": "No server tunnels configured.", - "noClientTunnels": "No client tunnels configured on this desktop.", - "manageClientTunnels": "Manage Client Tunnels", - "addTunnel": "Add Tunnel", - "editTunnel": "Edit Tunnel", - "deleteTunnel": "Delete Tunnel", - "tunnelName": "Tunnel Name", - "remoteHost": "Remote Host", - "autoStart": "Auto Start", - "autoStartContainer": "Auto Start on Launch", - "autoStartContainerDesc": "Automatically start this tunnel when your Termix server launches.", - "autoStartEnableFailed": "Host saved, but failed to start auto-start tunnels for {{name}}.", - "clientAutoStartDesc": "Starts when this desktop client opens and stays connected.", - "clientManualStartDesc": "Use Start and Stop from this row. Termix will not open it automatically.", - "clientRemoteServerNote": "Remote forwarding may require AllowTcpForwarding and GatewayPorts on the endpoint SSH server. The remote port closes when this desktop disconnects.", - "clientTunnelStarted": "Client tunnel started", - "clientTunnelStopped": "Client tunnel stopped", - "tunnelTestSucceeded": "Tunnel test succeeded", - "tunnelTestFailed": "Tunnel test failed", - "localSaved": "Client tunnels saved", - "localSaveError": "Failed to save local client tunnels", - "invalidBindIp": "Local IP must be a valid IPv4 address.", - "invalidLocalTargetIp": "Local target IP must be a valid IPv4 address.", - "invalidCurrentHostIp": "Current Host IP must be a valid IPv4 address.", - "invalidLocalPort": "Local port must be between 1 and 65535.", - "invalidRemotePort": "Remote port must be between 1 and 65535.", - "invalidLocalTargetPort": "Local target port must be between 1 and 65535.", - "invalidEndpointPort": "Endpoint port must be between 1 and 65535.", - "duplicateAutoStartBind": "Only one auto-start client tunnel can use {{bind}}.", - "manualControlError": "Failed to update tunnel state.", - "saveHostBeforeManualControl": "Save this host before starting or stopping its server tunnels.", - "status": "Status", - "active": "Active", - "inactive": "Inactive", - "start": "Start", - "stop": "Stop", - "test": "Test", - "restart": "Restart", - "connectionType": "Connection Type", - "type": "Tunnel Type", - "typeLocal": "Local (-L)", - "typeRemote": "Remote (-R)", - "typeDynamic": "Dynamic (-D)", - "typeServerLocalDesc": "Current host to endpoint.", - "typeServerRemoteDesc": "Endpoint back to current host.", - "typeClientLocalDesc": "Local computer to endpoint.", - "typeClientRemoteDesc": "Endpoint back to local computer.", - "typeClientDynamicDesc": "SOCKS on local computer.", - "typeDynamicDesc": "Forward SOCKS5 CONNECT traffic through SSH", - "forwardDescriptionServerLocal": "Current host {{sourcePort}} → endpoint {{endpointPort}}.", - "forwardDescriptionServerRemote": "Endpoint {{endpointPort}} → current host {{sourcePort}}.", - "forwardDescriptionServerDynamic": "SOCKS on current host {{sourcePort}}.", - "forwardDescriptionClientLocal": "Local {{sourcePort}} → remote {{endpointPort}}.", - "forwardDescriptionClientRemote": "Remote {{sourcePort}} → local {{endpointPort}}.", - "forwardDescriptionClientDynamic": "SOCKS on local port {{sourcePort}}.", - "summaryClientLocal": "{{localHost}}:{{localPort}} → {{endpoint}}:{{remotePort}}", - "summaryClientRemote": "{{endpoint}}:{{remotePort}} → {{localHost}}:{{localPort}}", - "summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}", - "autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}", - "autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}", - "autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}", - "route": "Route:", - "lastStarted": "Last started", - "lastTested": "Last tested", - "lastError": "Last error", - "maxRetries": "Max Retries", - "maxRetriesDescription": "Maximum amount of retry attempts.", - "retryInterval": "Retry Interval (seconds)", - "retryIntervalDescription": "Time to wait between retry attempts.", - "local": "Local", - "remote": "Remote", - "dynamic": "Dynamic", - "unknownConnectionStatus": "Unknown", - "portMapping": "Port {{sourcePort}} → {{endpointHost}}:{{endpointPort}}", - "endpointHostNotFound": "Endpoint host not found", - "discord": "Discord", - "githubIssue": "GitHub issue", - "forHelp": "for help" - }, - "serverStats": { - "title": "Server Statistics", - "cpu": "CPU", - "memory": "Memory", - "disk": "Disk", - "network": "Network", - "uptime": "Uptime", - "loadAverage": "Avg: {{avg1}}, {{avg5}}, {{avg15}}", - "processes": "Processes", - "connections": "Connections", - "usage": "Usage", - "available": "Available", - "total": "Total", - "free": "Free", - "used": "Used", - "percentage": "Percentage", - "refreshStatusAndMetrics": "Refresh status and metrics", - "refreshStatus": "Refresh Status", - "fileManagerAlreadyOpen": "File Manager already open for this host", - "openFileManager": "Open File Manager", - "connecting": "Connecting...", - "connectionFailed": "Failed to connect to server", - "cpuCores_one": "{{count}} CPU", - "cpuCores_other": "{{count}} CPUs", - "naCpus": "N/A CPU(s)", - "loadAverageNA": "Avg: N/A", - "cpuUsage": "CPU Usage", - "memoryUsage": "Memory Usage", - "diskUsage": "Disk Usage", - "rootStorageSpace": "Root Storage Space", - "of": "of", - "feedbackMessage": "Have ideas for what should come next for server management? Share them on", - "failedToFetchHostConfig": "Failed to fetch host configuration", - "failedToFetchStatus": "Failed to fetch server status", - "failedToFetchMetrics": "Failed to fetch server metrics", - "failedToFetchHomeData": "Failed to fetch home data", - "loadingMetrics": "Loading metrics...", - "connecting": "Connecting...", - "refreshing": "Refreshing...", - "serverOffline": "Server Offline", - "cannotFetchMetrics": "Cannot fetch metrics from offline server", - "totpRequired": "TOTP Authentication Required", - "totpUnavailable": "Server Stats unavailable for TOTP-enabled servers", - "totpVerified": "TOTP verified, metrics collection started", - "totpFailed": "TOTP verification failed", - "totpInvalidCode": "Invalid verification code", - "totpCancelled": "Metrics collection cancelled", - "authenticationFailed": "Authentication failed", - "noneAuthNotSupported": "Server Stats does not support 'none' authentication type.", - "load": "Load", - "editLayout": "Edit Layout", - "cancelEdit": "Cancel", - "addWidget": "Add Widget", - "saveLayout": "Save Layout", - "unsavedChanges": "Unsaved changes", - "layoutSaved": "Layout saved successfully", - "failedToSaveLayout": "Failed to save layout", - "systemInfo": "System Information", - "hostname": "Hostname", - "operatingSystem": "Operating System", - "kernel": "Kernel", - "totalUptime": "Total Uptime", - "seconds": "seconds", - "networkInterfaces": "Network Interfaces", - "noInterfacesFound": "No network interfaces found", - "totalProcesses": "Total Processes", - "running": "Running", - "noProcessesFound": "No processes found", - "loginStats": "SSH Login Statistics", - "totalLogins": "Total Logins", - "uniqueIPs": "Unique IPs", - "recentSuccessfulLogins": "Recent Successful Logins", - "recentFailedAttempts": "Recent Failed Attempts", - "noRecentLoginData": "No recent login data", - "from": "from", - "quickActions": "Quick Actions", - "executeQuickAction": "Execute {{name}}", - "executingQuickAction": "Executing {{name}}...", - "quickActionSuccess": "{{name}} completed successfully", - "quickActionFailed": "{{name}} failed", - "quickActionError": "Failed to execute {{name}}", - "ports": { - "title": "Listening Ports", - "protocol": "Protocol", - "port": "Port", - "address": "Address", - "state": "State", - "process": "Process", - "noData": "No listening ports data" - }, - "firewall": { - "title": "Firewall", - "active": "Active", - "inactive": "Inactive", - "notDetected": "Not Detected", - "policy": "Policy", - "rules": "rules", - "noRules": "No rules", - "noData": "No firewall data available", - "action": "Action", - "protocol": "Proto", - "port": "Port", - "source": "Source", - "accept": "ACCEPT", - "drop": "DROP", - "reject": "REJECT", - "anywhere": "Anywhere" - } - }, - "auth": { - "tagline": "SSH SERVER MANAGER", - "description": "Secure, powerful, and intuitive SSH connection management", - "welcomeBack": "Welcome back to TERMIX", - "createAccount": "Create your TERMIX account", - "continueExternal": "Continue with external provider", - "loginTitle": "Login to Termix", - "registerTitle": "Create Account", - "loginButton": "Login", - "registerButton": "Register", - "forgotPassword": "Forgot Password?", - "rememberMe": "Remember Device for 30 Days (includes TOTP)", - "noAccount": "Don't have an account?", - "hasAccount": "Already have an account?", - "loginSuccess": "Login successful", - "loginFailed": "Login failed", - "registerSuccess": "Registration successful", - "registerFailed": "Registration failed", - "logoutSuccess": "Logged out successfully", - "invalidCredentials": "Invalid username or password", - "accountCreated": "Account created successfully", - "passwordReset": "Password reset link sent", - "twoFactorAuth": "Two-Factor Authentication", - "enterCode": "Enter verification code", - "backupCode": "Or use backup code", - "verifyCode": "Verify Code", - "redirectingToApp": "Redirecting to app...", - "enableTwoFactor": "Enable Two-Factor Authentication", - "disableTwoFactor": "Disable Two-Factor Authentication", - "scanQRCode": "Scan this QR code with your authenticator app", - "backupCodes": "Backup Codes", - "saveBackupCodes": "Save these backup codes in a safe place", - "twoFactorEnabledSuccess": "Two-factor authentication enabled successfully!", - "twoFactorDisabled": "Two-factor authentication disabled", - "newBackupCodesGenerated": "New backup codes generated", - "backupCodesDownloaded": "Backup codes downloaded", - "pleaseEnterSixDigitCode": "Please enter a 6-digit code", - "invalidVerificationCode": "Invalid verification code", - "failedToDisableTotp": "Failed to disable TOTP", - "failedToGenerateBackupCodes": "Failed to generate backup codes", - "enterPassword": "Enter your password", - "lockedOidcAuth": "Locked (OIDC Auth)", - "twoFactorTitle": "Two-Factor Authentication", - "twoFactorProtected": "Your account is protected with two-factor authentication", - "twoFactorActive": "Two-factor authentication is currently active on your account", - "disable2FA": "Disable 2FA", - "disableTwoFactorWarning": "Disabling two-factor authentication will make your account less secure", - "passwordOrTotpCode": "Password or TOTP Code", - "or": "Or", - "generateNewBackupCodesText": "Generate new backup codes if you've lost your existing ones", - "generateNewBackupCodes": "Generate New Backup Codes", - "yourBackupCodes": "Your Backup Codes", - "download": "Download", - "setupTwoFactorTitle": "Set Up Two-Factor Authentication", - "sshAuthenticationRequired": "SSH Authentication Required", - "sshNoKeyboardInteractive": "Keyboard-Interactive Authentication Unavailable", - "sshAuthenticationFailed": "Authentication Failed", - "sshAuthenticationTimeout": "Authentication Timeout", - "sshNoKeyboardInteractiveDescription": "The server does not support keyboard-interactive authentication. Please provide your password or SSH key.", - "sshAuthFailedDescription": "The provided credentials were incorrect. Please try again with valid credentials.", - "sshTimeoutDescription": "The authentication attempt timed out. Please try again.", - "sshProvideCredentialsDescription": "Please provide your SSH credentials to connect to this server.", - "sshPasswordDescription": "Enter the password for this SSH connection.", - "sshKeyPasswordDescription": "If your SSH key is encrypted, enter the passphrase here.", - "passphraseRequired": "Passphrase Required", - "passphraseRequiredDescription": "The SSH key is encrypted. Please enter the passphrase to unlock it.", - "step1ScanQR": "Step 1: Scan the QR code with your authenticator app", - "manualEntryCode": "Manual Entry Code", - "cannotScanQRText": "If you can't scan the QR code, enter this code manually in your authenticator app", - "nextVerifyCode": "Next: Verify Code", - "verifyAuthenticator": "Verify Your Authenticator", - "step2EnterCode": "Step 2: Enter the 6-digit code from your authenticator app", - "verificationCode": "Verification Code", - "back": "Back", - "verifyAndEnable": "Verify and Enable", - "saveBackupCodesTitle": "Save Your Backup Codes", - "step3StoreCodesSecurely": "Step 3: Store these codes in a safe place", - "importantBackupCodesText": "Save these backup codes in a secure location. You can use them to access your account if you lose your authenticator device.", - "completeSetup": "Complete Setup", - "notEnabledText": "Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app when signing in.", - "enableTwoFactorButton": "Enable Two-Factor Authentication", - "addExtraSecurityLayer": "Add an extra layer of security to your account", - "firstUser": "First User", - "firstUserMessage": "You are the first user and will be made an admin. You can view admin settings in the sidebar user dropdown. If you think this is a mistake, check the docker logs, or create a GitHub issue.", - "external": "External", - "loginWithExternal": "Login with External Provider", - "loginWithExternalDesc": "Login using your configured external identity provider", - "externalNotSupportedInElectron": "External authentication is not supported in the Electron app yet. Please use the web version for OIDC login.", - "resetPasswordButton": "Reset Password", - "sendResetCode": "Send Reset Code", - "resetCodeDesc": "Enter your username to receive a password reset code. The code will be logged in the docker container logs.", - "resetCode": "Reset Code", - "verifyCodeButton": "Verify Code", - "enterResetCode": "Enter the 6-digit code from the docker container logs for user:", - "goToLogin": "Go to Login", - "newPassword": "New Password", - "confirmNewPassword": "Confirm Password", - "enterNewPassword": "Enter your new password for user:", - "signUp": "Sign Up", - "mobileApp": "Mobile App", - "loggingInToMobileApp": "Logging in to the mobile app", - "desktopApp": "Desktop App", - "loggingInToDesktopApp": "Logging in to the desktop app", - "loggingInToDesktopAppViaWeb": "Logging in to the desktop app via web interface", - "loadingServer": "Loading server...", - "authenticating": "Authenticating...", - "dataLossWarning": "Resetting your password this way will delete all your saved SSH hosts, credentials, and other encrypted data. This action cannot be undone. Only use this if you have forgotten your password and are not logged in.", - "authenticationDisabled": "Authentication Disabled", - "authenticationDisabledDesc": "All authentication methods are currently disabled. Please contact your administrator.", - "passwordResetSuccess": "Password Reset Successful", - "passwordResetSuccessDesc": "Your password has been reset successfully. You can now log in with your new password." - }, - "hostKey": { - "verifyNewHost": "Verify SSH Host Key", - "keyChangedWarning": "SSH Host Key Changed", - "firstConnectionTitle": "First time connecting to this host", - "firstConnectionDescription": "The authenticity of this host cannot be established. Verify the fingerprint matches what you expect.", - "keyChangedDescription": "The host key for this server has changed since your last connection. This could indicate a security issue.", - "previousKey": "Previous Key", - "newFingerprint": "New Fingerprint", - "fingerprint": "Fingerprint", - "verifyInstructions": "If you trust this host, click Accept to continue and save this fingerprint for future connections.", - "securityWarning": "Security Warning", - "acceptAndContinue": "Accept & Continue", - "acceptNewKey": "Accept New Key & Continue" - }, - "errors": { - "notFound": "Page not found", - "unauthorized": "Unauthorized access", - "forbidden": "Access forbidden", - "serverError": "Server error", - "networkError": "Network error", - "databaseConnection": "Could not connect to the database", - "unknownError": "Unknown error", - "loginFailed": "Login failed", - "failedPasswordReset": "Failed to initiate password reset", - "failedVerifyCode": "Failed to verify reset code", - "failedCompleteReset": "Failed to complete password reset", - "invalidTotpCode": "Invalid TOTP code", - "failedOidcLogin": "Failed to start OIDC login", - "failedUserInfo": "Failed to get user info after login", - "oidcAuthFailed": "OIDC authentication failed", - "noTokenReceived": "No token received from login", - "invalidAuthUrl": "Invalid authorization URL received from backend", - "invalidInput": "Invalid input", - "requiredField": "This field is required", - "minLength": "Minimum length is {{min}}", - "maxLength": "Maximum length is {{max}}", - "invalidEmail": "Invalid email address", - "passwordMismatch": "Passwords do not match", - "passwordLoginDisabled": "Username/password login is currently disabled", - "weakPassword": "Password is too weak", - "usernameExists": "Username already exists", - "emailExists": "Email already exists", - "loadFailed": "Failed to load data", - "saveError": "Failed to save", - "sessionExpired": "Session expired - please log in again", - "totpRateLimited": "Rate limited: Too many TOTP verification attempts. Please try again later.", - "totpRateLimitedWithTime": "Rate limited: Too many TOTP verification attempts. Please wait {{time}} seconds before trying again.", - "resetCodeRateLimited": "Rate limited: Too many verification attempts. Please try again later.", - "resetCodeRateLimitedWithTime": "Rate limited: Too many verification attempts. Please wait {{time}} seconds before trying again." - }, - "messages": { - "saveSuccess": "Saved successfully", - "saveError": "Failed to save", - "deleteSuccess": "Deleted successfully", - "deleteError": "Failed to delete", - "updateSuccess": "Updated successfully", - "updateError": "Failed to update", - "copySuccess": "Copied to clipboard", - "copyError": "Failed to copy", - "copiedToClipboard": "{{item}} copied to clipboard", - "connectionEstablished": "Connection established", - "connectionClosed": "Connection closed", - "reconnecting": "Reconnecting...", - "processing": "Processing...", - "pleaseWait": "Please wait...", - "registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator.", - "userNotAllowed": "Your account is not authorized to register. Please contact an administrator.", - "databaseConnected": "Database connected successfully", - "databaseConnectionFailed": "Failed to connect to the database server", - "checkServerConnection": "Please check your server connection and try again", - "resetCodeSent": "Reset code sent to Docker logs", - "codeVerified": "Code verified successfully", - "passwordResetSuccess": "Password reset successfully", - "loginSuccess": "Login successful", - "registrationSuccess": "Registration successful" - }, - "profile": { - "title": "User Profile", - "description": "Manage your account settings and security", - "security": "Security", - "changePassword": "Change Password", - "twoFactorAuth": "Two-Factor Authentication", - "accountInfo": "Account Information", - "role": "Role", - "admin": "Administrator", - "user": "User", - "authMethod": "Authentication Method", - "local": "Local", - "external": "External (OIDC)", - "externalAndLocal": "Dual Auth", - "selectPreferredLanguage": "Select your preferred language for the interface", - "fileColorCoding": "File Color Coding", - "fileColorCodingDesc": "Color-code files by type: folders (red), files (blue), symlinks (green)", - "commandAutocomplete": "Command Autocomplete", - "commandAutocompleteDesc": "Enable Tab key autocomplete suggestions for terminal commands based on your command history", - "commandHistoryTracking": "Save Command History", - "commandHistoryTrackingDesc": "Store terminal commands in history for autocomplete and sidebar. Disabled by default for privacy.", - "defaultSnippetFoldersCollapsed": "Collapse Snippet Folders by Default", - "defaultSnippetFoldersCollapsedDesc": "When enabled, all snippet folders will be collapsed when you open the snippets tab", - "terminalSyntaxHighlighting": "Terminal Syntax Highlighting", - "showHostTags": "Show Host Tags", - "showHostTagsDesc": "Display tags under each host in the sidebar. Disable to hide all tags.", - "account": "Account", - "appearance": "Appearance", - "c2sTunnelConfigDesc": "Local desktop tunnels targeting configured SSH hosts.", - "c2sTunnelPresets": "Client Tunnel Presets", - "c2sTunnelPresetsDesc": "Save this desktop client's local tunnel list as a named server preset, or load a preset back into this client.", - "c2sTunnelPresetsUnavailable": "Client tunnel presets are only available in the desktop client.", - "c2sPresetName": "Preset Name", - "c2sPresetNamePlaceholder": "Client preset name", - "c2sPresetToLoad": "Preset To Load", - "c2sNoPresetSelected": "No preset selected", - "c2sNoPresets": "No presets saved", - "c2sLoadPreset": "Load", - "c2sCurrentLocalConfig": "{{count}} local client tunnel(s) configured on this desktop.", - "c2sPresetSyncNote": "Presets are explicit snapshots; loading one replaces this desktop client's local client tunnel list.", - "c2sPresetSaved": "Client tunnel preset saved", - "c2sPresetLoaded": "Client tunnel preset loaded locally", - "c2sPresetRenamed": "Client tunnel preset renamed", - "c2sPresetDeleted": "Client tunnel preset deleted", - "c2sPresetLoadError": "Failed to load client tunnel presets", - "languageLocalization": "Language & Localization", - "fileManagerSettings": "File Manager", - "terminalSettings": "Terminal", - "hostSidebarSettings": "Host & Sidebar", - "snippetsSettings": "Snippets", - "confirmSnippetExecution": "Confirm Snippet Execution", - "confirmSnippetExecutionDesc": "Show confirmation dialog before executing snippets", - "updateSettings": "Updates", - "disableUpdateCheck": "Disable Update Check", - "disableUpdateCheckDesc": "Stop checking for new versions on startup and dashboard. Reduces network requests.", - "currentPassword": "Current Password", - "passwordChangedSuccess": "Password changed successfully! Please log in again.", - "failedToChangePassword": "Failed to change password. Please check your current password and try again.", - "theme": "Theme", - "themeLight": "Light", - "themeDark": "Dark", - "themeSystem": "System", - "appearanceDesc": "Select the color theme for the application", - "terminalSyntaxHighlightingDesc": "Automatically highlight commands, paths, IPs, and log levels in terminal output", - "enableCommandPaletteShortcut": "Enable Command Palette Shortcut", - "enableCommandPaletteShortcutDesc": "Double-tap left Shift to open the Command Palette for quick access to hosts", - "enableTerminalSessionPersistence": "Persistent Tabs/Sessions", - "enableTerminalSessionPersistenceDesc": "Maintain SSH connections when switching tabs or closing the browser (may be unstable)" - }, - "user": { - "failedToLoadVersionInfo": "Failed to load version information" - }, - "placeholders": { - "enterCode": "000000", - "ipAddress": "192.168.1.1 or example.com", - "port": "22", - "maxRetries": "3", - "retryInterval": "10", - "language": "Language", - "username": "username", - "hostname": "host name", - "folder": "folder", - "password": "password", - "keyPassword": "key password", - "sudoPassword": "sudo password (optional)", - "notes": "add notes about this host...", - "expirationDate": "Select expiration date", - "pastePrivateKey": "Paste your private key here...", - "pastePublicKey": "Paste your public key here...", - "credentialName": "My SSH Server", - "description": "SSH credential description", - "searchCredentials": "Search credentials by name, username, or tags...", - "sshConfig": "endpoint ssh configuration", - "bindLocalhost": "127.0.0.1 (bind to localhost)", - "localListenerHost": "127.0.0.1 (listen locally)", - "localTargetHost": "127.0.0.1 (target on this computer)", - "socksListenerHost": "127.0.0.1 (SOCKS listener)", - "homePath": "/home", - "clientId": "your-client-id", - "clientSecret": "your-client-secret", - "authUrl": "https://your-provider.com/application/o/authorize/", - "redirectUrl": "https://your-provider.com/application/o/termix/", - "tokenUrl": "https://your-provider.com/application/o/token/", - "userIdField": "sub", - "usernameField": "name", - "scopes": "openid email profile", - "userinfoUrl": "https://your-provider.com/application/o/userinfo/", - "allowedUsers": "user@example.com, @company.com", - "enterUsername": "Enter username to make admin", - "searchHosts": "Search hosts by name, username, IP, folder, tags...", - "enterPassword": "Enter your password", - "totpCode": "6-digit TOTP code", - "searchHostsAny": "Search hosts (try: tag:prod, user:root, ip:192.168)...", - "confirmPassword": "Enter your password to confirm", - "typeHere": "Type here", - "fileName": "Enter file name (e.g., example.txt)", - "folderName": "Enter folder name", - "fullPath": "Enter full path to item", - "currentPath": "Enter current path to item", - "newName": "Enter new name", - "socks5Host": "127.0.0.1", - "socks5Username": "proxy username", - "socks5Password": "proxy password", - "socks5PresetName": "e.g., Work VPN Chain", - "socks5PresetDescription": "e.g., Proxy chain for accessing work servers", - "moshCommand": "mosh user@server", - "defaultPort": "22", - "defaultEndpointPort": "224", - "defaultMaxRetries": "3", - "defaultRetryInterval": "10" - }, - "leftSidebar": { - "failedToLoadHosts": "Failed to load hosts", - "noFolder": "No Folder", - "passwordRequired": "Password is required", - "failedToDeleteAccount": "Failed to delete account", - "failedToMakeUserAdmin": "Failed to make user admin", - "userIsNowAdmin": "User {{username}} is now an admin", - "removeAdminConfirm": "Are you sure you want to remove admin status from {{username}}?", - "deleteUserConfirm": "Are you sure you want to delete user {{username}}? This action cannot be undone.", - "deleteAccount": "Delete Account", - "closeDeleteAccount": "Close Delete Account", - "deleteAccountWarning": "This action cannot be undone. This will permanently delete your account and all associated data.", - "deleteAccountWarningDetails": "Deleting your account will remove all your data including SSH hosts, configurations, and settings. This action is irreversible.", - "deleteAccountWarningShort": "This action is not reversible and will permanently delete your account.", - "cannotDeleteAccount": "Cannot Delete Account", - "lastAdminWarning": "You are the last admin user. You cannot delete your account as this would leave the system without any administrators. Please make another user an admin first, or contact system support.", - "confirmPassword": "Confirm Password", - "deleting": "Deleting...", - "cancel": "Cancel" - }, - "interface": { - "sidebar": "Sidebar", - "toggleSidebar": "Toggle Sidebar", - "close": "Close", - "online": "Online", - "offline": "Offline", - "maintenance": "Maintenance", - "degraded": "Degraded", - "noTunnelConnections": "No tunnel connections configured", - "discord": "Discord", - "connectToSshForOperations": "Connect to SSH to use file operations", - "uploadFile": "Upload File", - "newFile": "New File", - "newFolder": "New Folder", - "rename": "Rename", - "deleteItem": "Delete Item", - "createNewFile": "Create New File", - "createNewFolder": "Create New Folder", - "renameItem": "Rename Item", - "clickToSelectFile": "Click to select a file", - "noSshHosts": "No SSH Hosts", - "sshHosts": "SSH Hosts", - "importSshHosts": "Import SSH Hosts from JSON", - "clientId": "Client ID", - "clientSecret": "Client Secret", - "error": "Error", - "warning": "Warning", - "deleteAccount": "Delete Account", - "closeDeleteAccount": "Close Delete Account", - "cannotDeleteAccount": "Cannot Delete Account", - "confirmPassword": "Confirm Password", - "deleting": "Deleting...", - "externalAuth": "External Authentication (OIDC)", - "configureExternalProvider": "Configure external identity provider for", - "waitingForRetry": "Waiting for retry", - "retryingConnection": "Retrying connection", - "resetSplitSizes": "Reset split sizes", - "sshManagerAlreadyOpen": "SSH Manager already open", - "disabledDuringSplitScreen": "Disabled during split screen", - "unknown": "Unknown", - "connected": "Connected", - "disconnected": "Disconnected", - "maxRetriesExhausted": "Max retries exhausted", - "endpointHostNotFound": "Endpoint host not found", - "administrator": "Administrator", - "user": "User", - "external": "External", - "local": "Local", - "saving": "Saving...", - "saveConfiguration": "Save Configuration", - "loading": "Loading...", - "refresh": "Refresh", - "adding": "Adding...", - "makeAdmin": "Make Admin", - "verifying": "Verifying...", - "verifyAndEnable": "Verify and Enable", - "secretKey": "Secret key", - "totpQrCode": "TOTP QR Code", - "passwordRequired": "Password is required when using password authentication", - "sshKeyRequired": "SSH Private Key is required when using key authentication", - "keyTypeRequired": "Key Type is required when using key authentication", - "validSshConfigRequired": "Must select a valid SSH configuration from the list", - "updateHost": "Update Host", - "addHost": "Add Host", - "editHost": "Edit Host", - "pinConnection": "Pin Connection", - "authentication": "Authentication", - "password": "Password", - "key": "Key", - "sshPrivateKey": "SSH Private Key", - "keyPassword": "Key Password", - "keyType": "Key Type", - "enableTerminal": "Enable Terminal", - "enableTunnel": "Enable Tunnel", - "enableFileManager": "Enable File Manager", - "defaultPath": "Default Path", - "tunnelConnections": "Tunnel Connections", - "maxRetries": "Max Retries", - "upload": "Upload", - "updateKey": "Update Key", - "productionFolder": "Production", - "databaseServer": "Database Server", - "developmentServer": "Development Server", - "developmentFolder": "Development", - "webServerProduction": "Web Server - Production", - "unknownError": "Unknown error", - "failedToInitiatePasswordReset": "Failed to initiate password reset", - "failedToVerifyResetCode": "Failed to verify reset code", - "failedToCompletePasswordReset": "Failed to complete password reset", - "invalidTotpCode": "Invalid TOTP code", - "failedToStartOidcLogin": "Failed to start OIDC login", - "failedToGetUserInfoAfterOidc": "Failed to get user info after OIDC login", - "loginWithExternalProvider": "Login with external provider", - "loginWithExternal": "Login with External Provider", - "sendResetCode": "Send Reset Code", - "verifyCode": "Verify Code", - "resetPassword": "Reset Password", - "login": "Login", - "signUp": "Sign Up", - "failedToUpdateOidcConfig": "Failed to update OIDC configuration", - "failedToMakeUserAdmin": "Failed to make user admin", - "failedToStartTotpSetup": "Failed to start TOTP setup", - "invalidVerificationCode": "Invalid verification code", - "failedToDisableTotp": "Failed to disable TOTP", - "failedToGenerateBackupCodes": "Failed to generate backup codes" - }, - "mobile": { - "selectHostToStart": "Select a host to start your terminal session", - "limitedSupportMessage": "Website mobile support is still in progress. Use the mobile app for a better experience.", - "mobileAppInProgress": "Mobile app is in progress", - "mobileAppInProgressDesc": "We're working on a dedicated mobile app to provide a better experience on mobile devices.", - "viewMobileAppDocs": "Install Mobile App", - "mobileAppDocumentation": "Mobile App Documentation" - }, - "dashboard": { - "title": "Dashboard", - "loading": "Loading dashboard...", - "github": "GitHub", - "support": "Support", - "discord": "Discord", - "donate": "Donate", - "serverOverview": "Server Overview", - "version": "Version", - "upToDate": "Up to Date", - "updateAvailable": "Update Available", - "beta": "Beta", - "uptime": "Uptime", - "database": "Database", - "healthy": "Healthy", - "error": "Error", - "totalHosts": "Total Hosts", - "totalTunnels": "Total Tunnels", - "totalCredentials": "Total Credentials", - "recentActivity": "Recent Activity", - "reset": "Reset", - "loadingRecentActivity": "Loading recent activity...", - "noRecentActivity": "No recent activity", - "quickActions": "Quick Actions", - "addHost": "Add Host", - "addCredential": "Add Credential", - "adminSettings": "Admin Settings", - "userProfile": "User Profile", - "serverStats": "Server Stats", - "loadingServerStats": "Loading server stats...", - "noServerData": "No server data available", - "cpu": "CPU", - "ram": "RAM", - "notAvailable": "N/A", - "customizeLayout": "Customize Dashboard", - "dashboardSettings": "Dashboard Settings", - "enableDisableCards": "Enable/Disable Cards", - "resetLayout": "Reset to Default", - "serverOverviewCard": "Server Overview", - "recentActivityCard": "Recent Activity", - "networkGraphCard": "Network Graph", - "networkGraph": "Network Graph", - "quickActionsCard": "Quick Actions", - "serverStatsCard": "Server Stats" - }, - "networkGraph": { - "title": "Network Graph", - "addHost": "Add Host", - "addGroup": "Add Group", - "addLink": "Add Link", - "deleteSelected": "Delete Selected", - "zoomIn": "Zoom In", - "zoomOut": "Zoom Out", - "resetView": "Reset View", - "exportJson": "Export JSON", - "importJson": "Import JSON", - "selectHost": "Select Host", - "chooseHost": "Choose a host...", - "parentGroup": "Parent Group", - "noGroup": "No Group", - "groupName": "Group Name", - "color": "Color", - "source": "Source", - "target": "Target", - "moveToGroup": "Move to Group", - "selectGroup": "Select group...", - "addConnection": "Add Connection", - "hostDetails": "Host Details", - "connectToHost": "Connect to Host", - "openTerminal": "Open Terminal", - "openFileManager": "Open File Manager", - "openTunnel": "Open Tunnels", - "openDocker": "Open Docker", - "openServerStats": "Open Server Details", - "moveToGroupAction": "Move to Group", - "removeFromGroup": "Remove from Group", - "addHostHere": "Add Host Here", - "editGroup": "Edit Group", - "delete": "Delete", - "cancel": "Cancel", - "add": "Add", - "create": "Create", - "update": "Update", - "move": "Move", - "connect": "Connect", - "close": "Close", - "createGroup": "Create Group", - "noAvailableHosts": "No available hosts", - "selectSourcePlaceholder": "Select Source...", - "selectTargetPlaceholder": "Select Target...", - "invalidFile": "Invalid File", - "hostAlreadyExists": "Host is already in the topology", - "sourceTargetMustDiffer": "Source and target must be different", - "connectionExists": "Connection already exists", - "unknown": "Unknown", - "name": "Name", - "ip": "IP", - "status": "Status", - "id": "ID", - "failedToLoadData": "Failed to load network topology data", - "failedToAddNode": "Failed to add node", - "sourceDifferentFromTarget": "Source and target must be different", - "exportJSON": "Export JSON", - "importJSON": "Import JSON", - "searchHost": "Search hosts...", - "noHostFound": "No hosts found", - "searchGroup": "Search groups...", - "noGroupFound": "No groups found", - "searchNode": "Search nodes...", - "noNodeFound": "No nodes found", - "terminal": "Terminal", - "fileManager": "File Manager", - "tunnel": "Tunnel", - "docker": "Docker", - "serverStats": "Server Stats" - }, - "rbac": { - "shareHost": "Share Host", - "shareHostTitle": "Share Host Access", - "shareHostDescription": "Grant temporary or permanent access to this host", - "targetUser": "Target User", - "selectUser": "Select a user to share with", - "duration": "Duration", - "durationHours": "Duration (hours)", - "neverExpires": "Never expires", - "permissionLevel": "Permission Level", - "permissionLevels": { - "readonly": "Read-Only", - "readonlyDesc": "Can view only, no command input", - "restricted": "Restricted", - "restrictedDesc": "Blocks dangerous commands (passwd, rm -rf, etc.)", - "monitored": "Monitored", - "monitoredDesc": "Records all commands but doesn't block (Recommended)", - "full": "Full Access", - "fullDesc": "No restrictions (Not recommended)" - }, - "blockedCommands": "Blocked Commands", - "blockedCommandsPlaceholder": "Enter commands to block, e.g., passwd, rm, dd", - "maxSessionDuration": "Max Session Duration (minutes)", - "createTempUser": "Create Temporary User", - "createTempUserDesc": "Creates a restricted user on the server instead of sharing your credentials. Requires sudo access. Most secure option.", - "expiresAt": "Expires At", - "expiresIn": "Expires in {{hours}} hours", - "expired": "Expired", - "grantedBy": "Granted By", - "accessLevel": "Access Level", - "lastAccessed": "Last Accessed", - "accessCount": "Access Count", - "revokeAccess": "Revoke Access", - "confirmRevokeAccess": "Are you sure you want to revoke access for {{username}}?", - "hostSharedSuccessfully": "Host shared successfully with {{username}}", - "hostAccessUpdated": "Host access updated", - "failedToShareHost": "Failed to share host", - "accessRevokedSuccessfully": "Access revoked successfully", - "failedToRevokeAccess": "Failed to revoke access", - "shared": "Shared", - "sharedHosts": "Shared Hosts", - "sharedWithMe": "Shared With Me", - "noSharedHosts": "No hosts shared with you", - "owner": "Owner", - "viewAccessList": "View Access List", - "accessList": "Access List", - "noAccessGranted": "No access has been granted for this host", - "noAccessGrantedMessage": "No users have been granted access to this host yet", - "manageAccessFor": "Manage access for", - "totalAccessRecords": "{{count}} access record(s)", - "neverAccessed": "Never", - "timesAccessed": "{{count}} time(s)", - "daysRemaining": "{{days}} day(s)", - "hoursRemaining": "{{hours}} hour(s)", - "expired": "Expired", - "failedToFetchAccessList": "Failed to fetch access list", - "currentAccess": "Current Access", - "securityWarning": "Security Warning", - "securityWarningMessage": "Sharing credentials gives the user full access to perform any operations on the server, including changing passwords and deleting files. Only share with trusted users.", - "tempUserRecommended": "We recommend enabling 'Create Temporary User' for better security.", - "roleManagement": "Role Management", - "manageRoles": "Manage Roles", - "manageRolesFor": "Manage roles for {{username}}", - "assignRole": "Assign Role", - "removeRole": "Remove Role", - "userRoles": "User Roles", - "permissions": "Permissions", - "systemRole": "System Role", - "customRole": "Custom Role", - "roleAssignedSuccessfully": "Role assigned to {{username}} successfully", - "failedToAssignRole": "Failed to assign role", - "roleRemovedSuccessfully": "Role removed from {{username}} successfully", - "failedToRemoveRole": "Failed to remove role", - "cannotRemoveSystemRole": "Cannot remove system role", - "cannotShareWithSelf": "Cannot share host with yourself", - "noCustomRolesToAssign": "No custom roles available. System roles are auto-assigned.", - "credentialSharingWarning": "Credential Authentication Not Supported for Sharing", - "credentialRequired": "Credential is required when sharing a host", - "credentialRequiredDescription": "This host does not use credential-based authentication. In order to share hosts, due to per-user-encryption, the host must use credential based authentication.", - "auditLogs": "Audit Logs", - "viewAuditLogs": "View Audit Logs", - "action": "Action", - "resourceType": "Resource Type", - "resourceName": "Resource Name", - "timestamp": "Timestamp", - "ipAddress": "IP Address", - "userAgent": "User Agent", - "success": "Success", - "failed": "Failed", - "details": "Details", - "noAuditLogs": "No audit logs available", - "sessionRecordings": "Session Recordings", - "viewRecording": "View Recording", - "downloadRecording": "Download Recording", - "dangerousCommand": "Dangerous Command Detected", - "commandBlocked": "Command Blocked", - "terminateSession": "Terminate Session", - "sessionTerminated": "Session terminated by host owner", - "sharedAccessExpired": "Your shared access to this host has expired", - "sharedAccessExpiresIn": "Shared access expires in {{hours}} hours", - "roles": { - "label": "Roles", - "admin": "Administrator", - "user": "User" - }, - "createRole": "Create Role", - "editRole": "Edit Role", - "roleName": "Role Name", - "displayName": "Display Name", - "description": "Description", - "assignRoles": "Assign Roles", - "userRoleAssignment": "User-Role Assignment", - "selectUserPlaceholder": "Select a user", - "searchUsers": "Search users...", - "noUserFound": "No user found", - "currentRoles": "Current Roles", - "noRolesAssigned": "No roles assigned", - "assignNewRole": "Assign New Role", - "selectRolePlaceholder": "Select a role", - "searchRoles": "Search roles...", - "noRoleFound": "No role found", - "assign": "Assign", - "roleCreatedSuccessfully": "Role created successfully", - "roleUpdatedSuccessfully": "Role updated successfully", - "roleDeletedSuccessfully": "Role deleted successfully", - "failedToLoadRoles": "Failed to load roles", - "failedToSaveRole": "Failed to save role", - "failedToDeleteRole": "Failed to delete role", - "roleDisplayNameRequired": "Role display name is required", - "roleNameRequired": "Role name is required", - "roleNameHint": "Use lowercase letters, numbers, underscores, and hyphens only", - "displayNamePlaceholder": "Developer", - "descriptionPlaceholder": "Software developers and engineers", - "confirmDeleteRole": "Delete Role", - "confirmDeleteRoleDescription": "Are you sure you want to delete the role '{{name}}'? This action cannot be undone.", - "confirmRemoveRole": "Remove Role", - "confirmRemoveRoleDescription": "Are you sure you want to remove this role from the user?", - "editRoleDescription": "Update role information", - "createRoleDescription": "Create a new custom role for grouping users", - "assignRolesDescription": "Manage role assignments for users", - "noRoles": "No roles found", - "selectRole": "Select Role", - "type": "Type", - "user": "User", - "role": "Role", - "saveHostFirst": "Save Host First", - "saveHostFirstDescription": "Please save the host before configuring sharing settings.", - "shareWithUser": "Share with User", - "shareWithRole": "Share with Role", - "share": "Share", - "target": "Target", - "expires": "Expires", - "never": "Never", - "noAccessRecords": "No access records found", - "sharedSuccessfully": "Shared successfully", - "failedToShare": "Failed to share", - "confirmRevokeAccessDescription": "Are you sure you want to revoke this access?", - "hours": "hours", - "sharing": "Sharing", - "selectUserAndRole": "Please select both a user and a role", - "view": "View Only", - "viewDesc": "Due to the Termix encryption system, other permission levels will come at a later date" - }, - "commandPalette": { - "searchPlaceholder": "Search for hosts or quick actions...", - "recentActivity": "Recent Activity", - "navigation": "Navigation", - "addHost": "Add Host", - "addCredential": "Add Credential", - "adminSettings": "Admin Settings", - "userProfile": "User Profile", - "updateLog": "Update Log", - "hosts": "Hosts", - "openServerDetails": "Open Server Details", - "openFileManager": "Open File Manager", - "edit": "Edit", - "links": "Links", - "github": "GitHub", - "support": "Support", - "discord": "Discord", - "donate": "Donate", - "press": "Press", - "toToggle": "to toggle", - "close": "Close", - "hostManager": "Host Manager", - "pressToToggle": "Press Left Shift twice to open the command palette" - }, - "docker": { - "notEnabled": "Docker is not enabled for this host", - "validating": "Validating Docker...", - "connectingToHost": "Connecting to host...", - "connecting": "Connecting...", - "error": "Error", - "errorCode": "Error code: {{code}}", - "version": "Docker {{version}}", - "connectionFailed": "Failed to connect to Docker", - "totpVerificationFailed": "TOTP verification failed", - "warpgateVerificationFailed": "Warpgate verification failed", - "verificationCodePrompt": "Enter verification code", - "loadingContainers": "Loading containers...", - "containerStarted": "Container {{name}} started", - "failedToStartContainer": "Failed to start container {{name}}", - "containerStopped": "Container {{name}} stopped", - "failedToStopContainer": "Failed to stop container {{name}}", - "containerRestarted": "Container {{name}} restarted", - "failedToRestartContainer": "Failed to restart container {{name}}", - "containerPaused": "Container {{name}} paused", - "containerUnpaused": "Container {{name}} unpaused", - "failedToTogglePauseContainer": "Failed to toggle pause state for container {{name}}", - "containerRemoved": "Container {{name}} removed", - "failedToRemoveContainer": "Failed to remove container {{name}}", - "image": "Image", - "idLabel": "ID", - "ports": "Ports", - "noPorts": "No ports", - "created": "Created", - "start": "Start", - "stop": "Stop", - "pause": "Pause", - "unpause": "Unpause", - "restart": "Restart", - "remove": "Remove", - "removeContainer": "Remove Container", - "confirmRemoveContainer": "Are you sure you want to remove the container '{{name}}'? This action cannot be undone.", - "runningContainerWarning": "Warning: This container is currently running. Removing it will stop the container first.", - "removing": "Removing...", - "loadingContainers": "Loading containers...", - "noContainersFound": "No containers found", - "noContainersFoundHint": "No Docker containers are available on this host", - "searchPlaceholder": "Search containers...", - "filterByStatusPlaceholder": "Filter by status", - "allContainersCount": "All ({{count}})", - "statusCount": "{{status}} ({{count}})", - "noContainersMatchFilters": "No containers match your filters", - "noContainersMatchFiltersHint": "Try adjusting your search or filter criteria", - "containerMustBeRunningToViewStats": "Container must be running to view statistics", - "failedToFetchStats": "Failed to fetch container statistics", - "containerNotRunning": "Container not running", - "startContainerToViewStats": "Start the container to view statistics", - "loadingStats": "Loading statistics...", - "errorLoadingStats": "Error loading statistics", - "noStatsAvailable": "No statistics available", - "cpuUsage": "CPU Usage", - "current": "Current", - "memoryUsage": "Memory Usage", - "usedLimit": "Used / Limit", - "percentage": "Percentage", - "networkIo": "Network I/O", - "input": "Input", - "output": "Output", - "blockIo": "Block I/O", - "read": "Read", - "write": "Write", - "pids": "PIDs", - "containerInformation": "Container Information", - "name": "Name", - "id": "ID", - "state": "State", - "disconnectedFromContainer": "Disconnected from container", - "containerMustBeRunning": "Container must be running to access console", - "authenticationRequired": "Authentication required", - "verificationCodePrompt": "Enter verification code", - "totpVerificationFailed": "TOTP verification failed. Please try again.", - "warpgateVerificationFailed": "Warpgate authentication failed. Please try again.", - "connectedTo": "Connected to {{containerName}}", - "disconnected": "Disconnected", - "consoleError": "Console error", - "errorMessage": "Error: {{message}}", - "failedToConnect": "Failed to connect to container", - "console": "Console", - "selectShell": "Select shell", - "bash": "Bash", - "sh": "sh", - "ash": "ash", - "connecting": "Connecting...", - "connect": "Connect", - "disconnect": "Disconnect", - "notConnected": "Not connected", - "clickToConnect": "Click connect to start a shell session", - "connectingTo": "Connecting to {{containerName}}...", - "containerNotFound": "Container not found", - "backToList": "Back to List", - "logs": "Logs", - "stats": "Stats", - "consoleTab": "Console", - "startContainerToAccess": "Start the container to access the console", - "log": { - "connecting": "Connecting to Docker host...", - "authStarted": "Resolving authentication credentials...", - "totpRequired": "TOTP verification required", - "warpgateRequired": "Warpgate authentication required", - "sessionCreated": "Docker SSH session established", - "sessionReady": "Ready for Docker operations", - "error": "Docker connection failed: {{error}}" - } - }, - "stats": { - "log": { - "connecting": "Starting metrics collection...", - "existingSession": "Using existing metrics session", - "totpVerifying": "TOTP verification required", - "sessionEstablished": "Metrics session established", - "collecting": "Collecting server metrics", - "heartbeat": "Keeping connection alive", - "stopped": "Metrics collection stopped", - "error": "Metrics collection failed: {{error}}" - } - }, - "tunnel": { - "log": { - "connecting": "Establishing tunnel connection...", - "sourceConnecting": "Connecting to source host {{host}}...", - "sourceConnected": "Connected to source host", - "endpointResolving": "Resolving endpoint host {{host}}...", - "endpointResolved": "Endpoint host resolved", - "forwarding": "Setting up port forwarding {{localPort}} → {{remotePort}}...", - "connected": "Tunnel established successfully", - "retrying": "Connection failed, retrying in {{countdown}}s (attempt {{attempt}}/{{max}})...", - "retryExhausted": "Max retry attempts reached", - "error": "Tunnel connection failed: {{error}}" - } - }, - "sftp": { - "log": { - "connecting": "Connecting to SFTP server...", - "jumpHost": "Connecting via jump host {{host}}...", - "connected": "SFTP connection established", - "authFailed": "Authentication failed", - "error": "SFTP operation failed: {{error}}" - } - }, - "theme": { - "switchToLight": "Switch to Light", - "switchToDark": "Switch to Dark" - } -} diff --git a/src/main.tsx b/src/main.tsx index 4a30cbcd..ee4b9bc1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,53 +1,95 @@ /* eslint-disable react-refresh/only-export-components */ import { prepareClientCacheVersion } from "@/lib/client-cache-version"; -import { StrictMode, Suspense, lazy, useEffect, useState, useRef } from "react"; +import { StrictMode, Suspense, lazy, useState, useRef, useEffect } from "react"; import { createRoot } from "react-dom/client"; -import "./index.css"; +import "./ui/index.css"; import { ThemeProvider } from "@/components/theme-provider"; -import "./i18n/i18n"; +import "./ui/i18n/i18n"; import { isElectron } from "@/lib/electron"; +import { Toaster } from "@/components/sonner"; +import { Auth, getStoredAuth, clearStoredAuth } from "@/auth/Auth"; +import { getUserInfo, getCurrentToken, appReadyPromise } from "@/main-axios"; +import { applyAccentColor, applyFontSize } from "@/lib/theme"; +import type { FontSizeId } from "@/types/ui-types"; +import { useServiceWorker } from "@/hooks/use-service-worker"; +import { useTranslation } from "react-i18next"; -const DesktopApp = lazy(() => import("@/ui/desktop/DesktopApp.tsx")); -const MobileApp = lazy(() => - import("@/ui/mobile/MobileApp.tsx").then((module) => ({ - default: module.MobileApp, - })), -); -const HostManagerApp = lazy( - () => import("./ui/desktop/apps/host-manager/HostManagerApp.tsx"), -); -const TerminalApp = lazy( - () => import("./ui/desktop/apps/features/terminal/TerminalApp.tsx"), +const AppShell = lazy(() => + import("@/AppShell").then((m) => ({ default: m.AppShell })), ); + +// Full-screen apps opened via query params (e.g. from external links or Electron) +const TerminalApp = lazy(() => import("@/features/terminal/TerminalApp")); const FileManagerApp = lazy( - () => import("./ui/desktop/apps/features/file-manager/FileManagerApp.tsx"), -); -const TunnelApp = lazy( - () => import("./ui/desktop/apps/features/tunnel/TunnelApp.tsx"), + () => import("@/features/file-manager/FileManagerApp"), ); +const TunnelApp = lazy(() => import("@/features/tunnel/TunnelApp")); const ServerStatsApp = lazy( - () => import("./ui/desktop/apps/features/server-stats/ServerStatsApp.tsx"), -); -const DockerApp = lazy( - () => import("./ui/desktop/apps/features/docker/DockerApp.tsx"), -); -const GuacamoleApp = lazy( - () => import("@/ui/desktop/apps/features/guacamole/GuacamoleApp.tsx"), + () => import("@/features/server-stats/ServerStatsApp"), ); +const DockerApp = lazy(() => import("@/features/docker/DockerApp")); +const GuacamoleApp = lazy(() => import("@/features/guacamole/GuacamoleApp")); + const ElectronVersionCheck = lazy(() => - import("@/ui/desktop/user/ElectronVersionCheck.tsx").then((module) => ({ + import("@/user/ElectronVersionCheck").then((module) => ({ default: module.ElectronVersionCheck, })), ); -const FullscreenApp: React.FC = () => { +type Phase = + | "verifying" + | "idle-auth" + | "fading-in" + | "idle-app" + | "fading-out"; + +function useWindowWidth() { + const [width, setWidth] = useState(window.innerWidth); + const lastSwitchTime = useRef(0); + const isCurrentlyMobile = useRef(window.innerWidth < 768); + const hasSwitchedOnce = useRef(false); + + useEffect(() => { + let timeoutId: number; + const handleResize = () => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + const newWidth = window.innerWidth; + const newIsMobile = newWidth < 768; + const now = Date.now(); + if (hasSwitchedOnce.current && now - lastSwitchTime.current < 10000) { + setWidth(newWidth); + return; + } + if ( + newIsMobile !== isCurrentlyMobile.current && + now - lastSwitchTime.current > 5000 + ) { + lastSwitchTime.current = now; + isCurrentlyMobile.current = newIsMobile; + hasSwitchedOnce.current = true; + setWidth(newWidth); + } else { + setWidth(newWidth); + } + }, 2000); + }; + window.addEventListener("resize", handleResize); + return () => { + clearTimeout(timeoutId); + window.removeEventListener("resize", handleResize); + }; + }, []); + + return width; +} + +function FullscreenApp() { const searchParams = new URLSearchParams(window.location.search); const view = searchParams.get("view"); const hostId = searchParams.get("hostId"); switch (view) { - case "host-manager": - return ; case "terminal": return ; case "file-manager": @@ -63,53 +105,136 @@ const FullscreenApp: React.FC = () => { case "telnet": return ; default: - return ; + return null; } -}; -import { useServiceWorker } from "@/hooks/use-service-worker"; +} -function useWindowWidth() { - const [width, setWidth] = useState(window.innerWidth); - const lastSwitchTime = useRef(0); - const isCurrentlyMobile = useRef(window.innerWidth < 768); - const hasSwitchedOnce = useRef(false); +function App() { + const stored = getStoredAuth(); + const [phase, setPhase] = useState( + stored?.loggedIn ? "verifying" : "idle-auth", + ); + const [authUsername, setAuthUsername] = useState(stored?.username ?? ""); + const timerRef = useRef | null>(null); useEffect(() => { - let timeoutId: NodeJS.Timeout; - const handleResize = () => { - clearTimeout(timeoutId); - timeoutId = setTimeout(() => { - const newWidth = window.innerWidth; - const newIsMobile = newWidth < 768; - const now = Date.now(); - - if (hasSwitchedOnce.current && now - lastSwitchTime.current < 10000) { - setWidth(newWidth); - return; - } - - if ( - newIsMobile !== isCurrentlyMobile.current && - now - lastSwitchTime.current > 5000 - ) { - lastSwitchTime.current = now; - isCurrentlyMobile.current = newIsMobile; - hasSwitchedOnce.current = true; - setWidth(newWidth); - } else { - setWidth(newWidth); - } - }, 2000); - }; - window.addEventListener("resize", handleResize); - + const savedAccent = localStorage.getItem("termix-accent"); + if (savedAccent) applyAccentColor(savedAccent); + const savedSize = localStorage.getItem( + "termix-font-size", + ) as FontSizeId | null; + applyFontSize(savedSize ?? "lg"); return () => { - clearTimeout(timeoutId); - window.removeEventListener("resize", handleResize); + if (timerRef.current) clearTimeout(timerRef.current); }; }, []); - return width; + // Verify stored session against the server before rendering AppShell. + // Wait for API instances to be initialized with correct embedded/server config first. + // In Electron, also repopulate localStorage["jwt"] so WebSocket connections can auth + // after a session restore (the token is only written to localStorage during a fresh login). + useEffect(() => { + if (phase !== "verifying") return; + appReadyPromise + .then(() => getUserInfo()) + .then(() => { + if (isElectron()) { + getCurrentToken() + .then((token) => { + if (token) localStorage.setItem("jwt", token); + }) + .catch(() => {}); + } + setPhase("fading-in"); + timerRef.current = setTimeout(() => setPhase("idle-app"), 450); + }) + .catch(() => { + clearStoredAuth(); + setPhase("idle-auth"); + }); + }, [phase]); + + function handleLogin(u: string) { + setAuthUsername(u); + setPhase("fading-in"); + timerRef.current = setTimeout(() => setPhase("idle-app"), 450); + if (isElectron()) { + window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {}); + } + } + + function handleLogout() { + clearStoredAuth(); + setPhase("fading-out"); + timerRef.current = setTimeout(() => { + setAuthUsername(""); + setPhase("idle-auth"); + }, 450); + } + + const showApp = + phase === "idle-app" || phase === "fading-in" || phase === "fading-out"; + const showAuth = + phase === "idle-auth" || phase === "fading-in" || phase === "fading-out"; + const appOpacity = phase === "idle-app" ? 1 : 0; + const authOpacity = phase === "idle-auth" ? 1 : 0; + + const { t } = useTranslation(); + const isTransitioning = phase === "fading-in" || phase === "fading-out"; + + if (phase === "verifying") { + return ( +
+
+
+

{t("common.loading")}

+
+
+ ); + } + + return ( + <> + {isTransitioning && ( +
+
+
+

+ {t("common.loading")} +

+
+
+ )} + + {showApp && ( +
+ + + +
+ )} + + {showAuth && ( +
+ +
+ )} + + + + ); } function RootApp() { @@ -125,61 +250,26 @@ function RootApp() { (window as Window & { opera?: string }).opera || ""; const isTermixMobile = /Termix-Mobile/.test(userAgent); - const searchParams = new URLSearchParams(window.location.search); const isFullscreen = searchParams.has("view"); - const renderApp = () => { - if (isFullscreen) { - return ; - } + if (isFullscreen) { + return ( + + + + ); + } - if (isElectron()) { - return ; - } + if (isElectron() && showVersionCheck) { + return ( + + setShowVersionCheck(false)} /> + + ); + } - if (isTermixMobile) { - return ; - } - - return isMobile ? : ; - }; - - return ( - <> - {!isFullscreen && ( -
- )} -
- {isElectron() && showVersionCheck && !isFullscreen ? ( - - setShowVersionCheck(false)} - isAuthenticated={false} - /> - - ) : ( - {renderApp()} - )} -
- - ); + return ; } prepareClientCacheVersion().finally(() => { diff --git a/src/package.json b/src/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/src/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/src/types/index.ts b/src/types/index.ts index f39913e5..a8910122 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -88,12 +88,28 @@ export interface Host { ignoreCert?: boolean; guacamoleConfig?: string | Record; + enableSsh?: boolean; + enableRdp?: boolean; + enableVnc?: boolean; + enableTelnet?: boolean; + sshPort?: number; + rdpPort?: number; + vncPort?: number; + telnetPort?: number; + rdpUser?: string; + rdpPassword?: string; + rdpDomain?: string; + rdpSecurity?: string; + rdpIgnoreCert?: boolean; + vncPassword?: string; + vncUser?: string; + telnetUser?: string; + telnetPassword?: string; createdAt: string; updatedAt: string; - hasPassword?: boolean; hasKey?: boolean; - hasSudoPassword?: boolean; + hasKeyPassword?: boolean; isShared?: boolean; permissionLevel?: "view"; @@ -171,6 +187,24 @@ export interface HostData { ignoreCert?: boolean; guacamoleConfig?: Record | null; dockerConfig?: Record | null; + + enableSsh?: boolean; + enableRdp?: boolean; + enableVnc?: boolean; + enableTelnet?: boolean; + sshPort?: number; + rdpPort?: number; + vncPort?: number; + telnetPort?: number; + rdpUser?: string; + rdpPassword?: string; + rdpDomain?: string; + rdpSecurity?: string; + rdpIgnoreCert?: boolean; + vncPassword?: string; + vncUser?: string; + telnetUser?: string; + telnetPassword?: string; } export type SSHHost = Host; @@ -201,6 +235,10 @@ export interface Credential { password?: string; key?: string; publicKey?: string; + /** CA-signed certificate file content (e.g. id_ed25519-cert.pub) */ + certPublicKey?: string; + /** True when a cert is stored but certPublicKey content is redacted in list responses */ + hasCertPublicKey?: boolean; keyPassword?: string; keyType?: string; usageCount: number; @@ -222,6 +260,8 @@ export interface CredentialBackend { key: string; privateKey?: string; publicKey?: string; + /** CA-signed certificate file content (e.g. id_ed25519-cert.pub) */ + certPublicKey?: string; keyPassword: string | null; keyType?: string; detectedKeyType: string; @@ -241,6 +281,8 @@ export interface CredentialData { password?: string; key?: string; publicKey?: string; + /** CA-signed certificate file content (e.g. id_ed25519-cert.pub) */ + certPublicKey?: string | null; keyPassword?: string; keyType?: string; } diff --git a/src/types/stats-widgets.ts b/src/types/stats-widgets.ts index b355a4a8..4ea351dd 100644 --- a/src/types/stats-widgets.ts +++ b/src/types/stats-widgets.ts @@ -69,6 +69,9 @@ export const DEFAULT_STATS_CONFIG: StatsConfig = { "uptime", "system", "login_stats", + "processes", + "ports", + "firewall", ], statusCheckEnabled: true, statusCheckInterval: 30, diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts new file mode 100644 index 00000000..d3a68d18 --- /dev/null +++ b/src/types/ui-types.ts @@ -0,0 +1,318 @@ +export type Host = { + id: string; + name: string; + username: string; + ip: string; + port: number; + folder: string; + online: boolean; + cpu: number | null; + ram: number | null; + lastAccess: string; + tags?: string[]; + authType: "password" | "key" | "credential" | "none" | "opkssh"; + credentialId?: string; + overrideCredentialUsername?: boolean; + password?: string; + hasKey?: boolean; + hasKeyPassword?: boolean; + key?: string; + keyPassword?: string; + keyType?: string; + notes?: string; + macAddress?: string; + pin?: boolean; + + enableTerminal: boolean; + terminalConfig?: { + cursorBlink: boolean; + cursorStyle: "block" | "underline" | "bar"; + fontSize: number; + fontFamily: string; + letterSpacing: number; + lineHeight: number; + theme: string; + scrollback: number; + bellStyle: "none" | "sound" | "visual" | "both"; + rightClickSelectsWord: boolean; + fastScrollModifier: "alt" | "ctrl" | "shift"; + fastScrollSensitivity: number; + minimumContrastRatio: number; + backspaceMode: "normal" | "control-h"; + agentForwarding: boolean; + autoMosh: boolean; + moshCommand: string; + autoTmux: boolean; + sudoPasswordAutoFill: boolean; + sudoPassword?: string; + keepaliveInterval?: number; + keepaliveCountMax?: number; + environmentVariables: { key: string; value: string }[]; + startupSnippetId?: number | null; + }; + + useSocks5?: boolean; + socks5Host?: string; + socks5Port?: number; + socks5Username?: string; + socks5Password?: string; + socks5ProxyChain?: { + host: string; + port: number; + type: 4 | 5 | "http" | string; + username?: string; + password?: string; + }[]; + jumpHosts?: { hostId: string }[]; + portKnockSequence?: { + port: number; + protocol: "tcp" | "udp"; + delay: number; + }[]; + + enableTunnel: boolean; + serverTunnels: { + mode: "local" | "remote" | "dynamic"; + bindHost?: string; + targetHost?: string; + sourcePort: number; + endpointHost: string; + endpointPort: number; + maxRetries: number; + retryInterval: number; + autoStart: boolean; + }[]; + + enableFileManager: boolean; + defaultPath?: string; + + enableDocker: boolean; + + statsConfig?: { + statusCheckEnabled: boolean; + statusCheckInterval: number; + useGlobalStatusInterval: boolean; + metricsEnabled: boolean; + metricsInterval: number; + useGlobalMetricsInterval: boolean; + enabledWidgets: string[]; + }; + quickActions: { name: string; snippetId: string }[]; + + enableSsh: boolean; + enableRdp: boolean; + enableVnc: boolean; + enableTelnet: boolean; + + sshPort: number; + rdpPort: number; + vncPort: number; + telnetPort: number; + + rdpUser?: string; + rdpPassword?: string; + domain?: string; + security?: string; + ignoreCert?: boolean; + + vncPassword?: string; + vncUser?: string; + + telnetUser?: string; + telnetPassword?: string; + + guacamoleConfig?: Record; + forceKeyboardInteractive?: boolean; +}; + +export type Credential = { + id: string; + name: string; + username: string; + type: "password" | "key"; + value?: string; + publicKey?: string; + passphrase?: string; + description?: string; + folder?: string; + tags?: string[]; +}; + +export type HostFolder = { + name: string; + children: (Host | HostFolder)[]; +}; + +export type TabType = + | "dashboard" + | "terminal" + | "rdp" + | "vnc" + | "telnet" + | "stats" + | "files" + | "host-manager" + | "user-profile" + | "admin-settings" + | "docker" + | "tunnel" + | "network_graph"; + +export type TunnelStatusValue = + | "CONNECTED" + | "CONNECTING" + | "DISCONNECTING" + | "DISCONNECTED" + | "ERROR" + | "WAITING"; +export type TunnelMode = "local" | "remote" | "dynamic"; + +export type Tunnel = { + id: string; + hostId: string; + sourcePort: number; + endpointHost: string; + endpointPort: number; + status: TunnelStatusValue; + mode: TunnelMode; + reason?: string; + retryCount?: number; + maxRetries?: number; +}; + +export type Tab = { + id: string; + type: TabType; + label: string; + host?: Host; + terminalRef?: import("react").RefObject<{ + sendInput?: (data: string) => void; + reconnect?: () => void; + } | null>; +}; + +export type DockerContainerStatus = + | "running" + | "exited" + | "paused" + | "created" + | "restarting"; + +export type DockerContainer = { + id: string; + name: string; + image: string; + status: DockerContainerStatus; + cpu: number; + memory: string; + ports: string[]; + created: string; +}; + +export type DashboardCardId = + | "stats_bar" + | "counters_bar" + | "quick_actions" + | "host_status" + | "recent_activity" + | "network_graph"; + +export type DashboardCardConfig = { + id: DashboardCardId; + label: string; + description: string; + defaultEnabled: boolean; +}; + +export type CardColSpan = "full" | "wide" | "half" | "narrow"; +export type CardRowSize = "short" | "medium" | "tall" | "flex"; + +export type CardLayoutConfig = { + id: DashboardCardId; + colSpan: CardColSpan; + rowSize: CardRowSize; + order: number; +}; + +export type LayoutPresetId = "default" | "compact" | "focus" | "wide"; + +export type LayoutPreset = { + id: LayoutPresetId; + label: string; + description: string; + cards: CardLayoutConfig[]; +}; + +export type UserProfileSection = + | "account" + | "appearance" + | "security" + | "api-keys"; +export type AdminSection = + | "general" + | "oidc" + | "users" + | "sessions" + | "roles" + | "database" + | "api-keys"; +export type AccentColorId = string; +export type ThemeId = + | "dark" + | "light" + | "system" + | "dracula" + | "catppuccin" + | "nord" + | "solarized" + | "tokyo-night" + | "one-dark" + | "gruvbox"; +export type FontSizeId = "xs" | "sm" | "md" | "lg" | "xl"; + +export type ToolsTab = "ssh-tools" | "snippets" | "history" | "split-screen"; +export type SplitMode = + | "none" + | "2-way" + | "3-way" + | "3-way-horizontal" + | "4-way" + | "5-way" + | "6-way"; + +export type Snippet = { + id: number; + name: string; + description?: string; + content: string; + folder: string | null; +}; + +export const FOLDER_ICONS = [ + "folder", + "server", + "cloud", + "database", + "box", + "network", + "copy", + "settings", + "cpu", + "globe", +] as const; +export type FolderIconId = (typeof FOLDER_ICONS)[number]; + +export type SnippetFolder = { + id: number; + name: string; + color: string; + icon: FolderIconId; + open: boolean; +}; + +export type HistoryEntry = { + id: number; + command: string; + host: string; + time: string; +}; diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx new file mode 100644 index 00000000..cfac02b7 --- /dev/null +++ b/src/ui/AppShell.tsx @@ -0,0 +1,859 @@ +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { Separator } from "@/components/separator"; +import { Button } from "@/components/button"; +import { Sheet, SheetContent } from "@/components/sheet"; +import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react"; +import { + useState, + useRef, + useCallback, + useEffect, + createRef, + createPortal, +} from "react"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { MobileBottomBar } from "@/shell/MobileBottomBar"; +import { CommandPalette } from "@/shell/CommandPalette"; +import { AppRail } from "@/sidebar/AppRail"; +import type { RailView } from "@/sidebar/AppRail"; +import { HostsPanel } from "@/sidebar/HostsPanel"; +import { QuickConnectPanel } from "@/sidebar/QuickConnectPanel"; +import { SshToolsPanel } from "@/sidebar/SshToolsPanel"; +import { SnippetsPanel } from "@/sidebar/SnippetsPanel"; +import { HistoryPanel } from "@/sidebar/HistoryPanel"; +import { SplitScreenPanel } from "@/sidebar/SplitScreenPanel"; +import { UserProfilePanel } from "@/sidebar/UserProfilePanel"; +import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel"; +import { CredentialsPanel } from "@/sidebar/CredentialsPanel"; +import { SplitView } from "@/shell/SplitView"; +import { renderTabContent } from "@/shell/tabUtils"; +import { TabBar } from "@/shell/TabBar"; +import type { + Tab, + TabType, + Host, + SplitMode, + HostFolder, +} from "@/types/ui-types"; +import { getSSHHosts, getUserInfo } from "@/main-axios"; +import { dbHealthMonitor } from "@/lib/db-health-monitor"; +import type { SSHHostWithStatus } from "@/main-axios"; + +function sshHostToHost(h: SSHHostWithStatus): Host { + return { + id: String(h.id), + name: h.name, + username: h.username, + ip: h.ip, + port: h.port, + folder: h.folder ?? "", + online: h.status === "online", + cpu: 0, + ram: 0, + lastAccess: "", + tags: h.tags ?? [], + authType: h.authType, + password: h.password, + key: typeof h.key === "string" ? h.key : undefined, + keyPassword: h.keyPassword, + keyType: h.keyType, + credentialId: h.credentialId != null ? String(h.credentialId) : undefined, + notes: h.notes, + pin: h.pin ?? false, + macAddress: h.macAddress, + enableSsh: h.enableSsh ?? (h.connectionType === "ssh" || !h.connectionType), + enableTerminal: h.enableTerminal ?? true, + enableTunnel: h.enableTunnel ?? false, + enableFileManager: h.enableFileManager ?? false, + enableDocker: h.enableDocker ?? false, + enableRdp: h.enableRdp ?? h.connectionType === "rdp", + enableVnc: h.enableVnc ?? h.connectionType === "vnc", + enableTelnet: h.enableTelnet ?? h.connectionType === "telnet", + sshPort: h.port, + rdpPort: 3389, + vncPort: 5900, + telnetPort: 23, + quickActions: (h.quickActions ?? []).map((a) => ({ + name: a.name, + snippetId: String(a.snippetId), + })), + serverTunnels: [], + defaultPath: h.defaultPath, + terminalConfig: h.terminalConfig as Host["terminalConfig"], + useSocks5: h.useSocks5, + socks5Host: h.socks5Host, + socks5Port: h.socks5Port, + socks5Username: h.socks5Username, + socks5Password: h.socks5Password, + }; +} + +function buildHostTree(hosts: SSHHostWithStatus[]): HostFolder { + const root: HostFolder = { name: "root", children: [] }; + const folderMap = new Map(); + const getOrCreateFolder = (path: string): HostFolder => { + if (folderMap.has(path)) return folderMap.get(path)!; + const parts = path.split(" / "); + let current = root; + let accumulated = ""; + for (const part of parts) { + accumulated = accumulated ? `${accumulated} / ${part}` : part; + if (!folderMap.has(accumulated)) { + const folder: HostFolder = { name: part, children: [] }; + folderMap.set(accumulated, folder); + current.children.push(folder); + } + current = folderMap.get(accumulated)!; + } + return current; + }; + for (const h of hosts) { + const host = sshHostToHost(h); + if (h.folder) { + getOrCreateFolder(h.folder).children.push(host); + } else { + root.children.push(host); + } + } + return root; +} +export { tabIcon, renderTabContent } from "@/shell/tabUtils"; + +// ─── AppShell ──────────────────────────────────────────────────────────────── + +export function AppShell({ + username, + onLogout, +}: { + username: string; + onLogout: () => void; +}) { + const { t } = useTranslation(); + const [tabs, setTabs] = useState([ + { id: "dashboard", type: "dashboard", label: t("nav.dashboard") }, + ]); + const [activeTabId, setActiveTabId] = useState("dashboard"); + const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); + const [splitMode, setSplitMode] = useState("none"); + const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>( + Array(6).fill(null), + ); + const [realHostTree, setRealHostTree] = useState(null); + const [hostsLoading, setHostsLoading] = useState(true); + const [allHosts, setAllHosts] = useState([]); + const [isAdmin, setIsAdmin] = useState(false); + + const [sidebarOpen, setSidebarOpen] = useState(true); + const [railView, setRailView] = useState("hosts"); + const [profileDropdownOpen, setProfileDropdownOpen] = useState(false); + const [sidebarWidth, setSidebarWidth] = useState(266); + const [sidebarDragging, setSidebarDragging] = useState(false); + const [sidebarEditing, setSidebarEditing] = useState(false); + + const isMobile = useIsMobile(); + + const sidebarOpenBeforeMobile = useRef(sidebarOpen); + useEffect(() => { + if (isMobile) { + sidebarOpenBeforeMobile.current = sidebarOpen; + setSidebarOpen(false); + } else { + setSidebarOpen(sidebarOpenBeforeMobile.current); + } + }, [isMobile]); + + useEffect(() => { + getUserInfo() + .then((info) => setIsAdmin(info.is_admin)) + .catch(() => setIsAdmin(false)); + }, []); + + const lastShiftTime = useRef(0); + const terminalRefs = useRef>>( + new Map(), + ); + const [paneContentEls, setPaneContentEls] = useState< + (HTMLDivElement | null)[] + >(Array(6).fill(null)); + + const onPaneContentRef = useCallback( + (paneIndex: number, el: HTMLDivElement | null) => { + setPaneContentEls((prev) => { + if (prev[paneIndex] === el) return prev; + const next = [...prev]; + next[paneIndex] = el; + return next; + }); + }, + [], + ); + + const sidebarTitle: Record = { + hosts: "Hosts", + credentials: "Credentials", + "quick-connect": "Quick Connect", + "ssh-tools": "SSH Tools", + snippets: "Snippets", + history: "History", + "split-screen": "Split Screen", + "user-profile": "User Profile", + "admin-settings": "Admin Settings", + }; + + // Double-shift opens command palette + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.code === "ShiftLeft") { + const now = Date.now(); + if (now - lastShiftTime.current < 300) + setCommandPaletteOpen((prev) => !prev); + lastShiftTime.current = now; + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + useEffect(() => { + const handle = () => onLogout(); + window.addEventListener("termix:logout", handle); + return () => window.removeEventListener("termix:logout", handle); + }, [onLogout]); + + useEffect(() => { + const handleSessionExpired = () => onLogout(); + dbHealthMonitor.on("session-expired", handleSessionExpired); + return () => dbHealthMonitor.off("session-expired", handleSessionExpired); + }, [onLogout]); + + useEffect(() => { + const activeTab = tabs.find((t) => t.id === activeTabId); + if (!activeTab?.terminalRef) return; + let innerRafId: number; + const outerRafId = requestAnimationFrame(() => { + innerRafId = requestAnimationFrame(() => { + const ref = activeTab.terminalRef?.current; + ref?.fit?.(); + ref?.notifyResize?.(); + ref?.refresh?.(); + }); + }); + return () => { + cancelAnimationFrame(outerRafId); + cancelAnimationFrame(innerRafId); + }; + }, [activeTabId]); + + useEffect(() => { + const handleDegraded = () => { + toast.loading(t("common.connectionDegraded"), { + id: "db-connection-degraded", + duration: Infinity, + dismissible: false, + action: { + label: t("common.reload"), + onClick: () => window.location.reload(), + }, + }); + }; + + const handleRestored = () => { + toast.dismiss("db-connection-degraded"); + toast.success(t("common.backendReconnected"), { duration: 3000 }); + }; + + dbHealthMonitor.on("database-connection-degraded", handleDegraded); + dbHealthMonitor.on("database-connection-degraded-cleared", handleRestored); + + return () => { + dbHealthMonitor.off("database-connection-degraded", handleDegraded); + dbHealthMonitor.off( + "database-connection-degraded-cleared", + handleRestored, + ); + }; + }, [t]); + + // Load real hosts from API + const loadHosts = useCallback(async () => { + try { + const raw = await getSSHHosts(); + const converted = raw.map(sshHostToHost); + setAllHosts(converted); + setRealHostTree(buildHostTree(raw)); + } catch { + // Keep empty state on error + } finally { + setHostsLoading(false); + } + }, []); + + useEffect(() => { + loadHosts(); + }, [loadHosts]); + + useEffect(() => { + window.addEventListener("termix:hosts-changed", loadHosts); + return () => window.removeEventListener("termix:hosts-changed", loadHosts); + }, [loadHosts]); + + // Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings) + useEffect(() => { + if (allHosts.length === 0) return; + setTabs((prev) => + prev.map((t) => + t.host + ? { ...t, host: allHosts.find((h) => h.id === t.host!.id) ?? t.host } + : t, + ), + ); + }, [allHosts]); + + // Let HostManager trigger tab opens via custom event + useEffect(() => { + const handle = (e: Event) => { + const { hostId, type } = ( + e as CustomEvent<{ hostId: string; type?: TabType }> + ).detail; + const host = allHosts.find((h) => h.id === hostId); + if (host) connectHost(host, type); + }; + window.addEventListener("termix:open-tab", handle); + return () => window.removeEventListener("termix:open-tab", handle); + }, [allHosts]); + + // ─── Tab management ────────────────────────────────────────────────────── + + const openTab = useCallback(function openTab(host: Host, type: TabType) { + const tabId = `${host.name}-${type}-${Date.now()}`; + const ref = type === "terminal" ? createRef() : undefined; + if (ref) terminalRefs.current.set(tabId, ref); + + setTabs((prev) => { + const same = prev.filter( + (t) => + t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name, + ); + const label = + same.length === 0 ? host.name : `${host.name} (${same.length + 1})`; + + // Retrofit the first duplicate's label to "(1)" if needed + const next = + same.length === 1 && !/\(\d+\)$/.test(same[0].label) + ? prev.map((t) => + t.id === same[0].id ? { ...t, label: `${host.name} (1)` } : t, + ) + : prev; + + return [...next, { id: tabId, type, label, host, terminalRef: ref }]; + }); + setActiveTabId(tabId); + }, []); + + function connectHost(host: Host, preferredType?: TabType) { + const type: TabType = + preferredType ?? + (host.enableSsh + ? "terminal" + : host.enableRdp + ? "rdp" + : host.enableVnc + ? "vnc" + : host.enableTelnet + ? "telnet" + : "terminal"); + openTab(host, type); + } + + const openSingletonTab = useCallback( + function openSingletonTab(type: TabType, pendingEvent?: string) { + if (type === "host-manager") { + if (pendingEvent === "host-manager:add-credential") { + setSidebarOpen(true); + setRailView("credentials"); + setTimeout( + () => + window.dispatchEvent( + new CustomEvent("host-manager:add-credential"), + ), + 0, + ); + } else { + setSidebarOpen(true); + setRailView("hosts"); + if (pendingEvent) { + setTimeout( + () => window.dispatchEvent(new CustomEvent(pendingEvent)), + 0, + ); + } + } + return; + } + if (type === "user-profile" || type === "admin-settings") { + setSidebarEditing(false); + setRailView(type as RailView); + setSidebarOpen(true); + return; + } + const id = type; + setTabs((prev) => { + if (prev.find((t) => t.id === id)) return prev; + const singletonLabels: Partial> = { + "host-manager": t("nav.hostManager"), + docker: t("nav.docker"), + tunnel: t("nav.tunnels"), + network_graph: t("nav.networkGraph"), + }; + return [...prev, { id, type, label: singletonLabels[type] ?? type }]; + }); + setActiveTabId(id); + }, + [t], + ); + + const SESSION_TAB_TYPES: TabType[] = ["terminal", "rdp", "vnc", "telnet"]; + + function doCloseTab(id: string) { + terminalRefs.current.delete(id); + if (id === activeTabId) { + const remaining = tabs.filter((t) => t.id !== id); + setActiveTabId( + remaining.length > 0 ? remaining[remaining.length - 1].id : "dashboard", + ); + } + setTabs((prev) => { + const next = prev.filter((t) => t.id !== id); + if (next.length === 0) + return [ + { id: "dashboard", type: "dashboard", label: t("nav.dashboard") }, + ]; + return next; + }); + } + + function refreshTab(id: string) { + const tab = tabs.find((t) => t.id === id); + if (!tab) return; + if (tab.type === "terminal") { + const ref = tab.terminalRef?.current; + ref?.reconnect?.(); + } else if (["rdp", "vnc", "telnet"].includes(tab.type)) { + window.dispatchEvent( + new CustomEvent("termix:refresh-guacamole", { detail: { tabId: id } }), + ); + } + } + + function closeTab(id: string) { + const tab = tabs.find((t) => t.id === id); + const confirmEnabled = localStorage.getItem("confirmTabClose") === "true"; + if (tab && SESSION_TAB_TYPES.includes(tab.type) && confirmEnabled) { + toast(t("nav.confirmClose"), { + duration: 5000, + action: { + label: t("nav.close"), + onClick: () => doCloseTab(id), + }, + cancel: { + label: t("nav.cancel"), + onClick: () => {}, + }, + }); + return; + } + doCloseTab(id); + } + + // ─── Rail / sidebar ────────────────────────────────────────────────────── + + function handleRailClick(view: RailView) { + if (railView === view && sidebarOpen) { + setSidebarOpen(false); + } else { + if (view !== railView) setSidebarEditing(false); + setRailView(view); + setSidebarOpen(true); + } + } + + function editHostInManager(host: Host) { + setSidebarOpen(true); + setRailView("hosts"); + setTimeout(() => { + window.dispatchEvent( + new CustomEvent("host-manager:edit-host", { detail: host.id }), + ); + }, 0); + } + + const onSidebarMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setSidebarDragging(true); + const startX = e.clientX; + const startW = sidebarWidth; + function onMove(ev: MouseEvent) { + setSidebarWidth( + Math.max(160, Math.min(480, startW + ev.clientX - startX)), + ); + } + function onUp() { + setSidebarDragging(false); + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + } + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }, + [sidebarWidth], + ); + + // Resize all terminals in panes + active terminal when split mode or sidebar changes + const resizeAllTerminals = useCallback(() => { + const id = requestAnimationFrame(() => { + tabs.forEach((tab) => { + if (!tab.terminalRef) return; + const ref = tab.terminalRef.current as any; + ref?.fit?.(); + ref?.notifyResize?.(); + }); + }); + return id; + }, [tabs]); + + useEffect(() => { + const id = resizeAllTerminals(); + return () => cancelAnimationFrame(id); + }, [splitMode, sidebarWidth, sidebarOpen]); + + const activeTab = tabs.find((t) => t.id === activeTabId)!; + const isSplit = splitMode !== "none"; + const terminalTabs = tabs.filter((t) => t.type === "terminal"); + + // Sidebar panel content — shared between desktop inline sidebar and mobile sheet + const sidebarPanelContent = ( +
+
+ { + connectHost(host, type); + if (isMobile) setSidebarOpen(false); + }} + onEditHost={editHostInManager} + hostTree={realHostTree ?? undefined} + loading={hostsLoading} + onEditingChange={setSidebarEditing} + active={railView === "hosts"} + /> +
+ +
+ +
+ + {railView === "quick-connect" && ( + { + openTab(host, type); + if (isMobile) setSidebarOpen(false); + }} + /> + )} + + {railView === "ssh-tools" && ( +
+ +
+ )} + + {railView === "snippets" && ( +
+ +
+ )} + + {railView === "history" && ( +
+ +
+ )} + + {railView === "split-screen" && ( +
+ +
+ )} + + {railView === "user-profile" && ( +
+ +
+ )} + + {railView === "admin-settings" && isAdmin && ( +
+ +
+ )} +
+ ); + + // Sidebar header — shared + const sidebarHeader = ( +
+ + {sidebarTitle[railView]} + + {!isMobile && ( + <> + + + + )} + + +
+ ); + + return ( + <> +
+ {/* Skinny icon rail — desktop only, hidden on mobile */} + + + {/* Desktop: inline resizable sidebar */} + {!isMobile && ( +
+ {sidebarHeader} + {sidebarPanelContent} + + {sidebarOpen && !sidebarEditing && ( +
+ )} +
+ )} + + {/* Mobile: sidebar as overlay sheet */} + {isMobile && ( + + + {sidebarHeader} + {sidebarPanelContent} + + + )} + + {/* Main content area */} +
+ {!isMobile && !sidebarOpen && ( + + )} +
+ +
+ {/* Split view — always mounted when not mobile, hidden via CSS when inactive */} + {!isMobile && ( +
+ +
+ )} + + {/* Normal tab view — always mounted, hidden via CSS when split is active */} +
+ {/* Terminal tabs: always in DOM, visibility-toggled so xterm keeps its dimensions. + When split is active and this tab is assigned to a pane, portal it there. */} + {tabs + .filter((tab) => tab.type === "terminal") + .map((tab) => { + const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1; + const inPane = paneIdx !== -1; + const paneEl = inPane ? paneContentEls[paneIdx] : null; + const visible = inPane || tab.id === activeTabId; + + const terminalContent = renderTabContent( + tab, + openSingletonTab, + openTab, + closeTab, + visible, + ); + + if (inPane && paneEl) { + return createPortal( +
+ {terminalContent} +
, + paneEl, + tab.id, + ); + } + + return ( +
+ {terminalContent} +
+ ); + })} + {/* Non-terminal tabs */} + {tabs + .filter((tab) => tab.type !== "terminal") + .map((tab) => { + const visible = tab.id === activeTabId; + return ( +
+ {renderTabContent( + tab, + openSingletonTab, + openTab, + closeTab, + visible, + )} +
+ ); + })} +
+
+
+ + {/* Bottom nav bar — mobile only */} + +
+
+ + { + if ( + [ + "dashboard", + "host-manager", + "user-profile", + "admin-settings", + ].includes(type) + ) { + openSingletonTab(type, pendingEvent); + } else if (label) { + const host = allHosts.find((h) => h.name === label); + if (host) openTab(host, type); + } + }} + /> + + ); +} diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx new file mode 100644 index 00000000..ab6e8d5f --- /dev/null +++ b/src/ui/auth/Auth.tsx @@ -0,0 +1,1338 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { Button } from "@/components/button"; +import { Input } from "@/components/input"; +import { Separator } from "@/components/separator"; +import { toast } from "sonner"; +import { + Eye, + EyeOff, + User, + KeyRound, + ArrowLeft, + Shield, + CheckCircle2, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + loginUser, + registerUser, + getUserInfo, + getRegistrationAllowed, + getPasswordLoginAllowed, + getPasswordResetAllowed, + getOIDCConfig, + getSetupRequired, + initiatePasswordReset, + verifyPasswordResetCode, + completePasswordReset, + getOIDCAuthorizeUrl, + verifyTOTPLogin, + getServerConfig, + saveServerConfig, + isElectron, + getEmbeddedServerStatus, +} from "@/main-axios"; +import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig"; +import { ElectronLoginForm } from "@/auth/ElectronLoginForm"; +import { Checkbox } from "@/components/checkbox"; +import i18n from "@/i18n/i18n"; +import { + removeSilentSigninFromSearch, + shouldTriggerSilentSignin, +} from "./silent-signin"; + +const LANGUAGES = [ + { code: "en", label: "English" }, + { code: "af", label: "Afrikaans" }, + { code: "ar", label: "العربية" }, + { code: "bn", label: "বাংলা" }, + { code: "bg", label: "Български" }, + { code: "ca", label: "Català" }, + { code: "zh-CN", label: "中文 (简体)" }, + { code: "zh-TW", label: "中文 (繁體)" }, + { code: "cs", label: "Čeština" }, + { code: "da", label: "Dansk" }, + { code: "nl", label: "Nederlands" }, + { code: "fi", label: "Suomi" }, + { code: "fr", label: "Français" }, + { code: "de", label: "Deutsch" }, + { code: "el", label: "Ελληνικά" }, + { code: "he", label: "עברית" }, + { code: "hi", label: "हिन्दी" }, + { code: "hu", label: "Magyar" }, + { code: "id", label: "Indonesia" }, + { code: "it", label: "Italiano" }, + { code: "ja", label: "日本語" }, + { code: "ko", label: "한국어" }, + { code: "no", label: "Norsk" }, + { code: "pl", label: "Polski" }, + { code: "pt-PT", label: "Português (PT)" }, + { code: "pt-BR", label: "Português (BR)" }, + { code: "ro", label: "Română" }, + { code: "ru", label: "Русский" }, + { code: "sr", label: "Српски" }, + { code: "es-ES", label: "Español" }, + { code: "sv-SE", label: "Svenska" }, + { code: "th", label: "ไทย" }, + { code: "tr", label: "Türkçe" }, + { code: "uk", label: "Українська" }, + { code: "vi", label: "Tiếng Việt" }, +]; + +const STORAGE_KEY = "termix_auth"; + +export function getStoredAuth(): { + loggedIn: boolean; + username: string; +} | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch { + return null; + } +} + +export function clearStoredAuth() { + localStorage.removeItem(STORAGE_KEY); +} + +function storeAuth(username: string) { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ loggedIn: true, username }), + ); +} + +type AuthView = "login" | "register" | "reset" | "totp" | "external"; +type ResetStep = "email" | "code" | "newpass"; + +interface AuthProps { + onLogin: (username: string, userId?: string, isAdmin?: boolean) => void; +} + +interface ExtendedWindow extends Window { + IS_ELECTRON_WEBVIEW?: boolean; +} + +const isInElectronWebView = () => { + if ((window as ExtendedWindow).IS_ELECTRON_WEBVIEW) return true; + try { + if (window.self !== window.top) return true; + } catch { + return true; + } + return false; +}; + +function PasswordInput({ + value, + onChange, + placeholder, + disabled, + id, +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + disabled?: boolean; + id?: string; +}) { + const [show, setShow] = useState(false); + return ( +
+ onChange(e.target.value)} + placeholder={placeholder ?? "••••••••"} + disabled={disabled} + className="pr-9 font-mono" + /> + +
+ ); +} + +function FieldLabel({ + htmlFor, + children, +}: { + htmlFor?: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +function Field({ + label, + htmlFor, + children, +}: { + label: string; + htmlFor?: string; + children: React.ReactNode; +}) { + return ( +
+ {label} + {children} +
+ ); +} + +export function Auth({ onLogin }: AuthProps) { + const { t } = useTranslation(); + const [view, setView] = useState("login"); + const [loading, setLoading] = useState(false); + const [oidcLoading, setOidcLoading] = useState(false); + + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [rememberMe, setRememberMe] = useState(() => { + try { + return localStorage.getItem("rememberMe") === "true"; + } catch { + return false; + } + }); + + const [totpCode, setTotpCode] = useState(""); + const [totpTempToken, setTotpTempToken] = useState(""); + const totpInputRef = useRef(null); + + const [resetStep, setResetStep] = useState("email"); + const [resetCode, setResetCode] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmNewPassword, setConfirmNewPassword] = useState(""); + const [resetTempToken, setResetTempToken] = useState(""); + + const [language, setLanguage] = useState( + () => localStorage.getItem("i18nextLng") ?? "en", + ); + + function handleLanguageChange(code: string) { + setLanguage(code); + localStorage.setItem("i18nextLng", code); + i18n.changeLanguage(code); + } + + const [registrationAllowed, setRegistrationAllowed] = useState(true); + const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true); + const [passwordResetAllowed, setPasswordResetAllowed] = useState(true); + const [oidcConfigured, setOidcConfigured] = useState(false); + const [oidcConfigLoaded, setOidcConfigLoaded] = useState(false); + const silentSigninHandledRef = useRef(false); + const [firstUser, setFirstUser] = useState(false); + const [dbConnectionFailed, setDbConnectionFailed] = useState(false); + const [dbHealthChecking, setDbHealthChecking] = useState(true); + + const [showServerConfig, setShowServerConfig] = useState( + null, + ); + const [currentServerUrl, setCurrentServerUrl] = useState(""); + const [webviewAuthSuccess, setWebviewAuthSuccess] = useState(false); + + useEffect(() => { + try { + localStorage.setItem("rememberMe", rememberMe.toString()); + } catch {} + }, [rememberMe]); + + useEffect(() => { + getRegistrationAllowed() + .then((res) => setRegistrationAllowed(res.allowed)) + .catch(() => {}); + getPasswordLoginAllowed() + .then((res) => setPasswordLoginAllowed(res.allowed)) + .catch(() => {}); + getPasswordResetAllowed() + .then((allowed) => setPasswordResetAllowed(allowed)) + .catch(() => setPasswordResetAllowed(false)); + getOIDCConfig() + .then((res) => setOidcConfigured(!!res)) + .catch(() => setOidcConfigured(false)) + .finally(() => setOidcConfigLoaded(true)); + }, []); + + useEffect(() => { + if (showServerConfig) return; + setDbHealthChecking(true); + getSetupRequired() + .then((res) => { + if (res.setup_required) { + setFirstUser(true); + setView("register"); + } + setDbConnectionFailed(false); + }) + .catch(() => setDbConnectionFailed(true)) + .finally(() => setDbHealthChecking(false)); + }, [showServerConfig]); + + useEffect(() => { + const checkElectron = async () => { + if (isInElectronWebView()) { + setShowServerConfig(false); + return; + } + if (isElectron()) { + try { + const [config, status] = await Promise.all([ + getServerConfig(), + getEmbeddedServerStatus(), + ]); + if ( + status?.embedded && + status?.running && + config && + !config.serverUrl + ) { + setShowServerConfig(false); + setCurrentServerUrl(""); + return; + } + setCurrentServerUrl(config?.serverUrl || ""); + setShowServerConfig(!config || !config.serverUrl); + } catch { + setShowServerConfig(true); + } + } else { + setShowServerConfig(false); + } + }; + checkElectron(); + }, []); + + useEffect(() => { + if (view === "totp" && totpInputRef.current) totpInputRef.current.focus(); + }, [view]); + + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const success = urlParams.get("success"); + const error = urlParams.get("error"); + if (error) { + if (error === "registration_disabled") + toast.error(t("messages.registrationDisabled")); + else if (error === "user_not_allowed") + toast.error(t("messages.userNotAllowed")); + else toast.error(`${t("errors.oidcAuthFailed")}: ${error}`); + window.history.replaceState({}, document.title, window.location.pathname); + return; + } + if (success) { + if (isInElectronWebView()) { + window.parent.postMessage( + { + type: "AUTH_SUCCESS", + source: "oidc_callback", + platform: "desktop", + timestamp: Date.now(), + }, + "*", + ); + setWebviewAuthSuccess(true); + window.history.replaceState( + {}, + document.title, + window.location.pathname, + ); + return; + } + getUserInfo() + .then((meRes) => { + storeAuth(meRes.username || ""); + onLogin( + meRes.username || "", + meRes.userId || undefined, + !!meRes.is_admin, + ); + window.history.replaceState( + {}, + document.title, + window.location.pathname, + ); + }) + .catch(() => { + toast.error(t("errors.failedUserInfo")); + }); + } + }, [onLogin, t]); + + const handleElectronAuthSuccess = useCallback( + async (token: string | null) => { + try { + if (!token) { + // No token in postMessage — fall back to waiting for the HttpOnly cookie + const cookieReady = await window.electronAPI?.waitForSessionCookie?.( + "jwt", + currentServerUrl, + null, + 5000, + ); + if (cookieReady && !cookieReady.success) + throw new Error(cookieReady.error || "Auth cookie not ready"); + } + const meRes = await getUserInfo(); + if (!meRes) throw new Error("Failed to get user info"); + storeAuth(meRes.username || ""); + onLogin( + meRes.username || "", + meRes.userId || undefined, + !!meRes.is_admin, + ); + toast.success(t("messages.loginSuccess")); + } catch { + toast.error(t("errors.failedUserInfo")); + } + }, + [onLogin, currentServerUrl, t], + ); + + function resetAll() { + setUsername(""); + setPassword(""); + setConfirmPassword(""); + setResetStep("email"); + setResetCode(""); + setNewPassword(""); + setConfirmNewPassword(""); + setResetTempToken(""); + setTotpCode(""); + setTotpTempToken(""); + } + + function switchView(v: AuthView) { + const currentUsername = username; + resetAll(); + if (v === "reset") setUsername(currentUsername); + setView(v); + } + + async function handleLogin(e: React.FormEvent) { + e.preventDefault(); + if (!username.trim()) { + toast.error(t("errors.requiredField")); + return; + } + setLoading(true); + try { + const res = await loginUser(username.trim(), password, rememberMe); + if (res.requires_totp) { + setTotpTempToken(res.temp_token); + setView("totp"); + return; + } + if (!res?.success) throw new Error(t("errors.loginFailed")); + if (isInElectronWebView()) { + window.parent.postMessage( + { + type: "AUTH_SUCCESS", + source: "auth_component", + platform: "desktop", + timestamp: Date.now(), + }, + "*", + ); + setWebviewAuthSuccess(true); + return; + } + const meRes = await getUserInfo(); + storeAuth(meRes.username || username.trim()); + toast.success(t("messages.loginSuccess")); + onLogin( + meRes.username || username.trim(), + meRes.userId || undefined, + !!meRes.is_admin, + ); + } catch (err: unknown) { + const error = err as { + message?: string; + response?: { data?: { error?: string } }; + }; + toast.error( + error?.response?.data?.error || + error?.message || + t("errors.unknownError"), + ); + } finally { + setLoading(false); + } + } + + async function handleRegister(e: React.FormEvent) { + e.preventDefault(); + if (!username.trim()) { + toast.error(t("errors.requiredField")); + return; + } + if (password.length < 6) { + toast.error(t("errors.minLength", { min: 6 })); + return; + } + if (password !== confirmPassword) { + toast.error(t("errors.passwordMismatch")); + return; + } + setLoading(true); + try { + await registerUser(username.trim(), password); + const res = await loginUser(username.trim(), password, rememberMe); + if (res.requires_totp) { + setTotpTempToken(res.temp_token); + setView("totp"); + return; + } + const meRes = await getUserInfo(); + storeAuth(meRes.username || username.trim()); + toast.success(t("messages.registrationSuccess")); + onLogin( + meRes.username || username.trim(), + meRes.userId || undefined, + !!meRes.is_admin, + ); + } catch (err: unknown) { + const error = err as { + message?: string; + response?: { data?: { error?: string } }; + }; + toast.error( + error?.response?.data?.error || + error?.message || + t("errors.unknownError"), + ); + } finally { + setLoading(false); + } + } + + async function handleTOTP(e: React.FormEvent) { + e.preventDefault(); + if (totpCode.length !== 6) { + toast.error(t("auth.enterCode")); + return; + } + setLoading(true); + try { + const res = await verifyTOTPLogin(totpTempToken, totpCode, rememberMe); + if (!res?.success) throw new Error(t("errors.loginFailed")); + if (isInElectronWebView()) { + window.parent.postMessage( + { + type: "AUTH_SUCCESS", + source: "totp_auth_component", + platform: "desktop", + timestamp: Date.now(), + }, + "*", + ); + setWebviewAuthSuccess(true); + return; + } + storeAuth(res.username || username); + toast.success(t("messages.loginSuccess")); + onLogin( + res.username || username, + res.userId || undefined, + !!res.is_admin, + ); + } catch (err: unknown) { + const error = err as { + message?: string; + response?: { data?: { error?: string; code?: string } }; + }; + if (error?.response?.data?.code === "SESSION_EXPIRED") { + setView("login"); + toast.error(t("errors.sessionExpired")); + return; + } + toast.error( + error?.response?.data?.error || + error?.message || + t("errors.invalidTotpCode"), + ); + } finally { + setLoading(false); + } + } + + async function handleResetInitiate(e: React.FormEvent) { + e.preventDefault(); + if (!username.trim()) { + toast.error(t("errors.requiredField")); + return; + } + setLoading(true); + try { + await initiatePasswordReset(username.trim()); + setResetStep("code"); + toast.success(t("messages.resetCodeSent")); + } catch (err: unknown) { + const error = err as { + message?: string; + response?: { data?: { error?: string } }; + }; + toast.error( + error?.response?.data?.error || + error?.message || + t("errors.failedPasswordReset"), + ); + } finally { + setLoading(false); + } + } + + async function handleResetVerify(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + try { + const res = await verifyPasswordResetCode(username.trim(), resetCode); + setResetTempToken(res.tempToken as string); + setResetStep("newpass"); + toast.success(t("messages.codeVerified")); + } catch (err: unknown) { + const error = err as { + message?: string; + response?: { data?: { error?: string } }; + }; + toast.error( + error?.response?.data?.error || + error?.message || + t("errors.failedVerifyCode"), + ); + } finally { + setLoading(false); + } + } + + async function handleResetComplete(e: React.FormEvent) { + e.preventDefault(); + if (newPassword.length < 6) { + toast.error(t("errors.minLength", { min: 6 })); + return; + } + if (newPassword !== confirmNewPassword) { + toast.error(t("errors.passwordMismatch")); + return; + } + setLoading(true); + try { + await completePasswordReset(username.trim(), resetTempToken, newPassword); + toast.success(t("messages.passwordResetSuccess")); + switchView("login"); + } catch (err: unknown) { + const error = err as { + message?: string; + response?: { data?: { error?: string } }; + }; + toast.error( + error?.response?.data?.error || + error?.message || + t("errors.failedCompleteReset"), + ); + } finally { + setLoading(false); + } + } + + const handleOIDCLogin = useCallback(async () => { + setOidcLoading(true); + try { + if (isElectron()) { + const electronAPI = ( + window as unknown as { + electronAPI?: { + oidcSystemBrowserAuth?: ( + authUrl: string, + port: number, + ) => Promise<{ + success: boolean; + token?: string; + error?: string; + }>; + }; + } + ).electronAPI; + if (electronAPI?.oidcSystemBrowserAuth) { + const callbackPort = 17832 + Math.floor(Math.random() * 100); + const authResponse = await getOIDCAuthorizeUrl( + rememberMe, + callbackPort, + ); + const { auth_url: authUrl } = authResponse; + if (!authUrl) throw new Error(t("errors.invalidAuthUrl")); + const result = await electronAPI.oidcSystemBrowserAuth( + authUrl, + callbackPort, + ); + if (result.success && result.token) { + localStorage.setItem("jwt_token", result.token); + window.location.reload(); + return; + } + throw new Error(result.error || "Authentication failed"); + } + } + const authResponse = await getOIDCAuthorizeUrl(rememberMe); + const { auth_url: authUrl } = authResponse; + if (!authUrl || authUrl === "undefined") + throw new Error(t("errors.invalidAuthUrl")); + window.location.replace(authUrl); + } catch (err: unknown) { + const error = err as { + message?: string; + response?: { data?: { error?: string } }; + }; + toast.error( + error?.response?.data?.error || + error?.message || + t("errors.failedOidcLogin"), + ); + setOidcLoading(false); + } + }, [rememberMe, t]); + + useEffect(() => { + if (!oidcConfigLoaded || silentSigninHandledRef.current) return; + if (!shouldTriggerSilentSignin(window.location.search)) return; + + const nextSearch = removeSilentSigninFromSearch(window.location.search); + window.history.replaceState( + {}, + document.title, + `${window.location.pathname}${nextSearch}${window.location.hash}`, + ); + + silentSigninHandledRef.current = true; + if (oidcConfigured && !isElectron()) { + handleOIDCLogin(); + return; + } + + toast.info(t("errors.silentSigninOidcUnavailable")); + }, [handleOIDCLogin, oidcConfigLoaded, oidcConfigured, t]); + + // Electron server config / webview auth success screens + if (isElectron() && !isInElectronWebView()) { + if (showServerConfig === null) + return ( +
+
+
+ ); + if (showServerConfig) + return ( +
+
+ window.location.reload()} + onUseEmbedded={async () => { + await saveServerConfig({ + serverUrl: "", + lastUpdated: new Date().toISOString(), + }); + setShowServerConfig(false); + setCurrentServerUrl(""); + }} + onCancel={() => setShowServerConfig(false)} + isFirstTime={!currentServerUrl} + /> +
+
+ ); + if (!webviewAuthSuccess && showServerConfig === false && currentServerUrl) + return ( +
+
+ setShowServerConfig(true)} + /> +
+
+ ); + } + + if (webviewAuthSuccess || (isInElectronWebView() && webviewAuthSuccess)) + return ( +
+
+ +

{t("auth.redirectingToApp")}

+
+
+ ); + + if (dbConnectionFailed) + return ( +
+
+
+

+ {t("errors.databaseConnection")} +

+

+ {t("messages.databaseConnectionFailed")} +

+
+ +
+ + {t("common.language")} + + +
+ {isElectron() && currentServerUrl && ( +
+
+ + {t("serverConfig.serverUrl")} + + + {currentServerUrl} + +
+ +
+ )} +
+
+ ); + + if (dbHealthChecking && showServerConfig === false) + return ( +
+
+
+ ); + + const TAB_ITEMS: { id: AuthView; label: string; show: boolean }[] = [ + { + id: "login", + label: t("common.login"), + show: passwordLoginAllowed && !firstUser, + }, + { + id: "register", + label: t("common.register"), + show: (passwordLoginAllowed || firstUser) && registrationAllowed, + }, + { id: "external", label: t("auth.external"), show: oidcConfigured }, + ]; + + return ( +
+ {isElectron() && !isInElectronWebView() && showServerConfig === false && ( +
+ + + {t("serverConfig.localServer")} + +
+
+ )} +
+ {/* Left decorative panel */} +
+
+
+ + TERMIX + +
+ + {t("auth.tagline")} + +
+
+ + {/* Right panel */} +
+
+ {/* TOTP view */} + {view === "totp" && ( +
+
+

+ {t("auth.twoFactorAuth")} +

+

+ {t("auth.enterCode")} +

+
+
+ + + setTotpCode(e.target.value.replace(/\D/g, "")) + } + disabled={loading} + className="text-center text-2xl tracking-widest font-mono" + autoComplete="one-time-code" + /> + + + +
+
+ )} + + {/* Reset password view */} + {view === "reset" && ( +
+ +
+

+ {t("auth.forgotPassword")} +

+

+ {t("auth.resetCodeDesc")} +

+
+
+ {(["email", "code", "newpass"] as ResetStep[]).map( + (step, i) => { + const stepIdx = ["email", "code", "newpass"].indexOf( + resetStep, + ); + const done = i < stepIdx; + const active = i === stepIdx; + return ( +
+
+ {done ? : i + 1} +
+ {i < 2 && ( +
+ )} +
+ ); + }, + )} +
+ {resetStep === "email" && ( +
+ + setUsername(e.target.value)} + placeholder="your_username" + disabled={loading} + /> + + +
+ )} + {resetStep === "code" && ( +
+ + + setResetCode(e.target.value.replace(/\D/g, "")) + } + placeholder="000000" + maxLength={6} + className="text-center font-mono text-lg tracking-widest" + disabled={loading} + /> + + + +
+ )} + {resetStep === "newpass" && ( +
+ + + + + + + +
+ )} +
+ )} + + {/* Login / Register / External */} + {(view === "login" || + view === "register" || + view === "external") && ( +
+
+ {TAB_ITEMS.filter((item) => item.show).map((item) => ( + + ))} +
+ +
+

+ {view === "login" + ? t("auth.loginTitle") + : view === "register" + ? t("auth.registerTitle") + : t("auth.loginWithExternal")} +

+

+ {view === "login" + ? t("auth.loginSubtitle", "") + : view === "register" + ? t("auth.registerSubtitle", "") + : t("auth.loginWithExternalDesc")} +

+
+ + {view === "external" && ( +
+ {isElectron() ? ( +

+ {t("auth.externalNotSupportedInElectron")} +

+ ) : ( + <> +
+ setRememberMe(v === true)} + /> + +
+ + + )} +
+ )} + + {view === "login" && ( +
+ +
+ + setUsername(e.target.value)} + placeholder="username" + className="pl-8" + disabled={loading} + autoFocus + /> +
+
+ + + +
+
+ setRememberMe(v === true)} + disabled={loading} + /> + +
+ {passwordResetAllowed && ( + + )} +
+ +
+ )} + + {view === "register" && ( +
+ +
+ + setUsername(e.target.value)} + placeholder="choose_a_username" + className="pl-8" + disabled={loading} + autoFocus + /> +
+
+ + + + + + + +
+ )} + + +

+ {view === "login" && registrationAllowed ? ( + <> + {t("auth.noAccount", "Don't have an account?")}{" "} + + + ) : view === "register" && + passwordLoginAllowed && + !firstUser ? ( + <> + {t("auth.hasAccount", "Already have an account?")}{" "} + + + ) : null} +

+
+ + {t("common.language")} + + +
+
+ )} +
+
+
+
+ ); +} diff --git a/src/ui/desktop/authentication/ElectronLoginForm.tsx b/src/ui/auth/ElectronLoginForm.tsx similarity index 92% rename from src/ui/desktop/authentication/ElectronLoginForm.tsx rename to src/ui/auth/ElectronLoginForm.tsx index 81222ee9..affa510e 100644 --- a/src/ui/desktop/authentication/ElectronLoginForm.tsx +++ b/src/ui/auth/ElectronLoginForm.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback } from "react"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx"; +import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx"; import { useTranslation } from "react-i18next"; import { AlertCircle, Loader2, ArrowLeft, RefreshCw } from "lucide-react"; @@ -125,15 +125,15 @@ export function ElectronLoginForm({ const isEmbeddedServer = serverUrl.includes("localhost:30001"); return ( -
+
{isAuthenticating && ( -
+
)} {!isAuthenticating && ( -
+
- {!isEmbeddedServer && ( -
- - {displayUrl} - -
- )} - {isEmbeddedServer &&
} +
+ + {isEmbeddedServer ? t("serverConfig.localServer") : displayUrl} + +
+

+ {t("serverConfig.embeddedDesc")} +

+
+
+ + {t("common.or") || "OR"} + +
+
+ + )} + +
+
+ + handleUrlChange(e.target.value)} + disabled={loading || embeddedLoading} + /> +
+ + {error && ( + + {t("common.error")} + {error} + + )} + +
+ {onCancel && !isFirstTime && ( + + )} + +
+ +

+ {t("serverConfig.helpText")} +

+
+
+
+ ); +} diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/auth/LoginPage.tsx similarity index 97% rename from src/ui/desktop/authentication/Auth.tsx rename to src/ui/auth/LoginPage.tsx index aa7e5d08..0569958d 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/auth/LoginPage.tsx @@ -1,13 +1,13 @@ -import React, { useState, useEffect, useCallback } from "react"; -import { Button } from "@/components/ui/button.tsx"; -import { Input } from "@/components/ui/input.tsx"; -import { PasswordInput } from "@/components/ui/password-input.tsx"; -import { Label } from "@/components/ui/label.tsx"; -import { Checkbox } from "@/components/ui/checkbox.tsx"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs.tsx"; +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { Button } from "@/components/button.tsx"; +import { Input } from "@/components/input.tsx"; +import { PasswordInput } from "@/components/password-input.tsx"; +import { Label } from "@/components/label.tsx"; +import { Checkbox } from "@/components/checkbox.tsx"; +import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx"; +import { Tabs, TabsList, TabsTrigger } from "@/components/tabs.tsx"; import { useTranslation } from "react-i18next"; -import { LanguageSwitcher } from "@/ui/desktop/user/LanguageSwitcher.tsx"; +import { LanguageSwitcher } from "@/user/LanguageSwitcher.tsx"; import { toast } from "sonner"; import { Sun, Moon, Monitor } from "lucide-react"; import { useTheme } from "@/components/theme-provider"; @@ -28,9 +28,13 @@ import { saveServerConfig, isElectron, getEmbeddedServerStatus, -} from "../../main-axios.ts"; -import { ElectronServerConfig as ServerConfigComponent } from "@/ui/desktop/authentication/ElectronServerConfig.tsx"; -import { ElectronLoginForm } from "@/ui/desktop/authentication/ElectronLoginForm.tsx"; +} from "@/main-axios"; +import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig.tsx"; +import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx"; +import { + removeSilentSigninFromSearch, + shouldTriggerSilentSignin, +} from "./silent-signin"; function isMissingServerConfigError(error: unknown): boolean { if (!(error instanceof Error)) { @@ -123,6 +127,8 @@ export function Auth({ const [registrationAllowed, setRegistrationAllowed] = useState(true); const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true); const [oidcConfigured, setOidcConfigured] = useState(false); + const [oidcConfigLoaded, setOidcConfigLoaded] = useState(false); + const silentSigninHandledRef = useRef(false); const [resetStep, setResetStep] = useState< "initiate" | "verify" | "newPassword" @@ -248,6 +254,9 @@ export function Auth({ } else { setOidcConfigured(false); } + }) + .finally(() => { + setOidcConfigLoaded(true); }); }, []); @@ -625,7 +634,7 @@ export function Auth({ } } - async function handleOIDCLogin() { + const handleOIDCLogin = useCallback(async () => { setOidcLoading(true); try { const authResponse = await getOIDCAuthorizeUrl(rememberMe); @@ -648,7 +657,27 @@ export function Auth({ toast.error(errorMessage); setOidcLoading(false); } - } + }, [rememberMe, t]); + + useEffect(() => { + if (!oidcConfigLoaded || silentSigninHandledRef.current) return; + if (!shouldTriggerSilentSignin(window.location.search)) return; + + const nextSearch = removeSilentSigninFromSearch(window.location.search); + window.history.replaceState( + {}, + document.title, + `${window.location.pathname}${nextSearch}${window.location.hash}`, + ); + + silentSigninHandledRef.current = true; + if (oidcConfigured && !isElectron()) { + handleOIDCLogin(); + return; + } + + toast.info(t("errors.silentSigninOidcUnavailable")); + }, [handleOIDCLogin, oidcConfigLoaded, oidcConfigured, t]); useEffect(() => { const urlParams = new URLSearchParams(window.location.search); diff --git a/src/ui/auth/silent-signin.ts b/src/ui/auth/silent-signin.ts new file mode 100644 index 00000000..60a1e754 --- /dev/null +++ b/src/ui/auth/silent-signin.ts @@ -0,0 +1,18 @@ +export function shouldTriggerSilentSignin(search: string) { + const params = new URLSearchParams(search); + for (const [key, rawValue] of params) { + if (key.toLowerCase() !== "silentsignin") continue; + const value = rawValue; + return value === "" || value === "true" || value === "1"; + } + return false; +} + +export function removeSilentSigninFromSearch(search: string) { + const params = new URLSearchParams(search); + for (const key of Array.from(params.keys())) { + if (key.toLowerCase() === "silentsignin") params.delete(key); + } + const nextSearch = params.toString(); + return nextSearch ? `?${nextSearch}` : ""; +} diff --git a/src/components/ui/accordion.tsx b/src/ui/components/accordion.tsx similarity index 100% rename from src/components/ui/accordion.tsx rename to src/ui/components/accordion.tsx diff --git a/src/components/ui/alert-dialog.tsx b/src/ui/components/alert-dialog.tsx similarity index 98% rename from src/components/ui/alert-dialog.tsx rename to src/ui/components/alert-dialog.tsx index 1c376739..dc04cf85 100644 --- a/src/components/ui/alert-dialog.tsx +++ b/src/ui/components/alert-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; import { cn } from "@/lib/utils"; -import { buttonVariants } from "@/components/ui/button"; +import { buttonVariants } from "@/components/button"; function AlertDialog({ ...props diff --git a/src/components/ui/alert.tsx b/src/ui/components/alert.tsx similarity index 100% rename from src/components/ui/alert.tsx rename to src/ui/components/alert.tsx diff --git a/src/components/ui/badge.tsx b/src/ui/components/badge.tsx similarity index 100% rename from src/components/ui/badge.tsx rename to src/ui/components/badge.tsx diff --git a/src/components/ui/button-group.tsx b/src/ui/components/button-group.tsx similarity index 90% rename from src/components/ui/button-group.tsx rename to src/ui/components/button-group.tsx index 46f8cda3..e742659d 100644 --- a/src/components/ui/button-group.tsx +++ b/src/ui/components/button-group.tsx @@ -1,6 +1,11 @@ -import { Children, ReactElement, cloneElement, isValidElement } from "react"; +import { + Children, + type ReactElement, + cloneElement, + isValidElement, +} from "react"; -import { type ButtonProps } from "@/components/ui/button"; +import { type ButtonProps } from "@/components/button"; import { cn } from "@/lib/utils"; interface ButtonGroupProps { diff --git a/src/ui/components/button.tsx b/src/ui/components/button.tsx new file mode 100644 index 00000000..dd09238a --- /dev/null +++ b/src/ui/components/button.tsx @@ -0,0 +1,68 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { Slot } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-none px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-none px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": "size-6 rounded-none [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-7 rounded-none", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot.Root : "button"; + + return ( + + ); +} + +export type ButtonProps = React.ComponentProps<"button"> & + VariantProps & { asChild?: boolean }; + +export { Button, buttonVariants }; diff --git a/src/components/ui/card.tsx b/src/ui/components/card.tsx similarity index 54% rename from src/components/ui/card.tsx rename to src/ui/components/card.tsx index 5b8522b8..2d30a89a 100644 --- a/src/components/ui/card.tsx +++ b/src/ui/components/card.tsx @@ -2,12 +2,17 @@ import * as React from "react"; import { cn } from "@/lib/utils"; -function Card({ className, ...props }: React.ComponentProps<"div">) { +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { return (
img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none", className, )} {...props} @@ -20,7 +25,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
) { return (
); @@ -42,7 +50,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) { return (
); @@ -65,7 +73,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) { return (
); @@ -75,7 +83,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) { return (
); diff --git a/src/components/ui/checkbox.tsx b/src/ui/components/checkbox.tsx similarity index 100% rename from src/components/ui/checkbox.tsx rename to src/ui/components/checkbox.tsx diff --git a/src/ui/components/command.tsx b/src/ui/components/command.tsx new file mode 100644 index 00000000..a302540c --- /dev/null +++ b/src/ui/components/command.tsx @@ -0,0 +1,130 @@ +import * as React from "react"; +import { Search } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const Command = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Command.displayName = "Command"; + +const CommandInput = React.forwardRef< + HTMLInputElement, + React.InputHTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +
+)); +CommandInput.displayName = "CommandInput"; + +const CommandList = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CommandList.displayName = "CommandList"; + +const CommandGroup = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & { heading?: string } +>(({ className, heading, children, ...props }, ref) => ( +
+ {heading &&
{heading}
} + {children} +
+)); +CommandGroup.displayName = "CommandGroup"; + +const CommandSeparator = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CommandSeparator.displayName = "CommandSeparator"; + +const CommandItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & { onSelect?: () => void } +>(({ className, onSelect, ...props }, ref) => ( +
+)); +CommandItem.displayName = "CommandItem"; + +const CommandEmpty = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CommandEmpty.displayName = "CommandEmpty"; + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => ( + +); +CommandShortcut.displayName = "CommandShortcut"; + +export { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandSeparator, + CommandEmpty, + CommandShortcut, +}; diff --git a/src/components/ui/dialog.tsx b/src/ui/components/dialog.tsx similarity index 60% rename from src/components/ui/dialog.tsx rename to src/ui/components/dialog.tsx index 64249170..4493c20f 100644 --- a/src/components/ui/dialog.tsx +++ b/src/ui/components/dialog.tsx @@ -1,8 +1,9 @@ import * as React from "react"; -import * as DialogPrimitive from "@radix-ui/react-dialog"; -import { XIcon } from "lucide-react"; +import { Dialog as DialogPrimitive } from "radix-ui"; import { cn } from "@/lib/utils"; +import { Button } from "@/components/button"; +import { XIcon } from "lucide-react"; function Dialog({ ...props @@ -36,7 +37,7 @@ function DialogOverlay({ + {children} {showCloseButton && ( - - - Close + + )} @@ -82,13 +86,20 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return (
); } -function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean; +}) { return (
) { className, )} {...props} - /> + > + {children} + {showCloseButton && ( + + + + )} +
); } @@ -108,7 +126,7 @@ function DialogTitle({ return ( ); @@ -121,7 +139,10 @@ function DialogDescription({ return ( ); diff --git a/src/ui/components/dropdown-menu.tsx b/src/ui/components/dropdown-menu.tsx new file mode 100644 index 00000000..c03b7987 --- /dev/null +++ b/src/ui/components/dropdown-menu.tsx @@ -0,0 +1,271 @@ +import * as React from "react"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; +import { CheckIcon, ChevronRightIcon } from "lucide-react"; + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuContent({ + className, + align = "start", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/src/components/ui/folder.tsx b/src/ui/components/folder.tsx similarity index 100% rename from src/components/ui/folder.tsx rename to src/ui/components/folder.tsx diff --git a/src/components/ui/form.tsx b/src/ui/components/form.tsx similarity index 98% rename from src/components/ui/form.tsx rename to src/ui/components/form.tsx index 50ee37c3..ca28b235 100644 --- a/src/components/ui/form.tsx +++ b/src/ui/components/form.tsx @@ -13,7 +13,7 @@ import { } from "react-hook-form"; import { cn } from "@/lib/utils"; -import { Label } from "@/components/ui/label"; +import { Label } from "@/components/label"; const Form = FormProvider; diff --git a/src/ui/components/input.tsx b/src/ui/components/input.tsx new file mode 100644 index 00000000..53ec593c --- /dev/null +++ b/src/ui/components/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/src/ui/components/kbd.tsx b/src/ui/components/kbd.tsx new file mode 100644 index 00000000..c1cb3361 --- /dev/null +++ b/src/ui/components/kbd.tsx @@ -0,0 +1,40 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +export type KbdProps = React.HTMLAttributes; + +const Kbd = React.forwardRef( + ({ className, ...props }, ref) => { + return ( + + ); + }, +); +Kbd.displayName = "Kbd"; + +const KbdKey = ({ + className, + ...props +}: React.HTMLAttributes) => ( + +); +KbdKey.displayName = "KbdKey"; + +const KbdSeparator = ({ + className, + ...props +}: React.HTMLAttributes) => ( + + + + +); +KbdSeparator.displayName = "KbdSeparator"; + +export { Kbd, KbdKey, KbdSeparator }; diff --git a/src/components/ui/label.tsx b/src/ui/components/label.tsx similarity index 100% rename from src/components/ui/label.tsx rename to src/ui/components/label.tsx diff --git a/src/components/ui/password-input.tsx b/src/ui/components/password-input.tsx similarity index 96% rename from src/components/ui/password-input.tsx rename to src/ui/components/password-input.tsx index 5eac52b5..d8a57ff0 100644 --- a/src/components/ui/password-input.tsx +++ b/src/ui/components/password-input.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { Eye, EyeOff } from "lucide-react"; -import { Input } from "@/components/ui/input"; +import { Input } from "@/components/input"; import { cn } from "@/lib/utils"; type PasswordInputProps = React.InputHTMLAttributes; diff --git a/src/components/ui/popover.tsx b/src/ui/components/popover.tsx similarity index 100% rename from src/components/ui/popover.tsx rename to src/ui/components/popover.tsx diff --git a/src/components/ui/scroll-area.tsx b/src/ui/components/scroll-area.tsx similarity index 100% rename from src/components/ui/scroll-area.tsx rename to src/ui/components/scroll-area.tsx diff --git a/src/ui/components/section-card.tsx b/src/ui/components/section-card.tsx new file mode 100644 index 00000000..61a1a020 --- /dev/null +++ b/src/ui/components/section-card.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +import type React from "react"; + +export function SectionCard({ + title, + icon, + action, + children, +}: { + title: string; + icon: React.ReactNode; + action?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+ {icon} + + {title} + + {action &&
{action}
} +
+
{children}
+
+ ); +} + +export function SettingRow({ + label, + badge, + description, + children, +}: { + label: string; + badge?: string; + description?: string; + children: React.ReactNode; +}) { + return ( +
+
+
+ {label} + {badge && ( + + {badge} + + )} +
+ {description && ( + {description} + )} +
+
{children}
+
+ ); +} + +export function FakeSwitch({ + defaultChecked = false, + checked, + onChange, +}: { + defaultChecked?: boolean; + checked?: boolean; + onChange?: (v: boolean) => void; +}) { + const [internalOn, setInternalOn] = useState(defaultChecked); + const on = checked !== undefined ? checked : internalOn; + return ( + + ); +} diff --git a/src/components/ui/select.tsx b/src/ui/components/select.tsx similarity index 100% rename from src/components/ui/select.tsx rename to src/ui/components/select.tsx diff --git a/src/components/ui/separator.tsx b/src/ui/components/separator.tsx similarity index 64% rename from src/components/ui/separator.tsx rename to src/ui/components/separator.tsx index 72c18e33..d7fc9faa 100644 --- a/src/components/ui/separator.tsx +++ b/src/ui/components/separator.tsx @@ -1,7 +1,5 @@ -"use client"; - import * as React from "react"; -import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import { Separator as SeparatorPrimitive } from "radix-ui"; import { cn } from "@/lib/utils"; @@ -17,7 +15,7 @@ function Separator({ decorative={decorative} orientation={orientation} className={cn( - "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", + "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch", className, )} {...props} diff --git a/src/components/ui/shadcn-io/status/index.tsx b/src/ui/components/shadcn-io/status/index.tsx similarity index 97% rename from src/components/ui/shadcn-io/status/index.tsx rename to src/ui/components/shadcn-io/status/index.tsx index 5131e84c..0ee2b367 100644 --- a/src/components/ui/shadcn-io/status/index.tsx +++ b/src/ui/components/shadcn-io/status/index.tsx @@ -1,5 +1,5 @@ import type { ComponentProps, HTMLAttributes } from "react"; -import { Badge } from "@/components/ui/badge"; +import { Badge } from "@/components/badge"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; diff --git a/src/components/ui/sheet.tsx b/src/ui/components/sheet.tsx similarity index 52% rename from src/components/ui/sheet.tsx rename to src/ui/components/sheet.tsx index f449ce91..c4168fe8 100644 --- a/src/components/ui/sheet.tsx +++ b/src/ui/components/sheet.tsx @@ -1,8 +1,9 @@ import * as React from "react"; -import * as SheetPrimitive from "@radix-ui/react-dialog"; -import { XIcon } from "lucide-react"; +import { Dialog as SheetPrimitive } from "radix-ui"; import { cn } from "@/lib/utils"; +import { Button } from "@/components/button"; +import { XIcon } from "lucide-react"; function Sheet({ ...props }: React.ComponentProps) { return ; @@ -34,7 +35,7 @@ function SheetOverlay({ & { side?: "top" | "right" | "bottom" | "left"; + showCloseButton?: boolean; }) { return ( {children} - - - Close - + {showCloseButton && ( + + + + )} ); @@ -83,7 +87,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { return (
); @@ -106,7 +110,10 @@ function SheetTitle({ return ( ); @@ -119,7 +126,7 @@ function SheetDescription({ return ( ); diff --git a/src/components/ui/sidebar.tsx b/src/ui/components/sidebar.tsx similarity index 63% rename from src/components/ui/sidebar.tsx rename to src/ui/components/sidebar.tsx index 8413b712..d2deb02f 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/ui/components/sidebar.tsx @@ -1,25 +1,29 @@ -/* eslint-disable react-refresh/only-export-components */ +"use client"; + import * as React from "react"; -import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; -import { PanelLeftIcon } from "lucide-react"; +import { Slot } from "radix-ui"; import { useIsMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Separator } from "@/components/ui/separator"; -import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/button"; +import { Input } from "@/components/input"; +import { Separator } from "@/components/separator"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/sheet"; +import { Skeleton } from "@/components/skeleton"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/tooltip"; +import { PanelLeftIcon } from "lucide-react"; const SIDEBAR_COOKIE_NAME = "sidebar_state"; const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; const SIDEBAR_WIDTH = "16rem"; +const SIDEBAR_WIDTH_MOBILE = "18rem"; const SIDEBAR_WIDTH_ICON = "3rem"; const SIDEBAR_KEYBOARD_SHORTCUT = "b"; @@ -119,25 +123,23 @@ function SidebarProvider({ return ( - -
- {children} -
-
+
+ {children} +
); } @@ -148,20 +150,21 @@ function Sidebar({ collapsible = "offcanvas", className, children, + dir, ...props }: React.ComponentProps<"div"> & { side?: "left" | "right"; variant?: "sidebar" | "floating" | "inset"; collapsible?: "offcanvas" | "icon" | "none"; }) { - const { state } = useSidebar(); + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); if (collapsible === "none") { return (
- // - // - // Sidebar - // Displays the mobile sidebar. - // - //
{children}
- //
- // - // ) - // } + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ); + } return (
+ {/* This is what handles the sidebar gap on desktop */}
@@ -256,8 +258,8 @@ function SidebarTrigger({ data-sidebar="trigger" data-slot="sidebar-trigger" variant="ghost" - size="icon" - className={cn("size-7", className)} + size="icon-sm" + className={cn(className)} onClick={(event) => { onClick?.(event); toggleSidebar(); @@ -282,10 +284,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { onClick={toggleSidebar} title="Toggle Sidebar" className={cn( - "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex", + "absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2", "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", - "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full", + "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar", "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", className, @@ -300,8 +302,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
); @@ -353,7 +354,7 @@ function SidebarSeparator({ ); @@ -365,7 +366,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { data-slot="sidebar-content" data-sidebar="content" className={cn( - "flex min-h-0 flex-1 flex-col gap-2 overflow-auto thin-scrollbar group-data-[collapsible=icon]:overflow-hidden", + "no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden", className, )} {...props} @@ -389,15 +390,14 @@ function SidebarGroupLabel({ asChild = false, ...props }: React.ComponentProps<"div"> & { asChild?: boolean }) { - const Comp = asChild ? Slot : "div"; + const Comp = asChild ? Slot.Root : "div"; return ( svg]:size-4 [&>svg]:shrink-0", - "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", + "flex h-8 shrink-0 items-center rounded-none px-2 text-xs text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", className, )} {...props} @@ -410,17 +410,14 @@ function SidebarGroupAction({ asChild = false, ...props }: React.ComponentProps<"button"> & { asChild?: boolean }) { - const Comp = asChild ? Slot : "button"; + const Comp = asChild ? Slot.Root : "button"; return ( svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 md:after:hidden", - "group-data-[collapsible=icon]:hidden", + "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-none p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0", className, )} {...props} @@ -436,7 +433,7 @@ function SidebarGroupContent({
); @@ -447,7 +444,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
    ); @@ -465,7 +462,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { } const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", + "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-none p-2 text-left text-xs ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate", { variants: { variant: { @@ -474,9 +471,9 @@ const sidebarMenuButtonVariants = cva( "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", }, size: { - default: "h-8 text-sm", + default: "h-8 text-xs", sm: "h-7 text-xs", - lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", + lg: "h-12 text-xs group-data-[collapsible=icon]:p-0!", }, }, defaultVariants: { @@ -499,7 +496,7 @@ function SidebarMenuButton({ isActive?: boolean; tooltip?: string | React.ComponentProps; } & VariantProps) { - const Comp = asChild ? Slot : "button"; + const Comp = asChild ? Slot.Root : "button"; const { isMobile, state } = useSidebar(); const button = ( @@ -545,22 +542,16 @@ function SidebarMenuAction({ asChild?: boolean; showOnHover?: boolean; }) { - const Comp = asChild ? Slot : "button"; + const Comp = asChild ? Slot.Root : "button"; return ( svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 md:after:hidden", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", + "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-none p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0", showOnHover && - "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0", + "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0", className, )} {...props} @@ -577,12 +568,7 @@ function SidebarMenuBadge({ data-slot="sidebar-menu-badge" data-sidebar="menu-badge" className={cn( - "text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none", - "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", + "pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-none px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground", className, )} {...props} @@ -606,12 +592,12 @@ function SidebarMenuSkeleton({
    {showIcon && ( )} @@ -634,8 +620,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { data-slot="sidebar-menu-sub" data-sidebar="menu-sub" className={cn( - "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5", - "group-data-[collapsible=icon]:hidden", + "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden", className, )} {...props} @@ -668,7 +653,7 @@ function SidebarMenuSubButton({ size?: "sm" | "md"; isActive?: boolean; }) { - const Comp = asChild ? Slot : "a"; + const Comp = asChild ? Slot.Root : "a"; return ( svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", - "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", - size === "sm" && "text-xs", - size === "md" && "text-sm", - "group-data-[collapsible=icon]:hidden", + "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-none px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-xs data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", className, )} {...props} diff --git a/src/components/ui/skeleton.tsx b/src/ui/components/skeleton.tsx similarity index 74% rename from src/components/ui/skeleton.tsx rename to src/ui/components/skeleton.tsx index 01689981..ac4620a7 100644 --- a/src/components/ui/skeleton.tsx +++ b/src/ui/components/skeleton.tsx @@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return (
    ); diff --git a/src/components/ui/slider.tsx b/src/ui/components/slider.tsx similarity index 74% rename from src/components/ui/slider.tsx rename to src/ui/components/slider.tsx index 231c5a81..794a3f93 100644 --- a/src/components/ui/slider.tsx +++ b/src/ui/components/slider.tsx @@ -37,21 +37,23 @@ function Slider({ {Array.from({ length: _values.length }, (_, index) => ( ))} diff --git a/src/ui/components/sonner.tsx b/src/ui/components/sonner.tsx new file mode 100644 index 00000000..900e3bfe --- /dev/null +++ b/src/ui/components/sonner.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useTheme } from "@/components/theme-provider"; +import { Toaster as Sonner } from "sonner"; + +type ToasterProps = React.ComponentProps; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + + ); +}; + +export { Toaster }; diff --git a/src/components/ui/switch.tsx b/src/ui/components/switch.tsx similarity index 100% rename from src/components/ui/switch.tsx rename to src/ui/components/switch.tsx diff --git a/src/components/ui/table.tsx b/src/ui/components/table.tsx similarity index 100% rename from src/components/ui/table.tsx rename to src/ui/components/table.tsx diff --git a/src/components/ui/tabs.tsx b/src/ui/components/tabs.tsx similarity index 100% rename from src/components/ui/tabs.tsx rename to src/ui/components/tabs.tsx diff --git a/src/components/ui/textarea.tsx b/src/ui/components/textarea.tsx similarity index 95% rename from src/components/ui/textarea.tsx rename to src/ui/components/textarea.tsx index a5c84926..2f68150e 100644 --- a/src/components/ui/textarea.tsx +++ b/src/ui/components/textarea.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import { cn } from "../../lib/utils"; +import { cn } from "@/lib/utils"; export type TextareaProps = React.TextareaHTMLAttributes; diff --git a/src/components/theme-provider.tsx b/src/ui/components/theme-provider.tsx similarity index 53% rename from src/components/theme-provider.tsx rename to src/ui/components/theme-provider.tsx index ba8b8e24..efb4bd78 100644 --- a/src/components/theme-provider.tsx +++ b/src/ui/components/theme-provider.tsx @@ -1,29 +1,32 @@ -/* eslint-disable react-refresh/only-export-components */ import { createContext, useContext, useEffect, useState } from "react"; - -type Theme = - | "dark" - | "light" - | "system" - | "dracula" - | "gentlemansChoice" - | "midnightEspresso" - | "catppuccinMocha"; +import type { ThemeId } from "@/types/ui-types"; type ThemeProviderProps = { children: React.ReactNode; - defaultTheme?: Theme; + defaultTheme?: ThemeId; storageKey?: string; }; type ThemeProviderState = { - theme: Theme; - setTheme: (theme: Theme) => void; - setThemePreview: (theme: Theme | null) => void; + theme: ThemeId; + setTheme: (theme: ThemeId) => void; + setThemePreview: (theme: ThemeId | null) => void; }; +const ALL_THEME_CLASSES = [ + "light", + "dark", + "dracula", + "catppuccin", + "nord", + "solarized", + "tokyo-night", + "one-dark", + "gruvbox", +]; + const initialState: ThemeProviderState = { - theme: "system", + theme: "dark", setTheme: () => null, setThemePreview: () => null, }; @@ -32,60 +35,41 @@ const ThemeProviderContext = createContext(initialState); export function ThemeProvider({ children, - defaultTheme = "system", + defaultTheme = "dark", storageKey = "vite-ui-theme", ...props }: ThemeProviderProps) { - const [theme, setTheme] = useState( - () => (localStorage.getItem(storageKey) as Theme) || defaultTheme, + const [theme, setTheme] = useState( + () => (localStorage.getItem(storageKey) as ThemeId) || defaultTheme, ); - const [previewTheme, setPreviewTheme] = useState(null); + const [previewTheme, setPreviewTheme] = useState(null); + + const activeTheme = previewTheme ?? theme; useEffect(() => { const root = window.document.documentElement; - - root.classList.remove( - "light", - "dark", - "dracula", - "gentlemansChoice", - "midnightEspresso", - "catppuccinMocha", - ); - - const activeTheme = previewTheme || theme; + root.classList.remove(...ALL_THEME_CLASSES); if (activeTheme === "system") { const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") .matches ? "dark" : "light"; - root.classList.add(systemTheme); return; } root.classList.add(activeTheme); - - const darkCustomThemes: Theme[] = [ - "dracula", - "gentlemansChoice", - "midnightEspresso", - "catppuccinMocha", - ]; - if (darkCustomThemes.includes(activeTheme)) { - root.classList.add("dark"); - } - }, [theme, previewTheme]); + }, [activeTheme]); const value = { theme, - setTheme: (theme: Theme) => { + setTheme: (theme: ThemeId) => { localStorage.setItem(storageKey, theme); setTheme(theme); }, - setThemePreview: (theme: Theme | null) => { - setPreviewTheme(theme); + setThemePreview: (preview: ThemeId | null) => { + setPreviewTheme(preview); }, }; @@ -98,9 +82,7 @@ export function ThemeProvider({ export const useTheme = () => { const context = useContext(ThemeProviderContext); - if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider"); - return context; }; diff --git a/src/ui/components/tooltip.tsx b/src/ui/components/tooltip.tsx new file mode 100644 index 00000000..71ebfff5 --- /dev/null +++ b/src/ui/components/tooltip.tsx @@ -0,0 +1,57 @@ +"use client"; + +import * as React from "react"; +import { Tooltip as TooltipPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return ; +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ); +} + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }; diff --git a/src/components/ui/version-alert.tsx b/src/ui/components/version-alert.tsx similarity index 98% rename from src/components/ui/version-alert.tsx rename to src/ui/components/version-alert.tsx index 4493e749..b4df3c32 100644 --- a/src/components/ui/version-alert.tsx +++ b/src/ui/components/version-alert.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx"; -import { Button } from "@/components/ui/button.tsx"; +import { Alert, AlertTitle, AlertDescription } from "@/components/alert.tsx"; +import { Button } from "@/components/button.tsx"; import { ExternalLink, Download, AlertTriangle, Info } from "lucide-react"; import { useTranslation } from "react-i18next"; diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/dashboard/Dashboard.tsx similarity index 72% rename from src/ui/desktop/apps/dashboard/Dashboard.tsx rename to src/ui/dashboard/Dashboard.tsx index 3727e5eb..bf3d6ec8 100644 --- a/src/ui/desktop/apps/dashboard/Dashboard.tsx +++ b/src/ui/dashboard/Dashboard.tsx @@ -1,7 +1,7 @@ -import React, { useEffect, useState } from "react"; -import { Auth } from "@/ui/desktop/authentication/Auth.tsx"; -import { AlertManager } from "@/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx"; -import { Button } from "@/components/ui/button.tsx"; +import React, { useEffect, useState, useRef, useCallback } from "react"; +import { Auth } from "@/auth/LoginPage.tsx"; +import { AlertManager } from "@/dashboard/panels/alerts/AlertManager.tsx"; +import { Button } from "@/components/button.tsx"; import { getUserInfo, getDatabaseHealth, @@ -19,21 +19,21 @@ import { getGuacamoleTokenFromHost, isCurrentAuthInvalidationError, type RecentActivityItem, -} from "@/ui/main-axios.ts"; -import { useSidebar } from "@/components/ui/sidebar.tsx"; -import { Separator } from "@/components/ui/separator.tsx"; -import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; -import { Kbd } from "@/components/ui/kbd"; +} from "@/main-axios.ts"; +import { useSidebar } from "@/components/sidebar.tsx"; +import { Separator } from "@/components/separator.tsx"; +import { useTabs } from "@/shell/TabContext.tsx"; +import { Kbd } from "@/components/kbd"; import { useTranslation } from "react-i18next"; import { Settings as SettingsIcon } from "lucide-react"; -import { ServerOverviewCard } from "@/ui/desktop/apps/dashboard/cards/ServerOverviewCard"; -import { RecentActivityCard } from "@/ui/desktop/apps/dashboard/cards/RecentActivityCard"; -import { QuickActionsCard } from "@/ui/desktop/apps/dashboard/cards/QuickActionsCard"; -import { ServerStatsCard } from "@/ui/desktop/apps/dashboard/cards/ServerStatsCard"; -import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard"; -import { useDashboardPreferences } from "@/ui/desktop/apps/dashboard/hooks/useDashboardPreferences"; -import { DashboardSettingsDialog } from "@/ui/desktop/apps/dashboard/components/DashboardSettingsDialog"; -import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader"; +import { ServerOverviewCard } from "@/dashboard/cards/ServerOverviewCard"; +import { RecentActivityCard } from "@/dashboard/cards/RecentActivityCard"; +import { QuickActionsCard } from "@/dashboard/cards/QuickActionsCard"; +import { ServerStatsCard } from "@/dashboard/cards/ServerStatsCard"; +import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard"; +import { useDashboardPreferences } from "@/dashboard/hooks/useDashboardPreferences"; +import { DashboardSettingsDialog } from "@/dashboard/components/DashboardSettingsDialog"; +import { SimpleLoader } from "@/lib/SimpleLoader"; interface DashboardProps { onSelectView: (view: string) => void; @@ -101,6 +101,59 @@ export function Dashboard({ resetLayout, } = useDashboardPreferences(loggedIn); + const containerRef = useRef(null); + const mainWidthPct = layout?.mainWidthPct ?? 68; + + const handleDividerMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + const startX = e.clientX; + const startPct = mainWidthPct; + const onMove = (ev: MouseEvent) => { + if (!containerRef.current) return; + const totalW = containerRef.current.getBoundingClientRect().width; + const delta = ev.clientX - startX; + const newPct = Math.min( + 85, + Math.max(30, startPct + (delta / totalW) * 100), + ); + if (layout) updateLayout({ ...layout, mainWidthPct: newPct }); + }; + const onUp = () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }, + [mainWidthPct, layout, updateLayout], + ); + + const handleCardHeightMouseDown = useCallback( + (e: React.MouseEvent, cardId: string, currentHeight: number) => { + e.preventDefault(); + const startY = e.clientY; + const startH = currentHeight; + const onMove = (ev: MouseEvent) => { + const newH = Math.max(180, startH + (ev.clientY - startY)); + if (!layout) return; + updateLayout({ + ...layout, + cards: layout.cards.map((c) => + c.id === cardId ? { ...c, height: Math.round(newH) } : c, + ), + }); + }; + const onUp = () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }, + [layout, updateLayout], + ); + let sidebarState: "expanded" | "collapsed" = "expanded"; try { const sidebar = useSidebar(); @@ -357,7 +410,7 @@ export function Dashboard({ name: string; cpu: number | null; ram: number | null; - } => server !== null && server.cpu !== null && server.ram !== null, + } => server !== null, ); setServerStats(validServerStats); setServerStatsLoading(false); @@ -683,79 +736,135 @@ export function Dashboard({ -
    - {!preferencesLoading && layout && ( -
    - {layout.cards +
    + {!preferencesLoading && + layout && + (() => { + const enabledCards = layout.cards .filter((card) => card.enabled) - .sort((a, b) => a.order - b.order) - .map((card) => { - if (card.id === "server_overview") { - return ( - - ); - } else if (card.id === "recent_activity") { - return ( - - ); - } else if (card.id === "network_graph") { - return ( - - ); - } else if (card.id === "quick_actions") { - return ( - - ); - } else if (card.id === "server_stats") { - return ( - - ); - } - return null; - })} -
    - )} + .sort((a, b) => a.order - b.order); + + const mainCards = enabledCards.filter( + (c) => (c.panel ?? "main") === "main", + ); + const sideCards = enabledCards.filter( + (c) => c.panel === "side", + ); + + const renderCard = (card: (typeof enabledCards)[0]) => { + if (card.id === "server_overview") { + return ( + + ); + } else if (card.id === "recent_activity") { + return ( + + ); + } else if (card.id === "network_graph") { + return ( + + ); + } else if (card.id === "quick_actions") { + return ( + + ); + } else if (card.id === "server_stats") { + return ( + + ); + } + return null; + }; + + const renderCardWithHandle = ( + card: (typeof enabledCards)[0], + ) => { + const currentHeight = card.height ?? 280; + return ( +
    +
    {renderCard(card)}
    +
    + handleCardHeightMouseDown(e, card.id, currentHeight) + } + > +
    +
    +
    + ); + }; + + return ( + <> +
    + {mainCards.map((card) => renderCardWithHandle(card))} +
    + +
    +
    +
    + +
    + {sideCards.map((card) => renderCardWithHandle(card))} +
    + + ); + })()}
    diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx new file mode 100644 index 00000000..17aea37e --- /dev/null +++ b/src/ui/dashboard/DashboardTab.tsx @@ -0,0 +1,1607 @@ +import { useState, useRef, useCallback, useEffect } from "react"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { Button } from "@/components/button"; +import { Card } from "@/components/card"; +import { Separator } from "@/components/separator"; +import { + Activity, + Database, + GripHorizontal, + GripVertical, + KeyRound, + LayoutDashboard, + MessagesSquare, + Network, + Plus, + Server, + Settings, + Terminal, + Trash2, + User, + Zap, +} from "lucide-react"; +import { Kbd } from "@/components/kbd"; +import { DASHBOARD_CARDS } from "@/lib/theme"; +import type { DashboardCardId, TabType, Host } from "@/types/ui-types"; +import { + getSSHHosts, + getUptime, + getVersionInfo, + getDatabaseHealth, + getRecentActivity, + getTunnelStatuses, + getCredentials, + resetRecentActivity, + getAllServerStatuses, + getServerMetricsById, + registerMetricsViewer, + sendMetricsHeartbeat, + getUserInfo, +} from "@/main-axios"; +import type { RecentActivityItem, SSHHostWithStatus } from "@/main-axios"; +import { useTranslation } from "react-i18next"; +import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard"; + +function sshHostToHost(h: SSHHostWithStatus): Host { + return { + id: String(h.id), + name: h.name, + username: h.username, + ip: h.ip, + port: h.port, + folder: h.folder ?? "", + online: h.status === "online", + cpu: 0, + ram: 0, + lastAccess: "", + tags: h.tags ?? [], + authType: h.authType, + password: h.password, + key: typeof h.key === "string" ? h.key : undefined, + keyPassword: h.keyPassword, + keyType: h.keyType, + credentialId: h.credentialId != null ? String(h.credentialId) : undefined, + notes: h.notes, + pin: h.pin ?? false, + macAddress: h.macAddress, + enableTerminal: h.enableTerminal ?? true, + enableTunnel: h.enableTunnel ?? false, + enableFileManager: h.enableFileManager ?? false, + enableDocker: h.enableDocker ?? false, + enableSsh: h.connectionType === "ssh" || !h.connectionType, + enableRdp: h.connectionType === "rdp", + enableVnc: h.connectionType === "vnc", + enableTelnet: h.connectionType === "telnet", + sshPort: h.port, + rdpPort: 3389, + vncPort: 5900, + telnetPort: 23, + quickActions: (h.quickActions ?? []).map((a) => ({ + name: a.name, + snippetId: String(a.snippetId), + })), + serverTunnels: [], + defaultPath: h.defaultPath, + terminalConfig: h.terminalConfig as Host["terminalConfig"], + useSocks5: h.useSocks5, + socks5Host: h.socks5Host, + socks5Port: h.socks5Port, + socks5Username: h.socks5Username, + socks5Password: h.socks5Password, + }; +} + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type PanelId = "main" | "side"; + +type CardSlot = { + id: DashboardCardId; + panel: PanelId; + order: number; + height: number | null; +}; + +type DragState = { + id: DashboardCardId; + sourcePanel: PanelId; + sourceOrder: number; +} | null; + +// ─── Default layout ─────────────────────────────────────────────────────────── + +const DEFAULT_SLOTS: CardSlot[] = [ + { id: "stats_bar", panel: "main", order: 0, height: 96 }, + { id: "counters_bar", panel: "main", order: 1, height: 48 }, + { id: "quick_actions", panel: "main", order: 2, height: 160 }, + { id: "host_status", panel: "main", order: 3, height: null }, + { id: "recent_activity", panel: "side", order: 0, height: null }, +]; + +// ─── useColumnResize ────────────────────────────────────────────────────────── + +function useColumnResize( + containerRef: React.RefObject, + mainWidthPct: number, + setMainWidthPct: (v: number) => void, +) { + const dragging = useRef(false); + const startX = useRef(0); + const startPct = useRef(0); + + const onMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + dragging.current = true; + startX.current = e.clientX; + startPct.current = mainWidthPct; + + const onMove = (ev: MouseEvent) => { + if (!dragging.current || !containerRef.current) return; + const totalW = containerRef.current.getBoundingClientRect().width; + const delta = ev.clientX - startX.current; + const newPct = Math.min( + 85, + Math.max(30, startPct.current + (delta / totalW) * 100), + ); + setMainWidthPct(newPct); + }; + const onUp = () => { + dragging.current = false; + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }, + [containerRef, mainWidthPct, setMainWidthPct], + ); + + return onMouseDown; +} + +// ─── Card components ────────────────────────────────────────────────────────── + +function StatsBarCard({ + hosts, + uptimeFormatted, + versionText, + versionStatus, + dbHealth, +}: { + hosts: Host[]; + uptimeFormatted: string; + versionText: string; + versionStatus: "up_to_date" | "requires_update" | "beta"; + dbHealth: "healthy" | "error"; +}) { + const { t } = useTranslation(); + const online = hosts.filter((h) => h.online).length; + const statusLabel = + versionStatus === "beta" + ? t("dashboard.beta").toUpperCase() + : versionStatus === "requires_update" + ? t("dashboard.updateAvailable").toUpperCase() + : t("dashboardTab.stable"); + const statusColor = + versionStatus === "beta" + ? "bg-blue-500/20 text-blue-400" + : versionStatus === "requires_update" + ? "bg-yellow-500/20 text-yellow-400" + : "bg-accent-brand/20 text-accent-brand"; + return ( + +
    + + {t("dashboard.version")} + + + {versionText || "—"} + + + {statusLabel} + +
    +
    + + {t("dashboard.uptime")} + + + {uptimeFormatted || "—"} + +
    +
    + + {t("dashboard.database")} + + + {dbHealth === "healthy" + ? t("dashboard.healthy") + : t("dashboard.error")} + +
    +
    + + {t("dashboardTab.hostsOnline")} + +
    + {online} + + /{hosts.length} + +
    +
    +
    + ); +} + +function CountersBarCard({ + hosts, + credentialCount, + activeTunnelCount, +}: { + hosts: Host[]; + credentialCount: number; + activeTunnelCount: number; +}) { + const { t } = useTranslation(); + return ( + +
    + + {hosts.length} + + {t("dashboard.totalHosts")} + +
    +
    + + {credentialCount} + + {t("dashboard.totalCredentials")} + +
    +
    + + {activeTunnelCount} + + {t("dashboardTab.activeTunnels")} + +
    +
    + ); +} + +function QuickActionsCard({ + onOpenSingletonTab, + hosts, + onOpenTab, + isAdmin, +}: { + onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; + hosts: Host[]; + onOpenTab: (host: Host, type: TabType) => void; + isAdmin: boolean; +}) { + const { t } = useTranslation(); + const pinnedHosts = hosts.filter((h) => h.pin); + return ( + +
    + + + {t("dashboard.quickActions")} + +
    +
    +
    + + +
    +
    + {pinnedHosts.length > 0 ? ( +
    + {pinnedHosts.slice(0, 4).map((host) => ( + + ))} +
    + ) : ( + <> + {isAdmin && ( + + )} + + + )} +
    +
    +
    + ); +} + +function HostStatusCard({ + hosts, + hostMetrics, + onOpenTab, +}: { + hosts: Host[]; + hostMetrics: Map; + onOpenTab: (host: Host, type: TabType) => void; +}) { + const { t } = useTranslation(); + const online = hosts.filter((h) => h.online).length; + return ( + +
    +
    + + + {t("dashboardTab.hostStatus")} + +
    + + {online}/{hosts.length} {t("dashboardTab.onlineLower")} + +
    +
    + {hosts.length === 0 && ( +
    + {t("dashboardTab.noHostsConfigured")} +
    + )} + {hosts.map((host, i) => { + const metrics = hostMetrics.get(host.id); + const cpu = metrics?.cpu ?? null; + const ram = metrics?.ram ?? null; + const hasMetrics = cpu !== null || ram !== null; + return ( +
    onOpenTab(host, "stats")} + className="flex items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer" + > +
    + +
    + {host.name} + + {host.ip} + +
    +
    +
    + {host.online && hasMetrics ? ( +
    + {cpu !== null && ( +
    +
    + + {t("dashboard.cpu")} + + + {cpu.toFixed(0)}% + +
    +
    +
    +
    +
    + )} + {ram !== null && ( +
    +
    + + {t("dashboard.ram")} + + + {ram.toFixed(0)}% + +
    +
    +
    +
    +
    + )} +
    + ) : ( +
    + + — + + + — + +
    + )} + + {host.online + ? t("dashboardTab.online") + : t("dashboardTab.offline")} + +
    +
    + ); + })} +
    + + ); +} + +function RecentActivityCard({ + activity, + hosts, + onOpenTab, + onClear, +}: { + activity: RecentActivityItem[]; + hosts: Host[]; + onOpenTab: (host: Host, type: TabType) => void; + onClear: () => void; +}) { + const { t } = useTranslation(); + const typeIcon: Record = { + terminal: , + file_manager: , + server_stats: , + tunnel: , + docker: , + rdp: , + vnc: , + telnet: , + }; + const typeToTab: Record = { + terminal: "terminal", + file_manager: "files", + server_stats: "stats", + tunnel: "tunnel", + docker: "docker", + rdp: "rdp", + vnc: "vnc", + telnet: "telnet", + }; + const typeLabel: Record = { + terminal: t("networkGraph.terminal"), + file_manager: t("networkGraph.fileManager"), + server_stats: t("networkGraph.serverStats"), + tunnel: t("networkGraph.tunnel"), + docker: t("networkGraph.docker"), + rdp: "RDP", + vnc: "VNC", + telnet: "Telnet", + }; + function formatTime(ts: string) { + const diffMs = Date.now() - new Date(ts).getTime(); + if (diffMs < 0) return t("dashboard.justNow"); + const diff = Math.floor(diffMs / 1000); + if (diff < 60) return t("dashboard.justNow"); + if (diff < 3600) return `${Math.floor(diff / 60)}m`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h`; + return `${Math.floor(diff / 86400)}d`; + } + return ( + +
    +
    + + + {t("dashboard.recentActivity")} + +
    + +
    +
    + {activity.length === 0 && ( +
    + {t("dashboard.noRecentActivity")} +
    + )} + {activity.map((item) => { + const host = hosts.find((h) => h.id === String(item.hostId)); + return ( +
    { + if (host) onOpenTab(host, typeToTab[item.type]); + }} + className="flex items-center justify-between px-4 py-2 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer" + > +
    + +
    + + {item.hostName} + +
    + {typeIcon[item.type]} + {typeLabel[item.type]} +
    +
    +
    + + {formatTime(item.timestamp)} + +
    + ); + })} +
    +
    + ); +} + +// ─── CardItem ───────────────────────────────────────────────────────────────── + +function CardItem({ + slot, + editMode, + isDragging, + onDragStart, + onDrop, + onDragOver, + onRemove, + onHeightChange, + onOpenSingletonTab, + onOpenTab, + hosts, + hostMetrics, + uptimeFormatted, + versionText, + versionStatus, + dbHealth, + credentialCount, + activeTunnelCount, + activity, + onClearActivity, + isAdmin, +}: { + slot: CardSlot; + editMode: boolean; + isDragging: boolean; + onDragStart: () => void; + onDrop: () => void; + onDragOver: (e: React.DragEvent) => void; + onRemove: () => void; + onHeightChange: (id: DashboardCardId, h: number) => void; + onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; + onOpenTab: (host: Host, type: TabType) => void; + hosts: Host[]; + hostMetrics: Map; + uptimeFormatted: string; + versionText: string; + versionStatus: "up_to_date" | "requires_update" | "beta"; + dbHealth: "healthy" | "error"; + credentialCount: number; + activeTunnelCount: number; + activity: RecentActivityItem[]; + onClearActivity: () => void; + isAdmin: boolean; +}) { + const cardRef = useRef(null); + + const onResizeMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const startY = e.clientY; + const startH = cardRef.current?.getBoundingClientRect().height ?? 100; + const onMove = (ev: MouseEvent) => { + onHeightChange(slot.id, Math.max(50, startH + (ev.clientY - startY))); + }; + const onUp = () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }, + [slot.id, onHeightChange], + ); + + const isFlex = slot.height === null; + + return ( +
    + {editMode && ( +
    + )} + {editMode && ( +
    +
    + +
    + +
    + )} +
    + {slot.id === "stats_bar" && ( + + )} + {slot.id === "counters_bar" && ( + + )} + {slot.id === "quick_actions" && ( + + )} + {slot.id === "host_status" && ( + + )} + {slot.id === "recent_activity" && ( + + )} + {slot.id === "network_graph" && ( + onOpenSingletonTab("network_graph")} + /> + )} +
    + {editMode && !isFlex && ( +
    +
    +
    + )} +
    + ); +} + +// ─── DropZone ───────────────────────────────────────────────────────────────── + +function DropZone({ + panel, + order, + onDrop, + onDragOver, + active, +}: { + panel: PanelId; + order: number; + onDrop: (panel: PanelId, order: number) => void; + onDragOver: (e: React.DragEvent) => void; + active: boolean; +}) { + const [over, setOver] = useState(false); + if (!active) return null; + return ( +
    { + onDragOver(e); + setOver(true); + }} + onDragLeave={() => setOver(false)} + onDrop={() => { + setOver(false); + onDrop(panel, order); + }} + /> + ); +} + +// ─── AddCardTray ────────────────────────────────────────────────────────────── + +function AddCardTray({ + activeIds, + onAdd, + cardLabels, +}: { + activeIds: DashboardCardId[]; + onAdd: (id: DashboardCardId) => void; + cardLabels: Record; +}) { + const { t } = useTranslation(); + const available = DASHBOARD_CARDS.filter((c) => !activeIds.includes(c.id)); + if (available.length === 0) return null; + return ( +
    + + {t("dashboardTab.add")} + + {available.map((card) => ( + + ))} +
    + ); +} + +// ─── PanelColumn ───────────────────────────────────────────────────────────── + +type PanelColumnProps = { + panel: PanelId; + slots: CardSlot[]; + editMode: boolean; + dragState: DragState; + onDragStart: (slot: CardSlot) => void; + onDrop: (targetPanel: PanelId, targetOrder: number) => void; + onDragOver: (e: React.DragEvent) => void; + onRemove: (id: DashboardCardId) => void; + onAdd: (id: DashboardCardId, panel: PanelId) => void; + onHeightChange: (id: DashboardCardId, h: number) => void; + onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; + onOpenTab: (host: Host, type: TabType) => void; + hosts: Host[]; + hostMetrics: Map; + uptimeFormatted: string; + versionText: string; + versionStatus: "up_to_date" | "requires_update" | "beta"; + dbHealth: "healthy" | "error"; + credentialCount: number; + activeTunnelCount: number; + activity: RecentActivityItem[]; + onClearActivity: () => void; + cardLabels: Record; + isAdmin: boolean; +}; + +function PanelColumn({ + panel, + slots, + editMode, + dragState, + onDragStart, + onDrop, + onDragOver, + onRemove, + onAdd, + onHeightChange, + onOpenSingletonTab, + onOpenTab, + hosts, + hostMetrics, + uptimeFormatted, + versionText, + versionStatus, + dbHealth, + credentialCount, + activeTunnelCount, + activity, + onClearActivity, + cardLabels, + isAdmin, +}: PanelColumnProps) { + const { t } = useTranslation(); + const sorted = [...slots].sort((a, b) => a.order - b.order); + const allIds = slots.map((s) => s.id); + + return ( +
    + + {sorted.map((slot, idx) => ( +
    + {idx > 0 && ( +
    + +
    + )} + onDragStart(slot)} + onDrop={() => onDrop(slot.panel, slot.order)} + onDragOver={onDragOver} + onRemove={() => onRemove(slot.id)} + onHeightChange={onHeightChange} + onOpenSingletonTab={onOpenSingletonTab} + onOpenTab={onOpenTab} + hosts={hosts} + hostMetrics={hostMetrics} + uptimeFormatted={uptimeFormatted} + versionText={versionText} + versionStatus={versionStatus} + dbHealth={dbHealth} + credentialCount={credentialCount} + activeTunnelCount={activeTunnelCount} + activity={activity} + onClearActivity={onClearActivity} + isAdmin={isAdmin} + /> +
    + ))} + + {editMode && ( + onAdd(id, panel)} + cardLabels={cardLabels} + /> + )} + {sorted.length === 0 && !editMode && ( +
    + {t("dashboardTab.empty")} +
    + )} +
    + ); +} + +function ColumnDivider({ + onMouseDown, +}: { + onMouseDown: (e: React.MouseEvent) => void; +}) { + return ( +
    +
    +
    + +
    +
    + ); +} + +// ─── DashboardTab ───────────────────────────────────────────────────────────── + +export function DashboardTab({ + onOpenSingletonTab, + onOpenTab, +}: { + onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; + onOpenTab: (host: Host, type: TabType) => void; +}) { + const { t, i18n } = useTranslation(); + + const [slots, setSlots] = useState(() => { + try { + const saved = localStorage.getItem("dashboardTab.slots"); + if (saved) return JSON.parse(saved) as CardSlot[]; + } catch { + /* ignore */ + } + return DEFAULT_SLOTS; + }); + + const [editMode, setEditMode] = useState(false); + const [dragState, setDragState] = useState(null); + + const [mainWidthPct, setMainWidthPct] = useState(() => { + try { + const saved = localStorage.getItem("dashboardTab.mainWidthPct"); + if (saved) return Number(saved); + } catch { + /* ignore */ + } + return 68; + }); + + useEffect(() => { + try { + localStorage.setItem("dashboardTab.slots", JSON.stringify(slots)); + } catch { + /* ignore */ + } + }, [slots]); + + useEffect(() => { + try { + localStorage.setItem("dashboardTab.mainWidthPct", String(mainWidthPct)); + } catch { + /* ignore */ + } + }, [mainWidthPct]); + + const [hosts, setHosts] = useState([]); + const [isAdmin, setIsAdmin] = useState(false); + const [uptimeFormatted, setUptimeFormatted] = useState(""); + const [versionText, setVersionText] = useState(""); + const [versionStatus, setVersionStatus] = useState< + "up_to_date" | "requires_update" | "beta" + >("up_to_date"); + const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy"); + const [credentialCount, setCredentialCount] = useState(0); + const [activeTunnelCount, setActiveTunnelCount] = useState(0); + const [activity, setActivity] = useState([]); + const [hostMetrics, setHostMetrics] = useState< + Map + >(new Map()); + const viewerSessionsRef = useRef>(new Map()); + + const fetchMetrics = useCallback(async (hostList: Host[]) => { + let statuses: Record = {}; + try { + statuses = (await getAllServerStatuses()) as Record< + number, + { status?: string } + >; + } catch { + /* best-effort */ + } + + const newSessions = new Map(viewerSessionsRef.current); + const results = await Promise.all( + hostList.map(async (host) => { + const hostId = Number(host.id); + const knownStatus = statuses?.[hostId]?.status; + if (knownStatus === "offline") return null; + if (host.authType === "none" || host.authType === "opkssh") return null; + + try { + const existing = newSessions.get(hostId); + if (!existing) { + const reg = await registerMetricsViewer(hostId); + if (reg.skipped) return null; + if (reg.success && reg.viewerSessionId) { + newSessions.set(hostId, reg.viewerSessionId); + } + } + const metrics = await getServerMetricsById(hostId); + if (!metrics) return null; + return { + id: host.id, + cpu: metrics.cpu?.percent ?? null, + ram: metrics.memory?.percent ?? null, + }; + } catch { + return null; + } + }), + ); + + viewerSessionsRef.current = newSessions; + const map = new Map(); + for (const r of results) { + if (r) map.set(r.id, { cpu: r.cpu, ram: r.ram }); + } + setHostMetrics(map); + }, []); + + useEffect(() => { + let mounted = true; + const load = async () => { + const raw = await getSSHHosts().catch(() => []); + const mapped = raw.map(sshHostToHost); + if (mounted) setHosts(mapped); + fetchMetrics(mapped).catch(() => {}); + }; + load(); + + getUserInfo() + .then((info) => setIsAdmin(!!info.is_admin)) + .catch(() => {}); + getUptime() + .then((u) => setUptimeFormatted(u.formatted)) + .catch(() => {}); + getVersionInfo() + .then((info) => { + setVersionText(info.localVersion ?? ""); + setVersionStatus(info.status ?? "up_to_date"); + }) + .catch(() => {}); + getDatabaseHealth() + .then((health) => { + setDbHealth( + health.status === "ok" || health.status === "healthy" + ? "healthy" + : "error", + ); + }) + .catch(() => { + setDbHealth("error"); + }); + getRecentActivity(50) + .then(setActivity) + .catch(() => {}); + getCredentials() + .then((res: any) => + setCredentialCount( + Array.isArray(res?.credentials) ? res.credentials.length : 0, + ), + ) + .catch(() => {}); + getTunnelStatuses() + .then((statuses) => { + const active = Object.values(statuses ?? {}).filter( + (s: any) => s?.status === "CONNECTED", + ).length; + setActiveTunnelCount(active); + }) + .catch(() => {}); + + const metricsInterval = setInterval(async () => { + const raw = await getSSHHosts().catch(() => []); + const mapped = raw.map(sshHostToHost); + if (mounted) setHosts(mapped); + fetchMetrics(mapped).catch(() => {}); + }, 30000); + + return () => { + mounted = false; + clearInterval(metricsInterval); + }; + }, [fetchMetrics]); + + useEffect(() => { + if (viewerSessionsRef.current.size === 0) return; + const heartbeat = setInterval(async () => { + for (const [, sessionId] of viewerSessionsRef.current) { + sendMetricsHeartbeat(sessionId).catch(() => {}); + } + }, 30000); + return () => clearInterval(heartbeat); + }, [hostMetrics]); + + const handleClearActivity = async () => { + try { + await resetRecentActivity(); + setActivity([]); + } catch { + /* ignore */ + } + }; + + const todayLabel = new Date().toLocaleDateString(i18n.language, { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + }); + const bodyRef = useRef(null); + + const mainSlots = slots + .filter((s) => s.panel === "main") + .sort((a, b) => a.order - b.order); + const sideSlots = slots + .filter((s) => s.panel === "side") + .sort((a, b) => a.order - b.order); + const hasSide = sideSlots.length > 0; + + const cardLabels: Record = { + stats_bar: t("dashboard.serverOverview"), + counters_bar: t("dashboard.serverStats"), + quick_actions: t("dashboard.quickActions"), + host_status: t("dashboardTab.hostStatus"), + recent_activity: t("dashboard.recentActivity"), + network_graph: t("dashboard.networkGraph"), + }; + + const onColumnDividerMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + const startX = e.clientX; + const startPct = mainWidthPct; + const onMove = (ev: MouseEvent) => { + if (!bodyRef.current) return; + const totalW = bodyRef.current.getBoundingClientRect().width; + setMainWidthPct( + Math.min( + 85, + Math.max(25, startPct + ((ev.clientX - startX) / totalW) * 100), + ), + ); + }; + const onUp = () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }, + [mainWidthPct], + ); + + const handleDragStart = (slot: CardSlot) => + setDragState({ + id: slot.id, + sourcePanel: slot.panel, + sourceOrder: slot.order, + }); + const handleDragOver = (e: React.DragEvent) => e.preventDefault(); + const handleDrop = (targetPanel: PanelId, targetOrder: number) => { + if (!dragState) return; + setSlots((prev) => { + const without = prev.filter((s) => s.id !== dragState.id); + const panelSlots = without + .filter((s) => s.panel === targetPanel) + .sort((a, b) => a.order - b.order); + const others = without.filter((s) => s.panel !== targetPanel); + const insertIdx = panelSlots.findIndex((s) => s.order > targetOrder); + const insertAt = insertIdx === -1 ? panelSlots.length : insertIdx; + const newPanelSlots = [ + ...panelSlots.slice(0, insertAt), + { + id: dragState.id, + panel: targetPanel, + order: 0, + height: prev.find((s) => s.id === dragState.id)?.height ?? null, + }, + ...panelSlots.slice(insertAt), + ].map((s, i) => ({ ...s, order: i })); + return [...others, ...newPanelSlots]; + }); + setDragState(null); + }; + const handleRemove = (id: DashboardCardId) => + setSlots((prev) => prev.filter((s) => s.id !== id)); + const handleAdd = (id: DashboardCardId, panel: PanelId) => { + setSlots((prev) => { + const panelSlots = prev.filter((s) => s.panel === panel); + const maxOrder = + panelSlots.length > 0 + ? Math.max(...panelSlots.map((s) => s.order)) + 1 + : 0; + const defaultHeight: number | null = + id === "network_graph" + ? 350 + : id === "host_status" || id === "recent_activity" + ? null + : 150; + return [...prev, { id, panel, order: maxOrder, height: defaultHeight }]; + }); + }; + const handleHeightChange = (id: DashboardCardId, h: number) => + setSlots((prev) => + prev.map((s) => (s.id === id ? { ...s, height: h } : s)), + ); + const handleReset = () => { + setSlots(DEFAULT_SLOTS); + setMainWidthPct(68); + setEditMode(false); + try { + localStorage.removeItem("dashboardTab.slots"); + localStorage.removeItem("dashboardTab.mainWidthPct"); + } catch { + /* ignore */ + } + }; + + const columnProps = { + hosts, + hostMetrics, + uptimeFormatted, + versionText, + versionStatus, + dbHealth, + credentialCount, + activeTunnelCount, + activity, + onClearActivity: handleClearActivity, + onOpenSingletonTab, + onOpenTab, + cardLabels, + isAdmin, + }; + + const isMobile = useIsMobile(); + + if (isMobile) { + const allSlots = [...mainSlots, ...sideSlots]; + return ( +
    +
    + +
    +

    + {t("dashboard.title")} +

    +

    {todayLabel}

    +
    + +
    + {allSlots.map((slot) => ( +
    + {slot.id === "stats_bar" && ( + + )} + {slot.id === "counters_bar" && ( + + )} + {slot.id === "quick_actions" && ( + + )} + {slot.id === "host_status" && ( + + )} + {slot.id === "recent_activity" && ( + + )} + {slot.id === "network_graph" && ( + onOpenSingletonTab("network_graph")} + /> + )} +
    + ))} +
    +
    + ); + } + + return ( +
    + +
    +

    + {t("dashboard.title")} +

    +

    {todayLabel}

    +
    +
    +
    + + {t("dashboardTab.commandPalette")} + +
    + Shift + + + Shift +
    +
    + + + + + {editMode ? ( + <> + + + + ) : ( + + )} +
    +
    + + {editMode && ( +
    + + + {t("dashboardTab.editModeInstructions")} + +
    + )} + +
    +
    + +
    + + {(hasSide || editMode) && + (editMode ? ( + + ) : ( +
    + ))} + + {(hasSide || editMode) && ( +
    + +
    + )} +
    +
    + ); +} diff --git a/src/ui/dashboard/cards/NetworkGraphCard.tsx b/src/ui/dashboard/cards/NetworkGraphCard.tsx new file mode 100644 index 00000000..e0714c4f --- /dev/null +++ b/src/ui/dashboard/cards/NetworkGraphCard.tsx @@ -0,0 +1,1342 @@ +import React, { + useEffect, + useState, + useRef, + useCallback, + useMemo, + useReducer, +} from "react"; +import { Card } from "@/components/card"; +import CytoscapeComponent from "react-cytoscapejs"; +import cytoscape from "cytoscape"; +import { + getSSHHosts, + getNetworkTopology, + saveNetworkTopology, + type SSHHostWithStatus, + type NetworkTopologyEdge, + type NetworkTopologyNode, +} from "@/main-axios"; +import { Button } from "@/components/button"; +import { Badge } from "@/components/badge"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogAction, +} from "@/components/alert-dialog"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/dialog"; +import { Input } from "@/components/input"; +import { Label } from "@/components/label"; +import { + Plus, + Trash2, + ZoomIn, + ZoomOut, + RotateCw, + AlertCircle, + Download, + Upload, + Link2, + FolderPlus, + Edit, + FolderInput, + FolderMinus, + Terminal, + ArrowUp, + Network, + FolderOpen, + Container, + Server, + ArrowDownUp, + Loader2, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useTabsSafe } from "@/shell/TabContext"; +import { cn } from "@/lib/utils"; + +const AVAILABLE_COLORS = [ + { value: "#ef4444", label: "Red" }, + { value: "#f97316", label: "Orange" }, + { value: "#eab308", label: "Yellow" }, + { value: "#22c55e", label: "Green" }, + { value: "#3b82f6", label: "Blue" }, + { value: "#a855f7", label: "Purple" }, + { value: "#ec4899", label: "Pink" }, + { value: "#6b7280", label: "Gray" }, +]; + +interface HostMap { + [key: string]: SSHHostWithStatus; +} + +interface ContextMenuState { + visible: boolean; + x: number; + y: number; + targetId: string; + type: "node" | "group" | "edge" | null; +} + +interface NetworkGraphCardProps { + isTopbarOpen?: boolean; + rightSidebarOpen?: boolean; + rightSidebarWidth?: number; + embedded?: boolean; + onOpenInNewTab?: () => void; +} + +type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge; + +function resolveCssVar(varName: string, fallback: string): string { + const raw = getComputedStyle(document.documentElement) + .getPropertyValue(varName) + .trim(); + if (!raw) return fallback; + const tmp = document.createElement("div"); + tmp.style.position = "absolute"; + tmp.style.visibility = "hidden"; + tmp.style.backgroundColor = `var(${varName})`; + document.body.appendChild(tmp); + const resolved = getComputedStyle(tmp).backgroundColor; + document.body.removeChild(tmp); + if ( + !resolved || + resolved === "rgba(0, 0, 0, 0)" || + resolved.includes("oklch") + ) + return fallback; + return resolved; +} + +const NODE_W = 220; +const NODE_H = 88; + +function buildNodeSvg( + name: string, + ip: string, + tags: string[], + status: string, +): string { + const isOnline = status === "online"; + const isOffline = status === "offline"; + const statusColor = isOnline + ? "rgb(16,185,129)" + : isOffline + ? "rgb(239,68,68)" + : "rgb(100,116,139)"; + + const bg = resolveCssVar("--card", "#1e1e20"); + const border = resolveCssVar("--border", "#2a2a2c"); + const textPrimary = resolveCssVar("--card-foreground", "#f1f5f9"); + const textSecondary = resolveCssVar("--muted-foreground", "#94a3b8"); + + const dpr = Math.min(window.devicePixelRatio || 1, 3); + const W = NODE_W * dpr; + const H = NODE_H * dpr; + const s = dpr; + + const esc = (str: string) => + str.replace( + /[<>&"]/g, + (c) => + ({ "<": "<", ">": ">", "&": "&", '"': """ })[c] ?? c, + ); + + const tagsHtml = tags + .slice(0, 3) + .map( + (tag) => + `${esc(tag)}`, + ) + .join(""); + + const serverIcon = ` +`; + + return ( + "data:image/svg+xml;utf8," + + encodeURIComponent( + ` + + + + + + + + + + + + ${serverIcon} + ${esc(name)} + ${esc(ip)} + ${tagsHtml ? `
    ${tagsHtml}
    ` : ""} +
    `, + ) + ); +} + +export function NetworkGraphCard({ + embedded = true, + onOpenInNewTab, +}: NetworkGraphCardProps): React.ReactElement { + const { t } = useTranslation(); + const { addTab } = useTabsSafe(); + + const [elements, setElements] = useState([]); + const [hosts, setHosts] = useState([]); + const [hostMap, setHostMap] = useState({}); + const hostMapRef = useRef({}); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [selectedEdgeId, setSelectedEdgeId] = useState(null); + + const [showAddNodeDialog, setShowAddNodeDialog] = useState(false); + const [showAddEdgeDialog, setShowAddEdgeDialog] = useState(false); + const [showAddGroupDialog, setShowAddGroupDialog] = useState(false); + const [showEditGroupDialog, setShowEditGroupDialog] = useState(false); + const [showNodeDetail, setShowNodeDetail] = useState(false); + const [showMoveNodeDialog, setShowMoveNodeDialog] = useState(false); + + const [selectedHostForAddNode, setSelectedHostForAddNode] = useState(""); + const [selectedGroupForAddNode, setSelectedGroupForAddNode] = + useState("ROOT"); + const [newGroupName, setNewGroupName] = useState(""); + const [newGroupColor, setNewGroupColor] = useState("#3b82f6"); + const [editingGroupId, setEditingGroupId] = useState(null); + const [selectedGroupForMove, setSelectedGroupForMove] = useState("ROOT"); + const [selectedHostForEdge, setSelectedHostForEdge] = useState(""); + const [targetHostForEdge, setTargetHostForEdge] = useState(""); + const [selectedNodeForDetail, setSelectedNodeForDetail] = + useState(null); + + const [contextMenu, setContextMenu] = useState({ + visible: false, + x: 0, + y: 0, + targetId: "", + type: null, + }); + + const [, forceUpdate] = useReducer((x: number) => x + 1, 0); + + const cyRef = useRef(null); + const statusIntervalRef = useRef | null>(null); + const saveTimeoutRef = useRef | null>(null); + const contextMenuRef = useRef(null); + const fileInputRef = useRef(null); + const cyContainerRef = useRef(null); + + useEffect(() => { + hostMapRef.current = hostMap; + }, [hostMap]); + + useEffect(() => { + loadData(); + statusIntervalRef.current = setInterval(updateHostStatuses, 30000); + const onClickOutside = (e: MouseEvent) => { + if ( + contextMenuRef.current && + !contextMenuRef.current.contains(e.target as Node) + ) + setContextMenu((p) => (p.visible ? { ...p, visible: false } : p)); + }; + document.addEventListener("mousedown", onClickOutside, true); + + const themeObserver = new MutationObserver(() => { + if (cyRef.current) applyStyle(cyRef.current); + }); + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "data-theme"], + }); + + return () => { + if (statusIntervalRef.current) clearInterval(statusIntervalRef.current); + document.removeEventListener("mousedown", onClickOutside, true); + themeObserver.disconnect(); + }; + }, []); + + const loadData = async () => { + setLoading(true); + setError(null); + try { + const hostsData = await getSSHHosts(); + const hostsArray = Array.isArray(hostsData) ? hostsData : []; + setHosts(hostsArray); + const newMap: HostMap = {}; + hostsArray.forEach((h) => (newMap[String(h.id)] = h)); + setHostMap(newMap); + + let nodes: NetworkTopologyNode[] = []; + let edges: NetworkTopologyEdge[] = []; + try { + const topo = await getNetworkTopology(); + if (topo?.nodes && Array.isArray(topo.nodes)) { + nodes = topo.nodes.map((node) => { + const h = newMap[node.data.id]; + return { + data: { + id: node.data.id, + label: h?.name || node.data.label || "Unknown", + ip: h ? `${h.ip}:${h.port}` : node.data.ip || "", + status: h?.status || "unknown", + tags: h?.tags || [], + parent: node.data.parent, + color: node.data.color, + }, + position: node.position || { x: 0, y: 0 }, + }; + }); + edges = topo.edges || []; + } + } catch { + /* start with empty topology */ + } + + const nodeIds = new Set(nodes.map((n) => n.data.id)); + const validEdges = edges.filter( + (e) => nodeIds.has(e.data.source) && nodeIds.has(e.data.target), + ); + setElements([...nodes, ...validEdges]); + } catch { + /* ignore */ + } finally { + setLoading(false); + } + }; + + const updateHostStatuses = useCallback(async () => { + if (!cyRef.current) return; + try { + const updated = await getSSHHosts(); + const updatedMap: HostMap = {}; + updated.forEach((h) => (updatedMap[String(h.id)] = h)); + cyRef.current.nodes().forEach((node) => { + if (node.isParent()) return; + const h = updatedMap[node.data("id")]; + if (h) { + node.data("status", h.status); + node.data("tags", h.tags || []); + } + }); + setHostMap(updatedMap); + } catch { + /* ignore */ + } + }, []); + + const debouncedSave = useCallback(() => { + if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = setTimeout(() => saveCurrentLayout(), 1000); + }, []); + + const saveCurrentLayout = async () => { + if (!cyRef.current) return; + try { + const nodes = cyRef.current.nodes().map((n) => ({ + data: { + id: n.data("id"), + label: n.data("label"), + ip: n.data("ip"), + status: n.data("status"), + tags: n.data("tags") || [], + parent: n.data("parent"), + color: n.data("color"), + }, + position: n.position(), + })); + const edges = cyRef.current.edges().map((e) => ({ + data: { + id: e.data("id"), + source: e.data("source"), + target: e.data("target"), + }, + })); + await saveNetworkTopology({ nodes, edges }); + } catch { + /* ignore */ + } + }; + + useEffect(() => { + if (!cyRef.current || loading || elements.length === 0) return; + const hasPositions = elements.some( + (el) => + "position" in el && + el.position && + (el.position.x !== 0 || el.position.y !== 0), + ); + if (!hasPositions) { + cyRef.current + .layout({ + name: "cose", + animate: false, + randomize: true, + componentSpacing: 100, + nodeOverlap: 20, + } as any) + .run(); + } else { + cyRef.current.fit(); + } + }, [loading]); + + const applyStyle = useCallback((cy: cytoscape.Core) => { + const edgeColor = resolveCssVar("--border", "#4a4a4e"); + const foreground = resolveCssVar("--foreground", "#f1f5f9"); + cy.style() + .selector("node") + .style({ + label: "", + width: `${NODE_W}px`, + height: `${NODE_H}px`, + shape: "rectangle", + "border-width": "0px", + "background-opacity": 0, + "background-image": (ele) => + buildNodeSvg( + ele.data("label") || "", + ele.data("ip") || "", + ele.data("tags") || [], + ele.data("status") || "unknown", + ), + "background-fit": "contain", + }) + .selector("node:parent") + .style({ + "background-image": "none", + "background-color": (ele) => ele.data("color") || "#1e3a8a", + "background-opacity": 0.08, + "border-color": (ele) => ele.data("color") || "#3b82f6", + "border-width": "1.5px", + "border-style": "dashed", + label: "data(label)", + "text-valign": "top", + "text-halign": "center", + "text-margin-y": -6, + color: foreground, + "font-size": "13px", + "font-weight": "bold", + shape: "rectangle", + padding: "20px", + }) + .selector("edge") + .style({ + width: "1.5px", + "line-color": edgeColor, + "curve-style": "bezier", + "target-arrow-shape": "none", + }) + .selector("edge:selected") + .style({ "line-color": "#f59145", width: "2.5px" }) + .selector("node:selected") + .style({ + "overlay-color": "#f59145", + "overlay-opacity": 0.06, + "overlay-padding": "6px", + }) + .update(); + }, []); + + const handleNodeInit = useCallback( + (cy: cytoscape.Core) => { + cyRef.current = cy; + if (embedded) { + cy.nodes().forEach((n) => n.ungrabify()); + } else { + cy.nodes().forEach((n) => n.grabify()); + } + applyStyle(cy); + + cy.on("tap", "node", (evt) => { + setContextMenu((p) => (p.visible ? { ...p, visible: false } : p)); + setSelectedEdgeId(null); + setSelectedNodeId(evt.target.id()); + }); + cy.on("tap", "edge", (evt) => { + evt.stopPropagation(); + setSelectedEdgeId(evt.target.id()); + setSelectedNodeId(null); + }); + cy.on("tap", (evt) => { + if (evt.target === cy) { + setContextMenu((p) => (p.visible ? { ...p, visible: false } : p)); + setSelectedNodeId(null); + setSelectedEdgeId(null); + } + }); + cy.on("cxttap", "node", (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + const node = evt.target; + const nodeId = node.id(); + const isGroup = node.isParent() || String(nodeId).startsWith("group-"); + if (isGroup && embedded) return; + setContextMenu({ + visible: true, + x: evt.originalEvent.clientX, + y: evt.originalEvent.clientY, + targetId: nodeId, + type: isGroup ? "group" : "node", + }); + }); + cy.on("zoom pan", () => + setContextMenu((p) => (p.visible ? { ...p, visible: false } : p)), + ); + cy.on("free", "node", () => !embedded && debouncedSave()); + cy.on("boxselect", "node", () => { + const sel = cy.$("node:selected"); + if (sel.length === 1) setSelectedNodeId(sel[0].id()); + }); + }, + [applyStyle, debouncedSave, embedded], + ); + + // Zoom centered on the viewport midpoint — no panning + const zoomIn = useCallback(() => { + const cy = cyRef.current; + if (!cy || !cyContainerRef.current) return; + const rect = cyContainerRef.current.getBoundingClientRect(); + cy.zoom({ + level: cy.zoom() * 1.25, + renderedPosition: { x: rect.width / 2, y: rect.height / 2 }, + }); + }, []); + + const zoomOut = useCallback(() => { + const cy = cyRef.current; + if (!cy || !cyContainerRef.current) return; + const rect = cyContainerRef.current.getBoundingClientRect(); + cy.zoom({ + level: cy.zoom() / 1.25, + renderedPosition: { x: rect.width / 2, y: rect.height / 2 }, + }); + }, []); + + const hideMenu = () => setContextMenu((p) => ({ ...p, visible: false })); + + const fireOpen = (hostId: string, type: string) => { + window.dispatchEvent( + new CustomEvent("termix:open-tab", { detail: { hostId, type } }), + ); + }; + + const handleContextAction = (action: string) => { + hideMenu(); + const targetId = contextMenu.targetId; + if (!cyRef.current) return; + if (action === "details") { + const h = hostMap[targetId]; + if (h) { + setSelectedNodeForDetail(h); + setShowNodeDetail(true); + } + } else if (action === "connect") { + fireOpen(targetId, "terminal"); + } else if (action === "move") { + setSelectedNodeId(targetId); + const node = cyRef.current.$id(targetId); + setSelectedGroupForMove(node.data("parent") || "ROOT"); + setShowMoveNodeDialog(true); + } else if (action === "removeFromGroup") { + cyRef.current.$id(targetId).move({ parent: null }); + debouncedSave(); + } else if (action === "editGroup") { + const node = cyRef.current.$id(targetId); + setEditingGroupId(targetId); + setNewGroupName(node.data("label")); + setNewGroupColor(node.data("color") || "#3b82f6"); + setShowEditGroupDialog(true); + } else if (action === "addHostToGroup") { + setSelectedGroupForAddNode(targetId); + setSelectedHostForAddNode(""); + setShowAddNodeDialog(true); + } else if (action === "delete") { + cyRef.current.$id(targetId).remove(); + debouncedSave(); + } + }; + + const handleConnectAction = (appType: string) => { + hideMenu(); + fireOpen(contextMenu.targetId, appType); + }; + + const hasTunnelConnections = (h: SSHHostWithStatus | undefined) => { + if (!h?.tunnelConnections) return false; + try { + const arr = Array.isArray(h.tunnelConnections) + ? h.tunnelConnections + : JSON.parse(h.tunnelConnections as string); + return Array.isArray(arr) && arr.length > 0; + } catch { + return false; + } + }; + + const handleConfirmAddNode = async () => { + if (!cyRef.current || !selectedHostForAddNode) return; + try { + if (cyRef.current.$id(selectedHostForAddNode).length > 0) { + setError(t("networkGraph.hostAlreadyExists")); + return; + } + const h = hostMap[selectedHostForAddNode]; + const parent = + selectedGroupForAddNode === "ROOT" + ? undefined + : selectedGroupForAddNode; + cyRef.current.add({ + data: { + id: selectedHostForAddNode, + label: h?.name || h?.ip || selectedHostForAddNode, + ip: h ? `${h.ip}:${h.port}` : "", + status: h?.status || "unknown", + tags: h?.tags || [], + parent, + }, + position: { + x: 100 + Math.random() * 200, + y: 100 + Math.random() * 200, + }, + }); + applyStyle(cyRef.current); + await saveCurrentLayout(); + setElements([...(cyRef.current.elements().jsons() as NetworkElement[])]); + forceUpdate(); + setShowAddNodeDialog(false); + } catch { + setError(t("networkGraph.failedToAddNode")); + } + }; + + const handleAddGroup = async () => { + if (!cyRef.current || !newGroupName) return; + const groupId = `group-${Date.now()}`; + cyRef.current.add({ + data: { id: groupId, label: newGroupName, color: newGroupColor }, + }); + await saveCurrentLayout(); + setElements([...(cyRef.current.elements().jsons() as NetworkElement[])]); + forceUpdate(); + setShowAddGroupDialog(false); + setNewGroupName(""); + }; + + const handleUpdateGroup = async () => { + if (!cyRef.current || !editingGroupId || !newGroupName) return; + const g = cyRef.current.$id(editingGroupId); + g.data("label", newGroupName); + g.data("color", newGroupColor); + await saveCurrentLayout(); + setShowEditGroupDialog(false); + setEditingGroupId(null); + }; + + const handleMoveNodeToGroup = async () => { + if (!cyRef.current || !selectedNodeId) return; + cyRef.current.$id(selectedNodeId).move({ + parent: selectedGroupForMove === "ROOT" ? null : selectedGroupForMove, + }); + await saveCurrentLayout(); + setShowMoveNodeDialog(false); + }; + + const handleAddEdge = async () => { + if (!cyRef.current || !selectedHostForEdge || !targetHostForEdge) return; + if (selectedHostForEdge === targetHostForEdge) + return setError(t("networkGraph.sourceDifferentFromTarget")); + const edgeId = `${selectedHostForEdge}-${targetHostForEdge}`; + if (cyRef.current.$id(edgeId).length > 0) + return setError(t("networkGraph.connectionExists")); + cyRef.current.add({ + data: { + id: edgeId, + source: selectedHostForEdge, + target: targetHostForEdge, + }, + }); + await saveCurrentLayout(); + setShowAddEdgeDialog(false); + }; + + const handleRemoveSelected = () => { + if (!cyRef.current) return; + if (selectedNodeId) { + cyRef.current.$id(selectedNodeId).remove(); + setSelectedNodeId(null); + } else if (selectedEdgeId) { + cyRef.current.$id(selectedEdgeId).remove(); + setSelectedEdgeId(null); + } + debouncedSave(); + }; + + const handleExport = () => { + if (!cyRef.current) return; + const json = JSON.stringify(cyRef.current.json().elements, null, 2); + const a = document.createElement("a"); + a.href = URL.createObjectURL( + new Blob([json], { type: "application/json" }), + ); + a.download = "network.json"; + a.click(); + }; + + const handleOpenInNewTab = () => { + if (onOpenInNewTab) { + onOpenInNewTab(); + } else { + addTab({ + type: "network_graph" as any, + title: t("dashboard.networkGraph"), + }); + } + }; + + const availableGroups = useMemo( + () => + elements + .filter( + (el) => + !el.data.source && !el.data.target && !el.data.ip && el.data.id, + ) + .map((el) => ({ + id: el.data.id!, + label: el.data.label || el.data.id!, + })), + [elements], + ); + + const availableNodesForConnection = useMemo( + () => + elements + .filter((el) => !el.data.source && !el.data.target) + .map((el) => ({ + id: el.data.id!, + label: el.data.label || el.data.id!, + })), + [elements], + ); + + const availableHostsForAdd = useMemo(() => { + if (!cyRef.current) return hosts; + const existing = new Set(elements.map((e) => e.data.id)); + return hosts.filter((h) => !existing.has(String(h.id))); + }, [hosts, elements]); + + const btnCls = + "h-7 w-7 p-0 rounded-sm border-0 hover:bg-muted/60 transition-colors flex items-center justify-center text-muted-foreground hover:text-foreground"; + + const contextMenuEl = contextMenu.visible ? ( +
    + {contextMenu.type === "node" && ( + <> + {hostMap[contextMenu.targetId]?.enableTerminal && ( + + )} + {hostMap[contextMenu.targetId]?.enableFileManager && ( + + )} + {hostMap[contextMenu.targetId]?.enableTunnel && + hasTunnelConnections(hostMap[contextMenu.targetId]) && ( + + )} + {hostMap[contextMenu.targetId]?.enableDocker && ( + + )} + + {!embedded && ( + <> +
    + + {cyRef.current?.$id(contextMenu.targetId).parent().length ? ( + + ) : null} +
    + + + )} + + )} + {contextMenu.type === "group" && !embedded && ( + <> + + +
    + + + )} +
    + ) : null; + + const cytoscapeEl = ( +
    e.preventDefault()} + > + {loading && ( +
    + +
    + )} + {contextMenuEl} + + {!loading && elements.length === 0 && ( +
    + +

    + {t("networkGraph.noNodes")} +

    +
    + )} +
    + ); + + const dialogs = ( + <> + setError(null)}> + +
    + + + {error} + +
    +
    + setError(null)}> + OK + +
    +
    +
    + + {/* Add Host dialog */} + + + + {t("networkGraph.addHost")} + +
    +
    + + +
    +
    + + +
    +
    + + + + +
    +
    + + {/* Add / Edit Group dialog */} + { + if (!o) { + setShowAddGroupDialog(false); + setShowEditGroupDialog(false); + } + }} + > + + + + {showEditGroupDialog + ? t("networkGraph.editGroup") + : t("networkGraph.createGroup")} + + +
    +
    + + setNewGroupName(e.target.value)} + placeholder={t("networkGraph.groupName")} + /> +
    +
    + +
    + {AVAILABLE_COLORS.map((c) => ( +
    +
    +
    + + + + +
    +
    + + {/* Move to Group dialog */} + + + + {t("networkGraph.moveToGroup")} + +
    +
    + + +
    +
    + + + + +
    +
    + + {/* Add Edge dialog */} + + + + {t("networkGraph.addConnection")} + +
    +
    + + +
    +
    + + +
    +
    + + + + +
    +
    + + {/* Node Detail dialog */} + + + + {t("networkGraph.hostDetails")} + + {selectedNodeForDetail && ( +
    +
    + + {t("networkGraph.name")} + + {selectedNodeForDetail.name} + + {t("networkGraph.ip")} + + {selectedNodeForDetail.ip} + + {t("networkGraph.status")} + + + {selectedNodeForDetail.status || t("networkGraph.unknown")} + +
    + {selectedNodeForDetail.tags && + selectedNodeForDetail.tags.length > 0 && ( +
    + {selectedNodeForDetail.tags.map((tag) => ( + + {tag} + + ))} +
    + )} +
    + )} + + + +
    +
    + + { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = async (evt) => { + try { + const json = JSON.parse(evt.target?.result as string); + await saveNetworkTopology({ + nodes: json.nodes, + edges: json.edges, + }); + await loadData(); + if (fileInputRef.current) fileInputRef.current.value = ""; + } catch { + setError(t("networkGraph.invalidFile")); + } + }; + reader.readAsText(file); + }} + /> + + ); + + if (!embedded) { + return ( +
    +
    +
    + + + + +
    +
    +
    + + + +
    +
    +
    + + +
    +
    + {cytoscapeEl} + {dialogs} +
    + ); + } + + /* ── embedded card ── */ + const nodeCount = elements.filter((e) => !e.data.source).length; + return ( + +
    +
    + + + {t("dashboard.networkGraph")} + + {!loading && ( + + {t("dashboardTab.nodes", { count: nodeCount })} + + )} +
    +
    + + + + +
    +
    + {cytoscapeEl} + {dialogs} +
    + ); +} diff --git a/src/ui/desktop/apps/dashboard/cards/QuickActionsCard.tsx b/src/ui/dashboard/cards/QuickActionsCard.tsx similarity index 99% rename from src/ui/desktop/apps/dashboard/cards/QuickActionsCard.tsx rename to src/ui/dashboard/cards/QuickActionsCard.tsx index 1bc1bccf..1d2ce32b 100644 --- a/src/ui/desktop/apps/dashboard/cards/QuickActionsCard.tsx +++ b/src/ui/dashboard/cards/QuickActionsCard.tsx @@ -1,7 +1,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { FastForward, Server, Key, Settings, User } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { Button } from "@/components/button"; interface QuickActionsCardProps { isAdmin: boolean; diff --git a/src/ui/desktop/apps/dashboard/cards/RecentActivityCard.tsx b/src/ui/dashboard/cards/RecentActivityCard.tsx similarity index 77% rename from src/ui/desktop/apps/dashboard/cards/RecentActivityCard.tsx rename to src/ui/dashboard/cards/RecentActivityCard.tsx index d030be15..48731fcc 100644 --- a/src/ui/desktop/apps/dashboard/cards/RecentActivityCard.tsx +++ b/src/ui/dashboard/cards/RecentActivityCard.tsx @@ -12,8 +12,8 @@ import { Eye, MessagesSquare, } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { type RecentActivityItem } from "@/ui/main-axios"; +import { Button } from "@/components/button"; +import { type RecentActivityItem } from "@/main-axios"; interface RecentActivityCardProps { activities: RecentActivityItem[]; @@ -22,6 +22,21 @@ interface RecentActivityCardProps { onActivityClick: (item: RecentActivityItem) => void; } +function formatRelativeTime( + timestamp: string, + t: (key: string) => string, +): string { + const diffMs = Date.now() - new Date(timestamp).getTime(); + if (diffMs < 0) return t("dashboard.justNow"); + const diffSec = Math.floor(diffMs / 1000); + if (diffSec < 60) return t("dashboard.justNow"); + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m`; + const diffHour = Math.floor(diffMin / 60); + if (diffHour < 24) return `${diffHour}h`; + return `${Math.floor(diffHour / 24)}d`; +} + export function RecentActivityCard({ activities, loading, @@ -95,7 +110,14 @@ export function RecentActivityCard({ ) : ( )} -

    {item.hostName}

    +
    +

    + {item.hostName} +

    +

    + {formatRelativeTime(item.timestamp, t)} +

    +
    )) )} diff --git a/src/ui/desktop/apps/dashboard/cards/ServerOverviewCard.tsx b/src/ui/dashboard/cards/ServerOverviewCard.tsx similarity index 90% rename from src/ui/desktop/apps/dashboard/cards/ServerOverviewCard.tsx rename to src/ui/dashboard/cards/ServerOverviewCard.tsx index 4821e305..019b3d95 100644 --- a/src/ui/desktop/apps/dashboard/cards/ServerOverviewCard.tsx +++ b/src/ui/dashboard/cards/ServerOverviewCard.tsx @@ -8,8 +8,8 @@ import { Key, ArrowDownUp, } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { UpdateLog } from "@/ui/desktop/apps/dashboard/apps/UpdateLog"; +import { Button } from "@/components/button"; +import { UpdateLog } from "@/dashboard/panels/UpdateLog"; interface ServerOverviewCardProps { loggedIn: boolean; @@ -56,22 +56,28 @@ export function ServerOverviewCard({

    {versionText}

    - {!updateCheckDisabled && ( + {versionStatus === "beta" ? ( + + ) : !updateCheckDisabled ? ( <> - )} + ) : null}
    diff --git a/src/ui/desktop/apps/dashboard/cards/ServerStatsCard.tsx b/src/ui/dashboard/cards/ServerStatsCard.tsx similarity index 78% rename from src/ui/desktop/apps/dashboard/cards/ServerStatsCard.tsx rename to src/ui/dashboard/cards/ServerStatsCard.tsx index 6e74dc0d..c95dcf5c 100644 --- a/src/ui/desktop/apps/dashboard/cards/ServerStatsCard.tsx +++ b/src/ui/dashboard/cards/ServerStatsCard.tsx @@ -1,7 +1,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { ChartLine, Loader2, Server } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { Button } from "@/components/button"; interface ServerStat { id: number; @@ -23,6 +23,10 @@ export function ServerStatsCard({ }: ServerStatsCardProps): React.ReactElement { const { t } = useTranslation(); + const visibleStats = serverStats.filter( + (s) => s.cpu !== null || s.ram !== null, + ); + return (
    @@ -38,12 +42,12 @@ export function ServerStatsCard({ {t("dashboard.loadingServerStats")}
    - ) : serverStats.length === 0 ? ( + ) : visibleStats.length === 0 ? (

    {t("dashboard.noServerData")}

    ) : ( - serverStats.map((server) => ( + visibleStats.map((server) => (
    - - {t("dashboard.cpu")}:{" "} - {server.cpu !== null - ? `${server.cpu}%` - : t("dashboard.notAvailable")} - - - {t("dashboard.ram")}:{" "} - {server.ram !== null - ? `${server.ram}%` - : t("dashboard.notAvailable")} - + {server.cpu !== null && ( + + {t("dashboard.cpu")}: {server.cpu.toFixed(1)}% + + )} + {server.ram !== null && ( + + {t("dashboard.ram")}: {server.ram.toFixed(1)}% + + )}
    diff --git a/src/ui/desktop/apps/dashboard/components/DashboardSettingsDialog.tsx b/src/ui/dashboard/components/DashboardSettingsDialog.tsx similarity index 72% rename from src/ui/desktop/apps/dashboard/components/DashboardSettingsDialog.tsx rename to src/ui/dashboard/components/DashboardSettingsDialog.tsx index 84da2239..8e89f81b 100644 --- a/src/ui/desktop/apps/dashboard/components/DashboardSettingsDialog.tsx +++ b/src/ui/dashboard/components/DashboardSettingsDialog.tsx @@ -6,12 +6,19 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { Checkbox } from "@/components/ui/checkbox"; +} from "@/components/dialog"; +import { Button } from "@/components/button"; +import { Label } from "@/components/label"; +import { Checkbox } from "@/components/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/select"; import { useTranslation } from "react-i18next"; -import type { DashboardLayout } from "@/ui/main-axios"; +import type { DashboardLayout } from "@/main-axios"; interface DashboardSettingsDialogProps { open: boolean; @@ -44,6 +51,15 @@ export function DashboardSettingsDialog({ })); }; + const handleCardPanel = (cardId: string, panel: "main" | "side") => { + setLayout((prev) => ({ + ...prev, + cards: prev.cards.map((card) => + card.id === cardId ? { ...card, panel } : card, + ), + })); + }; + const handleSave = () => { onSave(layout); onOpenChange(false); @@ -96,6 +112,24 @@ export function DashboardSettingsDialog({ > {cardLabels[card.id] || card.id} +
    ))}
    diff --git a/src/ui/dashboard/hooks/useDashboardPreferences.ts b/src/ui/dashboard/hooks/useDashboardPreferences.ts new file mode 100644 index 00000000..7860e227 --- /dev/null +++ b/src/ui/dashboard/hooks/useDashboardPreferences.ts @@ -0,0 +1,138 @@ +import { useState, useEffect, useCallback } from "react"; +import { + getDashboardPreferences, + saveDashboardPreferences, + type DashboardLayout, +} from "@/main-axios"; + +const LS_KEY = "dashboardLayout"; + +const DEFAULT_LAYOUT: DashboardLayout = { + cards: [ + { id: "server_overview", enabled: true, order: 1, panel: "main" }, + { id: "quick_actions", enabled: true, order: 2, panel: "main" }, + { id: "server_stats", enabled: true, order: 3, panel: "main" }, + { id: "network_graph", enabled: false, order: 4, panel: "main" }, + { id: "recent_activity", enabled: true, order: 1, panel: "side" }, + ], + mainWidthPct: 68, +}; + +function migrateLayout(preferences: DashboardLayout): DashboardLayout { + const needsMigration = preferences.cards.some((c) => !c.panel); + if (!needsMigration) return preferences; + const defaultCardMap = new Map(DEFAULT_LAYOUT.cards.map((c) => [c.id, c])); + return { + ...preferences, + mainWidthPct: preferences.mainWidthPct ?? DEFAULT_LAYOUT.mainWidthPct, + cards: preferences.cards.map((c) => ({ + ...c, + panel: c.panel ?? defaultCardMap.get(c.id)?.panel ?? "main", + })), + }; +} + +function readFromLocalStorage(): DashboardLayout | null { + try { + const raw = localStorage.getItem(LS_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (parsed?.cards && Array.isArray(parsed.cards)) { + return migrateLayout(parsed); + } + } catch { + // ignore + } + return null; +} + +function writeToLocalStorage(layout: DashboardLayout) { + try { + localStorage.setItem(LS_KEY, JSON.stringify(layout)); + } catch { + // ignore + } +} + +export function useDashboardPreferences(enabled: boolean = true) { + const [layout, setLayout] = useState(null); + const [loading, setLoading] = useState(true); + const [saveTimeout, setSaveTimeout] = useState(null); + + useEffect(() => { + if (!enabled) { + setLayout(DEFAULT_LAYOUT); + setLoading(false); + return; + } + + // Show cached layout immediately so the UI doesn't wait for the network + const cached = readFromLocalStorage(); + if (cached) { + setLayout(cached); + setLoading(false); + } + + const fetchPreferences = async () => { + try { + const preferences = await getDashboardPreferences(); + if (preferences?.cards && Array.isArray(preferences.cards)) { + const migrated = migrateLayout(preferences); + setLayout(migrated); + writeToLocalStorage(migrated); + } else { + if (!cached) { + setLayout(DEFAULT_LAYOUT); + } + } + } catch { + if (!cached) { + setLayout(DEFAULT_LAYOUT); + } + } finally { + setLoading(false); + } + }; + + fetchPreferences(); + }, [enabled]); + + const updateLayout = useCallback( + (newLayout: DashboardLayout) => { + setLayout(newLayout); + writeToLocalStorage(newLayout); + + if (saveTimeout) { + clearTimeout(saveTimeout); + } + + const timeout = setTimeout(async () => { + try { + await saveDashboardPreferences(newLayout); + } catch (error) { + console.error("Failed to save dashboard preferences:", error); + } + }, 1000); + + setSaveTimeout(timeout); + }, + [saveTimeout], + ); + + const resetLayout = useCallback(async () => { + setLayout(DEFAULT_LAYOUT); + writeToLocalStorage(DEFAULT_LAYOUT); + try { + await saveDashboardPreferences(DEFAULT_LAYOUT); + } catch (error) { + console.error("Failed to reset dashboard preferences:", error); + } + }, []); + + return { + layout, + loading, + updateLayout, + resetLayout, + }; +} diff --git a/src/ui/desktop/apps/dashboard/apps/UpdateLog.tsx b/src/ui/dashboard/panels/UpdateLog.tsx similarity index 97% rename from src/ui/desktop/apps/dashboard/apps/UpdateLog.tsx rename to src/ui/dashboard/panels/UpdateLog.tsx index ff54d502..9531449f 100644 --- a/src/ui/desktop/apps/dashboard/apps/UpdateLog.tsx +++ b/src/ui/dashboard/panels/UpdateLog.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useState } from "react"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx"; -import { Button } from "@/components/ui/button.tsx"; -import { Sheet, SheetContent } from "@/components/ui/sheet.tsx"; -import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts"; +import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx"; +import { Button } from "@/components/button.tsx"; +import { Sheet, SheetContent } from "@/components/sheet.tsx"; +import { getReleasesRSS, getVersionInfo } from "@/main-axios.ts"; import { useTranslation } from "react-i18next"; import { X } from "lucide-react"; diff --git a/src/ui/desktop/apps/dashboard/apps/alerts/AlertCard.tsx b/src/ui/dashboard/panels/alerts/AlertCard.tsx similarity index 94% rename from src/ui/desktop/apps/dashboard/apps/alerts/AlertCard.tsx rename to src/ui/dashboard/panels/alerts/AlertCard.tsx index eaaa3053..31faf754 100644 --- a/src/ui/desktop/apps/dashboard/apps/alerts/AlertCard.tsx +++ b/src/ui/dashboard/panels/alerts/AlertCard.tsx @@ -5,9 +5,9 @@ import { CardFooter, CardHeader, CardTitle, -} from "@/components/ui/card.tsx"; -import { Button } from "@/components/ui/button.tsx"; -import { Badge } from "@/components/ui/badge.tsx"; +} from "@/components/card.tsx"; +import { Button } from "@/components/button.tsx"; +import { Badge } from "@/components/badge.tsx"; import { X, ExternalLink, @@ -17,7 +17,7 @@ import { AlertCircle, } from "lucide-react"; import { useTranslation } from "react-i18next"; -import type { TermixAlert } from "../../../../../../types"; +import type { TermixAlert } from "@/types"; interface AlertCardProps { alert: TermixAlert; diff --git a/src/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx b/src/ui/dashboard/panels/alerts/AlertManager.tsx similarity index 96% rename from src/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx rename to src/ui/dashboard/panels/alerts/AlertManager.tsx index 52b06104..0d15c18c 100644 --- a/src/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx +++ b/src/ui/dashboard/panels/alerts/AlertManager.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useState } from "react"; import { AlertCard } from "./AlertCard.tsx"; -import { Button } from "@/components/ui/button.tsx"; -import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts"; +import { Button } from "@/components/button.tsx"; +import { getUserAlerts, dismissAlert } from "@/main-axios.ts"; import { useTranslation } from "react-i18next"; -import type { TermixAlert } from "../../../../../../types"; +import type { TermixAlert } from "@/types"; import { toast } from "sonner"; interface AlertManagerProps { diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx deleted file mode 100644 index 9ab6bca6..00000000 --- a/src/ui/desktop/DesktopApp.tsx +++ /dev/null @@ -1,810 +0,0 @@ -import React, { - useCallback, - Component, - Suspense, - lazy, - type ReactNode, - useEffect, - useRef, - useState, -} from "react"; -import { LeftSidebar } from "@/ui/desktop/navigation/LeftSidebar.tsx"; -import { AppView } from "@/ui/desktop/navigation/AppView.tsx"; -import { - TabProvider, - useTabs, -} from "@/ui/desktop/navigation/tabs/TabContext.tsx"; -import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx"; -import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx"; -import { ServerStatusProvider } from "@/ui/contexts/ServerStatusContext"; -import { Toaster } from "@/components/ui/sonner.tsx"; -import { toast } from "sonner"; -import { - getUserInfo, - logoutUser, - isCurrentAuthInvalidationError, -} from "@/ui/main-axios.ts"; -import { useTheme } from "@/components/theme-provider"; -import { dbHealthMonitor } from "@/lib/db-health-monitor.ts"; -import { useTranslation } from "react-i18next"; -import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; - -const Dashboard = lazy(() => - import("@/ui/desktop/apps/dashboard/Dashboard.tsx").then((module) => ({ - default: module.Dashboard, - })), -); -const HostManager = lazy(() => - import("@/ui/desktop/apps/host-manager/hosts/HostManager.tsx").then( - (module) => ({ - default: module.HostManager, - }), - ), -); -const AdminSettings = lazy(() => - import("@/ui/desktop/apps/admin/AdminSettings.tsx").then((module) => ({ - default: module.AdminSettings, - })), -); -const UserProfile = lazy(() => - import("@/ui/desktop/user/UserProfile.tsx").then((module) => ({ - default: module.UserProfile, - })), -); -const CommandPalette = lazy(() => - import("@/ui/desktop/apps/command-palette/CommandPalette.tsx").then( - (module) => ({ - default: module.CommandPalette, - }), - ), -); - -function AppContent({ - onAuthStateChange, -}: { - onAuthStateChange?: (isAuthenticated: boolean) => void; -}) { - const { t } = useTranslation(); - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [username, setUsername] = useState(null); - const [isAdmin, setIsAdmin] = useState(false); - const [authLoading, setAuthLoading] = useState(true); - const [isTopbarOpen, setIsTopbarOpen] = useState(() => { - const saved = localStorage.getItem("topNavbarOpen"); - return saved !== null ? JSON.parse(saved) : true; - }); - const [isTransitioning, setIsTransitioning] = useState(false); - const [transitionPhase, setTransitionPhase] = useState< - "idle" | "fadeOut" | "fadeIn" - >("idle"); - const { currentTab, tabs, updateTab, addTab } = useTabs(); - const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); - const { theme, setTheme } = useTheme(); - const [rightSidebarOpen, setRightSidebarOpen] = useState(false); - const [rightSidebarWidth, setRightSidebarWidth] = useState(400); - const isAuthenticatedRef = useRef(false); - - const isDarkMode = - theme === "dark" || - theme === "dracula" || - theme === "gentlemansChoice" || - theme === "midnightEspresso" || - theme === "catppuccinMocha" || - (theme === "system" && - window.matchMedia("(prefers-color-scheme: dark)").matches); - const lineColor = isDarkMode ? "#151517" : "#f9f9f9"; - - const lastShiftPressTime = useRef(0); - - const lastAltPressTime = useRef(0); - - useEffect(() => { - const DEGRADED_TOAST_ID = "db-connection-degraded"; - - const handleDatabaseConnectionDegraded = () => { - // Non-blocking, non-dismissible status toast that stays visible until - // connectivity is recovered. A Reload action lets users force-refresh - // the page if they want to, but the app itself remains fully usable. - toast.loading( - t("common.connectionDegraded", "Server connection lost, recovering…"), - { - id: DEGRADED_TOAST_ID, - duration: Infinity, - dismissible: false, - closeButton: false, - action: { - label: t("common.reload", "Reload"), - onClick: () => window.location.reload(), - }, - }, - ); - }; - - const handleDatabaseConnectionDegradedCleared = () => { - toast.dismiss(DEGRADED_TOAST_ID); - toast.success(t("common.backendReconnected")); - }; - - const handleSessionExpired = () => { - setIsAuthenticated(false); - setIsAdmin(false); - setUsername(null); - }; - - dbHealthMonitor.on( - "database-connection-degraded", - handleDatabaseConnectionDegraded, - ); - dbHealthMonitor.on( - "database-connection-degraded-cleared", - handleDatabaseConnectionDegradedCleared, - ); - dbHealthMonitor.on("session-expired", handleSessionExpired); - - return () => { - dbHealthMonitor.off( - "database-connection-degraded", - handleDatabaseConnectionDegraded, - ); - dbHealthMonitor.off( - "database-connection-degraded-cleared", - handleDatabaseConnectionDegradedCleared, - ); - dbHealthMonitor.off("session-expired", handleSessionExpired); - toast.dismiss(DEGRADED_TOAST_ID); - }; - }, [t]); - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.code === "ShiftLeft") { - if (event.repeat) { - return; - } - const shortcutEnabled = - localStorage.getItem("commandPaletteShortcutEnabled") !== "false"; - if (!shortcutEnabled) { - return; - } - const now = Date.now(); - if (now - lastShiftPressTime.current < 300) { - setIsCommandPaletteOpen((isOpen) => !isOpen); - lastShiftPressTime.current = 0; - } else { - lastShiftPressTime.current = now; - } - } - - if (event.code === "AltLeft" && !event.repeat) { - const now = Date.now(); - if (now - lastAltPressTime.current < 300) { - const currentIsDark = - theme === "dark" || - (theme === "system" && - window.matchMedia("(prefers-color-scheme: dark)").matches); - const newTheme = currentIsDark ? "light" : "dark"; - setTheme(newTheme); - lastAltPressTime.current = 0; - } else { - lastAltPressTime.current = now; - } - } - - if (event.key === "Escape") { - setIsCommandPaletteOpen(false); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [theme, setTheme]); - - useEffect(() => { - const path = window.location.pathname; - const terminalMatch = path.match(/^\/terminal\/([a-zA-Z0-9_-]+)$/); - const legacyMatch = path.match(/^\/hosts\/([a-zA-Z0-9_-]+)\/terminal$/); - const hostIdentifier = terminalMatch?.[1] || legacyMatch?.[1]; - - if (hostIdentifier) { - const openTerminal = async () => { - try { - const { getSSHHostById, getSSHHosts } = - await import("@/ui/main-axios.ts"); - let host = null; - - if (/^\d+$/.test(hostIdentifier)) { - host = await getSSHHostById(parseInt(hostIdentifier, 10)); - } else { - const hosts = await getSSHHosts(); - host = - hosts.find((h: { name?: string }) => h.name === hostIdentifier) || - null; - } - - if (host) { - addTab({ - type: "terminal", - title: host.name || host.ip, - data: { host, initialCommand: "" }, - }); - window.history.replaceState({}, "", "/"); - } else { - toast.error(`Host "${hostIdentifier}" not found`); - } - } catch (error) { - console.error("Failed to open terminal:", error); - toast.error("Failed to open terminal for host"); - } - }; - openTerminal(); - } - }, [addTab]); - - const isCheckingAuth = useRef(false); - const clientTunnelAutoStartStarted = useRef(false); - - const startClientTunnelAutoStart = useCallback(() => { - if ( - clientTunnelAutoStartStarted.current || - !window.electronAPI?.isElectron - ) { - return; - } - - clientTunnelAutoStartStarted.current = true; - window.electronAPI.startC2SAutoStartTunnels?.().catch((error) => { - clientTunnelAutoStartStarted.current = false; - console.error("Failed to start client tunnel auto-start entries:", error); - }); - }, []); - - useEffect(() => { - const checkAuth = () => { - if (isCheckingAuth.current) return; - isCheckingAuth.current = true; - setAuthLoading(true); - getUserInfo() - .then((meRes) => { - if (typeof meRes === "string" || !meRes.username) { - setIsAuthenticated(false); - setIsAdmin(false); - setUsername(null); - } else { - setIsAuthenticated(true); - setIsAdmin(!!meRes.is_admin); - setUsername(meRes.username || null); - startClientTunnelAutoStart(); - } - }) - .catch((err) => { - if (isCurrentAuthInvalidationError(err)) { - setIsAuthenticated(false); - setIsAdmin(false); - setUsername(null); - console.warn("Session expired - please log in again"); - return; - } - - if (!isAuthenticatedRef.current) { - setIsAuthenticated(false); - setIsAdmin(false); - setUsername(null); - } - }) - .finally(() => { - setAuthLoading(false); - isCheckingAuth.current = false; - }); - }; - - checkAuth(); - - const handleStorageChange = () => checkAuth(); - window.addEventListener("storage", handleStorageChange); - - return () => window.removeEventListener("storage", handleStorageChange); - }, [startClientTunnelAutoStart]); - - useEffect(() => { - localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen)); - }, [isTopbarOpen]); - - useEffect(() => { - onAuthStateChange?.(isAuthenticated); - isAuthenticatedRef.current = isAuthenticated; - }, [isAuthenticated, onAuthStateChange]); - - const handleAuthSuccess = useCallback( - (authData: { - isAdmin: boolean; - username: string | null; - userId: string | null; - }) => { - setIsTransitioning(true); - setTransitionPhase("fadeOut"); - - setTimeout(() => { - setIsAuthenticated(true); - setIsAdmin(authData.isAdmin); - setUsername(authData.username); - startClientTunnelAutoStart(); - setTransitionPhase("fadeIn"); - - setTimeout(() => { - setIsTransitioning(false); - setTransitionPhase("idle"); - }, 800); - }, 1200); - }, - [startClientTunnelAutoStart], - ); - - const handleLogout = useCallback(async () => { - setIsTransitioning(true); - setTransitionPhase("fadeOut"); - - setTimeout(async () => { - try { - await logoutUser(); - } catch (error) { - console.error("Logout failed:", error); - } - - window.location.reload(); - }, 1200); - }, []); - - const currentTabData = tabs.find((tab) => tab.id === currentTab); - const showTerminalView = - currentTabData?.type === "terminal" || - currentTabData?.type === "server_stats" || - currentTabData?.type === "file_manager" || - currentTabData?.type === "rdp" || - currentTabData?.type === "vnc" || - currentTabData?.type === "telnet" || - currentTabData?.type === "tunnel" || - currentTabData?.type === "docker" || - currentTabData?.type === "network_graph"; - const showHome = currentTabData?.type === "home"; - const showSshManager = currentTabData?.type === "ssh_manager"; - const showAdmin = currentTabData?.type === "admin"; - const showProfile = currentTabData?.type === "user_profile"; - - if (authLoading) { - return ( -
    -
    -
    -
    -
    -

    - {t("common.checkingAuthentication")} -

    -
    -
    -
    -
    - ); - } - - return ( -
    - - - - {!isAuthenticated && ( -
    - - - -
    - )} - - {isAuthenticated && ( - -
    - -
    - - {showHome && ( -
    - - -
    - } - > - - -
    - )} - - {showSshManager && ( -
    - - -
    - } - > - - -
    - )} - - {showAdmin && ( -
    - - -
    - } - > - - -
    - )} - - {showProfile && ( -
    - - -
    - } - > - - -
    - )} - - { - setRightSidebarOpen(isOpen); - setRightSidebarWidth(width); - }} - /> - - )} - - {isTransitioning && ( -
    - {transitionPhase === "fadeOut" && ( - <> -
    -
    -
    -
    -
    -
    -
    - TERMIX -
    -
    - SSH SERVER MANAGER -
    -
    -
    - - - )} -
    - )} - - -
    - ); -} - -class TabErrorBoundary extends Component< - { children: ReactNode }, - { hasError: boolean; errorCount: number } -> { - constructor(props: { children: ReactNode }) { - super(props); - this.state = { hasError: false, errorCount: 0 }; - } - - static getDerivedStateFromError(error: Error) { - if (error.message?.includes("useTabs must be used within a TabProvider")) { - return { hasError: true }; - } - throw error; - } - - componentDidCatch(error: Error, _errorInfo: ErrorInfo) { - if (error.message?.includes("useTabs must be used within a TabProvider")) { - console.warn( - "TabProvider mounting race condition detected, recovering...", - ); - this.setState((prev) => ({ errorCount: prev.errorCount + 1 })); - setTimeout(() => { - this.setState({ hasError: false }); - }, 0); - } - } - - render() { - if (this.state.hasError) { - return null; - } - return this.props.children; - } -} - -function DesktopApp() { - const [isAuthenticated, setIsAuthenticated] = useState(false); - - return ( - - - - - - - - - - ); -} - -export default DesktopApp; diff --git a/src/ui/desktop/apps/admin/AdminSettings.tsx b/src/ui/desktop/apps/admin/AdminSettings.tsx deleted file mode 100644 index 6ac257e7..00000000 --- a/src/ui/desktop/apps/admin/AdminSettings.tsx +++ /dev/null @@ -1,498 +0,0 @@ -import React from "react"; -import { useSidebar } from "@/components/ui/sidebar.tsx"; -import { Separator } from "@/components/ui/separator.tsx"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@/components/ui/tabs.tsx"; -import { Shield, Users, Database, Clock, Key } from "lucide-react"; -import { toast } from "sonner"; -import { useTranslation } from "react-i18next"; -import { useConfirmation } from "@/hooks/use-confirmation.ts"; -import { - getAdminOIDCConfig, - getRegistrationAllowed, - getPasswordLoginAllowed, - getPasswordResetAllowed, - getUserList, - getUserInfo, - isElectron, - getSessions, - unlinkOIDCFromPasswordAccount, -} from "@/ui/main-axios.ts"; -import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; -import { RolesTab } from "@/ui/desktop/apps/admin/tabs/RolesTab.tsx"; -import { GeneralSettingsTab } from "@/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx"; -import { OIDCSettingsTab } from "@/ui/desktop/apps/admin/tabs/OIDCSettingsTab.tsx"; -import { UserManagementTab } from "@/ui/desktop/apps/admin/tabs/UserManagementTab.tsx"; -import { SessionManagementTab } from "@/ui/desktop/apps/admin/tabs/SessionManagementTab.tsx"; -import { DatabaseSecurityTab } from "@/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx"; -import { ApiKeysTab } from "@/ui/desktop/apps/admin/tabs/ApiKeysTab.tsx"; -import { CreateUserDialog } from "./dialogs/CreateUserDialog.tsx"; -import { UserEditDialog } from "./dialogs/UserEditDialog.tsx"; -import { LinkAccountDialog } from "./dialogs/LinkAccountDialog.tsx"; - -interface AdminSettingsProps { - isTopbarOpen?: boolean; - rightSidebarOpen?: boolean; - rightSidebarWidth?: number; -} - -export function AdminSettings({ - isTopbarOpen = true, - rightSidebarOpen = false, - rightSidebarWidth = 400, -}: AdminSettingsProps): React.ReactElement { - const { t } = useTranslation(); - const { confirmWithToast } = useConfirmation(); - const { state: sidebarState } = useSidebar(); - - const [loading, setLoading] = React.useState(true); - const [allowRegistration, setAllowRegistration] = React.useState(true); - const [allowPasswordLogin, setAllowPasswordLogin] = React.useState(true); - const [allowPasswordReset, setAllowPasswordReset] = React.useState(true); - - const [oidcConfig, setOidcConfig] = React.useState({ - client_id: "", - client_secret: "", - issuer_url: "", - authorization_url: "", - token_url: "", - identifier_path: "sub", - name_path: "name", - scopes: "openid email profile", - userinfo_url: "", - allowed_users: "", - }); - - const [users, setUsers] = React.useState< - Array<{ - id: string; - username: string; - isAdmin: boolean; - isOidc: boolean; - passwordHash?: string; - }> - >([]); - const [usersLoading, setUsersLoading] = React.useState(false); - - const [createUserDialogOpen, setCreateUserDialogOpen] = React.useState(false); - const [userEditDialogOpen, setUserEditDialogOpen] = React.useState(false); - const [selectedUserForEdit, setSelectedUserForEdit] = React.useState<{ - id: string; - username: string; - isAdmin: boolean; - isOidc: boolean; - passwordHash?: string; - } | null>(null); - - const [currentUser, setCurrentUser] = React.useState<{ - id: string; - username: string; - is_admin: boolean; - is_oidc: boolean; - } | null>(null); - - const [sessions, setSessions] = React.useState< - Array<{ - id: string; - userId: string; - username?: string; - deviceType: string; - deviceInfo: string; - createdAt: string; - expiresAt: string; - lastActiveAt: string; - isRevoked?: boolean; - isCurrentSession?: boolean; - }> - >([]); - const [sessionsLoading, setSessionsLoading] = React.useState(false); - - const [linkAccountAlertOpen, setLinkAccountAlertOpen] = React.useState(false); - const [linkOidcUser, setLinkOidcUser] = React.useState<{ - id: string; - username: string; - } | null>(null); - - React.useEffect(() => { - if (isElectron()) { - const serverUrl = (window as { configuredServerUrl?: string }) - .configuredServerUrl; - if (!serverUrl) { - setLoading(false); - return; - } - } - - Promise.allSettled([ - getAdminOIDCConfig() - .then((res) => { - if (res) setOidcConfig(res); - }) - .catch((err) => { - if (!err.message?.includes("No server configured")) { - toast.error(t("admin.failedToFetchOidcConfig")); - } - }), - getUserInfo() - .then((info) => { - if (info) { - setCurrentUser({ - id: info.userId, - username: info.username, - is_admin: info.is_admin, - is_oidc: info.is_oidc, - }); - } - }) - .catch((err) => { - if (!err?.message?.includes("No server configured")) { - console.warn("Failed to fetch current user info", err); - } - }), - getSessions() - .then((data) => setSessions(data.sessions || [])) - .catch((err) => { - if (!err?.message?.includes("No server configured")) { - toast.error(t("admin.failedToFetchSessions")); - } - }), - ]).finally(() => setLoading(false)); - }, []); - - React.useEffect(() => { - if (isElectron()) { - const serverUrl = (window as { configuredServerUrl?: string }) - .configuredServerUrl; - if (!serverUrl) { - return; - } - } - - getRegistrationAllowed() - .then((res) => { - if (typeof res?.allowed === "boolean") { - setAllowRegistration(res.allowed); - } - }) - .catch((err) => { - if (!err.message?.includes("No server configured")) { - toast.error(t("admin.failedToFetchRegistrationStatus")); - } - }); - }, []); - - React.useEffect(() => { - if (isElectron()) { - const serverUrl = (window as { configuredServerUrl?: string }) - .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")); - } - }); - }, []); - - React.useEffect(() => { - if (isElectron()) { - const serverUrl = (window as { configuredServerUrl?: string }) - .configuredServerUrl; - if (!serverUrl) { - return; - } - } - - getPasswordResetAllowed() - .then((res) => { - if (typeof res === "boolean") { - setAllowPasswordReset(res); - } - }) - .catch((err) => { - if (err.code !== "NO_SERVER_CONFIGURED") { - console.warn("Failed to fetch password reset status", err); - } - }); - }, []); - - const fetchUsers = async () => { - if (isElectron()) { - const serverUrl = (window as { configuredServerUrl?: string }) - .configuredServerUrl; - if (!serverUrl) { - return; - } - } - - setUsersLoading(true); - try { - const response = await getUserList(); - setUsers(response.users); - } catch (err) { - if (!err.message?.includes("No server configured")) { - toast.error(t("admin.failedToFetchUsers")); - } - } finally { - setUsersLoading(false); - } - }; - - const handleEditUser = (user: (typeof users)[0]) => { - setSelectedUserForEdit(user); - setUserEditDialogOpen(true); - }; - - const handleCreateUserSuccess = () => { - fetchUsers(); - setCreateUserDialogOpen(false); - }; - - const handleEditUserSuccess = () => { - fetchUsers(); - setUserEditDialogOpen(false); - setSelectedUserForEdit(null); - }; - - const fetchSessions = async () => { - if (isElectron()) { - const serverUrl = (window as { configuredServerUrl?: string }) - .configuredServerUrl; - if (!serverUrl) { - return; - } - } - - setSessionsLoading(true); - try { - const data = await getSessions(); - setSessions(data.sessions || []); - } catch (err) { - if (!err?.message?.includes("No server configured")) { - toast.error(t("admin.failedToFetchSessions")); - } - } finally { - setSessionsLoading(false); - } - }; - - const handleLinkOIDCUser = (user: { id: string; username: string }) => { - setLinkOidcUser(user); - setLinkAccountAlertOpen(true); - }; - - const handleLinkSuccess = () => { - fetchUsers(); - fetchSessions(); - }; - - const handleUnlinkOIDC = async (userId: string, username: string) => { - confirmWithToast( - t("admin.unlinkOIDCDescription", { username }), - async () => { - try { - const result = await unlinkOIDCFromPasswordAccount(userId); - - toast.success( - result.message || t("admin.unlinkOIDCSuccess", { username }), - ); - fetchUsers(); - fetchSessions(); - } catch (error: unknown) { - const err = error as { - response?: { data?: { error?: string; code?: string } }; - }; - toast.error( - err.response?.data?.error || t("admin.failedToUnlinkOIDC"), - ); - } - }, - "destructive", - ); - }; - - const topMarginPx = isTopbarOpen ? 74 : 26; - const leftMarginPx = sidebarState === "collapsed" ? 26 : 8; - const bottomMarginPx = 8; - const wrapperStyle: React.CSSProperties = { - marginLeft: leftMarginPx, - marginRight: rightSidebarOpen - ? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)` - : 17, - marginTop: topMarginPx, - marginBottom: bottomMarginPx, - height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`, - transition: - "margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear", - }; - - return ( -
    - -
    -
    -

    {t("admin.title")}

    -
    - - -
    - { - if (value === "users") { - fetchUsers(); - } - }} - className="w-full" - > - - - - {t("admin.general")} - - - - OIDC - - - - {t("admin.users")} - - - - Sessions - - - - {t("rbac.roles.label")} - - - - {t("admin.databaseSecurity")} - - - - {t("admin.apiKeys.tabLabel")} - - - - - - - - - - - - - setCreateUserDialogOpen(true)} - onEditUser={handleEditUser} - onLinkOIDCUser={handleLinkOIDCUser} - onUnlinkOIDC={handleUnlinkOIDC} - /> - - - - - - - -
    - -
    -
    - - - - - - - - -
    -
    -
    - - - - - - -
    - ); -} - -export default AdminSettings; diff --git a/src/ui/desktop/apps/admin/dialogs/CreateUserDialog.tsx b/src/ui/desktop/apps/admin/dialogs/CreateUserDialog.tsx deleted file mode 100644 index 47e5cc22..00000000 --- a/src/ui/desktop/apps/admin/dialogs/CreateUserDialog.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/ui/dialog.tsx"; -import { Button } from "@/components/ui/button.tsx"; -import { Label } from "@/components/ui/label.tsx"; -import { Input } from "@/components/ui/input.tsx"; -import { PasswordInput } from "@/components/ui/password-input.tsx"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx"; -import { useTranslation } from "react-i18next"; -import { UserPlus, AlertCircle } from "lucide-react"; -import { toast } from "sonner"; -import { registerUser } from "@/ui/main-axios.ts"; - -interface CreateUserDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onSuccess: () => void; -} - -export function CreateUserDialog({ - open, - onOpenChange, - onSuccess, -}: CreateUserDialogProps) { - const { t } = useTranslation(); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (!open) { - setUsername(""); - setPassword(""); - setError(null); - } - }, [open]); - - const handleCreateUser = async (e?: React.FormEvent) => { - if (e) { - e.preventDefault(); - } - - if (!username.trim()) { - setError(t("admin.enterUsername")); - return; - } - - if (!password.trim()) { - setError(t("admin.enterPassword")); - return; - } - - if (password.length < 6) { - setError("Password must be at least 6 characters"); - return; - } - - setLoading(true); - setError(null); - - try { - await registerUser(username.trim(), password); - toast.success( - t("admin.userCreatedSuccessfully", { username: username.trim() }), - ); - setUsername(""); - setPassword(""); - onSuccess(); - onOpenChange(false); - } catch (err: unknown) { - const error = err as { response?: { data?: { error?: string } } }; - const errorMessage = - error?.response?.data?.error || t("admin.failedToCreateUser"); - setError(errorMessage); - toast.error(errorMessage); - } finally { - setLoading(false); - } - }; - - return ( - { - if (!loading) { - onOpenChange(newOpen); - } - }} - > - - - - - {t("admin.createUser")} - - - {t("admin.createUserDescription")} - - - -
    -
    - - { - setUsername(e.target.value); - setError(null); - }} - placeholder={t("admin.enterUsername")} - disabled={loading} - autoFocus - /> -
    - -
    - - { - setPassword(e.target.value); - setError(null); - }} - placeholder={t("admin.enterPassword")} - disabled={loading} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleCreateUser(); - } - }} - /> -

    - {t("admin.passwordMinLength")} -

    -
    - - {error && ( - - - {t("common.error")} - {error} - - )} -
    - - - - -
    -
    - ); -} diff --git a/src/ui/desktop/apps/admin/dialogs/LinkAccountDialog.tsx b/src/ui/desktop/apps/admin/dialogs/LinkAccountDialog.tsx deleted file mode 100644 index b99b0950..00000000 --- a/src/ui/desktop/apps/admin/dialogs/LinkAccountDialog.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog.tsx"; -import { Button } from "@/components/ui/button.tsx"; -import { Label } from "@/components/ui/label.tsx"; -import { Input } from "@/components/ui/input.tsx"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx"; -import { useTranslation } from "react-i18next"; -import { Link2 } from "lucide-react"; -import { toast } from "sonner"; -import { linkOIDCToPasswordAccount } from "@/ui/main-axios.ts"; - -interface LinkAccountDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - oidcUser: { id: string; username: string } | null; - onSuccess: () => void; -} - -export function LinkAccountDialog({ - open, - onOpenChange, - oidcUser, - onSuccess, -}: LinkAccountDialogProps) { - const { t } = useTranslation(); - const [linkTargetUsername, setLinkTargetUsername] = useState(""); - const [linkLoading, setLinkLoading] = useState(false); - - useEffect(() => { - if (!open) { - setLinkTargetUsername(""); - } - }, [open]); - - const handleLinkSubmit = async () => { - if (!oidcUser || !linkTargetUsername.trim()) { - toast.error("Target username is required"); - return; - } - - setLinkLoading(true); - try { - const result = await linkOIDCToPasswordAccount( - oidcUser.id, - linkTargetUsername.trim(), - ); - - toast.success( - result.message || - `OIDC user ${oidcUser.username} linked to ${linkTargetUsername}`, - ); - setLinkTargetUsername(""); - onSuccess(); - onOpenChange(false); - } catch (error: unknown) { - const err = error as { - response?: { data?: { error?: string; code?: string } }; - }; - toast.error(err.response?.data?.error || "Failed to link accounts"); - } finally { - setLinkLoading(false); - } - }; - - return ( - - - - - - {t("admin.linkOidcToPasswordAccount")} - - - {t("admin.linkOidcToPasswordAccountDescription", { - username: oidcUser?.username, - })} - - - -
    - - {t("admin.linkOidcWarningTitle")} - - {t("admin.linkOidcWarningDescription")} -
      -
    • {t("admin.linkOidcActionDeleteUser")}
    • -
    • {t("admin.linkOidcActionAddCapability")}
    • -
    • {t("admin.linkOidcActionDualAuth")}
    • -
    -
    -
    - -
    - - setLinkTargetUsername(e.target.value)} - placeholder={t("admin.linkTargetUsernamePlaceholder")} - disabled={linkLoading} - onKeyDown={(e) => { - if (e.key === "Enter" && linkTargetUsername.trim()) { - handleLinkSubmit(); - } - }} - /> -
    -
    - - - - - -
    -
    - ); -} diff --git a/src/ui/desktop/apps/admin/dialogs/UserEditDialog.tsx b/src/ui/desktop/apps/admin/dialogs/UserEditDialog.tsx deleted file mode 100644 index b8b41589..00000000 --- a/src/ui/desktop/apps/admin/dialogs/UserEditDialog.tsx +++ /dev/null @@ -1,536 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog.tsx"; -import { Button } from "@/components/ui/button.tsx"; -import { Label } from "@/components/ui/label.tsx"; -import { Badge } from "@/components/ui/badge.tsx"; -import { Switch } from "@/components/ui/switch.tsx"; -import { Separator } from "@/components/ui/separator.tsx"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx"; -import { useTranslation } from "react-i18next"; -import { - UserCog, - Trash2, - Plus, - AlertCircle, - Shield, - Clock, -} from "lucide-react"; -import { toast } from "sonner"; -import { useConfirmation } from "@/hooks/use-confirmation.ts"; -import { - getUserRoles, - getRoles, - assignRoleToUser, - removeRoleFromUser, - makeUserAdmin, - removeAdminStatus, - revokeAllUserSessions, - deleteUser, - type UserRole, - type Role, -} from "@/ui/main-axios.ts"; - -interface User { - id: string; - username: string; - isAdmin: boolean; - isOidc: boolean; - passwordHash?: string; -} - -interface UserEditDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - user: User | null; - currentUser: { id: string; username: string } | null; - onSuccess: () => void; -} - -export function UserEditDialog({ - open, - onOpenChange, - user, - currentUser, - onSuccess, -}: UserEditDialogProps) { - const { t } = useTranslation(); - const { confirmWithToast } = useConfirmation(); - - const [adminLoading, setAdminLoading] = useState(false); - const [sessionLoading, setSessionLoading] = useState(false); - const [deleteLoading, setDeleteLoading] = useState(false); - const [rolesLoading, setRolesLoading] = useState(false); - - const [userRoles, setUserRoles] = useState([]); - const [availableRoles, setAvailableRoles] = useState([]); - const [isAdmin, setIsAdmin] = useState(false); - - const isCurrentUser = user?.id === currentUser?.id; - - useEffect(() => { - if (open && user) { - setIsAdmin(user.isAdmin); - loadRoles(); - } - }, [open, user]); - - const loadRoles = async () => { - if (!user) return; - - setRolesLoading(true); - try { - const [rolesResponse, allRolesResponse] = await Promise.all([ - getUserRoles(user.id), - getRoles(), - ]); - - setUserRoles(rolesResponse.roles || []); - setAvailableRoles(allRolesResponse.roles || []); - } catch (error) { - console.error("Failed to load roles:", error); - toast.error(t("rbac.failedToLoadRoles")); - } finally { - setRolesLoading(false); - } - }; - - const handleToggleAdmin = async (checked: boolean) => { - if (!user) return; - - if (isCurrentUser) { - toast.error(t("admin.cannotRemoveOwnAdmin")); - return; - } - - const userToUpdate = user; - onOpenChange(false); - - const confirmed = await confirmWithToast({ - title: checked ? t("admin.makeUserAdmin") : t("admin.removeAdmin"), - description: checked - ? t("admin.confirmMakeAdmin", { username: userToUpdate.username }) - : t("admin.confirmRemoveAdmin", { username: userToUpdate.username }), - confirmText: checked ? t("admin.makeAdmin") : t("admin.removeAdmin"), - cancelText: t("common.cancel"), - variant: checked ? "default" : "destructive", - }); - - if (!confirmed) { - onOpenChange(true); - return; - } - - setAdminLoading(true); - try { - if (checked) { - await makeUserAdmin(userToUpdate.id); - toast.success( - t("admin.userIsNowAdmin", { username: userToUpdate.username }), - ); - } else { - await removeAdminStatus(userToUpdate.id); - toast.success( - t("admin.adminStatusRemoved", { username: userToUpdate.username }), - ); - } - setIsAdmin(checked); - onSuccess(); - } catch (error) { - console.error("Failed to toggle admin status:", error); - toast.error( - checked - ? t("admin.failedToMakeUserAdmin") - : t("admin.failedToRemoveAdminStatus"), - ); - onOpenChange(true); - } finally { - setAdminLoading(false); - } - }; - - const handleAssignRole = async (roleId: number) => { - if (!user) return; - - try { - await assignRoleToUser(user.id, roleId); - toast.success( - t("rbac.roleAssignedSuccessfully", { username: user.username }), - ); - await loadRoles(); - } catch (error) { - console.error("Failed to assign role:", error); - toast.error(t("rbac.failedToAssignRole")); - } - }; - - const handleRemoveRole = async (roleId: number) => { - if (!user) return; - - const userToUpdate = user; - onOpenChange(false); - - const confirmed = await confirmWithToast({ - title: t("rbac.confirmRemoveRole"), - description: t("rbac.confirmRemoveRoleDescription"), - confirmText: t("common.remove"), - cancelText: t("common.cancel"), - variant: "destructive", - }); - - if (!confirmed) { - onOpenChange(true); - return; - } - - try { - await removeRoleFromUser(userToUpdate.id, roleId); - toast.success( - t("rbac.roleRemovedSuccessfully", { username: userToUpdate.username }), - ); - await loadRoles(); - onOpenChange(true); - } catch (error) { - console.error("Failed to remove role:", error); - toast.error(t("rbac.failedToRemoveRole")); - onOpenChange(true); - } - }; - - const handleRevokeAllSessions = async () => { - if (!user) return; - - const isRevokingSelf = isCurrentUser; - - const userToUpdate = user; - onOpenChange(false); - - const confirmed = await confirmWithToast({ - title: t("admin.revokeAllSessions"), - description: isRevokingSelf - ? t("admin.confirmRevokeOwnSessions") - : t("admin.confirmRevokeAllSessions"), - confirmText: t("admin.revoke"), - cancelText: t("common.cancel"), - variant: "destructive", - }); - - if (!confirmed) { - onOpenChange(true); - return; - } - - setSessionLoading(true); - try { - const data = await revokeAllUserSessions(userToUpdate.id); - toast.success(data.message || t("admin.sessionsRevokedSuccessfully")); - - if (isRevokingSelf) { - setTimeout(() => { - window.location.reload(); - }, 1000); - } else { - onSuccess(); - onOpenChange(true); - } - } catch (error) { - console.error("Failed to revoke sessions:", error); - toast.error(t("admin.failedToRevokeSessions")); - onOpenChange(true); - } finally { - setSessionLoading(false); - } - }; - - const handleDeleteUser = async () => { - if (!user) return; - - if (isCurrentUser) { - toast.error(t("admin.cannotDeleteSelf")); - return; - } - - const userToDelete = user; - onOpenChange(false); - - const confirmed = await confirmWithToast({ - title: t("admin.deleteUserTitle"), - description: t("admin.deleteUser", { username: userToDelete.username }), - confirmText: t("common.delete"), - cancelText: t("common.cancel"), - variant: "destructive", - }); - - if (!confirmed) { - onOpenChange(true); - return; - } - - setDeleteLoading(true); - try { - await deleteUser(userToDelete.username); - toast.success( - t("admin.userDeletedSuccessfully", { username: userToDelete.username }), - ); - onSuccess(); - } catch (error) { - console.error("Failed to delete user:", error); - toast.error(t("admin.failedToDeleteUser")); - onOpenChange(true); - } finally { - setDeleteLoading(false); - } - }; - - const getAuthTypeDisplay = (): string => { - if (!user) return ""; - if (user.isOidc && user.passwordHash) { - return t("admin.dualAuth"); - } else if (user.isOidc) { - return t("admin.externalOIDC"); - } else { - return t("admin.localPassword"); - } - }; - - if (!user) return null; - - return ( - - - - - - {t("admin.manageUser")}: {user.username} - - - {t("admin.manageUserDescription")} - - - -
    -
    -
    - -

    {user.username}

    -
    -
    - -

    {getAuthTypeDisplay()}

    -
    -
    - -

    - {isAdmin ? ( - {t("admin.adminBadge")} - ) : ( - t("admin.regularUser") - )} -

    -
    -
    - -

    {user.id}

    -
    -
    - - - -
    - -
    -
    -

    {t("admin.administratorRole")}

    -

    - {t("admin.administratorRoleDescription")} -

    -
    - -
    - {isCurrentUser && ( -

    - {t("admin.cannotModifyOwnAdminStatus")} -

    - )} -
    - - - -
    - - - {rolesLoading ? ( -
    - {t("common.loading")} -
    - ) : ( - <> -
    - - {userRoles.length === 0 ? ( -

    - {t("rbac.noRolesAssigned")} -

    - ) : ( -
    - {userRoles.map((role) => ( -
    -
    -

    - {t(role.roleDisplayName)} -

    -

    - {role.roleName} -

    -
    -
    - {role.isSystem && ( - - {t("rbac.systemRole")} - - )} - {!role.isSystem && ( - - )} -
    -
    - ))} -
    - )} -
    - -
    - -
    - {availableRoles - .filter( - (role) => - !role.isSystem && - !userRoles.some((ur) => ur.roleId === role.id), - ) - .map((role) => ( - - ))} - {availableRoles.filter( - (role) => - !role.isSystem && - !userRoles.some((ur) => ur.roleId === role.id), - ).length === 0 && ( -

    - {t("rbac.noCustomRolesToAssign")} -

    - )} -
    -
    - - )} -
    - - - -
    - -
    -
    -

    - {t("admin.revokeAllSessions")} -

    -

    - {t("admin.revokeAllSessionsDescription")} -

    -
    - -
    -
    - - - -
    - - - - {t("admin.deleteUserTitle")} - - {t("admin.deleteUserWarning")} - - - - {isCurrentUser && ( -

    - {t("admin.cannotDeleteSelf")} -

    - )} -
    -
    -
    -
    - ); -} diff --git a/src/ui/desktop/apps/admin/tabs/ApiKeysTab.tsx b/src/ui/desktop/apps/admin/tabs/ApiKeysTab.tsx deleted file mode 100644 index 8e5a4a87..00000000 --- a/src/ui/desktop/apps/admin/tabs/ApiKeysTab.tsx +++ /dev/null @@ -1,507 +0,0 @@ -import React from "react"; -import { Button } from "@/components/ui/button.tsx"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table.tsx"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog.tsx"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover.tsx"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command.tsx"; -import { Input } from "@/components/ui/input.tsx"; -import { Label } from "@/components/ui/label.tsx"; -import { Badge } from "@/components/ui/badge.tsx"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx"; -import { - Key, - Plus, - Trash2, - Copy, - Check, - ChevronsUpDown, - AlertCircle, - RefreshCw, -} from "lucide-react"; -import { cn } from "@/lib/utils.ts"; -import { toast } from "sonner"; -import { useTranslation } from "react-i18next"; -import { useConfirmation } from "@/hooks/use-confirmation.ts"; -import { - getApiKeys, - createApiKey, - deleteApiKey, - getUserList, - type ApiKey, - type CreatedApiKey, -} from "@/ui/main-axios.ts"; - -interface UserOption { - id: string; - username: string; -} - -function UserCombobox({ - users, - value, - onChange, - disabled, -}: { - users: UserOption[]; - value: string; - onChange: (id: string) => void; - disabled?: boolean; -}) { - const { t } = useTranslation(); - const [open, setOpen] = React.useState(false); - const selected = users.find((u) => u.id === value); - - return ( - - - - - - - - - {t("admin.apiKeys.noUsersFound")} - - {users.map((user) => ( - { - onChange(user.id); - setOpen(false); - }} - > - - {user.username} - - ))} - - - - - - ); -} - -function CreateApiKeyDialog({ - open, - onOpenChange, - onCreated, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - onCreated: () => void; -}) { - const { t } = useTranslation(); - const [name, setName] = React.useState(""); - const [selectedUserId, setSelectedUserId] = React.useState(""); - const [expiresAt, setExpiresAt] = React.useState(""); - const [loading, setLoading] = React.useState(false); - const [usersLoading, setUsersLoading] = React.useState(false); - const [users, setUsers] = React.useState([]); - const [createdKey, setCreatedKey] = React.useState( - null, - ); - const [copied, setCopied] = React.useState(false); - - React.useEffect(() => { - if (!open) return; - if (createdKey) return; - - setUsersLoading(true); - getUserList() - .then((res) => - setUsers( - res.users.map((u) => ({ - id: (u as unknown as { id: string }).id, - username: u.username, - })), - ), - ) - .catch(() => toast.error(t("admin.failedToFetchUsers"))) - .finally(() => setUsersLoading(false)); - }, [open]); - - const handleClose = () => { - setCreatedKey(null); - setName(""); - setSelectedUserId(""); - setExpiresAt(""); - setCopied(false); - onOpenChange(false); - onCreated(); - }; - - const handleCreate = async () => { - if (!name.trim()) { - toast.error(t("admin.apiKeys.nameRequired")); - return; - } - if (!selectedUserId) { - toast.error(t("admin.apiKeys.userRequired")); - return; - } - - setLoading(true); - try { - const result = await createApiKey( - name.trim(), - selectedUserId, - expiresAt || undefined, - ); - setCreatedKey(result); - } catch (err: unknown) { - const e = err as { response?: { data?: { error?: string } } }; - toast.error( - e?.response?.data?.error || t("admin.apiKeys.failedToCreate"), - ); - } finally { - setLoading(false); - } - }; - - const handleCopy = async () => { - if (!createdKey) return; - await navigator.clipboard.writeText(createdKey.token); - setCopied(true); - toast.success(t("admin.apiKeys.tokenCopied")); - setTimeout(() => setCopied(false), 2000); - }; - - return ( - { - if (!isOpen) handleClose(); - }} - > - - - - - {createdKey - ? t("admin.apiKeys.keyCreated") - : t("admin.apiKeys.createApiKey")} - - - {createdKey - ? t("admin.apiKeys.keyCreatedDescription") - : t("admin.apiKeys.createApiKeyDescription")} - - - - {!createdKey ? ( -
    -
    - - setName(e.target.value)} - placeholder={t("admin.apiKeys.keyNamePlaceholder")} - disabled={loading} - autoFocus - /> -
    - -
    - - {usersLoading ? ( -

    - {t("admin.loading")} -

    - ) : ( - - )} -
    - -
    - - setExpiresAt(e.target.value)} - disabled={loading} - min={new Date().toISOString().split("T")[0]} - /> -

    - {t("admin.apiKeys.expiresAtHelp")} -

    -
    -
    - ) : ( -
    - - - {t("admin.apiKeys.copyWarningTitle")} - - {t("admin.apiKeys.copyWarningDescription")} - - -
    - -
    - - {createdKey.token} - - -
    -
    -
    - )} - - - {!createdKey ? ( - <> - - - - ) : ( - - )} - -
    -
    - ); -} - -export function ApiKeysTab(): React.ReactElement { - const { t } = useTranslation(); - const { confirmWithToast } = useConfirmation(); - const [keys, setKeys] = React.useState([]); - const [loading, setLoading] = React.useState(false); - const [createDialogOpen, setCreateDialogOpen] = React.useState(false); - - const fetchKeys = React.useCallback(async () => { - setLoading(true); - try { - const data = await getApiKeys(); - setKeys(data.apiKeys); - } catch { - toast.error(t("admin.apiKeys.failedToFetch")); - } finally { - setLoading(false); - } - }, [t]); - - React.useEffect(() => { - fetchKeys(); - }, [fetchKeys]); - - const handleDelete = (keyId: string, keyName: string) => { - confirmWithToast( - t("admin.apiKeys.confirmRevoke", { name: keyName }), - async () => { - try { - await deleteApiKey(keyId); - toast.success(t("admin.apiKeys.revokedSuccessfully")); - fetchKeys(); - } catch { - toast.error(t("admin.apiKeys.failedToRevoke")); - } - }, - "destructive", - ); - }; - - const formatDate = (iso: string | null) => { - if (!iso) return t("admin.apiKeys.never"); - const d = new Date(iso); - return ( - d.toLocaleDateString() + - " " + - d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) - ); - }; - - const isExpired = (expiresAt: string | null) => - expiresAt ? new Date(expiresAt) < new Date() : false; - - return ( -
    -
    -

    {t("admin.apiKeys.title")}

    -
    - - - -
    -
    - - {loading && keys.length === 0 ? ( -
    - {t("admin.loading")} -
    - ) : keys.length === 0 ? ( -
    - {t("admin.apiKeys.noKeys")} -
    - ) : ( - - - - {t("admin.apiKeys.name")} - {t("admin.user")} - {t("admin.apiKeys.prefix")} - {t("admin.created")} - {t("admin.expires")} - {t("admin.apiKeys.lastUsed")} - {t("admin.actions")} - - - - {keys.map((key) => ( - - - {key.name} - {!key.isActive && ( - - {t("admin.revoked")} - - )} - - - {key.username || key.userId} - - - - {key.tokenPrefix}… - - - - {formatDate(key.createdAt)} - - - - {formatDate(key.expiresAt)} - - - - {formatDate(key.lastUsedAt)} - - - - - - ))} - -
    - )} - - -
    - ); -} diff --git a/src/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx b/src/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx deleted file mode 100644 index 18bfe9f3..00000000 --- a/src/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import React from "react"; -import { Button } from "@/components/ui/button.tsx"; -import { Download, Upload } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; -import { isElectron } from "@/ui/main-axios.ts"; -import { getBasePath } from "@/lib/base-path"; - -export function DatabaseSecurityTab(): React.ReactElement { - const { t } = useTranslation(); - - const [exportLoading, setExportLoading] = React.useState(false); - const [importLoading, setImportLoading] = React.useState(false); - const [importFile, setImportFile] = React.useState(null); - - const handleExportDatabase = async () => { - setExportLoading(true); - try { - const isDev = - !isElectron() && - process.env.NODE_ENV === "development" && - (window.location.port === "3000" || - window.location.port === "5173" || - window.location.port === "" || - window.location.hostname === "localhost" || - window.location.hostname === "127.0.0.1"); - - const apiUrl = isElectron() - ? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/database/export` - : isDev - ? `http://localhost:30001/database/export` - : `${window.location.protocol}//${window.location.host}${getBasePath()}/database/export`; - - const headers: Record = { - "Content-Type": "application/json", - }; - - const response = await fetch(apiUrl, { - method: "POST", - headers, - credentials: "include", - body: JSON.stringify({}), - }); - - if (response.ok) { - const blob = await response.blob(); - const contentDisposition = response.headers.get("content-disposition"); - const filename = - contentDisposition?.match(/filename="([^"]+)"/)?.[1] || - "termix-export.sqlite"; - - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - - toast.success(t("admin.databaseExportedSuccessfully")); - } else { - const error = await response.json(); - toast.error(error.error || t("admin.databaseExportFailed")); - } - } catch { - toast.error(t("admin.databaseExportFailed")); - } finally { - setExportLoading(false); - } - }; - - const handleImportDatabase = async () => { - if (!importFile) { - toast.error(t("admin.pleaseSelectImportFile")); - return; - } - - setImportLoading(true); - try { - const isDev = - !isElectron() && - process.env.NODE_ENV === "development" && - (window.location.port === "3000" || - window.location.port === "5173" || - window.location.port === "" || - window.location.hostname === "localhost" || - window.location.hostname === "127.0.0.1"); - - const apiUrl = isElectron() - ? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/database/import` - : isDev - ? `http://localhost:30001/database/import` - : `${window.location.protocol}//${window.location.host}${getBasePath()}/database/import`; - - const formData = new FormData(); - formData.append("file", importFile); - - const response = await fetch(apiUrl, { - method: "POST", - credentials: "include", - body: formData, - }); - - if (response.ok) { - const result = await response.json(); - if (result.success) { - const summary = result.summary; - const imported = - summary.sshHostsImported + - summary.sshCredentialsImported + - summary.fileManagerItemsImported + - summary.dismissedAlertsImported + - (summary.settingsImported || 0); - const skipped = summary.skippedItems; - - const details = []; - if (summary.sshHostsImported > 0) - details.push(`${summary.sshHostsImported} SSH hosts`); - if (summary.sshCredentialsImported > 0) - details.push(`${summary.sshCredentialsImported} credentials`); - if (summary.fileManagerItemsImported > 0) - details.push( - `${summary.fileManagerItemsImported} file manager items`, - ); - if (summary.dismissedAlertsImported > 0) - details.push(`${summary.dismissedAlertsImported} alerts`); - if (summary.settingsImported > 0) - details.push(`${summary.settingsImported} settings`); - - toast.success( - `Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(", ")})` : ""}, ${skipped} items skipped`, - ); - setImportFile(null); - - setTimeout(() => { - window.location.reload(); - }, 1500); - } else { - toast.error( - `${t("admin.databaseImportFailed")}: ${result.summary?.errors?.join(", ") || "Unknown error"}`, - ); - } - } else { - const error = await response.json(); - toast.error(error.error || t("admin.databaseImportFailed")); - } - } catch { - toast.error(t("admin.databaseImportFailed")); - } finally { - setImportLoading(false); - } - }; - - return ( -
    -

    {t("admin.databaseSecurity")}

    - -
    -
    -
    -
    - -

    {t("admin.export")}

    -
    -

    - {t("admin.exportDescription")} -

    - -
    -
    - -
    -
    -
    - -

    {t("admin.import")}

    -
    -

    - {t("admin.importDescription")} -

    -
    - setImportFile(e.target.files?.[0] || null)} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - /> - -
    - -
    -
    -
    -
    - ); -} diff --git a/src/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx b/src/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx deleted file mode 100644 index cf4dcaac..00000000 --- a/src/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx +++ /dev/null @@ -1,531 +0,0 @@ -import React from "react"; -import { Checkbox } from "@/components/ui/checkbox.tsx"; -import { Input } from "@/components/ui/input.tsx"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select.tsx"; -import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; -import { useConfirmation } from "@/hooks/use-confirmation.ts"; -import { - updateRegistrationAllowed, - updatePasswordLoginAllowed, - updatePasswordResetAllowed, - getGlobalMonitoringSettings, - updateGlobalMonitoringSettings, - getGuacamoleSettings, - updateGuacamoleSettings, - getLogLevel, - updateLogLevel, - getSessionTimeout, - updateSessionTimeout, -} from "@/ui/main-axios.ts"; -import { Button } from "@/components/ui/button.tsx"; - -interface GeneralSettingsTabProps { - allowRegistration: boolean; - setAllowRegistration: (value: boolean) => void; - allowPasswordLogin: boolean; - setAllowPasswordLogin: (value: boolean) => void; - allowPasswordReset: boolean; - setAllowPasswordReset: (value: boolean) => void; - oidcConfig: { - client_id: string; - client_secret: string; - issuer_url: string; - authorization_url: string; - token_url: string; - }; -} - -export function GeneralSettingsTab({ - allowRegistration, - setAllowRegistration, - allowPasswordLogin, - setAllowPasswordLogin, - allowPasswordReset, - setAllowPasswordReset, - oidcConfig, -}: GeneralSettingsTabProps): React.ReactElement { - const { t } = useTranslation(); - const { confirmWithToast } = useConfirmation(); - - const [regLoading, setRegLoading] = React.useState(false); - const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false); - const [passwordResetLoading, setPasswordResetLoading] = React.useState(false); - - const [statusInterval, setStatusInterval] = React.useState(60); - const [metricsInterval, setMetricsInterval] = React.useState(30); - const [statusInputValue, setStatusInputValue] = React.useState("60"); - const [metricsInputValue, setMetricsInputValue] = React.useState("30"); - const [statusUnit, setStatusUnit] = React.useState<"seconds" | "minutes">( - "seconds", - ); - const [metricsUnit, setMetricsUnit] = React.useState<"seconds" | "minutes">( - "seconds", - ); - const [monitoringLoading, setMonitoringLoading] = React.useState(false); - - const [logLevel, setLogLevel] = React.useState("info"); - const [logLevelLoading, setLogLevelLoading] = React.useState(false); - - const [, setSessionTimeoutHours] = React.useState(24); - const [sessionTimeoutInput, setSessionTimeoutInput] = React.useState("24"); - const [sessionTimeoutLoading, setSessionTimeoutLoading] = - React.useState(false); - - const [guacEnabled, setGuacEnabled] = React.useState(true); - const [guacUrl, setGuacUrl] = React.useState("guacd:4822"); - const [guacLoading, setGuacLoading] = React.useState(false); - - React.useEffect(() => { - getLogLevel() - .then((data) => { - setLogLevel(data.level); - }) - .catch(() => {}); - }, []); - - React.useEffect(() => { - getSessionTimeout() - .then((data) => { - setSessionTimeoutHours(data.timeoutHours); - setSessionTimeoutInput(String(data.timeoutHours)); - }) - .catch(() => {}); - }, []); - - const handleLogLevelChange = async (value: string) => { - setLogLevel(value); - setLogLevelLoading(true); - try { - await updateLogLevel(value); - toast.success(t("admin.logLevelSaved")); - } catch { - toast.error(t("admin.failedToSaveLogLevel")); - } finally { - setLogLevelLoading(false); - } - }; - - const handleSessionTimeoutBlur = async () => { - const num = parseInt(sessionTimeoutInput) || 24; - const clamped = Math.max(1, Math.min(720, num)); - setSessionTimeoutHours(clamped); - setSessionTimeoutInput(String(clamped)); - setSessionTimeoutLoading(true); - try { - await updateSessionTimeout(clamped); - toast.success(t("admin.sessionTimeoutSaved")); - } catch { - toast.error(t("admin.failedToSaveSessionTimeout")); - } finally { - setSessionTimeoutLoading(false); - } - }; - - React.useEffect(() => { - getGuacamoleSettings() - .then((data) => { - setGuacEnabled(data.enabled); - setGuacUrl(data.url); - }) - .catch(() => { - toast.error(t("admin.failedToLoadGuacamoleSettings")); - }); - }, [t]); - - const saveGuacDebounce = React.useRef(null); - - const saveGuacSettings = React.useCallback( - (newEnabled: boolean, newUrl: string) => { - if (saveGuacDebounce.current) { - clearTimeout(saveGuacDebounce.current); - } - saveGuacDebounce.current = setTimeout(async () => { - setGuacLoading(true); - try { - await updateGuacamoleSettings({ enabled: newEnabled, url: newUrl }); - toast.success(t("admin.guacamoleSettingsSaved")); - } catch { - toast.error(t("admin.failedToSaveGuacamoleSettings")); - } finally { - setGuacLoading(false); - } - }, 800); - }, - [t], - ); - - React.useEffect(() => { - getGlobalMonitoringSettings() - .then((data) => { - setStatusInterval(data.statusCheckInterval); - setMetricsInterval(data.metricsInterval); - setStatusInputValue(String(data.statusCheckInterval)); - setMetricsInputValue(String(data.metricsInterval)); - }) - .catch(() => { - // Use defaults silently - }); - }, []); - - const saveMonitoringSettings = React.useCallback( - async (newStatus: number, newMetrics: number) => { - setMonitoringLoading(true); - try { - await updateGlobalMonitoringSettings({ - statusCheckInterval: newStatus, - metricsInterval: newMetrics, - }); - toast.success(t("admin.globalSettingsSaved")); - } catch (error) { - const errorMessage = - error instanceof Error - ? error.message - : t("admin.failedToSaveGlobalSettings"); - toast.error(errorMessage); - } finally { - setMonitoringLoading(false); - } - }, - [t], - ); - - const handleStatusBlur = () => { - const num = parseInt(statusInputValue) || 0; - const seconds = statusUnit === "minutes" ? num * 60 : num; - const clamped = Math.max(5, Math.min(3600, seconds)); - setStatusInterval(clamped); - setStatusInputValue( - statusUnit === "minutes" - ? String(Math.round(clamped / 60)) - : String(clamped), - ); - saveMonitoringSettings(clamped, metricsInterval); - }; - - const handleMetricsBlur = () => { - const num = parseInt(metricsInputValue) || 0; - const seconds = metricsUnit === "minutes" ? num * 60 : num; - const clamped = Math.max(5, Math.min(3600, seconds)); - setMetricsInterval(clamped); - setMetricsInputValue( - metricsUnit === "minutes" - ? String(Math.round(clamped / 60)) - : String(clamped), - ); - saveMonitoringSettings(statusInterval, clamped); - }; - - const handleToggleRegistration = async (checked: boolean) => { - setRegLoading(true); - try { - await updateRegistrationAllowed(checked); - setAllowRegistration(checked); - } finally { - setRegLoading(false); - } - }; - - const handleTogglePasswordLogin = async (checked: boolean) => { - if (!checked) { - const hasOIDCConfigured = - oidcConfig.client_id && - oidcConfig.client_secret && - oidcConfig.issuer_url && - oidcConfig.authorization_url && - oidcConfig.token_url; - - if (!hasOIDCConfigured) { - toast.error(t("admin.cannotDisablePasswordLoginWithoutOIDC"), { - duration: 5000, - }); - return; - } - - confirmWithToast( - t("admin.confirmDisablePasswordLogin"), - async () => { - setPasswordLoginLoading(true); - try { - await updatePasswordLoginAllowed(checked); - setAllowPasswordLogin(checked); - - if (allowRegistration) { - await updateRegistrationAllowed(false); - setAllowRegistration(false); - toast.success(t("admin.passwordLoginAndRegistrationDisabled")); - } else { - toast.success(t("admin.passwordLoginDisabled")); - } - } catch { - toast.error(t("admin.failedToUpdatePasswordLoginStatus")); - } finally { - setPasswordLoginLoading(false); - } - }, - "destructive", - ); - return; - } - - setPasswordLoginLoading(true); - try { - await updatePasswordLoginAllowed(checked); - setAllowPasswordLogin(checked); - } finally { - setPasswordLoginLoading(false); - } - }; - - const handleTogglePasswordReset = async (checked: boolean) => { - setPasswordResetLoading(true); - try { - await updatePasswordResetAllowed(checked); - setAllowPasswordReset(checked); - } finally { - setPasswordResetLoading(false); - } - }; - - return ( -
    -
    -

    {t("admin.userRegistration")}

    - - - -
    - -
    -

    {t("admin.sessionTimeout")}

    -

    - {t("admin.sessionTimeoutDesc")} -

    -
    - -
    - setSessionTimeoutInput(e.target.value)} - onBlur={handleSessionTimeoutBlur} - disabled={sessionTimeoutLoading} - className="flex-1" - /> - {t("admin.hours")} -
    -

    - {t("admin.sessionTimeoutNote")} -

    -
    -
    - -
    -

    - {t("admin.monitoringDefaults")} -

    -

    - {t("admin.monitoringDefaultsDesc")} -

    -
    -
    - -
    - setStatusInputValue(e.target.value)} - onBlur={handleStatusBlur} - disabled={monitoringLoading} - className="flex-1" - /> - -
    -
    -
    - -
    - setMetricsInputValue(e.target.value)} - onBlur={handleMetricsBlur} - disabled={monitoringLoading} - className="flex-1" - /> - -
    -
    -
    -
    - -
    -

    - {t("admin.guacamoleIntegration")} -

    -

    - {t("admin.guacamoleIntegrationDesc")} -

    - - - {guacEnabled && ( -
    - - { - setGuacUrl(e.target.value); - saveGuacSettings(guacEnabled, e.target.value); - }} - /> -

    - {t("admin.guacdUrlNote")} -

    -
    - )} -
    - -
    -

    {t("admin.logLevel")}

    -

    - {t("admin.logLevelDesc")} -

    -
    - - -

    - {t("admin.logLevelNote")} -

    -
    -
    -
    - ); -} diff --git a/src/ui/desktop/apps/admin/tabs/OIDCSettingsTab.tsx b/src/ui/desktop/apps/admin/tabs/OIDCSettingsTab.tsx deleted file mode 100644 index 5c27a2c4..00000000 --- a/src/ui/desktop/apps/admin/tabs/OIDCSettingsTab.tsx +++ /dev/null @@ -1,339 +0,0 @@ -import React from "react"; -import { Button } from "@/components/ui/button.tsx"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx"; -import { Input } from "@/components/ui/input.tsx"; -import { PasswordInput } from "@/components/ui/password-input.tsx"; -import { Label } from "@/components/ui/label.tsx"; -import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; -import { useConfirmation } from "@/hooks/use-confirmation.ts"; -import { Textarea } from "@/components/ui/textarea.tsx"; -import { updateOIDCConfig, disableOIDCConfig } from "@/ui/main-axios.ts"; - -interface OIDCSettingsTabProps { - allowPasswordLogin: boolean; - oidcConfig: { - client_id: string; - client_secret: string; - issuer_url: string; - authorization_url: string; - token_url: string; - identifier_path: string; - name_path: string; - scopes: string; - userinfo_url: string; - allowed_users: string; - }; - setOidcConfig: React.Dispatch< - React.SetStateAction<{ - client_id: string; - client_secret: string; - issuer_url: string; - authorization_url: string; - token_url: string; - identifier_path: string; - name_path: string; - scopes: string; - userinfo_url: string; - allowed_users: string; - }> - >; -} - -export function OIDCSettingsTab({ - allowPasswordLogin, - oidcConfig, - setOidcConfig, -}: OIDCSettingsTabProps): React.ReactElement { - const { t } = useTranslation(); - const { confirmWithToast } = useConfirmation(); - - const [oidcLoading, setOidcLoading] = React.useState(false); - const [oidcError, setOidcError] = React.useState(null); - - const handleOIDCConfigSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setOidcLoading(true); - setOidcError(null); - - const required = [ - "client_id", - "client_secret", - "issuer_url", - "authorization_url", - "token_url", - ]; - const missing = required.filter( - (f) => !oidcConfig[f as keyof typeof oidcConfig], - ); - if (missing.length > 0) { - setOidcError( - t("admin.missingRequiredFields", { fields: missing.join(", ") }), - ); - setOidcLoading(false); - return; - } - - try { - await updateOIDCConfig(oidcConfig); - toast.success(t("admin.oidcConfigurationUpdated")); - } catch (err: unknown) { - setOidcError( - (err as { response?: { data?: { error?: string } } })?.response?.data - ?.error || t("admin.failedToUpdateOidcConfig"), - ); - } finally { - setOidcLoading(false); - } - }; - - const handleOIDCConfigChange = (field: string, value: string) => { - setOidcConfig((prev) => ({ ...prev, [field]: value })); - }; - - const handleResetConfig = async () => { - if (!allowPasswordLogin) { - confirmWithToast( - t("admin.confirmDisableOIDCWarning"), - async () => { - const emptyConfig = { - client_id: "", - client_secret: "", - issuer_url: "", - authorization_url: "", - token_url: "", - identifier_path: "", - name_path: "", - scopes: "", - userinfo_url: "", - allowed_users: "", - }; - setOidcConfig(emptyConfig); - setOidcError(null); - setOidcLoading(true); - try { - await disableOIDCConfig(); - toast.success(t("admin.oidcConfigurationDisabled")); - } catch (err: unknown) { - setOidcError( - ( - err as { - response?: { data?: { error?: string } }; - } - )?.response?.data?.error || t("admin.failedToDisableOidcConfig"), - ); - } finally { - setOidcLoading(false); - } - }, - "destructive", - ); - return; - } - - const emptyConfig = { - client_id: "", - client_secret: "", - issuer_url: "", - authorization_url: "", - token_url: "", - identifier_path: "", - name_path: "", - scopes: "", - userinfo_url: "", - allowed_users: "", - }; - setOidcConfig(emptyConfig); - setOidcError(null); - setOidcLoading(true); - try { - await disableOIDCConfig(); - toast.success(t("admin.oidcConfigurationDisabled")); - } catch (err: unknown) { - setOidcError( - ( - err as { - response?: { data?: { error?: string } }; - } - )?.response?.data?.error || t("admin.failedToDisableOidcConfig"), - ); - } finally { - setOidcLoading(false); - } - }; - - return ( -
    -

    - {t("admin.externalAuthentication")} -

    -
    -

    - {t("admin.configureExternalProvider")} -

    - -
    - - {!allowPasswordLogin && ( - - {t("admin.criticalWarning")} - {t("admin.oidcRequiredWarning")} - - )} - - {oidcError && ( - - {t("common.error")} - {oidcError} - - )} - -
    -
    - - - handleOIDCConfigChange("client_id", e.target.value) - } - placeholder={t("placeholders.clientId")} - required - /> -
    -
    - - - handleOIDCConfigChange("client_secret", e.target.value) - } - placeholder={t("placeholders.clientSecret")} - required - /> -
    -
    - - - handleOIDCConfigChange("authorization_url", e.target.value) - } - placeholder={t("placeholders.authUrl")} - required - /> -
    -
    - - - handleOIDCConfigChange("issuer_url", e.target.value) - } - placeholder={t("placeholders.redirectUrl")} - required - /> -
    -
    - - - handleOIDCConfigChange("token_url", e.target.value) - } - placeholder={t("placeholders.tokenUrl")} - required - /> -
    -
    - - - handleOIDCConfigChange("identifier_path", e.target.value) - } - placeholder={t("placeholders.userIdField")} - required - /> -
    -
    - - - handleOIDCConfigChange("name_path", e.target.value) - } - placeholder={t("placeholders.usernameField")} - required - /> -
    -
    - - handleOIDCConfigChange("scopes", e.target.value)} - placeholder={t("placeholders.scopes")} - required - /> -
    -
    - - - handleOIDCConfigChange("userinfo_url", e.target.value) - } - placeholder="https://your-provider.com/application/o/userinfo/" - /> -
    -
    - -

    - {t("admin.allowedUsersDescription")} -

    -