Merge branch 'dev-1.10.0' into feature-socks5-support

This commit is contained in:
Luke Gustafson
2025-12-19 20:34:48 -06:00
committed by GitHub
78 changed files with 18095 additions and 2418 deletions

View File

@@ -27,7 +27,7 @@ on:
jobs:
build-windows:
runs-on: windows-latest
if: github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == ''
if: (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == '') && github.event.inputs.artifact_destination != 'submit'
permissions:
contents: write
@@ -72,10 +72,6 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm run build && npx electron-builder --win --x64 --ia32
- name: List release files
run: |
dir release
- name: Upload Windows x64 NSIS Installer
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_windows_x64_nsis.exe') != '' && github.event.inputs.artifact_destination != 'none'
@@ -136,7 +132,7 @@ jobs:
build-linux:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == ''
if: (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == '') && github.event.inputs.artifact_destination != 'submit'
permissions:
contents: write
@@ -199,17 +195,6 @@ jobs:
cd ..
- name: List release files
run: |
ls -la release/
- name: Debug electron-builder output
if: always()
run: |
if [ -f "release/builder-debug.yml" ]; then
cat release/builder-debug.yml
fi
- name: Upload Linux x64 AppImage
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_linux_x64_appimage.AppImage') != '' && github.event.inputs.artifact_destination != 'none'
@@ -282,6 +267,93 @@ jobs:
path: release/termix_linux_armv7l_portable.tar.gz
retention-days: 30
- name: Install Flatpak builder and dependencies
run: |
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder imagemagick
- name: Add Flathub repository
run: |
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
- name: Install Flatpak runtime and SDK
run: |
sudo flatpak install -y flathub org.freedesktop.Platform//24.08
sudo flatpak install -y flathub org.freedesktop.Sdk//24.08
sudo flatpak install -y flathub org.electronjs.Electron2.BaseApp//24.08
- name: Get version for Flatpak
id: flatpak-version
run: |
VERSION=$(node -p "require('./package.json').version")
RELEASE_DATE=$(date +%Y-%m-%d)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "release_date=$RELEASE_DATE" >> $GITHUB_OUTPUT
- name: Prepare Flatpak files
run: |
VERSION="${{ steps.flatpak-version.outputs.version }}"
RELEASE_DATE="${{ steps.flatpak-version.outputs.release_date }}"
CHECKSUM_X64=$(sha256sum "release/termix_linux_x64_appimage.AppImage" | awk '{print $1}')
CHECKSUM_ARM64=$(sha256sum "release/termix_linux_arm64_appimage.AppImage" | awk '{print $1}')
mkdir -p flatpak-build
cp flatpak/com.karmaa.termix.yml flatpak-build/
cp flatpak/com.karmaa.termix.desktop flatpak-build/
cp flatpak/com.karmaa.termix.metainfo.xml flatpak-build/
cp public/icon.svg flatpak-build/com.karmaa.termix.svg
convert public/icon.png -resize 256x256 flatpak-build/icon-256.png
convert public/icon.png -resize 128x128 flatpak-build/icon-128.png
cd flatpak-build
sed -i "s|https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_x64_appimage.AppImage|file://$(realpath ../release/termix_linux_x64_appimage.AppImage)|g" com.karmaa.termix.yml
sed -i "s|https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_arm64_appimage.AppImage|file://$(realpath ../release/termix_linux_arm64_appimage.AppImage)|g" com.karmaa.termix.yml
sed -i "s/CHECKSUM_X64_PLACEHOLDER/$CHECKSUM_X64/g" com.karmaa.termix.yml
sed -i "s/CHECKSUM_ARM64_PLACEHOLDER/$CHECKSUM_ARM64/g" com.karmaa.termix.yml
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" com.karmaa.termix.metainfo.xml
sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" com.karmaa.termix.metainfo.xml
- name: Build Flatpak bundle
run: |
cd flatpak-build
flatpak-builder --repo=repo --force-clean --disable-rofiles-fuse build-dir com.karmaa.termix.yml
# Determine the architecture
ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ]; then
FLATPAK_ARCH="x86_64"
elif [ "$ARCH" = "aarch64" ]; then
FLATPAK_ARCH="aarch64"
else
FLATPAK_ARCH="$ARCH"
fi
# Build bundle for the current architecture
flatpak build-bundle repo ../release/termix_linux_flatpak.flatpak com.karmaa.termix --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo
- name: Create flatpakref file
run: |
VERSION="${{ steps.flatpak-version.outputs.version }}"
cp flatpak/com.karmaa.termix.flatpakref release/
sed -i "s|VERSION_PLACEHOLDER|release-${VERSION}-tag|g" release/com.karmaa.termix.flatpakref
- name: Upload Flatpak bundle
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_linux_flatpak.flatpak') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_linux_flatpak
path: release/termix_linux_flatpak.flatpak
retention-days: 30
- name: Upload Flatpakref
uses: actions/upload-artifact@v4
if: hashFiles('release/com.karmaa.termix.flatpakref') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_linux_flatpakref
path: release/com.karmaa.termix.flatpakref
retention-days: 30
build-macos:
runs-on: macos-latest
if: github.event.inputs.build_type == 'macos' || github.event.inputs.build_type == 'all'
@@ -425,11 +497,6 @@ jobs:
export GH_TOKEN="${{ secrets.GITHUB_TOKEN }}"
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
- name: List release directory
if: steps.check_certs.outputs.has_certs == 'true'
run: |
ls -R release/ || echo "Release directory not found"
- name: Upload macOS MAS PKG
if: steps.check_certs.outputs.has_certs == 'true' && hashFiles('release/termix_macos_universal_mas.pkg') != '' && (github.event.inputs.artifact_destination == 'file' || github.event.inputs.artifact_destination == 'release' || github.event.inputs.artifact_destination == 'submit')
uses: actions/upload-artifact@v4
@@ -463,42 +530,51 @@ jobs:
path: release/termix_macos_arm64_dmg.dmg
retention-days: 30
- name: Check for App Store Connect API credentials
if: steps.check_certs.outputs.has_certs == 'true'
id: check_asc_creds
- name: Get version for Homebrew
id: homebrew-version
run: |
if [ -n "${{ secrets.APPLE_KEY_ID }}" ] && [ -n "${{ secrets.APPLE_ISSUER_ID }}" ] && [ -n "${{ secrets.APPLE_KEY_CONTENT }}" ]; then
echo "has_credentials=true" >> $GITHUB_OUTPUT
fi
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup Ruby for Fastlane
if: steps.check_asc_creds.outputs.has_credentials == 'true' && github.event.inputs.artifact_destination == 'submit'
uses: ruby/setup-ruby@v1
- name: Generate Homebrew Cask
if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && (github.event.inputs.artifact_destination == 'file' || github.event.inputs.artifact_destination == 'release')
run: |
VERSION="${{ steps.homebrew-version.outputs.version }}"
DMG_PATH="release/termix_macos_universal_dmg.dmg"
CHECKSUM=$(shasum -a 256 "$DMG_PATH" | awk '{print $1}')
mkdir -p homebrew-generated
cp homebrew/termix.rb homebrew-generated/termix.rb
sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-generated/termix.rb
sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-generated/termix.rb
sed -i '' "s|version \".*\"|version \"$VERSION\"|g" homebrew-generated/termix.rb
sed -i '' "s|sha256 \".*\"|sha256 \"$CHECKSUM\"|g" homebrew-generated/termix.rb
sed -i '' "s|release-[0-9.]*-tag|release-$VERSION-tag|g" homebrew-generated/termix.rb
- name: Upload Homebrew Cask as artifact
uses: actions/upload-artifact@v4
if: hashFiles('homebrew-generated/termix.rb') != '' && github.event.inputs.artifact_destination == 'file'
with:
ruby-version: "3.2"
bundler-cache: false
name: termix_macos_homebrew_cask
path: homebrew-generated/termix.rb
retention-days: 30
- name: Install Fastlane
if: steps.check_asc_creds.outputs.has_credentials == 'true' && github.event.inputs.artifact_destination == 'submit'
- name: Upload Homebrew Cask to release
if: hashFiles('homebrew-generated/termix.rb') != '' && github.event.inputs.artifact_destination == 'release'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gem install fastlane -N
VERSION="${{ steps.homebrew-version.outputs.version }}"
RELEASE_TAG="release-$VERSION-tag"
- name: Deploy to App Store Connect (TestFlight)
if: steps.check_asc_creds.outputs.has_credentials == 'true' && github.event.inputs.artifact_destination == 'submit'
run: |
PKG_FILE=$(find release -name "*.pkg" -type f | head -n 1)
if [ -z "$PKG_FILE" ]; then
gh release list --repo ${{ github.repository }} --limit 100 | grep -q "$RELEASE_TAG" || {
echo "Release $RELEASE_TAG not found"
exit 1
fi
}
mkdir -p ~/private_keys
echo "${{ secrets.APPLE_KEY_CONTENT }}" | base64 --decode > ~/private_keys/AuthKey_${{ secrets.APPLE_KEY_ID }}.p8
xcrun altool --upload-app -f "$PKG_FILE" \
--type macos \
--apiKey "${{ secrets.APPLE_KEY_ID }}" \
--apiIssuer "${{ secrets.APPLE_ISSUER_ID }}"
continue-on-error: true
gh release upload "$RELEASE_TAG" homebrew-generated/termix.rb --repo ${{ github.repository }} --clobber
- name: Clean up keychains
if: always()
@@ -509,7 +585,6 @@ jobs:
submit-to-chocolatey:
runs-on: windows-latest
if: github.event.inputs.artifact_destination == 'submit'
needs: [build-windows]
permissions:
contents: read
@@ -525,20 +600,25 @@ jobs:
$VERSION = (Get-Content package.json | ConvertFrom-Json).version
echo "version=$VERSION" >> $env:GITHUB_OUTPUT
- name: Download Windows x64 MSI artifact
uses: actions/download-artifact@v4
with:
name: termix_windows_x64_msi
path: artifact
- name: Get MSI file info
- name: Download and prepare MSI info from public release
id: msi-info
run: |
$VERSION = "${{ steps.package-version.outputs.version }}"
$MSI_FILE = Get-ChildItem -Path artifact -Filter "*.msi" | Select-Object -First 1
$MSI_NAME = $MSI_FILE.Name
$CHECKSUM = (Get-FileHash -Path $MSI_FILE.FullName -Algorithm SHA256).Hash
$MSI_NAME = "termix_windows_x64_msi.msi"
$DOWNLOAD_URL = "https://github.com/Termix-SSH/Termix/releases/download/release-$($VERSION)-tag/$($MSI_NAME)"
Write-Host "Downloading from $DOWNLOAD_URL"
New-Item -ItemType Directory -Force -Path "release_asset"
$DOWNLOAD_PATH = "release_asset\$MSI_NAME"
try {
Invoke-WebRequest -Uri $DOWNLOAD_URL -OutFile $DOWNLOAD_PATH -UseBasicParsing
} catch {
Write-Error "Failed to download MSI from $DOWNLOAD_URL. Please ensure the release and asset exist."
exit 1
}
$CHECKSUM = (Get-FileHash -Path $DOWNLOAD_PATH -Algorithm SHA256).Hash
echo "msi_name=$MSI_NAME" >> $env:GITHUB_OUTPUT
echo "checksum=$CHECKSUM" >> $env:GITHUB_OUTPUT
@@ -610,7 +690,7 @@ jobs:
submit-to-flatpak:
runs-on: ubuntu-latest
if: github.event.inputs.artifact_destination == 'submit'
needs: [build-linux]
needs: []
permissions:
contents: read
@@ -628,30 +708,27 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "release_date=$RELEASE_DATE" >> $GITHUB_OUTPUT
- name: Download Linux x64 AppImage artifact
uses: actions/download-artifact@v4
with:
name: termix_linux_x64_appimage
path: artifact-x64
- name: Download Linux arm64 AppImage artifact
uses: actions/download-artifact@v4
with:
name: termix_linux_arm64_appimage
path: artifact-arm64
- name: Get AppImage file info
- name: Download and prepare AppImage info from public release
id: appimage-info
run: |
VERSION="${{ steps.package-version.outputs.version }}"
mkdir -p release_assets
APPIMAGE_X64_FILE=$(find artifact-x64 -name "*.AppImage" -type f | head -n 1)
APPIMAGE_X64_NAME=$(basename "$APPIMAGE_X64_FILE")
CHECKSUM_X64=$(sha256sum "$APPIMAGE_X64_FILE" | awk '{print $1}')
APPIMAGE_X64_NAME="termix_linux_x64_appimage.AppImage"
URL_X64="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$APPIMAGE_X64_NAME"
PATH_X64="release_assets/$APPIMAGE_X64_NAME"
echo "Downloading x64 AppImage from $URL_X64"
curl -L -o "$PATH_X64" "$URL_X64"
chmod +x "$PATH_X64"
CHECKSUM_X64=$(sha256sum "$PATH_X64" | awk '{print $1}')
APPIMAGE_ARM64_FILE=$(find artifact-arm64 -name "*.AppImage" -type f | head -n 1)
APPIMAGE_ARM64_NAME=$(basename "$APPIMAGE_ARM64_FILE")
CHECKSUM_ARM64=$(sha256sum "$APPIMAGE_ARM64_FILE" | awk '{print $1}')
APPIMAGE_ARM64_NAME="termix_linux_arm64_appimage.AppImage"
URL_ARM64="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$APPIMAGE_ARM64_NAME"
PATH_ARM64="release_assets/$APPIMAGE_ARM64_NAME"
echo "Downloading arm64 AppImage from $URL_ARM64"
curl -L -o "$PATH_ARM64" "$URL_ARM64"
chmod +x "$PATH_ARM64"
CHECKSUM_ARM64=$(sha256sum "$PATH_ARM64" | awk '{print $1}')
echo "appimage_x64_name=$APPIMAGE_X64_NAME" >> $GITHUB_OUTPUT
echo "checksum_x64=$CHECKSUM_X64" >> $GITHUB_OUTPUT
@@ -690,10 +767,6 @@ jobs:
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak-submission/com.karmaa.termix.metainfo.xml
sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" flatpak-submission/com.karmaa.termix.metainfo.xml
- name: List submission files
run: |
ls -la flatpak-submission/
- name: Upload Flatpak submission as artifact
uses: actions/upload-artifact@v4
with:
@@ -704,7 +777,7 @@ jobs:
submit-to-homebrew:
runs-on: macos-latest
if: github.event.inputs.artifact_destination == 'submit'
needs: [build-macos]
needs: []
permissions:
contents: read
@@ -720,19 +793,19 @@ jobs:
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download macOS Universal DMG artifact
uses: actions/download-artifact@v4
with:
name: termix_macos_universal_dmg
path: artifact
- name: Get DMG file info
- name: Download and prepare DMG info from public release
id: dmg-info
run: |
VERSION="${{ steps.package-version.outputs.version }}"
DMG_FILE=$(find artifact -name "*.dmg" -type f | head -n 1)
DMG_NAME=$(basename "$DMG_FILE")
CHECKSUM=$(shasum -a 256 "$DMG_FILE" | awk '{print $1}')
DMG_NAME="termix_macos_universal_dmg.dmg"
URL="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$DMG_NAME"
mkdir -p release_asset
PATH="release_asset/$DMG_NAME"
echo "Downloading DMG from $URL"
curl -L -o "$PATH" "$URL"
CHECKSUM=$(shasum -a 256 "$PATH" | awk '{print $1}')
echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT
echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT
@@ -752,16 +825,8 @@ jobs:
- name: Verify Cask syntax
run: |
if ! command -v brew &> /dev/null; then
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi
ruby -c homebrew-submission/Casks/t/termix.rb
- name: List submission files
run: |
find homebrew-submission -type f
- name: Upload Homebrew submission as artifact
uses: actions/upload-artifact@v4
with:
@@ -789,10 +854,6 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
- name: Display artifact structure
run: |
ls -R artifacts/
- name: Upload artifacts to latest release
run: |
cd artifacts
@@ -808,3 +869,130 @@ jobs:
done
env:
GH_TOKEN: ${{ github.token }}
submit-to-testflight:
runs-on: macos-latest
if: github.event.inputs.artifact_destination == 'submit'
needs: []
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: |
for i in 1 2 3;
do
if npm ci; then
break
else
if [ $i -eq 3 ]; then
exit 1
fi
sleep 10
fi
done
npm install --force @rollup/rollup-darwin-arm64
npm install dmg-license
- name: Check for Code Signing Certificates
id: check_certs
run: |
if [ -n "${{ secrets.MAC_BUILD_CERTIFICATE_BASE64 }}" ] && [ -n "${{ secrets.MAC_P12_PASSWORD }}" ]; then
echo "has_certs=true" >> $GITHUB_OUTPUT
fi
- name: Import Code Signing Certificates
if: steps.check_certs.outputs.has_certs == 'true'
env:
MAC_BUILD_CERTIFICATE_BASE64: ${{ secrets.MAC_BUILD_CERTIFICATE_BASE64 }}
MAC_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.MAC_INSTALLER_CERTIFICATE_BASE64 }}
MAC_P12_PASSWORD: ${{ secrets.MAC_P12_PASSWORD }}
MAC_KEYCHAIN_PASSWORD: ${{ secrets.MAC_KEYCHAIN_PASSWORD }}
run: |
APP_CERT_PATH=$RUNNER_TEMP/app_certificate.p12
INSTALLER_CERT_PATH=$RUNNER_TEMP/installer_certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
echo -n "$MAC_BUILD_CERTIFICATE_BASE64" | base64 --decode -o $APP_CERT_PATH
if [ -n "$MAC_INSTALLER_CERTIFICATE_BASE64" ]; then
echo -n "$MAC_INSTALLER_CERTIFICATE_BASE64" | base64 --decode -o $INSTALLER_CERT_PATH
fi
security create-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import $APP_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
if [ -f "$INSTALLER_CERT_PATH" ]; then
security import $INSTALLER_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
fi
security list-keychain -d user -s $KEYCHAIN_PATH
security find-identity -v -p codesigning $KEYCHAIN_PATH
- name: Build macOS App Store Package
if: steps.check_certs.outputs.has_certs == 'true'
env:
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CURRENT_VERSION=$(node -p "require('./package.json').version")
BUILD_VERSION="${{ github.run_number }}"
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
- name: Check for App Store Connect API credentials
id: check_asc_creds
run: |
if [ -n "${{ secrets.APPLE_KEY_ID }}" ] && [ -n "${{ secrets.APPLE_ISSUER_ID }}" ] && [ -n "${{ secrets.APPLE_KEY_CONTENT }}" ]; then
echo "has_credentials=true" >> $GITHUB_OUTPUT
fi
- name: Setup Ruby for Fastlane
if: steps.check_asc_creds.outputs.has_credentials == 'true'
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.2"
bundler-cache: false
- name: Install Fastlane
if: steps.check_asc_creds.outputs.has_credentials == 'true'
run: |
gem install fastlane -N
- name: Deploy to App Store Connect (TestFlight)
if: steps.check_asc_creds.outputs.has_credentials == 'true'
run: |
PKG_FILE=$(find artifact-mas -name "*.pkg" -type f | head -n 1)
if [ -z "$PKG_FILE" ]; then
echo "PKG file not found, exiting."
exit 1
fi
mkdir -p ~/private_keys
echo "${{ secrets.APPLE_KEY_CONTENT }}" | base64 --decode > ~/private_keys/AuthKey_${{ secrets.APPLE_KEY_ID }}.p8
xcrun altool --upload-app -f "$PKG_FILE" \
--type macos \
--apiKey "${{ secrets.APPLE_KEY_ID }}" \
--apiIssuer "${{ secrets.APPLE_ISSUER_ID }}"
continue-on-error: true
- name: Clean up keychains
if: always()
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true

View File

@@ -93,6 +93,15 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/rbac(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/credentials(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -303,6 +312,42 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/docker(/.*)?$ {
proxy_pass http://127.0.0.1:30007;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
location /docker/console/ {
proxy_pass http://127.0.0.1:30008/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_connect_timeout 10s;
proxy_buffering off;
proxy_request_buffering off;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;

View File

@@ -90,6 +90,15 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/rbac(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/credentials(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -300,6 +309,42 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/docker(/.*)?$ {
proxy_pass http://127.0.0.1:30007;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
location /docker/console/ {
proxy_pass http://127.0.0.1:30008/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_connect_timeout 10s;
proxy_buffering off;
proxy_request_buffering off;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;

View File

@@ -124,5 +124,6 @@
"ITSAppUsesNonExemptEncryption": false,
"NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
}
}
},
"generateUpdatesFilesForAllChannels": true
}

View File

@@ -11,13 +11,11 @@ const fs = require("fs");
const os = require("os");
if (process.platform === "linux") {
app.commandLine.appendSwitch("--no-sandbox");
app.commandLine.appendSwitch("--disable-setuid-sandbox");
app.commandLine.appendSwitch("--disable-dev-shm-usage");
// Enable Ozone platform auto-detection for Wayland/X11 support
app.commandLine.appendSwitch("--ozone-platform-hint=auto");
app.disableHardwareAcceleration();
app.commandLine.appendSwitch("--disable-gpu");
app.commandLine.appendSwitch("--disable-gpu-compositing");
// Enable hardware video decoding if available
app.commandLine.appendSwitch("--enable-features=VaapiVideoDecoder");
}
app.commandLine.appendSwitch("--ignore-certificate-errors");

View File

@@ -1,7 +1,7 @@
[Desktop Entry]
Name=Termix
Comment=Web-based server management platform with SSH terminal, tunneling, and file editing
Exec=termix %U
Exec=run.sh %U
Icon=com.karmaa.termix
Terminal=false
Type=Application

View File

@@ -0,0 +1,12 @@
[Flatpak Ref]
Name=Termix
Branch=stable
Title=Termix - SSH Server Management Platform
IsRuntime=false
Url=https://github.com/Termix-SSH/Termix/releases/download/VERSION_PLACEHOLDER/termix_linux_flatpak.flatpak
GPGKey=
RuntimeRepo=https://flathub.org/repo/flathub.flatpakrepo
Comment=Web-based server management platform with SSH terminal, tunneling, and file editing
Description=Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides SSH terminal access, tunneling capabilities, and remote file management.
Icon=https://raw.githubusercontent.com/Termix-SSH/Termix/main/public/icon.png
Homepage=https://github.com/Termix-SSH/Termix

View File

@@ -5,7 +5,7 @@
<summary>Web-based server management platform with SSH terminal, tunneling, and file editing</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-or-later</project_license>
<project_license>Apache-2.0</project_license>
<developer_name>bugattiguy527</developer_name>

View File

@@ -1,10 +1,10 @@
app-id: com.karmaa.termix
runtime: org.freedesktop.Platform
runtime-version: "23.08"
runtime-version: "24.08"
sdk: org.freedesktop.Sdk
base: org.electronjs.Electron2.BaseApp
base-version: "23.08"
command: termix
base-version: "24.08"
command: run.sh
separate-locales: false
finish-args:
@@ -16,8 +16,11 @@ finish-args:
- --device=dri
- --filesystem=home
- --socket=ssh-auth
- --talk-name=org.freedesktop.Notifications
- --socket=session-bus
- --talk-name=org.freedesktop.secrets
- --env=ELECTRON_TRASH=gio
- --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons
- --env=ELECTRON_OZONE_PLATFORM_HINT=auto
modules:
- name: termix
@@ -30,6 +33,21 @@ modules:
- cp -r squashfs-root/resources /app/bin/
- cp -r squashfs-root/locales /app/bin/ || true
- cp squashfs-root/*.so /app/bin/ || true
- cp squashfs-root/*.pak /app/bin/ || true
- cp squashfs-root/*.bin /app/bin/ || true
- cp squashfs-root/*.dat /app/bin/ || true
- cp squashfs-root/*.json /app/bin/ || true
- |
cat > run.sh << 'EOF'
#!/bin/bash
export TMPDIR="$XDG_RUNTIME_DIR/app/$FLATPAK_ID"
exec zypak-wrapper /app/bin/termix "$@"
EOF
- chmod +x run.sh
- install -Dm755 run.sh /app/bin/run.sh
- install -Dm644 com.karmaa.termix.desktop /app/share/applications/com.karmaa.termix.desktop
- install -Dm644 com.karmaa.termix.metainfo.xml /app/share/metainfo/com.karmaa.termix.metainfo.xml
@@ -40,14 +58,14 @@ modules:
sources:
- type: file
url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_x64_VERSION_PLACEHOLDER_appimage.AppImage
url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_x64_appimage.AppImage
sha256: CHECKSUM_X64_PLACEHOLDER
dest-filename: termix.AppImage
only-arches:
- x86_64
- type: file
url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_arm64_VERSION_PLACEHOLDER_appimage.AppImage
url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_arm64_appimage.AppImage
sha256: CHECKSUM_ARM64_PLACEHOLDER
dest-filename: termix.AppImage
only-arches:

View File

@@ -1,34 +0,0 @@
#!/bin/bash
set -e
VERSION="$1"
CHECKSUM="$2"
RELEASE_DATE="$3"
if [ -z "$VERSION" ] || [ -z "$CHECKSUM" ] || [ -z "$RELEASE_DATE" ]; then
echo "Usage: $0 <version> <checksum> <release-date>"
echo "Example: $0 1.8.0 abc123... 2025-10-26"
exit 1
fi
echo "Preparing Flatpak submission for version $VERSION"
cp public/icon.svg flatpak/com.karmaa.termix.svg
echo "✓ Copied SVG icon"
if command -v convert &> /dev/null; then
convert public/icon.png -resize 256x256 flatpak/icon-256.png
convert public/icon.png -resize 128x128 flatpak/icon-128.png
echo "✓ Generated PNG icons"
else
cp public/icon.png flatpak/icon-256.png
cp public/icon.png flatpak/icon-128.png
echo "⚠ ImageMagick not found, using original icon"
fi
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak/com.karmaa.termix.yml
sed -i "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" flatpak/com.karmaa.termix.yml
echo "✓ Updated manifest with version $VERSION"
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak/com.karmaa.termix.metainfo.xml
sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" flatpak/com.karmaa.termix.metainfo.xml

181
package-lock.json generated
View File

@@ -16,6 +16,7 @@
"@hookform/resolvers": "^5.1.1",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15",
@@ -26,7 +27,7 @@
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -3156,6 +3157,52 @@
}
}
},
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
"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-dialog": "1.1.15",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "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-alert-dialog/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==",
"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/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
@@ -3265,6 +3312,24 @@
}
}
},
"node_modules/@radix-ui/react-collection/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==",
"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/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@@ -3331,6 +3396,24 @@
}
}
},
"node_modules/@radix-ui/react-dialog/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==",
"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/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
@@ -3523,6 +3606,24 @@
}
}
},
"node_modules/@radix-ui/react-menu/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==",
"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/@radix-ui/react-popover": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
@@ -3560,6 +3661,24 @@
}
}
},
"node_modules/@radix-ui/react-popover/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==",
"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/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
@@ -3663,6 +3782,24 @@
}
}
},
"node_modules/@radix-ui/react-primitive/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==",
"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/@radix-ui/react-progress": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
@@ -3792,6 +3929,24 @@
}
}
},
"node_modules/@radix-ui/react-select/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==",
"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/@radix-ui/react-separator": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
@@ -3849,9 +4004,9 @@
}
},
"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==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
@@ -3959,6 +4114,24 @@
}
}
},
"node_modules/@radix-ui/react-tooltip/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==",
"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/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",

View File

@@ -35,6 +35,7 @@
"@hookform/resolvers": "^5.1.1",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15",
@@ -45,7 +46,7 @@
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.8",

View File

@@ -8,6 +8,7 @@ import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import snippetsRoutes from "./routes/snippets.js";
import terminalRoutes from "./routes/terminal.js";
import rbacRoutes from "./routes/rbac.js";
import cors from "cors";
import fetch from "node-fetch";
import fs from "fs";
@@ -1436,6 +1437,7 @@ app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/snippets", snippetsRoutes);
app.use("/terminal", terminalRoutes);
app.use("/rbac", rbacRoutes);
app.use(
(

View File

@@ -201,12 +201,14 @@ async function initializeCompleteDatabase(): Promise<void> {
enable_tunnel INTEGER NOT NULL DEFAULT 1,
tunnel_connections TEXT,
enable_file_manager INTEGER NOT NULL DEFAULT 1,
enable_docker INTEGER NOT NULL DEFAULT 0,
default_path TEXT,
autostart_password TEXT,
autostart_key TEXT,
autostart_key_password TEXT,
force_keyboard_interactive TEXT,
stats_config TEXT,
docker_config TEXT,
terminal_config TEXT,
use_socks5 INTEGER,
socks5_host TEXT,
@@ -333,6 +335,81 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS host_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL,
user_id TEXT,
role_id INTEGER,
granted_by TEXT NOT NULL,
permission_level TEXT NOT NULL DEFAULT 'use',
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_accessed_at TEXT,
access_count INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
description TEXT,
is_system INTEGER NOT NULL DEFAULT 0,
permissions TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
role_id INTEGER NOT NULL,
granted_by TEXT,
granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
username TEXT NOT NULL,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT,
resource_name TEXT,
details TEXT,
ip_address TEXT,
user_agent TEXT,
success INTEGER NOT NULL,
error_message TEXT,
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS session_recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
access_id INTEGER,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TEXT,
duration INTEGER,
commands TEXT,
dangerous_actions TEXT,
recording_path TEXT,
terminated_by_owner INTEGER DEFAULT 0,
termination_reason TEXT,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
);
`);
try {
@@ -491,6 +568,12 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_data", "stats_config", "TEXT");
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
addColumnIfNotExists("ssh_data", "quick_actions", "TEXT");
addColumnIfNotExists(
"ssh_data",
"enable_docker",
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists("ssh_data", "docker_config", "TEXT");
// SOCKS5 Proxy columns
addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER");
@@ -564,6 +647,331 @@ const migrateSchema = () => {
}
}
// RBAC Phase 1: Host Access table
try {
sqlite.prepare("SELECT id FROM host_access LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS host_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL,
user_id TEXT,
role_id INTEGER,
granted_by TEXT NOT NULL,
permission_level TEXT NOT NULL DEFAULT 'use',
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_accessed_at TEXT,
access_count INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
);
`);
databaseLogger.info("Created host_access table", {
operation: "schema_migration",
});
} catch (createError) {
databaseLogger.warn("Failed to create host_access table", {
operation: "schema_migration",
error: createError,
});
}
}
// Migration: Add role_id column to existing host_access table
try {
sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get();
} catch {
try {
sqlite.exec("ALTER TABLE host_access ADD COLUMN role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE");
databaseLogger.info("Added role_id column to host_access table", {
operation: "schema_migration",
});
} catch (alterError) {
databaseLogger.warn("Failed to add role_id column", {
operation: "schema_migration",
error: alterError,
});
}
}
// RBAC Phase 2: Roles tables
try {
sqlite.prepare("SELECT id FROM roles LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
description TEXT,
is_system INTEGER NOT NULL DEFAULT 0,
permissions TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
databaseLogger.info("Created roles table", {
operation: "schema_migration",
});
} catch (createError) {
databaseLogger.warn("Failed to create roles table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM user_roles LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS user_roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
role_id INTEGER NOT NULL,
granted_by TEXT,
granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE SET NULL
);
`);
databaseLogger.info("Created user_roles table", {
operation: "schema_migration",
});
} catch (createError) {
databaseLogger.warn("Failed to create user_roles table", {
operation: "schema_migration",
error: createError,
});
}
}
// RBAC Phase 3: Audit logging tables
try {
sqlite.prepare("SELECT id FROM audit_logs LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
username TEXT NOT NULL,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT,
resource_name TEXT,
details TEXT,
ip_address TEXT,
user_agent TEXT,
success INTEGER NOT NULL,
error_message TEXT,
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
databaseLogger.info("Created audit_logs table", {
operation: "schema_migration",
});
} catch (createError) {
databaseLogger.warn("Failed to create audit_logs table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM session_recordings LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS session_recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
access_id INTEGER,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TEXT,
duration INTEGER,
commands TEXT,
dangerous_actions TEXT,
recording_path TEXT,
terminated_by_owner INTEGER DEFAULT 0,
termination_reason TEXT,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
);
`);
databaseLogger.info("Created session_recordings table", {
operation: "schema_migration",
});
} catch (createError) {
databaseLogger.warn("Failed to create session_recordings table", {
operation: "schema_migration",
error: createError,
});
}
}
// Clean up old system roles and seed correct ones
try {
// First, check what roles exist
const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>;
databaseLogger.info("Current roles in database", {
operation: "schema_migration",
roles: existingRoles,
});
// Migration: Remove ALL old unwanted roles (system or not) and keep only admin and user
try {
const validSystemRoles = ['admin', 'user'];
const unwantedRoleNames = ['superAdmin', 'powerUser', 'readonly', 'member'];
let deletedCount = 0;
// First delete known unwanted role names
const deleteByName = sqlite.prepare("DELETE FROM roles WHERE name = ?");
for (const roleName of unwantedRoleNames) {
const result = deleteByName.run(roleName);
if (result.changes > 0) {
deletedCount += result.changes;
databaseLogger.info(`Deleted role by name: ${roleName}`, {
operation: "schema_migration",
});
}
}
// Then delete any system roles that are not admin or user
const deleteOldSystemRole = sqlite.prepare("DELETE FROM roles WHERE name = ? AND is_system = 1");
for (const role of existingRoles) {
if (role.is_system === 1 && !validSystemRoles.includes(role.name) && !unwantedRoleNames.includes(role.name)) {
const result = deleteOldSystemRole.run(role.name);
if (result.changes > 0) {
deletedCount += result.changes;
databaseLogger.info(`Deleted system role: ${role.name}`, {
operation: "schema_migration",
});
}
}
}
databaseLogger.info("Cleanup completed", {
operation: "schema_migration",
deletedCount,
});
} catch (cleanupError) {
databaseLogger.warn("Failed to clean up old system roles", {
operation: "schema_migration",
error: cleanupError,
});
}
// Ensure only admin and user system roles exist
const systemRoles = [
{
name: "admin",
displayName: "rbac.roles.admin",
description: "Administrator with full access",
permissions: null,
},
{
name: "user",
displayName: "rbac.roles.user",
description: "Regular user",
permissions: null,
},
];
for (const role of systemRoles) {
const existingRole = sqlite.prepare("SELECT id FROM roles WHERE name = ?").get(role.name);
if (!existingRole) {
// Create if doesn't exist
try {
sqlite.prepare(`
INSERT INTO roles (name, display_name, description, is_system, permissions)
VALUES (?, ?, ?, 1, ?)
`).run(role.name, role.displayName, role.description, role.permissions);
} catch (insertError) {
databaseLogger.warn(`Failed to create system role: ${role.name}`, {
operation: "schema_migration",
error: insertError,
});
}
}
}
databaseLogger.info("System roles migration completed", {
operation: "schema_migration",
});
// Migrate existing is_admin users to roles
try {
const adminUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 1").all() as { id: string }[];
const normalUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 0").all() as { id: string }[];
const adminRole = sqlite.prepare("SELECT id FROM roles WHERE name = 'admin'").get() as { id: number } | undefined;
const userRole = sqlite.prepare("SELECT id FROM roles WHERE name = 'user'").get() as { id: number } | undefined;
if (adminRole) {
const insertUserRole = sqlite.prepare(`
INSERT OR IGNORE INTO user_roles (user_id, role_id, granted_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
`);
for (const admin of adminUsers) {
try {
insertUserRole.run(admin.id, adminRole.id);
} catch (error) {
// Ignore duplicate errors
}
}
databaseLogger.info("Migrated admin users to admin role", {
operation: "schema_migration",
count: adminUsers.length,
});
}
if (userRole) {
const insertUserRole = sqlite.prepare(`
INSERT OR IGNORE INTO user_roles (user_id, role_id, granted_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
`);
for (const user of normalUsers) {
try {
insertUserRole.run(user.id, userRole.id);
} catch (error) {
// Ignore duplicate errors
}
}
databaseLogger.info("Migrated normal users to user role", {
operation: "schema_migration",
count: normalUsers.length,
});
}
} catch (migrationError) {
databaseLogger.warn("Failed to migrate existing users to roles", {
operation: "schema_migration",
error: migrationError,
});
}
} catch (seedError) {
databaseLogger.warn("Failed to seed system roles", {
operation: "schema_migration",
error: seedError,
});
}
databaseLogger.success("Schema migration completed", {
operation: "schema_migration",
});

View File

@@ -86,6 +86,9 @@ export const sshData = sqliteTable("ssh_data", {
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
.notNull()
.default(true),
enableDocker: integer("enable_docker", { mode: "boolean" })
.notNull()
.default(false),
defaultPath: text("default_path"),
statsConfig: text("stats_config"),
terminalConfig: text("terminal_config"),
@@ -284,3 +287,140 @@ export const commandHistory = sqliteTable("command_history", {
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// RBAC Phase 1: Host Sharing
export const hostAccess = sqliteTable("host_access", {
id: integer("id").primaryKey({ autoIncrement: true }),
hostId: integer("host_id")
.notNull()
.references(() => sshData.id, { onDelete: "cascade" }),
// Share target: either userId OR roleId (at least one must be set)
userId: text("user_id")
.references(() => users.id, { onDelete: "cascade" }), // Optional
roleId: integer("role_id")
.references(() => roles.id, { onDelete: "cascade" }), // Optional
grantedBy: text("granted_by")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Permission level
permissionLevel: text("permission_level")
.notNull()
.default("use"), // "view" | "use" | "manage"
// Time-based access
expiresAt: text("expires_at"), // NULL = never expires
// Metadata
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
lastAccessedAt: text("last_accessed_at"),
accessCount: integer("access_count").notNull().default(0),
});
// RBAC Phase 2: Roles
export const roles = sqliteTable("roles", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull().unique(),
displayName: text("display_name").notNull(), // For i18n
description: text("description"),
// System roles cannot be deleted
isSystem: integer("is_system", { mode: "boolean" })
.notNull()
.default(false),
// Permissions stored as JSON array (optional - used for grouping only in current phase)
permissions: text("permissions"), // ["hosts.*", "credentials.read", ...] - optional
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const userRoles = sqliteTable("user_roles", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id")
.notNull()
.references(() => roles.id, { onDelete: "cascade" }),
grantedBy: text("granted_by").references(() => users.id, {
onDelete: "set null",
}),
grantedAt: text("granted_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// RBAC Phase 3: Audit Logging
export const auditLogs = sqliteTable("audit_logs", {
id: integer("id").primaryKey({ autoIncrement: true }),
// Who
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
username: text("username").notNull(), // Snapshot in case user deleted
// What
action: text("action").notNull(), // "create", "read", "update", "delete", "share"
resourceType: text("resource_type").notNull(), // "host", "credential", "user", "session"
resourceId: text("resource_id"), // Can be text or number, store as text
resourceName: text("resource_name"), // Human-readable identifier
// Context
details: text("details"), // JSON: { oldValue, newValue, reason, ... }
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
// Result
success: integer("success", { mode: "boolean" }).notNull(),
errorMessage: text("error_message"),
// When
timestamp: text("timestamp")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const sessionRecordings = sqliteTable("session_recordings", {
id: integer("id").primaryKey({ autoIncrement: true }),
hostId: integer("host_id")
.notNull()
.references(() => sshData.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
accessId: integer("access_id").references(() => hostAccess.id, {
onDelete: "set null",
}),
// Session info
startedAt: text("started_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
endedAt: text("ended_at"),
duration: integer("duration"), // seconds
// Command log (lightweight)
commands: text("commands"), // JSON: [{ts, cmd, exitCode, blocked}]
dangerousActions: text("dangerous_actions"), // JSON: blocked commands
// Full recording (optional, heavy)
recordingPath: text("recording_path"), // Path to .cast file
// Metadata
terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" })
.default(false),
terminationReason: text("termination_reason"),
});

View File

@@ -1,4 +1,7 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type {
AuthenticatedRequest,
CredentialBackend,
} from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { sshCredentials, sshCredentialUsage, sshData } from "../db/schema.js";
@@ -1124,10 +1127,9 @@ router.post(
async function deploySSHKeyToHost(
hostConfig: Record<string, unknown>,
publicKey: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_credentialData: Record<string, unknown>,
credData: CredentialBackend,
): Promise<{ success: boolean; message?: string; error?: string }> {
const publicKey = credData.public_key as string;
return new Promise((resolve) => {
const conn = new Client();
@@ -1248,7 +1250,7 @@ async function deploySSHKeyToHost(
.replace(/'/g, "'\\''");
conn.exec(
`printf '%s\\n' '${escapedKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
`printf '%s\\n' '${escapedKey} ${credData.name}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
(err, stream) => {
if (err) {
clearTimeout(addTimeout);
@@ -1510,7 +1512,7 @@ router.post(
});
}
const credData = credential[0];
const credData = credential[0] as unknown as CredentialBackend;
if (credData.authType !== "key") {
return res.status(400).json({
@@ -1519,7 +1521,7 @@ router.post(
});
}
const publicKey = credData.public_key || credData.publicKey;
const publicKey = credData.public_key;
if (!publicKey) {
return res.status(400).json({
success: false,
@@ -1601,7 +1603,6 @@ router.post(
const deployResult = await deploySSHKeyToHost(
hostConfig,
publicKey as string,
credData,
);

View File

@@ -0,0 +1,908 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import {
hostAccess,
sshData,
users,
roles,
userRoles,
auditLogs,
} from "../db/schema.js";
import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm";
import type { Request, Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
/**
* Share a host with a user or role
* POST /rbac/host/:id/share
*/
router.post(
"/host/:id/share",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const hostId = parseInt(req.params.id, 10);
const userId = req.userId!;
if (isNaN(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
try {
const {
targetType = "user", // "user" or "role"
targetUserId,
targetRoleId,
durationHours,
permissionLevel = "use",
} = req.body;
// Validate target type
if (!["user", "role"].includes(targetType)) {
return res
.status(400)
.json({ error: "Invalid target type. Must be 'user' or 'role'" });
}
// Validate required fields based on target type
if (targetType === "user" && !isNonEmptyString(targetUserId)) {
return res
.status(400)
.json({ error: "Target user ID is required when sharing with user" });
}
if (targetType === "role" && !targetRoleId) {
return res
.status(400)
.json({ error: "Target role ID is required when sharing with role" });
}
// Verify user owns the host
const host = await db
.select()
.from(sshData)
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
.limit(1);
if (host.length === 0) {
databaseLogger.warn("Attempt to share host not owned by user", {
operation: "share_host",
userId,
hostId,
});
return res.status(403).json({ error: "Not host owner" });
}
// Verify target exists (user or role)
if (targetType === "user") {
const targetUser = await db
.select({ id: users.id, username: users.username })
.from(users)
.where(eq(users.id, targetUserId))
.limit(1);
if (targetUser.length === 0) {
return res.status(404).json({ error: "Target user not found" });
}
} else {
const targetRole = await db
.select({ id: roles.id, name: roles.name })
.from(roles)
.where(eq(roles.id, targetRoleId))
.limit(1);
if (targetRole.length === 0) {
return res.status(404).json({ error: "Target role not found" });
}
}
// Calculate expiry time
let expiresAt: string | null = null;
if (
durationHours &&
typeof durationHours === "number" &&
durationHours > 0
) {
const expiryDate = new Date();
expiryDate.setHours(expiryDate.getHours() + durationHours);
expiresAt = expiryDate.toISOString();
}
// Validate permission level
const validLevels = ["view", "use", "manage"];
if (!validLevels.includes(permissionLevel)) {
return res.status(400).json({
error: "Invalid permission level",
validLevels,
});
}
// Check if access already exists
const whereConditions = [eq(hostAccess.hostId, hostId)];
if (targetType === "user") {
whereConditions.push(eq(hostAccess.userId, targetUserId));
} else {
whereConditions.push(eq(hostAccess.roleId, targetRoleId));
}
const existing = await db
.select()
.from(hostAccess)
.where(and(...whereConditions))
.limit(1);
if (existing.length > 0) {
// Update existing access
await db
.update(hostAccess)
.set({
permissionLevel,
expiresAt,
})
.where(eq(hostAccess.id, existing[0].id));
databaseLogger.info("Updated existing host access", {
operation: "share_host",
hostId,
targetType,
targetUserId: targetType === "user" ? targetUserId : undefined,
targetRoleId: targetType === "role" ? targetRoleId : undefined,
permissionLevel,
expiresAt,
});
return res.json({
success: true,
message: "Host access updated",
expiresAt,
});
}
// Create new access
const result = await db.insert(hostAccess).values({
hostId,
userId: targetType === "user" ? targetUserId : null,
roleId: targetType === "role" ? targetRoleId : null,
grantedBy: userId,
permissionLevel,
expiresAt,
});
databaseLogger.info("Created host access", {
operation: "share_host",
hostId,
hostName: host[0].name,
targetType,
targetUserId: targetType === "user" ? targetUserId : undefined,
targetRoleId: targetType === "role" ? targetRoleId : undefined,
permissionLevel,
expiresAt,
});
res.json({
success: true,
message: `Host shared successfully with ${targetType}`,
expiresAt,
});
} catch (error) {
databaseLogger.error("Failed to share host", error, {
operation: "share_host",
hostId,
userId,
});
res.status(500).json({ error: "Failed to share host" });
}
},
);
/**
* Revoke host access
* DELETE /rbac/host/:id/access/:accessId
*/
router.delete(
"/host/:id/access/:accessId",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const hostId = parseInt(req.params.id, 10);
const accessId = parseInt(req.params.accessId, 10);
const userId = req.userId!;
if (isNaN(hostId) || isNaN(accessId)) {
return res.status(400).json({ error: "Invalid ID" });
}
try {
// Verify user owns the host
const host = await db
.select()
.from(sshData)
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
.limit(1);
if (host.length === 0) {
return res.status(403).json({ error: "Not host owner" });
}
// Delete the access
await db.delete(hostAccess).where(eq(hostAccess.id, accessId));
databaseLogger.info("Revoked host access", {
operation: "revoke_host_access",
hostId,
accessId,
userId,
});
res.json({ success: true, message: "Access revoked" });
} catch (error) {
databaseLogger.error("Failed to revoke host access", error, {
operation: "revoke_host_access",
hostId,
accessId,
userId,
});
res.status(500).json({ error: "Failed to revoke access" });
}
},
);
/**
* Get host access list
* GET /rbac/host/:id/access
*/
router.get(
"/host/:id/access",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const hostId = parseInt(req.params.id, 10);
const userId = req.userId!;
if (isNaN(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
try {
// Verify user owns the host
const host = await db
.select()
.from(sshData)
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
.limit(1);
if (host.length === 0) {
return res.status(403).json({ error: "Not host owner" });
}
// Get all access records (both user and role based)
const rawAccessList = await db
.select({
id: hostAccess.id,
userId: hostAccess.userId,
roleId: hostAccess.roleId,
username: users.username,
roleName: roles.name,
roleDisplayName: roles.displayName,
grantedBy: hostAccess.grantedBy,
grantedByUsername: sql<string>`(SELECT username FROM users WHERE id = ${hostAccess.grantedBy})`,
permissionLevel: hostAccess.permissionLevel,
expiresAt: hostAccess.expiresAt,
createdAt: hostAccess.createdAt,
lastAccessedAt: hostAccess.lastAccessedAt,
accessCount: hostAccess.accessCount,
})
.from(hostAccess)
.leftJoin(users, eq(hostAccess.userId, users.id))
.leftJoin(roles, eq(hostAccess.roleId, roles.id))
.where(eq(hostAccess.hostId, hostId))
.orderBy(desc(hostAccess.createdAt));
// Format access list with type information
const accessList = rawAccessList.map((access) => ({
id: access.id,
targetType: access.userId ? "user" : "role",
userId: access.userId,
roleId: access.roleId,
username: access.username,
roleName: access.roleName,
roleDisplayName: access.roleDisplayName,
grantedBy: access.grantedBy,
grantedByUsername: access.grantedByUsername,
permissionLevel: access.permissionLevel,
expiresAt: access.expiresAt,
createdAt: access.createdAt,
lastAccessedAt: access.lastAccessedAt,
accessCount: access.accessCount,
}));
res.json({ accessList });
} catch (error) {
databaseLogger.error("Failed to get host access list", error, {
operation: "get_host_access_list",
hostId,
userId,
});
res.status(500).json({ error: "Failed to get access list" });
}
},
);
/**
* Get user's shared hosts (hosts shared WITH this user)
* GET /rbac/shared-hosts
*/
router.get(
"/shared-hosts",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId!;
try {
const now = new Date().toISOString();
const sharedHosts = await db
.select({
id: sshData.id,
name: sshData.name,
ip: sshData.ip,
port: sshData.port,
username: sshData.username,
folder: sshData.folder,
tags: sshData.tags,
permissionLevel: hostAccess.permissionLevel,
expiresAt: hostAccess.expiresAt,
grantedBy: hostAccess.grantedBy,
ownerUsername: users.username,
})
.from(hostAccess)
.innerJoin(sshData, eq(hostAccess.hostId, sshData.id))
.innerJoin(users, eq(sshData.userId, users.id))
.where(
and(
eq(hostAccess.userId, userId),
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
),
)
.orderBy(desc(hostAccess.createdAt));
res.json({ sharedHosts });
} catch (error) {
databaseLogger.error("Failed to get shared hosts", error, {
operation: "get_shared_hosts",
userId,
});
res.status(500).json({ error: "Failed to get shared hosts" });
}
},
);
/**
* Get all roles
* GET /rbac/roles
*/
router.get(
"/roles",
authenticateJWT,
permissionManager.requireAdmin(),
async (req: AuthenticatedRequest, res: Response) => {
try {
const allRoles = await db
.select()
.from(roles)
.orderBy(roles.isSystem, roles.name);
const rolesWithParsedPermissions = allRoles.map((role) => ({
...role,
permissions: JSON.parse(role.permissions),
}));
res.json({ roles: rolesWithParsedPermissions });
} catch (error) {
databaseLogger.error("Failed to get roles", error, {
operation: "get_roles",
});
res.status(500).json({ error: "Failed to get roles" });
}
},
);
// ============================================================================
// Role Management (CRUD)
// ============================================================================
/**
* Get all roles
* GET /rbac/roles
*/
router.get(
"/roles",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
try {
const rolesList = await db
.select({
id: roles.id,
name: roles.name,
displayName: roles.displayName,
description: roles.description,
isSystem: roles.isSystem,
createdAt: roles.createdAt,
updatedAt: roles.updatedAt,
})
.from(roles)
.orderBy(roles.isSystem, roles.name);
res.json({ roles: rolesList });
} catch (error) {
databaseLogger.error("Failed to get roles", error, {
operation: "get_roles",
});
res.status(500).json({ error: "Failed to get roles" });
}
},
);
/**
* Create new role
* POST /rbac/roles
*/
router.post(
"/roles",
authenticateJWT,
permissionManager.requireAdmin(),
async (req: AuthenticatedRequest, res: Response) => {
const { name, displayName, description } = req.body;
// Validate required fields
if (!isNonEmptyString(name) || !isNonEmptyString(displayName)) {
return res.status(400).json({
error: "Role name and display name are required",
});
}
// Validate name format (alphanumeric, underscore, hyphen only)
if (!/^[a-z0-9_-]+$/.test(name)) {
return res.status(400).json({
error:
"Role name must contain only lowercase letters, numbers, underscores, and hyphens",
});
}
try {
// Check if role name already exists
const existing = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, name))
.limit(1);
if (existing.length > 0) {
return res.status(409).json({
error: "A role with this name already exists",
});
}
// Create new role
const result = await db.insert(roles).values({
name,
displayName,
description: description || null,
isSystem: false,
permissions: null, // Roles are for grouping only
});
const newRoleId = result.lastInsertRowid;
databaseLogger.info("Created new role", {
operation: "create_role",
roleId: newRoleId,
roleName: name,
});
res.status(201).json({
success: true,
roleId: newRoleId,
message: "Role created successfully",
});
} catch (error) {
databaseLogger.error("Failed to create role", error, {
operation: "create_role",
roleName: name,
});
res.status(500).json({ error: "Failed to create role" });
}
},
);
/**
* Update role
* PUT /rbac/roles/:id
*/
router.put(
"/roles/:id",
authenticateJWT,
permissionManager.requireAdmin(),
async (req: AuthenticatedRequest, res: Response) => {
const roleId = parseInt(req.params.id, 10);
const { displayName, description } = req.body;
if (isNaN(roleId)) {
return res.status(400).json({ error: "Invalid role ID" });
}
// Validate at least one field to update
if (!displayName && description === undefined) {
return res.status(400).json({
error: "At least one field (displayName or description) is required",
});
}
try {
// Get existing role
const existingRole = await db
.select({
id: roles.id,
name: roles.name,
isSystem: roles.isSystem,
})
.from(roles)
.where(eq(roles.id, roleId))
.limit(1);
if (existingRole.length === 0) {
return res.status(404).json({ error: "Role not found" });
}
// Build update object
const updates: {
displayName?: string;
description?: string | null;
updatedAt: string;
} = {
updatedAt: new Date().toISOString(),
};
if (displayName) {
updates.displayName = displayName;
}
if (description !== undefined) {
updates.description = description || null;
}
// Update role
await db.update(roles).set(updates).where(eq(roles.id, roleId));
databaseLogger.info("Updated role", {
operation: "update_role",
roleId,
roleName: existingRole[0].name,
});
res.json({
success: true,
message: "Role updated successfully",
});
} catch (error) {
databaseLogger.error("Failed to update role", error, {
operation: "update_role",
roleId,
});
res.status(500).json({ error: "Failed to update role" });
}
},
);
/**
* Delete role
* DELETE /rbac/roles/:id
*/
router.delete(
"/roles/:id",
authenticateJWT,
permissionManager.requireAdmin(),
async (req: AuthenticatedRequest, res: Response) => {
const roleId = parseInt(req.params.id, 10);
if (isNaN(roleId)) {
return res.status(400).json({ error: "Invalid role ID" });
}
try {
// Get role details
const role = await db
.select({
id: roles.id,
name: roles.name,
isSystem: roles.isSystem,
})
.from(roles)
.where(eq(roles.id, roleId))
.limit(1);
if (role.length === 0) {
return res.status(404).json({ error: "Role not found" });
}
// Cannot delete system roles
if (role[0].isSystem) {
return res.status(403).json({
error: "Cannot delete system roles",
});
}
// Check if role is in use
const usageCount = await db
.select({ count: sql<number>`count(*)` })
.from(userRoles)
.where(eq(userRoles.roleId, roleId));
if (usageCount[0].count > 0) {
return res.status(409).json({
error: `Cannot delete role: ${usageCount[0].count} user(s) are assigned to this role`,
usageCount: usageCount[0].count,
});
}
// Check if role is used in host_access
const hostAccessCount = await db
.select({ count: sql<number>`count(*)` })
.from(hostAccess)
.where(eq(hostAccess.roleId, roleId));
if (hostAccessCount[0].count > 0) {
return res.status(409).json({
error: `Cannot delete role: ${hostAccessCount[0].count} host(s) are shared with this role`,
hostAccessCount: hostAccessCount[0].count,
});
}
// Delete role
await db.delete(roles).where(eq(roles.id, roleId));
databaseLogger.info("Deleted role", {
operation: "delete_role",
roleId,
roleName: role[0].name,
});
res.json({
success: true,
message: "Role deleted successfully",
});
} catch (error) {
databaseLogger.error("Failed to delete role", error, {
operation: "delete_role",
roleId,
});
res.status(500).json({ error: "Failed to delete role" });
}
},
);
// ============================================================================
// User-Role Assignment
// ============================================================================
/**
* Assign role to user
* POST /rbac/users/:userId/roles
*/
router.post(
"/users/:userId/roles",
authenticateJWT,
permissionManager.requireAdmin(),
async (req: AuthenticatedRequest, res: Response) => {
const targetUserId = req.params.userId;
const currentUserId = req.userId!;
try {
const { roleId } = req.body;
if (typeof roleId !== "number") {
return res.status(400).json({ error: "Role ID is required" });
}
// Verify target user exists
const targetUser = await db
.select()
.from(users)
.where(eq(users.id, targetUserId))
.limit(1);
if (targetUser.length === 0) {
return res.status(404).json({ error: "User not found" });
}
// Verify role exists
const role = await db
.select()
.from(roles)
.where(eq(roles.id, roleId))
.limit(1);
if (role.length === 0) {
return res.status(404).json({ error: "Role not found" });
}
// Prevent manual assignment of system roles
if (role[0].isSystem) {
return res.status(403).json({
error:
"System roles (admin, user) are automatically assigned and cannot be manually assigned",
});
}
// Check if already assigned
const existing = await db
.select()
.from(userRoles)
.where(
and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)),
)
.limit(1);
if (existing.length > 0) {
return res.status(409).json({ error: "Role already assigned" });
}
// Assign role
await db.insert(userRoles).values({
userId: targetUserId,
roleId,
grantedBy: currentUserId,
});
// Invalidate permission cache
permissionManager.invalidateUserPermissionCache(targetUserId);
databaseLogger.info("Assigned role to user", {
operation: "assign_role",
targetUserId,
roleId,
roleName: role[0].name,
grantedBy: currentUserId,
});
res.json({
success: true,
message: "Role assigned successfully",
});
} catch (error) {
databaseLogger.error("Failed to assign role", error, {
operation: "assign_role",
targetUserId,
});
res.status(500).json({ error: "Failed to assign role" });
}
},
);
/**
* Remove role from user
* DELETE /rbac/users/:userId/roles/:roleId
*/
router.delete(
"/users/:userId/roles/:roleId",
authenticateJWT,
permissionManager.requireAdmin(),
async (req: AuthenticatedRequest, res: Response) => {
const targetUserId = req.params.userId;
const roleId = parseInt(req.params.roleId, 10);
if (isNaN(roleId)) {
return res.status(400).json({ error: "Invalid role ID" });
}
try {
// Verify role exists and get its details
const role = await db
.select({
id: roles.id,
name: roles.name,
isSystem: roles.isSystem,
})
.from(roles)
.where(eq(roles.id, roleId))
.limit(1);
if (role.length === 0) {
return res.status(404).json({ error: "Role not found" });
}
// Prevent removal of system roles
if (role[0].isSystem) {
return res.status(403).json({
error:
"System roles (admin, user) are automatically assigned and cannot be removed",
});
}
// Delete the user-role assignment
await db
.delete(userRoles)
.where(
and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)),
);
// Invalidate permission cache
permissionManager.invalidateUserPermissionCache(targetUserId);
databaseLogger.info("Removed role from user", {
operation: "remove_role",
targetUserId,
roleId,
});
res.json({
success: true,
message: "Role removed successfully",
});
} catch (error) {
databaseLogger.error("Failed to remove role", error, {
operation: "remove_role",
targetUserId,
roleId,
});
res.status(500).json({ error: "Failed to remove role" });
}
},
);
/**
* Get user's roles
* GET /rbac/users/:userId/roles
*/
router.get(
"/users/:userId/roles",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const targetUserId = req.params.userId;
const currentUserId = req.userId!;
// Users can only see their own roles unless they're admin
if (
targetUserId !== currentUserId &&
!(await permissionManager.isAdmin(currentUserId))
) {
return res.status(403).json({ error: "Access denied" });
}
try {
const userRolesList = await db
.select({
id: userRoles.id,
roleId: roles.id,
roleName: roles.name,
roleDisplayName: roles.displayName,
description: roles.description,
isSystem: roles.isSystem,
grantedAt: userRoles.grantedAt,
})
.from(userRoles)
.innerJoin(roles, eq(userRoles.roleId, roles.id))
.where(eq(userRoles.userId, targetUserId));
res.json({ roles: userRolesList });
} catch (error) {
databaseLogger.error("Failed to get user roles", error, {
operation: "get_user_roles",
targetUserId,
});
res.status(500).json({ error: "Failed to get user roles" });
}
},
);
export default router;

View File

@@ -11,8 +11,10 @@ import {
sshFolders,
commandHistory,
recentActivity,
hostAccess,
userRoles,
} from "../db/schema.js";
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
import { eq, and, desc, isNotNull, or, isNull, gte, sql, inArray } from "drizzle-orm";
import type { Request, Response } from "express";
import multer from "multer";
import { sshLogger } from "../../utils/logger.js";
@@ -235,6 +237,7 @@ router.post(
enableTerminal,
enableTunnel,
enableFileManager,
enableDocker,
defaultPath,
tunnelConnections,
jumpHosts,
@@ -293,6 +296,7 @@ router.post(
? JSON.stringify(quickActions)
: null,
enableFileManager: enableFileManager ? 1 : 0,
enableDocker: enableDocker ? 1 : 0,
defaultPath: defaultPath || null,
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
@@ -360,6 +364,7 @@ router.post(
? JSON.parse(createdHost.jumpHosts as string)
: [],
enableFileManager: !!createdHost.enableFileManager,
enableDocker: !!createdHost.enableDocker,
statsConfig: createdHost.statsConfig
? JSON.parse(createdHost.statsConfig as string)
: undefined,
@@ -476,6 +481,7 @@ router.put(
enableTerminal,
enableTunnel,
enableFileManager,
enableDocker,
defaultPath,
tunnelConnections,
jumpHosts,
@@ -528,6 +534,7 @@ router.put(
? JSON.stringify(quickActions)
: null,
enableFileManager: enableFileManager ? 1 : 0,
enableDocker: enableDocker ? 1 : 0,
defaultPath: defaultPath || null,
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
@@ -613,9 +620,13 @@ router.put(
? JSON.parse(updatedHost.jumpHosts as string)
: [],
enableFileManager: !!updatedHost.enableFileManager,
enableDocker: !!updatedHost.enableDocker,
statsConfig: updatedHost.statsConfig
? JSON.parse(updatedHost.statsConfig as string)
: undefined,
dockerConfig: updatedHost.dockerConfig
? JSON.parse(updatedHost.dockerConfig as string)
: undefined,
};
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
@@ -687,8 +698,98 @@ router.get(
return res.status(400).json({ error: "Invalid userId" });
}
try {
const now = new Date().toISOString();
// Get user's role IDs
const userRoleIds = await db
.select({ roleId: userRoles.roleId })
.from(userRoles)
.where(eq(userRoles.userId, userId));
const roleIds = userRoleIds.map((r) => r.roleId);
// Query own hosts + shared hosts with access check
const rawData = await db
.select({
// All ssh_data fields
id: sshData.id,
userId: sshData.userId,
name: sshData.name,
ip: sshData.ip,
port: sshData.port,
username: sshData.username,
folder: sshData.folder,
tags: sshData.tags,
pin: sshData.pin,
authType: sshData.authType,
password: sshData.password,
key: sshData.key,
keyPassword: sshData.key_password,
keyType: sshData.keyType,
enableTerminal: sshData.enableTerminal,
enableTunnel: sshData.enableTunnel,
tunnelConnections: sshData.tunnelConnections,
jumpHosts: sshData.jumpHosts,
enableFileManager: sshData.enableFileManager,
defaultPath: sshData.defaultPath,
autostartPassword: sshData.autostartPassword,
autostartKey: sshData.autostartKey,
autostartKeyPassword: sshData.autostartKeyPassword,
forceKeyboardInteractive: sshData.forceKeyboardInteractive,
statsConfig: sshData.statsConfig,
terminalConfig: sshData.terminalConfig,
createdAt: sshData.createdAt,
updatedAt: sshData.updatedAt,
credentialId: sshData.credentialId,
overrideCredentialUsername: sshData.overrideCredentialUsername,
quickActions: sshData.quickActions,
// Shared access info
isShared: sql<boolean>`${hostAccess.id} IS NOT NULL`,
permissionLevel: hostAccess.permissionLevel,
expiresAt: hostAccess.expiresAt,
})
.from(sshData)
.leftJoin(
hostAccess,
and(
eq(hostAccess.hostId, sshData.id),
or(
eq(hostAccess.userId, userId),
roleIds.length > 0 ? inArray(hostAccess.roleId, roleIds) : sql`false`,
),
or(
isNull(hostAccess.expiresAt),
gte(hostAccess.expiresAt, now),
),
),
)
.where(
or(
eq(sshData.userId, userId), // Own hosts
and(
// Shared to user directly (not expired)
eq(hostAccess.userId, userId),
or(
isNull(hostAccess.expiresAt),
gte(hostAccess.expiresAt, now),
),
),
roleIds.length > 0
? and(
// Shared to user's role (not expired)
inArray(hostAccess.roleId, roleIds),
or(
isNull(hostAccess.expiresAt),
gte(hostAccess.expiresAt, now),
),
)
: sql`false`,
),
);
// Decrypt and format the data
const data = await SimpleDBOps.select(
db.select().from(sshData).where(eq(sshData.userId, userId)),
Promise.resolve(rawData),
"ssh_data",
userId,
);
@@ -714,6 +815,7 @@ router.get(
? JSON.parse(row.quickActions as string)
: [],
enableFileManager: !!row.enableFileManager,
enableDocker: !!row.enableDocker,
statsConfig: row.statsConfig
? JSON.parse(row.statsConfig as string)
: undefined,
@@ -724,6 +826,11 @@ router.get(
socks5ProxyChain: row.socks5ProxyChain
? JSON.parse(row.socks5ProxyChain as string)
: [],
// Add shared access metadata
isShared: !!row.isShared,
permissionLevel: row.permissionLevel || undefined,
sharedExpiresAt: row.expiresAt || undefined,
};
return (await resolveHostCredentials(baseHost)) || baseHost;
@@ -1492,6 +1599,29 @@ async function resolveHostCredentials(
host: Record<string, unknown>,
): Promise<Record<string, unknown>> {
try {
// Skip credential resolution for shared hosts
// Shared users cannot access the owner's encrypted credentials
if (host.isShared && host.credentialId) {
sshLogger.info(
`Skipping credential resolution for shared host ${host.id} with credentialId ${host.credentialId}`,
{
operation: "resolve_host_credentials_shared",
hostId: host.id as number,
isShared: host.isShared,
},
);
// Return host without resolving credentials
// The frontend should handle credential auth for shared hosts differently
const result = { ...host };
if (host.key_password !== undefined) {
if (result.keyPassword === undefined) {
result.keyPassword = host.key_password;
}
delete result.key_password;
}
return result;
}
if (host.credentialId && host.userId) {
const credentialId = host.credentialId as number;
const userId = host.userId as string;

View File

@@ -15,6 +15,8 @@ import {
sshCredentialUsage,
recentActivity,
snippets,
roles,
userRoles,
} from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import bcrypt from "bcryptjs";
@@ -210,6 +212,41 @@ router.post("/create", async (req, res) => {
totp_backup_codes: null,
});
// Assign default role to new user
try {
const defaultRoleName = isFirstUser ? "admin" : "user";
const defaultRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, defaultRoleName))
.limit(1);
if (defaultRole.length > 0) {
await db.insert(userRoles).values({
userId: id,
roleId: defaultRole[0].id,
grantedBy: id, // Self-assigned during registration
});
authLogger.info("Assigned default role to new user", {
operation: "assign_default_role",
userId: id,
roleName: defaultRoleName,
});
} else {
authLogger.warn("Default role not found during user registration", {
operation: "assign_default_role",
userId: id,
roleName: defaultRoleName,
});
}
} catch (roleError) {
authLogger.error("Failed to assign default role", roleError, {
operation: "assign_default_role",
userId: id,
});
// Don't fail user creation if role assignment fails
}
try {
await authManager.registerUser(id, password);
} catch (encryptionError) {
@@ -816,6 +853,41 @@ router.get("/oidc/callback", async (req, res) => {
scopes: String(config.scopes),
});
// Assign default role to new OIDC user
try {
const defaultRoleName = isFirstUser ? "admin" : "user";
const defaultRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, defaultRoleName))
.limit(1);
if (defaultRole.length > 0) {
await db.insert(userRoles).values({
userId: id,
roleId: defaultRole[0].id,
grantedBy: id, // Self-assigned during registration
});
authLogger.info("Assigned default role to new OIDC user", {
operation: "assign_default_role_oidc",
userId: id,
roleName: defaultRoleName,
});
} else {
authLogger.warn("Default role not found during OIDC user registration", {
operation: "assign_default_role_oidc",
userId: id,
roleName: defaultRoleName,
});
}
} catch (roleError) {
authLogger.error("Failed to assign default role to OIDC user", roleError, {
operation: "assign_default_role_oidc",
userId: id,
});
// Don't fail user creation if role assignment fails
}
try {
const sessionDurationMs =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"

View File

@@ -0,0 +1,687 @@
import { Client as SSHClient } from "ssh2";
import { WebSocketServer, WebSocket } from "ws";
import { parse as parseUrl } from "url";
import { AuthManager } from "../utils/auth-manager.js";
import { sshData, sshCredentials } from "../database/db/schema.js";
import { and, eq } from "drizzle-orm";
import { getDb } from "../database/db/index.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { systemLogger } from "../utils/logger.js";
import type { SSHHost } from "../../types/index.js";
const dockerConsoleLogger = systemLogger;
interface SSHSession {
client: SSHClient;
stream: any;
isConnected: boolean;
containerId?: string;
shell?: string;
}
const activeSessions = new Map<string, SSHSession>();
// WebSocket server on port 30008
const wss = new WebSocketServer({
port: 30008,
verifyClient: async (info, callback) => {
try {
const url = parseUrl(info.req.url || "", true);
const token = url.query.token as string;
if (!token) {
dockerConsoleLogger.warn("WebSocket connection rejected: No token", {
operation: "ws_verify",
});
return callback(false, 401, "Authentication required");
}
const authManager = AuthManager.getInstance();
const decoded = await authManager.verifyJWTToken(token);
if (!decoded || !decoded.userId) {
dockerConsoleLogger.warn(
"WebSocket connection rejected: Invalid token",
{
operation: "ws_verify",
},
);
return callback(false, 401, "Invalid token");
}
// Store userId in the request for later use
(info.req as any).userId = decoded.userId;
dockerConsoleLogger.info("WebSocket connection verified", {
operation: "ws_verify",
userId: decoded.userId,
});
callback(true);
} catch (error) {
dockerConsoleLogger.error("WebSocket verification error", error, {
operation: "ws_verify",
});
callback(false, 500, "Authentication failed");
}
},
});
// Helper function to detect available shell in container
async function detectShell(
session: SSHSession,
containerId: string,
): Promise<string> {
const shells = ["bash", "sh", "ash"];
for (const shell of shells) {
try {
await new Promise<void>((resolve, reject) => {
session.client.exec(
`docker exec ${containerId} which ${shell}`,
(err, stream) => {
if (err) return reject(err);
let output = "";
stream.on("data", (data: Buffer) => {
output += data.toString();
});
stream.on("close", (code: number) => {
if (code === 0 && output.trim()) {
resolve();
} else {
reject(new Error(`Shell ${shell} not found`));
}
});
stream.stderr.on("data", () => {
// Ignore stderr
});
},
);
});
// If we get here, the shell was found
return shell;
} catch {
// Try next shell
continue;
}
}
// Default to sh if nothing else works
return "sh";
}
// Helper function to create jump host chain
async function createJumpHostChain(
jumpHosts: any[],
userId: string,
): Promise<SSHClient | null> {
if (!jumpHosts || jumpHosts.length === 0) {
return null;
}
let currentClient: SSHClient | null = null;
for (let i = 0; i < jumpHosts.length; i++) {
const jumpHostId = jumpHosts[i].hostId;
// Fetch jump host from database
const jumpHostData = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.where(and(eq(sshData.id, jumpHostId), eq(sshData.userId, userId))),
"ssh_data",
userId,
);
if (jumpHostData.length === 0) {
throw new Error(`Jump host ${jumpHostId} not found`);
}
const jumpHost = jumpHostData[0] as unknown as SSHHost;
if (typeof jumpHost.jumpHosts === "string" && jumpHost.jumpHosts) {
try {
jumpHost.jumpHosts = JSON.parse(jumpHost.jumpHosts);
} catch (e) {
dockerConsoleLogger.error("Failed to parse jump hosts", e, {
hostId: jumpHost.id,
});
jumpHost.jumpHosts = [];
}
}
// Resolve credentials for jump host
let resolvedCredentials: any = {
password: jumpHost.password,
sshKey: jumpHost.key,
keyPassword: jumpHost.keyPassword,
authType: jumpHost.authType,
};
if (jumpHost.credentialId) {
const credentials = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, jumpHost.credentialId as number),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
userId,
);
if (credentials.length > 0) {
const credential = credentials[0];
resolvedCredentials = {
password: credential.password,
sshKey:
credential.private_key || credential.privateKey || credential.key,
keyPassword: credential.key_password || credential.keyPassword,
authType: credential.auth_type || credential.authType,
};
}
}
const client = new SSHClient();
const config: any = {
host: jumpHost.ip,
port: jumpHost.port || 22,
username: jumpHost.username,
tryKeyboard: true,
readyTimeout: 60000,
keepaliveInterval: 30000,
keepaliveCountMax: 120,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
};
// Set authentication
if (
resolvedCredentials.authType === "password" &&
resolvedCredentials.password
) {
config.password = resolvedCredentials.password;
} else if (
resolvedCredentials.authType === "key" &&
resolvedCredentials.sshKey
) {
const cleanKey = resolvedCredentials.sshKey
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
config.privateKey = Buffer.from(cleanKey, "utf8");
if (resolvedCredentials.keyPassword) {
config.passphrase = resolvedCredentials.keyPassword;
}
}
// If we have a previous client, use it as the sock
if (currentClient) {
await new Promise<void>((resolve, reject) => {
currentClient!.forwardOut(
"127.0.0.1",
0,
jumpHost.ip,
jumpHost.port || 22,
(err, stream) => {
if (err) return reject(err);
config.sock = stream;
resolve();
},
);
});
}
await new Promise<void>((resolve, reject) => {
client.on("ready", () => resolve());
client.on("error", reject);
client.connect(config);
});
currentClient = client;
}
return currentClient;
}
// Handle WebSocket connections
wss.on("connection", async (ws: WebSocket, req) => {
const userId = (req as any).userId;
const sessionId = `docker-console-${Date.now()}-${Math.random()}`;
dockerConsoleLogger.info("Docker console WebSocket connected", {
operation: "ws_connect",
sessionId,
userId,
});
let sshSession: SSHSession | null = null;
ws.on("message", async (data) => {
try {
const message = JSON.parse(data.toString());
switch (message.type) {
case "connect": {
const { hostConfig, containerId, shell, cols, rows } =
message.data as {
hostConfig: SSHHost;
containerId: string;
shell?: string;
cols?: number;
rows?: number;
};
if (
typeof hostConfig.jumpHosts === "string" &&
hostConfig.jumpHosts
) {
try {
hostConfig.jumpHosts = JSON.parse(hostConfig.jumpHosts);
} catch (e) {
dockerConsoleLogger.error("Failed to parse jump hosts", e, {
hostId: hostConfig.id,
});
hostConfig.jumpHosts = [];
}
}
if (!hostConfig || !containerId) {
ws.send(
JSON.stringify({
type: "error",
message: "Host configuration and container ID are required",
}),
);
return;
}
// Check if Docker is enabled for this host
if (!hostConfig.enableDocker) {
ws.send(
JSON.stringify({
type: "error",
message:
"Docker is not enabled for this host. Enable it in Host Settings.",
}),
);
return;
}
try {
// Resolve credentials
let resolvedCredentials: any = {
password: hostConfig.password,
sshKey: hostConfig.key,
keyPassword: hostConfig.keyPassword,
authType: hostConfig.authType,
};
if (hostConfig.credentialId) {
const credentials = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, hostConfig.credentialId as number),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
userId,
);
if (credentials.length > 0) {
const credential = credentials[0];
resolvedCredentials = {
password: credential.password,
sshKey:
credential.private_key ||
credential.privateKey ||
credential.key,
keyPassword:
credential.key_password || credential.keyPassword,
authType: credential.auth_type || credential.authType,
};
}
}
// Create SSH client
const client = new SSHClient();
const config: any = {
host: hostConfig.ip,
port: hostConfig.port || 22,
username: hostConfig.username,
tryKeyboard: true,
readyTimeout: 60000,
keepaliveInterval: 30000,
keepaliveCountMax: 120,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
};
// Set authentication
if (
resolvedCredentials.authType === "password" &&
resolvedCredentials.password
) {
config.password = resolvedCredentials.password;
} else if (
resolvedCredentials.authType === "key" &&
resolvedCredentials.sshKey
) {
const cleanKey = resolvedCredentials.sshKey
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
config.privateKey = Buffer.from(cleanKey, "utf8");
if (resolvedCredentials.keyPassword) {
config.passphrase = resolvedCredentials.keyPassword;
}
}
// Handle jump hosts if configured
if (hostConfig.jumpHosts && hostConfig.jumpHosts.length > 0) {
const jumpClient = await createJumpHostChain(
hostConfig.jumpHosts,
userId,
);
if (jumpClient) {
const stream = await new Promise<any>((resolve, reject) => {
jumpClient.forwardOut(
"127.0.0.1",
0,
hostConfig.ip,
hostConfig.port || 22,
(err, stream) => {
if (err) return reject(err);
resolve(stream);
},
);
});
config.sock = stream;
}
}
// Connect to SSH
await new Promise<void>((resolve, reject) => {
client.on("ready", () => resolve());
client.on("error", reject);
client.connect(config);
});
sshSession = {
client,
stream: null,
isConnected: true,
containerId,
};
activeSessions.set(sessionId, sshSession);
// Detect or use provided shell
const detectedShell =
shell || (await detectShell(sshSession, containerId));
sshSession.shell = detectedShell;
// Create docker exec PTY
const execCommand = `docker exec -it ${containerId} /bin/${detectedShell}`;
client.exec(
execCommand,
{
pty: {
term: "xterm-256color",
cols: cols || 80,
rows: rows || 24,
},
},
(err, stream) => {
if (err) {
dockerConsoleLogger.error(
"Failed to create docker exec",
err,
{
operation: "docker_exec",
sessionId,
containerId,
},
);
ws.send(
JSON.stringify({
type: "error",
message: `Failed to start console: ${err.message}`,
}),
);
return;
}
sshSession!.stream = stream;
// Forward stream output to WebSocket
stream.on("data", (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "output",
data: data.toString("utf8"),
}),
);
}
});
stream.stderr.on("data", (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "output",
data: data.toString("utf8"),
}),
);
}
});
stream.on("close", () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "disconnected",
message: "Console session ended",
}),
);
}
// Cleanup
if (sshSession) {
sshSession.client.end();
activeSessions.delete(sessionId);
}
});
ws.send(
JSON.stringify({
type: "connected",
data: { shell: detectedShell },
}),
);
dockerConsoleLogger.info("Docker console session started", {
operation: "console_start",
sessionId,
containerId,
shell: detectedShell,
});
},
);
} catch (error) {
dockerConsoleLogger.error("Failed to connect to container", error, {
operation: "console_connect",
sessionId,
containerId: message.data.containerId,
});
ws.send(
JSON.stringify({
type: "error",
message:
error instanceof Error
? error.message
: "Failed to connect to container",
}),
);
}
break;
}
case "input": {
if (sshSession && sshSession.stream) {
sshSession.stream.write(message.data);
}
break;
}
case "resize": {
if (sshSession && sshSession.stream) {
const { cols, rows } = message.data;
sshSession.stream.setWindow(rows, cols);
dockerConsoleLogger.debug("Console resized", {
operation: "console_resize",
sessionId,
cols,
rows,
});
}
break;
}
case "disconnect": {
if (sshSession) {
if (sshSession.stream) {
sshSession.stream.end();
}
sshSession.client.end();
activeSessions.delete(sessionId);
dockerConsoleLogger.info("Docker console disconnected", {
operation: "console_disconnect",
sessionId,
});
ws.send(
JSON.stringify({
type: "disconnected",
message: "Disconnected from container",
}),
);
}
break;
}
case "ping": {
// Respond with pong to acknowledge keepalive
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "pong" }));
}
break;
}
default:
dockerConsoleLogger.warn("Unknown message type", {
operation: "ws_message",
type: message.type,
});
}
} catch (error) {
dockerConsoleLogger.error("WebSocket message error", error, {
operation: "ws_message",
sessionId,
});
ws.send(
JSON.stringify({
type: "error",
message: error instanceof Error ? error.message : "An error occurred",
}),
);
}
});
ws.on("close", () => {
dockerConsoleLogger.info("WebSocket connection closed", {
operation: "ws_close",
sessionId,
});
// Cleanup SSH session if still active
if (sshSession) {
if (sshSession.stream) {
sshSession.stream.end();
}
sshSession.client.end();
activeSessions.delete(sessionId);
}
});
ws.on("error", (error) => {
dockerConsoleLogger.error("WebSocket error", error, {
operation: "ws_error",
sessionId,
});
// Cleanup
if (sshSession) {
if (sshSession.stream) {
sshSession.stream.end();
}
sshSession.client.end();
activeSessions.delete(sessionId);
}
});
});
dockerConsoleLogger.info(
"Docker console WebSocket server started on port 30008",
{
operation: "startup",
},
);
// Graceful shutdown
process.on("SIGTERM", () => {
dockerConsoleLogger.info("Shutting down Docker console server...", {
operation: "shutdown",
});
// Close all active sessions
activeSessions.forEach((session, sessionId) => {
if (session.stream) {
session.stream.end();
}
session.client.end();
dockerConsoleLogger.info("Closed session during shutdown", {
operation: "shutdown",
sessionId,
});
});
activeSessions.clear();
wss.close(() => {
dockerConsoleLogger.info("Docker console server closed", {
operation: "shutdown",
});
process.exit(0);
});
});

1464
src/backend/ssh/docker.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -323,7 +323,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
let sshConn: Client | null = null;
let sshStream: ClientChannel | null = null;
let pingInterval: NodeJS.Timeout | null = null;
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
let totpPromptSent = false;
let isKeyboardInteractive = false;
@@ -809,8 +808,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
);
});
setupPingInterval();
if (initialPath && initialPath.trim() !== "") {
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
stream.write(cdCommand);
@@ -1333,11 +1330,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
clearTimeout(timeoutId);
}
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
if (sshStream) {
try {
sshStream.end();
@@ -1374,24 +1366,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
}, 100);
}
function setupPingInterval() {
pingInterval = setInterval(() => {
if (sshConn && sshStream) {
try {
sshStream.write("\x00");
} catch (e: unknown) {
sshLogger.error(
"SSH keepalive failed: " +
(e instanceof Error ? e.message : "Unknown error"),
);
cleanupSSH();
}
} else if (!sshConn || !sshStream) {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
}
}, 30000);
}
// Note: PTY-level keepalive (writing \x00 to the stream) was removed.
// It was causing ^@ characters to appear in terminals with echoctl enabled.
// SSH-level keepalive is configured via connectConfig (keepaliveInterval,
// keepaliveCountMax, tcpKeepAlive), which handles connection health monitoring
// without producing visible output on the terminal.
//
// See: https://github.com/Termix-SSH/Support/issues/232
// See: https://github.com/Termix-SSH/Support/issues/309
});

View File

@@ -102,6 +102,8 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
await import("./ssh/tunnel.js");
await import("./ssh/file-manager.js");
await import("./ssh/server-stats.js");
await import("./ssh/docker.js");
await import("./ssh/docker-console.js");
await import("./dashboard.js");
process.on("SIGINT", () => {

View File

@@ -36,7 +36,7 @@ const SENSITIVE_FIELDS = [
const TRUNCATE_FIELDS = ["data", "content", "body", "response", "request"];
class Logger {
export class Logger {
private serviceName: string;
private serviceIcon: string;
private serviceColor: string;

View File

@@ -0,0 +1,456 @@
import type { Request, Response, NextFunction } from "express";
import { db } from "../database/db/index.js";
import {
hostAccess,
roles,
userRoles,
sshData,
users,
} from "../database/db/schema.js";
import { eq, and, or, isNull, gte, sql } from "drizzle-orm";
import { databaseLogger } from "./logger.js";
interface AuthenticatedRequest extends Request {
userId?: string;
dataKey?: Buffer;
}
interface HostAccessInfo {
hasAccess: boolean;
isOwner: boolean;
isShared: boolean;
permissionLevel?: string;
expiresAt?: string | null;
}
interface PermissionCheckResult {
allowed: boolean;
reason?: string;
}
class PermissionManager {
private static instance: PermissionManager;
private permissionCache: Map<string, { permissions: string[]; timestamp: number }>;
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
private constructor() {
this.permissionCache = new Map();
// Auto-cleanup expired host access every 1 minute
setInterval(
() => {
this.cleanupExpiredAccess().catch((error) => {
databaseLogger.error(
"Failed to run periodic host access cleanup",
error,
{
operation: "host_access_cleanup_periodic",
},
);
});
},
60 * 1000,
);
// Clear permission cache every 5 minutes
setInterval(
() => {
this.clearPermissionCache();
},
this.CACHE_TTL,
);
}
static getInstance(): PermissionManager {
if (!this.instance) {
this.instance = new PermissionManager();
}
return this.instance;
}
/**
* Clean up expired host access entries
*/
private async cleanupExpiredAccess(): Promise<void> {
try {
const now = new Date().toISOString();
const result = await db
.delete(hostAccess)
.where(
and(
sql`${hostAccess.expiresAt} IS NOT NULL`,
sql`${hostAccess.expiresAt} <= ${now}`,
),
)
.returning({ id: hostAccess.id });
if (result.length > 0) {
databaseLogger.info("Cleaned up expired host access", {
operation: "host_access_cleanup",
count: result.length,
});
}
} catch (error) {
databaseLogger.error("Failed to cleanup expired host access", error, {
operation: "host_access_cleanup_failed",
});
}
}
/**
* Clear permission cache
*/
private clearPermissionCache(): void {
this.permissionCache.clear();
}
/**
* Invalidate permission cache for a specific user
*/
invalidateUserPermissionCache(userId: string): void {
this.permissionCache.delete(userId);
}
/**
* Get user permissions from roles
*/
async getUserPermissions(userId: string): Promise<string[]> {
// Check cache first
const cached = this.permissionCache.get(userId);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
return cached.permissions;
}
try {
const userRoleRecords = await db
.select({
permissions: roles.permissions,
})
.from(userRoles)
.innerJoin(roles, eq(userRoles.roleId, roles.id))
.where(eq(userRoles.userId, userId));
const allPermissions = new Set<string>();
for (const record of userRoleRecords) {
try {
const permissions = JSON.parse(record.permissions) as string[];
for (const perm of permissions) {
allPermissions.add(perm);
}
} catch (parseError) {
databaseLogger.warn("Failed to parse role permissions", {
operation: "get_user_permissions",
userId,
error: parseError,
});
}
}
const permissionsArray = Array.from(allPermissions);
// Cache the result
this.permissionCache.set(userId, {
permissions: permissionsArray,
timestamp: Date.now(),
});
return permissionsArray;
} catch (error) {
databaseLogger.error("Failed to get user permissions", error, {
operation: "get_user_permissions",
userId,
});
return [];
}
}
/**
* Check if user has a specific permission
* Supports wildcards: "hosts.*", "*"
*/
async hasPermission(
userId: string,
permission: string,
): Promise<boolean> {
const userPermissions = await this.getUserPermissions(userId);
// Check for wildcard "*" (god mode)
if (userPermissions.includes("*")) {
return true;
}
// Check exact match
if (userPermissions.includes(permission)) {
return true;
}
// Check wildcard matches
const parts = permission.split(".");
for (let i = parts.length; i > 0; i--) {
const wildcardPermission = parts.slice(0, i).join(".") + ".*";
if (userPermissions.includes(wildcardPermission)) {
return true;
}
}
return false;
}
/**
* Check if user can access a specific host
*/
async canAccessHost(
userId: string,
hostId: number,
action: "read" | "write" | "execute" | "delete" | "share" = "read",
): Promise<HostAccessInfo> {
try {
// Check if user is the owner
const host = await db
.select()
.from(sshData)
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
.limit(1);
if (host.length > 0) {
return {
hasAccess: true,
isOwner: true,
isShared: false,
};
}
// Check if host is shared with user
const now = new Date().toISOString();
const sharedAccess = await db
.select()
.from(hostAccess)
.where(
and(
eq(hostAccess.hostId, hostId),
eq(hostAccess.userId, userId),
or(
isNull(hostAccess.expiresAt),
gte(hostAccess.expiresAt, now),
),
),
)
.limit(1);
if (sharedAccess.length > 0) {
const access = sharedAccess[0];
// Check permission level for write/delete actions
if (action === "write" || action === "delete") {
const level = access.permissionLevel;
if (level === "readonly") {
return {
hasAccess: false,
isOwner: false,
isShared: true,
permissionLevel: level,
expiresAt: access.expiresAt,
};
}
}
// Update last accessed time
try {
db.update(hostAccess)
.set({
lastAccessedAt: now,
accessCount: sql`${hostAccess.accessCount} + 1`,
})
.where(eq(hostAccess.id, access.id))
.run();
} catch (error) {
databaseLogger.warn("Failed to update host access stats", {
operation: "update_host_access_stats",
error,
});
}
return {
hasAccess: true,
isOwner: false,
isShared: true,
permissionLevel: access.permissionLevel,
expiresAt: access.expiresAt,
};
}
return {
hasAccess: false,
isOwner: false,
isShared: false,
};
} catch (error) {
databaseLogger.error("Failed to check host access", error, {
operation: "can_access_host",
userId,
hostId,
action,
});
return {
hasAccess: false,
isOwner: false,
isShared: false,
};
}
}
/**
* Check if user is admin (backward compatibility)
*/
async isAdmin(userId: string): Promise<boolean> {
try {
// Check old is_admin field
const user = await db
.select({ isAdmin: users.is_admin })
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (user.length > 0 && user[0].isAdmin) {
return true;
}
// Check if user has admin or super_admin role
const adminRoles = await db
.select({ roleName: roles.name })
.from(userRoles)
.innerJoin(roles, eq(userRoles.roleId, roles.id))
.where(
and(
eq(userRoles.userId, userId),
or(eq(roles.name, "admin"), eq(roles.name, "super_admin")),
),
);
return adminRoles.length > 0;
} catch (error) {
databaseLogger.error("Failed to check admin status", error, {
operation: "is_admin",
userId,
});
return false;
}
}
/**
* Middleware: Require specific permission
*/
requirePermission(permission: string) {
return async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
) => {
const userId = req.userId;
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
const hasPermission = await this.hasPermission(userId, permission);
if (!hasPermission) {
databaseLogger.warn("Permission denied", {
operation: "permission_check",
userId,
permission,
path: req.path,
});
return res.status(403).json({
error: "Insufficient permissions",
required: permission,
});
}
next();
};
}
/**
* Middleware: Require host access
*/
requireHostAccess(
hostIdParam: string = "id",
action: "read" | "write" | "execute" | "delete" | "share" = "read",
) {
return async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
) => {
const userId = req.userId;
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
const hostId = parseInt(req.params[hostIdParam], 10);
if (isNaN(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
const accessInfo = await this.canAccessHost(userId, hostId, action);
if (!accessInfo.hasAccess) {
databaseLogger.warn("Host access denied", {
operation: "host_access_check",
userId,
hostId,
action,
});
return res.status(403).json({
error: "Access denied to host",
hostId,
action,
});
}
// Attach access info to request for use in route handlers
(req as any).hostAccessInfo = accessInfo;
next();
};
}
/**
* Middleware: Require admin role (backward compatible)
*/
requireAdmin() {
return async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
) => {
const userId = req.userId;
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
const isAdmin = await this.isAdmin(userId);
if (!isAdmin) {
databaseLogger.warn("Admin access denied", {
operation: "admin_check",
userId,
path: req.path,
});
return res.status(403).json({ error: "Admin access required" });
}
next();
};
}
}
export { PermissionManager };
export type { AuthenticatedRequest, HostAccessInfo, PermissionCheckResult };

View File

@@ -0,0 +1,155 @@
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";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -705,6 +705,7 @@ export const DEFAULT_TERMINAL_CONFIG = {
startupSnippetId: null as number | null,
autoMosh: false,
moshCommand: "mosh-server new -s -l LANG=en_US.UTF-8",
sudoPasswordAutoFill: false,
};
export type TerminalConfigType = typeof DEFAULT_TERMINAL_CONFIG;

View File

@@ -36,24 +36,50 @@ export function useConfirmation() {
};
const confirmWithToast = (
message: string,
callback: () => void,
variant: "default" | "destructive" = "default",
) => {
const actionText = variant === "destructive" ? "Delete" : "Confirm";
const cancelText = "Cancel";
opts: ConfirmationOptions | string,
callback?: () => void,
variant?: "default" | "destructive",
): Promise<boolean> => {
// Legacy signature support
if (typeof opts === "string" && callback) {
const actionText = variant === "destructive" ? "Delete" : "Confirm";
const cancelText = "Cancel";
toast(message, {
action: {
label: actionText,
onClick: callback,
},
cancel: {
label: cancelText,
onClick: () => {},
},
duration: 10000,
className: variant === "destructive" ? "border-red-500" : "",
toast(opts, {
action: {
label: actionText,
onClick: callback,
},
cancel: {
label: cancelText,
onClick: () => {},
},
duration: 10000,
className: variant === "destructive" ? "border-red-500" : "",
});
return Promise.resolve(true);
}
// New Promise-based signature
return new Promise<boolean>((resolve) => {
const options = opts as ConfirmationOptions;
const actionText = options.confirmText || "Confirm";
const cancelText = options.cancelText || "Cancel";
const variantClass = options.variant === "destructive" ? "border-red-500" : "";
toast(options.title, {
description: options.description,
action: {
label: actionText,
onClick: () => resolve(true),
},
cancel: {
label: cancelText,
onClick: () => resolve(false),
},
duration: 10000,
className: variantClass,
});
});
};

View File

@@ -8,12 +8,13 @@ import deTranslation from "../locales/de/translation.json";
import ptbrTranslation from "../locales/pt-BR/translation.json";
import ruTranslation from "../locales/ru/translation.json";
import frTranslation from "../locales/fr/translation.json";
import koTranslation from "../locales/ko/translation.json";
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
supportedLngs: ["en", "zh", "de", "ptbr", "ru", "fr"],
supportedLngs: ["en", "zh", "de", "ptbr", "ru", "fr", "ko"],
fallbackLng: "en",
debug: false,
@@ -44,6 +45,9 @@ i18n
fr: {
translation: frTranslation,
},
ko: {
translation: koTranslation,
},
},
interpolation: {

File diff suppressed because it is too large Load Diff

View File

@@ -59,7 +59,6 @@
"keyTypeRSA": "RSA",
"keyTypeECDSA": "ECDSA",
"keyTypeEd25519": "Ed25519",
"updateCredential": "Update Credential",
"basicInfo": "Basic Info",
"authentication": "Authentication",
"organization": "Organization",
@@ -119,7 +118,6 @@
"credentialSecuredDescription": "All sensitive data is encrypted with AES-256",
"passwordAuthentication": "Password Authentication",
"keyAuthentication": "Key Authentication",
"keyType": "Key Type",
"securityReminder": "Security Reminder",
"securityReminderText": "Never share your credentials. All data is encrypted at rest.",
"hostsUsingCredential": "Hosts Using This Credential",
@@ -299,7 +297,7 @@
"warning": "Warning",
"info": "Info",
"success": "Success",
"loading": "Loading",
"loading": "Loading...",
"required": "Required",
"optional": "Optional",
"connect": "Connect",
@@ -315,7 +313,6 @@
"updateAvailable": "Update Available",
"sshPath": "SSH Path",
"localPath": "Local Path",
"loading": "Loading...",
"noAuthCredentials": "No authentication credentials available for this SSH host",
"noReleases": "No Releases",
"updatesAndReleases": "Updates & Releases",
@@ -330,13 +327,10 @@
"resetPassword": "Reset Password",
"resetCode": "Reset Code",
"newPassword": "New Password",
"sshPath": "SSH Path",
"localPath": "Local Path",
"folder": "Folder",
"file": "File",
"renamedSuccessfully": "renamed successfully",
"deletedSuccessfully": "deleted successfully",
"noAuthCredentials": "No authentication credentials available for this SSH host",
"noTunnelConnections": "No tunnel connections configured",
"sshTools": "SSH Tools",
"english": "English",
@@ -348,14 +342,12 @@
"login": "Login",
"logout": "Logout",
"register": "Register",
"username": "Username",
"password": "Password",
"version": "Version",
"confirmPassword": "Confirm Password",
"back": "Back",
"email": "Email",
"submit": "Submit",
"cancel": "Cancel",
"change": "Change",
"save": "Save",
"saving": "Saving...",
@@ -363,22 +355,15 @@
"edit": "Edit",
"add": "Add",
"search": "Search",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"warning": "Warning",
"info": "Info",
"confirm": "Confirm",
"yes": "Yes",
"no": "No",
"ok": "OK",
"close": "Close",
"enabled": "Enabled",
"disabled": "Disabled",
"important": "Important",
"notEnabled": "Not Enabled",
"settingUp": "Setting up...",
"back": "Back",
"next": "Next",
"previous": "Previous",
"refresh": "Refresh",
@@ -402,7 +387,11 @@
"documentation": "Documentation",
"retry": "Retry",
"checking": "Checking...",
"checkingDatabase": "Checking database connection..."
"checkingDatabase": "Checking database connection...",
"actions": "Actions",
"remove": "Remove",
"revoke": "Revoke",
"create": "Create"
},
"nav": {
"home": "Home",
@@ -431,7 +420,7 @@
"userManagement": "User Management",
"makeAdmin": "Make Admin",
"removeAdmin": "Remove Admin",
"deleteUser": "Delete User",
"deleteUser": "Delete user {{username}}? This cannot be undone.",
"allowRegistration": "Allow Registration",
"oidcSettings": "OIDC Settings",
"clientId": "Client ID",
@@ -485,7 +474,6 @@
"removeAdminStatus": "Remove admin status from {{username}}?",
"adminStatusRemoved": "Admin status removed from {{username}}",
"failedToRemoveAdminStatus": "Failed to remove admin status",
"deleteUser": "Delete user {{username}}? This cannot be undone.",
"userDeletedSuccessfully": "User {{username}} deleted successfully",
"failedToDeleteUser": "Failed to delete user",
"overrideUserInfoUrl": "Override User Info URL (not required)",
@@ -563,7 +551,6 @@
"verificationCompleted": "Compatibility verification completed - no data was changed",
"verificationInProgress": "Verification completed",
"dataMigrationCompleted": "Data migration completed successfully!",
"migrationCompleted": "Migration completed",
"verificationFailed": "Compatibility verification failed",
"migrationFailed": "Migration failed",
"runningVerification": "Running compatibility verification...",
@@ -641,7 +628,8 @@
"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?"
"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"
},
"hosts": {
"title": "Host Manager",
@@ -950,7 +938,9 @@
"quickActionName": "Action name",
"noSnippetFound": "No snippet found",
"quickActionsOrder": "Quick action buttons will appear in the order listed above on the Server Stats page",
"advancedAuthSettings": "Advanced Authentication Settings"
"advancedAuthSettings": "Advanced Authentication Settings",
"sudoPasswordAutoFill": "Sudo Password Auto-Fill",
"sudoPasswordAutoFillDesc": "Automatically offer to insert SSH password when sudo prompts for password"
},
"terminal": {
"title": "Terminal",
@@ -988,7 +978,11 @@
"totpRequired": "Two-Factor Authentication Required",
"totpCodeLabel": "Verification Code",
"totpPlaceholder": "000000",
"totpVerify": "Verify"
"totpVerify": "Verify",
"sudoPasswordPopupTitle": "Insert Password?",
"sudoPasswordPopupHint": "Press Enter to insert, Esc to dismiss",
"sudoPasswordPopupConfirm": "Insert",
"sudoPasswordPopupDismiss": "Dismiss"
},
"fileManager": {
"title": "File Manager",
@@ -1090,7 +1084,6 @@
"copyPaths": "Copy Paths",
"delete": "Delete",
"properties": "Properties",
"preview": "Preview",
"refresh": "Refresh",
"downloadFiles": "Download {{count}} files to Browser",
"copyFiles": "Copy {{count}} items",
@@ -1105,18 +1098,11 @@
"failedToDeleteItem": "Failed to delete item",
"itemRenamedSuccessfully": "{{type}} renamed successfully",
"failedToRenameItem": "Failed to rename item",
"upload": "Upload",
"download": "Download",
"newFile": "New File",
"newFolder": "New Folder",
"rename": "Rename",
"delete": "Delete",
"permissions": "Permissions",
"size": "Size",
"modified": "Modified",
"path": "Path",
"fileName": "File Name",
"folderName": "Folder Name",
"confirmDelete": "Are you sure you want to delete {{name}}?",
"uploadSuccess": "File uploaded successfully",
"uploadFailed": "File upload failed",
@@ -1136,10 +1122,7 @@
"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",
"folder": "Folder",
"file": "File",
"deletedSuccessfully": "deleted successfully",
"failedToDeleteItem": "Failed to delete item",
"connectToServer": "Connect to a Server",
"selectServerToEdit": "Select a server from the sidebar to start editing files",
"fileOperations": "File Operations",
@@ -1196,10 +1179,8 @@
"unpinFile": "Unpin file",
"removeShortcut": "Remove shortcut",
"saveFilesToSystem": "Save {{count}} files as...",
"saveToSystem": "Save as...",
"pinFile": "Pin file",
"addToShortcuts": "Add to shortcuts",
"selectLocationToSave": "Select location to save",
"downloadToDefaultLocation": "Download to default location",
"pasteFailed": "Paste failed",
"noUndoableActions": "No undoable actions",
@@ -1217,7 +1198,6 @@
"editPath": "Edit path",
"confirm": "Confirm",
"cancel": "Cancel",
"folderName": "Folder name",
"find": "Find...",
"replaceWith": "Replace with...",
"replace": "Replace",
@@ -1243,23 +1223,18 @@
"outdent": "Outdent",
"autoComplete": "Auto Complete",
"imageLoadError": "Failed to load image",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"rotate": "Rotate",
"originalSize": "Original Size",
"startTyping": "Start typing...",
"unknownSize": "Unknown size",
"fileIsEmpty": "File is empty",
"modified": "Modified",
"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.",
"fileSavedSuccessfully": "File saved successfully",
"autoSaveFailed": "Auto-save failed",
"fileAutoSaved": "File auto-saved",
"fileDownloadedSuccessfully": "File downloaded successfully",
"moveFileFailed": "Failed to move {{name}}",
"moveOperationFailed": "Move operation failed",
"canOnlyCompareFiles": "Can only compare two files",
@@ -1353,17 +1328,8 @@
"local": "Local",
"remote": "Remote",
"dynamic": "Dynamic",
"noSshTunnels": "No SSH Tunnels",
"createFirstTunnelMessage": "Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections.",
"unknownConnectionStatus": "Unknown",
"connected": "Connected",
"connecting": "Connecting...",
"disconnecting": "Disconnecting...",
"disconnected": "Disconnected",
"portMapping": "Port {{sourcePort}} → {{endpointHost}}:{{endpointPort}}",
"disconnect": "Disconnect",
"connect": "Connect",
"canceling": "Canceling...",
"endpointHostNotFound": "Endpoint host not found",
"discord": "Discord",
"githubIssue": "GitHub issue",
@@ -1376,7 +1342,7 @@
"disk": "Disk",
"network": "Network",
"uptime": "Uptime",
"loadAverage": "Load Average",
"loadAverage": "Avg: {{avg1}}, {{avg5}}, {{avg15}}",
"processes": "Processes",
"connections": "Connections",
"usage": "Usage",
@@ -1392,7 +1358,6 @@
"cpuCores_one": "{{count}} CPU",
"cpuCores_other": "{{count}} CPUs",
"naCpus": "N/A CPU(s)",
"loadAverage": "Avg: {{avg1}}, {{avg5}}, {{avg15}}",
"loadAverageNA": "Avg: N/A",
"cpuUsage": "CPU Usage",
"memoryUsage": "Memory Usage",
@@ -1411,7 +1376,6 @@
"totpRequired": "TOTP Authentication Required",
"totpUnavailable": "Server Stats unavailable for TOTP-enabled servers",
"load": "Load",
"available": "Available",
"editLayout": "Edit Layout",
"cancelEdit": "Cancel",
"addWidget": "Add Widget",
@@ -1550,7 +1514,9 @@
"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."
"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."
},
"errors": {
"notFound": "Page not found",
@@ -1628,6 +1594,8 @@
"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",
"defaultSnippetFoldersCollapsed": "Collapse Snippet Folders by Default",
"defaultSnippetFoldersCollapsedDesc": "When enabled, all snippet folders will be collapsed when you open the snippets tab",
"currentPassword": "Current Password",
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
"failedToChangePassword": "Failed to change password. Please check your current password and try again."
@@ -1719,7 +1687,6 @@
"deleteItem": "Delete Item",
"createNewFile": "Create New File",
"createNewFolder": "Create New Folder",
"deleteItem": "Delete Item",
"renameItem": "Rename Item",
"clickToSelectFile": "Click to select a file",
"noSshHosts": "No SSH Hosts",
@@ -1849,6 +1816,172 @@
"ram": "RAM",
"notAvailable": "N/A"
},
"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",
"credentialSharingWarningDescription": "This host uses credential-based authentication. Shared users will not be able to connect because credentials are encrypted per-user and cannot be shared. Please use password or key-based authentication for hosts you intend to share.",
"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": "Can view and connect to the host in read-only mode",
"use": "Use",
"useDesc": "Can use the host normally but cannot modify host configuration",
"manage": "Manage",
"manageDesc": "Full control including modifying host configuration and sharing settings"
},
"commandPalette": {
"searchPlaceholder": "Search for hosts or quick actions...",
"recentActivity": "Recent Activity",

View File

@@ -164,7 +164,8 @@
"generateKeyPairNote": "Générez une nouvelle paire de clés SSH directement. Cela remplacera toute clé existante dans le formulaire.",
"invalidKey": "Clé invalide",
"detectionError": "Erreur de détection",
"unknown": "Inconnu"
"unknown": "Inconnu",
"credentialId": "ID de l'identifiant"
},
"dragIndicator": {
"error": "Erreur : {{error}}",
@@ -385,7 +386,8 @@
"documentation": "Documentation",
"retry": "Réessayer",
"checking": "Vérification...",
"checkingDatabase": "Vérification de la connexion à la base de données..."
"checkingDatabase": "Vérification de la connexion à la base de données...",
"saving": "Enregistrement..."
},
"nav": {
"home": "Accueil",
@@ -395,7 +397,7 @@
"tunnels": "Tunnels",
"fileManager": "Gestionnaire de fichiers",
"serverStats": "Statistiques serveur",
"admin": "Admin",
"admin": "Administrateur",
"userProfile": "Profil utilisateur",
"tools": "Outils",
"snippets": "Extraits",
@@ -447,7 +449,7 @@
"makeUserAdmin": "Nommer l'utilisateur administrateur",
"adding": "Ajout...",
"currentAdmins": "Administrateurs actuels",
"adminBadge": "Admin",
"adminBadge": "Administrateur",
"removeAdminButton": "Retirer l'administrateur",
"general": "Général",
"userRegistration": "Inscription utilisateur",
@@ -470,7 +472,7 @@
"failedToRemoveAdminStatus": "Échec du retrait du statut d'administrateur",
"userDeletedSuccessfully": "Utilisateur {{username}} supprimé avec succès",
"failedToDeleteUser": "Échec de la suppression de l'utilisateur",
"overrideUserInfoUrl": "Remplacer l'URL User Info (optionnel)",
"overrideUserInfoUrl": "Remplacer l'URL Utilisateur Info (optionnel)",
"failedToFetchSessions": "Échec de la récupération des sessions",
"sessionRevokedSuccessfully": "Session révoquée avec succès",
"failedToRevokeSession": "Échec de la révocation de la session",
@@ -604,7 +606,26 @@
"requiresPasswordLogin": "Nécessite la connexion par mot de passe activée",
"passwordLoginDisabledWarning": "La connexion par mot de passe est désactivée. Vérifiez qu'OIDC est correctement configuré sinon vous ne pourrez plus vous connecter à Termix.",
"oidcRequiredWarning": "CRITIQUE : la connexion par mot de passe est désactivée. Si vous réinitialisez ou mal configurez OIDC, vous perdrez tout accès à Termix et bloquerez l'instance. Ne continuez que si vous en êtes absolument certain.",
"confirmDisableOIDCWarning": "AVERTISSEMENT : vous êtes sur le point de désactiver OIDC alors que la connexion par mot de passe est désactivée. Cela bloquera votre instance Termix et vous perdrez tout accès. Êtes-vous vraiment sûr de vouloir continuer ?"
"confirmDisableOIDCWarning": "AVERTISSEMENT : vous êtes sur le point de désactiver OIDC alors que la connexion par mot de passe est désactivée. Cela bloquera votre instance Termix et vous perdrez tout accès. Êtes-vous vraiment sûr de vouloir continuer ?",
"accountsLinkedSuccessfully": "Le compte OIDC {{oidcUsername}} a été lié à {{targetUsername}}",
"failedToLinkAccounts": "Impossible de lier les comptes",
"failedToUnlinkOIDC": "Impossible de délier OIDC",
"linkAccountsButton": "Lier les comptes",
"linkOIDCActionAddCapability": "Ajouter la capacité de connexion OIDC au compte mot de passe cible",
"linkOIDCActionDeleteUser": "Supprimer le compte utilisateur OIDC et toutes ses données",
"linkOIDCActionDualAuth": "Autoriser le compte mot de passe à se connecter avec le mot de passe et OIDC",
"linkOIDCDialogDescription": "Lier {{username}} (utilisateur OIDC) à un compte mot de passe existant. Cela activera la double authentification pour le compte mot de passe.",
"linkOIDCDialogTitle": "Lier Compte OIDC au Compte Mot de Passe",
"linkOIDCWarningTitle": "Avertissement : Les données de l'utilisateur OIDC seront supprimées",
"linkTargetUsernameLabel": "Nom d'utilisateur du compte mot de passe cible",
"linkTargetUsernamePlaceholder": "Entrer le nom d'utilisateur du compte mot de passe",
"linkTargetUsernameRequired": "Le nom d'utilisateur cible est requis",
"linkToPasswordAccount": "Lier au Compte Mot de Passe",
"linkingAccounts": "Liaison en cours...",
"unlinkOIDCDescription": "Retirer l'authentification OIDC de {{username}} ? L'utilisateur ne pourra se connecter qu'avec nom d'utilisateur/mot de passe après cela.",
"unlinkOIDCSuccess": "OIDC délié de {{username}}",
"unlinkOIDCTitle": "Délier l'Authentification OIDC",
"failedToUpdatePasswordLoginStatus": "Impossible de mettre à jour le statut de connexion par mot de passe"
},
"hosts": {
"title": "Gestionnaire d'hôtes",
@@ -720,7 +741,7 @@
"updateKey": "Mettre à jour la clé",
"existingKey": "Clé existante (cliquez pour modifier)",
"existingCredential": "Identifiant existant (cliquez pour modifier)",
"addTagsSpaceToAdd": "ajouter des labels (espace pour valider)",
"addTagsSpaceToAdd": "Ajouter des labels (espace pour valider)",
"terminalBadge": "Terminal",
"tunnelBadge": "Tunnel",
"fileManagerBadge": "Gestionnaire de fichiers",
@@ -790,7 +811,88 @@
"searchServers": "Rechercher des serveurs...",
"noServerFound": "Aucun serveur trouvé",
"jumpHostsOrder": "Les connexions seront établies dans l'ordre : Serveur de rebond 1 → Serveur de rebond 2 → ... → Serveur cible",
"advancedAuthSettings": "Paramètres d'authentification avancés"
"advancedAuthSettings": "Paramètres d'authentification avancés",
"addQuickAction": "Ajouter une action rapide",
"adjustFontSize": "Ajuster la taille de la police du terminal",
"adjustLetterSpacing": "Ajuster l'espacement des lettres",
"adjustLineHeight": "Ajuster l'espacement des lignes",
"advanced": "Avancé",
"allHostsInFolderDeleted": "{{count}} hôtes supprimés du dossier \"{{folder}}\" avec succès",
"appearance": "Apparence",
"backspaceMode": "Mode retour arrière (Backspace)",
"backspaceModeControlH": "Control-H (^H)",
"backspaceModeDesc": "Comportement de la touche retour arrière pour la compatibilité",
"backspaceModeNormal": "Normal (DEL)",
"behavior": "Comportement",
"bellStyle": "Style de la cloche (Bell)",
"bellStyleBoth": "Les deux",
"bellStyleDesc": "Comment gérer la cloche du terminal (caractère BEL, \\x07). Les programmes déclenchent ceci lors de la fin de tâches, d'erreurs ou pour les notifications. \"Son\" joue un bip audio, \"Visuel\" fait clignoter l'écran brièvement, \"Les deux\" fait les deux, \"Aucun\" désactive les alertes.",
"bellStyleNone": "Aucun",
"bellStyleSound": "Son",
"bellStyleVisual": "Visuel",
"chooseColorTheme": "Choisir un thème de couleur pour le terminal",
"chooseCursorAppearance": "Choisir l'apparence du curseur",
"confirmDeleteAllHostsInFolder": "Êtes-vous sûr de vouloir supprimer les {{count}} hôtes du dossier \"{{folder}}\" ? Cette action ne peut pas être annulée.",
"cursorBlink": "Clignotement du curseur",
"cursorStyle": "Style du curseur",
"cursorStyleBar": "Barre",
"cursorStyleBlock": "Bloc",
"cursorStyleUnderline": "Souligné",
"deleteAllHostsInFolder": "Supprimer tous les hôtes du dossier",
"editFolderAppearance": "Modifier l'apparence du dossier",
"editFolderAppearanceDesc": "Personnalisez la couleur et l'icône du dossier",
"enableCursorBlink": "Activer l'animation de clignotement du curseur",
"failedToDeleteHostsInFolder": "Impossible de supprimer les hôtes du dossier",
"failedToUpdateFolderAppearance": "Impossible de mettre à jour l'apparence du dossier",
"fastScrollModifier": "Touche de défilement rapide",
"fastScrollModifierDesc": "Touche modificatrice pour le défilement rapide",
"fastScrollSensitivity": "Sensibilité du défilement rapide",
"fastScrollSensitivityDesc": "Multiplicateur de vitesse de défilement lorsque la touche est maintenue",
"fastScrollSensitivityValue": "Sensibilité du défilement rapide : {{value}}",
"folderAppearanceUpdated": "Apparence du dossier mise à jour avec succès",
"folderColor": "Couleur du dossier",
"folderIcon": "Icône du dossier",
"fontFamily": "Famille de police",
"fontSize": "Taille de police",
"fontSizeValue": "Taille de police : {{value}}px",
"letterSpacing": "Espacement des lettres",
"letterSpacingValue": "Espacement des lettres : {{value}}px",
"lineHeight": "Hauteur de ligne",
"lineHeightValue": "Hauteur de ligne : {{value}}",
"minimumContrastRatio": "Ratio de contraste minimum",
"minimumContrastRatioDesc": "Ajuster automatiquement les couleurs pour une meilleure lisibilité",
"minimumContrastRatioValue": "Ratio de contraste minimum : {{value}}",
"modifierAlt": "Alt",
"modifierCtrl": "Ctrl",
"modifierShift": "Shift",
"noSnippetFound": "Aucun extrait trouvé",
"preview": "Aperçu",
"quickActionName": "Nom de l'action",
"quickActions": "Actions rapides",
"quickActionsDescription": "Les actions rapides vous permettent de créer des boutons personnalisés qui exécutent des extraits SSH sur ce serveur. Ces boutons apparaîtront en haut de la page Statistiques Serveur pour un accès rapide.",
"quickActionsList": "Liste des actions rapides",
"quickActionsOrder": "Les boutons d'action rapide apparaîtront dans l'ordre indiqué ci-dessus sur la page Statistiques Serveur",
"rightClickSelectsWord": "Clic droit sélectionne le mot",
"rightClickSelectsWordDesc": "Le clic droit sélectionne le mot sous le curseur",
"scrollbackBuffer": "Tampon de défilement",
"scrollbackBufferDesc": "Nombre de lignes à conserver dans l'historique de défilement",
"scrollbackBufferValue": "Tampon de défilement : {{value}} lignes",
"searchSnippets": "Rechercher des extraits...",
"selectBackspaceMode": "Sélectionner le mode retour arrière",
"selectBellStyle": "Sélectionner le style de cloche",
"selectCursorStyle": "Sélectionner le style de curseur",
"selectFont": "Sélectionner la police",
"selectFontDesc": "Sélectionner la police à utiliser dans le terminal",
"selectModifier": "Sélectionner la touche modificatrice",
"selectSnippet": "Sélectionner un extrait",
"selectTheme": "Sélectionner un thème",
"snippetNone": "Aucun",
"sshAgentForwarding": "Transfert d'agent SSH",
"sshAgentForwardingDesc": "Transférer l'agent d'authentification SSH à l'hôte distant",
"startupSnippet": "Extrait au démarrage",
"terminalCustomization": "Personnalisation du terminal",
"theme": "Thème",
"themePreview": "Aperçu du thème"
},
"terminal": {
"title": "Terminal",
@@ -828,7 +930,11 @@
"totpRequired": "Authentification à deux facteurs requise",
"totpCodeLabel": "Code de vérification",
"totpPlaceholder": "000000",
"totpVerify": "Vérifier"
"totpVerify": "Vérifier",
"sudoPasswordPopupTitle": "Insérer le mot de passe ?",
"sudoPasswordPopupHint": "Appuyez sur Entrée pour insérer, Échap pour annuler",
"sudoPasswordPopupConfirm": "Insérer",
"sudoPasswordPopupDismiss": "Annuler"
},
"fileManager": {
"title": "Gestionnaire de fichiers",
@@ -943,7 +1049,7 @@
"internalServerError": "Une erreur interne du serveur est survenue",
"serverError": "Erreur serveur",
"error": "Erreur",
"requestFailed": "Réquête échouée avec le code",
"requestFailed": "Requête échouée avec le code",
"unknownFileError": "Erreur de fichier inconnue",
"cannotReadFile": "Impossible de lire le fichier",
"noSshSessionId": "Aucun ID de session SSH",
@@ -1100,7 +1206,35 @@
"sshConnectionFailed": "La connexion SSH a échoué. Vérifiez votre connexion à {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "Échec du chargement du fichier : {{error}}",
"connectedSuccessfully": "Connexion réussie",
"totpVerificationFailed": "Échec de la vérification TOTP"
"totpVerificationFailed": "Échec de la vérification TOTP",
"andMoreFiles": "et {{count}} plus...",
"archiveExtractedSuccessfully": "{{name}} extrait avec succès",
"archiveName": "Nom de l'archive",
"changePermissions": "Modifier les permissions",
"changePermissionsDesc": "Modifier les permissions du fichier pour",
"compress": "Compresser",
"compressFailed": "Échec de la compression",
"compressFile": "Compresser le fichier",
"compressFiles": "Compresser les fichiers",
"compressFilesDesc": "Compresser {{count}} éléments dans une archive",
"compressingFiles": "Compression de {{count}} éléments dans {{name}}...",
"compressionFormat": "Format de compression",
"currentPermissions": "Permissions actuelles",
"enterArchiveName": "Entrer le nom de l'archive...",
"execute": "Exécuter",
"extractArchive": "Extraire l'archive",
"extractFailed": "Échec de l'extraction",
"extractingArchive": "Extraction de {{name}}...",
"failedToChangePermissions": "Impossible de modifier les permissions",
"filesCompressedSuccessfully": "{{name}} créé avec succès",
"group": "Groupe",
"newPermissions": "Nouvelles permissions",
"others": "Autres",
"owner": "Propriétaire",
"permissionsChangedSuccessfully": "Permissions modifiées avec succès",
"read": "Lecture",
"selectedFiles": "Fichiers sélectionnés",
"write": "Écriture"
},
"tunnels": {
"title": "Tunnels SSH",
@@ -1211,7 +1345,20 @@
"noInterfacesFound": "Aucune interface trouvée",
"totalProcesses": "Processus totaux",
"running": "En cours d'exécution",
"noProcessesFound": "Aucun processus trouvé"
"noProcessesFound": "Aucun processus trouvé",
"executeQuickAction": "Exécuter {{name}}",
"executingQuickAction": "Exécution de {{name}}...",
"from": "de",
"loginStats": "Statistiques de connexion SSH",
"noRecentLoginData": "Aucune donnée de connexion récente",
"quickActionError": "Impossible d'exécuter {{name}}",
"quickActionFailed": "{{name}} a échoué",
"quickActionSuccess": "{{name}} terminé avec succès",
"quickActions": "Actions rapides",
"recentFailedAttempts": "Tentatives échouées récentes",
"recentSuccessfulLogins": "Connexions réussies récentes",
"totalLogins": "Total des connexions",
"uniqueIPs": "IP uniques"
},
"auth": {
"loginTitle": "Connexion à Termix",
@@ -1314,7 +1461,14 @@
"authenticating": "Authentification...",
"dataLossWarning": "Réinitialiser votre mot de passe de cette manière supprimera tous vos hôtes, identifiants et autres données chiffrées. Action irréversible. À utiliser uniquement si vous avez oublié votre mot de passe et n'êtes pas connecté.",
"authenticationDisabled": "Authentification désactivée",
"authenticationDisabledDesc": "Toutes les méthodes d'authentification sont actuellement désactivées. Contactez votre administrateur."
"authenticationDisabledDesc": "Toutes les méthodes d'authentification sont actuellement désactivées. Contactez votre administrateur.",
"continueExternal": "Continuer avec un fournisseur externe",
"createAccount": "Créer votre compte TERMIX",
"description": "Gestion de connexions SSH sécurisée, puissante et intuitive",
"tagline": "GESTIONNAIRE DE SERVEURS SSH",
"welcomeBack": "Bon retour sur TERMIX",
"passwordResetSuccess": "Réinitialisation du mot de passe réussie",
"passwordResetSuccessDesc": "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
},
"errors": {
"notFound": "Page introuvable",
@@ -1391,9 +1545,12 @@
"fileColorCodingDesc": "Codage couleur des fichiers par type : dossiers (rouge), fichiers (bleu), liens symboliques (vert)",
"commandAutocomplete": "Autocomplétion des commandes",
"commandAutocompleteDesc": "Activer les suggestions d'autocomplétion avec la touche Tab pour les commandes du terminal basées sur votre historique",
"defaultSnippetFoldersCollapsed": "Réduire les dossiers de snippets par défaut",
"defaultSnippetFoldersCollapsedDesc": "Lorsque cette option est activée, tous les dossiers de snippets seront réduits à l'ouverture de l'onglet snippets",
"currentPassword": "Mot de passe actuel",
"passwordChangedSuccess": "Mot de passe modifié avec succès ! Veuillez vous reconnecter.",
"failedToChangePassword": "Échec de la modification du mot de passe. Vérifiez votre mot de passe actuel et réessayez."
"failedToChangePassword": "Échec de la modification du mot de passe. Vérifiez votre mot de passe actuel et réessayez.",
"externalAndLocal": "Double Auth"
},
"user": {
"failedToLoadVersionInfo": "Échec du chargement des informations de version"
@@ -1405,11 +1562,11 @@
"maxRetries": "3",
"retryInterval": "10",
"language": "Langue",
"username": "nom d'utilisateur",
"hostname": "nom d'hôte",
"folder": "dossier",
"password": "mot de passe",
"keyPassword": "mot de passe de la clé",
"username": "Nom d'utilisateur",
"hostname": "Nom d'hôte",
"folder": "Dossier",
"password": "Mot de passe",
"keyPassword": "Mot de passe de la clé",
"pastePrivateKey": "Collez votre clé privée ici...",
"pastePublicKey": "Collez votre clé publique ici...",
"credentialName": "Mon serveur SSH",
@@ -1605,5 +1762,28 @@
"cpu": "Processeur (CPU)",
"ram": "Mémoire (RAM)",
"notAvailable": "N/D"
},
"commandPalette": {
"addCredential": "Ajouter Identifiant",
"addHost": "Ajouter Hôte",
"adminSettings": "Paramètres Administrateur",
"close": "Fermer",
"discord": "Discord",
"donate": "Faire un don",
"edit": "Modifier",
"github": "GitHub",
"hostManager": "Gestionnaire d'hôtes",
"hosts": "Hôtes",
"links": "Liens",
"navigation": "Navigation",
"openFileManager": "Ouvrir Gestionnaire de fichiers",
"openServerDetails": "Ouvrir Détails Serveur",
"press": "Appuyer",
"recentActivity": "Activité Récente",
"searchPlaceholder": "Rechercher hôtes ou actions rapides...",
"support": "Support",
"toToggle": "pour basculer",
"updateLog": "Journal de mise à jour",
"userProfile": "Profil utilisateur"
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -59,7 +59,6 @@
"keyTypeRSA": "RSA",
"keyTypeECDSA": "ECDSA",
"keyTypeEd25519": "Ed25519",
"updateCredential": "Atualizar Credencial",
"basicInfo": "Informações básicas",
"authentication": "Autenticação",
"organization": "Organização",
@@ -93,7 +92,7 @@
"deploySSHKey": "Implantar Chave SSH",
"deploySSHKeyDescription": "Implantar chave pública no servidor de destino",
"sourceCredential": "Credencial de Origem",
"targetHost": "Host de Destino",
"targetHost": "Host de Destinenhum",
"deploymentProcess": "Processo de Implantação",
"deploymentProcessDescription": "Isso adicionará com segurança a chave pública ao arquivo ~/.ssh/authorized_keys do host de destino sem sobrescrever chaves existentes. A operação é reversível.",
"chooseHostToDeploy": "Escolha um host para implantar...",
@@ -118,7 +117,6 @@
"credentialSecuredDescription": "Todos os dados sensíveis são criptografados com AES-256",
"passwordAuthentication": "Autenticação por senha",
"keyAuthentication": "Autenticação por chave",
"keyType": "Tipo de chave",
"securityReminder": "Lembrete de segurança",
"securityReminderText": "Nunca compartilhe suas credenciais. Todos os dados são criptografados em repouso.",
"hostsUsingCredential": "Hosts usando esta credencial",
@@ -166,7 +164,8 @@
"generateKeyPairNote": "Gere um novo par de chaves SSH diretamente. Isso substituirá quaisquer chaves existentes no formulário.",
"invalidKey": "Chave inválida",
"detectionError": "Erro de detecção",
"unknown": "Desconhecido"
"unknown": "Desconhecido",
"credentialId": "Credencial ID"
},
"dragIndicator": {
"error": "Erro: {{error}}",
@@ -201,7 +200,7 @@
"noResults": "Nenhum comando encontrado",
"noResultsHint": "Nenhum comando correspondente a \"{{query}}\"",
"deleteSuccess": "Comando removido do histórico",
"deleteFailed": "Falha ao excluir comando.",
"deleteFailed": "Falha ao excluir comeo.",
"deleteTooltip": "Excluir comando",
"tabHint": "Use Tab no Terminal para autocompletar do histórico de comandos"
},
@@ -219,21 +218,25 @@
"testConnectionFirst": "Por favor, teste a conexão primeiro",
"connectionSuccess": "Conexão bem-sucedida!",
"connectionFailed": "Conexão falhou",
"connectionError": "Ocorreu um erro de conexão",
"connectionError": "Ocoureu um erro de conexão",
"connected": "Conectado",
"disconnected": "Desconectado",
"configSaved": "Configuração salva com sucesso",
"saveFailed": "Falha ao salvar configuração",
"saveError": "Erro ao salvar configuração",
"saving": "Salvando...",
"saving": "Salveo...",
"saveConfig": "Salvar Configuração",
"helpText": "Digite a URL onde seu servidor Termix está rodando (ex.: http://localhost:30001 ou https://seu-servidor.com)"
"helpText": "Digite a URL onde seu servidor Termix está rodando (ex.: http://localhost:30001 ou https://seu-servidor.com)",
"changeServer": "Alterar Servidor",
"mustIncludeProtocol": "URL do Servidor deve começar com http:// ou https://",
"notValidatedWarning": "URL não validada - verifique se está correta",
"warning": "Aviso"
},
"versionCheck": {
"error": "Erro na verificação de versão",
"checkFailed": "Falha ao verificar atualizações",
"upToDate": "Aplicativo atualizado",
"currentVersion": "Você está usando a versão {{version}}",
"currentVersion": "Você está useo a versão {{version}}",
"updateAvailable": "Atualização disponível",
"newVersionAvailable": "Uma nova versão está disponível! Você está usando {{current}}, mas {{latest}} está disponível.",
"releasedOn": "Lançada em {{date}}",
@@ -255,12 +258,12 @@
"continue": "Continuar",
"maintenance": "Manutenção",
"degraded": "Degradado",
"discord": "Discord",
"discord": "Discoud",
"error": "Erro",
"warning": "Aviso",
"info": "Info",
"success": "Sucesso",
"loading": "Carregando",
"loading": "Carregando...",
"required": "Obrigatório",
"optional": "Opcional",
"clear": "Limpar",
@@ -274,14 +277,13 @@
"updateAvailable": "Atualização Disponível",
"sshPath": "Caminho SSH",
"localPath": "Caminho Local",
"loading": "Carregando...",
"noAuthCredentials": "Não há credenciais de autenticação disponíveis para este host SSH",
"noReleases": "Sem Versões",
"updatesAndReleases": "Atualizações e Versões",
"newVersionAvailable": "Uma nova versão ({{version}}) está disponível.",
"failedToFetchUpdateInfo": "Falha ao buscar informações de atualização",
"preRelease": "Pré-lançamento",
"loginFailed": "Falha no login",
"loginFailed": "Falha nenhum login",
"noReleasesFound": "Nenhuma versão encontrada.",
"yourBackupCodes": "Seus Códigos de Backup",
"sendResetCode": "Enviar Código de Redefinição",
@@ -289,13 +291,10 @@
"resetPassword": "Redefinir Senha",
"resetCode": "Código de Redefinição",
"newPassword": "Nova Senha",
"sshPath": "Caminho SSH",
"localPath": "Caminho Local",
"folder": "Pasta",
"file": "Arquivo",
"renamedSuccessfully": "renomeado com sucesso",
"deletedSuccessfully": "excluído com sucesso",
"noAuthCredentials": "Não há credenciais de autenticação disponíveis para este host SSH",
"noTunnelConnections": "Não há conexões de túnel configuradas",
"sshTools": "Ferramentas SSH",
"english": "Inglês",
@@ -307,36 +306,27 @@
"login": "Entrar",
"logout": "Sair",
"register": "Registrar",
"username": "Usuário",
"password": "Senha",
"version": "Versão",
"confirmPassword": "Confirmar Senha",
"back": "Voltar",
"email": "Email",
"submit": "Enviar",
"cancel": "Cancelar",
"change": "Alterar",
"save": "Salvar",
"delete": "Excluir",
"edit": "Editar",
"add": "Adicionar",
"search": "Buscar",
"loading": "Carregando...",
"error": "Erro",
"success": "Sucesso",
"warning": "Aviso",
"info": "Info",
"confirm": "Confirmar",
"yes": "Sim",
"no": "Não",
"ok": "OK",
"close": "Fechar",
"enabled": "Habilitado",
"disabled": "Desabilitado",
"important": "Importante",
"notEnabled": "Não Habilitado",
"settingUp": "Configurando...",
"back": "Voltar",
"next": "Próximo",
"previous": "Anterior",
"refresh": "Atualizar",
@@ -353,7 +343,7 @@
"passwordResetDescription": "Você está prestes a redefinir sua senha. Isso fará com que você seja desconectado de todas as sessões ativas.",
"enterSixDigitCode": "Digite o código de 6 dígitos dos logs do container docker para o usuário:",
"enterNewPassword": "Digite sua nova senha para o usuário:",
"passwordsDoNotMatch": "As senhas não correspondem",
"passwordsDoNotMatch": "As senhas não courespondem",
"passwordMinLength": "A senha deve ter pelo menos 6 caracteres",
"passwordResetSuccess": "Senha redefinida com sucesso! Você pode agora entrar com sua nova senha.",
"failedToInitiatePasswordReset": "Falha ao iniciar redefinição de senha",
@@ -362,7 +352,8 @@
"documentation": "Documentação",
"retry": "Tentar Novamente",
"checking": "Verificando...",
"checkingDatabase": "Verificando conexão com o banco de dados..."
"checkingDatabase": "Verificando conexão com o banco de dados...",
"saving": "Salveo..."
},
"nav": {
"home": "Início",
@@ -372,7 +363,7 @@
"tunnels": "Túneis",
"fileManager": "Gerenciador de Arquivos",
"serverStats": "Estatísticas do Servidor",
"admin": "Admin",
"admin": "Administrador",
"userProfile": "Perfil do Usuário",
"tools": "Ferramentas",
"newTab": "Nova Aba",
@@ -381,15 +372,16 @@
"sshManager": "Gerenciador SSH",
"hostManager": "Gerenciador de Hosts",
"cannotSplitTab": "Não é possível dividir esta aba",
"tabNavigation": "Navegação de Abas"
"tabNavigation": "Navegação de Abas",
"snippets": "Snippets"
},
"admin": {
"title": "Configurações de Admin",
"title": "Configurações de Administrador",
"oidc": "OIDC",
"users": "Usuários",
"userManagement": "Gerenciamento de Usuários",
"makeAdmin": "Tornar Admin",
"removeAdmin": "Remover Admin",
"makeAdmin": "Tornar Administrador",
"removeAdmin": "Remover Administrador",
"deleteUser": "Excluir Usuário",
"allowRegistration": "Permitir Registro",
"oidcSettings": "Configurações OIDC",
@@ -400,11 +392,11 @@
"tokenUrl": "URL do Token",
"updateSettings": "Atualizar Configurações",
"confirmDelete": "Tem certeza que deseja excluir este usuário?",
"confirmMakeAdmin": "Tem certeza que deseja tornar este usuário um admin?",
"confirmRemoveAdmin": "Tem certeza que deseja remover os privilégios de admin deste usuário?",
"confirmMakeAdmin": "Tem certeza que deseja tornar este usuário um administrador?",
"confirmRemoveAdmin": "Tem certeza que deseja remover os privilégios de administrador deste usuário?",
"externalAuthentication": "Autenticação Externa (OIDC)",
"configureExternalProvider": "Configure o provedor de identidade externo para autenticação OIDC/OAuth2.",
"userIdentifierPath": "Caminho do Identificador do Usuário",
"userIdentifierPath": "Caminho do Identificadou do Usuário",
"displayNamePath": "Caminho do Nome de Exibição",
"scopes": "Escopos",
"saving": "Salvando...",
@@ -419,12 +411,12 @@
"actions": "Ações",
"external": "Externo",
"local": "Local",
"adminManagement": "Gerenciamento de Admin",
"makeUserAdmin": "Tornar Usuário Admin",
"adminManagement": "Gerenciamento de Administrador",
"makeUserAdmin": "Tornar Usuário Administrador",
"adding": "Adicionando...",
"currentAdmins": "Admins Atuais",
"adminBadge": "Admin",
"removeAdminButton": "Remover Admin",
"currentAdmins": "Administradores Atuais",
"adminBadge": "Administrador",
"removeAdminButton": "Remover Administrador",
"general": "Geral",
"userRegistration": "Registro de Usuário",
"allowNewAccountRegistration": "Permitir registro de novas contas",
@@ -436,13 +428,12 @@
"oidcConfigurationDisabled": "Configuração OIDC desativada com sucesso!",
"failedToUpdateOidcConfig": "Falha ao atualizar configuração OIDC",
"failedToDisableOidcConfig": "Falha ao desativar configuração OIDC",
"enterUsernameToMakeAdmin": "Insira o nome de usuário para tornar admin",
"enterUsernameToMakeAdmin": "Insira o nome de usuário para tornar administrador",
"userIsNowAdmin": "O usuário {{username}} agora é um administrador",
"failedToMakeUserAdmin": "Falha ao tornar o usuário administrador",
"removeAdminStatus": "Remover status de administrador de {{username}}?",
"adminStatusRemoved": "Status de administrador removido de {{username}}",
"failedToRemoveAdminStatus": "Falha ao remover o status de administrador",
"confirmDeleteUser": "Excluir usuário {{username}}? Esta ação não pode ser desfeita.",
"userDeletedSuccessfully": "Usuário {{username}} excluído com sucesso",
"failedToDeleteUser": "Falha ao excluir usuário",
"overrideUserInfoUrl": "Sobrescrever URL de informações do usuário (não obrigatório)",
@@ -495,7 +486,6 @@
"verificationCompleted": "Verificação de compatibilidade concluída - nenhum dado foi alterado",
"verificationInProgress": "Verificação concluída",
"dataMigrationCompleted": "Migração de dados concluída com sucesso!",
"migrationCompleted": "Migração concluída",
"verificationFailed": "Falha na verificação de compatibilidade",
"migrationFailed": "Falha na migração",
"runningVerification": "Executando verificação de compatibilidade...",
@@ -540,13 +530,13 @@
"databaseImportFailed": "Falha na importação do banco de dados SQLite",
"manageEncryptionAndBackups": "Gerenciar chaves de criptografia, segurança do banco de dados e operações de backup",
"activeSecurityFeatures": "Medidas e proteções de segurança atualmente ativas",
"deviceBindingTechnology": "Tecnologia avançada de proteção de chave baseada em hardware",
"deviceBindingTechnology": "Tecnenhumlogia avançada de proteção de chave baseada em hardware",
"backupAndRecovery": "Opções seguras de criação de backup e recuperação de banco de dados",
"crossSystemDataTransfer": "Exportar e importar bancos de dados entre diferentes sistemas",
"noMigrationNeeded": "Nenhuma migração necessária",
"encryptionKey": "Chave de Criptografia",
"keyProtection": "Proteção da Chave",
"active": "Ativo",
"active": "Ativa",
"legacy": "Legado",
"dataStatus": "Status dos Dados",
"encrypted": "Criptografado",
@@ -576,13 +566,38 @@
"confirmDisableOIDCWarning": "AVISO: Você está prestes a desativar o OIDC enquanto o login por senha também está desativado. Isso inutilizará sua instância do Termix e você perderá todo o acesso. Tem absoluta certeza de que deseja continuar?",
"allowPasswordLogin": "Permitir login com nome de usuário/senha",
"failedToFetchPasswordLoginStatus": "Falha ao buscar status do login por senha",
"failedToUpdatePasswordLoginStatus": "Falha ao atualizar status do login por senha"
"failedToUpdatePasswordLoginStatus": "Falha ao atualizar status do login por senha",
"accountsLinkedSuccessfully": "Usuário OIDC {{oidcUsername}} foi vinculado a {{targetUsername}}",
"confirmRevokeAllSessions": "Tem certeza de que deseja revogar todas as sessões para este usuário?",
"confirmRevokeSession": "Tem certeza de que deseja revogar esta sessão?",
"failedToFetchSessions": "Falha ao buscar sessões",
"failedToLinkAccounts": "Falha ao vincular contas",
"failedToRevokeSession": "Falha ao revogar sessão",
"failedToRevokeSessions": "Falha ao revogar sessões",
"failedToUnlinkOIDC": "Falha ao desvincular OIDC",
"linkAccountsButton": "Vincular Contas",
"linkOIDCActionAddCapability": "Adicionar capacidade de login OIDC à conta de senha de destino",
"linkOIDCActionDeleteUser": "Excluir a conta de usuário OIDC e todos os outros dados",
"linkOIDCActionDualAuth": "Permitir que a conta de senha faça login com senha e OIDC",
"linkOIDCDialogDescription": "Vincular {{username}} (usuário OIDC) a uma conta de senha existente. Isso ativará autenticação dupla para a conta de senha.",
"linkOIDCDialogTitle": "Link OIDC Account para Senha Account",
"linkOIDCWarningTitle": "Aviso: Dados do Usuário OIDC Serão Deletados",
"linkTargetUsernameLabel": "Nome de usuário da conta de senha de destino",
"linkTargetUsernamePlaceholder": "Inserir nome de usuário da conta de senha",
"linkTargetUsernameRequired": "Nome de usuário de destino é obrigatório",
"linkToPasswordAccount": "Vincular à Conta de Senha",
"linkingAccounts": "Vinculando...",
"sessionRevokedSuccessfully": "Sessão revogada com sucesso",
"sessionsRevokedSuccessfully": "Sessões revogadas com sucesso",
"unlinkOIDCDescription": "Remover autenticação OIDC de {{username}}? O usuário só poderá fazer login com nome de usuário/senha após isso.",
"unlinkOIDCSuccess": "OIDC desvinculado de {{username}}",
"unlinkOIDCTitle": "Desvincular Autenticação OIDC"
},
"hosts": {
"title": "Gerenciador de Hosts",
"sshHosts": "Hosts SSH",
"noHosts": "Sem Hosts SSH",
"noHostsMessage": "Você ainda não adicionou nenhum host SSH. Clique em \"Adicionar Host\" para começar.",
"noHostsMessage": "Você ainda não adicionenhumu nenhum host SSH. Clique em \"Adicionar Host\" para começar.",
"loadingHosts": "Carregando hosts...",
"failedToLoadHosts": "Falha ao carregar hosts",
"retry": "Tentar Novamente",
@@ -596,12 +611,12 @@
"formatGuide": "Guia de Formato",
"exportCredentialWarning": "Aviso: O host \"{{name}}\" usa autenticação por credencial. O arquivo exportado não incluirá os dados da credencial e precisará ser reconfigurado manualmente após a importação. Deseja continuar?",
"exportSensitiveDataWarning": "Aviso: O host \"{{name}}\" contém dados de autenticação sensíveis (senha/chave SSH). O arquivo exportado incluirá esses dados em texto simples. Mantenha o arquivo seguro e exclua-o após o uso. Deseja continuar?",
"uncategorized": "Sem categoria",
"uncategorized": "Sem categouia",
"confirmDelete": "Tem certeza que deseja excluir \"{{name}}\"?",
"failedToDeleteHost": "Falha ao excluir host",
"failedToExportHost": "Falha ao exportar host. Certifique-se de que está logado e tem acesso aos dados do host.",
"jsonMustContainHosts": "O JSON deve conter um array \"hosts\" ou ser um array de hosts",
"noHostsInJson": "Nenhum host encontrado no arquivo JSON",
"noHostsInJson": "Nenhum host encontrado nenhum arquivo JSON",
"maxHostsAllowed": "Máximo de 100 hosts permitidos por importação",
"importCompleted": "Importação concluída: {{success}} com sucesso, {{failed}} falhas",
"importFailed": "Falha na importação",
@@ -611,7 +626,7 @@
"organization": "Organização",
"ipAddress": "Endereço IP",
"port": "Porta",
"name": "Nome",
"name": "Nenhumme",
"username": "Usuário",
"folder": "Pasta",
"tags": "Tags",
@@ -632,13 +647,13 @@
"enableTerminalDesc": "Habilitar/desabilitar visibilidade do host na aba Terminal",
"enableTunnel": "Habilitar Túnel",
"enableTunnelDesc": "Habilitar/desabilitar visibilidade do host na aba Túnel",
"enableFileManager": "Habilitar Gerenciador de Arquivos",
"enableFileManagerDesc": "Habilitar/desabilitar visibilidade do host na aba Gerenciador de Arquivos",
"enableFileManager": "Habilitar Gerenciadou de Arquivos",
"enableFileManagerDesc": "Habilitar/desabilitar visibilidade do host na aba Gerenciadou de Arquivos",
"defaultPath": "Caminho Padrão",
"defaultPathDesc": "Diretório padrão ao abrir o gerenciador de arquivos para este host",
"defaultPathDesc": "Diretório padrão ao abrir o gerenciadou de arquivos para este host",
"tunnelConnections": "Conexões de Túnel",
"connection": "Conexão",
"remove": "Remover",
"remove": "Removerr",
"sourcePort": "Porta de Origem",
"sourcePortDesc": "(Source refere-se aos Detalhes da Conexão Atual na aba Geral)",
"endpointPort": "Porta de Destino",
@@ -695,12 +710,12 @@
"addTagsSpaceToAdd": "adicionar tags (espaço para adicionar)",
"terminalBadge": "Terminal",
"tunnelBadge": "Túnel",
"fileManagerBadge": "Gerenciador de Arquivos",
"fileManagerBadge": "Gerenciadou de Arquivos",
"general": "Geral",
"terminal": "Terminal",
"tunnel": "Túnel",
"fileManager": "Gerenciador de Arquivos",
"hostViewer": "Visualizador de Host",
"fileManager": "Gerenciadou de Arquivos",
"hostViewer": "Visualizadou de Host",
"confirmRemoveFromFolder": "Tem certeza que deseja remover \"{{name}}\" da pasta \"{{folder}}\"? O host será movido para \"Sem Pasta\".",
"removedFromFolder": "Host \"{{name}}\" removido da pasta com sucesso",
"failedToRemoveFromFolder": "Falha ao remover host da pasta",
@@ -745,7 +760,105 @@
"searchServers": "Pesquisar servidores...",
"noServerFound": "Nenhum servidor encontrado",
"jumpHostsOrder": "As conexões serão feitas na ordem: Host de Salto 1 → Host de Salto 2 → ... → Servidor de Destino",
"advancedAuthSettings": "Configurações Avançadas de Autenticação"
"advancedAuthSettings": "Configurações Avançadas de Autenticação",
"addQuickAction": "Adicionar Ação Rápida",
"adjustFontSize": "Ajustar o tamanho da fonte do terminal",
"adjustLetterSpacing": "Ajustar espaçamento entre caracteres",
"adjustLineHeight": "Ajustar espaçamento entre linhas",
"advanced": "Avançado",
"allHostsInFolderDeleted": "Excluídos {{count}} hosts da pasta \"{{folder}}\" com sucesso",
"appearance": "Aparência",
"backspaceMode": "Modo Backspace",
"backspaceModeControlH": "Control-H (^H)",
"backspaceModeDesc": "Comportamento da tecla Backspace para compatibilidade",
"backspaceModeNormal": "Normal (DEL)",
"behavior": "Comportamento",
"bellStyle": "Estilo de Campainha",
"bellStyleBoth": "Both",
"bellStyleDesc": "Como lidar com a campainha do terminal (caractere BEL, \\x07). Programas acionam isso ao completar tarefas, encontrar erros, ou para notificações. \"Som\" toca um bipe de áudio, \"Visual\" pisca a tela brevemente, \"Ambos\" faz os dois, \"Nenhum\" desativa os alertas de campainha.",
"bellStyleNone": "Nenhum",
"bellStyleSound": "Som",
"bellStyleVisual": "Visual",
"chooseColorTheme": "Escolha um tema de cor para o terminal",
"chooseCursorAppearance": "Escolha a aparência do cursor",
"confirmDeleteAllHostsInFolder": "Tem certeza de que deseja excluir todos os {{count}} hosts na pasta \"{{folder}}\"? Esta ação não pode ser desfeita.",
"cursorBlink": "Piscar Cursor",
"cursorStyle": "Estilo do Cursor",
"cursorStyleBar": "Barra",
"cursorStyleBlock": "Bloco",
"cursorStyleUnderline": "Sublinhado",
"customCommands": "Comandos Personalizados (Em Breve)",
"customCommandsDesc": "Defina comandos personalizados de desligamento e reinicialização para este servidor",
"deleteAllHostsInFolder": "Excluir Todos os Hosts na Pasta",
"displayItems": "Exibir Itens",
"displayItemsDesc": "Escolha quais métricas exibir na página de estatísticas do servidor",
"editFolderAppearance": "Editar Aparência da Pasta",
"editFolderAppearanceDesc": "Personalize a cor e o ícone da pasta",
"enableCpu": "Uso da CPU",
"enableCursorBlink": "Ativar animação de piscar do cursor",
"enableDisk": "Uso de Disco",
"enableHostname": "Hostname (Em Breve)",
"enableMemory": "Uso de Memória",
"enableNetwork": "Estatísticas de Rede (Em Breve)",
"enableOs": "Sistema Operacional (Em Breve)",
"enableProcesses": "Contagem de Processos (Em Breve)",
"enableServerStats": "Ativar Estatísticas do Servidor",
"enableServerStatsDesc": "Ativar/desativar coleta de estatísticas do servidor para este host",
"enableUptime": "Tempo de Atividade (Em Breve)",
"failedToDeleteHostsInFolder": "Falha ao excluir hosts na pasta",
"failedToUpdateFolderAppearance": "Falha ao atualizar aparência da pasta",
"fastScrollModifier": "Modificador de Rolagem Rápida",
"fastScrollModifierDesc": "Tecla modificadora para rolagem rápida",
"fastScrollSensitivity": "Sensibilidade de Rolagem Rápida",
"fastScrollSensitivityDesc": "Multiplicador de velocidade de rolagem quando modificador é segurado",
"fastScrollSensitivityValue": "Sensibilidade de Rolagem Rápida: {{value}}",
"folderAppearanceUpdated": "Aparência da pasta atualizada com sucesso",
"folderColor": "Cor da Pasta",
"folderIcon": "Ícone da Pasta",
"fontFamily": "Família da Fonte",
"fontSize": "Tamanho da Fonte",
"fontSizeValue": "Tamanho da Fonte: {{value}}px",
"letterSpacing": "Espaçamento entre Letras",
"letterSpacingValue": "Espaçamento entre Letras: {{value}}px",
"lineHeight": "Altura da Linha",
"lineHeightValue": "Altura da Linha: {{value}}",
"minimumContrastRatio": "Taxa de Contraste Mínima",
"minimumContrastRatioDesc": "Ajustar cores automaticamente para melhor legibilidade",
"minimumContrastRatioValue": "Taxa de Contraste Mínima: {{value}}",
"modifierAlt": "Alt",
"modifierCtrl": "Ctrl",
"modifierShift": "Shift",
"noSnippetFound": "Nenhum snippet encontrado",
"preview": "Pré-visualização",
"quickActionName": "Nome da Ação",
"quickActions": "Ações Rápidas",
"quickActionsDescription": "Ações rápidas permitem criar botões personalizados que executam snippets SSH neste servidor. Esses botões aparecerão no topo da página de Estatísticas do Servidor para acesso rápido.",
"quickActionsList": "Lista de Ações Rápidas",
"quickActionsOrder": "Botões de ação rápida aparecerão na ordem listada acima na página de Estatísticas do Servidor",
"rebootCommand": "Comando de Reinicialização",
"rightClickSelectsWord": "Clique Direito Seleciona Palavra",
"rightClickSelectsWordDesc": "Clicar com o botão direito seleciona a palavra sob o cursor",
"scrollbackBuffer": "Histórico de Rolagem",
"scrollbackBufferDesc": "Número de linhas para manter no histórico de rolagem",
"scrollbackBufferValue": "Histórico de Rolagem: {{value}} linhas",
"searchSnippets": "Pesquisar snippets...",
"selectBackspaceMode": "Selecionar modo backspace",
"selectBellStyle": "Selecionar estilo de campainha",
"selectCursorStyle": "Selecionar estilo de cursor",
"selectFont": "Selecionar fonte",
"selectFontDesc": "Selecione a fonte para usar no terminal",
"selectModifier": "Selecionar modificador",
"selectSnippet": "Selecionar snippet",
"selectTheme": "Selecionar tema",
"serverStats": "Estatísticas do Servidor",
"shutdownCommand": "Comando de Desligamento",
"snippetNone": "Nenhum",
"sshAgentForwarding": "Encaminhamento de Agente SSH",
"sshAgentForwardingDesc": "Encaminhar agente de autenticação SSH para host remoto",
"startupSnippet": "Snippet de Inicialização",
"terminalCustomization": "Personalização do Terminal",
"theme": "Tema",
"themePreview": "Pré-visualização do Tema"
},
"terminal": {
"title": "Terminal",
@@ -779,7 +892,11 @@
"connectionTimeout": "Tempo limite de conexão esgotado",
"terminalTitle": "Terminal - {{host}}",
"terminalWithPath": "Terminal - {{host}}:{{path}}",
"runTitle": "Executando {{command}} - {{host}}"
"runTitle": "Executando {{command}} - {{host}}",
"totpCodeLabel": "Código de Verificação",
"totpPlaceholder": "000000",
"totpRequired": "Autenticação de Dois Fatores Obrigatória",
"totpVerify": "Verificar"
},
"fileManager": {
"title": "Gerenciador de Arquivos",
@@ -865,7 +982,6 @@
"copyPaths": "Copiar caminhos",
"delete": "Excluir",
"properties": "Propriedades",
"preview": "Visualizar",
"refresh": "Atualizar",
"downloadFiles": "Baixar {{count}} arquivos para o Navegador",
"copyFiles": "Copiar {{count}} itens",
@@ -880,18 +996,11 @@
"failedToDeleteItem": "Falha ao excluir item",
"itemRenamedSuccessfully": "{{type}} renomeado com sucesso",
"failedToRenameItem": "Falha ao renomear item",
"upload": "Enviar",
"download": "Baixar",
"newFile": "Novo Arquivo",
"newFolder": "Nova Pasta",
"rename": "Renomear",
"delete": "Excluir",
"permissions": "Permissões",
"size": "Tamanho",
"modified": "Modificado",
"path": "Caminho",
"fileName": "Nome do Arquivo",
"folderName": "Nome da Pasta",
"confirmDelete": "Tem certeza que deseja excluir {{name}}?",
"uploadSuccess": "Arquivo enviado com sucesso",
"uploadFailed": "Falha ao enviar arquivo",
@@ -911,10 +1020,7 @@
"fileSavedSuccessfully": "Arquivo salvo com sucesso",
"saveTimeout": "Tempo limite da operação de salvamento esgotado. O arquivo pode ter sido salvo com sucesso, mas a operação demorou muito para ser concluída. Verifique os logs do Docker para confirmação.",
"failedToSaveFile": "Falha ao salvar arquivo",
"folder": "Pasta",
"file": "Arquivo",
"deletedSuccessfully": "excluído com sucesso",
"failedToDeleteItem": "Falha ao excluir item",
"connectToServer": "Conectar a um Servidor",
"selectServerToEdit": "Selecione um servidor da barra lateral para começar a editar arquivos",
"fileOperations": "Operações de Arquivo",
@@ -943,7 +1049,7 @@
"sshReconnectionTimeout": "Tempo limite excedido na reconexão SSH",
"saveOperationTimeout": "Tempo limite excedido na operação de salvar",
"cannotSaveFile": "Não é possível salvar o arquivo",
"dragSystemFilesToUpload": "Arraste arquivos do sistema aqui para fazer upload",
"dragSystemFilesToUpload": "Arraste arquivos do sistema aqui para enviar",
"dragFilesToWindowToDownload": "Arraste arquivos para fora da janela para baixar",
"openTerminalHere": "Abrir Terminal Aqui",
"run": "Executar",
@@ -971,14 +1077,12 @@
"unpinFile": "Desfixar arquivo",
"removeShortcut": "Remover atalho",
"saveFilesToSystem": "Salvar {{count}} arquivos como...",
"saveToSystem": "Salvar como...",
"pinFile": "Fixar arquivo",
"addToShortcuts": "Adicionar aos atalhos",
"selectLocationToSave": "Selecionar local para salvar",
"downloadToDefaultLocation": "Baixar para o local padrão",
"pasteFailed": "Falha ao colar",
"noUndoableActions": "Nenhuma ação pode ser desfeita",
"undoCopySuccess": "Operação de cópia desfeita: {{count}} arquivos copiados foram excluídos",
"undoCopySuccess": "oOperação de cópia desfeita: {{count}} arquivos copiados foram excluídos",
"undoCopyFailedDelete": "Falha ao desfazer: Não foi possível excluir os arquivos copiados",
"undoCopyFailedNoInfo": "Falha ao desfazer: Não foi possível encontrar informações do arquivo copiado",
"undoMoveSuccess": "Operação de mover desfeita: {{count}} arquivos movidos de volta ao local original",
@@ -989,10 +1093,9 @@
"undoOperationFailed": "Falha na operação de desfazer",
"unknownError": "Erro desconhecido",
"enterPath": "Digite o caminho...",
"editPath": "Editar caminho",
"editPath": "Editarar caminho",
"confirm": "Confirmar",
"cancel": "Cancelar",
"folderName": "Nome da pasta",
"find": "Localizar...",
"replaceWith": "Substituir por...",
"replace": "Substituir",
@@ -1016,25 +1119,20 @@
"toggleComment": "Alternar Comentário",
"indent": "Indentar",
"outdent": "Remover Indentação",
"autoComplete": "Auto Completar",
"autoComplete": "Autocompletar",
"imageLoadError": "Falha ao carregar imagem",
"zoomIn": "Aumentar Zoom",
"zoomOut": "Diminuir Zoom",
"rotate": "Rotacionar",
"originalSize": "Tamanho Original",
"startTyping": "Comece a digitar...",
"unknownSize": "Tamanho desconhecido",
"fileIsEmpty": "Arquivo está vazio",
"modified": "Modificado",
"largeFileWarning": "Aviso de Arquivo Grande",
"largeFileWarningDesc": "Este arquivo tem {{size}} de tamanho, o que pode causar problemas de desempenho quando aberto como texto.",
"fileNotFoundAndRemoved": "Arquivo \"{{name}}\" não encontrado e foi removido dos arquivos recentes/fixados",
"failedToLoadFile": "Falha ao carregar arquivo: {{error}}",
"serverErrorOccurred": "Ocorreu um erro no servidor. Por favor, tente novamente mais tarde.",
"fileSavedSuccessfully": "Arquivo salvo com sucesso",
"autoSaveFailed": "Falha no salvamento automático",
"fileAutoSaved": "Arquivo salvo automaticamente",
"moveFileFailed": "Falha ao mover {{name}}",
"moveOperationFailed": "Falha na operação de mover",
"canOnlyCompareFiles": "Só é possível comparar dois arquivos",
@@ -1049,7 +1147,7 @@
"operationCompletedSuccessfully": "{{operation}} {{count}} itens com sucesso",
"operationCompleted": "{{operation}} {{count}} itens",
"downloadFileSuccess": "Arquivo {{name}} baixado com sucesso",
"downloadFileFailed": "Falha no download",
"downloadFileFailed": "Falha no baixar",
"moveTo": "Mover para {{name}}",
"diffCompareWith": "Comparar diferenças com {{name}}",
"dragOutsideToDownload": "Arraste para fora da janela para baixar ({{count}} arquivos)",
@@ -1068,12 +1166,42 @@
"fileComparison": "Comparação de Arquivos: {{file1}} vs {{file2}}",
"fileTooLarge": "Arquivo muito grande: {{error}}",
"sshConnectionFailed": "Falha na conexão SSH. Por favor, verifique sua conexão com {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "Falha ao carregar arquivo: {{error}}"
"loadFileFailed": "Falha ao carregar arquivo: {{error}}",
"andMoreFiles": "e {{count}} mais...",
"archiveExtractedSuccessfully": "{{name}} extraído com sucesso",
"archiveName": "Nome do Arquivo",
"changePermissions": "Alterar Permissões",
"changePermissionsDesc": "Modificar permissões do arquivo para",
"compress": "Comprimir",
"compressFailed": "Falha na compressão",
"compressFile": "Comprimir Arquivo",
"compressFiles": "Comprimir Arquivos",
"compressFilesDesc": "Comprimir {{count}} itens em um arquivo",
"compressingFiles": "Comprimindo {{count}} itens em {{name}}...",
"compressionFormat": "Formato de Compressão",
"connectedSuccessfully": "Conectado com sucesso",
"currentPermissions": "Permissões Atuais",
"enterArchiveName": "Inserir nome do arquivo...",
"execute": "Executar",
"extractArchive": "Extrair Arquivo",
"extractFailed": "Extração falhou",
"extractingArchive": "Extraindo {{name}}...",
"failedToChangePermissions": "Falha ao alterar permissões",
"filesCompressedSuccessfully": "{{name}} criado com sucesso",
"group": "Grupo",
"newPermissions": "Novas Permissões",
"others": "Outros",
"owner": "Proprietário",
"permissionsChangedSuccessfully": "Permissões alteradas com sucesso",
"read": "Leitura",
"selectedFiles": "Arquivos Selecionados",
"totpVerificationFailed": "Verificação TOTP falhou",
"write": "Escrita"
},
"tunnels": {
"title": "Túneis SSH",
"noSshTunnels": "Sem Túneis SSH",
"createFirstTunnelMessage": "Você ainda não criou nenhum túnel SSH. Configure conexões de túnel no Gerenciador de Hosts para começar.",
"createFirstTunnelMessage": "Crie seu primeiro túnel SSH para começar. Use o Gerenciador SSH para adicionar hosts com conexões de túnel.",
"connected": "Conectado",
"disconnected": "Desconectado",
"connecting": "Conectando...",
@@ -1093,7 +1221,7 @@
"port": "Porta",
"attempt": "Tentativa {{current}} de {{max}}",
"nextRetryIn": "Próxima tentativa em {{seconds}} segundos",
"checkDockerLogs": "Verifique seus logs do Docker para ver o motivo do erro, entre no",
"checkDockerLogs": "Verifique seus logs do Docker para ver o motivo do erro",
"noTunnelConnections": "Nenhuma conexão de túnel configurada",
"tunnelConnections": "Conexões de Túnel",
"addTunnel": "Adicionar Túnel",
@@ -1114,18 +1242,9 @@
"local": "Local",
"remote": "Remoto",
"dynamic": "Dinâmico",
"noSshTunnels": "Sem Túneis SSH",
"createFirstTunnelMessage": "Crie seu primeiro túnel SSH para começar. Use o Gerenciador SSH para adicionar hosts com conexões de túnel.",
"unknownConnectionStatus": "Desconhecido",
"connected": "Conectado",
"connecting": "Conectando...",
"disconnecting": "Desconectando...",
"disconnected": "Desconectado",
"portMapping": "Porta {{sourcePort}} → {{endpointHost}}:{{endpointPort}}",
"disconnect": "Desconectar",
"connect": "Conectar",
"canceling": "Cancelando...",
"endpointHostNotFound": "Host de destino não encontrado",
"endpointHostNotFound": "Host de destinenhum não encontrado",
"discord": "Discord",
"githubIssue": "issue no GitHub",
"forHelp": "para ajuda"
@@ -1136,8 +1255,8 @@
"memory": "Memória",
"disk": "Disco",
"network": "Rede",
"uptime": "Tempo Ativo",
"loadAverage": "Carga Média",
"uptime": "Tempo de Atividade",
"loadAverage": "Média: {{avg1}}, {{avg5}}, {{avg15}}",
"processes": "Processos",
"connections": "Conexões",
"usage": "Uso",
@@ -1153,7 +1272,6 @@
"cpuCores_one": "{{count}} CPU",
"cpuCores_other": "{{count}} CPUs",
"naCpus": "N/D CPU(s)",
"loadAverage": "Média: {{avg1}}, {{avg5}}, {{avg15}}",
"loadAverageNA": "Média: N/D",
"cpuUsage": "Uso da CPU",
"memoryUsage": "Uso de Memória",
@@ -1169,8 +1287,40 @@
"serverOffline": "Servidor Offline",
"cannotFetchMetrics": "Não é possível buscar métricas do servidor offline",
"load": "Carga",
"free": "Livre",
"available": "Disponível"
"addWidget": "Adicionar Widget",
"cancelEdit": "Cancelar",
"diskUsage": "Uso de Disco",
"editLayout": "Editar Layout",
"executeQuickAction": "Executar {{name}}",
"executingQuickAction": "Executando {{name}}...",
"failedToSaveLayout": "Falha ao salvar layout",
"from": "de",
"hostname": "Hostname",
"kernel": "Kernel",
"layoutSaved": "Layout salvo com sucesso",
"loginStats": "Estatísticas de Login SSH",
"networkInterfaces": "Interfaces de Rede",
"noInterfacesFound": "Nenhuma interface de rede encontrada",
"noProcessesFound": "Nenhum processo encontrado",
"noRecentLoginData": "Nenhum dado de login recente",
"operatingSystem": "Sistema Operacional",
"quickActionError": "Falha ao executar {{name}}",
"quickActionFailed": "{{name}} falhou",
"quickActionSuccess": "{{name}} concluído com sucesso",
"quickActions": "Ações Rápidas",
"recentFailedAttempts": "Tentativas Falhas Recentemente",
"recentSuccessfulLogins": "Logins Bem-sucedidos Recentemente",
"running": "Executando",
"saveLayout": "Salvar Layout",
"seconds": "segundos",
"systemInfo": "Informações do Sistema",
"totalLogins": "Total de Logins",
"totalProcesses": "Total de Processos",
"totalUptime": "Tempo de Atividade Total",
"totpRequired": "Autenticação TOTP Obrigatória",
"totpUnavailable": "Estatísticas do Servidor indisponíveis para servidores com TOTP ativado",
"uniqueIPs": "IPs Únicos",
"unsavedChanges": "Alterações não salvas"
},
"auth": {
"tagline": "GERENCIADOR DE TERMINAL SSH",
@@ -1242,7 +1392,7 @@
"enableTwoFactorButton": "Ativar Autenticação de Dois Fatores",
"addExtraSecurityLayer": "Adicione uma camada extra de segurança à sua conta",
"firstUser": "Primeiro Usuário",
"firstUserMessage": "Você é o primeiro usuário e será tornado admin. Você pode ver as configurações de admin no menu suspenso do usuário na barra lateral. Se você acha que isso é um erro, verifique os logs do docker ou crie uma issue no GitHub.",
"firstUserMessage": "Você é o primeiro usuário e será tornado administrador. Você pode ver as configurações de administrador no menu suspenso do usuário na barra lateral. Se você acha que isso é um erro, verifique os logs do docker ou crie uma issue no GitHub.",
"external": "Externo",
"loginWithExternal": "Entrar com Provedor Externo",
"loginWithExternalDesc": "Entre usando seu provedor de identidade externo configurado",
@@ -1269,7 +1419,18 @@
"sshTimeoutDescription": "A tentativa de autenticação expirou. Por favor, tente novamente.",
"sshProvideCredentialsDescription": "Por favor, forneça suas credenciais SSH para conectar a este servidor.",
"sshPasswordDescription": "Digite a senha para esta conexão SSH.",
"sshKeyPasswordDescription": "Se sua chave SSH estiver criptografada, digite a senha aqui."
"sshKeyPasswordDescription": "Se sua chave SSH estiver criptografada, digite a senha aqui.",
"authenticating": "Autenticando...",
"authenticationDisabled": "Autenticação Desativada",
"authenticationDisabledDesc": "Todos os métodos de autenticação estão atualmente desativados. Por favor, contate seu administrador.",
"desktopApp": "App Desktop",
"loadingServer": "Carregando servidor...",
"loggingInToDesktopApp": "Entrando no app desktop",
"loggingInToDesktopAppViaWeb": "Entrando no app desktop via interface web",
"loggingInToMobileApp": "Entrando no app mobile",
"mobileApp": "App Mobile",
"redirectingToApp": "Redirecionando para o app...",
"passwordResetSuccessDesc": "Sua senha foi redefinida com sucesso. Você pode agora entrar com sua nova senha."
},
"errors": {
"notFound": "Página não encontrada",
@@ -1300,7 +1461,8 @@
"emailExists": "Email já existe",
"loadFailed": "Falha ao carregar dados",
"saveError": "Falha ao salvar",
"sessionExpired": "Sessão expirada - por favor, faça login novamente"
"sessionExpired": "Sessão expirada - por favor, faça login novamente",
"passwordLoginDisabled": "Login com nome de usuário/senha está atualmente desativado"
},
"messages": {
"saveSuccess": "Salvo com sucesso",
@@ -1345,9 +1507,12 @@
"fileColorCodingDesc": "Codificar arquivos por cores por tipo: pastas (vermelho), arquivos (azul), links simbólicos (verde)",
"commandAutocomplete": "Autocompletar Comandos",
"commandAutocompleteDesc": "Ativar sugestões de autocompletar com a tecla Tab para comandos do terminal baseado no seu histórico",
"defaultSnippetFoldersCollapsed": "Recolher Pastas de Snippets por Padrão",
"defaultSnippetFoldersCollapsedDesc": "Quando ativado, todas as pastas de snippets serão recolhidas ao abrir a aba de snippets",
"currentPassword": "Senha Atual",
"passwordChangedSuccess": "Senha alterada com sucesso! Por favor, faça login novamente.",
"failedToChangePassword": "Falha ao alterar a senha. Por favor, verifique sua senha atual e tente novamente."
"failedToChangePassword": "Falha ao alterar a senha. Por favor, verifique sua senha atual e tente novamente.",
"externalAndLocal": "Autenticação Dupla"
},
"user": {
"failedToLoadVersionInfo": "Falha ao carregar informações da versão"
@@ -1377,10 +1542,10 @@
"redirectUrl": "https://seu-provedor.com/application/o/termix/",
"tokenUrl": "https://seu-provedor.com/application/o/token/",
"userIdField": "sub",
"usernameField": "name",
"usernameField": "nome",
"scopes": "openid email profile",
"userinfoUrl": "https://your-provider.com/application/o/userinfo/",
"enterUsername": "Digite o nome de usuário para tornar admin",
"userinfoUrl": "https://seu-provider.com/application/o/userinfo/",
"enterUsername": "Digite o nome de usuário para tornar administrador",
"searchHosts": "Procurar hosts por nome, usuário, IP, pasta, tags...",
"enterPassword": "Digite sua senha",
"totpCode": "Código TOTP de 6 dígitos",
@@ -1398,9 +1563,9 @@
"noFolder": "Sem Pasta",
"passwordRequired": "Senha é obrigatória",
"failedToDeleteAccount": "Falha ao excluir conta",
"failedToMakeUserAdmin": "Falha ao tornar usuário admin",
"userIsNowAdmin": "Usuário {{username}} agora é um admin",
"removeAdminConfirm": "Tem certeza que deseja remover o status de admin de {{username}}?",
"failedToMakeUserAdmin": "Falha ao tornar usuário administrador",
"userIsNowAdmin": "Usuário {{username}} agora é um administrador",
"removeAdminConfirm": "Tem certeza que deseja remover o status de administrador de {{username}}?",
"deleteUserConfirm": "Tem certeza que deseja excluir o usuário {{username}}? Esta ação não pode ser desfeita.",
"deleteAccount": "Excluir Conta",
"closeDeleteAccount": "Fechar Exclusão de Conta",
@@ -1408,7 +1573,7 @@
"deleteAccountWarningDetails": "Excluir sua conta removerá todos os seus dados, incluindo hosts SSH, configurações e preferências. Esta ação é irreversível.",
"deleteAccountWarningShort": "Esta ação é irreversível e excluirá permanentemente sua conta.",
"cannotDeleteAccount": "Não é Possível Excluir Conta",
"lastAdminWarning": "Você é o último usuário administrador. Você não pode excluir sua conta pois isso deixaria o sistema sem administradores. Por favor, torne outro usuário administrador primeiro, ou contate o suporte do sistema.",
"lastAdminWarning": "Você é o último administrador. Você não pode excluir sua conta pois isso deixaria o sistema sem administradores. Por favor, torne outro usuário administrador primeiro, ou contate o suporte do sistema.",
"confirmPassword": "Confirmar Senha",
"deleting": "Excluindo...",
"cancel": "Cancelar"
@@ -1431,7 +1596,6 @@
"deleteItem": "Excluir Item",
"createNewFile": "Criar Novo Arquivo",
"createNewFolder": "Criar Nova Pasta",
"deleteItem": "Excluir Item",
"renameItem": "Renomear Item",
"clickToSelectFile": "Clique para selecionar um arquivo",
"noSshHosts": "Sem Hosts SSH",
@@ -1527,5 +1691,95 @@
"mobileAppInProgressDesc": "Estamos trabalhando em um aplicativo móvel dedicado para proporcionar uma melhor experiência em dispositivos móveis.",
"viewMobileAppDocs": "Instalar Aplicativo Móvel",
"mobileAppDocumentation": "Documentação do Aplicativo Móvel"
},
"commandPalette": {
"addCredential": "Adicionar Credencial",
"addHost": "Adicionar Host",
"adminSettings": "Configurações de Administrador",
"close": "Fechar",
"discord": "Discord",
"donate": "Doar",
"edit": "Editar",
"github": "GitHub",
"hostManager": "Gerenciador de Hosts",
"hosts": "Hosts",
"links": "Links",
"navigation": "Navegação",
"openFileManager": "Abrir Gerenciador de Arquivos",
"openServerDetails": "Abrir Detalhes do Servidor",
"press": "Pressione",
"recentActivity": "Atividade Recente",
"searchPlaceholder": "Pesquisar por hosts ou ações rápidas...",
"support": "Suporte",
"toToggle": "para alternar",
"updateLog": "Log de Atualizações",
"userProfile": "Perfil do Usuário"
},
"dashboard": {
"addCredential": "Adicionar Credencial",
"addHost": "Adicionar Host",
"adminSettings": "Configurações de Administrador",
"cpu": "CPU",
"database": "Banco de Dados",
"discord": "Discord",
"donate": "Doar",
"error": "Erro",
"github": "GitHub",
"healthy": "Saudável",
"loadingRecentActivity": "Carregando atividade recente...",
"loadingServerStats": "Carregando estatísticas do servidor...",
"noRecentActivity": "Nenhuma atividade recente",
"noServerData": "Nenhum dado do servidor disponível",
"notAvailable": "N/D",
"quickActions": "Ações Rápidas",
"ram": "RAM",
"recentActivity": "Atividade Recente",
"reset": "Redefinir",
"serverOverview": "Visão Geral do Servidor",
"serverStats": "Estatísticas do Servidor",
"support": "Suporte",
"title": "Painel",
"totalCredentials": "Total de Credenciais",
"totalServers": "Total de Servidores",
"totalTunnels": "Total de Túneis",
"upToDate": "Atualizado",
"updateAvailable": "Atualização Disponível",
"uptime": "Tempo de Atividade",
"userProfile": "Perfil do Usuário",
"version": "Versão"
},
"snippets": {
"content": "Comando",
"contentPlaceholder": "ex: sudo systemctl restart nginx",
"contentRequired": "Comando é obrigatório",
"copySuccess": "Copiado \"{{name}}\" para área de transferência",
"copyTooltip": "Copiar snippet para área de transferência",
"create": "Criar Snippet",
"createDescription": "Criar um novo snippet de comando para execução rápida",
"createFailed": "Falha ao criar snippet",
"createSuccess": "Snippet criado com sucesso",
"deleteConfirmDescription": "Tem certeza de que deseja excluir \"{{name}}\"?",
"deleteConfirmTitle": "Excluir Snippet",
"deleteFailed": "Falha ao excluir snippet",
"deleteSuccess": "Snippet excluído com sucesso",
"deleteTooltip": "Excluir este snippet",
"description": "Descrição",
"descriptionPlaceholder": "Descrição opcional",
"edit": "Editar Snippet",
"editDescription": "Editar este snippet de comando",
"editTooltip": "Editar este snippet",
"empty": "Nenhum snippet ainda",
"emptyHint": "Crie um snippet para salvar comandos comumente usados",
"executeSuccess": "Executando: {{name}}",
"failedToFetch": "Falha ao buscar snippets",
"name": "Nome",
"namePlaceholder": "ex: Reiniciar Nginx",
"nameRequired": "Nome é obrigatório",
"new": "Novo Snippet",
"run": "Executar",
"runTooltip": "Executar este snippet no terminal",
"title": "Snippets",
"updateFailed": "Falha ao atualizar snippet",
"updateSuccess": "Snippet atualizado com sucesso"
}
}
}

View File

@@ -59,7 +59,6 @@
"keyTypeRSA": "RSA",
"keyTypeECDSA": "ECDSA",
"keyTypeEd25519": "Ed25519",
"updateCredential": "Обновить учетные данные",
"basicInfo": "Основная информация",
"authentication": "Аутентификация",
"organization": "Организация",
@@ -118,7 +117,6 @@
"credentialSecuredDescription": "Все конфиденциальные данные зашифрованы с помощью AES-256",
"passwordAuthentication": "Аутентификация по паролю",
"keyAuthentication": "Аутентификация по ключу",
"keyType": "Тип ключа",
"securityReminder": "Напоминание о безопасности",
"securityReminderText": "Никогда не передавайте ваши учетные данные. Все данные зашифрованы при хранении.",
"hostsUsingCredential": "Хосты, использующие эти учетные данные",
@@ -166,7 +164,8 @@
"generateKeyPairNote": "Сгенерировать новую пару SSH-ключей напрямую. Это заменит любые существующие ключи в форме.",
"invalidKey": "Неверный ключ",
"detectionError": "Ошибка определения",
"unknown": "Неизвестно"
"unknown": "Неизвестно",
"credentialId": "Учётные данные ID"
},
"dragIndicator": {
"error": "Ошибка: {{error}}",
@@ -261,7 +260,11 @@
"saveError": "Ошибка сохранения конфигурации",
"saving": "Сохранение...",
"saveConfig": "Сохранить конфигурацию",
"helpText": "Введите URL, где работает ваш сервер Termix (например, http://localhost:30001 или https://your-server.com)"
"helpText": "Введите URL, где работает ваш сервер Termix (например, http://localhost:30001 или https://your-server.com)",
"changeServer": "Сменить сервер",
"mustIncludeProtocol": "URL сервера должен начинаться с http:// или https://",
"notValidatedWarning": "URL не проверен - убедитесь, что он правильный",
"warning": "Предупреждение"
},
"versionCheck": {
"error": "Ошибка проверки версии",
@@ -294,7 +297,7 @@
"warning": "Предупреждение",
"info": "Информация",
"success": "Успех",
"loading": "Загрузка",
"loading": "Загрузка...",
"required": "Обязательно",
"optional": "Опционально",
"clear": "Очистить",
@@ -308,7 +311,6 @@
"updateAvailable": "Доступно обновление",
"sshPath": "SSH-путь",
"localPath": "Локальный путь",
"loading": "Загрузка...",
"noAuthCredentials": "Нет учетных данных аутентификации для этого SSH-хоста",
"noReleases": "Нет выпусков",
"updatesAndReleases": "Обновления и выпуски",
@@ -323,17 +325,13 @@
"resetPassword": "Сбросить пароль",
"resetCode": "Код сброса",
"newPassword": "Новый пароль",
"sshPath": "SSH-путь",
"localPath": "Локальный путь",
"folder": "Папка",
"file": "Файл",
"renamedSuccessfully": "успешно переименован",
"deletedSuccessfully": "успешно удален",
"noAuthCredentials": "Нет учетных данных аутентификации для этого SSH-хоста",
"noTunnelConnections": "Нет настроенных туннельных подключений",
"sshTools": "SSH-инструменты",
"english": "Английский",
"russia": "Русский",
"chinese": "Китайский",
"german": "Немецкий",
"cancel": "Отмена",
@@ -342,36 +340,27 @@
"login": "Войти",
"logout": "Выйти",
"register": "Зарегистрироваться",
"username": "Имя пользователя",
"password": "Пароль",
"version": "Версия",
"confirmPassword": "Подтвердите пароль",
"back": "Назад",
"email": "Email",
"submit": "Отправить",
"cancel": "Отмена",
"change": "Изменить",
"save": "Сохранить",
"delete": "Удалить",
"edit": "Редактировать",
"add": "Добавить",
"search": "Поиск",
"loading": "Загрузка...",
"error": "Ошибка",
"success": "Успех",
"warning": "Предупреждение",
"info": "Информация",
"confirm": "Подтвердить",
"yes": "Да",
"no": "Нет",
"ok": "OK",
"close": "Закрыть",
"enabled": "Включено",
"disabled": "Отключено",
"important": "Важно",
"notEnabled": "Не включено",
"settingUp": "Настройка...",
"back": "Назад",
"next": "Далее",
"previous": "Назад",
"refresh": "Обновить",
@@ -395,7 +384,10 @@
"documentation": "Документация",
"retry": "Повторить",
"checking": "Проверка...",
"checkingDatabase": "Проверка подключения к базе данных..."
"checkingDatabase": "Проверка подключения к базе данных...",
"connect": "Подключить",
"connecting": "Подключение...",
"saving": "Сохранение..."
},
"nav": {
"home": "Главная",
@@ -424,7 +416,7 @@
"userManagement": "Управление пользователями",
"makeAdmin": "Сделать администратором",
"removeAdmin": "Убрать администратора",
"deleteUser": "Удалить пользователя",
"deleteUser": "Удалить пользователя {{username}}? Это нельзя отменить.",
"allowRegistration": "Разрешить регистрацию",
"oidcSettings": "Настройки OIDC",
"clientId": "Client ID",
@@ -478,10 +470,9 @@
"removeAdminStatus": "Убрать статус администратора у {{username}}?",
"adminStatusRemoved": "Статус администратора убран у {{username}}",
"failedToRemoveAdminStatus": "Не удалось убрать статус администратора",
"deleteUser": "Удалить пользователя {{username}}? Это нельзя отменить.",
"userDeletedSuccessfully": "Пользователь {{username}} успешно удален",
"failedToDeleteUser": "Не удалось удалить пользователя",
"overrideUserInfoUrl": "Переопределить User Info URL (не требуется)",
"overrideUserInfoUrl": "Переопределить Пользователь Info URL (не требуется)",
"databaseSecurity": "Безопасность базы данных",
"encryptionStatus": "Статус шифрования",
"encryptionEnabled": "Шифрование включено",
@@ -531,7 +522,6 @@
"verificationCompleted": "Проверка совместимости завершена - данные не изменялись",
"verificationInProgress": "Проверка завершена",
"dataMigrationCompleted": "Миграция данных успешно завершена!",
"migrationCompleted": "Миграция завершена",
"verificationFailed": "Проверка совместимости не удалась",
"migrationFailed": "Миграция не удалась",
"runningVerification": "Выполняется проверка совместимости...",
@@ -609,7 +599,33 @@
"requiresPasswordLogin": "Требуется включенный вход по паролю",
"passwordLoginDisabledWarning": "Вход по паролю отключен. Убедитесь, что OIDC правильно настроен, иначе вы не сможете войти в Termix.",
"oidcRequiredWarning": "КРИТИЧЕСКИ: Вход по паролю отключен. Если вы сбросите или неправильно настроите OIDC, вы потеряете весь доступ к Termix и заблокируете свой экземпляр. Продолжайте только если вы абсолютно уверены.",
"confirmDisableOIDCWarning": "ПРЕДУПРЕЖДЕНИЕ: Вы собираетесь отключить OIDC, пока вход по паролю также отключен. Это заблокирует ваш экземпляр Termix, и вы потеряете весь доступ. Вы абсолютно уверены, что хотите продолжить?"
"confirmDisableOIDCWarning": "ПРЕДУПРЕЖДЕНИЕ: Вы собираетесь отключить OIDC, пока вход по паролю также отключен. Это заблокирует ваш экземпляр Termix, и вы потеряете весь доступ. Вы абсолютно уверены, что хотите продолжить?",
"accountsLinkedSuccessfully": "OIDC пользователь {{oidcUsername}} связан с {{targetUsername}}",
"confirmRevokeAllSessions": "Вы уверены, что хотите отозвать все сессии для этого пользователя?",
"confirmRevokeSession": "Вы уверены, что хотите отозвать эту сессию?",
"failedToFetchSessions": "Не удалось загрузить сессии",
"failedToLinkAccounts": "Не удалось связать аккаунты",
"failedToRevokeSession": "Не удалось отозвать сессию",
"failedToRevokeSessions": "Не удалось отозвать сессии",
"failedToUnlinkOIDC": "Не удалось отвязать OIDC",
"linkAccountsButton": "Связать аккаунты",
"linkOIDCActionAddCapability": "Добавить возможность входа через OIDC к целевому аккаунту с паролем",
"linkOIDCActionDeleteUser": "Удалить аккаунт пользователя OIDC и все его данные",
"linkOIDCActionDualAuth": "Разрешить аккаунту с паролем вход как по паролю, так и через OIDC",
"linkOIDCDialogDescription": "Связать {{username}} (пользователь OIDC) с существующим аккаунтом с паролем. Это включит двойную аутентификацию для аккаунта с паролем.",
"linkOIDCDialogTitle": "Связать аккаунт OIDC с аккаунтом с паролем",
"linkOIDCWarningTitle": "Предупреждение: Данные пользователя OIDC будут удалены",
"linkTargetUsernameLabel": "Имя пользователя целевого аккаунта с паролем",
"linkTargetUsernamePlaceholder": "Введите имя пользователя аккаунта с паролем",
"linkTargetUsernameRequired": "Целевое имя пользователя обязательно",
"linkToPasswordAccount": "Связать с аккаунтом с паролем",
"linkingAccounts": "Связывание...",
"sessionRevokedSuccessfully": "Сессия успешно отозвана",
"sessionsRevokedSuccessfully": "Сессии успешно отозваны",
"unlinkOIDCDescription": "Удалить аутентификацию OIDC для {{username}}? После этого пользователь сможет войти только с помощью имени пользователя/пароля.",
"unlinkOIDCSuccess": "OIDC отвязан от {{username}}",
"unlinkOIDCTitle": "Отвязать аутентификацию OIDC",
"failedToUpdatePasswordLoginStatus": "Не удалось обновить статус входа по паролю"
},
"hosts": {
"title": "Менеджер хостов",
@@ -834,11 +850,11 @@
"minimumContrastRatioDesc": "Автоматически настраивать цвета для лучшей читаемости",
"sshAgentForwarding": "Переадресация SSH-агента",
"sshAgentForwardingDesc": "Переадресовать агент SSH-аутентификации на удаленный хост",
"backspaceMode": "Режим Backspace",
"selectBackspaceMode": "Выбрать режим Backspace",
"backspaceMode": "Режим Назадspace",
"selectBackspaceMode": "Выбрать режим Назадspace",
"backspaceModeNormal": "Обычный (DEL)",
"backspaceModeControlH": "Control-H (^H)",
"backspaceModeDesc": "Поведение клавиши Backspace для совместимости",
"backspaceModeDesc": "Поведение клавиши Назадspace для совместимости",
"startupSnippet": "Сниппет запуска",
"selectSnippet": "Выбрать сниппет",
"searchSnippets": "Поиск сниппетов...",
@@ -900,6 +916,25 @@
"proxyNode": "Узел прокси",
"proxyType": "Тип прокси",
"advancedAuthSettings": "Расширенные настройки аутентификации"
"advancedAuthSettings": "Расширенные настройки аутентификации",
"addQuickAction": "Добавить Quick Action",
"allHostsInFolderDeleted": "{{count}} хостов успешно удалены из папки \"{{folder}}\"",
"confirmDeleteAllHostsInFolder": "Вы уверены, что хотите удалить все {{count}} хостов в папке \"{{folder}}\"? Это действие нельзя отменить.",
"deleteAllHostsInFolder": "Удалить все хосты в папке",
"editFolderAppearance": "Редактировать вид папки",
"editFolderAppearanceDesc": "Настроить цвет и иконку для папки",
"failedToDeleteHostsInFolder": "Не удалось удалить хосты в папке",
"failedToUpdateFolderAppearance": "Не удалось обновить вид папки",
"folderAppearanceUpdated": "Вид папки успешно обновлен",
"folderColor": "Цвет папки",
"folderIcon": "Иконка папки",
"noSnippetFound": "Сниппет не найден",
"preview": "Предпросмотр",
"quickActionName": "Название действия",
"quickActions": "Быстрые действия",
"quickActionsDescription": "Быстрые действия позволяют создавать пользовательские кнопки, выполняющие SSH-сниппеты на этом сервере. Эти кнопки появятся в верхней части страницы статистики сервера для быстрого доступа.",
"quickActionsList": "Список быстрых действий",
"quickActionsOrder": "Кнопки быстрых действий появятся в указанном выше порядке на странице статистики сервера"
},
"terminal": {
"title": "Терминал",
@@ -937,7 +972,11 @@
"totpRequired": "Требуется двухфакторная аутентификация",
"totpCodeLabel": "Код проверки",
"totpPlaceholder": "000000",
"totpVerify": "Проверить"
"totpVerify": "Проверить",
"sudoPasswordPopupTitle": "Вставить пароль?",
"sudoPasswordPopupHint": "Нажмите Enter для вставки, Esc для отмены",
"sudoPasswordPopupConfirm": "Вставить",
"sudoPasswordPopupDismiss": "Отмена"
},
"fileManager": {
"title": "Файловый менеджер",
@@ -1023,7 +1062,6 @@
"copyPaths": "Копировать пути",
"delete": "Удалить",
"properties": "Свойства",
"preview": "Просмотр",
"refresh": "Обновить",
"downloadFiles": "Скачать {{count}} файлов в браузер",
"copyFiles": "Копировать {{count}} элементов",
@@ -1038,18 +1076,11 @@
"failedToDeleteItem": "Не удалось удалить элемент",
"itemRenamedSuccessfully": "{{type}} успешно переименован",
"failedToRenameItem": "Не удалось переименовать элемент",
"upload": "Загрузить",
"download": "Скачать",
"newFile": "Новый файл",
"newFolder": "Новая папка",
"rename": "Переименовать",
"delete": "Удалить",
"permissions": "Права доступа",
"size": "Размер",
"modified": "Изменен",
"path": "Путь",
"fileName": "Имя файла",
"folderName": "Имя папки",
"confirmDelete": "Вы уверены, что хотите удалить {{name}}?",
"uploadSuccess": "Файл успешно загружен",
"uploadFailed": "Не удалось загрузить файл",
@@ -1069,10 +1100,7 @@
"fileSavedSuccessfully": "Файл успешно сохранен",
"saveTimeout": "Операция сохранения превысила время ожидания. Файл мог быть успешно сохранен, но операция заняла слишком много времени для завершения. Проверьте логи Docker для подтверждения.",
"failedToSaveFile": "Не удалось сохранить файл",
"folder": "Папка",
"file": "Файл",
"deletedSuccessfully": "успешно удален",
"failedToDeleteItem": "Не удалось удалить элемент",
"connectToServer": "Подключиться к серверу",
"selectServerToEdit": "Выберите сервер на боковой панели, чтобы начать редактирование файлов",
"fileOperations": "Файловые операции",
@@ -1129,10 +1157,8 @@
"unpinFile": "Открепить файл",
"removeShortcut": "Удалить ярлык",
"saveFilesToSystem": "Сохранить {{count}} файлов как...",
"saveToSystem": "Сохранить как...",
"pinFile": "Закрепить файл",
"addToShortcuts": "Добавить в ярлыки",
"selectLocationToSave": "Выберите место для сохранения",
"downloadToDefaultLocation": "Скачать в место по умолчанию",
"pasteFailed": "Вставка не удалась",
"noUndoableActions": "Нет действий для отмены",
@@ -1150,7 +1176,6 @@
"editPath": "Редактировать путь",
"confirm": "Подтвердить",
"cancel": "Отмена",
"folderName": "Имя папки",
"find": "Найти...",
"replaceWith": "Заменить на...",
"replace": "Заменить",
@@ -1176,23 +1201,18 @@
"outdent": "Уменьшить отступ",
"autoComplete": "Автозавершение",
"imageLoadError": "Не удалось загрузить изображение",
"zoomIn": "Увеличить",
"zoomOut": "Уменьшить",
"rotate": "Повернуть",
"originalSize": "Оригинальный размер",
"startTyping": "Начните печатать...",
"unknownSize": "Неизвестный размер",
"fileIsEmpty": "Файл пуст",
"modified": "Изменен",
"largeFileWarning": "Предупреждение о большом файле",
"largeFileWarningDesc": "Этот файл имеет размер {{size}}, что может вызвать проблемы с производительностью при открытии как текста.",
"fileNotFoundAndRemoved": "Файл \"{{name}}\" не найден и был удален из недавних/закрепленных файлов",
"failedToLoadFile": "Не удалось загрузить файл: {{error}}",
"serverErrorOccurred": "Произошла ошибка сервера. Пожалуйста, попробуйте позже.",
"fileSavedSuccessfully": "Файл успешно сохранен",
"autoSaveFailed": "Автосохранение не удалось",
"fileAutoSaved": "Файл автосохранен",
"fileDownloadedSuccessfully": "Файл успешно скачан",
"moveFileFailed": "Не удалось переместить {{name}}",
"moveOperationFailed": "Операция перемещения не удалась",
"canOnlyCompareFiles": "Можно сравнивать только два файла",
@@ -1228,12 +1248,40 @@
"sshConnectionFailed": "SSH-подключение не удалось. Пожалуйста, проверьте ваше подключение к {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "Не удалось загрузить файл: {{error}}",
"connectedSuccessfully": "Успешно подключено",
"totpVerificationFailed": "Проверка TOTP не удалась"
"totpVerificationFailed": "Проверка TOTP не удалась",
"andMoreFiles": "and {{count}} more...",
"archiveExtractedSuccessfully": "{{name}} успешно извлечен",
"archiveName": "Имя архива",
"changePermissions": "Изменить права",
"changePermissionsDesc": "Изменить права файла для",
"compress": "Сжать",
"compressFailed": "Сжатие не удалось",
"compressFile": "Сжать файл",
"compressFiles": "Сжать файлы",
"compressFilesDesc": "Сжать {{count}} элементов в архив",
"compressingFiles": "Сжатие {{count}} элементов в {{name}}...",
"compressionFormat": "Формат сжатия",
"currentPermissions": "Текущие права",
"enterArchiveName": "Введите имя архива...",
"execute": "Выполнить",
"extractArchive": "Извлечь архив",
"extractFailed": "Извлечение не удалось",
"extractingArchive": "Извлечение {{name}}...",
"failedToChangePermissions": "Не удалось изменить права",
"filesCompressedSuccessfully": "{{name}} успешно создан",
"group": "Группа",
"newPermissions": "Новые права",
"others": "Другие",
"owner": "Владелец",
"permissionsChangedSuccessfully": "Права успешно изменены",
"read": "Чтение",
"selectedFiles": "Выбранные файлы",
"write": "Запись"
},
"tunnels": {
"title": "SSH-туннели",
"noSshTunnels": "Нет SSH-туннелей",
"createFirstTunnelMessage": "Вы еще не создали SSH-туннели. Настройте туннельные подключения в Менеджере хостов, чтобы начать.",
"createFirstTunnelMessage": "Создайте ваш первый SSH-туннель, чтобы начать. Используйте SSH-менеджер для добавления хостов с туннельными подключениями.",
"connected": "Подключено",
"disconnected": "Отключено",
"connecting": "Подключение...",
@@ -1274,17 +1322,8 @@
"local": "Локальный",
"remote": "Удаленный",
"dynamic": "Динамический",
"noSshTunnels": "Нет SSH-туннелей",
"createFirstTunnelMessage": "Создайте ваш первый SSH-туннель, чтобы начать. Используйте SSH-менеджер для добавления хостов с туннельными подключениями.",
"unknownConnectionStatus": "Неизвестно",
"connected": "Подключено",
"connecting": "Подключение...",
"disconnecting": "Отключение...",
"disconnected": "Отключено",
"portMapping": "Порт {{sourcePort}} → {{endpointHost}}:{{endpointPort}}",
"disconnect": "Отключить",
"connect": "Подключить",
"canceling": "Отмена...",
"endpointHostNotFound": "Хост конечной точки не найден",
"discord": "Discord",
"githubIssue": "Проблема на GitHub",
@@ -1297,7 +1336,7 @@
"disk": "Диск",
"network": "Сеть",
"uptime": "Время работы",
"loadAverage": "Средняя загрузка",
"loadAverage": "Средняя: {{avg1}}, {{avg5}}, {{avg15}}",
"processes": "Процессы",
"connections": "Подключения",
"usage": "Использование",
@@ -1313,7 +1352,6 @@
"cpuCores_one": "{{count}} CPU",
"cpuCores_other": "{{count}} CPU",
"naCpus": "N/A CPU",
"loadAverage": "Средняя: {{avg1}}, {{avg5}}, {{avg15}}",
"loadAverageNA": "Средняя: N/A",
"cpuUsage": "Использование CPU",
"memoryUsage": "Использование памяти",
@@ -1332,8 +1370,6 @@
"totpRequired": "Требуется TOTP-аутентификация",
"totpUnavailable": "Статистика сервера недоступна для серверов с включенным TOTP",
"load": "Загрузка",
"free": "Свободно",
"available": "Доступно",
"editLayout": "Редактировать макет",
"cancelEdit": "Отмена",
"addWidget": "Добавить виджет",
@@ -1358,7 +1394,13 @@
"recentSuccessfulLogins": "Последние успешные входы",
"recentFailedAttempts": "Последние неудачные попытки",
"noRecentLoginData": "Нет данных о недавних входах",
"from": "с"
"from": "с",
"executeQuickAction": "Выполнить {{name}}",
"executingQuickAction": "Выполнение {{name}}...",
"quickActionError": "Не удалось выполнить {{name}}",
"quickActionFailed": "{{name}} завершилось ошибкой",
"quickActionSuccess": "{{name}} завершено успешно",
"quickActions": "Быстрые действия"
},
"auth": {
"tagline": "SSH ТЕРМИНАЛ МЕНЕДЖЕР",
@@ -1448,7 +1490,27 @@
"signUp": "Зарегистрироваться",
"dataLossWarning": "Сброс пароля этим способом удалит все ваши сохраненные SSH-хосты, учетные данные и другие зашифрованные данные. Это действие нельзя отменить. Используйте это только если вы забыли пароль и не вошли в систему.",
"authenticationDisabled": "Аутентификация отключена",
"authenticationDisabledDesc": "Все методы аутентификации в настоящее время отключены. Пожалуйста, свяжитесь с вашим администратором."
"authenticationDisabledDesc": "Все методы аутентификации в настоящее время отключены. Пожалуйста, свяжитесь с вашим администратором.",
"authenticating": "Аутентификация...",
"desktopApp": "Настольное приложение",
"loadingServer": "Загрузка сервера...",
"loggingInToDesktopApp": "Вход в настольное приложение",
"loggingInToDesktopAppViaWeb": "Вход в настольное приложение через веб-интерфейс",
"loggingInToMobileApp": "Вход в мобильное приложение",
"mobileApp": "Мобильное приложение",
"redirectingToApp": "Перенаправление в приложение...",
"sshAuthFailedDescription": "Предоставленные учетные данные неверны. Пожалуйста, попробуйте снова с правильными учетными данными.",
"sshAuthenticationFailed": "Аутентификация не удалась",
"sshAuthenticationRequired": "Требуется SSH-аутентификация",
"sshAuthenticationTimeout": "Тайм-аут аутентификации",
"sshKeyPasswordDescription": "Если ваш SSH-ключ зашифрован, введите парольную фразу здесь.",
"sshNoKeyboardInteractive": "Клавиатурная интерактивная аутентификация недоступна",
"sshNoKeyboardInteractiveDescription": "Сервер не поддерживает клавиатурную интерактивную аутентификацию. Пожалуйста, укажите ваш пароль или SSH-ключ.",
"sshPasswordDescription": "Введите пароль для этого SSH-подключения.",
"sshProvideCredentialsDescription": "Пожалуйста, предоставьте ваши SSH-учетные данные для подключения к этому серверу.",
"sshTimeoutDescription": "Попытка аутентификации истекла по времени. Пожалуйста, попробуйте снова.",
"passwordResetSuccess": "Сброс пароля прошел успешно",
"passwordResetSuccessDesc": "Ваш пароль был успешно сброшен. Теперь вы можете войти с новым паролем."
},
"errors": {
"notFound": "Страница не найдена",
@@ -1525,9 +1587,12 @@
"fileColorCodingDesc": "Цветовая кодировка файлов по типу: папки (красный), файлы (синий), символические ссылки (зелёный)",
"commandAutocomplete": "Автодополнение команд",
"commandAutocompleteDesc": "Включить автодополнение команд терминала клавишей Tab на основе вашей истории команд",
"defaultSnippetFoldersCollapsed": "Сворачивать папки сниппетов по умолчанию",
"defaultSnippetFoldersCollapsedDesc": "Если включено, все папки сниппетов будут свёрнуты при открытии вкладки сниппетов",
"currentPassword": "Текущий пароль",
"passwordChangedSuccess": "Пароль успешно изменен! Пожалуйста, войдите снова.",
"failedToChangePassword": "Не удалось изменить пароль. Пожалуйста, проверьте ваш текущий пароль и попробуйте снова."
"failedToChangePassword": "Не удалось изменить пароль. Пожалуйста, проверьте ваш текущий пароль и попробуйте снова.",
"externalAndLocal": "Двойная аутентификация"
},
"user": {
"failedToLoadVersionInfo": "Не удалось загрузить информацию о версии"
@@ -1595,7 +1660,8 @@
"lastAdminWarning": "Вы последний пользователь-администратор. Вы не можете удалить свою учетную запись, так как это оставит систему без администраторов. Пожалуйста, сначала сделайте другого пользователя администратором или свяжитесь с поддержкой системы.",
"confirmPassword": "Подтвердите пароль",
"deleting": "Удаление...",
"cancel": "Отмена"
"cancel": "Отмена",
"deleteAccountWarningShort": "Это действие необратимо и приведет к окончательному удалению вашей учетной записи."
},
"interface": {
"sidebar": "Боковая панель",
@@ -1615,7 +1681,6 @@
"deleteItem": "Удалить элемент",
"createNewFile": "Создать новый файл",
"createNewFolder": "Создать новую папку",
"deleteItem": "Удалить элемент",
"renameItem": "Переименовать элемент",
"clickToSelectFile": "Нажмите для выбора файла",
"noSshHosts": "Нет SSH-хостов",
@@ -1768,4 +1833,4 @@
"close": "Закрыть",
"hostManager": "Менеджер хостов"
}
}
}

View File

@@ -1,7 +1,6 @@
{
"credentials": {
"credentialsViewer": "凭证查看器",
"credentialsManager": "凭据管理器",
"manageYourSSHCredentials": "安全管理您的SSH凭据",
"addCredential": "添加凭据",
"createCredential": "创建凭据",
@@ -164,7 +163,9 @@
"failedToGenerateKeyPair": "生成密钥对失败",
"generateKeyPairNote": "直接生成新的SSH密钥对。这将替换表单中的现有密钥。",
"invalidKey": "无效密钥",
"detectionError": "检测错误"
"detectionError": "检测错误",
"credentialId": "凭据 ID",
"unknown": "未知"
},
"dragIndicator": {
"error": "错误:{{error}}",
@@ -259,7 +260,11 @@
"saveError": "保存配置时出错",
"saving": "保存中...",
"saveConfig": "保存配置",
"helpText": "输入您的 Termix 服务器运行地址例如http://localhost:30001 或 https://your-server.com"
"helpText": "输入您的 Termix 服务器运行地址例如http://localhost:30001 或 https://your-server.com",
"changeServer": "更换服务器",
"mustIncludeProtocol": "服务器URL必须以 http:// 或 https:// 开头",
"notValidatedWarning": "URL 未经验证 - 请确保其正确",
"warning": "警告"
},
"versionCheck": {
"error": "版本检查错误",
@@ -335,13 +340,11 @@
"login": "登录",
"logout": "登出",
"register": "注册",
"username": "用户名",
"password": "密码",
"confirmPassword": "确认密码",
"back": "返回",
"email": "邮箱",
"submit": "提交",
"cancel": "取消",
"change": "更改",
"save": "保存",
"delete": "删除",
@@ -382,7 +385,13 @@
"documentation": "文档",
"retry": "重试",
"checking": "检查中...",
"checkingDatabase": "正在检查数据库连接..."
"checkingDatabase": "正在检查数据库连接...",
"actions": "操作",
"remove": "移除",
"revoke": "撤销",
"create": "创建",
"saving": "保存中...",
"version": "Version"
},
"nav": {
"home": "首页",
@@ -511,7 +520,7 @@
"loadingEncryptionStatus": "正在加载加密状态...",
"testMigrationDescription": "验证现有数据是否可以安全地迁移到加密格式,不会实际修改任何数据",
"serverMigrationGuide": "服务器迁移指南",
"migrationInstructions": "要将加密数据迁移到新服务器1) 备份数据库文件2) 在新服务器设置环境变量 DB_ENCRYPTION_KEY=\"你的密钥\"3) 恢复数据库文件",
"migrationInstructions": "要将加密数据迁移到新服务器1) 备份数据库文件2) 在新服务器设置环境变量 DB_ENCRYPTION_KEY=\"你的key\"3) 恢复数据库文件",
"environmentProtection": "环境保护",
"environmentProtectionDesc": "基于服务器环境信息(主机名、路径等)保护加密密钥,可通过环境变量实现迁移",
"verificationCompleted": "兼容性验证完成 - 未修改任何数据",
@@ -595,7 +604,32 @@
"passwordLoginDisabledWarning": "密码登录已禁用。请确保 OIDC 已正确配置,否则您将无法登录 Termix。",
"oidcRequiredWarning": "严重警告:密码登录已禁用。如果您重置或错误配置 OIDC您将失去对 Termix 的所有访问权限并使您的实例无法使用。只有在您完全确定的情况下才能继续。",
"confirmDisableOIDCWarning": "警告:您即将在密码登录也已禁用的情况下禁用 OIDC。这将使您的 Termix 实例无法使用,您将失去所有访问权限。您确定要继续吗?",
"failedToUpdatePasswordLoginStatus": "更新密码登录状态失败"
"failedToUpdatePasswordLoginStatus": "更新密码登录状态失败",
"accountsLinkedSuccessfully": "OIDC 用户 {{oidcUsername}} 已关联到 {{targetUsername}}",
"confirmRevokeAllSessions": "您确定要撤销此用户的所有会话吗?",
"confirmRevokeSession": "您确定要撤销此会话吗?",
"failedToFetchSessions": "获取会话失败",
"failedToLinkAccounts": "关联账户失败",
"failedToRevokeSession": "撤销会话失败",
"failedToRevokeSessions": "撤销会话失败",
"failedToUnlinkOIDC": "取消 OIDC 关联失败",
"linkAccountsButton": "关联账户",
"linkOIDCActionAddCapability": "将 OIDC 登录功能添加到目标密码账户",
"linkOIDCActionDeleteUser": "删除 OIDC 用户账户及其所有数据",
"linkOIDCActionDualAuth": "允许密码账户同时使用密码和 OIDC 登录",
"linkOIDCDialogDescription": "将 {{username}} (OIDC 用户) 关联到现有的密码账户。这将为密码账户启用双重认证。",
"linkOIDCDialogTitle": "将 OIDC 账户关联到密码账户",
"linkOIDCWarningTitle": "警告: OIDC 用户数据将被删除",
"linkTargetUsernameLabel": "目标密码账户用户名",
"linkTargetUsernamePlaceholder": "输入密码账户的用户名",
"linkTargetUsernameRequired": "目标用户名是必需的",
"linkToPasswordAccount": "关联到密码账户",
"linkingAccounts": "关联中...",
"sessionRevokedSuccessfully": "会话撤销成功",
"sessionsRevokedSuccessfully": "会话撤销成功",
"unlinkOIDCDescription": "移除 {{username}} 的 OIDC 认证?此操作后用户只能使用用户名/密码登录。",
"unlinkOIDCSuccess": "已取消 {{username}} 的 OIDC 关联",
"unlinkOIDCTitle": "取消 OIDC 认证关联"
},
"hosts": {
"title": "主机管理",
@@ -632,7 +666,6 @@
"port": "端口",
"name": "名称",
"username": "用户名",
"hostName": "主机名",
"folder": "文件夹",
"tags": "标签",
"passwordRequired": "使用密码认证时需要密码",
@@ -642,10 +675,6 @@
"addHost": "添加主机",
"editHost": "编辑主机",
"cloneHost": "克隆主机",
"deleteHost": "删除主机",
"authType": "认证类型",
"passwordAuth": "密码",
"keyAuth": "SSH 密钥",
"keyPassword": "密钥密码",
"keyType": "密钥类型",
"pin": "固定",
@@ -653,15 +682,6 @@
"enableTunnel": "启用隧道",
"enableFileManager": "启用文件管理器",
"defaultPath": "默认路径",
"testConnection": "测试连接",
"connect": "连接",
"disconnect": "断开连接",
"connected": "已连接",
"disconnected": "已断开",
"connecting": "连接中...",
"connectionFailed": "连接失败",
"connectionSuccess": "连接成功",
"addTags": "添加标签(空格添加)",
"sourcePort": "源端口",
"sourcePortDesc": "(源指通用标签页中的当前连接详情)",
"endpointPort": "目标端口",
@@ -671,20 +691,7 @@
"remove": "移除",
"addConnection": "添加连接",
"sshpassRequired": "密码认证需要安装 Sshpass",
"sshpassInstallCommand": "安装命令sudo apt install sshpass",
"sshServerConfig": "需要配置 SSH 服务器",
"sshServerConfigInstructions": "运行以下命令以允许密码认证:",
"sshConfigCommand1": "sudo sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/' /etc/ssh/sshd_config",
"sshConfigCommand2": "sudo systemctl restart sshd",
"localPortForwarding": "本地端口转发",
"localPortForwardingDesc": "通过 SSH 连接将本地端口转发到远程服务器",
"remotePortForwarding": "远程端口转发",
"remotePortForwardingDesc": "通过 SSH 连接将远程端口转发到本地服务器",
"dynamicPortForwarding": "动态端口转发SOCKS 代理)",
"dynamicPortForwardingDesc": "在本地计算机上创建 SOCKS 代理,通过 SSH 连接路由流量",
"bindAddress": "绑定地址",
"hostViewer": "主机查看器",
"configuration": "配置",
"maxRetries": "最大重试次数",
"tunnelConnections": "隧道连接",
"enableTerminalDesc": "启用/禁用在终端选项卡中显示此主机",
@@ -693,8 +700,6 @@
"autoStartDesc": "容器启动时自动启动此隧道",
"defaultPathDesc": "打开此主机文件管理器时的默认目录",
"tunnelForwardDescription": "此隧道将从源计算机(常规选项卡中的当前连接详情)的端口 {{sourcePort}} 转发流量到端点计算机的端口 {{endpointPort}}。",
"endpointSshConfiguration": "端点 SSH 配置",
"sourcePortDescription": "(源指的是常规选项卡中的当前连接详情)",
"autoStartContainer": "容器启动时自动启动",
"upload": "上传",
"authentication": "认证方式",
@@ -715,20 +720,12 @@
"centosRhelFedora": "CentOS/RHEL/Fedora",
"macos": "macOS",
"windows": "Windows",
"sshpassOSInstructions": {
"centos": "CentOS/RHEL/Fedora: sudo yum install sshpass 或 sudo dnf install sshpass",
"macos": "macOS: brew install hudochenkov/sshpass/sshpass",
"windows": "Windows: 使用 WSL 或考虑使用 SSH 密钥认证"
},
"sshpassOSInstructions": {},
"sshServerConfigRequired": "SSH 服务器配置要求",
"sshServerConfigDesc": "对于隧道连接SSH 服务器必须配置允许端口转发:",
"gatewayPortsYes": "绑定远程端口到所有接口",
"allowTcpForwardingYes": "启用端口转发",
"permitRootLoginYes": "如果使用 root 用户进行隧道连接",
"sshServerConfigReverse": "对于反向 SSH 隧道,端点 SSH 服务器必须允许:",
"gatewayPorts": "GatewayPorts yes绑定远程端口",
"allowTcpForwarding": "AllowTcpForwarding yes端口转发",
"permitRootLogin": "PermitRootLogin yes如果使用 root",
"editSshConfig": "编辑 /etc/ssh/sshd_config 并重启 SSH: sudo systemctl restart sshd",
"updateHost": "更新主机",
"hostUpdatedSuccessfully": "主机 \"{{name}}\" 更新成功!",
@@ -758,7 +755,6 @@
"tunnel": "隧道",
"fileManager": "文件管理器",
"serverStats": "服务器统计",
"hostViewer": "主机查看器",
"enableServerStats": "启用服务器统计",
"enableServerStatsDesc": "启用/禁用此主机的服务器统计信息收集",
"displayItems": "显示项目",
@@ -893,13 +889,21 @@
"searchServers": "搜索服务器...",
"noServerFound": "未找到服务器",
"jumpHostsOrder": "连接将按顺序进行:跳板主机 1 → 跳板主机 2 → ... → 目标服务器",
"advancedAuthSettings": "高级身份验证设置"
"advancedAuthSettings": "高级身份验证设置",
"addQuickAction": "添加 Quick Action",
"noSnippetFound": "没有 snippet found",
"quickActionName": "Action 名称",
"quickActions": "Quick Actions",
"quickActionsDescription": "Quick actions allow you to 创建 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",
"quickActionsOrder": "Quick action buttons will appear in the order listed above on the Server Stats page",
"sshpassRequiredDesc": "For 密码 认证 in tunnels, sshpass must be installed on the system."
},
"terminal": {
"title": "终端",
"terminalTitle": "终端 - {{host}}",
"terminalWithPath": "终端 - {{host}}:{{path}}",
"runTitle": "运行 {{command}} - {{host}}",
"runTitle": "运行 {{command}} - {{name}}",
"totpRequired": "需要双因素认证",
"totpCodeLabel": "验证码",
"totpPlaceholder": "000000",
@@ -931,7 +935,11 @@
"reconnecting": "重新连接中... ({{attempt}}/{{max}})",
"reconnected": "重新连接成功",
"maxReconnectAttemptsReached": "已达到最大重连尝试次数",
"connectionTimeout": "连接超时"
"connectionTimeout": "连接超时",
"sudoPasswordPopupTitle": "插入密码?",
"sudoPasswordPopupHint": "按 Enter 插入Esc 取消",
"sudoPasswordPopupConfirm": "插入",
"sudoPasswordPopupDismiss": "取消"
},
"fileManager": {
"title": "文件管理器",
@@ -1222,7 +1230,16 @@
"write": "写入",
"execute": "执行",
"permissionsChangedSuccessfully": "权限修改成功",
"failedToChangePermissions": "权限修改失败"
"failedToChangePermissions": "权限修改失败",
"autoSaveFailed": "自动保存失败",
"delete": "删除",
"download": "下载",
"fileAutoSaved": "文件已自动保存",
"fileDownloadedSuccessfully": "文件 \"{{name}}\" 下载成功",
"fileSavedSuccessfully": "文件保存成功",
"path": "Path",
"permissions": "Permissions",
"size": "Size"
},
"tunnels": {
"title": "SSH 隧道",
@@ -1272,7 +1289,8 @@
"endpointHostNotFound": "未找到端点主机",
"discord": "Discord",
"githubIssue": "GitHub 问题",
"forHelp": "寻求帮助"
"forHelp": "寻求帮助",
"unknownConnectionStatus": "Unk没有wn"
},
"serverStats": {
"title": "服务器统计",
@@ -1314,8 +1332,6 @@
"totpRequired": "需要 TOTP 认证",
"totpUnavailable": "启用了 TOTP 的服务器无法使用服务器统计功能",
"load": "负载",
"free": "空闲",
"available": "可用",
"editLayout": "编辑布局",
"cancelEdit": "取消",
"addWidget": "添加小组件",
@@ -1340,7 +1356,14 @@
"recentSuccessfulLogins": "最近成功登录",
"recentFailedAttempts": "最近失败尝试",
"noRecentLoginData": "无最近登录数据",
"from": "来自"
"from": "来自",
"executeQuickAction": "执行 {{name}}",
"executingQuickAction": "执行中 {{name}}...",
"failedToFetchHomeData": "获取主页数据失败",
"quickActionError": "无法执行 {{name}}",
"quickActionFailed": "{{name}} 失败",
"quickActionSuccess": "{{name}} 完成成功",
"quickActions": "Quick Actions"
},
"auth": {
"tagline": "SSH 终端管理器",
@@ -1440,7 +1463,17 @@
"sshTimeoutDescription": "身份验证尝试超时。请重试。",
"sshProvideCredentialsDescription": "请提供您的 SSH 凭据以连接到此服务器。",
"sshPasswordDescription": "输入此 SSH 连接的密码。",
"sshKeyPasswordDescription": "如果您的 SSH 密钥已加密,请在此处输入密码。"
"sshKeyPasswordDescription": "如果您的 SSH 密钥已加密,请在此处输入密码。",
"authenticating": "Authenticating...",
"authenticationDisabled": "认证已禁用",
"authenticationDisabledDesc": "所有认证方式当前已禁用。请联系您的管理员。",
"desktopApp": "桌面应用",
"loadingServer": "加载服务器中...",
"loggingInToDesktopApp": "登录桌面应用",
"loggingInToDesktopAppViaWeb": "通过网页界面登录桌面应用",
"loggingInToMobileApp": "登录移动应用",
"mobileApp": "移动应用",
"redirectingToApp": "重定向到应用..."
},
"errors": {
"notFound": "页面未找到",
@@ -1517,9 +1550,12 @@
"fileColorCodingDesc": "按类型对文件进行颜色编码:文件夹(红色)、文件(蓝色)、符号链接(绿色)",
"commandAutocomplete": "命令自动补全",
"commandAutocompleteDesc": "启用基于命令历史记录的 Tab 键终端命令自动补全建议",
"defaultSnippetFoldersCollapsed": "默认折叠代码片段文件夹",
"defaultSnippetFoldersCollapsedDesc": "启用后,打开代码片段标签时所有文件夹将默认折叠",
"currentPassword": "当前密码",
"passwordChangedSuccess": "密码修改成功!请重新登录。",
"failedToChangePassword": "修改密码失败。请检查您当前的密码并重试。"
"failedToChangePassword": "修改密码失败。请检查您当前的密码并重试。",
"externalAndLocal": "Dual Auth"
},
"user": {
"failedToLoadVersionInfo": "加载版本信息失败"
@@ -1549,7 +1585,7 @@
"redirectUrl": "https://your-provider.com/application/o/termix/",
"tokenUrl": "https://your-provider.com/application/o/token/",
"userIdField": "sub",
"usernameField": "name",
"usernameField": "名称",
"scopes": "openid email profile",
"userinfoUrl": "https://your-provider.com/application/o/userinfo/",
"enterUsername": "输入用户名以设为管理员",
@@ -1571,9 +1607,9 @@
"passwordRequired": "需要输入密码",
"failedToDeleteAccount": "删除账户失败",
"failedToMakeUserAdmin": "设为管理员失败",
"userIsNowAdmin": "用户 {{username}} 现在是管理员",
"removeAdminConfirm": "确定要移除 {{username}} 的管理员权限吗?",
"deleteUserConfirm": "确定要删除用户 {{username}} 吗?此操作无法撤销。",
"userIsNowAdmin": "用户 {{用户名}} 现在是管理员",
"removeAdminConfirm": "确定要移除 {{用户名}} 的管理员权限吗?",
"deleteUserConfirm": "确定要删除用户 {{用户名}} 吗?此操作无法撤销。",
"deleteAccount": "删除账户",
"closeDeleteAccount": "关闭删除账户",
"deleteAccountWarning": "此操作无法撤销。这将永久删除您的账户和所有相关数据。",
@@ -1620,7 +1656,76 @@
"failedToStartOidcLogin": "启动 OIDC 登录失败",
"failedToGetUserInfoAfterOidc": "OIDC 登录后获取用户信息失败",
"loginWithExternalProvider": "使用外部提供者登录",
"failedToStartTotpSetup": "启动 TOTP 设置失败"
"failedToStartTotpSetup": "启动 TOTP 设置失败",
"addHost": "添加 主机",
"adding": "添加ing...",
"authentication": "认证",
"cannotDeleteAccount": "Can没有t 删除 Account",
"clickToSelectFile": "Click to 选择 a 文件",
"clientId": "Client ID",
"clientSecret": "Client Secret",
"closeDeleteAccount": "关闭 删除 Account",
"configureExternalProvider": "Configure external identity provider for",
"confirmPassword": "Confirm 密码",
"connected": "已连接",
"createNewFile": "创建 New 文件",
"createNewFolder": "创建 New 文件夹",
"defaultPath": "Default Path",
"deleteAccount": "删除 Account",
"deleteItem": "删除 Item",
"deleting": "删除中...",
"disconnected": "已断开",
"editHost": "编辑 主机",
"enableFileManager": "启用 文件 Manager",
"enableTerminal": "启用 终端",
"enableTunnel": "启用 隧道",
"endpointHostNotFound": "Endpoint host 未找到",
"external": "External",
"failedToCompletePasswordReset": "无法 完成 密码 reset",
"failedToDisableTotp": "无法 禁用 TOTP",
"failedToGenerateBackupCodes": "无法 generate 返回up codes",
"failedToInitiatePasswordReset": "无法 initiate 密码 reset",
"failedToMakeUserAdmin": "无法 make 用户 管理员",
"failedToUpdateOidcConfig": "无法 更新 OIDC 配置",
"failedToVerifyResetCode": "无法 verify reset code",
"invalidTotpCode": "Invalid TOTP code",
"invalidVerificationCode": "Invalid verification code",
"key": "密钥",
"keyPassword": "密钥 密码",
"keyType": "密钥 Type",
"keyTypeRequired": "密钥 Type 是必需的 when using 密钥 认证",
"loading": "加载中...",
"local": "Local",
"login": "Login",
"loginWithExternal": "Login with External Provider",
"makeAdmin": "Make 管理员",
"maxRetries": "Max Retries",
"newFile": "New 文件",
"newFolder": "New 文件夹",
"password": "密码",
"passwordRequired": "密码 是必需的 when using 密码 认证",
"refresh": "刷新",
"renameItem": "Re名称 Item",
"resetPassword": "Reset 密码",
"retryingConnection": "重试ing 连接",
"saveConfiguration": "保存 配置",
"saving": "保存中...",
"sendResetCode": "Send Reset Code",
"signUp": "Sign Up",
"sshHosts": "SSH 主机s",
"sshKeyRequired": "SSH Private 密钥 是必需的 when using 密钥 认证",
"sshPrivateKey": "SSH Private 密钥",
"tunnelConnections": "隧道 连接s",
"unknown": "Unk没有wn",
"unknownError": "Unk没有wn 错误",
"updateHost": "更新 主机",
"updateKey": "更新 密钥",
"upload": "上传",
"user": "用户",
"verifyAndEnable": "Verify and 启用",
"verifyCode": "Verify Code",
"waitingForRetry": "Waiting for 重试",
"warning": "警告"
},
"mobile": {
"selectHostToStart": "选择一个主机以开始您的终端会话",
@@ -1663,6 +1768,172 @@
"ram": "内存",
"notAvailable": "不可用"
},
"rbac": {
"shareHost": "分享主机",
"shareHostTitle": "分享主机访问权限",
"shareHostDescription": "授予临时或永久访问此主机的权限",
"targetUser": "目标用户",
"selectUser": "选择要分享的用户",
"duration": "时长",
"durationHours": "时长(小时)",
"neverExpires": "永不过期",
"permissionLevel": "权限级别",
"permissionLevels": {
"readonly": "只读",
"readonlyDesc": "仅可查看,无法输入命令",
"restricted": "受限",
"restrictedDesc": "阻止危险命令passwd、rm -rf等",
"monitored": "监控",
"monitoredDesc": "记录所有命令但不阻止(推荐)",
"full": "完全访问",
"fullDesc": "无任何限制(不推荐)"
},
"blockedCommands": "阻止的命令",
"blockedCommandsPlaceholder": "输入要阻止的命令passwd, rm, dd",
"maxSessionDuration": "最大会话时长(分钟)",
"createTempUser": "创建临时用户",
"createTempUserDesc": "在服务器上创建受限用户而不是共享您的凭据。需要sudo权限。最安全的选项。",
"expiresAt": "过期时间",
"expiresIn": "{{hours}}小时后过期",
"expired": "已过期",
"grantedBy": "授予者",
"accessLevel": "访问级别",
"lastAccessed": "最后访问",
"accessCount": "访问次数",
"revokeAccess": "撤销访问",
"confirmRevokeAccess": "确定要撤销{{username}}的访问权限吗?",
"hostSharedSuccessfully": "已成功与{{username}}分享主机",
"hostAccessUpdated": "主机访问已更新",
"failedToShareHost": "分享主机失败",
"accessRevokedSuccessfully": "访问权限已成功撤销",
"failedToRevokeAccess": "撤销访问失败",
"shared": "共享",
"sharedHosts": "共享主机",
"sharedWithMe": "与我共享",
"noSharedHosts": "没有与您共享的主机",
"owner": "所有者",
"viewAccessList": "查看访问列表",
"accessList": "访问列表",
"noAccessGranted": "此主机尚未授予任何访问权限",
"noAccessGrantedMessage": "还没有用户被授予此主机的访问权限",
"manageAccessFor": "管理访问权限",
"totalAccessRecords": "{{count}} 条访问记录",
"neverAccessed": "从未访问",
"timesAccessed": "{{count}} 次",
"daysRemaining": "{{days}} 天",
"hoursRemaining": "{{hours}} 小时",
"expired": "已过期",
"failedToFetchAccessList": "获取访问列表失败",
"currentAccess": "当前访问",
"securityWarning": "安全警告",
"securityWarningMessage": "分享凭据会让用户完全访问服务器并执行任何操作,包括更改密码和删除文件。仅与受信任的用户共享。",
"tempUserRecommended": "我们建议启用'创建临时用户'以获得更好的安全性。",
"roleManagement": "角色管理",
"manageRoles": "管理角色",
"manageRolesFor": "管理 {{username}} 的角色",
"assignRole": "分配角色",
"removeRole": "移除角色",
"userRoles": "用户角色",
"permissions": "权限",
"systemRole": "系统角色",
"customRole": "自定义角色",
"roleAssignedSuccessfully": "已成功为{{username}}分配角色",
"failedToAssignRole": "分配角色失败",
"roleRemovedSuccessfully": "已成功从{{username}}移除角色",
"failedToRemoveRole": "移除角色失败",
"cannotRemoveSystemRole": "无法移除系统角色",
"cannotShareWithSelf": "不能与自己共享主机",
"noCustomRolesToAssign": "没有可用的自定义角色。系统角色已自动分配。",
"credentialSharingWarning": "不支持共享使用凭据认证的主机",
"credentialSharingWarningDescription": "此主机使用凭据认证。由于凭据是按用户加密的无法共享,共享用户将无法连接。请为计划共享的主机使用密码或密钥认证。",
"auditLogs": "审计日志",
"viewAuditLogs": "查看审计日志",
"action": "操作",
"resourceType": "资源类型",
"resourceName": "资源名称",
"timestamp": "时间戳",
"ipAddress": "IP地址",
"userAgent": "用户代理",
"success": "成功",
"failed": "失败",
"details": "详情",
"noAuditLogs": "无可用审计日志",
"sessionRecordings": "会话录制",
"viewRecording": "查看录制",
"downloadRecording": "下载录制",
"dangerousCommand": "检测到危险命令",
"commandBlocked": "命令已阻止",
"terminateSession": "终止会话",
"sessionTerminated": "会话已被主机所有者终止",
"sharedAccessExpired": "您对此主机的共享访问权限已过期",
"sharedAccessExpiresIn": "共享访问将在{{hours}}小时后过期",
"roles": {
"label": "角色",
"admin": "管理员",
"user": "用户"
},
"createRole": "创建角色",
"editRole": "编辑角色",
"roleName": "角色名称",
"displayName": "显示名称",
"description": "描述",
"assignRoles": "分配角色",
"userRoleAssignment": "用户角色分配",
"selectUserPlaceholder": "选择用户",
"searchUsers": "搜索用户...",
"noUserFound": "未找到用户",
"currentRoles": "当前角色",
"noRolesAssigned": "未分配角色",
"assignNewRole": "分配新角色",
"selectRolePlaceholder": "选择角色",
"searchRoles": "搜索角色...",
"noRoleFound": "未找到角色",
"assign": "分配",
"roleCreatedSuccessfully": "角色创建成功",
"roleUpdatedSuccessfully": "角色更新成功",
"roleDeletedSuccessfully": "角色删除成功",
"failedToLoadRoles": "加载角色失败",
"failedToSaveRole": "保存角色失败",
"failedToDeleteRole": "删除角色失败",
"roleDisplayNameRequired": "角色显示名称是必需的",
"roleNameRequired": "角色名称是必需的",
"roleNameHint": "仅使用小写字母、数字、下划线和连字符",
"displayNamePlaceholder": "开发者",
"descriptionPlaceholder": "软件开发人员和工程师",
"confirmDeleteRole": "删除角色",
"confirmDeleteRoleDescription": "确定要删除角色'{{name}}'吗?此操作无法撤销。",
"confirmRemoveRole": "移除角色",
"confirmRemoveRoleDescription": "确定要从用户中移除此角色吗?",
"editRoleDescription": "更新角色信息",
"createRoleDescription": "创建新的自定义角色以分组用户",
"assignRolesDescription": "管理用户的角色分配",
"noRoles": "未找到角色",
"selectRole": "选择角色",
"type": "类型",
"user": "用户",
"role": "角色",
"saveHostFirst": "请先保存主机",
"saveHostFirstDescription": "请先保存主机后再配置分享设置。",
"shareWithUser": "与用户分享",
"shareWithRole": "与角色分享",
"share": "分享",
"target": "目标",
"expires": "过期时间",
"never": "永不",
"noAccessRecords": "未找到访问记录",
"sharedSuccessfully": "分享成功",
"failedToShare": "分享失败",
"confirmRevokeAccessDescription": "确定要撤销此访问权限吗?",
"hours": "小时",
"sharing": "分享",
"selectUserAndRole": "请选择用户和角色",
"view": "仅查看",
"viewDesc": "可以查看和连接主机,但仅限只读模式",
"use": "使用",
"useDesc": "可以正常使用主机,但不能修改主机配置",
"manage": "管理",
"manageDesc": "完全控制,包括修改主机配置和分享设置"
},
"commandPalette": {
"searchPlaceholder": "搜索主机或快速操作...",
"recentActivity": "最近活动",

View File

@@ -40,6 +40,7 @@ export interface SSHHost {
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
enableDocker: boolean;
defaultPath: string;
tunnelConnections: TunnelConnection[];
jumpHosts?: JumpHost[];
@@ -56,6 +57,11 @@ export interface SSHHost {
createdAt: string;
updatedAt: string;
// Shared access metadata
isShared?: boolean;
permissionLevel?: "view" | "manage";
sharedExpiresAt?: string;
}
export interface JumpHostData {
@@ -93,6 +99,7 @@ export interface SSHHostData {
enableTerminal?: boolean;
enableTunnel?: boolean;
enableFileManager?: boolean;
enableDocker?: boolean;
defaultPath?: string;
forceKeyboardInteractive?: boolean;
tunnelConnections?: TunnelConnection[];
@@ -143,6 +150,28 @@ export interface Credential {
updatedAt: string;
}
export interface CredentialBackend {
id: number;
userId: string;
name: string;
description: string | null;
folder: string | null;
tags: string;
authType: "password" | "key";
username: string;
password: string | null;
key: string;
private_key?: string;
public_key?: string;
key_password: string | null;
keyType?: string;
detectedKeyType: string;
usageCount: number;
lastUsed: string | null;
createdAt: string;
updatedAt: string;
}
export interface CredentialData {
name: string;
description?: string;
@@ -339,6 +368,7 @@ export interface TerminalConfig {
startupSnippetId: number | null;
autoMosh: boolean;
moshCommand: string;
sudoPasswordAutoFill: boolean;
}
// ============================================================================
@@ -354,7 +384,8 @@ export interface TabContextTab {
| "server"
| "admin"
| "file_manager"
| "user_profile";
| "user_profile"
| "docker";
title: string;
hostConfig?: SSHHost;
terminalRef?: any;
@@ -680,3 +711,55 @@ export interface RestoreRequestBody {
backupPath: string;
targetPath?: string;
}
// ============================================================================
// DOCKER TYPES
// ============================================================================
export interface DockerContainer {
id: string;
name: string;
image: string;
status: string;
state:
| "created"
| "running"
| "paused"
| "restarting"
| "removing"
| "exited"
| "dead";
ports: string;
created: string;
command?: string;
labels?: Record<string, string>;
networks?: string[];
mounts?: string[];
}
export interface DockerStats {
cpu: string;
memoryUsed: string;
memoryLimit: string;
memoryPercent: string;
netInput: string;
netOutput: string;
blockRead: string;
blockWrite: string;
pids?: string;
}
export interface DockerLogOptions {
tail?: number;
timestamps?: boolean;
since?: string;
until?: string;
follow?: boolean;
}
export interface DockerValidation {
available: boolean;
version?: string;
error?: string;
code?: string;
}

View File

@@ -155,7 +155,9 @@ function AppContent() {
const showTerminalView =
currentTabData?.type === "terminal" ||
currentTabData?.type === "server" ||
currentTabData?.type === "file_manager";
currentTabData?.type === "file_manager" ||
currentTabData?.type === "tunnel" ||
currentTabData?.type === "docker";
const showHome = currentTabData?.type === "home";
const showSshManager = currentTabData?.type === "ssh_manager";
const showAdmin = currentTabData?.type === "admin";

View File

@@ -42,6 +42,7 @@ import {
Smartphone,
Globe,
Clock,
UserCog,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
@@ -66,7 +67,14 @@ import {
revokeAllUserSessions,
linkOIDCToPasswordAccount,
unlinkOIDCFromPasswordAccount,
getUserRoles,
assignRoleToUser,
removeRoleFromUser,
getRoles,
type UserRole,
type Role,
} from "@/ui/main-axios.ts";
import { RoleManagement } from "./RoleManagement.tsx";
interface AdminSettingsProps {
isTopbarOpen?: boolean;
@@ -119,6 +127,16 @@ export function AdminSettings({
null,
);
// Role management states
const [rolesDialogOpen, setRolesDialogOpen] = React.useState(false);
const [selectedUser, setSelectedUser] = React.useState<{
id: string;
username: string;
} | null>(null);
const [userRoles, setUserRoles] = React.useState<UserRole[]>([]);
const [availableRoles, setAvailableRoles] = React.useState<Role[]>([]);
const [rolesLoading, setRolesLoading] = React.useState(false);
const [securityInitialized, setSecurityInitialized] = React.useState(true);
const [currentUser, setCurrentUser] = React.useState<{
id: string;
@@ -267,6 +285,65 @@ export function AdminSettings({
}
};
// Role management functions
const handleOpenRolesDialog = async (user: {
id: string;
username: string;
}) => {
setSelectedUser(user);
setRolesDialogOpen(true);
setRolesLoading(true);
try {
// Load user's current roles
const rolesResponse = await getUserRoles(user.id);
setUserRoles(rolesResponse.roles || []);
// Load all available roles
const allRolesResponse = await getRoles();
setAvailableRoles(allRolesResponse.roles || []);
} catch (error) {
console.error("Failed to load roles:", error);
toast.error(t("rbac.failedToLoadRoles"));
} finally {
setRolesLoading(false);
}
};
const handleAssignRole = async (roleId: number) => {
if (!selectedUser) return;
try {
await assignRoleToUser(selectedUser.id, roleId);
toast.success(
t("rbac.roleAssignedSuccessfully", { username: selectedUser.username }),
);
// Reload user roles
const rolesResponse = await getUserRoles(selectedUser.id);
setUserRoles(rolesResponse.roles || []);
} catch (error) {
toast.error(t("rbac.failedToAssignRole"));
}
};
const handleRemoveRole = async (roleId: number) => {
if (!selectedUser) return;
try {
await removeRoleFromUser(selectedUser.id, roleId);
toast.success(
t("rbac.roleRemovedSuccessfully", { username: selectedUser.username }),
);
// Reload user roles
const rolesResponse = await getUserRoles(selectedUser.id);
setUserRoles(rolesResponse.roles || []);
} catch (error) {
toast.error(t("rbac.failedToRemoveRole"));
}
};
const handleToggleRegistration = async (checked: boolean) => {
setRegLoading(true);
try {
@@ -771,6 +848,10 @@ export function AdminSettings({
<Shield className="h-4 w-4" />
{t("admin.adminManagement")}
</TabsTrigger>
<TabsTrigger value="roles" className="flex items-center gap-2">
<Shield className="h-4 w-4" />
{t("rbac.roles.label")}
</TabsTrigger>
<TabsTrigger value="security" className="flex items-center gap-2">
<Database className="h-4 w-4" />
{t("admin.databaseSecurity")}
@@ -1081,88 +1162,92 @@ export function AdminSettings({
{t("admin.loadingUsers")}
</div>
) : (
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">
{t("admin.username")}
</TableHead>
<TableHead className="px-4">
{t("admin.type")}
</TableHead>
<TableHead className="px-4">
{t("admin.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="px-4 font-medium">
{user.username}
{user.is_admin && (
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
{t("admin.adminBadge")}
</span>
)}
</TableCell>
<TableCell className="px-4">
{user.is_oidc && user.password_hash
? "Dual Auth"
: user.is_oidc
? t("admin.external")
: t("admin.local")}
</TableCell>
<TableCell className="px-4">
<div className="flex gap-2">
{user.is_oidc && !user.password_hash && (
<Button
variant="ghost"
size="sm"
onClick={() =>
handleLinkOIDCUser({
id: user.id,
username: user.username,
})
}
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
title="Link to password account"
>
<Link2 className="h-4 w-4" />
</Button>
)}
{user.is_oidc && user.password_hash && (
<Button
variant="ghost"
size="sm"
onClick={() =>
handleUnlinkOIDC(user.id, user.username)
}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
title="Unlink OIDC (keep password only)"
>
<Unlink className="h-4 w-4" />
</Button>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("admin.username")}</TableHead>
<TableHead>{t("admin.type")}</TableHead>
<TableHead>{t("admin.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">
{user.username}
{user.is_admin && (
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
{t("admin.adminBadge")}
</span>
)}
</TableCell>
<TableCell>
{user.is_oidc && user.password_hash
? "Dual Auth"
: user.is_oidc
? t("admin.external")
: t("admin.local")}
</TableCell>
<TableCell>
<div className="flex gap-2">
{user.is_oidc && !user.password_hash && (
<Button
variant="ghost"
size="sm"
onClick={() =>
handleDeleteUser(user.username)
handleLinkOIDCUser({
id: user.id,
username: user.username,
})
}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
title="Link to password account"
>
<Trash2 className="h-4 w-4" />
<Link2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{user.is_oidc && user.password_hash && (
<Button
variant="ghost"
size="sm"
onClick={() =>
handleUnlinkOIDC(user.id, user.username)
}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
title="Unlink OIDC (keep password only)"
>
<Unlink className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() =>
handleOpenRolesDialog({
id: user.id,
username: user.username,
})
}
className="text-purple-600 hover:text-purple-700 hover:bg-purple-50"
title={t("rbac.manageRoles")}
>
<UserCog className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteUser(user.username)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</TabsContent>
@@ -1189,115 +1274,107 @@ export function AdminSettings({
No active sessions found.
</div>
) : (
<div className="border rounded-md overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">Device</TableHead>
<TableHead className="px-4">User</TableHead>
<TableHead className="px-4">Created</TableHead>
<TableHead className="px-4">Last Active</TableHead>
<TableHead className="px-4">Expires</TableHead>
<TableHead className="px-4">
{t("admin.actions")}
</TableHead>
<Table>
<TableHeader>
<TableRow>
<TableHead>Device</TableHead>
<TableHead>User</TableHead>
<TableHead>Created</TableHead>
<TableHead>Last Active</TableHead>
<TableHead>Expires</TableHead>
<TableHead>{t("admin.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session) => {
const DeviceIcon =
session.deviceType === "desktop"
? Monitor
: session.deviceType === "mobile"
? Smartphone
: Globe;
const createdDate = new Date(session.createdAt);
const lastActiveDate = new Date(session.lastActiveAt);
const expiresDate = new Date(session.expiresAt);
const formatDate = (date: Date) =>
date.toLocaleDateString() +
" " +
date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
return (
<TableRow
key={session.id}
className={
session.isRevoked ? "opacity-50" : undefined
}
>
<TableCell>
<div className="flex items-center gap-2">
<DeviceIcon className="h-4 w-4" />
<div className="flex flex-col">
<span className="font-medium text-sm">
{session.deviceInfo}
</span>
{session.isRevoked && (
<span className="text-xs text-red-600">
Revoked
</span>
)}
</div>
</div>
</TableCell>
<TableCell>
{session.username || session.userId}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(createdDate)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(lastActiveDate)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(expiresDate)}
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRevokeSession(session.id)
}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={session.isRevoked}
>
<Trash2 className="h-4 w-4" />
</Button>
{session.username && (
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRevokeAllUserSessions(
session.userId,
)
}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50 text-xs"
title="Revoke all sessions for this user"
>
Revoke All
</Button>
)}
</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session) => {
const DeviceIcon =
session.deviceType === "desktop"
? Monitor
: session.deviceType === "mobile"
? Smartphone
: Globe;
const createdDate = new Date(session.createdAt);
const lastActiveDate = new Date(
session.lastActiveAt,
);
const expiresDate = new Date(session.expiresAt);
const formatDate = (date: Date) =>
date.toLocaleDateString() +
" " +
date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
return (
<TableRow
key={session.id}
className={
session.isRevoked ? "opacity-50" : undefined
}
>
<TableCell className="px-4">
<div className="flex items-center gap-2">
<DeviceIcon className="h-4 w-4" />
<div className="flex flex-col">
<span className="font-medium text-sm">
{session.deviceInfo}
</span>
{session.isRevoked && (
<span className="text-xs text-red-600">
Revoked
</span>
)}
</div>
</div>
</TableCell>
<TableCell className="px-4">
{session.username || session.userId}
</TableCell>
<TableCell className="px-4 text-sm text-muted-foreground">
{formatDate(createdDate)}
</TableCell>
<TableCell className="px-4 text-sm text-muted-foreground">
{formatDate(lastActiveDate)}
</TableCell>
<TableCell className="px-4 text-sm text-muted-foreground">
{formatDate(expiresDate)}
</TableCell>
<TableCell className="px-4">
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRevokeSession(session.id)
}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={session.isRevoked}
>
<Trash2 className="h-4 w-4" />
</Button>
{session.username && (
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRevokeAllUserSessions(
session.userId,
)
}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50 text-xs"
title="Revoke all sessions for this user"
>
Revoke All
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
);
})}
</TableBody>
</Table>
)}
</div>
</TabsContent>
@@ -1345,59 +1422,55 @@ export function AdminSettings({
<div className="space-y-4">
<h4 className="font-medium">{t("admin.currentAdmins")}</h4>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">
{t("admin.username")}
</TableHead>
<TableHead className="px-4">
{t("admin.type")}
</TableHead>
<TableHead className="px-4">
{t("admin.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users
.filter((u) => u.is_admin)
.map((admin) => (
<TableRow key={admin.id}>
<TableCell className="px-4 font-medium">
{admin.username}
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
{t("admin.adminBadge")}
</span>
</TableCell>
<TableCell className="px-4">
{admin.is_oidc
? t("admin.external")
: t("admin.local")}
</TableCell>
<TableCell className="px-4">
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRemoveAdminStatus(admin.username)
}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
>
<Shield className="h-4 w-4" />
{t("admin.removeAdminButton")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("admin.username")}</TableHead>
<TableHead>{t("admin.type")}</TableHead>
<TableHead>{t("admin.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users
.filter((u) => u.is_admin)
.map((admin) => (
<TableRow key={admin.id}>
<TableCell className="font-medium">
{admin.username}
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
{t("admin.adminBadge")}
</span>
</TableCell>
<TableCell>
{admin.is_oidc
? t("admin.external")
: t("admin.local")}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRemoveAdminStatus(admin.username)
}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
>
<Shield className="h-4 w-4" />
{t("admin.removeAdminButton")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</TabsContent>
<TabsContent value="roles" className="space-y-6">
<RoleManagement />
</TabsContent>
<TabsContent value="security" className="space-y-6">
<div className="space-y-4">
<div className="flex items-center gap-3">
@@ -1613,6 +1686,114 @@ export function AdminSettings({
</DialogContent>
</Dialog>
)}
{/* Role Management Dialog */}
<Dialog open={rolesDialogOpen} onOpenChange={setRolesDialogOpen}>
<DialogContent className="max-w-2xl bg-dark-bg border-2 border-dark-border">
<DialogHeader>
<DialogTitle>{t("rbac.manageRoles")}</DialogTitle>
<DialogDescription>
{t("rbac.manageRolesFor", {
username: selectedUser?.username || "",
})}
</DialogDescription>
</DialogHeader>
{rolesLoading ? (
<div className="text-center py-8 text-muted-foreground">
{t("common.loading")}
</div>
) : (
<div className="space-y-6 py-4">
{/* Current Roles */}
<div className="space-y-3">
<Label>{t("rbac.currentRoles")}</Label>
{userRoles.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("rbac.noRolesAssigned")}
</p>
) : (
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-2">
{userRoles.map((userRole) => (
<div
key={userRole.roleId}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<p className="font-medium">
{t(userRole.roleDisplayName)}
</p>
<p className="text-xs text-muted-foreground">
{userRole.roleName}
</p>
</div>
{userRole.isSystem ? (
<Badge variant="secondary" className="text-xs">
{t("rbac.systemRole")}
</Badge>
) : (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => handleRemoveRole(userRole.roleId)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
)}
</div>
{/* Assign New Role */}
<div className="space-y-3">
<Label>{t("rbac.assignNewRole")}</Label>
<div className="flex gap-2">
{availableRoles
.filter(
(role) =>
!role.isSystem &&
!userRoles.some((ur) => ur.roleId === role.id),
)
.map((role) => (
<Button
key={role.id}
type="button"
size="sm"
variant="outline"
onClick={() => handleAssignRole(role.id)}
>
{t(role.displayName)}
</Button>
))}
{availableRoles.filter(
(role) =>
!role.isSystem &&
!userRoles.some((ur) => ur.roleId === role.id),
).length === 0 && (
<p className="text-sm text-muted-foreground">
{t("rbac.noCustomRolesToAssign")}
</p>
)}
</div>
</div>
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setRolesDialogOpen(false)}
>
{t("common.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,650 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Textarea } from "@/components/ui/textarea.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import {
Shield,
Plus,
Edit,
Trash2,
Users,
Check,
ChevronsUpDown,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getRoles,
createRole,
updateRole,
deleteRole,
getUserList,
getUserRoles,
assignRoleToUser,
removeRoleFromUser,
type Role,
type UserRole,
} from "@/ui/main-axios.ts";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command.tsx";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover.tsx";
import { cn } from "@/lib/utils";
interface User {
id: string;
username: string;
is_admin: boolean;
}
export function RoleManagement(): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [roles, setRoles] = React.useState<Role[]>([]);
const [users, setUsers] = React.useState<User[]>([]);
const [loading, setLoading] = React.useState(false);
// Create/Edit Role Dialog
const [roleDialogOpen, setRoleDialogOpen] = React.useState(false);
const [editingRole, setEditingRole] = React.useState<Role | null>(null);
const [roleName, setRoleName] = React.useState("");
const [roleDisplayName, setRoleDisplayName] = React.useState("");
const [roleDescription, setRoleDescription] = React.useState("");
// Assign Role Dialog
const [assignDialogOpen, setAssignDialogOpen] = React.useState(false);
const [selectedUserId, setSelectedUserId] = React.useState<string>("");
const [selectedRoleId, setSelectedRoleId] = React.useState<number | null>(
null,
);
const [userRoles, setUserRoles] = React.useState<UserRole[]>([]);
// Combobox states
const [userComboOpen, setUserComboOpen] = React.useState(false);
const [roleComboOpen, setRoleComboOpen] = React.useState(false);
// Load roles
const loadRoles = React.useCallback(async () => {
setLoading(true);
try {
const response = await getRoles();
setRoles(response.roles || []);
} catch (error) {
toast.error(t("rbac.failedToLoadRoles"));
console.error("Failed to load roles:", error);
setRoles([]);
} finally {
setLoading(false);
}
}, [t]);
// Load users
const loadUsers = React.useCallback(async () => {
try {
const response = await getUserList();
// Map UserInfo to User format
const mappedUsers = (response.users || []).map((user) => ({
id: user.id,
username: user.username,
is_admin: user.is_admin,
}));
setUsers(mappedUsers);
} catch (error) {
console.error("Failed to load users:", error);
setUsers([]);
}
}, []);
React.useEffect(() => {
loadRoles();
loadUsers();
}, [loadRoles, loadUsers]);
// Create role
const handleCreateRole = () => {
setEditingRole(null);
setRoleName("");
setRoleDisplayName("");
setRoleDescription("");
setRoleDialogOpen(true);
};
// Edit role
const handleEditRole = (role: Role) => {
setEditingRole(role);
setRoleName(role.name);
setRoleDisplayName(role.displayName);
setRoleDescription(role.description || "");
setRoleDialogOpen(true);
};
// Save role
const handleSaveRole = async () => {
if (!roleDisplayName.trim()) {
toast.error(t("rbac.roleDisplayNameRequired"));
return;
}
if (!editingRole && !roleName.trim()) {
toast.error(t("rbac.roleNameRequired"));
return;
}
try {
if (editingRole) {
// Update existing role
await updateRole(editingRole.id, {
displayName: roleDisplayName,
description: roleDescription || null,
});
toast.success(t("rbac.roleUpdatedSuccessfully"));
} else {
// Create new role
await createRole({
name: roleName,
displayName: roleDisplayName,
description: roleDescription || null,
});
toast.success(t("rbac.roleCreatedSuccessfully"));
}
setRoleDialogOpen(false);
loadRoles();
} catch (error) {
toast.error(t("rbac.failedToSaveRole"));
}
};
// Delete role
const handleDeleteRole = async (role: Role) => {
const confirmed = await confirmWithToast({
title: t("rbac.confirmDeleteRole"),
description: t("rbac.confirmDeleteRoleDescription", {
name: role.displayName,
}),
confirmText: t("common.delete"),
cancelText: t("common.cancel"),
});
if (!confirmed) return;
try {
await deleteRole(role.id);
toast.success(t("rbac.roleDeletedSuccessfully"));
loadRoles();
} catch (error) {
toast.error(t("rbac.failedToDeleteRole"));
}
};
// Open assign dialog
const handleOpenAssignDialog = async () => {
setSelectedUserId("");
setSelectedRoleId(null);
setUserRoles([]);
setAssignDialogOpen(true);
};
// Load user roles when user is selected
const handleUserSelect = async (userId: string) => {
setSelectedUserId(userId);
setUserRoles([]);
if (!userId) return;
try {
const response = await getUserRoles(userId);
setUserRoles(response.roles || []);
} catch (error) {
console.error("Failed to load user roles:", error);
setUserRoles([]);
}
};
// Assign role to user
const handleAssignRole = async () => {
if (!selectedUserId || !selectedRoleId) {
toast.error(t("rbac.selectUserAndRole"));
return;
}
try {
await assignRoleToUser(selectedUserId, selectedRoleId);
const selectedUser = users.find((u) => u.id === selectedUserId);
toast.success(
t("rbac.roleAssignedSuccessfully", {
username: selectedUser?.username || selectedUserId,
}),
);
setSelectedRoleId(null);
handleUserSelect(selectedUserId);
} catch (error) {
toast.error(t("rbac.failedToAssignRole"));
}
};
// Remove role from user
const handleRemoveUserRole = async (roleId: number) => {
if (!selectedUserId) return;
try {
await removeRoleFromUser(selectedUserId, roleId);
const selectedUser = users.find((u) => u.id === selectedUserId);
toast.success(
t("rbac.roleRemovedSuccessfully", {
username: selectedUser?.username || selectedUserId,
}),
);
handleUserSelect(selectedUserId);
} catch (error) {
toast.error(t("rbac.failedToRemoveRole"));
}
};
return (
<div className="space-y-6">
{/* Roles Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Shield className="h-5 w-5" />
{t("rbac.roleManagement")}
</h3>
<Button onClick={handleCreateRole}>
<Plus className="h-4 w-4 mr-2" />
{t("rbac.createRole")}
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("rbac.roleName")}</TableHead>
<TableHead>{t("rbac.displayName")}</TableHead>
<TableHead>{t("rbac.description")}</TableHead>
<TableHead>{t("rbac.type")}</TableHead>
<TableHead className="text-right">
{t("common.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={5}
className="text-center text-muted-foreground"
>
{t("common.loading")}
</TableCell>
</TableRow>
) : roles.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="text-center text-muted-foreground"
>
{t("rbac.noRoles")}
</TableCell>
</TableRow>
) : (
roles.map((role) => (
<TableRow key={role.id}>
<TableCell className="font-mono">{role.name}</TableCell>
<TableCell>{t(role.displayName)}</TableCell>
<TableCell className="max-w-xs truncate">
{role.description || "-"}
</TableCell>
<TableCell>
{role.isSystem ? (
<Badge variant="secondary">{t("rbac.systemRole")}</Badge>
) : (
<Badge variant="outline">{t("rbac.customRole")}</Badge>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{!role.isSystem && (
<>
<Button
size="sm"
variant="ghost"
onClick={() => handleEditRole(role)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteRole(role)}
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* User-Role Assignment Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Users className="h-5 w-5" />
{t("rbac.userRoleAssignment")}
</h3>
<Button onClick={handleOpenAssignDialog}>
<Users className="h-4 w-4 mr-2" />
{t("rbac.assignRoles")}
</Button>
</div>
</div>
{/* Create/Edit Role Dialog */}
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
<DialogHeader>
<DialogTitle>
{editingRole ? t("rbac.editRole") : t("rbac.createRole")}
</DialogTitle>
<DialogDescription>
{editingRole
? t("rbac.editRoleDescription")
: t("rbac.createRoleDescription")}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{!editingRole && (
<div className="space-y-2">
<Label htmlFor="role-name">{t("rbac.roleName")}</Label>
<Input
id="role-name"
value={roleName}
onChange={(e) => setRoleName(e.target.value.toLowerCase())}
placeholder="developer"
disabled={!!editingRole}
/>
<p className="text-xs text-muted-foreground">
{t("rbac.roleNameHint")}
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="role-display-name">{t("rbac.displayName")}</Label>
<Input
id="role-display-name"
value={roleDisplayName}
onChange={(e) => setRoleDisplayName(e.target.value)}
placeholder={t("rbac.displayNamePlaceholder")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="role-description">{t("rbac.description")}</Label>
<Textarea
id="role-description"
value={roleDescription}
onChange={(e) => setRoleDescription(e.target.value)}
placeholder={t("rbac.descriptionPlaceholder")}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRoleDialogOpen(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleSaveRole}>
{editingRole ? t("common.save") : t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Assign Role Dialog */}
<Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}>
<DialogContent className="max-w-2xl bg-dark-bg border-2 border-dark-border">
<DialogHeader>
<DialogTitle>{t("rbac.assignRoles")}</DialogTitle>
<DialogDescription>
{t("rbac.assignRolesDescription")}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* User Selection */}
<div className="space-y-2">
<Label htmlFor="user-select">{t("rbac.selectUser")}</Label>
<Popover open={userComboOpen} onOpenChange={setUserComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={userComboOpen}
className="w-full justify-between"
>
{selectedUserId
? users.find((u) => u.id === selectedUserId)?.username +
(users.find((u) => u.id === selectedUserId)?.is_admin
? " (Admin)"
: "")
: t("rbac.selectUserPlaceholder")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder={t("rbac.searchUsers")} />
<CommandEmpty>{t("rbac.noUserFound")}</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto">
{users.map((user) => (
<CommandItem
key={user.id}
value={`${user.username} ${user.id}`}
onSelect={() => {
handleUserSelect(user.id);
setUserComboOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedUserId === user.id
? "opacity-100"
: "opacity-0",
)}
/>
{user.username}
{user.is_admin ? " (Admin)" : ""}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
{/* Current User Roles */}
{selectedUserId && (
<div className="space-y-2">
<Label>{t("rbac.currentRoles")}</Label>
{userRoles.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("rbac.noRolesAssigned")}
</p>
) : (
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-2">
{userRoles.map((userRole, index) => (
<div
key={index}
className="flex items-center justify-between p-2 border rounded"
>
<div>
<p className="font-medium">
{t(userRole.roleDisplayName)}
</p>
{userRole.roleDisplayName && (
<p className="text-xs text-muted-foreground">
{userRole.roleName}
</p>
)}
</div>
<div className="flex items-center gap-2">
{userRole.isSystem ? (
<Badge variant="secondary" className="text-xs">
{t("rbac.systemRole")}
</Badge>
) : (
<Button
size="sm"
variant="ghost"
onClick={() =>
handleRemoveUserRole(userRole.roleId)
}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Assign New Role */}
{selectedUserId && (
<div className="space-y-2">
<Label htmlFor="role-select">{t("rbac.assignNewRole")}</Label>
<div className="flex gap-2">
<Popover open={roleComboOpen} onOpenChange={setRoleComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={roleComboOpen}
className="flex-1 justify-between"
>
{selectedRoleId !== null
? (() => {
const role = roles.find(
(r) => r.id === selectedRoleId,
);
return role
? `${t(role.displayName)}${role.isSystem ? ` (${t("rbac.systemRole")})` : ""}`
: t("rbac.selectRolePlaceholder");
})()
: t("rbac.selectRolePlaceholder")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder={t("rbac.searchRoles")} />
<CommandEmpty>{t("rbac.noRoleFound")}</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto">
{roles
.filter(
(role) =>
!role.isSystem &&
!userRoles.some((ur) => ur.roleId === role.id),
)
.map((role) => (
<CommandItem
key={role.id}
value={`${role.displayName} ${role.name} ${role.id}`}
onSelect={() => {
setSelectedRoleId(role.id);
setRoleComboOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedRoleId === role.id
? "opacity-100"
: "opacity-0",
)}
/>
{t(role.displayName)}
{role.isSystem
? ` (${t("rbac.systemRole")})`
: ""}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<Button onClick={handleAssignRole} disabled={!selectedRoleId}>
<Plus className="h-4 w-4 mr-2" />
{t("rbac.assign")}
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setAssignDialogOpen(false)}
>
{t("common.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,390 @@
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 { useTranslation } from "react-i18next";
import { toast } from "sonner";
import type {
SSHHost,
DockerContainer,
DockerValidation,
} from "@/types/index.js";
import {
connectDockerSession,
disconnectDockerSession,
listDockerContainers,
validateDockerAvailability,
keepaliveDockerSession,
} from "@/ui/main-axios.ts";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
import { ContainerList } from "./components/ContainerList.tsx";
import { LogViewer } from "./components/LogViewer.tsx";
import { ContainerStats } from "./components/ContainerStats.tsx";
import { ConsoleTerminal } from "./components/ConsoleTerminal.tsx";
import { ContainerDetail } from "./components/ContainerDetail.tsx";
interface DockerManagerProps {
hostConfig?: SSHHost;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
}
export function DockerManager({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false,
}: DockerManagerProps): React.ReactElement {
const { t } = useTranslation();
const { state: sidebarState } = useSidebar();
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [sessionId, setSessionId] = React.useState<string | null>(null);
const [containers, setContainers] = React.useState<DockerContainer[]>([]);
const [selectedContainer, setSelectedContainer] = React.useState<
string | null
>(null);
const [isConnecting, setIsConnecting] = React.useState(false);
const [activeTab, setActiveTab] = React.useState("containers");
const [dockerValidation, setDockerValidation] =
React.useState<DockerValidation | null>(null);
const [isValidating, setIsValidating] = React.useState(false);
const [viewMode, setViewMode] = React.useState<"list" | "detail">("list");
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setCurrentHostConfig(hostConfig);
setContainers([]);
setSelectedContainer(null);
setSessionId(null);
setDockerValidation(null);
setViewMode("list");
}
}, [hostConfig?.id]);
React.useEffect(() => {
const fetchLatestHostConfig = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
// Silently handle error
}
}
};
fetchLatestHostConfig();
const handleHostsChanged = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
// Silently handle error
}
}
};
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
return () =>
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
}, [hostConfig?.id]);
// SSH session lifecycle
React.useEffect(() => {
const initSession = async () => {
if (!currentHostConfig?.id || !currentHostConfig.enableDocker) {
return;
}
setIsConnecting(true);
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
await connectDockerSession(sid, currentHostConfig.id);
setSessionId(sid);
// Validate Docker availability
setIsValidating(true);
const validation = await validateDockerAvailability(sid);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
toast.error(
validation.error || "Docker is not available on this host",
);
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to connect to host",
);
setIsConnecting(false);
setIsValidating(false);
} finally {
setIsConnecting(false);
}
};
initSession();
return () => {
if (sessionId) {
disconnectDockerSession(sessionId).catch(() => {
// Silently handle disconnect errors
});
}
};
}, [currentHostConfig?.id, currentHostConfig?.enableDocker]);
// Keepalive interval
React.useEffect(() => {
if (!sessionId || !isVisible) return;
const keepalive = setInterval(
() => {
keepaliveDockerSession(sessionId).catch(() => {
// Silently handle keepalive errors
});
},
10 * 60 * 1000,
); // Every 10 minutes
return () => clearInterval(keepalive);
}, [sessionId, isVisible]);
// Refresh containers function
const refreshContainers = React.useCallback(async () => {
if (!sessionId) return;
try {
const data = await listDockerContainers(sessionId, true);
setContainers(data);
} catch (error) {
// Silently handle polling errors
}
}, [sessionId]);
// Poll containers
React.useEffect(() => {
if (!sessionId || !isVisible || !dockerValidation?.available) return;
let cancelled = false;
const pollContainers = async () => {
try {
const data = await listDockerContainers(sessionId, true);
if (!cancelled) {
setContainers(data);
}
} catch (error) {
// Silently handle polling errors
}
};
pollContainers(); // Initial fetch
const interval = setInterval(pollContainers, 5000); // Poll every 5 seconds
return () => {
cancelled = true;
clearInterval(interval);
};
}, [sessionId, isVisible, dockerValidation?.available]);
const handleBack = React.useCallback(() => {
setViewMode("list");
setSelectedContainer(null);
}, []);
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
const bottomMarginPx = 8;
const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
: {
opacity: isVisible ? 1 : 0,
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
};
const containerClass = embedded
? "h-full w-full text-white overflow-hidden bg-transparent"
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
// Check if Docker is enabled
if (!currentHostConfig?.enableDocker) {
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-hidden min-h-0 p-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Docker is not enabled for this host. Enable it in Host Settings
to use Docker features.
</AlertDescription>
</Alert>
</div>
</div>
</div>
);
}
// Loading state
if (isConnecting || isValidating) {
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-hidden min-h-0 p-4 flex items-center justify-center">
<div className="text-center">
<SimpleLoader size="lg" />
<p className="text-gray-400 mt-4">
{isValidating
? "Validating Docker..."
: "Connecting to host..."}
</p>
</div>
</div>
</div>
</div>
);
}
// Docker not available
if (dockerValidation && !dockerValidation.available) {
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-hidden min-h-0 p-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<div className="font-semibold mb-2">Docker Error</div>
<div>{dockerValidation.error}</div>
{dockerValidation.code && (
<div className="mt-2 text-xs opacity-70">
Error code: {dockerValidation.code}
</div>
)}
</AlertDescription>
</Alert>
</div>
</div>
</div>
);
}
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
{dockerValidation?.version && (
<p className="text-xs text-gray-400">
Docker v{dockerValidation.version}
</p>
)}
</div>
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-hidden min-h-0">
{viewMode === "list" ? (
<div className="h-full px-4 py-4">
{sessionId ? (
<ContainerList
containers={containers}
sessionId={sessionId}
onSelectContainer={(id) => {
setSelectedContainer(id);
setViewMode("detail");
}}
selectedContainerId={selectedContainer}
onRefresh={refreshContainers}
/>
) : (
<div className="text-center py-8">
<p className="text-gray-400">No session available</p>
</div>
)}
</div>
) : sessionId && selectedContainer && currentHostConfig ? (
<ContainerDetail
sessionId={sessionId}
containerId={selectedContainer}
containers={containers}
hostConfig={currentHostConfig}
onBack={handleBack}
/>
) : (
<div className="text-center py-8">
<p className="text-gray-400">
Select a container to view details
</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,448 @@
import React from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { Button } from "@/components/ui/button.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import { Card, CardContent } from "@/components/ui/card.tsx";
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
import { toast } from "sonner";
import type { SSHHost } from "@/types/index.js";
import { getCookie, isElectron } from "@/ui/main-axios.ts";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
interface ConsoleTerminalProps {
sessionId: string;
containerId: string;
containerName: string;
containerState: string;
hostConfig: SSHHost;
}
export function ConsoleTerminal({
sessionId,
containerId,
containerName,
containerState,
hostConfig,
}: ConsoleTerminalProps): React.ReactElement {
const { instance: terminal, ref: xtermRef } = useXTerm();
const [isConnected, setIsConnected] = React.useState(false);
const [isConnecting, setIsConnecting] = React.useState(false);
const [selectedShell, setSelectedShell] = React.useState<string>("bash");
const wsRef = React.useRef<WebSocket | null>(null);
const fitAddonRef = React.useRef<FitAddon | null>(null);
const pingIntervalRef = React.useRef<NodeJS.Timeout | null>(null);
const getWebSocketBaseUrl = React.useCallback(() => {
const isElectronApp = isElectron();
// Development mode check (similar to Terminal.tsx)
const isDev =
!isElectronApp &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
if (isDev) {
// Development: connect directly to port 30008
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//localhost:30008`;
}
if (isElectronApp) {
// Electron: construct URL from configured server
const baseUrl =
(window as { configuredServerUrl?: string }).configuredServerUrl ||
"http://127.0.0.1:30001";
const wsProtocol = baseUrl.startsWith("https://") ? "wss://" : "ws://";
const wsHost = baseUrl.replace(/^https?:\/\//, "");
// Use nginx path routing, not direct port
return `${wsProtocol}${wsHost}/docker/console/`;
}
// Production web: use nginx proxy path (same as Terminal uses /ssh/websocket/)
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/docker/console/`;
}, []);
// Initialize terminal
React.useEffect(() => {
if (!terminal) return;
const fitAddon = new FitAddon();
const clipboardAddon = new ClipboardAddon();
const webLinksAddon = new WebLinksAddon();
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
terminal.loadAddon(clipboardAddon);
terminal.loadAddon(webLinksAddon);
terminal.options.cursorBlink = true;
terminal.options.fontSize = 14;
terminal.options.fontFamily = "monospace";
terminal.options.theme = {
background: "#18181b",
foreground: "#c9d1d9",
};
setTimeout(() => {
fitAddon.fit();
}, 100);
const resizeHandler = () => {
if (fitAddonRef.current) {
fitAddonRef.current.fit();
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const { rows, cols } = terminal;
wsRef.current.send(
JSON.stringify({
type: "resize",
data: { rows, cols },
}),
);
}
}
};
window.addEventListener("resize", resizeHandler);
return () => {
window.removeEventListener("resize", resizeHandler);
// Clean up WebSocket before disposing terminal
if (wsRef.current) {
try {
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch (error) {
// Ignore errors during cleanup
}
wsRef.current.close();
wsRef.current = null;
}
terminal.dispose();
};
}, [terminal]);
const disconnect = React.useCallback(() => {
if (wsRef.current) {
try {
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch (error) {
// WebSocket might already be closed
}
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
if (terminal) {
try {
terminal.clear();
terminal.write("Disconnected from container console.\r\n");
} catch (error) {
// Terminal might be disposed
}
}
}, [terminal]);
const connect = React.useCallback(() => {
if (!terminal || containerState !== "running") {
toast.error("Container must be running to connect to console");
return;
}
setIsConnecting(true);
try {
const token = isElectron()
? localStorage.getItem("jwt")
: getCookie("jwt");
if (!token) {
toast.error("Authentication required");
setIsConnecting(false);
return;
}
// Ensure terminal is fitted before connecting
if (fitAddonRef.current) {
fitAddonRef.current.fit();
}
const wsUrl = `${getWebSocketBaseUrl()}?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
// Double-check terminal dimensions
const cols = terminal.cols || 80;
const rows = terminal.rows || 24;
ws.send(
JSON.stringify({
type: "connect",
data: {
hostConfig,
containerId,
shell: selectedShell,
cols,
rows,
},
}),
);
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
switch (msg.type) {
case "output":
terminal.write(msg.data);
break;
case "connected":
setIsConnected(true);
setIsConnecting(false);
toast.success(`Connected to ${containerName}`);
// Fit terminal and send resize to ensure correct dimensions
setTimeout(() => {
if (fitAddonRef.current) {
fitAddonRef.current.fit();
}
// Send resize message with correct dimensions
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "resize",
data: { rows: terminal.rows, cols: terminal.cols },
}),
);
}
}, 100);
break;
case "disconnected":
setIsConnected(false);
setIsConnecting(false);
terminal.write(
`\r\n\x1b[1;33m${msg.message || "Disconnected"}\x1b[0m\r\n`,
);
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
break;
case "error":
setIsConnecting(false);
toast.error(msg.message || "Console error");
terminal.write(`\r\n\x1b[1;31mError: ${msg.message}\x1b[0m\r\n`);
break;
}
} catch (error) {
console.error("Failed to parse WebSocket message:", error);
}
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
setIsConnecting(false);
setIsConnected(false);
toast.error("Failed to connect to console");
};
// Set up periodic ping to keep connection alive
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
}
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }));
}
}, 30000); // Ping every 30 seconds
ws.onclose = () => {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
setIsConnected(false);
setIsConnecting(false);
if (wsRef.current === ws) {
wsRef.current = null;
}
};
wsRef.current = ws;
// Handle terminal input
terminal.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "input",
data,
}),
);
}
});
} catch (error) {
setIsConnecting(false);
toast.error(
`Failed to connect: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}, [
terminal,
containerState,
getWebSocketBaseUrl,
hostConfig,
containerId,
selectedShell,
containerName,
]);
// Cleanup WebSocket on unmount (terminal cleanup is handled in the terminal effect)
React.useEffect(() => {
return () => {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
if (wsRef.current) {
try {
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch (error) {
// Ignore errors during cleanup
}
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
};
}, []);
if (containerState !== "running") {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<TerminalIcon className="h-12 w-12 text-gray-600 mx-auto" />
<p className="text-gray-400 text-lg">Container is not running</p>
<p className="text-gray-500 text-sm">
Start the container to access the console
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full gap-3">
{/* Controls */}
<Card className="py-3">
<CardContent className="px-3">
<div className="flex flex-col sm:flex-row gap-2 items-center sm:items-center">
<div className="flex items-center gap-2 flex-1">
<TerminalIcon className="h-5 w-5" />
<span className="text-base font-medium">Console</span>
</div>
<Select
value={selectedShell}
onValueChange={setSelectedShell}
disabled={isConnected}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Select shell" />
</SelectTrigger>
<SelectContent>
<SelectItem value="bash">Bash</SelectItem>
<SelectItem value="sh">Sh</SelectItem>
<SelectItem value="ash">Ash</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2 sm:gap-2">
{!isConnected ? (
<Button
onClick={connect}
disabled={isConnecting}
className="min-w-[120px]"
>
{isConnecting ? (
<>
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
Connecting...
</>
) : (
<>
<Power className="h-4 w-4 mr-2" />
Connect
</>
)}
</Button>
) : (
<Button
onClick={disconnect}
variant="destructive"
className="min-w-[120px]"
>
<PowerOff className="h-4 w-4 mr-2" />
Disconnect
</Button>
)}
</div>
</div>
</CardContent>
</Card>
{/* Terminal */}
<Card className="flex-1 overflow-hidden pt-1 pb-0">
<CardContent className="p-0 h-full relative">
{/* Terminal container - always rendered */}
<div
ref={xtermRef}
className="h-full w-full"
style={{ display: isConnected ? "block" : "none" }}
/>
{/* Not connected message */}
{!isConnected && !isConnecting && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center space-y-2">
<TerminalIcon className="h-12 w-12 text-gray-600 mx-auto" />
<p className="text-gray-400">Not connected</p>
<p className="text-gray-500 text-sm">
Click Connect to start an interactive shell
</p>
</div>
</div>
)}
{/* Connecting message */}
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<SimpleLoader size="lg" />
<p className="text-gray-400 mt-4">
Connecting to {containerName}...
</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,446 @@
import React from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import {
Play,
Square,
RotateCw,
Pause,
Trash2,
PlayCircle,
} from "lucide-react";
import { toast } from "sonner";
import type { DockerContainer } from "@/types/index.js";
import {
startDockerContainer,
stopDockerContainer,
restartDockerContainer,
pauseDockerContainer,
unpauseDockerContainer,
removeDockerContainer,
} from "@/ui/main-axios.ts";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip.tsx";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog.tsx";
interface ContainerCardProps {
container: DockerContainer;
sessionId: string;
onSelect?: () => void;
isSelected?: boolean;
onRefresh?: () => void;
}
export function ContainerCard({
container,
sessionId,
onSelect,
isSelected = false,
onRefresh,
}: ContainerCardProps): React.ReactElement {
const [isStarting, setIsStarting] = React.useState(false);
const [isStopping, setIsStopping] = React.useState(false);
const [isRestarting, setIsRestarting] = React.useState(false);
const [isPausing, setIsPausing] = React.useState(false);
const [isRemoving, setIsRemoving] = React.useState(false);
const [showRemoveDialog, setShowRemoveDialog] = React.useState(false);
const statusColors = {
running: {
bg: "bg-green-500/10",
border: "border-green-500/20",
text: "text-green-400",
badge: "bg-green-500/20 text-green-300 border-green-500/30",
},
exited: {
bg: "bg-red-500/10",
border: "border-red-500/20",
text: "text-red-400",
badge: "bg-red-500/20 text-red-300 border-red-500/30",
},
paused: {
bg: "bg-yellow-500/10",
border: "border-yellow-500/20",
text: "text-yellow-400",
badge: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
},
created: {
bg: "bg-blue-500/10",
border: "border-blue-500/20",
text: "text-blue-400",
badge: "bg-blue-500/20 text-blue-300 border-blue-500/30",
},
restarting: {
bg: "bg-orange-500/10",
border: "border-orange-500/20",
text: "text-orange-400",
badge: "bg-orange-500/20 text-orange-300 border-orange-500/30",
},
removing: {
bg: "bg-purple-500/10",
border: "border-purple-500/20",
text: "text-purple-400",
badge: "bg-purple-500/20 text-purple-300 border-purple-500/30",
},
dead: {
bg: "bg-gray-500/10",
border: "border-gray-500/20",
text: "text-gray-400",
badge: "bg-gray-500/20 text-gray-300 border-gray-500/30",
},
};
const colors = statusColors[container.state] || statusColors.created;
const handleStart = async (e: React.MouseEvent) => {
e.stopPropagation();
setIsStarting(true);
try {
await startDockerContainer(sessionId, container.id);
toast.success(`Container ${container.name} started`);
onRefresh?.();
} catch (error) {
toast.error(
`Failed to start container: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setIsStarting(false);
}
};
const handleStop = async (e: React.MouseEvent) => {
e.stopPropagation();
setIsStopping(true);
try {
await stopDockerContainer(sessionId, container.id);
toast.success(`Container ${container.name} stopped`);
onRefresh?.();
} catch (error) {
toast.error(
`Failed to stop container: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setIsStopping(false);
}
};
const handleRestart = async (e: React.MouseEvent) => {
e.stopPropagation();
setIsRestarting(true);
try {
await restartDockerContainer(sessionId, container.id);
toast.success(`Container ${container.name} restarted`);
onRefresh?.();
} catch (error) {
toast.error(
`Failed to restart container: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setIsRestarting(false);
}
};
const handlePause = async (e: React.MouseEvent) => {
e.stopPropagation();
setIsPausing(true);
try {
if (container.state === "paused") {
await unpauseDockerContainer(sessionId, container.id);
toast.success(`Container ${container.name} unpaused`);
} else {
await pauseDockerContainer(sessionId, container.id);
toast.success(`Container ${container.name} paused`);
}
onRefresh?.();
} catch (error) {
toast.error(
`Failed to ${container.state === "paused" ? "unpause" : "pause"} container: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setIsPausing(false);
}
};
const handleRemove = async () => {
setIsRemoving(true);
try {
const force = container.state === "running";
await removeDockerContainer(sessionId, container.id, force);
toast.success(`Container ${container.name} removed`);
setShowRemoveDialog(false);
onRefresh?.();
} catch (error) {
toast.error(
`Failed to remove container: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setIsRemoving(false);
}
};
const isLoading =
isStarting || isStopping || isRestarting || isPausing || isRemoving;
// Format the created date to be more readable
const formatCreatedDate = (dateStr: string): string => {
try {
// Remove the timezone suffix like "+0000 UTC"
const cleanDate = dateStr.replace(/\s*\+\d{4}\s*UTC\s*$/, "").trim();
return cleanDate;
} catch {
return dateStr;
}
};
// Parse ports into array of port mappings
const parsePorts = (portsStr: string | undefined): string[] => {
if (!portsStr || portsStr.trim() === "") return [];
// Split by comma and clean up
return portsStr
.split(",")
.map((p) => p.trim())
.filter((p) => p.length > 0);
};
const portsList = parsePorts(container.ports);
return (
<>
<Card
className={`cursor-pointer transition-all hover:shadow-lg ${
isSelected
? "ring-2 ring-primary border-primary"
: `border-2 ${colors.border}`
} ${colors.bg} pt-3 pb-0`}
onClick={onSelect}
>
<CardHeader className="pb-2 px-4">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base font-semibold truncate flex-1">
{container.name.startsWith("/")
? container.name.slice(1)
: container.name}
</CardTitle>
<Badge className={`${colors.badge} border shrink-0`}>
{container.state}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-3">
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-gray-400 min-w-[50px] text-xs">Image:</span>
<span className="truncate text-gray-200 text-xs">
{container.image}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-gray-400 min-w-[50px] text-xs">ID:</span>
<span className="font-mono text-xs text-gray-200">
{container.id.substring(0, 12)}
</span>
</div>
<div className="flex items-start gap-2">
<span className="text-gray-400 min-w-[50px] text-xs shrink-0">
Ports:
</span>
<div className="flex flex-wrap gap-1">
{portsList.length > 0 ? (
portsList.map((port, idx) => (
<Badge
key={idx}
variant="outline"
className="text-xs font-mono bg-gray-500/10 text-gray-400 border-gray-500/30"
>
{port}
</Badge>
))
) : (
<Badge
variant="outline"
className="text-xs bg-gray-500/10 text-gray-400 border-gray-500/30"
>
None
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-gray-400 min-w-[50px] text-xs">
Created:
</span>
<span className="text-gray-200 text-xs">
{formatCreatedDate(container.created)}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2 pt-2 border-t border-gray-700/50">
<TooltipProvider>
{container.state !== "running" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={handleStart}
disabled={isLoading}
>
{isStarting ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Start</TooltipContent>
</Tooltip>
)}
{container.state === "running" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={handleStop}
disabled={isLoading}
>
{isStopping ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : (
<Square className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Stop</TooltipContent>
</Tooltip>
)}
{(container.state === "running" ||
container.state === "paused") && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={handlePause}
disabled={isLoading}
>
{isPausing ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : container.state === "paused" ? (
<PlayCircle className="h-4 w-4" />
) : (
<Pause className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{container.state === "paused" ? "Unpause" : "Pause"}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={handleRestart}
disabled={isLoading || container.state === "exited"}
>
{isRestarting ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : (
<RotateCw className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Restart</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 text-red-400 hover:text-red-300 hover:bg-red-500/20"
onClick={(e) => {
e.stopPropagation();
setShowRemoveDialog(true);
}}
disabled={isLoading}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Remove</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</CardContent>
</Card>
<AlertDialog open={showRemoveDialog} onOpenChange={setShowRemoveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Container</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove container{" "}
<span className="font-semibold">
{container.name.startsWith("/")
? container.name.slice(1)
: container.name}
</span>
?
{container.state === "running" && (
<div className="mt-2 text-yellow-400">
Warning: This container is currently running and will be
force-removed.
</div>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRemoving}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleRemove();
}}
disabled={isRemoving}
className="bg-red-600 hover:bg-red-700"
>
{isRemoving ? "Removing..." : "Remove"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,118 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { ArrowLeft } from "lucide-react";
import type { DockerContainer, SSHHost } from "@/types/index.js";
import { LogViewer } from "./LogViewer.tsx";
import { ContainerStats } from "./ContainerStats.tsx";
import { ConsoleTerminal } from "./ConsoleTerminal.tsx";
interface ContainerDetailProps {
sessionId: string;
containerId: string;
containers: DockerContainer[];
hostConfig: SSHHost;
onBack: () => void;
}
export function ContainerDetail({
sessionId,
containerId,
containers,
hostConfig,
onBack,
}: ContainerDetailProps): React.ReactElement {
const [activeTab, setActiveTab] = React.useState("logs");
const container = containers.find((c) => c.id === containerId);
if (!container) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<p className="text-gray-400 text-lg">Container not found</p>
<Button onClick={onBack} variant="outline">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to list
</Button>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header with back button */}
<div className="flex items-center gap-4 px-4 pt-3 pb-3">
<Button variant="ghost" onClick={onBack} size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div className="min-w-0 flex-1">
<h2 className="font-bold text-lg truncate">{container.name}</h2>
<p className="text-sm text-gray-400 truncate">{container.image}</p>
</div>
</div>
<Separator className="p-0.25 w-full" />
{/* Tabs for Logs, Stats, Console */}
<div className="flex-1 overflow-hidden min-h-0">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="h-full flex flex-col"
>
<div className="px-4 pt-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="stats">Stats</TabsTrigger>
<TabsTrigger value="console">Console</TabsTrigger>
</TabsList>
</div>
<TabsContent
value="logs"
className="flex-1 overflow-auto px-3 pb-3 mt-3"
>
<LogViewer
sessionId={sessionId}
containerId={containerId}
containerName={container.name}
/>
</TabsContent>
<TabsContent
value="stats"
className="flex-1 overflow-auto px-3 pb-3 mt-3"
>
<ContainerStats
sessionId={sessionId}
containerId={containerId}
containerName={container.name}
containerState={container.state}
/>
</TabsContent>
<TabsContent
value="console"
className="flex-1 overflow-hidden px-3 pb-3 mt-3"
>
<ConsoleTerminal
sessionId={sessionId}
containerId={containerId}
containerName={container.name}
containerState={container.state}
hostConfig={hostConfig}
/>
</TabsContent>
</Tabs>
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
import React from "react";
import { Input } from "@/components/ui/input.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import { Search, Filter } from "lucide-react";
import type { DockerContainer } from "@/types/index.js";
import { ContainerCard } from "./ContainerCard.tsx";
interface ContainerListProps {
containers: DockerContainer[];
sessionId: string;
onSelectContainer: (containerId: string) => void;
selectedContainerId?: string | null;
onRefresh?: () => void;
}
export function ContainerList({
containers,
sessionId,
onSelectContainer,
selectedContainerId = null,
onRefresh,
}: ContainerListProps): React.ReactElement {
const [searchQuery, setSearchQuery] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState<string>("all");
const filteredContainers = React.useMemo(() => {
return containers.filter((container) => {
const matchesSearch =
container.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
container.image.toLowerCase().includes(searchQuery.toLowerCase()) ||
container.id.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus =
statusFilter === "all" || container.state === statusFilter;
return matchesSearch && matchesStatus;
});
}, [containers, searchQuery, statusFilter]);
const statusCounts = React.useMemo(() => {
const counts: Record<string, number> = {};
containers.forEach((c) => {
counts[c.state] = (counts[c.state] || 0) + 1;
});
return counts;
}, [containers]);
if (containers.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<p className="text-gray-400 text-lg">No containers found</p>
<p className="text-gray-500 text-sm">
Start by creating containers on your server
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full gap-3">
{/* Search and Filter Bar */}
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search by name, image, or ID..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex items-center gap-2 sm:min-w-[200px]">
<Filter className="h-4 w-4 text-gray-400" />
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All ({containers.length})</SelectItem>
{Object.entries(statusCounts).map(([status, count]) => (
<SelectItem key={status} value={status}>
{status.charAt(0).toUpperCase() + status.slice(1)} ({count})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Container Grid */}
{filteredContainers.length === 0 ? (
<div className="flex items-center justify-center flex-1">
<div className="text-center space-y-2">
<p className="text-gray-400">No containers match your filters</p>
<p className="text-gray-500 text-sm">
Try adjusting your search or filter
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-3 overflow-auto pb-2">
{filteredContainers.map((container) => (
<ContainerCard
key={container.id}
container={container}
sessionId={sessionId}
onSelect={() => onSelectContainer(container.id)}
isSelected={selectedContainerId === container.id}
onRefresh={onRefresh}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,242 @@
import React from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card.tsx";
import { Progress } from "@/components/ui/progress.tsx";
import { Cpu, MemoryStick, Network, HardDrive, Activity } from "lucide-react";
import type { DockerStats } from "@/types/index.js";
import { getContainerStats } from "@/ui/main-axios.ts";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
interface ContainerStatsProps {
sessionId: string;
containerId: string;
containerName: string;
containerState: string;
}
export function ContainerStats({
sessionId,
containerId,
containerName,
containerState,
}: ContainerStatsProps): React.ReactElement {
const [stats, setStats] = React.useState<DockerStats | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const fetchStats = React.useCallback(async () => {
if (containerState !== "running") {
setError("Container must be running to view stats");
return;
}
setIsLoading(true);
setError(null);
try {
const data = await getContainerStats(sessionId, containerId);
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch stats");
} finally {
setIsLoading(false);
}
}, [sessionId, containerId, containerState]);
React.useEffect(() => {
fetchStats();
// Poll stats every 2 seconds
const interval = setInterval(fetchStats, 2000);
return () => clearInterval(interval);
}, [fetchStats]);
if (containerState !== "running") {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<Activity className="h-12 w-12 text-gray-600 mx-auto" />
<p className="text-gray-400 text-lg">Container is not running</p>
<p className="text-gray-500 text-sm">
Start the container to view statistics
</p>
</div>
</div>
);
}
if (isLoading && !stats) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<SimpleLoader size="lg" />
<p className="text-gray-400 mt-4">Loading stats...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<p className="text-red-400 text-lg">Error loading stats</p>
<p className="text-gray-500 text-sm">{error}</p>
</div>
</div>
);
}
if (!stats) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-gray-400">No stats available</p>
</div>
);
}
const cpuPercent = parseFloat(stats.cpu) || 0;
const memPercent = parseFloat(stats.memoryPercent) || 0;
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 h-full overflow-auto">
{/* CPU Usage */}
<Card className="py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<Cpu className="h-5 w-5 text-blue-400" />
CPU Usage
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Current</span>
<span className="font-mono font-semibold text-blue-300">
{stats.cpu}
</span>
</div>
<Progress value={Math.min(cpuPercent, 100)} className="h-2" />
</div>
</CardContent>
</Card>
{/* Memory Usage */}
<Card className="py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<MemoryStick className="h-5 w-5 text-purple-400" />
Memory Usage
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Used / Limit</span>
<span className="font-mono font-semibold text-purple-300">
{stats.memoryUsed} / {stats.memoryLimit}
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Percentage</span>
<span className="font-mono text-purple-300">
{stats.memoryPercent}
</span>
</div>
<Progress value={Math.min(memPercent, 100)} className="h-2" />
</div>
</CardContent>
</Card>
{/* Network I/O */}
<Card className="py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<Network className="h-5 w-5 text-green-400" />
Network I/O
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Input</span>
<span className="font-mono text-green-300">{stats.netInput}</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Output</span>
<span className="font-mono text-green-300">
{stats.netOutput}
</span>
</div>
</div>
</CardContent>
</Card>
{/* Block I/O */}
<Card className="py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<HardDrive className="h-5 w-5 text-orange-400" />
Block I/O
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Read</span>
<span className="font-mono text-orange-300">
{stats.blockRead}
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Write</span>
<span className="font-mono text-orange-300">
{stats.blockWrite}
</span>
</div>
{stats.pids && (
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">PIDs</span>
<span className="font-mono text-orange-300">{stats.pids}</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Container Info */}
<Card className="md:col-span-2 py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<Activity className="h-5 w-5 text-cyan-400" />
Container Information
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div className="flex justify-between items-center">
<span className="text-gray-400">Name:</span>
<span className="font-mono text-gray-200">{containerName}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-400">ID:</span>
<span className="font-mono text-sm text-gray-300">
{containerId.substring(0, 12)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-400">State:</span>
<span className="font-semibold text-green-400 capitalize">
{containerState}
</span>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,246 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import { Card, CardContent } from "@/components/ui/card.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Download, RefreshCw, Filter } from "lucide-react";
import { toast } from "sonner";
import type { DockerLogOptions } from "@/types/index.js";
import { getContainerLogs, downloadContainerLogs } from "@/ui/main-axios.ts";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
interface LogViewerProps {
sessionId: string;
containerId: string;
containerName: string;
}
export function LogViewer({
sessionId,
containerId,
containerName,
}: LogViewerProps): React.ReactElement {
const [logs, setLogs] = React.useState<string>("");
const [isLoading, setIsLoading] = React.useState(false);
const [isDownloading, setIsDownloading] = React.useState(false);
const [tailLines, setTailLines] = React.useState<string>("100");
const [showTimestamps, setShowTimestamps] = React.useState(false);
const [autoRefresh, setAutoRefresh] = React.useState(false);
const [searchFilter, setSearchFilter] = React.useState("");
const logsEndRef = React.useRef<HTMLDivElement>(null);
const fetchLogs = React.useCallback(async () => {
setIsLoading(true);
try {
const options: DockerLogOptions = {
tail: tailLines === "all" ? undefined : parseInt(tailLines, 10),
timestamps: showTimestamps,
};
const data = await getContainerLogs(sessionId, containerId, options);
setLogs(data.logs);
} catch (error) {
toast.error(
`Failed to fetch logs: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setIsLoading(false);
}
}, [sessionId, containerId, tailLines, showTimestamps]);
React.useEffect(() => {
fetchLogs();
}, [fetchLogs]);
// Auto-refresh
React.useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(() => {
fetchLogs();
}, 3000); // Refresh every 3 seconds
return () => clearInterval(interval);
}, [autoRefresh, fetchLogs]);
// Auto-scroll to bottom when new logs arrive
React.useEffect(() => {
if (autoRefresh && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [logs, autoRefresh]);
const handleDownload = async () => {
setIsDownloading(true);
try {
const options: DockerLogOptions = {
timestamps: showTimestamps,
};
const blob = await downloadContainerLogs(sessionId, containerId, options);
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${containerName.replace(/[^a-z0-9]/gi, "_")}_logs.txt`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success("Logs downloaded successfully");
} catch (error) {
toast.error(
`Failed to download logs: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setIsDownloading(false);
}
};
const filteredLogs = React.useMemo(() => {
if (!searchFilter.trim()) return logs;
return logs
.split("\n")
.filter((line) => line.toLowerCase().includes(searchFilter.toLowerCase()))
.join("\n");
}, [logs, searchFilter]);
return (
<div className="flex flex-col h-full gap-3">
{/* Controls */}
<Card className="py-3">
<CardContent className="px-3">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{/* Tail Lines */}
<div className="flex flex-col">
<Label htmlFor="tail-lines" className="mb-1">
Lines to show
</Label>
<Select value={tailLines} onValueChange={setTailLines}>
<SelectTrigger id="tail-lines">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="50">Last 50 lines</SelectItem>
<SelectItem value="100">Last 100 lines</SelectItem>
<SelectItem value="500">Last 500 lines</SelectItem>
<SelectItem value="1000">Last 1000 lines</SelectItem>
<SelectItem value="all">All logs</SelectItem>
</SelectContent>
</Select>
</div>
{/* Timestamps */}
<div className="flex flex-col">
<Label htmlFor="timestamps" className="mb-1">
Show Timestamps
</Label>
<div className="flex items-center h-10 px-3 border rounded-md">
<Switch
id="timestamps"
checked={showTimestamps}
onCheckedChange={setShowTimestamps}
/>
<span className="ml-2 text-sm">
{showTimestamps ? "Enabled" : "Disabled"}
</span>
</div>
</div>
{/* Auto Refresh */}
<div className="flex flex-col">
<Label htmlFor="auto-refresh" className="mb-1">
Auto Refresh
</Label>
<div className="flex items-center h-10 px-3 border rounded-md">
<Switch
id="auto-refresh"
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
<span className="ml-2 text-sm">
{autoRefresh ? "On" : "Off"}
</span>
</div>
</div>
{/* Actions */}
<div className="flex flex-col">
<Label className="mb-1">Actions</Label>
<div className="flex gap-2 h-10">
<Button
size="sm"
variant="outline"
onClick={fetchLogs}
disabled={isLoading}
className="flex-1 h-full"
>
{isLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleDownload}
disabled={isDownloading}
className="flex-1 h-full"
>
{isDownloading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
{/* Search Filter */}
<div className="mt-2">
<div className="relative">
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Filter logs..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-dark-bg border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
</CardContent>
</Card>
{/* Logs Display */}
<Card className="flex-1 overflow-hidden py-0">
<CardContent className="p-0 h-full">
{isLoading && !logs ? (
<div className="flex items-center justify-center h-full">
<SimpleLoader size="lg" />
</div>
) : (
<div className="h-full overflow-auto">
<pre className="p-4 text-xs font-mono whitespace-pre-wrap break-words text-gray-200 leading-relaxed">
{filteredLogs || (
<span className="text-gray-500">No logs available</span>
)}
<div ref={logsEndRef} />
</pre>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -42,6 +42,15 @@ export function HostManager({
}
}, [initialTab]);
// Update editingHost when hostConfig changes
useEffect(() => {
if (hostConfig) {
setEditingHost(hostConfig);
setActiveTab("add_host");
lastProcessedHostIdRef.current = hostConfig.id;
}
}, [hostConfig?.id]);
const handleEditHost = (host: SSHHost) => {
setEditingHost(host);
setActiveTab("add_host");

View File

@@ -37,6 +37,7 @@ import {
} from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import { CredentialSelector } from "@/ui/desktop/apps/credentials/CredentialSelector.tsx";
import { HostSharingTab } from "./HostSharingTab.tsx";
import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorView } from "@codemirror/view";
@@ -514,6 +515,7 @@ export function HostManagerEditor({
startupSnippetId: z.number().nullable(),
autoMosh: z.boolean(),
moshCommand: z.string(),
sudoPasswordAutoFill: z.boolean(),
})
.optional(),
forceKeyboardInteractive: z.boolean().optional(),
@@ -548,6 +550,7 @@ export function HostManagerEditor({
}),
)
.optional(),
enableDocker: z.boolean().default(false),
})
.superRefine((data, ctx) => {
if (data.authType === "none") {
@@ -610,7 +613,7 @@ export function HostManagerEditor({
type FormData = z.infer<typeof formSchema>;
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
resolver: zodResolver(formSchema) as any,
defaultValues: {
name: "",
ip: "",
@@ -642,6 +645,7 @@ export function HostManagerEditor({
socks5Username: "",
socks5Password: "",
socks5ProxyChain: [],
enableDocker: false,
},
});
@@ -745,6 +749,7 @@ export function HostManagerEditor({
socks5ProxyChain: Array.isArray(cleanedHost.socks5ProxyChain)
? cleanedHost.socks5ProxyChain
: [],
enableDocker: Boolean(cleanedHost.enableDocker),
};
// Determine proxy mode based on existing data
@@ -805,6 +810,7 @@ export function HostManagerEditor({
statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false,
enableDocker: false,
};
form.reset(defaultFormData as FormData);
@@ -856,14 +862,7 @@ export function HostManagerEditor({
const submitData: Partial<SSHHost> = {
...data,
};
console.log("Submitting host data:", {
useSocks5: submitData.useSocks5,
socks5Host: submitData.socks5Host,
socks5ProxyChain: submitData.socks5ProxyChain,
proxyMode,
});
if (proxyMode === "single") {
submitData.socks5ProxyChain = [];
} else if (proxyMode === "chain") {
@@ -974,6 +973,8 @@ export function HostManagerEditor({
setActiveTab("general");
} else if (errors.enableTerminal || errors.terminalConfig) {
setActiveTab("terminal");
} else if (errors.enableDocker) {
setActiveTab("docker");
} else if (errors.enableTunnel || errors.tunnelConnections) {
setActiveTab("tunnel");
} else if (errors.enableFileManager || errors.defaultPath) {
@@ -1166,6 +1167,7 @@ export function HostManagerEditor({
<TabsTrigger value="terminal">
{t("hosts.terminal")}
</TabsTrigger>
<TabsTrigger value="docker">Docker</TabsTrigger>
<TabsTrigger value="tunnel">{t("hosts.tunnel")}</TabsTrigger>
<TabsTrigger value="file_manager">
{t("hosts.fileManager")}
@@ -1173,6 +1175,11 @@ export function HostManagerEditor({
<TabsTrigger value="statistics">
{t("hosts.statistics")}
</TabsTrigger>
{!editingHost?.isShared && (
<TabsTrigger value="sharing">
{t("rbac.sharing")}
</TabsTrigger>
)}
</TabsList>
<TabsContent value="general" className="pt-2">
<FormLabel className="mb-3 font-bold">
@@ -2680,6 +2687,29 @@ export function HostManagerEditor({
/>
)}
<FormField
control={form.control}
name="terminalConfig.sudoPasswordAutoFill"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>
{t("hosts.sudoPasswordAutoFill")}
</FormLabel>
<FormDescription>
{t("hosts.sudoPasswordAutoFillDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className="space-y-2">
<label className="text-sm font-medium">
Environment Variables
@@ -2758,6 +2788,26 @@ export function HostManagerEditor({
</AccordionItem>
</Accordion>
</TabsContent>
<TabsContent value="docker" className="space-y-4">
<FormField
control={form.control}
name="enableDocker"
render={({ field }) => (
<FormItem>
<FormLabel>Enable Docker</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
Enable Docker integration for this host
</FormDescription>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="tunnel">
<FormField
control={form.control}
@@ -3513,18 +3563,27 @@ export function HostManagerEditor({
/>
</div>
</TabsContent>
<TabsContent value="sharing" className="space-y-6">
<HostSharingTab
hostId={editingHost?.id}
isNewHost={!editingHost?.id}
/>
</TabsContent>
</Tabs>
</div>
</ScrollArea>
<footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25" />
<Button className="translate-y-2" type="submit" variant="outline">
{editingHost
? editingHost.id
? t("hosts.updateHost")
: t("hosts.cloneHost")
: t("hosts.addHost")}
</Button>
{!(editingHost?.permissionLevel === "view") && (
<Button className="translate-y-2" type="submit" variant="outline">
{editingHost
? editingHost.id
? t("hosts.updateHost")
: t("hosts.cloneHost")
: t("hosts.addHost")}
</Button>
)}
</footer>
</form>
</Form>

View File

@@ -61,6 +61,8 @@ import {
HardDrive,
Globe,
FolderOpen,
Share2,
Users,
} from "lucide-react";
import type {
SSHHost,
@@ -1230,6 +1232,14 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
{host.name ||
`${host.username}@${host.ip}`}
</h3>
{(host as any).isShared && (
<Badge
variant="outline"
className="text-xs px-1 py-0 text-violet-500 border-violet-500/50"
>
{t("rbac.shared")}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground truncate">
{host.ip}:{host.port}

View File

@@ -0,0 +1,585 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Label } from "@/components/ui/label.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import {
AlertCircle,
Plus,
Trash2,
Users,
Shield,
Clock,
UserCircle,
Check,
ChevronsUpDown,
} from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getRoles,
getUserList,
getUserInfo,
shareHost,
getHostAccess,
revokeHostAccess,
getSSHHostById,
type Role,
type AccessRecord,
type SSHHost,
} from "@/ui/main-axios.ts";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command.tsx";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover.tsx";
import { cn } from "@/lib/utils";
interface HostSharingTabProps {
hostId: number | undefined;
isNewHost: boolean;
}
interface User {
id: string;
username: string;
is_admin: boolean;
}
const PERMISSION_LEVELS = [
{ value: "view", labelKey: "rbac.view" },
{ value: "manage", labelKey: "rbac.manage" },
];
export function HostSharingTab({
hostId,
isNewHost,
}: HostSharingTabProps): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [shareType, setShareType] = React.useState<"user" | "role">("user");
const [selectedUserId, setSelectedUserId] = React.useState<string>("");
const [selectedRoleId, setSelectedRoleId] = React.useState<number | null>(
null,
);
const [permissionLevel, setPermissionLevel] = React.useState("view");
const [expiresInHours, setExpiresInHours] = React.useState<string>("");
const [roles, setRoles] = React.useState<Role[]>([]);
const [users, setUsers] = React.useState<User[]>([]);
const [accessList, setAccessList] = React.useState<AccessRecord[]>([]);
const [loading, setLoading] = React.useState(false);
const [currentUserId, setCurrentUserId] = React.useState<string>("");
const [hostData, setHostData] = React.useState<SSHHost | null>(null);
const [userComboOpen, setUserComboOpen] = React.useState(false);
const [roleComboOpen, setRoleComboOpen] = React.useState(false);
// Load roles
const loadRoles = React.useCallback(async () => {
try {
const response = await getRoles();
setRoles(response.roles || []);
} catch (error) {
console.error("Failed to load roles:", error);
setRoles([]);
}
}, []);
// Load users
const loadUsers = React.useCallback(async () => {
try {
const response = await getUserList();
// Map UserInfo to User format
const mappedUsers = (response.users || []).map((user) => ({
id: user.id,
username: user.username,
is_admin: user.is_admin,
}));
setUsers(mappedUsers);
} catch (error) {
console.error("Failed to load users:", error);
setUsers([]);
}
}, []);
// Load access list
const loadAccessList = React.useCallback(async () => {
if (!hostId) return;
setLoading(true);
try {
const response = await getHostAccess(hostId);
setAccessList(response.accessList || []);
} catch (error) {
console.error("Failed to load access list:", error);
setAccessList([]);
} finally {
setLoading(false);
}
}, [hostId]);
// Load host data
const loadHostData = React.useCallback(async () => {
if (!hostId) return;
try {
const host = await getSSHHostById(hostId);
setHostData(host);
} catch (error) {
console.error("Failed to load host data:", error);
setHostData(null);
}
}, [hostId]);
React.useEffect(() => {
loadRoles();
loadUsers();
if (!isNewHost) {
loadAccessList();
loadHostData();
}
}, [loadRoles, loadUsers, loadAccessList, loadHostData, isNewHost]);
// Load current user ID
React.useEffect(() => {
const fetchCurrentUser = async () => {
try {
const userInfo = await getUserInfo();
setCurrentUserId(userInfo.userId);
} catch (error) {
console.error("Failed to load current user:", error);
}
};
fetchCurrentUser();
}, []);
// Share host
const handleShare = async () => {
if (!hostId) {
toast.error(t("rbac.saveHostFirst"));
return;
}
if (shareType === "user" && !selectedUserId) {
toast.error(t("rbac.selectUser"));
return;
}
if (shareType === "role" && !selectedRoleId) {
toast.error(t("rbac.selectRole"));
return;
}
// Prevent sharing with self
if (shareType === "user" && selectedUserId === currentUserId) {
toast.error(t("rbac.cannotShareWithSelf"));
return;
}
try {
await shareHost(hostId, {
targetType: shareType,
targetUserId: shareType === "user" ? selectedUserId : undefined,
targetRoleId: shareType === "role" ? selectedRoleId : undefined,
permissionLevel,
durationHours: expiresInHours
? parseInt(expiresInHours, 10)
: undefined,
});
toast.success(t("rbac.sharedSuccessfully"));
setSelectedUserId("");
setSelectedRoleId(null);
setExpiresInHours("");
loadAccessList();
} catch (error) {
toast.error(t("rbac.failedToShare"));
}
};
// Revoke access
const handleRevoke = async (accessId: number) => {
if (!hostId) return;
const confirmed = await confirmWithToast({
title: t("rbac.confirmRevokeAccess"),
description: t("rbac.confirmRevokeAccessDescription"),
confirmText: t("common.revoke"),
cancelText: t("common.cancel"),
});
if (!confirmed) return;
try {
await revokeHostAccess(hostId, accessId);
toast.success(t("rbac.accessRevokedSuccessfully"));
loadAccessList();
} catch (error) {
toast.error(t("rbac.failedToRevokeAccess"));
}
};
// Format date
const formatDate = (dateString: string | null) => {
if (!dateString) return "-";
return new Date(dateString).toLocaleString();
};
// Check if expired
const isExpired = (expiresAt: string | null) => {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
};
// Filter out current user from the users list
const availableUsers = React.useMemo(() => {
return users.filter((user) => user.id !== currentUserId);
}, [users, currentUserId]);
const selectedUser = availableUsers.find((u) => u.id === selectedUserId);
const selectedRole = roles.find((r) => r.id === selectedRoleId);
if (isNewHost) {
return (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("rbac.saveHostFirst")}</AlertTitle>
<AlertDescription>
{t("rbac.saveHostFirstDescription")}
</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-6">
{/* Credential Authentication Warning */}
{hostData?.authType === "Credential" && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("rbac.credentialSharingWarning")}</AlertTitle>
<AlertDescription>
{t("rbac.credentialSharingWarningDescription")}
</AlertDescription>
</Alert>
)}
{/* Share Form */}
<div className="space-y-4 border rounded-lg p-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Plus className="h-5 w-5" />
{t("rbac.shareHost")}
</h3>
{/* Share Type Selection */}
<Tabs
value={shareType}
onValueChange={(v) => setShareType(v as "user" | "role")}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="user" className="flex items-center gap-2">
<UserCircle className="h-4 w-4" />
{t("rbac.shareWithUser")}
</TabsTrigger>
<TabsTrigger value="role" className="flex items-center gap-2">
<Shield className="h-4 w-4" />
{t("rbac.shareWithRole")}
</TabsTrigger>
</TabsList>
<TabsContent value="user" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="user-select">{t("rbac.selectUser")}</Label>
<Popover open={userComboOpen} onOpenChange={setUserComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={userComboOpen}
className="w-full justify-between"
>
{selectedUser
? `${selectedUser.username}${selectedUser.is_admin ? " (Admin)" : ""}`
: t("rbac.selectUserPlaceholder")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder={t("rbac.searchUsers")} />
<CommandEmpty>{t("rbac.noUserFound")}</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto">
{availableUsers.map((user) => (
<CommandItem
key={user.id}
value={`${user.username} ${user.id}`}
onSelect={() => {
setSelectedUserId(user.id);
setUserComboOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedUserId === user.id
? "opacity-100"
: "opacity-0",
)}
/>
{user.username}
{user.is_admin ? " (Admin)" : ""}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</TabsContent>
<TabsContent value="role" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="role-select">{t("rbac.selectRole")}</Label>
<Popover open={roleComboOpen} onOpenChange={setRoleComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={roleComboOpen}
className="w-full justify-between"
>
{selectedRole
? `${t(selectedRole.displayName)}${selectedRole.isSystem ? ` (${t("rbac.systemRole")})` : ""}`
: t("rbac.selectRolePlaceholder")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder={t("rbac.searchRoles")} />
<CommandEmpty>{t("rbac.noRoleFound")}</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto">
{roles.map((role) => (
<CommandItem
key={role.id}
value={`${role.displayName} ${role.name} ${role.id}`}
onSelect={() => {
setSelectedRoleId(role.id);
setRoleComboOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedRoleId === role.id
? "opacity-100"
: "opacity-0",
)}
/>
{t(role.displayName)}
{role.isSystem ? ` (${t("rbac.systemRole")})` : ""}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</TabsContent>
</Tabs>
{/* Permission Level */}
<div className="space-y-2">
<Label htmlFor="permission-level">{t("rbac.permissionLevel")}</Label>
<Select
value={permissionLevel || "use"}
onValueChange={(v) => setPermissionLevel(v || "use")}
>
<SelectTrigger id="permission-level">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PERMISSION_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>
{t(level.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Expiration */}
<div className="space-y-2">
<Label htmlFor="expires-in">{t("rbac.durationHours")}</Label>
<Input
id="expires-in"
type="number"
value={expiresInHours}
onChange={(e) => {
const value = e.target.value;
if (value === "" || /^\d+$/.test(value)) {
setExpiresInHours(value);
}
}}
placeholder={t("rbac.neverExpires")}
min="1"
/>
</div>
<Button type="button" onClick={handleShare} className="w-full">
<Plus className="h-4 w-4 mr-2" />
{t("rbac.share")}
</Button>
</div>
{/* Access List */}
<div className="space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Users className="h-5 w-5" />
{t("rbac.accessList")}
</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("rbac.type")}</TableHead>
<TableHead>{t("rbac.target")}</TableHead>
<TableHead>{t("rbac.permissionLevel")}</TableHead>
<TableHead>{t("rbac.grantedBy")}</TableHead>
<TableHead>{t("rbac.expires")}</TableHead>
<TableHead>{t("rbac.accessCount")}</TableHead>
<TableHead className="text-right">
{t("common.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground"
>
{t("common.loading")}
</TableCell>
</TableRow>
) : accessList.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground"
>
{t("rbac.noAccessRecords")}
</TableCell>
</TableRow>
) : (
accessList.map((access) => (
<TableRow
key={access.id}
className={isExpired(access.expiresAt) ? "opacity-50" : ""}
>
<TableCell>
{access.targetType === "user" ? (
<Badge
variant="outline"
className="flex items-center gap-1 w-fit"
>
<UserCircle className="h-3 w-3" />
{t("rbac.user")}
</Badge>
) : (
<Badge
variant="outline"
className="flex items-center gap-1 w-fit"
>
<Shield className="h-3 w-3" />
{t("rbac.role")}
</Badge>
)}
</TableCell>
<TableCell>
{access.targetType === "user"
? access.username
: t(access.roleDisplayName || access.roleName || "")}
</TableCell>
<TableCell>
<Badge variant="secondary">{access.permissionLevel}</Badge>
</TableCell>
<TableCell>{access.grantedByUsername}</TableCell>
<TableCell>
{access.expiresAt ? (
<div className="flex items-center gap-2">
<Clock className="h-3 w-3" />
<span
className={
isExpired(access.expiresAt) ? "text-red-500" : ""
}
>
{formatDate(access.expiresAt)}
{isExpired(access.expiresAt) && (
<span className="ml-2">({t("rbac.expired")})</span>
)}
</span>
</div>
) : (
t("rbac.never")
)}
</TableCell>
<TableCell>{access.accessCount}</TableCell>
<TableCell className="text-right">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => handleRevoke(access.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -3,7 +3,6 @@ import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
import { Separator } from "@/components/ui/separator.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx";
import {
getServerStatusById,
getServerMetricsById,
@@ -64,7 +63,7 @@ interface ServerProps {
embedded?: boolean;
}
export function Server({
export function ServerStats({
hostConfig,
title,
isVisible = true,
@@ -462,7 +461,7 @@ export function Server({
{(metricsEnabled && showStatsUI) ||
(currentHostConfig?.quickActions &&
currentHostConfig.quickActions.length > 0) ? (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 overflow-y-auto relative flex-1 flex flex-col">
<div className="rounded-lg border-dark-border m-3 p-1 overflow-y-auto relative flex-1 flex flex-col">
{currentHostConfig?.quickActions &&
currentHostConfig.quickActions.length > 0 && (
<div className={metricsEnabled && showStatsUI ? "mb-4" : ""}>
@@ -600,20 +599,6 @@ export function Server({
)}
</div>
) : null}
{currentHostConfig?.tunnelConnections &&
currentHostConfig.tunnelConnections.length > 0 && (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0">
<Tunnel
filterHostKey={
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
}
/>
</div>
)}
</div>
</div>
</div>

View File

@@ -30,7 +30,7 @@ export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
}, [metricsHistory]);
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Cpu className="h-5 w-5 text-blue-400" />
<h3 className="font-semibold text-lg text-white">

View File

@@ -27,7 +27,7 @@ export function DiskWidget({ metrics }: DiskWidgetProps) {
}, [metrics]);
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<HardDrive className="h-5 w-5 text-orange-400" />
<h3 className="font-semibold text-lg text-white">

View File

@@ -35,7 +35,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
const uniqueIPs = loginStats?.uniqueIPs || 0;
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<UserCheck className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white">

View File

@@ -30,7 +30,7 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
}, [metricsHistory]);
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<MemoryStick className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white">

View File

@@ -24,7 +24,7 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
const interfaces = network?.interfaces || [];
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Network className="h-5 w-5 text-indigo-400" />
<h3 className="font-semibold text-lg text-white">

View File

@@ -28,7 +28,7 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
const topProcesses = processes?.top || [];
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<List className="h-5 w-5 text-yellow-400" />
<h3 className="font-semibold text-lg text-white">

View File

@@ -21,7 +21,7 @@ export function SystemWidget({ metrics }: SystemWidgetProps) {
const system = metricsWithSystem?.system;
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Server className="h-5 w-5 text-purple-400" />
<h3 className="font-semibold text-lg text-white">

View File

@@ -20,7 +20,7 @@ export function UptimeWidget({ metrics }: UptimeWidgetProps) {
const uptime = metricsWithUptime?.uptime;
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Clock className="h-5 w-5 text-cyan-400" />
<h3 className="font-semibold text-lg text-white">

View File

@@ -0,0 +1,82 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { KeyRound } from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
interface SudoPasswordPopupProps {
isOpen: boolean;
hostPassword: string;
backgroundColor: string;
onConfirm: (password: string) => void;
onDismiss: () => void;
}
export function SudoPasswordPopup({
isOpen,
hostPassword,
backgroundColor,
onConfirm,
onDismiss
}: SudoPasswordPopupProps) {
const { t } = useTranslation();
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
onConfirm(hostPassword);
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
onDismiss();
}
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [isOpen, onConfirm, onDismiss, hostPassword]);
if (!isOpen) return null;
return (
<div
className="absolute bottom-4 right-4 z-50 backdrop-blur-sm border border-border rounded-lg shadow-lg p-4 min-w-[280px]"
style={{ backgroundColor: backgroundColor }}
>
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-primary/10 rounded-full">
<KeyRound className="h-5 w-5 text-primary" />
</div>
<div>
<h4 className="font-medium text-sm">
{t("terminal.sudoPasswordPopupTitle", "Insert password?")}
</h4>
<p className="text-xs text-muted-foreground">
{t("terminal.sudoPasswordPopupHint", "Press Enter to insert, Esc to dismiss")}
</p>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button
variant="ghost"
size="sm"
onClick={onDismiss}
>
{t("terminal.sudoPasswordPopupDismiss", "Dismiss")}
</Button>
<Button
variant="default"
size="sm"
onClick={() => onConfirm(hostPassword)}
>
{t("terminal.sudoPasswordPopupConfirm", "Insert")}
</Button>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -197,9 +197,11 @@ export function SSHToolsSidebar({
);
const [draggedSnippet, setDraggedSnippet] = useState<Snippet | null>(null);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
new Set(),
);
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(() => {
const shouldCollapse =
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
return shouldCollapse ? new Set() : new Set();
});
const [showFolderDialog, setShowFolderDialog] = useState(false);
const [editingFolder, setEditingFolder] = useState<SnippetFolder | null>(
null,
@@ -351,6 +353,55 @@ export function SSHToolsSidebar({
}
}, [isOpen, activeTab]);
useEffect(() => {
if (snippetFolders.length > 0) {
const shouldCollapse =
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
if (shouldCollapse) {
const allFolderNames = new Set(snippetFolders.map((f) => f.name));
const uncategorizedSnippets = snippets.filter(
(s) => !s.folder || s.folder === "",
);
if (uncategorizedSnippets.length > 0) {
allFolderNames.add("");
}
setCollapsedFolders(allFolderNames);
} else {
setCollapsedFolders(new Set());
}
}
}, [snippetFolders, snippets]);
useEffect(() => {
const handleSettingChange = () => {
const shouldCollapse =
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
if (shouldCollapse) {
const allFolderNames = new Set(snippetFolders.map((f) => f.name));
const uncategorizedSnippets = snippets.filter(
(s) => !s.folder || s.folder === "",
);
if (uncategorizedSnippets.length > 0) {
allFolderNames.add("");
}
setCollapsedFolders(allFolderNames);
} else {
setCollapsedFolders(new Set());
}
};
window.addEventListener(
"defaultSnippetFoldersCollapsedChanged",
handleSettingChange,
);
return () => {
window.removeEventListener(
"defaultSnippetFoldersCollapsedChanged",
handleSettingChange,
);
};
}, [snippetFolders, snippets]);
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);

View File

@@ -0,0 +1,143 @@
import React from "react";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx";
import { useTranslation } from "react-i18next";
interface HostConfig {
id: number;
name: string;
ip: string;
username: string;
folder?: string;
enableFileManager?: boolean;
tunnelConnections?: unknown[];
[key: string]: unknown;
}
interface TunnelManagerProps {
hostConfig?: HostConfig;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
}
export function TunnelManager({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false,
}: TunnelManagerProps): React.ReactElement {
const { t } = useTranslation();
const { state: sidebarState } = useSidebar();
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setCurrentHostConfig(hostConfig);
}
}, [hostConfig?.id]);
React.useEffect(() => {
const fetchLatestHostConfig = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
// Silently handle error
}
}
};
fetchLatestHostConfig();
const handleHostsChanged = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
// Silently handle error
}
}
};
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
return () =>
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
}, [hostConfig?.id]);
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
const bottomMarginPx = 8;
const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
: {
opacity: isVisible ? 1 : 0,
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
};
const containerClass = embedded
? "h-full w-full text-white overflow-hidden bg-transparent"
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-hidden min-h-0 p-1">
{currentHostConfig?.tunnelConnections &&
currentHostConfig.tunnelConnections.length > 0 ? (
<div className="rounded-lg h-full overflow-hidden flex flex-col min-h-0">
<Tunnel
filterHostKey={
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
}
/>
</div>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-gray-400 text-lg">
{t("tunnel.noTunnelsConfigured")}
</p>
<p className="text-gray-500 text-sm mt-2">
{t("tunnel.configureTunnelsInHostSettings")}
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -43,11 +43,6 @@ export function TunnelViewer({
return (
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
<div className="w-full flex-shrink-0 mb-2">
<h1 className="text-xl font-semibold text-foreground">
{t("tunnels.title")}
</h1>
</div>
<div className="min-h-0 flex-1 overflow-auto pr-1">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{activeHost.tunnelConnections.map((t, idx) => (

View File

@@ -1,7 +1,9 @@
import React, { useEffect, useRef, useState, useMemo } from "react";
import { Terminal } from "@/ui/desktop/apps/terminal/Terminal.tsx";
import { Server as ServerView } from "@/ui/desktop/apps/server/Server.tsx";
import { ServerStats as ServerView } from "@/ui/desktop/apps/server-stats/ServerStats.tsx";
import { FileManager } from "@/ui/desktop/apps/file-manager/FileManager.tsx";
import { TunnelManager } from "@/ui/desktop/apps/tunnel/TunnelManager.tsx";
import { DockerManager } from "@/ui/desktop/apps/docker/DockerManager.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import {
ResizablePanelGroup,
@@ -58,7 +60,9 @@ export function AppView({
(tab: TabData) =>
tab.type === "terminal" ||
tab.type === "server" ||
tab.type === "file_manager",
tab.type === "file_manager" ||
tab.type === "tunnel" ||
tab.type === "docker",
),
[tabs],
);
@@ -210,7 +214,10 @@ export function AppView({
const mainTab = terminalTabs.find((tab: TabData) => tab.id === currentTab);
if (allSplitScreenTab.length === 0 && mainTab) {
const isFileManagerTab = mainTab.type === "file_manager";
const isFileManagerTab =
mainTab.type === "file_manager" ||
mainTab.type === "tunnel" ||
mainTab.type === "docker";
const newStyle = {
position: "absolute" as const,
top: isFileManagerTab ? 0 : 4,
@@ -257,9 +264,14 @@ export function AppView({
const isVisible =
hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
const effectiveVisible = isVisible;
const previousStyle = previousStylesRef.current[t.id];
const isFileManagerTab = t.type === "file_manager";
const isFileManagerTab =
t.type === "file_manager" ||
t.type === "tunnel" ||
t.type === "docker";
const standardStyle = {
position: "absolute" as const,
top: isFileManagerTab ? 0 : 4,
@@ -270,16 +282,24 @@ export function AppView({
const finalStyle: React.CSSProperties = hasStyle
? { ...styles[t.id], overflow: "hidden" }
: ({
...(previousStyle || standardStyle),
opacity: 0,
pointerEvents: "none",
zIndex: 0,
transition: "opacity 150ms ease-in-out",
overflow: "hidden",
} as React.CSSProperties);
const effectiveVisible = isVisible;
: effectiveVisible
? {
...(previousStyle || standardStyle),
opacity: 1,
pointerEvents: "auto",
zIndex: 20,
display: "block",
transition: "opacity 150ms ease-in-out",
overflow: "hidden",
}
: ({
...(previousStyle || standardStyle),
opacity: 0,
pointerEvents: "none",
zIndex: 0,
transition: "opacity 150ms ease-in-out",
overflow: "hidden",
} as React.CSSProperties);
const isTerminal = t.type === "terminal";
const terminalConfig = {
@@ -317,6 +337,22 @@ export function AppView({
isTopbarOpen={isTopbarOpen}
embedded
/>
) : t.type === "tunnel" ? (
<TunnelManager
hostConfig={t.hostConfig}
title={t.title}
isVisible={effectiveVisible}
isTopbarOpen={isTopbarOpen}
embedded
/>
) : t.type === "docker" ? (
<DockerManager
hostConfig={t.hostConfig}
title={t.title}
isVisible={effectiveVisible}
isTopbarOpen={isTopbarOpen}
embedded
/>
) : (
<FileManager
embedded
@@ -636,6 +672,8 @@ export function AppView({
const currentTabData = tabs.find((tab: TabData) => tab.id === currentTab);
const isFileManager = currentTabData?.type === "file_manager";
const isTunnel = currentTabData?.type === "tunnel";
const isDocker = currentTabData?.type === "docker";
const isTerminal = currentTabData?.type === "terminal";
const isSplitScreen = allSplitScreenTab.length > 0;
@@ -653,7 +691,7 @@ export function AppView({
const bottomMarginPx = 8;
let containerBackground = "var(--color-dark-bg)";
if (isFileManager && !isSplitScreen) {
if ((isFileManager || isTunnel || isDocker) && !isSplitScreen) {
containerBackground = "var(--color-dark-bg-darkest)";
} else if (isTerminal) {
containerBackground = terminalBackgroundColor;

View File

@@ -369,10 +369,13 @@ export function TopNavbar({
const isTerminal = tab.type === "terminal";
const isServer = tab.type === "server";
const isFileManager = tab.type === "file_manager";
const isTunnel = tab.type === "tunnel";
const isDocker = tab.type === "docker";
const isSshManager = tab.type === "ssh_manager";
const isAdmin = tab.type === "admin";
const isUserProfile = tab.type === "user_profile";
const isSplittable = isTerminal || isServer || isFileManager;
const isSplittable =
isTerminal || isServer || isFileManager || isTunnel || isDocker;
const disableSplit = !isSplittable;
const disableActivate =
isSplit ||
@@ -484,6 +487,8 @@ export function TopNavbar({
isTerminal ||
isServer ||
isFileManager ||
isTunnel ||
isDocker ||
isSshManager ||
isAdmin ||
isUserProfile
@@ -498,6 +503,8 @@ export function TopNavbar({
isTerminal ||
isServer ||
isFileManager ||
isTunnel ||
isDocker ||
isSshManager ||
isAdmin ||
isUserProfile

View File

@@ -8,6 +8,8 @@ import {
Server,
FolderOpen,
Pencil,
ArrowDownUp,
Container,
} from "lucide-react";
import {
DropdownMenu,
@@ -63,6 +65,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
}, [host.statsConfig]);
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
const shouldShowMetrics = statsConfig.metricsEnabled !== false;
useEffect(() => {
if (!shouldShowStatus) {
@@ -151,24 +154,50 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
side="right"
className="w-56 bg-dark-bg border-dark-border text-white"
>
<DropdownMenuItem
onClick={() =>
addTab({ type: "server", title, hostConfig: host })
}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<Server className="h-4 w-4" />
<span className="flex-1">Open Server Details</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
addTab({ type: "file_manager", title, hostConfig: host })
}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<FolderOpen className="h-4 w-4" />
<span className="flex-1">Open File Manager</span>
</DropdownMenuItem>
{shouldShowMetrics && (
<DropdownMenuItem
onClick={() =>
addTab({ type: "server", title, hostConfig: host })
}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<Server className="h-4 w-4" />
<span className="flex-1">Open Server Stats</span>
</DropdownMenuItem>
)}
{host.enableFileManager && (
<DropdownMenuItem
onClick={() =>
addTab({ type: "file_manager", title, hostConfig: host })
}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<FolderOpen className="h-4 w-4" />
<span className="flex-1">Open File Manager</span>
</DropdownMenuItem>
)}
{host.enableTunnel && (
<DropdownMenuItem
onClick={() =>
addTab({ type: "tunnel", title, hostConfig: host })
}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<ArrowDownUp className="h-4 w-4" />
<span className="flex-1">Open Tunnels</span>
</DropdownMenuItem>
)}
{host.enableDocker && (
<DropdownMenuItem
onClick={() =>
addTab({ type: "docker", title, hostConfig: host })
}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<Container className="h-4 w-4" />
<span className="flex-1">Open Docker</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() =>
addTab({

View File

@@ -10,6 +10,8 @@ import {
Server as ServerIcon,
Folder as FolderIcon,
User as UserIcon,
ArrowDownUp as TunnelIcon,
Container as DockerIcon,
} from "lucide-react";
interface TabProps {
@@ -119,10 +121,14 @@ export function Tab({
tabType === "terminal" ||
tabType === "server" ||
tabType === "file_manager" ||
tabType === "tunnel" ||
tabType === "docker" ||
tabType === "user_profile"
) {
const isServer = tabType === "server";
const isFileManager = tabType === "file_manager";
const isTunnel = tabType === "tunnel";
const isDocker = tabType === "docker";
const isUserProfile = tabType === "user_profile";
const displayTitle =
@@ -131,9 +137,13 @@ export function Tab({
? t("nav.serverStats")
: isFileManager
? t("nav.fileManager")
: isUserProfile
? t("nav.userProfile")
: t("nav.terminal"));
: isTunnel
? t("nav.tunnels")
: isDocker
? t("nav.docker")
: isUserProfile
? t("nav.userProfile")
: t("nav.terminal"));
const { base, suffix } = splitTitle(displayTitle);
@@ -151,6 +161,10 @@ export function Tab({
<ServerIcon className="h-4 w-4 flex-shrink-0" />
) : isFileManager ? (
<FolderIcon className="h-4 w-4 flex-shrink-0" />
) : isTunnel ? (
<TunnelIcon className="h-4 w-4 flex-shrink-0" />
) : isDocker ? (
<DockerIcon className="h-4 w-4 flex-shrink-0" />
) : isUserProfile ? (
<UserIcon className="h-4 w-4 flex-shrink-0" />
) : (

View File

@@ -76,7 +76,11 @@ export function TabProvider({ children }: TabProviderProps) {
? t("nav.serverStats")
: tabType === "file_manager"
? t("nav.fileManager")
: t("nav.terminal");
: tabType === "tunnel"
? t("nav.tunnels")
: tabType === "docker"
? t("nav.docker")
: t("nav.terminal");
const baseTitle = (desiredTitle || defaultTitle).trim();
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
const root = match ? match[1] : baseTitle;
@@ -137,7 +141,9 @@ export function TabProvider({ children }: TabProviderProps) {
const needsUniqueTitle =
tabData.type === "terminal" ||
tabData.type === "server" ||
tabData.type === "file_manager";
tabData.type === "file_manager" ||
tabData.type === "tunnel" ||
tabData.type === "docker";
const effectiveTitle = needsUniqueTitle
? computeUniqueTitle(tabData.type, tabData.title)
: tabData.title || "";

View File

@@ -12,6 +12,8 @@ import {
Terminal as TerminalIcon,
Server as ServerIcon,
Folder as FolderIcon,
ArrowDownUp as TunnelIcon,
Container as DockerIcon,
Shield as AdminIcon,
Network as SshManagerIcon,
User as UserIcon,
@@ -33,6 +35,10 @@ export function TabDropdown(): React.ReactElement {
return <ServerIcon className="h-4 w-4" />;
case "file_manager":
return <FolderIcon className="h-4 w-4" />;
case "tunnel":
return <TunnelIcon className="h-4 w-4" />;
case "docker":
return <DockerIcon className="h-4 w-4" />;
case "user_profile":
return <UserIcon className="h-4 w-4" />;
case "ssh_manager":
@@ -52,6 +58,10 @@ export function TabDropdown(): React.ReactElement {
return tab.title || t("nav.serverStats");
case "file_manager":
return tab.title || t("nav.fileManager");
case "tunnel":
return tab.title || t("nav.tunnels");
case "docker":
return tab.title || t("nav.docker");
case "user_profile":
return tab.title || t("nav.userProfile");
case "ssh_manager":

View File

@@ -20,6 +20,8 @@ const languages = [
},
{ code: "ru", name: "Russian", nativeName: "Русский" },
{ code: "fr", name: "French", nativeName: "Français" },
{ code: "it", name: "Italian", nativeName: "Italiano" },
{ code: "ko", name: "Korean", nativeName: "한국어" },
];
export function LanguageSwitcher() {

View File

@@ -19,6 +19,8 @@ import {
deleteAccount,
logoutUser,
isElectron,
getUserRoles,
type UserRole,
} from "@/ui/main-axios.ts";
import { PasswordReset } from "@/ui/desktop/user/PasswordReset.tsx";
import { useTranslation } from "react-i18next";
@@ -101,6 +103,11 @@ export function UserProfile({
const [commandAutocomplete, setCommandAutocomplete] = useState<boolean>(
localStorage.getItem("commandAutocomplete") !== "false",
);
const [defaultSnippetFoldersCollapsed, setDefaultSnippetFoldersCollapsed] =
useState<boolean>(
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
);
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
useEffect(() => {
fetchUserInfo();
@@ -129,6 +136,15 @@ export function UserProfile({
is_dual_auth: info.is_dual_auth || false,
totp_enabled: info.totp_enabled || false,
});
// Fetch user roles
try {
const rolesResponse = await getUserRoles(info.userId);
setUserRoles(rolesResponse.roles || []);
} catch (rolesErr) {
console.error("Failed to fetch user roles:", rolesErr);
setUserRoles([]);
}
} catch (err: unknown) {
const error = err as { response?: { data?: { error?: string } } };
setError(error?.response?.data?.error || t("errors.loadFailed"));
@@ -154,6 +170,12 @@ export function UserProfile({
localStorage.setItem("commandAutocomplete", enabled.toString());
};
const handleDefaultSnippetFoldersCollapsedToggle = (enabled: boolean) => {
setDefaultSnippetFoldersCollapsed(enabled);
localStorage.setItem("defaultSnippetFoldersCollapsed", enabled.toString());
window.dispatchEvent(new Event("defaultSnippetFoldersCollapsedChanged"));
};
const handleDeleteAccount = async (e: React.FormEvent) => {
e.preventDefault();
setDeleteLoading(true);
@@ -294,11 +316,26 @@ export function UserProfile({
<Label className="text-gray-300">
{t("profile.role")}
</Label>
<p className="text-lg font-medium mt-1 text-white">
{userInfo.is_admin
? t("interface.administrator")
: t("interface.user")}
</p>
<div className="mt-1">
{userRoles.length > 0 ? (
<div className="flex flex-wrap gap-2">
{userRoles.map((role) => (
<span
key={role.roleId}
className="inline-flex items-center px-2.5 py-1 rounded-md text-sm font-medium bg-muted/50 text-white border border-border"
>
{t(role.roleDisplayName)}
</span>
))}
</div>
) : (
<p className="text-lg font-medium text-white">
{userInfo.is_admin
? t("interface.administrator")
: t("interface.user")}
</p>
)}
</div>
</div>
<div>
<Label className="text-gray-300">
@@ -391,6 +428,25 @@ export function UserProfile({
</div>
</div>
<div className="mt-6 pt-6 border-t border-dark-border">
<div className="flex items-center justify-between">
<div>
<Label className="text-gray-300">
{t("profile.defaultSnippetFoldersCollapsed")}
</Label>
<p className="text-sm text-gray-400 mt-1">
{t("profile.defaultSnippetFoldersCollapsedDesc")}
</p>
</div>
<Switch
checked={defaultSnippetFoldersCollapsed}
onCheckedChange={
handleDefaultSnippetFoldersCollapsedToggle
}
/>
</div>
</div>
<div className="mt-6 pt-6 border-t border-dark-border">
<div className="flex items-center justify-between">
<div>

View File

@@ -7,7 +7,53 @@ import type {
TunnelStatus,
FileManagerFile,
FileManagerShortcut,
DockerContainer,
DockerStats,
DockerLogOptions,
DockerValidation,
} from "../types/index.js";
// ============================================================================
// RBAC TYPE DEFINITIONS
// ============================================================================
export interface Role {
id: number;
name: string;
displayName: string;
description: string | null;
isSystem: boolean;
permissions: string | null;
createdAt: string;
updatedAt: string;
}
export interface UserRole {
userId: string;
roleId: number;
roleName: string;
roleDisplayName: string;
grantedBy: string;
grantedByUsername: string;
grantedAt: string;
}
export interface AccessRecord {
id: number;
targetType: "user" | "role";
userId: string | null;
roleId: number | null;
username: string | null;
roleName: string | null;
roleDisplayName: string | null;
grantedBy: string;
grantedByUsername: string;
permissionLevel: string;
expiresAt: string | null;
createdAt: string;
lastAccessedAt: string | null;
accessCount: number;
}
import {
apiLogger,
authLogger,
@@ -594,6 +640,12 @@ function initializeApiInstances() {
// Homepage API (port 30006)
homepageApi = createApiInstance(getApiUrl("", 30006), "HOMEPAGE");
// RBAC API (port 30001)
rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC");
// Docker Management API (port 30007)
dockerApi = createApiInstance(getApiUrl("/docker", 30007), "DOCKER");
}
// SSH Host Management API (port 30001)
@@ -614,6 +666,12 @@ export let authApi: AxiosInstance;
// Homepage API (port 30006)
export let homepageApi: AxiosInstance;
// RBAC API (port 30001)
export let rbacApi: AxiosInstance;
// Docker Management API (port 30007)
export let dockerApi: AxiosInstance;
function initializeApp() {
if (isElectron()) {
getServerConfig()
@@ -856,6 +914,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
enableTerminal: Boolean(hostData.enableTerminal),
enableTunnel: Boolean(hostData.enableTunnel),
enableFileManager: Boolean(hostData.enableFileManager),
enableDocker: Boolean(hostData.enableDocker),
defaultPath: hostData.defaultPath || "/",
tunnelConnections: hostData.tunnelConnections || [],
jumpHosts: hostData.jumpHosts || [],
@@ -865,6 +924,11 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
? hostData.statsConfig
: JSON.stringify(hostData.statsConfig)
: null,
dockerConfig: hostData.dockerConfig
? typeof hostData.dockerConfig === "string"
? hostData.dockerConfig
: JSON.stringify(hostData.dockerConfig)
: null,
terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
useSocks5: Boolean(hostData.useSocks5),
@@ -928,6 +992,7 @@ export async function updateSSHHost(
enableTerminal: Boolean(hostData.enableTerminal),
enableTunnel: Boolean(hostData.enableTunnel),
enableFileManager: Boolean(hostData.enableFileManager),
enableDocker: Boolean(hostData.enableDocker),
defaultPath: hostData.defaultPath || "/",
tunnelConnections: hostData.tunnelConnections || [],
jumpHosts: hostData.jumpHosts || [],
@@ -937,6 +1002,11 @@ export async function updateSSHHost(
? hostData.statsConfig
: JSON.stringify(hostData.statsConfig)
: null,
dockerConfig: hostData.dockerConfig
? typeof hostData.dockerConfig === "string"
? hostData.dockerConfig
: JSON.stringify(hostData.dockerConfig)
: null,
terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
useSocks5: Boolean(hostData.useSocks5),
@@ -3128,3 +3198,361 @@ export async function unlinkOIDCFromPasswordAccount(
}
}
// ============================================================================
// RBAC MANAGEMENT
// ============================================================================
// Role Management
export async function getRoles(): Promise<{ roles: Role[] }> {
try {
const response = await rbacApi.get("/rbac/roles");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch roles");
}
}
export async function createRole(roleData: {
name: string;
displayName: string;
description?: string | null;
}): Promise<{ role: Role }> {
try {
const response = await rbacApi.post("/rbac/roles", roleData);
return response.data;
} catch (error) {
throw handleApiError(error, "create role");
}
}
export async function updateRole(
roleId: number,
roleData: {
displayName?: string;
description?: string | null;
},
): Promise<{ role: Role }> {
try {
const response = await rbacApi.put(`/rbac/roles/${roleId}`, roleData);
return response.data;
} catch (error) {
throw handleApiError(error, "update role");
}
}
export async function deleteRole(roleId: number): Promise<{ success: boolean }> {
try {
const response = await rbacApi.delete(`/rbac/roles/${roleId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete role");
}
}
// User-Role Management
export async function getUserRoles(userId: string): Promise<{ roles: UserRole[] }> {
try {
const response = await rbacApi.get(`/rbac/users/${userId}/roles`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch user roles");
}
}
export async function assignRoleToUser(
userId: string,
roleId: number,
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.post(`/rbac/users/${userId}/roles`, { roleId });
return response.data;
} catch (error) {
throw handleApiError(error, "assign role to user");
}
}
export async function removeRoleFromUser(
userId: string,
roleId: number,
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.delete(`/rbac/users/${userId}/roles/${roleId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "remove role from user");
}
}
// Host Sharing Management
export async function shareHost(
hostId: number,
shareData: {
targetType: "user" | "role";
targetUserId?: string;
targetRoleId?: number;
permissionLevel: string;
durationHours?: number;
},
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.post(`/rbac/host/${hostId}/share`, shareData);
return response.data;
} catch (error) {
throw handleApiError(error, "share host");
}
}
export async function getHostAccess(hostId: number): Promise<{ accessList: AccessRecord[] }> {
try {
const response = await rbacApi.get(`/rbac/host/${hostId}/access`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch host access");
}
}
export async function revokeHostAccess(
hostId: number,
accessId: number,
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.delete(`/rbac/host/${hostId}/access/${accessId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "revoke host access");
// ============================================================================
// DOCKER MANAGEMENT API
// ============================================================================
export async function connectDockerSession(
sessionId: string,
hostId: number,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post("/ssh/connect", {
sessionId,
hostId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "connect to Docker SSH session");
}
}
export async function disconnectDockerSession(
sessionId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post("/ssh/disconnect", {
sessionId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "disconnect from Docker SSH session");
}
}
export async function keepaliveDockerSession(
sessionId: string,
): Promise<{ success: boolean }> {
try {
const response = await dockerApi.post("/ssh/keepalive", {
sessionId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "keepalive Docker SSH session");
}
}
export async function getDockerSessionStatus(
sessionId: string,
): Promise<{ success: boolean; connected: boolean }> {
try {
const response = await dockerApi.get("/ssh/status", {
params: { sessionId },
});
return response.data;
} catch (error) {
throw handleApiError(error, "get Docker session status");
}
}
export async function validateDockerAvailability(
sessionId: string,
): Promise<DockerValidation> {
try {
const response = await dockerApi.get(`/validate/${sessionId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "validate Docker availability");
}
}
export async function listDockerContainers(
sessionId: string,
all: boolean = true,
): Promise<DockerContainer[]> {
try {
const response = await dockerApi.get(`/containers/${sessionId}`, {
params: { all },
});
return response.data;
} catch (error) {
throw handleApiError(error, "list Docker containers");
}
}
export async function getDockerContainerDetails(
sessionId: string,
containerId: string,
): Promise<DockerContainer> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "get Docker container details");
}
}
export async function startDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/start`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "start Docker container");
}
}
export async function stopDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/stop`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "stop Docker container");
}
}
export async function restartDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/restart`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "restart Docker container");
}
}
export async function pauseDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/pause`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "pause Docker container");
}
}
export async function unpauseDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/unpause`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "unpause Docker container");
}
}
export async function removeDockerContainer(
sessionId: string,
containerId: string,
force: boolean = false,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.delete(
`/containers/${sessionId}/${containerId}/remove`,
{
params: { force },
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "remove Docker container");
}
}
export async function getContainerLogs(
sessionId: string,
containerId: string,
options?: DockerLogOptions,
): Promise<{ logs: string }> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}/logs`,
{
params: options,
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "get container logs");
}
}
export async function downloadContainerLogs(
sessionId: string,
containerId: string,
options?: DockerLogOptions,
): Promise<Blob> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}/logs`,
{
params: { ...options, download: true },
responseType: "blob",
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "download container logs");
}
}
export async function getContainerStats(
sessionId: string,
containerId: string,
): Promise<DockerStats> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}/stats`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "get container stats");
}
}