v1.8.0 #429
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
@@ -42,22 +42,18 @@ jobs:
|
|||||||
ALL_TAGS=()
|
ALL_TAGS=()
|
||||||
|
|
||||||
if [ "$BUILD_TYPE" = "Production" ]; then
|
if [ "$BUILD_TYPE" = "Production" ]; then
|
||||||
# Production build → push release + latest to both GHCR and Docker Hub
|
|
||||||
TAGS+=("release-$VERSION" "latest")
|
TAGS+=("release-$VERSION" "latest")
|
||||||
for tag in "${TAGS[@]}"; do
|
for tag in "${TAGS[@]}"; do
|
||||||
ALL_TAGS+=("ghcr.io/lukegus/termix:$tag")
|
ALL_TAGS+=("ghcr.io/lukegus/termix:$tag")
|
||||||
ALL_TAGS+=("docker.io/bugattiguy527/termix:$tag")
|
ALL_TAGS+=("docker.io/bugattiguy527/termix:$tag")
|
||||||
done
|
done
|
||||||
else
|
else
|
||||||
# Dev build → push only dev-x.x.x to GHCR
|
|
||||||
TAGS+=("dev-$VERSION")
|
TAGS+=("dev-$VERSION")
|
||||||
for tag in "${TAGS[@]}"; do
|
for tag in "${TAGS[@]}"; do
|
||||||
ALL_TAGS+=("ghcr.io/lukegus/termix:$tag")
|
ALL_TAGS+=("ghcr.io/lukegus/termix:$tag")
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "ALL_TAGS=${ALL_TAGS[*]}" >> $GITHUB_ENV
|
|
||||||
echo "All tags to build:"
|
|
||||||
printf '%s\n' "${ALL_TAGS[@]}"
|
printf '%s\n' "${ALL_TAGS[@]}"
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to GHCR
|
||||||
|
|||||||
401
.github/workflows/electron.yml
vendored
401
.github/workflows/electron.yml
vendored
@@ -43,7 +43,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
# Retry npm ci up to 3 times on failure
|
|
||||||
$maxAttempts = 3
|
$maxAttempts = 3
|
||||||
$attempt = 1
|
$attempt = 1
|
||||||
while ($attempt -le $maxAttempts) {
|
while ($attempt -le $maxAttempts) {
|
||||||
@@ -55,7 +54,6 @@ jobs:
|
|||||||
Write-Error "npm ci failed after $maxAttempts attempts"
|
Write-Error "npm ci failed after $maxAttempts attempts"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
Write-Host "npm ci attempt $attempt failed, retrying in 10 seconds..."
|
|
||||||
Start-Sleep -Seconds 10
|
Start-Sleep -Seconds 10
|
||||||
$attempt++
|
$attempt++
|
||||||
}
|
}
|
||||||
@@ -66,14 +64,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$VERSION = (Get-Content package.json | ConvertFrom-Json).version
|
$VERSION = (Get-Content package.json | ConvertFrom-Json).version
|
||||||
echo "version=$VERSION" >> $env:GITHUB_OUTPUT
|
echo "version=$VERSION" >> $env:GITHUB_OUTPUT
|
||||||
echo "Building version: $VERSION"
|
|
||||||
|
|
||||||
- name: Build Windows (All Architectures)
|
- name: Build Windows (All Architectures)
|
||||||
run: npm run build && npx electron-builder --win --x64 --ia32
|
run: npm run build && npx electron-builder --win --x64 --ia32
|
||||||
|
|
||||||
- name: List release files
|
- name: List release files
|
||||||
run: |
|
run: |
|
||||||
echo "Contents of release directory:"
|
|
||||||
dir release
|
dir release
|
||||||
|
|
||||||
- name: Upload Windows x64 NSIS Installer
|
- name: Upload Windows x64 NSIS Installer
|
||||||
@@ -154,16 +150,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
# Retry npm ci up to 3 times on failure
|
for i in 1 2 3;
|
||||||
for i in 1 2 3; do
|
do
|
||||||
if npm ci; then
|
if npm ci; then
|
||||||
break
|
break
|
||||||
else
|
else
|
||||||
if [ $i -eq 3 ]; then
|
if [ $i -eq 3 ]; then
|
||||||
echo "npm ci failed after 3 attempts"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "npm ci attempt $i failed, retrying in 10 seconds..."
|
|
||||||
sleep 10
|
sleep 10
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -179,41 +173,30 @@ jobs:
|
|||||||
VERSION=$(node -p "require('./package.json').version")
|
VERSION=$(node -p "require('./package.json').version")
|
||||||
cd release
|
cd release
|
||||||
|
|
||||||
# Rename x64 AppImage to use 'x64'
|
|
||||||
if [ -f "termix_linux_x86_64_${VERSION}_appimage.AppImage" ]; then
|
if [ -f "termix_linux_x86_64_${VERSION}_appimage.AppImage" ]; then
|
||||||
mv "termix_linux_x86_64_${VERSION}_appimage.AppImage" "termix_linux_x64_${VERSION}_appimage.AppImage"
|
mv "termix_linux_x86_64_${VERSION}_appimage.AppImage" "termix_linux_x64_${VERSION}_appimage.AppImage"
|
||||||
echo "Renamed x64 AppImage to use 'x64' arch"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Rename x64 deb to use 'x64'
|
|
||||||
if [ -f "termix_linux_amd64_${VERSION}_deb.deb" ]; then
|
if [ -f "termix_linux_amd64_${VERSION}_deb.deb" ]; then
|
||||||
mv "termix_linux_amd64_${VERSION}_deb.deb" "termix_linux_x64_${VERSION}_deb.deb"
|
mv "termix_linux_amd64_${VERSION}_deb.deb" "termix_linux_x64_${VERSION}_deb.deb"
|
||||||
echo "Renamed x64 deb to use 'x64' arch"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Rename x64 tar.gz if it exists
|
|
||||||
if [ -f "termix-${VERSION}.tar.gz" ]; then
|
if [ -f "termix-${VERSION}.tar.gz" ]; then
|
||||||
mv "termix-${VERSION}.tar.gz" "termix_linux_x64_${VERSION}_portable.tar.gz"
|
mv "termix-${VERSION}.tar.gz" "termix_linux_x64_${VERSION}_portable.tar.gz"
|
||||||
echo "Renamed x64 tar.gz"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Rename arm64 tar.gz if it exists
|
|
||||||
if [ -f "termix-${VERSION}-arm64.tar.gz" ]; then
|
if [ -f "termix-${VERSION}-arm64.tar.gz" ]; then
|
||||||
mv "termix-${VERSION}-arm64.tar.gz" "termix_linux_arm64_${VERSION}_portable.tar.gz"
|
mv "termix-${VERSION}-arm64.tar.gz" "termix_linux_arm64_${VERSION}_portable.tar.gz"
|
||||||
echo "Renamed arm64 tar.gz"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Rename armv7l tar.gz if it exists
|
|
||||||
if [ -f "termix-${VERSION}-armv7l.tar.gz" ]; then
|
if [ -f "termix-${VERSION}-armv7l.tar.gz" ]; then
|
||||||
mv "termix-${VERSION}-armv7l.tar.gz" "termix_linux_armv7l_${VERSION}_portable.tar.gz"
|
mv "termix-${VERSION}-armv7l.tar.gz" "termix_linux_armv7l_${VERSION}_portable.tar.gz"
|
||||||
echo "Renamed armv7l tar.gz"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
- name: List release files
|
- name: List release files
|
||||||
run: |
|
run: |
|
||||||
echo "Contents of release directory:"
|
|
||||||
ls -la release/
|
ls -la release/
|
||||||
|
|
||||||
- name: Upload Linux x64 AppImage
|
- name: Upload Linux x64 AppImage
|
||||||
@@ -299,16 +282,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
# Retry npm ci up to 3 times on failure
|
for i in 1 2 3;
|
||||||
for i in 1 2 3; do
|
do
|
||||||
if npm ci; then
|
if npm ci; then
|
||||||
break
|
break
|
||||||
else
|
else
|
||||||
if [ $i -eq 3 ]; then
|
if [ $i -eq 3 ]; then
|
||||||
echo "npm ci failed after 3 attempts"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "npm ci attempt $i failed, retrying in 10 seconds..."
|
|
||||||
sleep 10
|
sleep 10
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -320,9 +301,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
if [ -n "${{ secrets.MAC_BUILD_CERTIFICATE_BASE64 }}" ] && [ -n "${{ secrets.MAC_P12_PASSWORD }}" ]; then
|
if [ -n "${{ secrets.MAC_BUILD_CERTIFICATE_BASE64 }}" ] && [ -n "${{ secrets.MAC_P12_PASSWORD }}" ]; then
|
||||||
echo "has_certs=true" >> $GITHUB_OUTPUT
|
echo "has_certs=true" >> $GITHUB_OUTPUT
|
||||||
else
|
|
||||||
echo "has_certs=false" >> $GITHUB_OUTPUT
|
|
||||||
echo "⚠️ Code signing certificates not configured. MAS build will be unsigned."
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Import Code Signing Certificates
|
- name: Import Code Signing Certificates
|
||||||
@@ -337,36 +315,26 @@ jobs:
|
|||||||
INSTALLER_CERT_PATH=$RUNNER_TEMP/installer_certificate.p12
|
INSTALLER_CERT_PATH=$RUNNER_TEMP/installer_certificate.p12
|
||||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||||
|
|
||||||
# Decode certificates
|
|
||||||
echo -n "$MAC_BUILD_CERTIFICATE_BASE64" | base64 --decode -o $APP_CERT_PATH
|
echo -n "$MAC_BUILD_CERTIFICATE_BASE64" | base64 --decode -o $APP_CERT_PATH
|
||||||
|
|
||||||
if [ -n "$MAC_INSTALLER_CERTIFICATE_BASE64" ]; then
|
if [ -n "$MAC_INSTALLER_CERTIFICATE_BASE64" ]; then
|
||||||
echo "Decoding installer certificate..."
|
|
||||||
echo -n "$MAC_INSTALLER_CERTIFICATE_BASE64" | base64 --decode -o $INSTALLER_CERT_PATH
|
echo -n "$MAC_INSTALLER_CERTIFICATE_BASE64" | base64 --decode -o $INSTALLER_CERT_PATH
|
||||||
else
|
else
|
||||||
echo "⚠️ MAC_INSTALLER_CERTIFICATE_BASE64 is empty"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create and configure keychain
|
|
||||||
security create-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
security create-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||||
security unlock-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
security unlock-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||||
|
|
||||||
# Import application certificate
|
|
||||||
echo "Importing application certificate..."
|
|
||||||
security import $APP_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
security import $APP_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||||
|
|
||||||
# Import installer certificate if it exists
|
|
||||||
if [ -f "$INSTALLER_CERT_PATH" ]; then
|
if [ -f "$INSTALLER_CERT_PATH" ]; then
|
||||||
echo "Importing installer certificate..."
|
|
||||||
security import $INSTALLER_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
security import $INSTALLER_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||||
else
|
else
|
||||||
echo "⚠️ Installer certificate file not found, skipping import"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||||
|
|
||||||
echo "Imported certificates:"
|
|
||||||
security find-identity -v -p codesigning $KEYCHAIN_PATH
|
security find-identity -v -p codesigning $KEYCHAIN_PATH
|
||||||
|
|
||||||
- name: Build macOS App Store Package
|
- name: Build macOS App Store Package
|
||||||
@@ -374,32 +342,21 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
|
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
|
||||||
run: |
|
run: |
|
||||||
# Get current version for display
|
|
||||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||||
BUILD_VERSION="${{ github.run_number }}"
|
BUILD_VERSION="${{ github.run_number }}"
|
||||||
|
|
||||||
echo "✅ Package version: $CURRENT_VERSION (unchanged)"
|
|
||||||
echo "✅ Build number for Apple: $BUILD_VERSION"
|
|
||||||
|
|
||||||
# Build MAS with custom buildVersion
|
|
||||||
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
|
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
|
||||||
|
|
||||||
- name: Clean up MAS keychain before DMG build
|
- name: Clean up MAS keychain before DMG build
|
||||||
if: steps.check_certs.outputs.has_certs == 'true'
|
if: steps.check_certs.outputs.has_certs == 'true'
|
||||||
run: |
|
run: |
|
||||||
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
|
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
|
||||||
echo "Cleaned up MAS keychain"
|
|
||||||
|
|
||||||
- name: Check for Developer ID Certificates
|
- name: Check for Developer ID Certificates
|
||||||
id: check_dev_id_certs
|
id: check_dev_id_certs
|
||||||
run: |
|
run: |
|
||||||
if [ -n "${{ secrets.DEVELOPER_ID_CERTIFICATE_BASE64 }}" ] && [ -n "${{ secrets.DEVELOPER_ID_P12_PASSWORD }}" ]; then
|
if [ -n "${{ secrets.DEVELOPER_ID_CERTIFICATE_BASE64 }}" ] && [ -n "${{ secrets.DEVELOPER_ID_P12_PASSWORD }}" ]; then
|
||||||
echo "has_dev_id_certs=true" >> $GITHUB_OUTPUT
|
echo "has_dev_id_certs=true" >> $GITHUB_OUTPUT
|
||||||
echo "✅ Developer ID certificates configured for DMG signing"
|
|
||||||
else
|
|
||||||
echo "has_dev_id_certs=false" >> $GITHUB_OUTPUT
|
|
||||||
echo "⚠️ Developer ID certificates not configured. DMG will be unsigned."
|
|
||||||
echo "Add DEVELOPER_ID_CERTIFICATE_BASE64 and DEVELOPER_ID_P12_PASSWORD secrets to enable DMG signing."
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Import Developer ID Certificates
|
- name: Import Developer ID Certificates
|
||||||
@@ -414,34 +371,25 @@ jobs:
|
|||||||
DEV_INSTALLER_CERT_PATH=$RUNNER_TEMP/dev_installer_certificate.p12
|
DEV_INSTALLER_CERT_PATH=$RUNNER_TEMP/dev_installer_certificate.p12
|
||||||
KEYCHAIN_PATH=$RUNNER_TEMP/dev-signing.keychain-db
|
KEYCHAIN_PATH=$RUNNER_TEMP/dev-signing.keychain-db
|
||||||
|
|
||||||
# Decode Developer ID certificate
|
|
||||||
echo -n "$DEVELOPER_ID_CERTIFICATE_BASE64" | base64 --decode -o $DEV_CERT_PATH
|
echo -n "$DEVELOPER_ID_CERTIFICATE_BASE64" | base64 --decode -o $DEV_CERT_PATH
|
||||||
|
|
||||||
if [ -n "$DEVELOPER_ID_INSTALLER_CERTIFICATE_BASE64" ]; then
|
if [ -n "$DEVELOPER_ID_INSTALLER_CERTIFICATE_BASE64" ]; then
|
||||||
echo "Decoding Developer ID installer certificate..."
|
|
||||||
echo -n "$DEVELOPER_ID_INSTALLER_CERTIFICATE_BASE64" | base64 --decode -o $DEV_INSTALLER_CERT_PATH
|
echo -n "$DEVELOPER_ID_INSTALLER_CERTIFICATE_BASE64" | base64 --decode -o $DEV_INSTALLER_CERT_PATH
|
||||||
else
|
else
|
||||||
echo "⚠️ DEVELOPER_ID_INSTALLER_CERTIFICATE_BASE64 is empty (optional)"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create and configure keychain
|
|
||||||
security create-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
security create-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||||
security unlock-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
security unlock-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||||
|
|
||||||
# Import Developer ID Application certificate
|
|
||||||
echo "Importing Developer ID Application certificate..."
|
|
||||||
security import $DEV_CERT_PATH -P "$DEVELOPER_ID_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
security import $DEV_CERT_PATH -P "$DEVELOPER_ID_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||||
|
|
||||||
# Import Developer ID Installer certificate if it exists
|
|
||||||
if [ -f "$DEV_INSTALLER_CERT_PATH" ]; then
|
if [ -f "$DEV_INSTALLER_CERT_PATH" ]; then
|
||||||
echo "Importing Developer ID Installer certificate..."
|
|
||||||
security import $DEV_INSTALLER_CERT_PATH -P "$DEVELOPER_ID_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
security import $DEV_INSTALLER_CERT_PATH -P "$DEVELOPER_ID_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||||
fi
|
fi
|
||||||
|
|
||||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||||
|
|
||||||
echo "Imported Developer ID certificates:"
|
|
||||||
security find-identity -v -p codesigning $KEYCHAIN_PATH
|
security find-identity -v -p codesigning $KEYCHAIN_PATH
|
||||||
|
|
||||||
- name: Build macOS DMG
|
- name: Build macOS DMG
|
||||||
@@ -452,19 +400,15 @@ jobs:
|
|||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
run: |
|
run: |
|
||||||
# Build DMG without running npm run build again (already built above or skip if no certs)
|
|
||||||
if [ "${{ steps.check_certs.outputs.has_certs }}" == "true" ]; then
|
if [ "${{ steps.check_certs.outputs.has_certs }}" == "true" ]; then
|
||||||
# Frontend already built, just package DMG
|
|
||||||
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
|
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
|
||||||
else
|
else
|
||||||
# No certs, need to build frontend first
|
|
||||||
npm run build && npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
|
npm run build && npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: List release directory
|
- name: List release directory
|
||||||
if: steps.check_certs.outputs.has_certs == 'true'
|
if: steps.check_certs.outputs.has_certs == 'true'
|
||||||
run: |
|
run: |
|
||||||
echo "Contents of release directory:"
|
|
||||||
ls -R release/ || echo "Release directory not found"
|
ls -R release/ || echo "Release directory not found"
|
||||||
|
|
||||||
- name: Upload macOS MAS PKG
|
- name: Upload macOS MAS PKG
|
||||||
@@ -506,15 +450,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
if [ -n "${{ secrets.APPLE_KEY_ID }}" ] && [ -n "${{ secrets.APPLE_ISSUER_ID }}" ] && [ -n "${{ secrets.APPLE_KEY_CONTENT }}" ]; then
|
if [ -n "${{ secrets.APPLE_KEY_ID }}" ] && [ -n "${{ secrets.APPLE_ISSUER_ID }}" ] && [ -n "${{ secrets.APPLE_KEY_CONTENT }}" ]; then
|
||||||
echo "has_credentials=true" >> $GITHUB_OUTPUT
|
echo "has_credentials=true" >> $GITHUB_OUTPUT
|
||||||
if [ "${{ github.event.inputs.artifact_destination }}" == "submit" ]; then
|
|
||||||
echo "✅ App Store Connect API credentials found. Will deploy to TestFlight."
|
|
||||||
else
|
|
||||||
echo "ℹ️ App Store Connect API credentials found, but store submission is disabled."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "has_credentials=false" >> $GITHUB_OUTPUT
|
|
||||||
echo "⚠️ App Store Connect API credentials not configured. Skipping deployment."
|
|
||||||
echo "Add APPLE_KEY_ID, APPLE_ISSUER_ID, and APPLE_KEY_CONTENT secrets to enable automatic deployment."
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Setup Ruby for Fastlane
|
- name: Setup Ruby for Fastlane
|
||||||
@@ -528,29 +463,22 @@ jobs:
|
|||||||
if: steps.check_asc_creds.outputs.has_credentials == 'true' && github.event.inputs.artifact_destination == 'submit'
|
if: steps.check_asc_creds.outputs.has_credentials == 'true' && github.event.inputs.artifact_destination == 'submit'
|
||||||
run: |
|
run: |
|
||||||
gem install fastlane -N
|
gem install fastlane -N
|
||||||
fastlane --version
|
|
||||||
|
|
||||||
- name: Deploy to App Store Connect (TestFlight)
|
- name: Deploy to App Store Connect (TestFlight)
|
||||||
if: steps.check_asc_creds.outputs.has_credentials == 'true' && github.event.inputs.artifact_destination == 'submit'
|
if: steps.check_asc_creds.outputs.has_credentials == 'true' && github.event.inputs.artifact_destination == 'submit'
|
||||||
run: |
|
run: |
|
||||||
PKG_FILE=$(find release -name "*.pkg" -type f | head -n 1)
|
PKG_FILE=$(find release -name "*.pkg" -type f | head -n 1)
|
||||||
if [ -z "$PKG_FILE" ]; then
|
if [ -z "$PKG_FILE" ]; then
|
||||||
echo "Error: No .pkg file found in release directory"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Found package: $PKG_FILE"
|
|
||||||
|
|
||||||
# Create API key file
|
|
||||||
mkdir -p ~/private_keys
|
mkdir -p ~/private_keys
|
||||||
echo "${{ secrets.APPLE_KEY_CONTENT }}" | base64 --decode > ~/private_keys/AuthKey_${{ secrets.APPLE_KEY_ID }}.p8
|
echo "${{ secrets.APPLE_KEY_CONTENT }}" | base64 --decode > ~/private_keys/AuthKey_${{ secrets.APPLE_KEY_ID }}.p8
|
||||||
|
|
||||||
# Upload to App Store Connect using xcrun altool
|
|
||||||
xcrun altool --upload-app -f "$PKG_FILE" \
|
xcrun altool --upload-app -f "$PKG_FILE" \
|
||||||
--type macos \
|
--type macos \
|
||||||
--apiKey "${{ secrets.APPLE_KEY_ID }}" \
|
--apiKey "${{ secrets.APPLE_KEY_ID }}" \
|
||||||
--apiIssuer "${{ secrets.APPLE_ISSUER_ID }}"
|
--apiIssuer "${{ secrets.APPLE_ISSUER_ID }}"
|
||||||
|
|
||||||
echo "✅ Upload complete! Build will appear in App Store Connect after processing (10-30 minutes)"
|
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Clean up keychains
|
- name: Clean up keychains
|
||||||
@@ -577,7 +505,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$VERSION = (Get-Content package.json | ConvertFrom-Json).version
|
$VERSION = (Get-Content package.json | ConvertFrom-Json).version
|
||||||
echo "version=$VERSION" >> $env:GITHUB_OUTPUT
|
echo "version=$VERSION" >> $env:GITHUB_OUTPUT
|
||||||
echo "Building Chocolatey package for version: $VERSION"
|
|
||||||
|
|
||||||
- name: Download Windows x64 MSI artifact
|
- name: Download Windows x64 MSI artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
@@ -595,8 +522,6 @@ jobs:
|
|||||||
|
|
||||||
echo "msi_name=$MSI_NAME" >> $env:GITHUB_OUTPUT
|
echo "msi_name=$MSI_NAME" >> $env:GITHUB_OUTPUT
|
||||||
echo "checksum=$CHECKSUM" >> $env:GITHUB_OUTPUT
|
echo "checksum=$CHECKSUM" >> $env:GITHUB_OUTPUT
|
||||||
echo "MSI File: $MSI_NAME"
|
|
||||||
echo "SHA256: $CHECKSUM"
|
|
||||||
|
|
||||||
- name: Prepare Chocolatey package
|
- name: Prepare Chocolatey package
|
||||||
run: |
|
run: |
|
||||||
@@ -604,33 +529,20 @@ jobs:
|
|||||||
$CHECKSUM = "${{ steps.msi-info.outputs.checksum }}"
|
$CHECKSUM = "${{ steps.msi-info.outputs.checksum }}"
|
||||||
$MSI_NAME = "${{ steps.msi-info.outputs.msi_name }}"
|
$MSI_NAME = "${{ steps.msi-info.outputs.msi_name }}"
|
||||||
|
|
||||||
# Construct the download URL with the actual release tag format
|
|
||||||
$DOWNLOAD_URL = "https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$MSI_NAME"
|
$DOWNLOAD_URL = "https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$MSI_NAME"
|
||||||
|
|
||||||
# Copy chocolatey files to build directory
|
|
||||||
New-Item -ItemType Directory -Force -Path "choco-build"
|
New-Item -ItemType Directory -Force -Path "choco-build"
|
||||||
Copy-Item -Path "chocolatey\*" -Destination "choco-build" -Recurse -Force
|
Copy-Item -Path "chocolatey\*" -Destination "choco-build" -Recurse -Force
|
||||||
|
|
||||||
# Update chocolateyinstall.ps1 with actual values
|
|
||||||
$installScript = Get-Content "choco-build\tools\chocolateyinstall.ps1" -Raw -Encoding UTF8
|
$installScript = Get-Content "choco-build\tools\chocolateyinstall.ps1" -Raw -Encoding UTF8
|
||||||
$installScript = $installScript -replace 'DOWNLOAD_URL_PLACEHOLDER', $DOWNLOAD_URL
|
$installScript = $installScript -replace 'DOWNLOAD_URL_PLACEHOLDER', $DOWNLOAD_URL
|
||||||
$installScript = $installScript -replace 'CHECKSUM_PLACEHOLDER', $CHECKSUM
|
$installScript = $installScript -replace 'CHECKSUM_PLACEHOLDER', $CHECKSUM
|
||||||
[System.IO.File]::WriteAllText("$PWD\choco-build\tools\chocolateyinstall.ps1", $installScript, [System.Text.UTF8Encoding]::new($false))
|
[System.IO.File]::WriteAllText("$PWD\choco-build\tools\chocolateyinstall.ps1", $installScript, [System.Text.UTF8Encoding]::new($false))
|
||||||
|
|
||||||
# Update nuspec with version (preserve UTF-8 encoding without BOM)
|
|
||||||
$nuspec = Get-Content "choco-build\termix-ssh.nuspec" -Raw -Encoding UTF8
|
$nuspec = Get-Content "choco-build\termix-ssh.nuspec" -Raw -Encoding UTF8
|
||||||
$nuspec = $nuspec -replace 'VERSION_PLACEHOLDER', $VERSION
|
$nuspec = $nuspec -replace 'VERSION_PLACEHOLDER', $VERSION
|
||||||
[System.IO.File]::WriteAllText("$PWD\choco-build\termix-ssh.nuspec", $nuspec, [System.Text.UTF8Encoding]::new($false))
|
[System.IO.File]::WriteAllText("$PWD\choco-build\termix-ssh.nuspec", $nuspec, [System.Text.UTF8Encoding]::new($false))
|
||||||
|
|
||||||
echo "Chocolatey package prepared for version $VERSION"
|
|
||||||
echo "Download URL: $DOWNLOAD_URL"
|
|
||||||
|
|
||||||
# Verify the nuspec is valid
|
|
||||||
echo ""
|
|
||||||
echo "Verifying nuspec content:"
|
|
||||||
Get-Content "choco-build\termix-ssh.nuspec" -Head 10
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
- name: Install Chocolatey
|
- name: Install Chocolatey
|
||||||
run: |
|
run: |
|
||||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||||
@@ -640,29 +552,17 @@ jobs:
|
|||||||
- name: Pack Chocolatey package
|
- name: Pack Chocolatey package
|
||||||
run: |
|
run: |
|
||||||
cd choco-build
|
cd choco-build
|
||||||
echo "Packing Chocolatey package..."
|
|
||||||
choco pack termix-ssh.nuspec
|
choco pack termix-ssh.nuspec
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
echo "❌ Failed to pack Chocolatey package"
|
throw "Chocolatey push failed with exit code $LASTEXITCODE"
|
||||||
exit 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ Package created successfully"
|
|
||||||
echo "Package contents:"
|
|
||||||
Get-ChildItem *.nupkg | ForEach-Object { echo $_.Name }
|
|
||||||
|
|
||||||
- name: Check for Chocolatey API Key
|
- name: Check for Chocolatey API Key
|
||||||
id: check_choco_key
|
id: check_choco_key
|
||||||
run: |
|
run: |
|
||||||
if ("${{ secrets.CHOCOLATEY_API_KEY }}" -ne "") {
|
if ("${{ secrets.CHOCOLATEY_API_KEY }}" -ne "") {
|
||||||
echo "has_key=true" >> $env:GITHUB_OUTPUT
|
echo "has_key=true" >> $env:GITHUB_OUTPUT
|
||||||
echo "✅ Chocolatey API key found. Will push to Chocolatey."
|
|
||||||
} else {
|
|
||||||
echo "has_key=false" >> $env:GITHUB_OUTPUT
|
|
||||||
echo "⚠️ Chocolatey API key not configured. Package will be created but not pushed."
|
|
||||||
echo "Add CHOCOLATEY_API_KEY secret to enable automatic submission."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: Push to Chocolatey
|
- name: Push to Chocolatey
|
||||||
@@ -675,29 +575,10 @@ jobs:
|
|||||||
try {
|
try {
|
||||||
choco push "termix-ssh.$VERSION.nupkg" --source https://push.chocolatey.org/
|
choco push "termix-ssh.$VERSION.nupkg" --source https://push.chocolatey.org/
|
||||||
if ($LASTEXITCODE -eq 0) {
|
if ($LASTEXITCODE -eq 0) {
|
||||||
echo ""
|
|
||||||
echo "✅ Package pushed to Chocolatey successfully!"
|
|
||||||
echo "View at: https://community.chocolatey.org/packages/termix-ssh/$VERSION"
|
|
||||||
} else {
|
} else {
|
||||||
throw "Chocolatey push failed with exit code $LASTEXITCODE"
|
throw "Chocolatey push failed with exit code $LASTEXITCODE"
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
echo ""
|
|
||||||
echo "❌ Failed to push to Chocolatey"
|
|
||||||
echo ""
|
|
||||||
echo "Common reasons:"
|
|
||||||
echo "1. Package ID 'termix-ssh' is already owned by another user"
|
|
||||||
echo "2. You need to register/claim the package ID first"
|
|
||||||
echo "3. API key doesn't have push permissions"
|
|
||||||
echo ""
|
|
||||||
echo "Solutions:"
|
|
||||||
echo "1. Check if package exists: https://community.chocolatey.org/packages/termix-ssh"
|
|
||||||
echo "2. If it exists and is yours, contact Chocolatey support to claim it"
|
|
||||||
echo "3. Register a new package ID at: https://community.chocolatey.org/"
|
|
||||||
echo ""
|
|
||||||
echo "The package artifact has been saved for manual submission."
|
|
||||||
echo ""
|
|
||||||
exit 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: Upload Chocolatey package as artifact
|
- name: Upload Chocolatey package as artifact
|
||||||
@@ -727,7 +608,6 @@ jobs:
|
|||||||
RELEASE_DATE=$(date +%Y-%m-%d)
|
RELEASE_DATE=$(date +%Y-%m-%d)
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "release_date=$RELEASE_DATE" >> $GITHUB_OUTPUT
|
echo "release_date=$RELEASE_DATE" >> $GITHUB_OUTPUT
|
||||||
echo "Building Flatpak submission for version: $VERSION"
|
|
||||||
|
|
||||||
- name: Download Linux x64 AppImage artifact
|
- name: Download Linux x64 AppImage artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
@@ -746,12 +626,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.package-version.outputs.version }}"
|
VERSION="${{ steps.package-version.outputs.version }}"
|
||||||
|
|
||||||
# x64 AppImage
|
|
||||||
APPIMAGE_X64_FILE=$(find artifact-x64 -name "*.AppImage" -type f | head -n 1)
|
APPIMAGE_X64_FILE=$(find artifact-x64 -name "*.AppImage" -type f | head -n 1)
|
||||||
APPIMAGE_X64_NAME=$(basename "$APPIMAGE_X64_FILE")
|
APPIMAGE_X64_NAME=$(basename "$APPIMAGE_X64_FILE")
|
||||||
CHECKSUM_X64=$(sha256sum "$APPIMAGE_X64_FILE" | awk '{print $1}')
|
CHECKSUM_X64=$(sha256sum "$APPIMAGE_X64_FILE" | awk '{print $1}')
|
||||||
|
|
||||||
# arm64 AppImage
|
|
||||||
APPIMAGE_ARM64_FILE=$(find artifact-arm64 -name "*.AppImage" -type f | head -n 1)
|
APPIMAGE_ARM64_FILE=$(find artifact-arm64 -name "*.AppImage" -type f | head -n 1)
|
||||||
APPIMAGE_ARM64_NAME=$(basename "$APPIMAGE_ARM64_FILE")
|
APPIMAGE_ARM64_NAME=$(basename "$APPIMAGE_ARM64_FILE")
|
||||||
CHECKSUM_ARM64=$(sha256sum "$APPIMAGE_ARM64_FILE" | awk '{print $1}')
|
CHECKSUM_ARM64=$(sha256sum "$APPIMAGE_ARM64_FILE" | awk '{print $1}')
|
||||||
@@ -761,11 +639,6 @@ jobs:
|
|||||||
echo "appimage_arm64_name=$APPIMAGE_ARM64_NAME" >> $GITHUB_OUTPUT
|
echo "appimage_arm64_name=$APPIMAGE_ARM64_NAME" >> $GITHUB_OUTPUT
|
||||||
echo "checksum_arm64=$CHECKSUM_ARM64" >> $GITHUB_OUTPUT
|
echo "checksum_arm64=$CHECKSUM_ARM64" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
echo "x64 AppImage: $APPIMAGE_X64_NAME"
|
|
||||||
echo "x64 SHA256: $CHECKSUM_X64"
|
|
||||||
echo "arm64 AppImage: $APPIMAGE_ARM64_NAME"
|
|
||||||
echo "arm64 SHA256: $CHECKSUM_ARM64"
|
|
||||||
|
|
||||||
- name: Install ImageMagick for icon generation
|
- name: Install ImageMagick for icon generation
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
@@ -780,101 +653,26 @@ jobs:
|
|||||||
APPIMAGE_X64_NAME="${{ steps.appimage-info.outputs.appimage_x64_name }}"
|
APPIMAGE_X64_NAME="${{ steps.appimage-info.outputs.appimage_x64_name }}"
|
||||||
APPIMAGE_ARM64_NAME="${{ steps.appimage-info.outputs.appimage_arm64_name }}"
|
APPIMAGE_ARM64_NAME="${{ steps.appimage-info.outputs.appimage_arm64_name }}"
|
||||||
|
|
||||||
# Create submission directory
|
|
||||||
mkdir -p flatpak-submission
|
mkdir -p flatpak-submission
|
||||||
|
|
||||||
# Copy Flatpak files to submission directory
|
|
||||||
cp flatpak/com.karmaa.termix.yml flatpak-submission/
|
cp flatpak/com.karmaa.termix.yml flatpak-submission/
|
||||||
cp flatpak/com.karmaa.termix.desktop flatpak-submission/
|
cp flatpak/com.karmaa.termix.desktop flatpak-submission/
|
||||||
cp flatpak/com.karmaa.termix.metainfo.xml flatpak-submission/
|
cp flatpak/com.karmaa.termix.metainfo.xml flatpak-submission/
|
||||||
cp flatpak/flathub.json flatpak-submission/
|
cp flatpak/flathub.json flatpak-submission/
|
||||||
|
|
||||||
# Copy and prepare icons
|
|
||||||
cp public/icon.svg flatpak-submission/com.karmaa.termix.svg
|
cp public/icon.svg flatpak-submission/com.karmaa.termix.svg
|
||||||
convert public/icon.png -resize 256x256 flatpak-submission/icon-256.png
|
convert public/icon.png -resize 256x256 flatpak-submission/icon-256.png
|
||||||
convert public/icon.png -resize 128x128 flatpak-submission/icon-128.png
|
convert public/icon.png -resize 128x128 flatpak-submission/icon-128.png
|
||||||
|
|
||||||
# Update manifest with version and checksums
|
|
||||||
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak-submission/com.karmaa.termix.yml
|
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak-submission/com.karmaa.termix.yml
|
||||||
sed -i "s/CHECKSUM_X64_PLACEHOLDER/$CHECKSUM_X64/g" flatpak-submission/com.karmaa.termix.yml
|
sed -i "s/CHECKSUM_X64_PLACEHOLDER/$CHECKSUM_X64/g" flatpak-submission/com.karmaa.termix.yml
|
||||||
sed -i "s/CHECKSUM_ARM64_PLACEHOLDER/$CHECKSUM_ARM64/g" flatpak-submission/com.karmaa.termix.yml
|
sed -i "s/CHECKSUM_ARM64_PLACEHOLDER/$CHECKSUM_ARM64/g" flatpak-submission/com.karmaa.termix.yml
|
||||||
|
|
||||||
# Update metainfo with version and date
|
|
||||||
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak-submission/com.karmaa.termix.metainfo.xml
|
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
|
sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" flatpak-submission/com.karmaa.termix.metainfo.xml
|
||||||
|
|
||||||
echo "✅ Flatpak submission files prepared for version $VERSION"
|
|
||||||
echo "x64 Download URL: https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$APPIMAGE_X64_NAME"
|
|
||||||
echo "arm64 Download URL: https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$APPIMAGE_ARM64_NAME"
|
|
||||||
|
|
||||||
- name: Create submission instructions
|
|
||||||
run: |
|
|
||||||
cat > flatpak-submission/SUBMISSION_INSTRUCTIONS.md << 'EOF'
|
|
||||||
# Flathub Submission Instructions for Termix
|
|
||||||
|
|
||||||
## Automatic Submission (Recommended)
|
|
||||||
|
|
||||||
All files needed for Flathub submission are in this artifact. Follow these steps:
|
|
||||||
|
|
||||||
1. **Fork the Flathub repository**:
|
|
||||||
- Go to https://github.com/flathub/flathub
|
|
||||||
- Click "Fork" button
|
|
||||||
|
|
||||||
2. **Clone your fork**:
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/YOUR-USERNAME/flathub.git
|
|
||||||
cd flathub
|
|
||||||
git checkout -b com.karmaa.termix
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Copy all files from this artifact** to the root of your flathub fork
|
|
||||||
|
|
||||||
4. **Commit and push**:
|
|
||||||
```bash
|
|
||||||
git add .
|
|
||||||
git commit -m "Add Termix ${{ steps.package-version.outputs.version }}"
|
|
||||||
git push origin com.karmaa.termix
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Create Pull Request**:
|
|
||||||
- Go to https://github.com/YOUR-USERNAME/flathub
|
|
||||||
- Click "Compare & pull request"
|
|
||||||
- Submit PR to flathub/flathub
|
|
||||||
|
|
||||||
## Files in this submission:
|
|
||||||
|
|
||||||
- `com.karmaa.termix.yml` - Flatpak manifest
|
|
||||||
- `com.karmaa.termix.desktop` - Desktop entry
|
|
||||||
- `com.karmaa.termix.metainfo.xml` - AppStream metadata
|
|
||||||
- `flathub.json` - Flathub configuration
|
|
||||||
- `com.karmaa.termix.svg` - SVG icon
|
|
||||||
- `icon-256.png` - 256x256 icon
|
|
||||||
- `icon-128.png` - 128x128 icon
|
|
||||||
|
|
||||||
## Version Information:
|
|
||||||
|
|
||||||
- Version: ${{ steps.package-version.outputs.version }}
|
|
||||||
- Release Date: ${{ steps.package-version.outputs.release_date }}
|
|
||||||
- x64 AppImage SHA256: ${{ steps.appimage-info.outputs.checksum_x64 }}
|
|
||||||
- arm64 AppImage SHA256: ${{ steps.appimage-info.outputs.checksum_arm64 }}
|
|
||||||
|
|
||||||
## After Submission:
|
|
||||||
|
|
||||||
1. Flathub maintainers will review your submission (usually 1-5 days)
|
|
||||||
2. They may request changes - be responsive to feedback
|
|
||||||
3. Once approved, Termix will be available via: `flatpak install flathub com.karmaa.termix`
|
|
||||||
|
|
||||||
## Resources:
|
|
||||||
|
|
||||||
- [Flathub Submission Guidelines](https://docs.flathub.org/docs/for-app-authors/submission)
|
|
||||||
- [Flatpak Documentation](https://docs.flatpak.org/)
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "✅ Created submission instructions"
|
|
||||||
|
|
||||||
- name: List submission files
|
- name: List submission files
|
||||||
run: |
|
run: |
|
||||||
echo "Flatpak submission files:"
|
|
||||||
ls -la flatpak-submission/
|
ls -la flatpak-submission/
|
||||||
|
|
||||||
- name: Upload Flatpak submission as artifact
|
- name: Upload Flatpak submission as artifact
|
||||||
@@ -884,19 +682,6 @@ jobs:
|
|||||||
path: flatpak-submission/*
|
path: flatpak-submission/*
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- name: Display next steps
|
|
||||||
run: |
|
|
||||||
echo ""
|
|
||||||
echo "🎉 Flatpak submission files ready!"
|
|
||||||
echo ""
|
|
||||||
echo "📦 Download the 'flatpak-submission' artifact and follow SUBMISSION_INSTRUCTIONS.md"
|
|
||||||
echo ""
|
|
||||||
echo "Quick summary:"
|
|
||||||
echo "1. Fork https://github.com/flathub/flathub"
|
|
||||||
echo "2. Copy artifact files to your fork"
|
|
||||||
echo "3. Create PR to flathub/flathub"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
submit-to-homebrew:
|
submit-to-homebrew:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
if: github.event.inputs.artifact_destination == 'submit'
|
if: github.event.inputs.artifact_destination == 'submit'
|
||||||
@@ -915,7 +700,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION=$(node -p "require('./package.json').version")
|
VERSION=$(node -p "require('./package.json').version")
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "Building Homebrew Cask for version: $VERSION"
|
|
||||||
|
|
||||||
- name: Download macOS Universal DMG artifact
|
- name: Download macOS Universal DMG artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
@@ -933,8 +717,6 @@ jobs:
|
|||||||
|
|
||||||
echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT
|
echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT
|
||||||
echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT
|
echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT
|
||||||
echo "DMG File: $DMG_NAME"
|
|
||||||
echo "SHA256: $CHECKSUM"
|
|
||||||
|
|
||||||
- name: Prepare Homebrew submission files
|
- name: Prepare Homebrew submission files
|
||||||
run: |
|
run: |
|
||||||
@@ -942,155 +724,24 @@ jobs:
|
|||||||
CHECKSUM="${{ steps.dmg-info.outputs.checksum }}"
|
CHECKSUM="${{ steps.dmg-info.outputs.checksum }}"
|
||||||
DMG_NAME="${{ steps.dmg-info.outputs.dmg_name }}"
|
DMG_NAME="${{ steps.dmg-info.outputs.dmg_name }}"
|
||||||
|
|
||||||
# Create submission directory
|
|
||||||
mkdir -p homebrew-submission/Casks/t
|
mkdir -p homebrew-submission/Casks/t
|
||||||
|
|
||||||
# Copy Homebrew cask file
|
|
||||||
cp homebrew/termix.rb homebrew-submission/Casks/t/termix.rb
|
cp homebrew/termix.rb homebrew-submission/Casks/t/termix.rb
|
||||||
cp homebrew/README.md homebrew-submission/
|
cp homebrew/README.md homebrew-submission/
|
||||||
|
|
||||||
# Update cask with version and checksum
|
|
||||||
sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-submission/Casks/t/termix.rb
|
sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-submission/Casks/t/termix.rb
|
||||||
sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb
|
sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb
|
||||||
|
|
||||||
echo "✅ Homebrew Cask prepared for version $VERSION"
|
|
||||||
echo "Download URL: https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$DMG_NAME"
|
|
||||||
|
|
||||||
- name: Verify Cask syntax
|
- name: Verify Cask syntax
|
||||||
run: |
|
run: |
|
||||||
# Install Homebrew if not present (should be on macos-latest)
|
|
||||||
if ! command -v brew &> /dev/null; then
|
if ! command -v brew &> /dev/null; then
|
||||||
echo "Installing Homebrew..."
|
|
||||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Basic syntax check
|
|
||||||
ruby -c homebrew-submission/Casks/t/termix.rb
|
ruby -c homebrew-submission/Casks/t/termix.rb
|
||||||
echo "✅ Cask syntax is valid"
|
|
||||||
|
|
||||||
- name: Create submission instructions
|
|
||||||
run: |
|
|
||||||
cat > homebrew-submission/SUBMISSION_INSTRUCTIONS.md << 'EOF'
|
|
||||||
# Homebrew Cask Submission Instructions for Termix
|
|
||||||
|
|
||||||
## Option 1: Submit to Official Homebrew Cask (Recommended)
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
- macOS with Homebrew installed
|
|
||||||
- GitHub account
|
|
||||||
|
|
||||||
### Steps
|
|
||||||
|
|
||||||
1. **Fork the Homebrew Cask repository**:
|
|
||||||
- Go to https://github.com/Homebrew/homebrew-cask
|
|
||||||
- Click "Fork" button
|
|
||||||
|
|
||||||
2. **Clone your fork**:
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/YOUR-USERNAME/homebrew-cask.git
|
|
||||||
cd homebrew-cask
|
|
||||||
git checkout -b termix
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Copy the cask file**:
|
|
||||||
- Copy `Casks/t/termix.rb` from this artifact to your fork at `Casks/t/termix.rb`
|
|
||||||
- Note: Casks are organized by first letter in subdirectories
|
|
||||||
|
|
||||||
4. **Test the cask locally**:
|
|
||||||
```bash
|
|
||||||
brew install --cask ./Casks/t/termix.rb
|
|
||||||
brew uninstall --cask termix
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Run audit checks**:
|
|
||||||
```bash
|
|
||||||
brew audit --cask --online ./Casks/t/termix.rb
|
|
||||||
brew style ./Casks/t/termix.rb
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Commit and push**:
|
|
||||||
```bash
|
|
||||||
git add Casks/t/termix.rb
|
|
||||||
git commit -m "Add Termix ${{ steps.package-version.outputs.version }}"
|
|
||||||
git push origin termix
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Create Pull Request**:
|
|
||||||
- Go to https://github.com/YOUR-USERNAME/homebrew-cask
|
|
||||||
- Click "Compare & pull request"
|
|
||||||
- Fill in the PR template
|
|
||||||
- Submit to Homebrew/homebrew-cask
|
|
||||||
|
|
||||||
### PR Requirements
|
|
||||||
|
|
||||||
Your PR should include:
|
|
||||||
- Clear commit message: "Add Termix X.Y.Z" or "Update Termix to X.Y.Z"
|
|
||||||
- All audit checks passing
|
|
||||||
- Working download URL
|
|
||||||
- Valid SHA256 checksum
|
|
||||||
|
|
||||||
## Option 2: Create Your Own Tap (Alternative)
|
|
||||||
|
|
||||||
If you want more control and faster updates:
|
|
||||||
|
|
||||||
1. **Create a tap repository**:
|
|
||||||
- Create repo: `Termix-SSH/homebrew-termix`
|
|
||||||
- Add `Casks/termix.rb` to the repo
|
|
||||||
|
|
||||||
2. **Users install with**:
|
|
||||||
```bash
|
|
||||||
brew tap termix-ssh/termix
|
|
||||||
brew install --cask termix
|
|
||||||
```
|
|
||||||
|
|
||||||
### Advantages of Custom Tap
|
|
||||||
- No approval process
|
|
||||||
- Instant updates
|
|
||||||
- Full control
|
|
||||||
- Can include beta versions
|
|
||||||
|
|
||||||
### Disadvantages
|
|
||||||
- Less discoverable
|
|
||||||
- Users must add tap first
|
|
||||||
- You maintain it yourself
|
|
||||||
|
|
||||||
## Files in this submission:
|
|
||||||
|
|
||||||
- `Casks/t/termix.rb` - Homebrew Cask formula
|
|
||||||
- `README.md` - Detailed documentation
|
|
||||||
- `SUBMISSION_INSTRUCTIONS.md` - This file
|
|
||||||
|
|
||||||
## Version Information:
|
|
||||||
|
|
||||||
- Version: ${{ steps.package-version.outputs.version }}
|
|
||||||
- DMG SHA256: ${{ steps.dmg-info.outputs.checksum }}
|
|
||||||
- DMG URL: https://github.com/Termix-SSH/Termix/releases/download/release-${{ steps.package-version.outputs.version }}-tag/${{ steps.dmg-info.outputs.dmg_name }}
|
|
||||||
|
|
||||||
## After Submission:
|
|
||||||
|
|
||||||
### Official Homebrew Cask:
|
|
||||||
1. Maintainers will review (usually 24-48 hours)
|
|
||||||
2. May request changes or fixes
|
|
||||||
3. Once merged, users can install with: `brew install --cask termix`
|
|
||||||
4. Homebrew bot will auto-update for future releases
|
|
||||||
|
|
||||||
### Custom Tap:
|
|
||||||
1. Push to your tap repository
|
|
||||||
2. Immediately available to users
|
|
||||||
3. Update the cask file for each new release
|
|
||||||
|
|
||||||
## Resources:
|
|
||||||
|
|
||||||
- [Homebrew Cask Documentation](https://docs.brew.sh/Cask-Cookbook)
|
|
||||||
- [Acceptable Casks](https://docs.brew.sh/Acceptable-Casks)
|
|
||||||
- [How to Open a PR](https://docs.brew.sh/How-To-Open-a-Homebrew-Pull-Request)
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "✅ Created submission instructions"
|
|
||||||
|
|
||||||
- name: List submission files
|
- name: List submission files
|
||||||
run: |
|
run: |
|
||||||
echo "Homebrew submission files:"
|
|
||||||
find homebrew-submission -type f
|
find homebrew-submission -type f
|
||||||
|
|
||||||
- name: Upload Homebrew submission as artifact
|
- name: Upload Homebrew submission as artifact
|
||||||
@@ -1100,18 +751,6 @@ jobs:
|
|||||||
path: homebrew-submission/*
|
path: homebrew-submission/*
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- name: Display next steps
|
|
||||||
run: |
|
|
||||||
echo ""
|
|
||||||
echo "🍺 Homebrew Cask ready!"
|
|
||||||
echo ""
|
|
||||||
echo "📦 Download the 'homebrew-submission' artifact and follow SUBMISSION_INSTRUCTIONS.md"
|
|
||||||
echo ""
|
|
||||||
echo "Quick summary:"
|
|
||||||
echo "Option 1 (Recommended): Fork https://github.com/Homebrew/homebrew-cask and submit PR"
|
|
||||||
echo "Option 2 (Alternative): Create your own tap at Termix-SSH/homebrew-termix"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
upload-to-release:
|
upload-to-release:
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
if: github.event.inputs.artifact_destination == 'release'
|
if: github.event.inputs.artifact_destination == 'release'
|
||||||
@@ -1128,49 +767,25 @@ jobs:
|
|||||||
- name: Get latest release
|
- name: Get latest release
|
||||||
id: get_release
|
id: get_release
|
||||||
run: |
|
run: |
|
||||||
echo "Fetching latest release from ${{ github.repository }}..."
|
LATEST_RELEASE=$(gh release list --repo ${{ github.repository }} --limit 1 --json tagName,name,isLatest -q '.[0]') env:
|
||||||
LATEST_RELEASE=$(gh release list --repo ${{ github.repository }} --limit 1 --json tagName,name,isLatest -q '.[0]')
|
|
||||||
|
|
||||||
if [ -z "$LATEST_RELEASE" ]; then
|
|
||||||
echo "ERROR: No releases found in ${{ github.repository }}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
RELEASE_TAG=$(echo "$LATEST_RELEASE" | jq -r '.tagName')
|
|
||||||
RELEASE_NAME=$(echo "$LATEST_RELEASE" | jq -r '.name')
|
|
||||||
|
|
||||||
echo "tag=$RELEASE_TAG" >> $GITHUB_OUTPUT
|
|
||||||
echo "name=$RELEASE_NAME" >> $GITHUB_OUTPUT
|
|
||||||
echo "Latest release: $RELEASE_NAME ($RELEASE_TAG)"
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
- name: Display artifact structure
|
- name: Display artifact structure
|
||||||
run: |
|
run: |
|
||||||
echo "Artifact structure:"
|
|
||||||
ls -R artifacts/
|
ls -R artifacts/
|
||||||
|
|
||||||
- name: Upload artifacts to latest release
|
- name: Upload artifacts to latest release
|
||||||
run: |
|
run: |
|
||||||
RELEASE_TAG="${{ steps.get_release.outputs.tag }}"
|
|
||||||
echo "Uploading artifacts to release: $RELEASE_TAG"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
cd artifacts
|
cd artifacts
|
||||||
for dir in */; do
|
for dir in */; do
|
||||||
echo "Processing directory: $dir"
|
|
||||||
cd "$dir"
|
cd "$dir"
|
||||||
for file in *; do
|
for file in *;
|
||||||
|
do
|
||||||
if [ -f "$file" ]; then
|
if [ -f "$file" ]; then
|
||||||
echo "Uploading: $file"
|
|
||||||
gh release upload "$RELEASE_TAG" "$file" --repo ${{ github.repository }} --clobber
|
gh release upload "$RELEASE_TAG" "$file" --repo ${{ github.repository }} --clobber
|
||||||
echo "✓ $file uploaded successfully"
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
cd ..
|
cd ..
|
||||||
done
|
done
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "All artifacts uploaded to: https://github.com/${{ github.repository }}/releases/tag/$RELEASE_TAG"
|
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|||||||
@@ -103,4 +103,4 @@ This will start the backend and the frontend Vite server. You can access Termix
|
|||||||
|
|
||||||
If you need help or want to request a feature with Termix, visit the [Issues](https://github.com/Termix-SSH/Support/issues) page, log in, and press `New Issue`.
|
If you need help or want to request a feature with Termix, visit the [Issues](https://github.com/Termix-SSH/Support/issues) page, log in, and press `New Issue`.
|
||||||
Please be as detailed as possible in your issue, preferably written in English. You can also join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
|
Please be as detailed as possible in your issue, preferably written in English. You can also join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
|
||||||
channel, however, response times may be longer.
|
channel, however, response times may be longer.
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
<bugTrackerUrl>https://github.com/Termix-SSH/Support/issues</bugTrackerUrl>
|
<bugTrackerUrl>https://github.com/Termix-SSH/Support/issues</bugTrackerUrl>
|
||||||
<tags>docker ssh self-hosted file-management ssh-tunnel termix server-management terminal</tags>
|
<tags>docker ssh self-hosted file-management ssh-tunnel termix server-management terminal</tags>
|
||||||
<summary>Termix is a web-based server management platform with SSH terminal, tunneling, and file editing capabilities.</summary>
|
<summary>Termix is a web-based server management platform with SSH terminal, tunneling, and file editing capabilities.</summary>
|
||||||
<description>Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface.
|
<description>
|
||||||
|
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface.
|
||||||
|
|
||||||
Termix offers:
|
Termix offers:
|
||||||
- SSH terminal access
|
- SSH terminal access
|
||||||
@@ -24,7 +25,8 @@ Termix offers:
|
|||||||
- Remote file management
|
- Remote file management
|
||||||
- Server monitoring and management
|
- Server monitoring and management
|
||||||
|
|
||||||
This package installs the desktop application version of Termix.</description>
|
This package installs the desktop application version of Termix.
|
||||||
|
</description>
|
||||||
<releaseNotes>https://github.com/Termix-SSH/Termix/releases</releaseNotes>
|
<releaseNotes>https://github.com/Termix-SSH/Termix/releases</releaseNotes>
|
||||||
</metadata>
|
</metadata>
|
||||||
<files>
|
<files>
|
||||||
|
|||||||
@@ -29,6 +29,5 @@ if ($key.Count -eq 1) {
|
|||||||
} elseif ($key.Count -gt 1) {
|
} elseif ($key.Count -gt 1) {
|
||||||
Write-Warning "$($key.Count) matches found!"
|
Write-Warning "$($key.Count) matches found!"
|
||||||
Write-Warning "To prevent accidental data loss, no programs will be uninstalled."
|
Write-Warning "To prevent accidental data loss, no programs will be uninstalled."
|
||||||
Write-Warning "Please alert package maintainer the following keys were matched:"
|
|
||||||
$key | % {Write-Warning "- $($_.DisplayName)"}
|
$key | % {Write-Warning "- $($_.DisplayName)"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,18 +23,14 @@ http {
|
|||||||
listen ${PORT};
|
listen ${PORT};
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
|
|
||||||
# X-Frame-Options removed to allow Electron iframe embedding
|
|
||||||
# add_header X-Frame-Options DENY always;
|
|
||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
# CORS headers for Electron iframe - reflect the origin for credentials support
|
|
||||||
add_header Access-Control-Allow-Origin $http_origin always;
|
add_header Access-Control-Allow-Origin $http_origin always;
|
||||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS, PATCH" always;
|
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS, PATCH" always;
|
||||||
add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept, Authorization" always;
|
add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept, Authorization" always;
|
||||||
add_header Access-Control-Allow-Credentials "true" always;
|
add_header Access-Control-Allow-Credentials "true" always;
|
||||||
|
|
||||||
# Serve static assets directly
|
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
expires 1y;
|
expires 1y;
|
||||||
@@ -98,7 +94,7 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 300s;
|
||||||
@@ -107,18 +103,18 @@ http {
|
|||||||
location ~ ^/database(/.*)?$ {
|
location ~ ^/database(/.*)?$ {
|
||||||
client_max_body_size 5G;
|
client_max_body_size 5G;
|
||||||
client_body_timeout 300s;
|
client_body_timeout 300s;
|
||||||
|
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 300s;
|
||||||
|
|
||||||
proxy_request_buffering off;
|
proxy_request_buffering off;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
@@ -126,18 +122,18 @@ http {
|
|||||||
location ~ ^/db(/.*)?$ {
|
location ~ ^/db(/.*)?$ {
|
||||||
client_max_body_size 5G;
|
client_max_body_size 5G;
|
||||||
client_body_timeout 300s;
|
client_body_timeout 300s;
|
||||||
|
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 300s;
|
||||||
|
|
||||||
proxy_request_buffering off;
|
proxy_request_buffering off;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
@@ -222,18 +218,18 @@ http {
|
|||||||
location /ssh/file_manager/ssh/ {
|
location /ssh/file_manager/ssh/ {
|
||||||
client_max_body_size 5G;
|
client_max_body_size 5G;
|
||||||
client_body_timeout 300s;
|
client_body_timeout 300s;
|
||||||
|
|
||||||
proxy_pass http://127.0.0.1:30004;
|
proxy_pass http://127.0.0.1:30004;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 300s;
|
||||||
|
|
||||||
proxy_request_buffering off;
|
proxy_request_buffering off;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
@@ -288,4 +284,4 @@ http {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
const { app, BrowserWindow, shell, ipcMain, dialog, Menu } = require("electron");
|
const {
|
||||||
|
app,
|
||||||
|
BrowserWindow,
|
||||||
|
shell,
|
||||||
|
ipcMain,
|
||||||
|
dialog,
|
||||||
|
Menu,
|
||||||
|
} = require("electron");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const os = require("os");
|
const os = require("os");
|
||||||
@@ -57,37 +64,38 @@ function createWindow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
mainWindow.loadURL("http://localhost:5173");
|
mainWindow.loadURL("http:://localhost:5173");
|
||||||
mainWindow.webContents.openDevTools();
|
mainWindow.webContents.openDevTools();
|
||||||
} else {
|
} else {
|
||||||
const indexPath = path.join(__dirname, "..", "dist", "index.html");
|
const indexPath = path.join(__dirname, "..", "dist", "index.html");
|
||||||
mainWindow.loadFile(indexPath);
|
mainWindow.loadFile(indexPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow iframes to load from any origin by removing X-Frame-Options headers
|
|
||||||
mainWindow.webContents.session.webRequest.onHeadersReceived(
|
mainWindow.webContents.session.webRequest.onHeadersReceived(
|
||||||
(details, callback) => {
|
(details, callback) => {
|
||||||
const headers = details.responseHeaders;
|
const headers = details.responseHeaders;
|
||||||
|
|
||||||
// Remove headers that block iframe embedding
|
|
||||||
if (headers) {
|
if (headers) {
|
||||||
delete headers["x-frame-options"];
|
delete headers["x-frame-options"];
|
||||||
delete headers["X-Frame-Options"];
|
delete headers["X-Frame-Options"];
|
||||||
|
|
||||||
// Modify CSP to allow framing
|
|
||||||
if (headers["content-security-policy"]) {
|
if (headers["content-security-policy"]) {
|
||||||
headers["content-security-policy"] = headers["content-security-policy"]
|
headers["content-security-policy"] = headers[
|
||||||
.map(value => value.replace(/frame-ancestors[^;]*/gi, ''))
|
"content-security-policy"
|
||||||
.filter(value => value.trim().length > 0);
|
]
|
||||||
|
.map((value) => value.replace(/frame-ancestors[^;]*/gi, ""))
|
||||||
|
.filter((value) => value.trim().length > 0);
|
||||||
|
|
||||||
if (headers["content-security-policy"].length === 0) {
|
if (headers["content-security-policy"].length === 0) {
|
||||||
delete headers["content-security-policy"];
|
delete headers["content-security-policy"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (headers["Content-Security-Policy"]) {
|
if (headers["Content-Security-Policy"]) {
|
||||||
headers["Content-Security-Policy"] = headers["Content-Security-Policy"]
|
headers["Content-Security-Policy"] = headers[
|
||||||
.map(value => value.replace(/frame-ancestors[^;]*/gi, ''))
|
"Content-Security-Policy"
|
||||||
.filter(value => value.trim().length > 0);
|
]
|
||||||
|
.map((value) => value.replace(/frame-ancestors[^;]*/gi, ""))
|
||||||
|
.filter((value) => value.trim().length > 0);
|
||||||
|
|
||||||
if (headers["Content-Security-Policy"].length === 0) {
|
if (headers["Content-Security-Policy"].length === 0) {
|
||||||
delete headers["Content-Security-Policy"];
|
delete headers["Content-Security-Policy"];
|
||||||
@@ -96,7 +104,7 @@ function createWindow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
callback({ responseHeaders: headers });
|
callback({ responseHeaders: headers });
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
mainWindow.once("ready-to-show", () => {
|
mainWindow.once("ready-to-show", () => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
app-id: com.karmaa.termix
|
app-id: com.karmaa.termix
|
||||||
runtime: org.freedesktop.Platform
|
runtime: org.freedesktop.Platform
|
||||||
runtime-version: '23.08'
|
runtime-version: "23.08"
|
||||||
sdk: org.freedesktop.Sdk
|
sdk: org.freedesktop.Sdk
|
||||||
base: org.electronjs.Electron2.BaseApp
|
base: org.electronjs.Electron2.BaseApp
|
||||||
base-version: '23.08'
|
base-version: "23.08"
|
||||||
command: termix
|
command: termix
|
||||||
separate-locales: false
|
separate-locales: false
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# This script prepares the Flatpak submission files
|
|
||||||
# It should be run from the repository root
|
|
||||||
|
|
||||||
VERSION="$1"
|
VERSION="$1"
|
||||||
CHECKSUM="$2"
|
CHECKSUM="$2"
|
||||||
RELEASE_DATE="$3"
|
RELEASE_DATE="$3"
|
||||||
@@ -16,39 +13,22 @@ fi
|
|||||||
|
|
||||||
echo "Preparing Flatpak submission for version $VERSION"
|
echo "Preparing Flatpak submission for version $VERSION"
|
||||||
|
|
||||||
# Copy icon files
|
|
||||||
cp public/icon.svg flatpak/com.karmaa.termix.svg
|
cp public/icon.svg flatpak/com.karmaa.termix.svg
|
||||||
echo "✓ Copied SVG icon"
|
echo "✓ Copied SVG icon"
|
||||||
|
|
||||||
# Generate PNG icons if ImageMagick is available
|
|
||||||
if command -v convert &> /dev/null; then
|
if command -v convert &> /dev/null; then
|
||||||
convert public/icon.png -resize 256x256 flatpak/icon-256.png
|
convert public/icon.png -resize 256x256 flatpak/icon-256.png
|
||||||
convert public/icon.png -resize 128x128 flatpak/icon-128.png
|
convert public/icon.png -resize 128x128 flatpak/icon-128.png
|
||||||
echo "✓ Generated PNG icons"
|
echo "✓ Generated PNG icons"
|
||||||
else
|
else
|
||||||
# Fallback: just copy the original PNG
|
|
||||||
cp public/icon.png flatpak/icon-256.png
|
cp public/icon.png flatpak/icon-256.png
|
||||||
cp public/icon.png flatpak/icon-128.png
|
cp public/icon.png flatpak/icon-128.png
|
||||||
echo "⚠ ImageMagick not found, using original icon"
|
echo "⚠ ImageMagick not found, using original icon"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Update manifest with version and checksum
|
|
||||||
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak/com.karmaa.termix.yml
|
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak/com.karmaa.termix.yml
|
||||||
sed -i "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" flatpak/com.karmaa.termix.yml
|
sed -i "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" flatpak/com.karmaa.termix.yml
|
||||||
echo "✓ Updated manifest with version $VERSION"
|
echo "✓ Updated manifest with version $VERSION"
|
||||||
|
|
||||||
# Update metainfo with version and date
|
|
||||||
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak/com.karmaa.termix.metainfo.xml
|
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
|
sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" flatpak/com.karmaa.termix.metainfo.xml
|
||||||
echo "✓ Updated metainfo with version $VERSION and date $RELEASE_DATE"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ Flatpak submission files prepared!"
|
|
||||||
echo ""
|
|
||||||
echo "Next steps:"
|
|
||||||
echo "1. Review the files in the flatpak/ directory"
|
|
||||||
echo "2. Fork https://github.com/flathub/flathub"
|
|
||||||
echo "3. Create a new branch named 'com.karmaa.termix'"
|
|
||||||
echo "4. Copy all files from flatpak/ to the root of your fork"
|
|
||||||
echo "5. Commit and push to your fork"
|
|
||||||
echo "6. Open a PR to flathub/flathub"
|
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
# Homebrew Cask for Termix
|
|
||||||
|
|
||||||
This directory contains the Homebrew Cask formula for installing Termix on macOS.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
- **termix.rb** - Homebrew Cask formula
|
|
||||||
|
|
||||||
## What is a Homebrew Cask?
|
|
||||||
|
|
||||||
Homebrew Casks are used to install GUI macOS applications. Unlike formulae (which are for command-line tools), casks handle:
|
|
||||||
|
|
||||||
- Downloading DMG/PKG installers
|
|
||||||
- Installing .app bundles to /Applications
|
|
||||||
- Managing application preferences and cache cleanup
|
|
||||||
|
|
||||||
## Submission Options
|
|
||||||
|
|
||||||
You have two options for distributing Termix via Homebrew:
|
|
||||||
|
|
||||||
### Option 1: Submit to Official Homebrew Cask (Recommended)
|
|
||||||
|
|
||||||
Submit to the official homebrew-cask repository for maximum visibility.
|
|
||||||
|
|
||||||
**Advantages:**
|
|
||||||
|
|
||||||
- Discoverable by all Homebrew users
|
|
||||||
- Built-in update checking
|
|
||||||
- Official Homebrew support
|
|
||||||
|
|
||||||
**Process:**
|
|
||||||
|
|
||||||
1. Download the `homebrew-submission` artifact from GitHub Actions (when using "submit" option)
|
|
||||||
2. Fork https://github.com/Homebrew/homebrew-cask
|
|
||||||
3. Create a new branch: `git checkout -b termix`
|
|
||||||
4. Add the cask file: `Casks/t/termix.rb` (note the subdirectory by first letter)
|
|
||||||
5. Test locally: `brew install --cask ./Casks/t/termix.rb`
|
|
||||||
6. Run audit: `brew audit --cask --online ./Casks/t/termix.rb`
|
|
||||||
7. Commit and push to your fork
|
|
||||||
8. Create a PR to Homebrew/homebrew-cask
|
|
||||||
|
|
||||||
**Requirements for acceptance:**
|
|
||||||
|
|
||||||
- App must be stable (not beta/alpha)
|
|
||||||
- Source code must be public
|
|
||||||
- No analytics/tracking without opt-in
|
|
||||||
- Pass all brew audit checks
|
|
||||||
|
|
||||||
### Option 2: Create Your Own Tap
|
|
||||||
|
|
||||||
Create a custom Homebrew tap for more control and faster updates.
|
|
||||||
|
|
||||||
**Advantages:**
|
|
||||||
|
|
||||||
- Full control over updates
|
|
||||||
- No approval process
|
|
||||||
- Can include beta/alpha releases
|
|
||||||
|
|
||||||
**Process:**
|
|
||||||
|
|
||||||
1. Create a new repository: `Termix-SSH/homebrew-termix`
|
|
||||||
2. Add the cask file to: `Casks/termix.rb`
|
|
||||||
3. Users install with: `brew install --cask termix-ssh/termix/termix`
|
|
||||||
|
|
||||||
## Installation (for users)
|
|
||||||
|
|
||||||
### From Official Homebrew Cask (after approval):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install --cask termix
|
|
||||||
```
|
|
||||||
|
|
||||||
### From Custom Tap:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Add the tap
|
|
||||||
brew tap termix-ssh/termix
|
|
||||||
|
|
||||||
# Install the cask
|
|
||||||
brew install --cask termix
|
|
||||||
```
|
|
||||||
|
|
||||||
## Updating the Cask
|
|
||||||
|
|
||||||
When you release a new version:
|
|
||||||
|
|
||||||
### For Official Homebrew Cask:
|
|
||||||
|
|
||||||
1. Homebrew bot usually auto-updates within hours
|
|
||||||
2. Or manually submit a PR with the new version/checksum
|
|
||||||
|
|
||||||
### For Custom Tap:
|
|
||||||
|
|
||||||
1. Update the version and sha256 in termix.rb
|
|
||||||
2. Commit and push to your tap repository
|
|
||||||
3. Users run: `brew upgrade --cask termix`
|
|
||||||
|
|
||||||
## Testing Locally
|
|
||||||
|
|
||||||
Before submitting, test the cask:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install from local file
|
|
||||||
brew install --cask ./homebrew/termix.rb
|
|
||||||
|
|
||||||
# Verify it works
|
|
||||||
open /Applications/Termix.app
|
|
||||||
|
|
||||||
# Uninstall
|
|
||||||
brew uninstall --cask termix
|
|
||||||
|
|
||||||
# Run audit checks
|
|
||||||
brew audit --cask --online ./homebrew/termix.rb
|
|
||||||
|
|
||||||
# Style check
|
|
||||||
brew style ./homebrew/termix.rb
|
|
||||||
```
|
|
||||||
|
|
||||||
## Automated Submission Preparation
|
|
||||||
|
|
||||||
The GitHub Actions workflow automatically prepares the Homebrew submission when you select "submit":
|
|
||||||
|
|
||||||
1. Builds macOS universal DMG
|
|
||||||
2. Calculates SHA256 checksum
|
|
||||||
3. Updates the cask file with version and checksum
|
|
||||||
4. Creates a `homebrew-submission` artifact
|
|
||||||
|
|
||||||
Download the artifact and follow the submission instructions included.
|
|
||||||
|
|
||||||
## Cask File Structure
|
|
||||||
|
|
||||||
The cask file (`termix.rb`) includes:
|
|
||||||
|
|
||||||
- **version** - Automatically set from package.json
|
|
||||||
- **sha256** - Checksum of the universal DMG for security
|
|
||||||
- **url** - Download URL from GitHub releases
|
|
||||||
- **name** - Display name
|
|
||||||
- **desc** - Short description
|
|
||||||
- **homepage** - Project homepage
|
|
||||||
- **livecheck** - Automatic update detection
|
|
||||||
- **app** - The .app bundle to install
|
|
||||||
- **zap** - Files to remove on complete uninstall
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- macOS 10.15 (Catalina) or later
|
|
||||||
- Homebrew 4.0.0 or later
|
|
||||||
- Universal DMG must be code-signed and notarized (already handled by your build process)
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- [Homebrew Cask Documentation](https://docs.brew.sh/Cask-Cookbook)
|
|
||||||
- [Cask Submission Guidelines](https://github.com/Homebrew/homebrew-cask/blob/master/CONTRIBUTING.md)
|
|
||||||
- [Homebrew Formula Cookbook](https://docs.brew.sh/Formula-Cookbook)
|
|
||||||
@@ -12,10 +12,8 @@ import type { AuthenticatedRequest } from "../types/index.js";
|
|||||||
const app = express();
|
const app = express();
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
// Track server start time
|
|
||||||
const serverStartTime = Date.now();
|
const serverStartTime = Date.now();
|
||||||
|
|
||||||
// In-memory rate limiter for activity logging
|
|
||||||
const activityRateLimiter = new Map<string, number>();
|
const activityRateLimiter = new Map<string, number>();
|
||||||
const RATE_LIMIT_MS = 1000; // 1 second window
|
const RATE_LIMIT_MS = 1000; // 1 second window
|
||||||
|
|
||||||
@@ -60,7 +58,6 @@ app.use(express.json({ limit: "1mb" }));
|
|||||||
|
|
||||||
app.use(authManager.createAuthMiddleware());
|
app.use(authManager.createAuthMiddleware());
|
||||||
|
|
||||||
// Get server uptime
|
|
||||||
app.get("/uptime", async (req, res) => {
|
app.get("/uptime", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const uptimeMs = Date.now() - serverStartTime;
|
const uptimeMs = Date.now() - serverStartTime;
|
||||||
@@ -80,7 +77,6 @@ app.get("/uptime", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get recent activity for current user
|
|
||||||
app.get("/activity/recent", async (req, res) => {
|
app.get("/activity/recent", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -112,7 +108,6 @@ app.get("/activity/recent", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log new activity
|
|
||||||
app.post("/activity/log", async (req, res) => {
|
app.post("/activity/log", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -138,22 +133,18 @@ app.post("/activity/log", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-memory rate limiting to prevent duplicate requests
|
|
||||||
const rateLimitKey = `${userId}:${hostId}:${type}`;
|
const rateLimitKey = `${userId}:${hostId}:${type}`;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const lastLogged = activityRateLimiter.get(rateLimitKey);
|
const lastLogged = activityRateLimiter.get(rateLimitKey);
|
||||||
|
|
||||||
if (lastLogged && now - lastLogged < RATE_LIMIT_MS) {
|
if (lastLogged && now - lastLogged < RATE_LIMIT_MS) {
|
||||||
// Too soon after last request, reject as duplicate
|
|
||||||
return res.json({
|
return res.json({
|
||||||
message: "Activity already logged recently (rate limited)",
|
message: "Activity already logged recently (rate limited)",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update rate limiter
|
|
||||||
activityRateLimiter.set(rateLimitKey, now);
|
activityRateLimiter.set(rateLimitKey, now);
|
||||||
|
|
||||||
// Clean up old entries from rate limiter (keep it from growing indefinitely)
|
|
||||||
if (activityRateLimiter.size > 10000) {
|
if (activityRateLimiter.size > 10000) {
|
||||||
const entriesToDelete: string[] = [];
|
const entriesToDelete: string[] = [];
|
||||||
for (const [key, timestamp] of activityRateLimiter.entries()) {
|
for (const [key, timestamp] of activityRateLimiter.entries()) {
|
||||||
@@ -164,7 +155,6 @@ app.post("/activity/log", async (req, res) => {
|
|||||||
entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
|
entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the host belongs to the user
|
|
||||||
const hosts = await SimpleDBOps.select(
|
const hosts = await SimpleDBOps.select(
|
||||||
getDb()
|
getDb()
|
||||||
.select()
|
.select()
|
||||||
@@ -178,7 +168,6 @@ app.post("/activity/log", async (req, res) => {
|
|||||||
return res.status(404).json({ error: "Host not found" });
|
return res.status(404).json({ error: "Host not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert new activity
|
|
||||||
const result = (await SimpleDBOps.insert(
|
const result = (await SimpleDBOps.insert(
|
||||||
recentActivity,
|
recentActivity,
|
||||||
"recent_activity",
|
"recent_activity",
|
||||||
@@ -191,7 +180,6 @@ app.post("/activity/log", async (req, res) => {
|
|||||||
userId,
|
userId,
|
||||||
)) as unknown as { id: number };
|
)) as unknown as { id: number };
|
||||||
|
|
||||||
// Keep only the last 100 activities per user to prevent bloat
|
|
||||||
const allActivities = await SimpleDBOps.select(
|
const allActivities = await SimpleDBOps.select(
|
||||||
getDb()
|
getDb()
|
||||||
.select()
|
.select()
|
||||||
@@ -216,7 +204,6 @@ app.post("/activity/log", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset recent activity for current user
|
|
||||||
app.delete("/activity/reset", async (req, res) => {
|
app.delete("/activity/reset", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -228,7 +215,6 @@ app.delete("/activity/reset", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete all activities for the user
|
|
||||||
await SimpleDBOps.delete(
|
await SimpleDBOps.delete(
|
||||||
recentActivity,
|
recentActivity,
|
||||||
"recent_activity",
|
"recent_activity",
|
||||||
|
|||||||
@@ -915,7 +915,6 @@ app.post(
|
|||||||
const isOidcUser = !!userRecords[0].is_oidc;
|
const isOidcUser = !!userRecords[0].is_oidc;
|
||||||
|
|
||||||
if (!isOidcUser) {
|
if (!isOidcUser) {
|
||||||
// Local accounts still prove knowledge of the password so their DEK can be derived again.
|
|
||||||
if (!password) {
|
if (!password) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: "Password required for import",
|
error: "Password required for import",
|
||||||
@@ -928,7 +927,6 @@ app.post(
|
|||||||
return res.status(401).json({ error: "Invalid password" });
|
return res.status(401).json({ error: "Invalid password" });
|
||||||
}
|
}
|
||||||
} else if (!DataCrypto.getUserDataKey(userId)) {
|
} else if (!DataCrypto.getUserDataKey(userId)) {
|
||||||
// OIDC users skip the password prompt; make sure their DEK is unlocked via the OIDC session.
|
|
||||||
const oidcUnlocked = await authManager.authenticateOIDCUser(userId);
|
const oidcUnlocked = await authManager.authenticateOIDCUser(userId);
|
||||||
if (!oidcUnlocked) {
|
if (!oidcUnlocked) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
@@ -947,7 +945,6 @@ app.post(
|
|||||||
|
|
||||||
let userDataKey = DataCrypto.getUserDataKey(userId);
|
let userDataKey = DataCrypto.getUserDataKey(userId);
|
||||||
if (!userDataKey && isOidcUser) {
|
if (!userDataKey && isOidcUser) {
|
||||||
// authenticateOIDCUser lazily provisions the session key; retry the fetch when it succeeds.
|
|
||||||
const oidcUnlocked = await authManager.authenticateOIDCUser(userId);
|
const oidcUnlocked = await authManager.authenticateOIDCUser(userId);
|
||||||
if (oidcUnlocked) {
|
if (oidcUnlocked) {
|
||||||
userDataKey = DataCrypto.getUserDataKey(userId);
|
userDataKey = DataCrypto.getUserDataKey(userId);
|
||||||
@@ -1425,7 +1422,6 @@ app.use(
|
|||||||
err: unknown,
|
err: unknown,
|
||||||
req: express.Request,
|
req: express.Request,
|
||||||
res: express.Response,
|
res: express.Response,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
_next: express.NextFunction,
|
_next: express.NextFunction,
|
||||||
) => {
|
) => {
|
||||||
apiLogger.error("Unhandled error in request", err, {
|
apiLogger.error("Unhandled error in request", err, {
|
||||||
@@ -1482,17 +1478,13 @@ app.get(
|
|||||||
if (status.hasUnencryptedDb) {
|
if (status.hasUnencryptedDb) {
|
||||||
try {
|
try {
|
||||||
unencryptedSize = fs.statSync(dbPath).size;
|
unencryptedSize = fs.statSync(dbPath).size;
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore file access errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.hasEncryptedDb) {
|
if (status.hasEncryptedDb) {
|
||||||
try {
|
try {
|
||||||
encryptedSize = fs.statSync(encryptedDbPath).size;
|
encryptedSize = fs.statSync(encryptedDbPath).size;
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore file access errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
|||||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||||
const dbDir = path.resolve(dataDir);
|
const dbDir = path.resolve(dataDir);
|
||||||
if (!fs.existsSync(dbDir)) {
|
if (!fs.existsSync(dbDir)) {
|
||||||
databaseLogger.info(`Creating database directory`, {
|
|
||||||
operation: "db_init",
|
|
||||||
path: dbDir,
|
|
||||||
});
|
|
||||||
fs.mkdirSync(dbDir, { recursive: true });
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +27,6 @@ let sqlite: Database.Database;
|
|||||||
async function initializeDatabaseAsync(): Promise<void> {
|
async function initializeDatabaseAsync(): Promise<void> {
|
||||||
const systemCrypto = SystemCrypto.getInstance();
|
const systemCrypto = SystemCrypto.getInstance();
|
||||||
|
|
||||||
// Ensure database key is initialized
|
|
||||||
await systemCrypto.getDatabaseKey();
|
await systemCrypto.getDatabaseKey();
|
||||||
if (enableFileEncryption) {
|
if (enableFileEncryption) {
|
||||||
try {
|
try {
|
||||||
@@ -41,18 +36,11 @@ async function initializeDatabaseAsync(): Promise<void> {
|
|||||||
|
|
||||||
memoryDatabase = new Database(decryptedBuffer);
|
memoryDatabase = new Database(decryptedBuffer);
|
||||||
|
|
||||||
// Count sessions after loading
|
|
||||||
try {
|
try {
|
||||||
const sessionCount = memoryDatabase
|
const sessionCount = memoryDatabase
|
||||||
.prepare("SELECT COUNT(*) as count FROM sessions")
|
.prepare("SELECT COUNT(*) as count FROM sessions")
|
||||||
.get() as { count: number };
|
.get() as { count: number };
|
||||||
databaseLogger.info("Database loaded from encrypted file", {
|
|
||||||
operation: "db_load",
|
|
||||||
sessionCount: sessionCount.count,
|
|
||||||
bufferSize: decryptedBuffer.length,
|
|
||||||
});
|
|
||||||
} catch (countError) {
|
} catch (countError) {
|
||||||
// Ignore count errors
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const migration = new DatabaseMigration(dataDir);
|
const migration = new DatabaseMigration(dataDir);
|
||||||
@@ -297,9 +285,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
sqlite.prepare("DELETE FROM sessions").run();
|
sqlite.prepare("DELETE FROM sessions").run();
|
||||||
databaseLogger.info("All sessions cleared on startup", {
|
|
||||||
operation: "db_init_session_cleanup",
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
databaseLogger.warn("Could not clear sessions on startup", {
|
databaseLogger.warn("Could not clear sessions on startup", {
|
||||||
operation: "db_init_session_cleanup_failed",
|
operation: "db_init_session_cleanup_failed",
|
||||||
@@ -453,7 +438,6 @@ const migrateSchema = () => {
|
|||||||
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
||||||
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
|
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
|
||||||
|
|
||||||
// Create sessions table if it doesn't exist (for existing databases)
|
|
||||||
try {
|
try {
|
||||||
sqlite
|
sqlite
|
||||||
.prepare("SELECT id FROM sessions LIMIT 1")
|
.prepare("SELECT id FROM sessions LIMIT 1")
|
||||||
@@ -473,9 +457,6 @@ const migrateSchema = () => {
|
|||||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
databaseLogger.info("Sessions table created via migration", {
|
|
||||||
operation: "schema_migration",
|
|
||||||
});
|
|
||||||
} catch (createError) {
|
} catch (createError) {
|
||||||
databaseLogger.warn("Failed to create sessions table", {
|
databaseLogger.warn("Failed to create sessions table", {
|
||||||
operation: "schema_migration",
|
operation: "schema_migration",
|
||||||
@@ -499,18 +480,11 @@ async function saveMemoryDatabaseToFile() {
|
|||||||
fs.mkdirSync(dataDir, { recursive: true });
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count sessions before saving
|
|
||||||
try {
|
try {
|
||||||
const sessionCount = memoryDatabase
|
const sessionCount = memoryDatabase
|
||||||
.prepare("SELECT COUNT(*) as count FROM sessions")
|
.prepare("SELECT COUNT(*) as count FROM sessions")
|
||||||
.get() as { count: number };
|
.get() as { count: number };
|
||||||
databaseLogger.info("Saving database to file", {
|
|
||||||
operation: "db_save",
|
|
||||||
sessionCount: sessionCount.count,
|
|
||||||
bufferSize: buffer.length,
|
|
||||||
});
|
|
||||||
} catch (countError) {
|
} catch (countError) {
|
||||||
// Ignore count errors
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enableFileEncryption) {
|
if (enableFileEncryption) {
|
||||||
@@ -605,18 +579,15 @@ async function cleanupDatabase() {
|
|||||||
try {
|
try {
|
||||||
fs.unlinkSync(path.join(tempDir, file));
|
fs.unlinkSync(path.join(tempDir, file));
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.rmdirSync(tempDir);
|
fs.rmdirSync(tempDir);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,7 +596,6 @@ process.on("exit", () => {
|
|||||||
try {
|
try {
|
||||||
sqlite.close();
|
sqlite.close();
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore close errors on exit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -336,14 +336,10 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => {
|
|||||||
userId,
|
userId,
|
||||||
adminDataKey,
|
adminDataKey,
|
||||||
);
|
);
|
||||||
authLogger.info("OIDC configuration encrypted with admin data key", {
|
|
||||||
operation: "oidc_config_encrypt",
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
encryptedConfig = {
|
encryptedConfig = {
|
||||||
...config,
|
...config,
|
||||||
client_secret: `encrypted:${Buffer.from(client_secret).toString("base64")}`, // Simple base64 encoding
|
client_secret: `encrypted:${Buffer.from(client_secret).toString("base64")}`,
|
||||||
};
|
};
|
||||||
authLogger.warn(
|
authLogger.warn(
|
||||||
"OIDC configuration stored with basic encoding - admin should re-save with password",
|
"OIDC configuration stored with basic encoding - admin should re-save with password",
|
||||||
@@ -421,7 +417,6 @@ router.get("/oidc-config", async (req, res) => {
|
|||||||
|
|
||||||
const config = JSON.parse((row as Record<string, unknown>).value as string);
|
const config = JSON.parse((row as Record<string, unknown>).value as string);
|
||||||
|
|
||||||
// Only return public fields needed for login page
|
|
||||||
const publicConfig = {
|
const publicConfig = {
|
||||||
client_id: config.client_id,
|
client_id: config.client_id,
|
||||||
issuer_url: config.issuer_url,
|
issuer_url: config.issuer_url,
|
||||||
@@ -661,7 +656,6 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
config.client_id,
|
config.client_id,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback to manual decoding
|
|
||||||
try {
|
try {
|
||||||
const parts = (tokenData.id_token as string).split(".");
|
const parts = (tokenData.id_token as string).split(".");
|
||||||
if (parts.length === 3) {
|
if (parts.length === 3) {
|
||||||
@@ -812,7 +806,6 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect platform and device info
|
|
||||||
const deviceInfo = parseUserAgent(req);
|
const deviceInfo = parseUserAgent(req);
|
||||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||||
deviceType: deviceInfo.type,
|
deviceType: deviceInfo.type,
|
||||||
@@ -838,7 +831,6 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
const redirectUrl = new URL(frontendUrl);
|
const redirectUrl = new URL(frontendUrl);
|
||||||
redirectUrl.searchParams.set("success", "true");
|
redirectUrl.searchParams.set("success", "true");
|
||||||
|
|
||||||
// Calculate max age based on device type
|
|
||||||
const maxAge =
|
const maxAge =
|
||||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||||
? 30 * 24 * 60 * 60 * 1000
|
? 30 * 24 * 60 * 60 * 1000
|
||||||
@@ -965,7 +957,6 @@ router.post("/login", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect platform and device info
|
|
||||||
const deviceInfo = parseUserAgent(req);
|
const deviceInfo = parseUserAgent(req);
|
||||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||||
deviceType: deviceInfo.type,
|
deviceType: deviceInfo.type,
|
||||||
@@ -995,7 +986,6 @@ router.post("/login", async (req, res) => {
|
|||||||
response.token = token;
|
response.token = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate max age based on device type
|
|
||||||
const maxAge =
|
const maxAge =
|
||||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||||
? 30 * 24 * 60 * 60 * 1000
|
? 30 * 24 * 60 * 60 * 1000
|
||||||
@@ -1018,7 +1008,6 @@ router.post("/logout", authenticateJWT, async (req, res) => {
|
|||||||
const userId = authReq.userId;
|
const userId = authReq.userId;
|
||||||
|
|
||||||
if (userId) {
|
if (userId) {
|
||||||
// Get sessionId from JWT if available
|
|
||||||
const token =
|
const token =
|
||||||
req.cookies?.jwt || req.headers["authorization"]?.split(" ")[1];
|
req.cookies?.jwt || req.headers["authorization"]?.split(" ")[1];
|
||||||
let sessionId: string | undefined;
|
let sessionId: string | undefined;
|
||||||
@@ -1027,9 +1016,7 @@ router.post("/logout", authenticateJWT, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const payload = await authManager.verifyJWTToken(token);
|
const payload = await authManager.verifyJWTToken(token);
|
||||||
sessionId = payload?.sessionId;
|
sessionId = payload?.sessionId;
|
||||||
} catch (error) {
|
} catch (error) {}
|
||||||
// Ignore token verification errors during logout
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await authManager.logoutUser(userId, sessionId);
|
await authManager.logoutUser(userId, sessionId);
|
||||||
@@ -1435,7 +1422,6 @@ router.post("/complete-reset", async (req, res) => {
|
|||||||
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
||||||
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
||||||
|
|
||||||
// Check if user is logged in and data is unlocked
|
|
||||||
let userIdFromJwt: string | null = null;
|
let userIdFromJwt: string | null = null;
|
||||||
const cookie = req.cookies?.jwt;
|
const cookie = req.cookies?.jwt;
|
||||||
let header: string | undefined;
|
let header: string | undefined;
|
||||||
@@ -1452,7 +1438,6 @@ router.post("/complete-reset", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (userIdFromJwt === userId) {
|
if (userIdFromJwt === userId) {
|
||||||
// Logged-in user: preserve data
|
|
||||||
try {
|
try {
|
||||||
const success = await authManager.resetUserPasswordWithPreservedDEK(
|
const success = await authManager.resetUserPasswordWithPreservedDEK(
|
||||||
userId,
|
userId,
|
||||||
@@ -1491,15 +1476,12 @@ router.post("/complete-reset", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Logged-out user: data is lost
|
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ password_hash })
|
.set({ password_hash })
|
||||||
.where(eq(users.username, username));
|
.where(eq(users.username, username));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Delete all encrypted data since we're creating a new DEK
|
|
||||||
// The old DEK is lost, so old encrypted data becomes unreadable
|
|
||||||
await db
|
await db
|
||||||
.delete(sshCredentialUsage)
|
.delete(sshCredentialUsage)
|
||||||
.where(eq(sshCredentialUsage.userId, userId));
|
.where(eq(sshCredentialUsage.userId, userId));
|
||||||
@@ -1524,11 +1506,9 @@ router.post("/complete-reset", async (req, res) => {
|
|||||||
.delete(sshCredentials)
|
.delete(sshCredentials)
|
||||||
.where(eq(sshCredentials.userId, userId));
|
.where(eq(sshCredentials.userId, userId));
|
||||||
|
|
||||||
// Now setup new encryption with new DEK
|
|
||||||
await authManager.registerUser(userId, newPassword);
|
await authManager.registerUser(userId, newPassword);
|
||||||
authManager.logoutUser(userId);
|
authManager.logoutUser(userId);
|
||||||
|
|
||||||
// Clear TOTP settings
|
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({
|
.set({
|
||||||
@@ -1597,13 +1577,11 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
|
|||||||
return res.status(404).json({ error: "User not found" });
|
return res.status(404).json({ error: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify old password for login hash
|
|
||||||
const isMatch = await bcrypt.compare(oldPassword, user[0].password_hash);
|
const isMatch = await bcrypt.compare(oldPassword, user[0].password_hash);
|
||||||
if (!isMatch) {
|
if (!isMatch) {
|
||||||
return res.status(401).json({ error: "Incorrect current password" });
|
return res.status(401).json({ error: "Incorrect current password" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Change encryption keys and login hash
|
|
||||||
const success = await authManager.changeUserPassword(
|
const success = await authManager.changeUserPassword(
|
||||||
userId,
|
userId,
|
||||||
oldPassword,
|
oldPassword,
|
||||||
@@ -1619,7 +1597,7 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
|
|||||||
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
||||||
await db.update(users).set({ password_hash }).where(eq(users.id, userId));
|
await db.update(users).set({ password_hash }).where(eq(users.id, userId));
|
||||||
|
|
||||||
authManager.logoutUser(userId); // Log out user for security
|
authManager.logoutUser(userId);
|
||||||
|
|
||||||
res.json({ message: "Password changed successfully. Please log in again." });
|
res.json({ message: "Password changed successfully. Please log in again." });
|
||||||
});
|
});
|
||||||
@@ -1836,7 +1814,6 @@ router.post("/totp/verify-login", async (req, res) => {
|
|||||||
.where(eq(users.id, userRecord.id));
|
.where(eq(users.id, userRecord.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect platform and device info
|
|
||||||
const deviceInfo = parseUserAgent(req);
|
const deviceInfo = parseUserAgent(req);
|
||||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||||
deviceType: deviceInfo.type,
|
deviceType: deviceInfo.type,
|
||||||
@@ -1867,7 +1844,6 @@ router.post("/totp/verify-login", async (req, res) => {
|
|||||||
response.token = token;
|
response.token = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate max age based on device type
|
|
||||||
const maxAge =
|
const maxAge =
|
||||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||||
? 30 * 24 * 60 * 60 * 1000
|
? 30 * 24 * 60 * 60 * 1000
|
||||||
@@ -2230,7 +2206,6 @@ router.get("/data-status", authenticateJWT, async (req, res) => {
|
|||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Data lock functionality has been removed - always return unlocked for authenticated users
|
|
||||||
res.json({
|
res.json({
|
||||||
unlocked: true,
|
unlocked: true,
|
||||||
message: "Data is unlocked",
|
message: "Data is unlocked",
|
||||||
@@ -2320,10 +2295,8 @@ router.get("/sessions", authenticateJWT, async (req, res) => {
|
|||||||
let sessionList;
|
let sessionList;
|
||||||
|
|
||||||
if (userRecord.is_admin) {
|
if (userRecord.is_admin) {
|
||||||
// Admin: Get all sessions with user info
|
|
||||||
sessionList = await authManager.getAllSessions();
|
sessionList = await authManager.getAllSessions();
|
||||||
|
|
||||||
// Join with users to get usernames
|
|
||||||
const enrichedSessions = await Promise.all(
|
const enrichedSessions = await Promise.all(
|
||||||
sessionList.map(async (session) => {
|
sessionList.map(async (session) => {
|
||||||
const sessionUser = await db
|
const sessionUser = await db
|
||||||
@@ -2341,7 +2314,6 @@ router.get("/sessions", authenticateJWT, async (req, res) => {
|
|||||||
|
|
||||||
return res.json({ sessions: enrichedSessions });
|
return res.json({ sessions: enrichedSessions });
|
||||||
} else {
|
} else {
|
||||||
// Regular user: Get only their own sessions
|
|
||||||
sessionList = await authManager.getUserSessions(userId);
|
sessionList = await authManager.getUserSessions(userId);
|
||||||
return res.json({ sessions: sessionList });
|
return res.json({ sessions: sessionList });
|
||||||
}
|
}
|
||||||
@@ -2369,7 +2341,6 @@ router.delete("/sessions/:sessionId", authenticateJWT, async (req, res) => {
|
|||||||
|
|
||||||
const userRecord = user[0];
|
const userRecord = user[0];
|
||||||
|
|
||||||
// Check if session exists
|
|
||||||
const sessionRecords = await db
|
const sessionRecords = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sessions)
|
.from(sessions)
|
||||||
@@ -2382,7 +2353,6 @@ router.delete("/sessions/:sessionId", authenticateJWT, async (req, res) => {
|
|||||||
|
|
||||||
const session = sessionRecords[0];
|
const session = sessionRecords[0];
|
||||||
|
|
||||||
// Non-admin users can only revoke their own sessions
|
|
||||||
if (!userRecord.is_admin && session.userId !== userId) {
|
if (!userRecord.is_admin && session.userId !== userId) {
|
||||||
return res
|
return res
|
||||||
.status(403)
|
.status(403)
|
||||||
@@ -2421,19 +2391,15 @@ router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => {
|
|||||||
|
|
||||||
const userRecord = user[0];
|
const userRecord = user[0];
|
||||||
|
|
||||||
// Determine which user's sessions to revoke
|
|
||||||
let revokeUserId = userId;
|
let revokeUserId = userId;
|
||||||
if (targetUserId && userRecord.is_admin) {
|
if (targetUserId && userRecord.is_admin) {
|
||||||
// Admin can revoke any user's sessions
|
|
||||||
revokeUserId = targetUserId;
|
revokeUserId = targetUserId;
|
||||||
} else if (targetUserId && targetUserId !== userId) {
|
} else if (targetUserId && targetUserId !== userId) {
|
||||||
// Non-admin can only revoke their own sessions
|
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: "Not authorized to revoke sessions for other users",
|
error: "Not authorized to revoke sessions for other users",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current session ID if needed
|
|
||||||
let currentSessionId: string | undefined;
|
let currentSessionId: string | undefined;
|
||||||
if (exceptCurrent) {
|
if (exceptCurrent) {
|
||||||
const token =
|
const token =
|
||||||
|
|||||||
@@ -120,9 +120,7 @@ function cleanupSession(sessionId: string) {
|
|||||||
if (session) {
|
if (session) {
|
||||||
try {
|
try {
|
||||||
session.client.end();
|
session.client.end();
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore connection close errors
|
|
||||||
}
|
|
||||||
clearTimeout(session.timeout);
|
clearTimeout(session.timeout);
|
||||||
delete sshSessions[sessionId];
|
delete sshSessions[sessionId];
|
||||||
}
|
}
|
||||||
@@ -352,8 +350,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
}
|
}
|
||||||
config.password = resolvedCredentials.password;
|
config.password = resolvedCredentials.password;
|
||||||
} else if (resolvedCredentials.authType === "none") {
|
} else if (resolvedCredentials.authType === "none") {
|
||||||
// Use authHandler to control authentication flow
|
|
||||||
// This ensures we only try keyboard-interactive, not password auth
|
|
||||||
config.authHandler = (
|
config.authHandler = (
|
||||||
methodsLeft: string[] | null,
|
methodsLeft: string[] | null,
|
||||||
partialSuccess: boolean,
|
partialSuccess: boolean,
|
||||||
@@ -409,7 +405,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
scheduleSessionCleanup(sessionId);
|
scheduleSessionCleanup(sessionId);
|
||||||
res.json({ status: "success", message: "SSH connection established" });
|
res.json({ status: "success", message: "SSH connection established" });
|
||||||
|
|
||||||
// Log activity to dashboard API
|
|
||||||
if (hostId && userId) {
|
if (hostId && userId) {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -458,14 +453,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
responseSent = true;
|
responseSent = true;
|
||||||
|
|
||||||
if (authMethodNotAvailable && resolvedCredentials.authType === "none") {
|
if (authMethodNotAvailable && resolvedCredentials.authType === "none") {
|
||||||
fileLogger.info(
|
|
||||||
"Keyboard-interactive not available, requesting credentials",
|
|
||||||
{
|
|
||||||
operation: "file_connect_auth_not_available",
|
|
||||||
sessionId,
|
|
||||||
hostId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
status: "auth_required",
|
status: "auth_required",
|
||||||
message:
|
message:
|
||||||
@@ -557,51 +544,26 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
prompt: prompts[totpPromptIndex].prompt,
|
prompt: prompts[totpPromptIndex].prompt,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Non-TOTP prompts (password, etc.)
|
|
||||||
const hasStoredPassword =
|
const hasStoredPassword =
|
||||||
resolvedCredentials.password &&
|
resolvedCredentials.password &&
|
||||||
resolvedCredentials.authType !== "none";
|
resolvedCredentials.authType !== "none";
|
||||||
|
|
||||||
// Check if this is a password prompt
|
|
||||||
const passwordPromptIndex = prompts.findIndex((p) =>
|
const passwordPromptIndex = prompts.findIndex((p) =>
|
||||||
/password/i.test(p.prompt),
|
/password/i.test(p.prompt),
|
||||||
);
|
);
|
||||||
|
|
||||||
// If no stored password (including authType "none"), prompt the user
|
|
||||||
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
||||||
if (responseSent) {
|
if (responseSent) {
|
||||||
// Connection is already being handled, don't send duplicate responses
|
|
||||||
fileLogger.info(
|
|
||||||
"Skipping duplicate password prompt - response already sent",
|
|
||||||
{
|
|
||||||
operation: "keyboard_interactive_skip",
|
|
||||||
hostId,
|
|
||||||
sessionId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
responseSent = true;
|
responseSent = true;
|
||||||
|
|
||||||
if (pendingTOTPSessions[sessionId]) {
|
if (pendingTOTPSessions[sessionId]) {
|
||||||
// Session already waiting for TOTP, don't override
|
|
||||||
fileLogger.info("Skipping password prompt - TOTP session pending", {
|
|
||||||
operation: "keyboard_interactive_skip",
|
|
||||||
hostId,
|
|
||||||
sessionId,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
keyboardInteractiveResponded = true;
|
keyboardInteractiveResponded = true;
|
||||||
|
|
||||||
fileLogger.info("Requesting password from user (authType: none)", {
|
|
||||||
operation: "keyboard_interactive_password",
|
|
||||||
hostId,
|
|
||||||
sessionId,
|
|
||||||
prompt: prompts[passwordPromptIndex].prompt,
|
|
||||||
});
|
|
||||||
|
|
||||||
pendingTOTPSessions[sessionId] = {
|
pendingTOTPSessions[sessionId] = {
|
||||||
client,
|
client,
|
||||||
finish,
|
finish,
|
||||||
@@ -627,7 +589,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-respond with stored credentials if available
|
|
||||||
const responses = prompts.map((p) => {
|
const responses = prompts.map((p) => {
|
||||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||||
return resolvedCredentials.password;
|
return resolvedCredentials.password;
|
||||||
@@ -679,9 +640,7 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
|||||||
delete pendingTOTPSessions[sessionId];
|
delete pendingTOTPSessions[sessionId];
|
||||||
try {
|
try {
|
||||||
session.client.end();
|
session.client.end();
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore errors when closing timed out session
|
|
||||||
}
|
|
||||||
fileLogger.warn("TOTP session timeout before code submission", {
|
fileLogger.warn("TOTP session timeout before code submission", {
|
||||||
operation: "file_totp_verify",
|
operation: "file_totp_verify",
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -693,7 +652,6 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
|||||||
.json({ error: "TOTP session timeout. Please reconnect." });
|
.json({ error: "TOTP session timeout. Please reconnect." });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build responses for ALL prompts, just like in terminal.ts
|
|
||||||
const responses = (session.prompts || []).map((p, index) => {
|
const responses = (session.prompts || []).map((p, index) => {
|
||||||
if (index === session.totpPromptIndex) {
|
if (index === session.totpPromptIndex) {
|
||||||
return totpCode;
|
return totpCode;
|
||||||
@@ -704,22 +662,9 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
|||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
|
|
||||||
fileLogger.info("Full keyboard-interactive response for file manager", {
|
|
||||||
operation: "file_totp_full_response",
|
|
||||||
sessionId,
|
|
||||||
userId,
|
|
||||||
totalPrompts: session.prompts?.length || 0,
|
|
||||||
responsesProvided: responses.filter((r) => r !== "").length,
|
|
||||||
});
|
|
||||||
|
|
||||||
let responseSent = false;
|
let responseSent = false;
|
||||||
let responseTimeout: NodeJS.Timeout;
|
let responseTimeout: NodeJS.Timeout;
|
||||||
|
|
||||||
// Don't remove event listeners - just add our own 'once' handlers
|
|
||||||
// The ssh2 library manages multiple listeners correctly
|
|
||||||
// Removing them can cause the connection to become unstable
|
|
||||||
|
|
||||||
// CRITICAL: Attach event listeners BEFORE calling finish() to avoid race condition
|
|
||||||
session.client.once("ready", () => {
|
session.client.once("ready", () => {
|
||||||
if (responseSent) return;
|
if (responseSent) return;
|
||||||
responseSent = true;
|
responseSent = true;
|
||||||
@@ -727,8 +672,6 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
|||||||
|
|
||||||
delete pendingTOTPSessions[sessionId];
|
delete pendingTOTPSessions[sessionId];
|
||||||
|
|
||||||
// Add a small delay to let SSH2 stabilize the connection after keyboard-interactive
|
|
||||||
// This prevents "Not connected" errors when immediately trying to exec commands
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
sshSessions[sessionId] = {
|
sshSessions[sessionId] = {
|
||||||
client: session.client,
|
client: session.client,
|
||||||
@@ -742,7 +685,6 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
|||||||
message: "TOTP verified, SSH connection established",
|
message: "TOTP verified, SSH connection established",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log activity to dashboard API after connection is stable
|
|
||||||
if (session.hostId && session.userId) {
|
if (session.hostId && session.userId) {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -789,7 +731,7 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
}, 200); // Give SSH2 connection 200ms to fully stabilize after keyboard-interactive
|
}, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
session.client.once("error", (err) => {
|
session.client.once("error", (err) => {
|
||||||
@@ -822,7 +764,6 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
|||||||
}
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
// Now that event listeners are attached, submit the TOTP response
|
|
||||||
session.finish(responses);
|
session.finish(responses);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2493,15 +2434,6 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
|||||||
: code;
|
: code;
|
||||||
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim();
|
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim();
|
||||||
|
|
||||||
fileLogger.info("File execution completed", {
|
|
||||||
operation: "execute_file",
|
|
||||||
sessionId,
|
|
||||||
filePath,
|
|
||||||
exitCode: actualExitCode,
|
|
||||||
outputLength: cleanOutput.length,
|
|
||||||
errorLength: errorOutput.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
exitCode: actualExitCode,
|
exitCode: actualExitCode,
|
||||||
|
|||||||
@@ -112,8 +112,6 @@ class SSHConnectionPool {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (totpPrompt) {
|
if (totpPrompt) {
|
||||||
// Record TOTP failure as permanent - never retry
|
|
||||||
// The recordFailure method will log this once
|
|
||||||
authFailureTracker.recordFailure(host.id, "TOTP", true);
|
authFailureTracker.recordFailure(host.id, "TOTP", true);
|
||||||
client.end();
|
client.end();
|
||||||
reject(
|
reject(
|
||||||
@@ -158,9 +156,7 @@ class SSHConnectionPool {
|
|||||||
if (!conn.inUse && now - conn.lastUsed > maxAge) {
|
if (!conn.inUse && now - conn.lastUsed > maxAge) {
|
||||||
try {
|
try {
|
||||||
conn.client.end();
|
conn.client.end();
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore errors when closing stale connections
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -180,9 +176,7 @@ class SSHConnectionPool {
|
|||||||
for (const conn of connections) {
|
for (const conn of connections) {
|
||||||
try {
|
try {
|
||||||
conn.client.end();
|
conn.client.end();
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore errors when closing connections during cleanup
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.connections.clear();
|
this.connections.clear();
|
||||||
@@ -220,9 +214,7 @@ class RequestQueue {
|
|||||||
if (request) {
|
if (request) {
|
||||||
try {
|
try {
|
||||||
await request();
|
await request();
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore errors from queued requests
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,13 +264,13 @@ interface AuthFailureRecord {
|
|||||||
count: number;
|
count: number;
|
||||||
lastFailure: number;
|
lastFailure: number;
|
||||||
reason: "TOTP" | "AUTH" | "TIMEOUT";
|
reason: "TOTP" | "AUTH" | "TIMEOUT";
|
||||||
permanent: boolean; // If true, don't retry at all
|
permanent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuthFailureTracker {
|
class AuthFailureTracker {
|
||||||
private failures = new Map<number, AuthFailureRecord>();
|
private failures = new Map<number, AuthFailureRecord>();
|
||||||
private maxRetries = 3;
|
private maxRetries = 3;
|
||||||
private backoffBase = 60000; // 1 minute base backoff
|
private backoffBase = 60000;
|
||||||
|
|
||||||
recordFailure(
|
recordFailure(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
@@ -305,17 +297,14 @@ class AuthFailureTracker {
|
|||||||
const record = this.failures.get(hostId);
|
const record = this.failures.get(hostId);
|
||||||
if (!record) return false;
|
if (!record) return false;
|
||||||
|
|
||||||
// Always skip TOTP hosts
|
|
||||||
if (record.reason === "TOTP" || record.permanent) {
|
if (record.reason === "TOTP" || record.permanent) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if we've exceeded max retries
|
|
||||||
if (record.count >= this.maxRetries) {
|
if (record.count >= this.maxRetries) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate exponential backoff
|
|
||||||
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
|
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
|
||||||
const timeSinceFailure = Date.now() - record.lastFailure;
|
const timeSinceFailure = Date.now() - record.lastFailure;
|
||||||
|
|
||||||
@@ -351,11 +340,9 @@ class AuthFailureTracker {
|
|||||||
|
|
||||||
reset(hostId: number): void {
|
reset(hostId: number): void {
|
||||||
this.failures.delete(hostId);
|
this.failures.delete(hostId);
|
||||||
// Don't log reset - it's not important
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup(): void {
|
cleanup(): void {
|
||||||
// Clean up old failures (older than 1 hour)
|
|
||||||
const maxAge = 60 * 60 * 1000;
|
const maxAge = 60 * 60 * 1000;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@@ -459,7 +446,6 @@ class PollingManager {
|
|||||||
const statsConfig = this.parseStatsConfig(host.statsConfig);
|
const statsConfig = this.parseStatsConfig(host.statsConfig);
|
||||||
const existingConfig = this.pollingConfigs.get(host.id);
|
const existingConfig = this.pollingConfigs.get(host.id);
|
||||||
|
|
||||||
// Clear existing timers if they exist
|
|
||||||
if (existingConfig) {
|
if (existingConfig) {
|
||||||
if (existingConfig.statusTimer) {
|
if (existingConfig.statusTimer) {
|
||||||
clearInterval(existingConfig.statusTimer);
|
clearInterval(existingConfig.statusTimer);
|
||||||
@@ -474,35 +460,27 @@ class PollingManager {
|
|||||||
statsConfig,
|
statsConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start status polling if enabled
|
|
||||||
if (statsConfig.statusCheckEnabled) {
|
if (statsConfig.statusCheckEnabled) {
|
||||||
const intervalMs = statsConfig.statusCheckInterval * 1000;
|
const intervalMs = statsConfig.statusCheckInterval * 1000;
|
||||||
|
|
||||||
// Poll immediately (don't await - let it run in background)
|
|
||||||
this.pollHostStatus(host);
|
this.pollHostStatus(host);
|
||||||
|
|
||||||
// Then set up interval to poll periodically
|
|
||||||
config.statusTimer = setInterval(() => {
|
config.statusTimer = setInterval(() => {
|
||||||
this.pollHostStatus(host);
|
this.pollHostStatus(host);
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
} else {
|
} else {
|
||||||
// Remove status if monitoring is disabled
|
|
||||||
this.statusStore.delete(host.id);
|
this.statusStore.delete(host.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start metrics polling if enabled
|
|
||||||
if (statsConfig.metricsEnabled) {
|
if (statsConfig.metricsEnabled) {
|
||||||
const intervalMs = statsConfig.metricsInterval * 1000;
|
const intervalMs = statsConfig.metricsInterval * 1000;
|
||||||
|
|
||||||
// Poll immediately (don't await - let it run in background)
|
|
||||||
this.pollHostMetrics(host);
|
this.pollHostMetrics(host);
|
||||||
|
|
||||||
// Then set up interval to poll periodically
|
|
||||||
config.metricsTimer = setInterval(() => {
|
config.metricsTimer = setInterval(() => {
|
||||||
this.pollHostMetrics(host);
|
this.pollHostMetrics(host);
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
} else {
|
} else {
|
||||||
// Remove metrics if monitoring is disabled
|
|
||||||
this.metricsStore.delete(host.id);
|
this.metricsStore.delete(host.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,12 +554,10 @@ class PollingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refreshHostPolling(userId: string): Promise<void> {
|
async refreshHostPolling(userId: string): Promise<void> {
|
||||||
// Stop all current polling
|
|
||||||
for (const hostId of this.pollingConfigs.keys()) {
|
for (const hostId of this.pollingConfigs.keys()) {
|
||||||
this.stopPollingForHost(hostId);
|
this.stopPollingForHost(hostId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reinitialize
|
|
||||||
await this.initializePolling(userId);
|
await this.initializePolling(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1019,10 +995,8 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
os: string | null;
|
os: string | null;
|
||||||
};
|
};
|
||||||
}> {
|
}> {
|
||||||
// Check if we should skip this host due to auth failures
|
|
||||||
if (authFailureTracker.shouldSkip(host.id)) {
|
if (authFailureTracker.shouldSkip(host.id)) {
|
||||||
const reason = authFailureTracker.getSkipReason(host.id);
|
const reason = authFailureTracker.getSkipReason(host.id);
|
||||||
// Don't log - just skip silently to avoid spam
|
|
||||||
throw new Error(reason || "Authentication failed");
|
throw new Error(reason || "Authentication failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1166,7 +1140,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
availableHuman = null;
|
availableHuman = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect network interfaces
|
|
||||||
const interfaces: Array<{
|
const interfaces: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
@@ -1225,7 +1198,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
// Collect uptime
|
|
||||||
let uptimeSeconds: number | null = null;
|
let uptimeSeconds: number | null = null;
|
||||||
let uptimeFormatted: string | null = null;
|
let uptimeFormatted: string | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -1242,7 +1214,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
// Collect process information
|
|
||||||
let totalProcesses: number | null = null;
|
let totalProcesses: number | null = null;
|
||||||
let runningProcesses: number | null = null;
|
let runningProcesses: number | null = null;
|
||||||
const topProcesses: Array<{
|
const topProcesses: Array<{
|
||||||
@@ -1285,7 +1256,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
runningProcesses = Number(runningCount.stdout.trim());
|
runningProcesses = Number(runningCount.stdout.trim());
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
// Collect system information
|
|
||||||
let hostname: string | null = null;
|
let hostname: string | null = null;
|
||||||
let kernel: string | null = null;
|
let kernel: string | null = null;
|
||||||
let os: string | null = null;
|
let os: string | null = null;
|
||||||
@@ -1338,25 +1308,20 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Record authentication failures for backoff
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
if (error.message.includes("TOTP authentication required")) {
|
if (error.message.includes("TOTP authentication required")) {
|
||||||
// TOTP failures are already recorded in keyboard-interactive handler
|
|
||||||
throw error;
|
throw error;
|
||||||
} else if (
|
} else if (
|
||||||
error.message.includes("No password available") ||
|
error.message.includes("No password available") ||
|
||||||
error.message.includes("Unsupported authentication type") ||
|
error.message.includes("Unsupported authentication type") ||
|
||||||
error.message.includes("No SSH key available")
|
error.message.includes("No SSH key available")
|
||||||
) {
|
) {
|
||||||
// Configuration errors - permanent failures, don't retry
|
|
||||||
// recordFailure will log once when first detected
|
|
||||||
authFailureTracker.recordFailure(host.id, "AUTH", true);
|
authFailureTracker.recordFailure(host.id, "AUTH", true);
|
||||||
} else if (
|
} else if (
|
||||||
error.message.includes("authentication") ||
|
error.message.includes("authentication") ||
|
||||||
error.message.includes("Permission denied") ||
|
error.message.includes("Permission denied") ||
|
||||||
error.message.includes("All configured authentication methods failed")
|
error.message.includes("All configured authentication methods failed")
|
||||||
) {
|
) {
|
||||||
// recordFailure will log once when first detected
|
|
||||||
authFailureTracker.recordFailure(host.id, "AUTH");
|
authFailureTracker.recordFailure(host.id, "AUTH");
|
||||||
} else if (
|
} else if (
|
||||||
error.message.includes("timeout") ||
|
error.message.includes("timeout") ||
|
||||||
@@ -1384,9 +1349,7 @@ function tcpPing(
|
|||||||
settled = true;
|
settled = true;
|
||||||
try {
|
try {
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore errors when destroying socket
|
|
||||||
}
|
|
||||||
resolve(result);
|
resolve(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1409,7 +1372,6 @@ app.get("/status", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize polling if no hosts are being polled yet
|
|
||||||
const statuses = pollingManager.getAllStatuses();
|
const statuses = pollingManager.getAllStatuses();
|
||||||
if (statuses.size === 0) {
|
if (statuses.size === 0) {
|
||||||
await pollingManager.initializePolling(userId);
|
await pollingManager.initializePolling(userId);
|
||||||
@@ -1433,7 +1395,6 @@ app.get("/status/:id", validateHostId, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize polling if no hosts are being polled yet
|
|
||||||
const statuses = pollingManager.getAllStatuses();
|
const statuses = pollingManager.getAllStatuses();
|
||||||
if (statuses.size === 0) {
|
if (statuses.size === 0) {
|
||||||
await pollingManager.initializePolling(userId);
|
await pollingManager.initializePolling(userId);
|
||||||
@@ -1520,7 +1481,6 @@ app.listen(PORT, async () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup old auth failures every 10 minutes
|
|
||||||
setInterval(
|
setInterval(
|
||||||
() => {
|
() => {
|
||||||
authFailureTracker.cleanup();
|
authFailureTracker.cleanup();
|
||||||
|
|||||||
@@ -333,15 +333,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "password_response": {
|
case "password_response": {
|
||||||
const passwordData = data as TOTPResponseData; // Same structure
|
const passwordData = data as TOTPResponseData;
|
||||||
if (keyboardInteractiveFinish && passwordData?.code) {
|
if (keyboardInteractiveFinish && passwordData?.code) {
|
||||||
const password = passwordData.code;
|
const password = passwordData.code;
|
||||||
sshLogger.info("Password received from user", {
|
|
||||||
operation: "password_response",
|
|
||||||
userId,
|
|
||||||
passwordLength: password.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
keyboardInteractiveFinish([password]);
|
keyboardInteractiveFinish([password]);
|
||||||
keyboardInteractiveFinish = null;
|
keyboardInteractiveFinish = null;
|
||||||
} else {
|
} else {
|
||||||
@@ -374,7 +368,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update the host config with provided credentials
|
|
||||||
if (credentialsData.password) {
|
if (credentialsData.password) {
|
||||||
credentialsData.hostConfig.password = credentialsData.password;
|
credentialsData.hostConfig.password = credentialsData.password;
|
||||||
credentialsData.hostConfig.authType = "password";
|
credentialsData.hostConfig.authType = "password";
|
||||||
@@ -384,10 +377,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
credentialsData.hostConfig.authType = "key";
|
credentialsData.hostConfig.authType = "key";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup existing connection if any
|
|
||||||
cleanupSSH();
|
cleanupSSH();
|
||||||
|
|
||||||
// Reconnect with new credentials
|
|
||||||
const reconnectData: ConnectToHostData = {
|
const reconnectData: ConnectToHostData = {
|
||||||
cols: credentialsData.cols,
|
cols: credentialsData.cols,
|
||||||
rows: credentialsData.rows,
|
rows: credentialsData.rows,
|
||||||
@@ -555,8 +546,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
sshConn.on("ready", () => {
|
sshConn.on("ready", () => {
|
||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
|
|
||||||
// Immediately try to create shell - don't delay as it can cause connection to be cleaned up
|
|
||||||
// The connection is already ready at this point
|
|
||||||
if (!sshConn) {
|
if (!sshConn) {
|
||||||
sshLogger.warn(
|
sshLogger.warn(
|
||||||
"SSH connection was cleaned up before shell could be created",
|
"SSH connection was cleaned up before shell could be created",
|
||||||
@@ -666,11 +655,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
JSON.stringify({ type: "connected", message: "SSH connected" }),
|
JSON.stringify({ type: "connected", message: "SSH connected" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log activity to dashboard API
|
|
||||||
if (id && hostConfig.userId) {
|
if (id && hostConfig.userId) {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
// Fetch host name from database
|
|
||||||
const hosts = await SimpleDBOps.select(
|
const hosts = await SimpleDBOps.select(
|
||||||
getDb()
|
getDb()
|
||||||
.select()
|
.select()
|
||||||
@@ -790,8 +777,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||||
finish: (responses: string[]) => void,
|
finish: (responses: string[]) => void,
|
||||||
) => {
|
) => {
|
||||||
// Notify frontend that keyboard-interactive is available (e.g., for Warpgate OIDC)
|
|
||||||
// This allows the terminal to be displayed immediately so user can see auth prompts
|
|
||||||
if (resolvedCredentials.authType === "none") {
|
if (resolvedCredentials.authType === "none") {
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -846,37 +831,19 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
resolvedCredentials.password &&
|
resolvedCredentials.password &&
|
||||||
resolvedCredentials.authType !== "none";
|
resolvedCredentials.authType !== "none";
|
||||||
|
|
||||||
// Check if this is a password prompt
|
|
||||||
const passwordPromptIndex = prompts.findIndex((p) =>
|
const passwordPromptIndex = prompts.findIndex((p) =>
|
||||||
/password/i.test(p.prompt),
|
/password/i.test(p.prompt),
|
||||||
);
|
);
|
||||||
|
|
||||||
// If no stored password (including authType "none"), prompt the user
|
|
||||||
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
||||||
// Don't block duplicate password prompts - some servers (like Warpgate) may ask multiple times
|
|
||||||
if (keyboardInteractiveResponded && totpPromptSent) {
|
if (keyboardInteractiveResponded && totpPromptSent) {
|
||||||
// Only block if we already sent a TOTP prompt
|
|
||||||
sshLogger.info(
|
|
||||||
"Skipping duplicate password prompt after TOTP sent",
|
|
||||||
{
|
|
||||||
operation: "keyboard_interactive_skip",
|
|
||||||
hostId: id,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
keyboardInteractiveResponded = true;
|
keyboardInteractiveResponded = true;
|
||||||
|
|
||||||
sshLogger.info("Requesting password from user (authType: none)", {
|
|
||||||
operation: "keyboard_interactive_password",
|
|
||||||
hostId: id,
|
|
||||||
prompt: prompts[passwordPromptIndex].prompt,
|
|
||||||
});
|
|
||||||
|
|
||||||
keyboardInteractiveFinish = (userResponses: string[]) => {
|
keyboardInteractiveFinish = (userResponses: string[]) => {
|
||||||
const userInput = (userResponses[0] || "").trim();
|
const userInput = (userResponses[0] || "").trim();
|
||||||
|
|
||||||
// Build responses for all prompts
|
|
||||||
const responses = prompts.map((p, index) => {
|
const responses = prompts.map((p, index) => {
|
||||||
if (index === passwordPromptIndex) {
|
if (index === passwordPromptIndex) {
|
||||||
return userInput;
|
return userInput;
|
||||||
@@ -884,16 +851,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
|
|
||||||
sshLogger.info(
|
|
||||||
"User-provided password being sent to SSH server",
|
|
||||||
{
|
|
||||||
operation: "interactive_password_verification",
|
|
||||||
hostId: id,
|
|
||||||
passwordLength: userInput.length,
|
|
||||||
totalPrompts: prompts.length,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
finish(responses);
|
finish(responses);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -906,8 +863,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-respond with stored credentials if available
|
|
||||||
// Allow multiple responses - the server might ask multiple times during auth flow
|
|
||||||
const responses = prompts.map((p) => {
|
const responses = prompts.map((p) => {
|
||||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||||
return resolvedCredentials.password;
|
return resolvedCredentials.password;
|
||||||
@@ -991,28 +946,15 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (resolvedCredentials.authType === "none") {
|
if (resolvedCredentials.authType === "none") {
|
||||||
// For "none" auth type, allow natural SSH negotiation
|
|
||||||
// The authHandler will try keyboard-interactive if available, otherwise notify frontend
|
|
||||||
// This allows for Warpgate OIDC and other interactive auth scenarios
|
|
||||||
connectConfig.authHandler = (
|
connectConfig.authHandler = (
|
||||||
methodsLeft: string[] | null,
|
methodsLeft: string[] | null,
|
||||||
partialSuccess: boolean,
|
partialSuccess: boolean,
|
||||||
callback: (nextMethod: string | false) => void,
|
callback: (nextMethod: string | false) => void,
|
||||||
) => {
|
) => {
|
||||||
if (methodsLeft && methodsLeft.length > 0) {
|
if (methodsLeft && methodsLeft.length > 0) {
|
||||||
// Prefer keyboard-interactive if available
|
|
||||||
if (methodsLeft.includes("keyboard-interactive")) {
|
if (methodsLeft.includes("keyboard-interactive")) {
|
||||||
callback("keyboard-interactive");
|
callback("keyboard-interactive");
|
||||||
} else {
|
} else {
|
||||||
// No keyboard-interactive available - notify frontend to show auth dialog
|
|
||||||
sshLogger.info(
|
|
||||||
"Server does not support keyboard-interactive auth for 'none' auth type",
|
|
||||||
{
|
|
||||||
operation: "ssh_auth_handler_no_keyboard",
|
|
||||||
hostId: id,
|
|
||||||
methodsLeft,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "auth_method_not_available",
|
type: "auth_method_not_available",
|
||||||
@@ -1024,11 +966,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
callback(false);
|
callback(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No methods left or empty - try to proceed without auth
|
|
||||||
sshLogger.info("No auth methods available, proceeding without auth", {
|
|
||||||
operation: "ssh_auth_no_methods",
|
|
||||||
hostId: id,
|
|
||||||
});
|
|
||||||
callback(false);
|
callback(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -217,9 +217,7 @@ function cleanupTunnelResources(
|
|||||||
if (verification?.timeout) clearTimeout(verification.timeout);
|
if (verification?.timeout) clearTimeout(verification.timeout);
|
||||||
try {
|
try {
|
||||||
verification?.conn.end();
|
verification?.conn.end();
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
tunnelVerifications.delete(tunnelName);
|
tunnelVerifications.delete(tunnelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,9 +282,7 @@ function handleDisconnect(
|
|||||||
const verification = tunnelVerifications.get(tunnelName);
|
const verification = tunnelVerifications.get(tunnelName);
|
||||||
if (verification?.timeout) clearTimeout(verification.timeout);
|
if (verification?.timeout) clearTimeout(verification.timeout);
|
||||||
verification?.conn.end();
|
verification?.conn.end();
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
tunnelVerifications.delete(tunnelName);
|
tunnelVerifications.delete(tunnelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -642,9 +638,7 @@ async function connectSSHTunnel(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
conn.end();
|
conn.end();
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
activeTunnels.delete(tunnelName);
|
||||||
|
|
||||||
@@ -784,9 +778,7 @@ async function connectSSHTunnel(
|
|||||||
const verification = tunnelVerifications.get(tunnelName);
|
const verification = tunnelVerifications.get(tunnelName);
|
||||||
if (verification?.timeout) clearTimeout(verification.timeout);
|
if (verification?.timeout) clearTimeout(verification.timeout);
|
||||||
verification?.conn.end();
|
verification?.conn.end();
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
tunnelVerifications.delete(tunnelName);
|
tunnelVerifications.delete(tunnelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,13 +829,9 @@ async function connectSSHTunnel(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.stdout?.on("data", () => {
|
stream.stdout?.on("data", () => {});
|
||||||
// Silently consume stdout data
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("error", () => {
|
stream.on("error", () => {});
|
||||||
// Silently consume stream errors
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.stderr.on("data", (data) => {
|
stream.stderr.on("data", (data) => {
|
||||||
const errorMsg = data.toString().trim();
|
const errorMsg = data.toString().trim();
|
||||||
@@ -1222,9 +1210,7 @@ async function killRemoteTunnelByMarker(
|
|||||||
executeNextKillCommand();
|
executeNextKillCommand();
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on("data", () => {
|
stream.on("data", () => {});
|
||||||
// Silently consume stream data
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.stderr.on("data", (data) => {
|
stream.stderr.on("data", (data) => {
|
||||||
const output = data.toString().trim();
|
const output = data.toString().trim();
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
|||||||
if (persistentConfig.parsed) {
|
if (persistentConfig.parsed) {
|
||||||
Object.assign(process.env, persistentConfig.parsed);
|
Object.assign(process.env, persistentConfig.parsed);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore errors if .env file doesn't exist
|
|
||||||
}
|
|
||||||
|
|
||||||
let version = "unknown";
|
let version = "unknown";
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ class AuthManager {
|
|||||||
this.invalidateUserTokens(userId);
|
this.invalidateUserTokens(userId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run session cleanup every 5 minutes
|
|
||||||
setInterval(
|
setInterval(
|
||||||
() => {
|
() => {
|
||||||
this.cleanupExpiredSessions().catch((error) => {
|
this.cleanupExpiredSessions().catch((error) => {
|
||||||
@@ -162,16 +161,15 @@ class AuthManager {
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||||
|
|
||||||
// Determine expiration based on device type
|
|
||||||
let expiresIn = options.expiresIn;
|
let expiresIn = options.expiresIn;
|
||||||
if (!expiresIn && !options.pendingTOTP) {
|
if (!expiresIn && !options.pendingTOTP) {
|
||||||
if (options.deviceType === "desktop" || options.deviceType === "mobile") {
|
if (options.deviceType === "desktop" || options.deviceType === "mobile") {
|
||||||
expiresIn = "30d"; // 30 days for desktop and mobile
|
expiresIn = "30d";
|
||||||
} else {
|
} else {
|
||||||
expiresIn = "7d"; // 7 days for web
|
expiresIn = "7d";
|
||||||
}
|
}
|
||||||
} else if (!expiresIn) {
|
} else if (!expiresIn) {
|
||||||
expiresIn = "7d"; // Default
|
expiresIn = "7d";
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: JWTPayload = { userId };
|
const payload: JWTPayload = { userId };
|
||||||
@@ -179,23 +177,19 @@ class AuthManager {
|
|||||||
payload.pendingTOTP = true;
|
payload.pendingTOTP = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session in database if not a temporary TOTP token
|
|
||||||
if (!options.pendingTOTP && options.deviceType && options.deviceInfo) {
|
if (!options.pendingTOTP && options.deviceType && options.deviceInfo) {
|
||||||
const sessionId = nanoid();
|
const sessionId = nanoid();
|
||||||
payload.sessionId = sessionId;
|
payload.sessionId = sessionId;
|
||||||
|
|
||||||
// Generate the token first to get it for storage
|
|
||||||
const token = jwt.sign(payload, jwtSecret, {
|
const token = jwt.sign(payload, jwtSecret, {
|
||||||
expiresIn,
|
expiresIn,
|
||||||
} as jwt.SignOptions);
|
} as jwt.SignOptions);
|
||||||
|
|
||||||
// Calculate expiration timestamp
|
|
||||||
const expirationMs = this.parseExpiresIn(expiresIn);
|
const expirationMs = this.parseExpiresIn(expiresIn);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const expiresAt = new Date(now.getTime() + expirationMs).toISOString();
|
const expiresAt = new Date(now.getTime() + expirationMs).toISOString();
|
||||||
const createdAt = now.toISOString();
|
const createdAt = now.toISOString();
|
||||||
|
|
||||||
// Store session in database
|
|
||||||
try {
|
try {
|
||||||
await db.insert(sessions).values({
|
await db.insert(sessions).values({
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
@@ -208,27 +202,11 @@ class AuthManager {
|
|||||||
lastActiveAt: createdAt,
|
lastActiveAt: createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
databaseLogger.info("Session created", {
|
|
||||||
operation: "session_create",
|
|
||||||
userId,
|
|
||||||
sessionId,
|
|
||||||
deviceType: options.deviceType,
|
|
||||||
expiresAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Immediately save database to disk to ensure session persists across restarts
|
|
||||||
try {
|
try {
|
||||||
const { saveMemoryDatabaseToFile } = await import(
|
const { saveMemoryDatabaseToFile } = await import(
|
||||||
"../database/db/index.js"
|
"../database/db/index.js"
|
||||||
);
|
);
|
||||||
await saveMemoryDatabaseToFile();
|
await saveMemoryDatabaseToFile();
|
||||||
databaseLogger.info(
|
|
||||||
"Database saved immediately after session creation",
|
|
||||||
{
|
|
||||||
operation: "session_create_db_save",
|
|
||||||
sessionId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (saveError) {
|
} catch (saveError) {
|
||||||
databaseLogger.error(
|
databaseLogger.error(
|
||||||
"Failed to save database after session creation",
|
"Failed to save database after session creation",
|
||||||
@@ -245,7 +223,6 @@ class AuthManager {
|
|||||||
userId,
|
userId,
|
||||||
sessionId,
|
sessionId,
|
||||||
});
|
});
|
||||||
// Continue anyway - session tracking is non-critical
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
@@ -259,7 +236,7 @@ class AuthManager {
|
|||||||
*/
|
*/
|
||||||
private parseExpiresIn(expiresIn: string): number {
|
private parseExpiresIn(expiresIn: string): number {
|
||||||
const match = expiresIn.match(/^(\d+)([smhd])$/);
|
const match = expiresIn.match(/^(\d+)([smhd])$/);
|
||||||
if (!match) return 7 * 24 * 60 * 60 * 1000; // Default 7 days
|
if (!match) return 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const value = parseInt(match[1]);
|
const value = parseInt(match[1]);
|
||||||
const unit = match[2];
|
const unit = match[2];
|
||||||
@@ -282,26 +259,8 @@ class AuthManager {
|
|||||||
try {
|
try {
|
||||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||||
|
|
||||||
databaseLogger.info("Attempting JWT verification", {
|
|
||||||
operation: "jwt_verify_attempt",
|
|
||||||
tokenLength: token.length,
|
|
||||||
secretLength: jwtSecret.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
||||||
|
|
||||||
databaseLogger.info("JWT signature verified successfully", {
|
|
||||||
operation: "jwt_signature_verified",
|
|
||||||
userId: payload.userId,
|
|
||||||
sessionId: payload.sessionId,
|
|
||||||
hasExpiration: !!payload.exp,
|
|
||||||
expiresAt: payload.exp
|
|
||||||
? new Date(payload.exp * 1000).toISOString()
|
|
||||||
: "N/A",
|
|
||||||
});
|
|
||||||
|
|
||||||
// For tokens with sessionId, verify the session exists in database
|
|
||||||
// This ensures revoked sessions are rejected even after backend restart
|
|
||||||
if (payload.sessionId) {
|
if (payload.sessionId) {
|
||||||
try {
|
try {
|
||||||
const sessionRecords = await db
|
const sessionRecords = await db
|
||||||
@@ -322,13 +281,6 @@ class AuthManager {
|
|||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
databaseLogger.info("Session found in database", {
|
|
||||||
operation: "jwt_session_found",
|
|
||||||
sessionId: payload.sessionId,
|
|
||||||
userId: payload.userId,
|
|
||||||
sessionExpiresAt: sessionRecords[0].expiresAt,
|
|
||||||
});
|
|
||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
databaseLogger.error(
|
databaseLogger.error(
|
||||||
"Failed to check session in database during JWT verification",
|
"Failed to check session in database during JWT verification",
|
||||||
@@ -338,15 +290,8 @@ class AuthManager {
|
|||||||
sessionId: payload.sessionId,
|
sessionId: payload.sessionId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// Continue anyway - database errors shouldn't block valid JWTs
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
databaseLogger.info("JWT verification successful", {
|
|
||||||
operation: "jwt_verify_success",
|
|
||||||
userId: payload.userId,
|
|
||||||
sessionId: payload.sessionId,
|
|
||||||
});
|
|
||||||
return payload;
|
return payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.warn("JWT verification failed", {
|
databaseLogger.warn("JWT verification failed", {
|
||||||
@@ -358,35 +303,14 @@ class AuthManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateJWTToken(token: string): void {
|
invalidateJWTToken(token: string): void {}
|
||||||
// No-op: Token invalidation is now handled through database session deletion
|
|
||||||
databaseLogger.info(
|
|
||||||
"Token invalidation requested (handled via session deletion)",
|
|
||||||
{
|
|
||||||
operation: "token_invalidate",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidateUserTokens(userId: string): void {
|
invalidateUserTokens(userId: string): void {}
|
||||||
databaseLogger.info("User tokens invalidation requested due to data lock", {
|
|
||||||
operation: "user_tokens_invalidate",
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
// Session cleanup will happen through revokeAllUserSessions if needed
|
|
||||||
}
|
|
||||||
|
|
||||||
async revokeSession(sessionId: string): Promise<boolean> {
|
async revokeSession(sessionId: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// Delete the session from database
|
|
||||||
// The JWT will be invalidated because verifyJWTToken checks for session existence
|
|
||||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||||
|
|
||||||
databaseLogger.info("Session deleted", {
|
|
||||||
operation: "session_delete",
|
|
||||||
sessionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to delete session", error, {
|
databaseLogger.error("Failed to delete session", error, {
|
||||||
@@ -402,7 +326,6 @@ class AuthManager {
|
|||||||
exceptSessionId?: string,
|
exceptSessionId?: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
try {
|
try {
|
||||||
// Get session count before deletion
|
|
||||||
const userSessions = await db
|
const userSessions = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sessions)
|
.from(sessions)
|
||||||
@@ -412,8 +335,6 @@ class AuthManager {
|
|||||||
(s) => !exceptSessionId || s.id !== exceptSessionId,
|
(s) => !exceptSessionId || s.id !== exceptSessionId,
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
// Delete sessions from database
|
|
||||||
// JWTs will be invalidated because verifyJWTToken checks for session existence
|
|
||||||
if (exceptSessionId) {
|
if (exceptSessionId) {
|
||||||
await db
|
await db
|
||||||
.delete(sessions)
|
.delete(sessions)
|
||||||
@@ -427,13 +348,6 @@ class AuthManager {
|
|||||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
await db.delete(sessions).where(eq(sessions.userId, userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
databaseLogger.info("User sessions deleted", {
|
|
||||||
operation: "user_sessions_delete",
|
|
||||||
userId,
|
|
||||||
exceptSessionId,
|
|
||||||
deletedCount,
|
|
||||||
});
|
|
||||||
|
|
||||||
return deletedCount;
|
return deletedCount;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to delete user sessions", error, {
|
databaseLogger.error("Failed to delete user sessions", error, {
|
||||||
@@ -446,7 +360,6 @@ class AuthManager {
|
|||||||
|
|
||||||
async cleanupExpiredSessions(): Promise<number> {
|
async cleanupExpiredSessions(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
// Get expired sessions count
|
|
||||||
const expiredSessions = await db
|
const expiredSessions = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sessions)
|
.from(sessions)
|
||||||
@@ -454,19 +367,10 @@ class AuthManager {
|
|||||||
|
|
||||||
const expiredCount = expiredSessions.length;
|
const expiredCount = expiredSessions.length;
|
||||||
|
|
||||||
// Delete expired sessions
|
|
||||||
// JWTs will be invalidated because verifyJWTToken checks for session existence
|
|
||||||
await db
|
await db
|
||||||
.delete(sessions)
|
.delete(sessions)
|
||||||
.where(sql`${sessions.expiresAt} < datetime('now')`);
|
.where(sql`${sessions.expiresAt} < datetime('now')`);
|
||||||
|
|
||||||
if (expiredCount > 0) {
|
|
||||||
databaseLogger.info("Expired sessions cleaned up", {
|
|
||||||
operation: "sessions_cleanup",
|
|
||||||
count: expiredCount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return expiredCount;
|
return expiredCount;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to cleanup expired sessions", error, {
|
databaseLogger.error("Failed to cleanup expired sessions", error, {
|
||||||
@@ -539,7 +443,6 @@ class AuthManager {
|
|||||||
return res.status(401).json({ error: "Invalid token" });
|
return res.status(401).json({ error: "Invalid token" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check session status if sessionId is present
|
|
||||||
if (payload.sessionId) {
|
if (payload.sessionId) {
|
||||||
try {
|
try {
|
||||||
const sessionRecords = await db
|
const sessionRecords = await db
|
||||||
@@ -557,9 +460,6 @@ class AuthManager {
|
|||||||
|
|
||||||
const session = sessionRecords[0];
|
const session = sessionRecords[0];
|
||||||
|
|
||||||
// Session exists, no need to check isRevoked since we delete sessions instead
|
|
||||||
|
|
||||||
// Check if session has expired by comparing timestamps
|
|
||||||
const sessionExpiryTime = new Date(session.expiresAt).getTime();
|
const sessionExpiryTime = new Date(session.expiresAt).getTime();
|
||||||
const currentTime = Date.now();
|
const currentTime = Date.now();
|
||||||
const isExpired = sessionExpiryTime < currentTime;
|
const isExpired = sessionExpiryTime < currentTime;
|
||||||
@@ -579,7 +479,6 @@ class AuthManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update lastActiveAt timestamp (async, non-blocking)
|
|
||||||
db.update(sessions)
|
db.update(sessions)
|
||||||
.set({ lastActiveAt: new Date().toISOString() })
|
.set({ lastActiveAt: new Date().toISOString() })
|
||||||
.where(eq(sessions.id, payload.sessionId))
|
.where(eq(sessions.id, payload.sessionId))
|
||||||
@@ -596,7 +495,6 @@ class AuthManager {
|
|||||||
operation: "session_check_failed",
|
operation: "session_check_failed",
|
||||||
sessionId: payload.sessionId,
|
sessionId: payload.sessionId,
|
||||||
});
|
});
|
||||||
// Continue anyway - session tracking failures shouldn't block auth
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,14 +512,8 @@ class AuthManager {
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get data key if available (may be null after restart)
|
|
||||||
const dataKey = this.userCrypto.getUserDataKey(userId);
|
const dataKey = this.userCrypto.getUserDataKey(userId);
|
||||||
authReq.dataKey = dataKey || undefined;
|
authReq.dataKey = dataKey || undefined;
|
||||||
|
|
||||||
// Note: Data key will be null after backend restart until user performs
|
|
||||||
// an operation that requires decryption. This is expected behavior.
|
|
||||||
// Individual routes that need encryption should check dataKey explicitly.
|
|
||||||
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -688,15 +580,9 @@ class AuthManager {
|
|||||||
async logoutUser(userId: string, sessionId?: string): Promise<void> {
|
async logoutUser(userId: string, sessionId?: string): Promise<void> {
|
||||||
this.userCrypto.logoutUser(userId);
|
this.userCrypto.logoutUser(userId);
|
||||||
|
|
||||||
// Delete the specific session from database if sessionId provided
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
try {
|
try {
|
||||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||||
databaseLogger.info("Session deleted on logout", {
|
|
||||||
operation: "session_delete_logout",
|
|
||||||
userId,
|
|
||||||
sessionId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to delete session on logout", error, {
|
databaseLogger.error("Failed to delete session on logout", error, {
|
||||||
operation: "session_delete_logout_failed",
|
operation: "session_delete_logout_failed",
|
||||||
@@ -705,13 +591,8 @@ class AuthManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If no sessionId, delete all sessions for this user
|
|
||||||
try {
|
try {
|
||||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
await db.delete(sessions).where(eq(sessions.userId, userId));
|
||||||
databaseLogger.info("All user sessions deleted on logout", {
|
|
||||||
operation: "sessions_delete_logout",
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error(
|
databaseLogger.error(
|
||||||
"Failed to delete user sessions on logout",
|
"Failed to delete user sessions on logout",
|
||||||
|
|||||||
@@ -233,9 +233,7 @@ IP.3 = 0.0.0.0
|
|||||||
let envContent = "";
|
let envContent = "";
|
||||||
try {
|
try {
|
||||||
envContent = await fs.readFile(this.ENV_FILE, "utf8");
|
envContent = await fs.readFile(this.ENV_FILE, "utf8");
|
||||||
} catch {
|
} catch {}
|
||||||
// File doesn't exist yet, will create with SSL config
|
|
||||||
}
|
|
||||||
|
|
||||||
let updatedContent = envContent;
|
let updatedContent = envContent;
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
|
|||||||
@@ -393,18 +393,6 @@ class DataCrypto {
|
|||||||
|
|
||||||
result.success = result.errors.length === 0;
|
result.success = result.errors.length === 0;
|
||||||
|
|
||||||
databaseLogger.info(
|
|
||||||
"User data re-encryption completed after password reset",
|
|
||||||
{
|
|
||||||
operation: "password_reset_reencrypt_completed",
|
|
||||||
userId,
|
|
||||||
success: result.success,
|
|
||||||
reencryptedTables: result.reencryptedTables,
|
|
||||||
reencryptedFieldsCount: result.reencryptedFieldsCount,
|
|
||||||
errorsCount: result.errors.length,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error(
|
databaseLogger.error(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import Database from "better-sqlite3";
|
/import Database from "better-sqlite3";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { databaseLogger } from "./logger.js";
|
import { databaseLogger } from "./logger.js";
|
||||||
@@ -62,10 +62,6 @@ export class DatabaseMigration {
|
|||||||
"Empty unencrypted database found alongside encrypted database. Removing empty file.";
|
"Empty unencrypted database found alongside encrypted database. Removing empty file.";
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(this.unencryptedDbPath);
|
fs.unlinkSync(this.unencryptedDbPath);
|
||||||
databaseLogger.info("Removed empty unencrypted database file", {
|
|
||||||
operation: "migration_cleanup_empty",
|
|
||||||
path: this.unencryptedDbPath,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.warn("Failed to remove empty unencrypted database", {
|
databaseLogger.warn("Failed to remove empty unencrypted database", {
|
||||||
operation: "migration_cleanup_empty_failed",
|
operation: "migration_cleanup_empty_failed",
|
||||||
|
|||||||
@@ -71,11 +71,6 @@ export class DatabaseSaveTrigger {
|
|||||||
this.pendingSave = true;
|
this.pendingSave = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
databaseLogger.info("Force saving database", {
|
|
||||||
operation: "db_save_trigger_force_start",
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.saveFunction();
|
await this.saveFunction();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Database force save failed", error, {
|
databaseLogger.error("Database force save failed", error, {
|
||||||
@@ -110,9 +105,5 @@ export class DatabaseSaveTrigger {
|
|||||||
this.pendingSave = false;
|
this.pendingSave = false;
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
this.saveFunction = null;
|
this.saveFunction = null;
|
||||||
|
|
||||||
databaseLogger.info("Database save trigger cleaned up", {
|
|
||||||
operation: "db_save_trigger_cleanup",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,9 +82,7 @@ export class LazyFieldEncryption {
|
|||||||
legacyFieldName,
|
legacyFieldName,
|
||||||
);
|
);
|
||||||
return decrypted;
|
return decrypted;
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore legacy format errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sensitiveFields = [
|
const sensitiveFields = [
|
||||||
@@ -176,9 +174,7 @@ export class LazyFieldEncryption {
|
|||||||
wasPlaintext: false,
|
wasPlaintext: false,
|
||||||
wasLegacyEncryption: true,
|
wasLegacyEncryption: true,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore legacy format errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
encrypted: fieldValue,
|
encrypted: fieldValue,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ type TableName = "users" | "ssh_data" | "ssh_credentials" | "recent_activity";
|
|||||||
|
|
||||||
class SimpleDBOps {
|
class SimpleDBOps {
|
||||||
static async insert<T extends Record<string, unknown>>(
|
static async insert<T extends Record<string, unknown>>(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
table: SQLiteTable<any>,
|
table: SQLiteTable<any>,
|
||||||
tableName: TableName,
|
tableName: TableName,
|
||||||
data: T,
|
data: T,
|
||||||
@@ -91,7 +90,6 @@ class SimpleDBOps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async update<T extends Record<string, unknown>>(
|
static async update<T extends Record<string, unknown>>(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
table: SQLiteTable<any>,
|
table: SQLiteTable<any>,
|
||||||
tableName: TableName,
|
tableName: TableName,
|
||||||
where: unknown,
|
where: unknown,
|
||||||
@@ -110,7 +108,6 @@ class SimpleDBOps {
|
|||||||
const result = await getDb()
|
const result = await getDb()
|
||||||
.update(table)
|
.update(table)
|
||||||
.set(encryptedData)
|
.set(encryptedData)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
.where(where as any)
|
.where(where as any)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -127,14 +124,12 @@ class SimpleDBOps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async delete(
|
static async delete(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
table: SQLiteTable<any>,
|
table: SQLiteTable<any>,
|
||||||
tableName: TableName,
|
tableName: TableName,
|
||||||
where: unknown,
|
where: unknown,
|
||||||
): Promise<unknown[]> {
|
): Promise<unknown[]> {
|
||||||
const result = await getDb()
|
const result = await getDb()
|
||||||
.delete(table)
|
.delete(table)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
.where(where as any)
|
.where(where as any)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -84,9 +84,7 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
|||||||
} else if (decodedString.includes("1.3.101.112")) {
|
} else if (decodedString.includes("1.3.101.112")) {
|
||||||
return "ssh-ed25519";
|
return "ssh-ed25519";
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Cannot decode key, fallback to length-based detection
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content.length < 800) {
|
if (content.length < 800) {
|
||||||
return "ssh-ed25519";
|
return "ssh-ed25519";
|
||||||
@@ -142,9 +140,7 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
|||||||
} else if (decodedString.includes("1.3.101.112")) {
|
} else if (decodedString.includes("1.3.101.112")) {
|
||||||
return "ssh-ed25519";
|
return "ssh-ed25519";
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Cannot decode key, fallback to length-based detection
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content.length < 400) {
|
if (content.length < 400) {
|
||||||
return "ssh-ed25519";
|
return "ssh-ed25519";
|
||||||
@@ -246,9 +242,7 @@ export function parseSSHKey(
|
|||||||
|
|
||||||
useSSH2 = true;
|
useSSH2 = true;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// SSH2 parsing failed, will use fallback method
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!useSSH2) {
|
if (!useSSH2) {
|
||||||
@@ -274,9 +268,7 @@ export function parseSSHKey(
|
|||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Fallback parsing also failed
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
privateKey: privateKeyData,
|
privateKey: privateKeyData,
|
||||||
|
|||||||
@@ -107,9 +107,7 @@ class SystemCrypto {
|
|||||||
process.env.DATABASE_KEY = dbKeyMatch[1];
|
process.env.DATABASE_KEY = dbKeyMatch[1];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore file read errors, will generate new key
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.generateAndGuideDatabaseKey();
|
await this.generateAndGuideDatabaseKey();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -146,9 +144,7 @@ class SystemCrypto {
|
|||||||
process.env.INTERNAL_AUTH_TOKEN = tokenMatch[1];
|
process.env.INTERNAL_AUTH_TOKEN = tokenMatch[1];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore file read errors, will generate new token
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.generateAndGuideInternalAuthToken();
|
await this.generateAndGuideInternalAuthToken();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -7,59 +7,43 @@ export interface DeviceInfo {
|
|||||||
browser: string;
|
browser: string;
|
||||||
version: string;
|
version: string;
|
||||||
os: string;
|
os: string;
|
||||||
deviceInfo: string; // Formatted string like "Chrome 120 on Windows 11"
|
deviceInfo: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect the platform type based on request headers
|
|
||||||
*/
|
|
||||||
export function detectPlatform(req: Request): DeviceType {
|
export function detectPlatform(req: Request): DeviceType {
|
||||||
const userAgent = req.headers["user-agent"] || "";
|
const userAgent = req.headers["user-agent"] || "";
|
||||||
const electronHeader = req.headers["x-electron-app"];
|
const electronHeader = req.headers["x-electron-app"];
|
||||||
|
|
||||||
// Electron app detection
|
|
||||||
if (electronHeader === "true") {
|
if (electronHeader === "true") {
|
||||||
return "desktop";
|
return "desktop";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile app detection
|
|
||||||
if (userAgent.includes("Termix-Mobile")) {
|
if (userAgent.includes("Termix-Mobile")) {
|
||||||
return "mobile";
|
return "mobile";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to web
|
|
||||||
return "web";
|
return "web";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse User-Agent string to extract device information
|
|
||||||
*/
|
|
||||||
export function parseUserAgent(req: Request): DeviceInfo {
|
export function parseUserAgent(req: Request): DeviceInfo {
|
||||||
const userAgent = req.headers["user-agent"] || "Unknown";
|
const userAgent = req.headers["user-agent"] || "Unknown";
|
||||||
const platform = detectPlatform(req);
|
const platform = detectPlatform(req);
|
||||||
|
|
||||||
// For Electron
|
|
||||||
if (platform === "desktop") {
|
if (platform === "desktop") {
|
||||||
return parseElectronUserAgent(userAgent);
|
return parseElectronUserAgent(userAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Mobile app
|
|
||||||
if (platform === "mobile") {
|
if (platform === "mobile") {
|
||||||
return parseMobileUserAgent(userAgent);
|
return parseMobileUserAgent(userAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For web browsers
|
|
||||||
return parseWebUserAgent(userAgent);
|
return parseWebUserAgent(userAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse Electron app user agent
|
|
||||||
*/
|
|
||||||
function parseElectronUserAgent(userAgent: string): DeviceInfo {
|
function parseElectronUserAgent(userAgent: string): DeviceInfo {
|
||||||
let os = "Unknown OS";
|
let os = "Unknown OS";
|
||||||
let version = "Unknown";
|
let version = "Unknown";
|
||||||
|
|
||||||
// Detect OS
|
|
||||||
if (userAgent.includes("Windows")) {
|
if (userAgent.includes("Windows")) {
|
||||||
os = parseWindowsVersion(userAgent);
|
os = parseWindowsVersion(userAgent);
|
||||||
} else if (userAgent.includes("Mac OS X")) {
|
} else if (userAgent.includes("Mac OS X")) {
|
||||||
@@ -68,7 +52,6 @@ function parseElectronUserAgent(userAgent: string): DeviceInfo {
|
|||||||
os = "Linux";
|
os = "Linux";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract Electron version
|
|
||||||
const electronMatch = userAgent.match(/Electron\/([\d.]+)/);
|
const electronMatch = userAgent.match(/Electron\/([\d.]+)/);
|
||||||
if (electronMatch) {
|
if (electronMatch) {
|
||||||
version = electronMatch[1];
|
version = electronMatch[1];
|
||||||
@@ -83,23 +66,17 @@ function parseElectronUserAgent(userAgent: string): DeviceInfo {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse mobile app user agent
|
|
||||||
*/
|
|
||||||
function parseMobileUserAgent(userAgent: string): DeviceInfo {
|
function parseMobileUserAgent(userAgent: string): DeviceInfo {
|
||||||
let os = "Unknown OS";
|
let os = "Unknown OS";
|
||||||
let version = "Unknown";
|
let version = "Unknown";
|
||||||
|
|
||||||
// Check for Termix-Mobile/Platform format first (e.g., "Termix-Mobile/Android" or "Termix-Mobile/iOS")
|
|
||||||
const termixPlatformMatch = userAgent.match(/Termix-Mobile\/(Android|iOS)/i);
|
const termixPlatformMatch = userAgent.match(/Termix-Mobile\/(Android|iOS)/i);
|
||||||
if (termixPlatformMatch) {
|
if (termixPlatformMatch) {
|
||||||
const platform = termixPlatformMatch[1];
|
const platform = termixPlatformMatch[1];
|
||||||
if (platform.toLowerCase() === "android") {
|
if (platform.toLowerCase() === "android") {
|
||||||
// Try to get Android version from full UA string
|
|
||||||
const androidMatch = userAgent.match(/Android ([\d.]+)/);
|
const androidMatch = userAgent.match(/Android ([\d.]+)/);
|
||||||
os = androidMatch ? `Android ${androidMatch[1]}` : "Android";
|
os = androidMatch ? `Android ${androidMatch[1]}` : "Android";
|
||||||
} else if (platform.toLowerCase() === "ios") {
|
} else if (platform.toLowerCase() === "ios") {
|
||||||
// Try to get iOS version from full UA string
|
|
||||||
const iosMatch = userAgent.match(/OS ([\d_]+)/);
|
const iosMatch = userAgent.match(/OS ([\d_]+)/);
|
||||||
if (iosMatch) {
|
if (iosMatch) {
|
||||||
const iosVersion = iosMatch[1].replace(/_/g, ".");
|
const iosVersion = iosMatch[1].replace(/_/g, ".");
|
||||||
@@ -109,7 +86,6 @@ function parseMobileUserAgent(userAgent: string): DeviceInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: Check for standard Android/iOS patterns in the user agent
|
|
||||||
if (userAgent.includes("Android")) {
|
if (userAgent.includes("Android")) {
|
||||||
const androidMatch = userAgent.match(/Android ([\d.]+)/);
|
const androidMatch = userAgent.match(/Android ([\d.]+)/);
|
||||||
os = androidMatch ? `Android ${androidMatch[1]}` : "Android";
|
os = androidMatch ? `Android ${androidMatch[1]}` : "Android";
|
||||||
@@ -128,8 +104,6 @@ function parseMobileUserAgent(userAgent: string): DeviceInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract app version (if included in UA)
|
|
||||||
// Match patterns like "Termix-Mobile/1.0.0" or just "Termix-Mobile"
|
|
||||||
const versionMatch = userAgent.match(
|
const versionMatch = userAgent.match(
|
||||||
/Termix-Mobile\/(?:Android|iOS|)([\d.]+)/i,
|
/Termix-Mobile\/(?:Android|iOS|)([\d.]+)/i,
|
||||||
);
|
);
|
||||||
@@ -146,15 +120,11 @@ function parseMobileUserAgent(userAgent: string): DeviceInfo {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse web browser user agent
|
|
||||||
*/
|
|
||||||
function parseWebUserAgent(userAgent: string): DeviceInfo {
|
function parseWebUserAgent(userAgent: string): DeviceInfo {
|
||||||
let browser = "Unknown Browser";
|
let browser = "Unknown Browser";
|
||||||
let version = "Unknown";
|
let version = "Unknown";
|
||||||
let os = "Unknown OS";
|
let os = "Unknown OS";
|
||||||
|
|
||||||
// Detect browser
|
|
||||||
if (userAgent.includes("Edg/")) {
|
if (userAgent.includes("Edg/")) {
|
||||||
const match = userAgent.match(/Edg\/([\d.]+)/);
|
const match = userAgent.match(/Edg\/([\d.]+)/);
|
||||||
browser = "Edge";
|
browser = "Edge";
|
||||||
@@ -177,7 +147,6 @@ function parseWebUserAgent(userAgent: string): DeviceInfo {
|
|||||||
version = match ? match[1] : "Unknown";
|
version = match ? match[1] : "Unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect OS
|
|
||||||
if (userAgent.includes("Windows")) {
|
if (userAgent.includes("Windows")) {
|
||||||
os = parseWindowsVersion(userAgent);
|
os = parseWindowsVersion(userAgent);
|
||||||
} else if (userAgent.includes("Mac OS X")) {
|
} else if (userAgent.includes("Mac OS X")) {
|
||||||
@@ -201,7 +170,6 @@ function parseWebUserAgent(userAgent: string): DeviceInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shorten version to major.minor
|
|
||||||
if (version !== "Unknown") {
|
if (version !== "Unknown") {
|
||||||
const versionParts = version.split(".");
|
const versionParts = version.split(".");
|
||||||
version = versionParts.slice(0, 2).join(".");
|
version = versionParts.slice(0, 2).join(".");
|
||||||
@@ -216,9 +184,6 @@ function parseWebUserAgent(userAgent: string): DeviceInfo {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse Windows version from user agent
|
|
||||||
*/
|
|
||||||
function parseWindowsVersion(userAgent: string): string {
|
function parseWindowsVersion(userAgent: string): string {
|
||||||
if (userAgent.includes("Windows NT 10.0")) {
|
if (userAgent.includes("Windows NT 10.0")) {
|
||||||
return "Windows 10/11";
|
return "Windows 10/11";
|
||||||
@@ -239,9 +204,6 @@ function parseWindowsVersion(userAgent: string): string {
|
|||||||
return "Windows";
|
return "Windows";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse macOS version from user agent
|
|
||||||
*/
|
|
||||||
function parseMacVersion(userAgent: string): string {
|
function parseMacVersion(userAgent: string): string {
|
||||||
const match = userAgent.match(/Mac OS X ([\d_]+)/);
|
const match = userAgent.match(/Mac OS X ([\d_]+)/);
|
||||||
if (match) {
|
if (match) {
|
||||||
@@ -250,7 +212,6 @@ function parseMacVersion(userAgent: string): string {
|
|||||||
const major = parseInt(parts[0]);
|
const major = parseInt(parts[0]);
|
||||||
const minor = parseInt(parts[1]);
|
const minor = parseInt(parts[1]);
|
||||||
|
|
||||||
// macOS naming
|
|
||||||
if (major === 10) {
|
if (major === 10) {
|
||||||
if (minor >= 15) return `macOS ${major}.${minor}`;
|
if (minor >= 15) return `macOS ${major}.${minor}`;
|
||||||
if (minor === 14) return "macOS Mojave";
|
if (minor === 14) return "macOS Mojave";
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export interface TerminalTheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TERMINAL_THEMES: Record<string, TerminalTheme> = {
|
export const TERMINAL_THEMES: Record<string, TerminalTheme> = {
|
||||||
// Current default theme
|
|
||||||
termix: {
|
termix: {
|
||||||
name: "Termix Default",
|
name: "Termix Default",
|
||||||
category: "dark",
|
category: "dark",
|
||||||
@@ -666,14 +665,12 @@ export const TERMINAL_FONTS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Cursor styles
|
|
||||||
export const CURSOR_STYLES = [
|
export const CURSOR_STYLES = [
|
||||||
{ value: "block", label: "Block" },
|
{ value: "block", label: "Block" },
|
||||||
{ value: "underline", label: "Underline" },
|
{ value: "underline", label: "Underline" },
|
||||||
{ value: "bar", label: "Bar" },
|
{ value: "bar", label: "Bar" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Bell styles
|
|
||||||
export const BELL_STYLES = [
|
export const BELL_STYLES = [
|
||||||
{ value: "none", label: "None" },
|
{ value: "none", label: "None" },
|
||||||
{ value: "sound", label: "Sound" },
|
{ value: "sound", label: "Sound" },
|
||||||
@@ -681,16 +678,13 @@ export const BELL_STYLES = [
|
|||||||
{ value: "both", label: "Both" },
|
{ value: "both", label: "Both" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Fast scroll modifiers
|
|
||||||
export const FAST_SCROLL_MODIFIERS = [
|
export const FAST_SCROLL_MODIFIERS = [
|
||||||
{ value: "alt", label: "Alt" },
|
{ value: "alt", label: "Alt" },
|
||||||
{ value: "ctrl", label: "Ctrl" },
|
{ value: "ctrl", label: "Ctrl" },
|
||||||
{ value: "shift", label: "Shift" },
|
{ value: "shift", label: "Shift" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Default terminal configuration
|
|
||||||
export const DEFAULT_TERMINAL_CONFIG = {
|
export const DEFAULT_TERMINAL_CONFIG = {
|
||||||
// Appearance
|
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
cursorStyle: "bar" as const,
|
cursorStyle: "bar" as const,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -699,7 +693,6 @@ export const DEFAULT_TERMINAL_CONFIG = {
|
|||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
theme: "termix",
|
theme: "termix",
|
||||||
|
|
||||||
// Behavior
|
|
||||||
scrollback: 10000,
|
scrollback: 10000,
|
||||||
bellStyle: "none" as const,
|
bellStyle: "none" as const,
|
||||||
rightClickSelectsWord: false,
|
rightClickSelectsWord: false,
|
||||||
@@ -707,7 +700,6 @@ export const DEFAULT_TERMINAL_CONFIG = {
|
|||||||
fastScrollSensitivity: 5,
|
fastScrollSensitivity: 5,
|
||||||
minimumContrastRatio: 1,
|
minimumContrastRatio: 1,
|
||||||
|
|
||||||
// Advanced
|
|
||||||
backspaceMode: "normal" as const,
|
backspaceMode: "normal" as const,
|
||||||
agentForwarding: false,
|
agentForwarding: false,
|
||||||
environmentVariables: [] as Array<{ key: string; value: string }>,
|
environmentVariables: [] as Array<{ key: string; value: string }>,
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
// ============================================================================
|
|
||||||
// CENTRAL TYPE DEFINITIONS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import type { Client } from "ssh2";
|
import type { Client } from "ssh2";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
|
|
||||||
@@ -60,7 +56,7 @@ export interface SSHHostData {
|
|||||||
enableFileManager?: boolean;
|
enableFileManager?: boolean;
|
||||||
defaultPath?: string;
|
defaultPath?: string;
|
||||||
tunnelConnections?: TunnelConnection[];
|
tunnelConnections?: TunnelConnection[];
|
||||||
statsConfig?: string | Record<string, unknown>; // Can be string (from backend) or object (from form)
|
statsConfig?: string | Record<string, unknown>;
|
||||||
terminalConfig?: TerminalConfig;
|
terminalConfig?: TerminalConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +106,6 @@ export interface TunnelConnection {
|
|||||||
endpointPort: number;
|
endpointPort: number;
|
||||||
endpointHost: string;
|
endpointHost: string;
|
||||||
|
|
||||||
// Endpoint host credentials for tunnel authentication
|
|
||||||
endpointPassword?: string;
|
endpointPassword?: string;
|
||||||
endpointKey?: string;
|
endpointKey?: string;
|
||||||
endpointKeyPassword?: string;
|
endpointKeyPassword?: string;
|
||||||
@@ -255,16 +250,14 @@ export interface TermixAlert {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface TerminalConfig {
|
export interface TerminalConfig {
|
||||||
// Appearance
|
|
||||||
cursorBlink: boolean;
|
cursorBlink: boolean;
|
||||||
cursorStyle: "block" | "underline" | "bar";
|
cursorStyle: "block" | "underline" | "bar";
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
fontFamily: string;
|
fontFamily: string;
|
||||||
letterSpacing: number;
|
letterSpacing: number;
|
||||||
lineHeight: number;
|
lineHeight: number;
|
||||||
theme: string; // Theme key from TERMINAL_THEMES
|
theme: string;
|
||||||
|
|
||||||
// Behavior
|
|
||||||
scrollback: number;
|
scrollback: number;
|
||||||
bellStyle: "none" | "sound" | "visual" | "both";
|
bellStyle: "none" | "sound" | "visual" | "both";
|
||||||
rightClickSelectsWord: boolean;
|
rightClickSelectsWord: boolean;
|
||||||
@@ -272,7 +265,6 @@ export interface TerminalConfig {
|
|||||||
fastScrollSensitivity: number;
|
fastScrollSensitivity: number;
|
||||||
minimumContrastRatio: number;
|
minimumContrastRatio: number;
|
||||||
|
|
||||||
// Advanced
|
|
||||||
backspaceMode: "normal" | "control-h";
|
backspaceMode: "normal" | "control-h";
|
||||||
agentForwarding: boolean;
|
agentForwarding: boolean;
|
||||||
environmentVariables: Array<{ key: string; value: string }>;
|
environmentVariables: Array<{ key: string; value: string }>;
|
||||||
@@ -298,7 +290,7 @@ export interface TabContextTab {
|
|||||||
title: string;
|
title: string;
|
||||||
hostConfig?: SSHHost;
|
hostConfig?: SSHHost;
|
||||||
terminalRef?: any;
|
terminalRef?: any;
|
||||||
initialTab?: string; // For ssh_manager: "host_viewer" | "add_host" | "credentials" | "add_credential"
|
initialTab?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -9,12 +9,10 @@ export type WidgetType =
|
|||||||
|
|
||||||
export interface StatsConfig {
|
export interface StatsConfig {
|
||||||
enabledWidgets: WidgetType[];
|
enabledWidgets: WidgetType[];
|
||||||
// Status monitoring configuration
|
|
||||||
statusCheckEnabled: boolean;
|
statusCheckEnabled: boolean;
|
||||||
statusCheckInterval: number; // seconds (5-3600)
|
statusCheckInterval: number;
|
||||||
// Metrics monitoring configuration
|
|
||||||
metricsEnabled: boolean;
|
metricsEnabled: boolean;
|
||||||
metricsInterval: number; // seconds (5-3600)
|
metricsInterval: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_STATS_CONFIG: StatsConfig = {
|
export const DEFAULT_STATS_CONFIG: StatsConfig = {
|
||||||
|
|||||||
@@ -153,7 +153,6 @@ export function AdminSettings({
|
|||||||
toast.error(t("admin.failedToFetchOidcConfig"));
|
toast.error(t("admin.failedToFetchOidcConfig"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Capture the current session so we know whether to ask for a password later.
|
|
||||||
getUserInfo()
|
getUserInfo()
|
||||||
.then((info) => {
|
.then((info) => {
|
||||||
if (info) {
|
if (info) {
|
||||||
@@ -251,9 +250,7 @@ export function AdminSettings({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTogglePasswordLogin = async (checked: boolean) => {
|
const handleTogglePasswordLogin = async (checked: boolean) => {
|
||||||
// If disabling password login, warn the user
|
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
// Check if OIDC is configured
|
|
||||||
const hasOIDCConfigured =
|
const hasOIDCConfigured =
|
||||||
oidcConfig.client_id &&
|
oidcConfig.client_id &&
|
||||||
oidcConfig.client_secret &&
|
oidcConfig.client_secret &&
|
||||||
@@ -276,7 +273,6 @@ export function AdminSettings({
|
|||||||
await updatePasswordLoginAllowed(checked);
|
await updatePasswordLoginAllowed(checked);
|
||||||
setAllowPasswordLogin(checked);
|
setAllowPasswordLogin(checked);
|
||||||
|
|
||||||
// Auto-disable registration when password login is disabled
|
|
||||||
if (allowRegistration) {
|
if (allowRegistration) {
|
||||||
await updateRegistrationAllowed(false);
|
await updateRegistrationAllowed(false);
|
||||||
setAllowRegistration(false);
|
setAllowRegistration(false);
|
||||||
@@ -295,7 +291,6 @@ export function AdminSettings({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enabling password login - proceed normally
|
|
||||||
setPasswordLoginLoading(true);
|
setPasswordLoginLoading(true);
|
||||||
try {
|
try {
|
||||||
await updatePasswordLoginAllowed(checked);
|
await updatePasswordLoginAllowed(checked);
|
||||||
@@ -493,7 +488,6 @@ export function AdminSettings({
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", importFile);
|
formData.append("file", importFile);
|
||||||
if (requiresImportPassword) {
|
if (requiresImportPassword) {
|
||||||
// Preserve the existing password flow for non-OIDC accounts.
|
|
||||||
formData.append("password", importPassword);
|
formData.append("password", importPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,7 +601,6 @@ export function AdminSettings({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRevokeSession = async (sessionId: string) => {
|
const handleRevokeSession = async (sessionId: string) => {
|
||||||
// Check if this is the current session
|
|
||||||
const currentJWT = getCookie("jwt");
|
const currentJWT = getCookie("jwt");
|
||||||
const currentSession = sessions.find((s) => s.jwtToken === currentJWT);
|
const currentSession = sessions.find((s) => s.jwtToken === currentJWT);
|
||||||
const isCurrentSession = currentSession?.id === sessionId;
|
const isCurrentSession = currentSession?.id === sessionId;
|
||||||
@@ -641,7 +634,6 @@ export function AdminSettings({
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
toast.success(t("admin.sessionRevokedSuccessfully"));
|
toast.success(t("admin.sessionRevokedSuccessfully"));
|
||||||
|
|
||||||
// If user revoked their own session, reload the page after a brief delay
|
|
||||||
if (isCurrentSession) {
|
if (isCurrentSession) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -661,7 +653,6 @@ export function AdminSettings({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRevokeAllUserSessions = async (userId: string) => {
|
const handleRevokeAllUserSessions = async (userId: string) => {
|
||||||
// Check if revoking sessions for current user
|
|
||||||
const isCurrentUser = currentUser?.id === userId;
|
const isCurrentUser = currentUser?.id === userId;
|
||||||
|
|
||||||
confirmWithToast(
|
confirmWithToast(
|
||||||
@@ -701,7 +692,6 @@ export function AdminSettings({
|
|||||||
data.message || t("admin.sessionsRevokedSuccessfully"),
|
data.message || t("admin.sessionsRevokedSuccessfully"),
|
||||||
);
|
);
|
||||||
|
|
||||||
// If revoking sessions for current user, reload the page after a brief delay
|
|
||||||
if (isCurrentUser) {
|
if (isCurrentUser) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -978,7 +968,6 @@ export function AdminSettings({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
// Check if password login is enabled
|
|
||||||
if (!allowPasswordLogin) {
|
if (!allowPasswordLogin) {
|
||||||
confirmWithToast(
|
confirmWithToast(
|
||||||
t("admin.confirmDisableOIDCWarning"),
|
t("admin.confirmDisableOIDCWarning"),
|
||||||
@@ -1469,7 +1458,6 @@ export function AdminSettings({
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/* Only render the password field when a local account is performing the import. */}
|
|
||||||
{importFile && requiresImportPassword && (
|
{importFile && requiresImportPassword && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="import-password">Password</Label>
|
<Label htmlFor="import-password">Password</Label>
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ export function CredentialEditor({
|
|||||||
|
|
||||||
setFolders(uniqueFolders);
|
setFolders(uniqueFolders);
|
||||||
} catch {
|
} catch {
|
||||||
// Failed to load credentials
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ export function Dashboard({
|
|||||||
const [userId, setUserId] = useState<string | null>(null);
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
const [dbError, setDbError] = useState<string | null>(null);
|
const [dbError, setDbError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Dashboard data state
|
|
||||||
const [uptime, setUptime] = useState<string>("0d 0h 0m");
|
const [uptime, setUptime] = useState<string>("0d 0h 0m");
|
||||||
const [versionStatus, setVersionStatus] = useState<
|
const [versionStatus, setVersionStatus] = useState<
|
||||||
"up_to_date" | "requires_update"
|
"up_to_date" | "requires_update"
|
||||||
@@ -141,22 +140,18 @@ export function Dashboard({
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
// Fetch dashboard data
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loggedIn) return;
|
if (!loggedIn) return;
|
||||||
|
|
||||||
const fetchDashboardData = async () => {
|
const fetchDashboardData = async () => {
|
||||||
try {
|
try {
|
||||||
// Fetch uptime
|
|
||||||
const uptimeInfo = await getUptime();
|
const uptimeInfo = await getUptime();
|
||||||
setUptime(uptimeInfo.formatted);
|
setUptime(uptimeInfo.formatted);
|
||||||
|
|
||||||
// Fetch version info
|
|
||||||
const versionInfo = await getVersionInfo();
|
const versionInfo = await getVersionInfo();
|
||||||
setVersionText(`v${versionInfo.localVersion}`);
|
setVersionText(`v${versionInfo.localVersion}`);
|
||||||
setVersionStatus(versionInfo.status || "up_to_date");
|
setVersionStatus(versionInfo.status || "up_to_date");
|
||||||
|
|
||||||
// Fetch database health
|
|
||||||
try {
|
try {
|
||||||
await getDatabaseHealth();
|
await getDatabaseHealth();
|
||||||
setDbHealth("healthy");
|
setDbHealth("healthy");
|
||||||
@@ -164,25 +159,20 @@ export function Dashboard({
|
|||||||
setDbHealth("error");
|
setDbHealth("error");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch total counts
|
|
||||||
const hosts = await getSSHHosts();
|
const hosts = await getSSHHosts();
|
||||||
setTotalServers(hosts.length);
|
setTotalServers(hosts.length);
|
||||||
|
|
||||||
// Count total tunnels across all hosts
|
|
||||||
let totalTunnelsCount = 0;
|
let totalTunnelsCount = 0;
|
||||||
for (const host of hosts) {
|
for (const host of hosts) {
|
||||||
if (host.tunnelConnections) {
|
if (host.tunnelConnections) {
|
||||||
try {
|
try {
|
||||||
// tunnelConnections is already parsed as an array from the backend
|
|
||||||
const tunnelConnections = Array.isArray(host.tunnelConnections)
|
const tunnelConnections = Array.isArray(host.tunnelConnections)
|
||||||
? host.tunnelConnections
|
? host.tunnelConnections
|
||||||
: JSON.parse(host.tunnelConnections);
|
: JSON.parse(host.tunnelConnections);
|
||||||
if (Array.isArray(tunnelConnections)) {
|
if (Array.isArray(tunnelConnections)) {
|
||||||
totalTunnelsCount += tunnelConnections.length;
|
totalTunnelsCount += tunnelConnections.length;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore parse errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTotalTunnels(totalTunnelsCount);
|
setTotalTunnels(totalTunnelsCount);
|
||||||
@@ -190,13 +180,11 @@ export function Dashboard({
|
|||||||
const credentials = await getCredentials();
|
const credentials = await getCredentials();
|
||||||
setTotalCredentials(credentials.length);
|
setTotalCredentials(credentials.length);
|
||||||
|
|
||||||
// Fetch recent activity (35 items)
|
|
||||||
setRecentActivityLoading(true);
|
setRecentActivityLoading(true);
|
||||||
const activity = await getRecentActivity(35);
|
const activity = await getRecentActivity(35);
|
||||||
setRecentActivity(activity);
|
setRecentActivity(activity);
|
||||||
setRecentActivityLoading(false);
|
setRecentActivityLoading(false);
|
||||||
|
|
||||||
// Fetch server stats for first 5 servers
|
|
||||||
setServerStatsLoading(true);
|
setServerStatsLoading(true);
|
||||||
const serversWithStats = await Promise.all(
|
const serversWithStats = await Promise.all(
|
||||||
hosts.slice(0, 5).map(async (host: { id: number; name: string }) => {
|
hosts.slice(0, 5).map(async (host: { id: number; name: string }) => {
|
||||||
@@ -229,12 +217,10 @@ export function Dashboard({
|
|||||||
|
|
||||||
fetchDashboardData();
|
fetchDashboardData();
|
||||||
|
|
||||||
// Refresh every 30 seconds
|
|
||||||
const interval = setInterval(fetchDashboardData, 30000);
|
const interval = setInterval(fetchDashboardData, 30000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loggedIn]);
|
}, [loggedIn]);
|
||||||
|
|
||||||
// Handler for resetting recent activity
|
|
||||||
const handleResetActivity = async () => {
|
const handleResetActivity = async () => {
|
||||||
try {
|
try {
|
||||||
await resetRecentActivity();
|
await resetRecentActivity();
|
||||||
@@ -244,9 +230,7 @@ export function Dashboard({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handler for opening a recent activity item
|
|
||||||
const handleActivityClick = (item: RecentActivityItem) => {
|
const handleActivityClick = (item: RecentActivityItem) => {
|
||||||
// Find the host and open appropriate tab
|
|
||||||
getSSHHosts().then((hosts) => {
|
getSSHHosts().then((hosts) => {
|
||||||
const host = hosts.find((h: { id: number }) => h.id === item.hostId);
|
const host = hosts.find((h: { id: number }) => h.id === item.hostId);
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
@@ -267,7 +251,6 @@ export function Dashboard({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Quick Actions handlers
|
|
||||||
const handleAddHost = () => {
|
const handleAddHost = () => {
|
||||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||||
if (sshManagerTab) {
|
if (sshManagerTab) {
|
||||||
|
|||||||
@@ -226,9 +226,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
const currentLoadingPathRef = useRef<string>("");
|
const currentLoadingPathRef = useRef<string>("");
|
||||||
const keepaliveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const keepaliveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const activityLoggedRef = useRef(false);
|
const activityLoggedRef = useRef(false);
|
||||||
const activityLoggingRef = useRef(false); // Prevent concurrent logging calls
|
const activityLoggingRef = useRef(false);
|
||||||
|
|
||||||
// Centralized activity logging to prevent duplicates
|
|
||||||
const logFileManagerActivity = useCallback(async () => {
|
const logFileManagerActivity = useCallback(async () => {
|
||||||
if (
|
if (
|
||||||
!currentHost?.id ||
|
!currentHost?.id ||
|
||||||
@@ -238,7 +237,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set flags IMMEDIATELY to prevent race conditions
|
|
||||||
activityLoggingRef.current = true;
|
activityLoggingRef.current = true;
|
||||||
activityLoggedRef.current = true;
|
activityLoggedRef.current = true;
|
||||||
|
|
||||||
@@ -246,10 +244,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
const hostName =
|
const hostName =
|
||||||
currentHost.name || `${currentHost.username}@${currentHost.ip}`;
|
currentHost.name || `${currentHost.username}@${currentHost.ip}`;
|
||||||
await logActivity("file_manager", currentHost.id, hostName);
|
await logActivity("file_manager", currentHost.id, hostName);
|
||||||
// Don't reset activityLoggedRef on success - we want to prevent future calls
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Failed to log file manager activity:", err);
|
console.warn("Failed to log file manager activity:", err);
|
||||||
// Reset on error so it can be retried
|
|
||||||
activityLoggedRef.current = false;
|
activityLoggedRef.current = false;
|
||||||
} finally {
|
} finally {
|
||||||
activityLoggingRef.current = false;
|
activityLoggingRef.current = false;
|
||||||
@@ -350,8 +346,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
initialLoadDoneRef.current = true;
|
initialLoadDoneRef.current = true;
|
||||||
|
|
||||||
// Log activity for recent connections (after successful directory load)
|
|
||||||
// Only log if TOTP was not required (if TOTP is required, we'll log after verification)
|
|
||||||
if (!result?.requires_totp) {
|
if (!result?.requires_totp) {
|
||||||
logFileManagerActivity();
|
logFileManagerActivity();
|
||||||
}
|
}
|
||||||
@@ -1306,7 +1300,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
initialLoadDoneRef.current = true;
|
initialLoadDoneRef.current = true;
|
||||||
toast.success(t("fileManager.connectedSuccessfully"));
|
toast.success(t("fileManager.connectedSuccessfully"));
|
||||||
|
|
||||||
// Log activity for recent connections (after successful directory load)
|
|
||||||
logFileManagerActivity();
|
logFileManagerActivity();
|
||||||
} catch (dirError: unknown) {
|
} catch (dirError: unknown) {
|
||||||
console.error("Failed to load initial directory:", dirError);
|
console.error("Failed to load initial directory:", dirError);
|
||||||
|
|||||||
@@ -34,21 +34,16 @@ export function HostManager({
|
|||||||
const ignoreNextHostConfigChangeRef = useRef<boolean>(false);
|
const ignoreNextHostConfigChangeRef = useRef<boolean>(false);
|
||||||
const lastProcessedHostIdRef = useRef<number | undefined>(undefined);
|
const lastProcessedHostIdRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
// Update editing host when hostConfig prop changes (from sidebar edit button)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip if we should ignore this change
|
|
||||||
if (ignoreNextHostConfigChangeRef.current) {
|
if (ignoreNextHostConfigChangeRef.current) {
|
||||||
ignoreNextHostConfigChangeRef.current = false;
|
ignoreNextHostConfigChangeRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only process if this is an external edit request (from sidebar)
|
|
||||||
if (hostConfig && initialTab === "add_host") {
|
if (hostConfig && initialTab === "add_host") {
|
||||||
const currentHostId = hostConfig.id;
|
const currentHostId = hostConfig.id;
|
||||||
|
|
||||||
// Open editor if it's a different host OR same host but user is on viewer/credentials tabs
|
|
||||||
if (currentHostId !== lastProcessedHostIdRef.current) {
|
if (currentHostId !== lastProcessedHostIdRef.current) {
|
||||||
// Different host - always open
|
|
||||||
setEditingHost(hostConfig);
|
setEditingHost(hostConfig);
|
||||||
setActiveTab("add_host");
|
setActiveTab("add_host");
|
||||||
lastProcessedHostIdRef.current = currentHostId;
|
lastProcessedHostIdRef.current = currentHostId;
|
||||||
@@ -57,11 +52,9 @@ export function HostManager({
|
|||||||
activeTab === "credentials" ||
|
activeTab === "credentials" ||
|
||||||
activeTab === "add_credential"
|
activeTab === "add_credential"
|
||||||
) {
|
) {
|
||||||
// Same host but user manually navigated away - reopen
|
|
||||||
setEditingHost(hostConfig);
|
setEditingHost(hostConfig);
|
||||||
setActiveTab("add_host");
|
setActiveTab("add_host");
|
||||||
}
|
}
|
||||||
// If same host and already on add_host tab, do nothing (don't block tab changes)
|
|
||||||
}
|
}
|
||||||
}, [hostConfig, initialTab]);
|
}, [hostConfig, initialTab]);
|
||||||
|
|
||||||
@@ -72,11 +65,9 @@ export function HostManager({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFormSubmit = () => {
|
const handleFormSubmit = () => {
|
||||||
// Ignore the next hostConfig change (which will come from ssh-hosts:changed event)
|
|
||||||
ignoreNextHostConfigChangeRef.current = true;
|
ignoreNextHostConfigChangeRef.current = true;
|
||||||
setEditingHost(null);
|
setEditingHost(null);
|
||||||
setActiveTab("host_viewer");
|
setActiveTab("host_viewer");
|
||||||
// Clear after a delay so the same host can be edited again
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
lastProcessedHostIdRef.current = undefined;
|
lastProcessedHostIdRef.current = undefined;
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|||||||
@@ -129,7 +129,6 @@ export function HostManagerEditor({
|
|||||||
);
|
);
|
||||||
const isSubmittingRef = useRef(false);
|
const isSubmittingRef = useRef(false);
|
||||||
|
|
||||||
// Monitoring interval states
|
|
||||||
const [statusIntervalUnit, setStatusIntervalUnit] = useState<
|
const [statusIntervalUnit, setStatusIntervalUnit] = useState<
|
||||||
"seconds" | "minutes"
|
"seconds" | "minutes"
|
||||||
>("seconds");
|
>("seconds");
|
||||||
@@ -168,9 +167,7 @@ export function HostManagerEditor({
|
|||||||
|
|
||||||
setFolders(uniqueFolders);
|
setFolders(uniqueFolders);
|
||||||
setSshConfigurations(uniqueConfigurations);
|
setSshConfigurations(uniqueConfigurations);
|
||||||
} catch {
|
} catch {}
|
||||||
// Failed to load hosts data
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -199,9 +196,7 @@ export function HostManagerEditor({
|
|||||||
|
|
||||||
setFolders(uniqueFolders);
|
setFolders(uniqueFolders);
|
||||||
setSshConfigurations(uniqueConfigurations);
|
setSshConfigurations(uniqueConfigurations);
|
||||||
} catch {
|
} catch {}
|
||||||
// Failed to reload hosts after credential change
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("credentials:changed", handleCredentialChange);
|
window.addEventListener("credentials:changed", handleCredentialChange);
|
||||||
@@ -319,7 +314,6 @@ export function HostManagerEditor({
|
|||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.authType === "none") {
|
if (data.authType === "none") {
|
||||||
// No credentials required for "none" auth type - will use keyboard-interactive
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,7 +438,6 @@ export function HostManagerEditor({
|
|||||||
: "none";
|
: "none";
|
||||||
setAuthTab(defaultAuthType);
|
setAuthTab(defaultAuthType);
|
||||||
|
|
||||||
// Parse statsConfig from JSON string if needed
|
|
||||||
let parsedStatsConfig = DEFAULT_STATS_CONFIG;
|
let parsedStatsConfig = DEFAULT_STATS_CONFIG;
|
||||||
try {
|
try {
|
||||||
if (cleanedHost.statsConfig) {
|
if (cleanedHost.statsConfig) {
|
||||||
@@ -457,7 +450,6 @@ export function HostManagerEditor({
|
|||||||
console.error("Failed to parse statsConfig:", error);
|
console.error("Failed to parse statsConfig:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge with defaults to ensure all new fields are present
|
|
||||||
parsedStatsConfig = { ...DEFAULT_STATS_CONFIG, ...parsedStatsConfig };
|
parsedStatsConfig = { ...DEFAULT_STATS_CONFIG, ...parsedStatsConfig };
|
||||||
|
|
||||||
const formData = {
|
const formData = {
|
||||||
@@ -552,7 +544,6 @@ export function HostManagerEditor({
|
|||||||
data.name = `${data.username}@${data.ip}`;
|
data.name = `${data.username}@${data.ip}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate monitoring intervals
|
|
||||||
if (data.statsConfig) {
|
if (data.statsConfig) {
|
||||||
const statusInterval = data.statsConfig.statusCheckInterval || 30;
|
const statusInterval = data.statsConfig.statusCheckInterval || 30;
|
||||||
const metricsInterval = data.statsConfig.metricsInterval || 30;
|
const metricsInterval = data.statsConfig.metricsInterval || 30;
|
||||||
@@ -663,7 +654,6 @@ export function HostManagerEditor({
|
|||||||
|
|
||||||
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
||||||
|
|
||||||
// Refresh backend polling to pick up new/updated host configuration
|
|
||||||
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
|
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
|
||||||
refreshServerPolling();
|
refreshServerPolling();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1391,7 +1381,6 @@ export function HostManagerEditor({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Font Family */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="terminalConfig.fontFamily"
|
name="terminalConfig.fontFamily"
|
||||||
@@ -1425,7 +1414,6 @@ export function HostManagerEditor({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Font Size */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="terminalConfig.fontSize"
|
name="terminalConfig.fontSize"
|
||||||
@@ -1450,7 +1438,6 @@ export function HostManagerEditor({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Letter Spacing */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="terminalConfig.letterSpacing"
|
name="terminalConfig.letterSpacing"
|
||||||
@@ -1477,7 +1464,6 @@ export function HostManagerEditor({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Line Height */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="terminalConfig.lineHeight"
|
name="terminalConfig.lineHeight"
|
||||||
@@ -1502,7 +1488,6 @@ export function HostManagerEditor({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Cursor Style */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="terminalConfig.cursorStyle"
|
name="terminalConfig.cursorStyle"
|
||||||
@@ -1533,7 +1518,6 @@ export function HostManagerEditor({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Cursor Blink */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="terminalConfig.cursorBlink"
|
name="terminalConfig.cursorBlink"
|
||||||
@@ -1557,11 +1541,9 @@ export function HostManagerEditor({
|
|||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
{/* Behavior Settings */}
|
|
||||||
<AccordionItem value="behavior">
|
<AccordionItem value="behavior">
|
||||||
<AccordionTrigger>Behavior</AccordionTrigger>
|
<AccordionTrigger>Behavior</AccordionTrigger>
|
||||||
<AccordionContent className="space-y-4 pt-4">
|
<AccordionContent className="space-y-4 pt-4">
|
||||||
{/* Scrollback Buffer */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="terminalConfig.scrollback"
|
name="terminalConfig.scrollback"
|
||||||
@@ -1588,7 +1570,6 @@ export function HostManagerEditor({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Bell Style */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="terminalConfig.bellStyle"
|
name="terminalConfig.bellStyle"
|
||||||
@@ -1623,7 +1604,6 @@ export function HostManagerEditor({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Right Click Selects Word */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="terminalConfig.rightClickSelectsWord"
|
name="terminalConfig.rightClickSelectsWord"
|
||||||
@@ -1645,7 +1625,6 @@ export function HostManagerEditor({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Fast Scroll Modifier */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="terminalConfig.fastScrollModifier"
|
name="terminalConfig.fastScrollModifier"
|
||||||
@@ -1674,7 +1653,6 @@ export function HostManagerEditor({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Fast Scroll Sensitivity */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="terminalConfig.fastScrollSensitivity"
|
name="terminalConfig.fastScrollSensitivity"
|
||||||
@@ -1701,7 +1679,6 @@ export function HostManagerEditor({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Minimum Contrast Ratio */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="terminalConfig.minimumContrastRatio"
|
name="terminalConfig.minimumContrastRatio"
|
||||||
@@ -1731,11 +1708,9 @@ export function HostManagerEditor({
|
|||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
{/* Advanced Settings */}
|
|
||||||
<AccordionItem value="advanced">
|
<AccordionItem value="advanced">
|
||||||
<AccordionTrigger>Advanced</AccordionTrigger>
|
<AccordionTrigger>Advanced</AccordionTrigger>
|
||||||
<AccordionContent className="space-y-4 pt-4">
|
<AccordionContent className="space-y-4 pt-4">
|
||||||
{/* Agent Forwarding */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="terminalConfig.agentForwarding"
|
name="terminalConfig.agentForwarding"
|
||||||
@@ -1758,7 +1733,6 @@ export function HostManagerEditor({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Backspace Mode */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="terminalConfig.backspaceMode"
|
name="terminalConfig.backspaceMode"
|
||||||
@@ -1790,7 +1764,6 @@ export function HostManagerEditor({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Startup Snippet */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="terminalConfig.startupSnippetId"
|
name="terminalConfig.startupSnippetId"
|
||||||
@@ -1862,7 +1835,6 @@ export function HostManagerEditor({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Auto MOSH */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="terminalConfig.autoMosh"
|
name="terminalConfig.autoMosh"
|
||||||
@@ -1884,7 +1856,6 @@ export function HostManagerEditor({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* MOSH Command */}
|
|
||||||
{form.watch("terminalConfig.autoMosh") && (
|
{form.watch("terminalConfig.autoMosh") && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -1906,7 +1877,6 @@ export function HostManagerEditor({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Environment Variables */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">
|
<label className="text-sm font-medium">
|
||||||
Environment Variables
|
Environment Variables
|
||||||
@@ -2395,9 +2365,7 @@ export function HostManagerEditor({
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="statistics" className="space-y-6">
|
<TabsContent value="statistics" className="space-y-6">
|
||||||
{/* Monitoring Configuration Section */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Status Check Monitoring */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -2463,7 +2431,6 @@ export function HostManagerEditor({
|
|||||||
value: "seconds" | "minutes",
|
value: "seconds" | "minutes",
|
||||||
) => {
|
) => {
|
||||||
setStatusIntervalUnit(value);
|
setStatusIntervalUnit(value);
|
||||||
// Convert current value to new unit
|
|
||||||
const currentSeconds = field.value || 30;
|
const currentSeconds = field.value || 30;
|
||||||
if (value === "minutes") {
|
if (value === "minutes") {
|
||||||
const minutes = Math.round(
|
const minutes = Math.round(
|
||||||
@@ -2496,7 +2463,6 @@ export function HostManagerEditor({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metrics Monitoring */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -2560,7 +2526,6 @@ export function HostManagerEditor({
|
|||||||
value: "seconds" | "minutes",
|
value: "seconds" | "minutes",
|
||||||
) => {
|
) => {
|
||||||
setMetricsIntervalUnit(value);
|
setMetricsIntervalUnit(value);
|
||||||
// Convert current value to new unit
|
|
||||||
const currentSeconds = field.value || 30;
|
const currentSeconds = field.value || 30;
|
||||||
if (value === "minutes") {
|
if (value === "minutes") {
|
||||||
const minutes = Math.round(
|
const minutes = Math.round(
|
||||||
@@ -2594,7 +2559,6 @@ export function HostManagerEditor({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Only show widget selection if metrics monitoring is enabled */}
|
|
||||||
{form.watch("statsConfig.metricsEnabled") && (
|
{form.watch("statsConfig.metricsEnabled") && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
await fetchHosts();
|
await fetchHosts();
|
||||||
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
||||||
|
|
||||||
// Refresh backend polling to remove deleted host
|
|
||||||
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
|
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
|
||||||
refreshServerPolling();
|
refreshServerPolling();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -392,7 +391,6 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to parse stats config and format monitoring status
|
|
||||||
const getMonitoringStatus = (host: SSHHost) => {
|
const getMonitoringStatus = (host: SSHHost) => {
|
||||||
try {
|
try {
|
||||||
const statsConfig = host.statsConfig
|
const statsConfig = host.statsConfig
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ export function Server({
|
|||||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||||
const [showStatsUI, setShowStatsUI] = React.useState(true);
|
const [showStatsUI, setShowStatsUI] = React.useState(true);
|
||||||
|
|
||||||
// Parse stats config for monitoring settings
|
|
||||||
const statsConfig = React.useMemo((): StatsConfig => {
|
const statsConfig = React.useMemo((): StatsConfig => {
|
||||||
if (!currentHostConfig?.statsConfig) {
|
if (!currentHostConfig?.statsConfig) {
|
||||||
return DEFAULT_STATS_CONFIG;
|
return DEFAULT_STATS_CONFIG;
|
||||||
@@ -181,7 +180,6 @@ export function Server({
|
|||||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||||
}, [hostConfig?.id]);
|
}, [hostConfig?.id]);
|
||||||
|
|
||||||
// Separate effect for status monitoring
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!statusCheckEnabled || !currentHostConfig?.id || !isVisible) {
|
if (!statusCheckEnabled || !currentHostConfig?.id || !isVisible) {
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
@@ -207,7 +205,6 @@ export function Server({
|
|||||||
} else if (err?.response?.status === 504) {
|
} else if (err?.response?.status === 504) {
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
} else if (err?.response?.status === 404) {
|
} else if (err?.response?.status === 404) {
|
||||||
// Status not available - monitoring disabled
|
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
} else {
|
} else {
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
@@ -217,7 +214,7 @@ export function Server({
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
intervalId = window.setInterval(fetchStatus, 10000); // Poll backend every 10 seconds
|
intervalId = window.setInterval(fetchStatus, 10000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -225,7 +222,6 @@ export function Server({
|
|||||||
};
|
};
|
||||||
}, [currentHostConfig?.id, isVisible, statusCheckEnabled]);
|
}, [currentHostConfig?.id, isVisible, statusCheckEnabled]);
|
||||||
|
|
||||||
// Separate effect for metrics monitoring
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!metricsEnabled || !currentHostConfig?.id || !isVisible) {
|
if (!metricsEnabled || !currentHostConfig?.id || !isVisible) {
|
||||||
setShowStatsUI(false);
|
setShowStatsUI(false);
|
||||||
@@ -244,7 +240,6 @@ export function Server({
|
|||||||
setMetrics(data);
|
setMetrics(data);
|
||||||
setMetricsHistory((prev) => {
|
setMetricsHistory((prev) => {
|
||||||
const newHistory = [...prev, data];
|
const newHistory = [...prev, data];
|
||||||
// Keep last 20 data points for chart
|
|
||||||
return newHistory.slice(-20);
|
return newHistory.slice(-20);
|
||||||
});
|
});
|
||||||
setShowStatsUI(true);
|
setShowStatsUI(true);
|
||||||
@@ -256,7 +251,6 @@ export function Server({
|
|||||||
response?: { status?: number; data?: { error?: string } };
|
response?: { status?: number; data?: { error?: string } };
|
||||||
};
|
};
|
||||||
if (err?.response?.status === 404) {
|
if (err?.response?.status === 404) {
|
||||||
// Metrics not available - monitoring disabled
|
|
||||||
setMetrics(null);
|
setMetrics(null);
|
||||||
setShowStatsUI(false);
|
setShowStatsUI(false);
|
||||||
} else if (
|
} else if (
|
||||||
@@ -281,7 +275,7 @@ export function Server({
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchMetrics();
|
fetchMetrics();
|
||||||
intervalId = window.setInterval(fetchMetrics, 10000); // Poll backend every 10 seconds
|
intervalId = window.setInterval(fetchMetrics, 10000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ interface CpuWidgetProps {
|
|||||||
export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
|
export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Prepare chart data
|
|
||||||
const chartData = React.useMemo(() => {
|
const chartData = React.useMemo(() => {
|
||||||
return metricsHistory.map((m, index) => ({
|
return metricsHistory.map((m, index) => ({
|
||||||
index,
|
index,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ interface DiskWidgetProps {
|
|||||||
export function DiskWidget({ metrics }: DiskWidgetProps) {
|
export function DiskWidget({ metrics }: DiskWidgetProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Prepare radial chart data
|
|
||||||
const radialData = React.useMemo(() => {
|
const radialData = React.useMemo(() => {
|
||||||
const percent = metrics?.disk?.percent || 0;
|
const percent = metrics?.disk?.percent || 0;
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ interface MemoryWidgetProps {
|
|||||||
export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
|
export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Prepare chart data
|
|
||||||
const chartData = React.useMemo(() => {
|
const chartData = React.useMemo(() => {
|
||||||
return metricsHistory.map((m, index) => ({
|
return metricsHistory.map((m, index) => ({
|
||||||
index,
|
index,
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ export function SnippetsSidebar({
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await getSnippets();
|
const data = await getSnippets();
|
||||||
// Defensive: ensure data is an array
|
|
||||||
setSnippets(Array.isArray(data) ? data : []);
|
setSnippets(Array.isArray(data) ? data : []);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("snippets.failedToFetch"));
|
toast.error(t("snippets.failedToFetch"));
|
||||||
@@ -118,7 +117,6 @@ export function SnippetsSidebar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
// Validate required fields
|
|
||||||
const errors = {
|
const errors = {
|
||||||
name: !formData.name.trim(),
|
name: !formData.name.trim(),
|
||||||
content: !formData.content.trim(),
|
content: !formData.content.trim(),
|
||||||
@@ -159,7 +157,6 @@ export function SnippetsSidebar({
|
|||||||
|
|
||||||
const handleExecute = (snippet: Snippet) => {
|
const handleExecute = (snippet: Snippet) => {
|
||||||
if (selectedTabIds.length > 0) {
|
if (selectedTabIds.length > 0) {
|
||||||
// Execute on selected terminals
|
|
||||||
selectedTabIds.forEach((tabId) => {
|
selectedTabIds.forEach((tabId) => {
|
||||||
const tab = tabs.find((t: TabData) => t.id === tabId);
|
const tab = tabs.find((t: TabData) => t.id === tabId);
|
||||||
if (tab?.terminalRef?.current?.sendInput) {
|
if (tab?.terminalRef?.current?.sendInput) {
|
||||||
@@ -173,7 +170,6 @@ export function SnippetsSidebar({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Execute on current terminal (legacy behavior)
|
|
||||||
onExecute(snippet.content);
|
onExecute(snippet.content);
|
||||||
toast.success(t("snippets.executeSuccess", { name: snippet.name }));
|
toast.success(t("snippets.executeSuccess", { name: snippet.name }));
|
||||||
}
|
}
|
||||||
@@ -190,7 +186,6 @@ export function SnippetsSidebar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Overlay and Sidebar */}
|
|
||||||
<div
|
<div
|
||||||
className="fixed top-0 left-0 right-0 bottom-0 z-[999999] flex justify-end pointer-events-auto isolate"
|
className="fixed top-0 left-0 right-0 bottom-0 z-[999999] flex justify-end pointer-events-auto isolate"
|
||||||
style={{
|
style={{
|
||||||
@@ -207,7 +202,6 @@ export function SnippetsSidebar({
|
|||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-dark-border">
|
<div className="flex items-center justify-between p-4 border-b border-dark-border">
|
||||||
<h2 className="text-lg font-semibold text-white">
|
<h2 className="text-lg font-semibold text-white">
|
||||||
{t("snippets.title")}
|
{t("snippets.title")}
|
||||||
@@ -223,10 +217,8 @@ export function SnippetsSidebar({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Terminal Selection */}
|
|
||||||
{terminalTabs.length > 0 && (
|
{terminalTabs.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -386,7 +378,6 @@ export function SnippetsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create/Edit Dialog - centered modal */}
|
|
||||||
{showDialog && (
|
{showDialog && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 flex items-center justify-center z-[9999999] bg-black/50 backdrop-blur-sm"
|
className="fixed inset-0 flex items-center justify-center z-[9999999] bg-black/50 backdrop-blur-sm"
|
||||||
|
|||||||
@@ -122,14 +122,13 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const isConnectingRef = useRef(false);
|
const isConnectingRef = useRef(false);
|
||||||
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const activityLoggedRef = useRef(false);
|
const activityLoggedRef = useRef(false);
|
||||||
const activityLoggingRef = useRef(false); // Prevent concurrent logging calls
|
const activityLoggingRef = useRef(false);
|
||||||
|
|
||||||
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||||
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||||
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const DEBOUNCE_MS = 140;
|
const DEBOUNCE_MS = 140;
|
||||||
|
|
||||||
// Centralized activity logging to prevent duplicates
|
|
||||||
const logTerminalActivity = async () => {
|
const logTerminalActivity = async () => {
|
||||||
if (
|
if (
|
||||||
!hostConfig.id ||
|
!hostConfig.id ||
|
||||||
@@ -139,7 +138,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set flags IMMEDIATELY to prevent race conditions
|
|
||||||
activityLoggingRef.current = true;
|
activityLoggingRef.current = true;
|
||||||
activityLoggedRef.current = true;
|
activityLoggedRef.current = true;
|
||||||
|
|
||||||
@@ -147,10 +145,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const hostName =
|
const hostName =
|
||||||
hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`;
|
hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`;
|
||||||
await logActivity("terminal", hostConfig.id, hostName);
|
await logActivity("terminal", hostConfig.id, hostName);
|
||||||
// Don't reset activityLoggedRef on success - we want to prevent future calls
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Failed to log terminal activity:", err);
|
console.warn("Failed to log terminal activity:", err);
|
||||||
// Reset on error so it can be retried
|
|
||||||
activityLoggedRef.current = false;
|
activityLoggedRef.current = false;
|
||||||
} finally {
|
} finally {
|
||||||
activityLoggingRef.current = false;
|
activityLoggingRef.current = false;
|
||||||
@@ -193,9 +189,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
terminal as { refresh?: (start: number, end: number) => void }
|
terminal as { refresh?: (start: number, end: number) => void }
|
||||||
).refresh(0, terminal.rows - 1);
|
).refresh(0, terminal.rows - 1);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore terminal refresh errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function performFit() {
|
function performFit() {
|
||||||
@@ -250,7 +244,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
}) {
|
}) {
|
||||||
if (webSocketRef.current && terminal) {
|
if (webSocketRef.current && terminal) {
|
||||||
// Send reconnect message with credentials
|
|
||||||
webSocketRef.current.send(
|
webSocketRef.current.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "reconnect_with_credentials",
|
type: "reconnect_with_credentials",
|
||||||
@@ -335,9 +328,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
scheduleNotify(cols, rows);
|
scheduleNotify(cols, rows);
|
||||||
hardRefresh();
|
hardRefresh();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore resize notification errors
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
refresh: () => hardRefresh(),
|
refresh: () => hardRefresh(),
|
||||||
}),
|
}),
|
||||||
@@ -587,18 +578,14 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
reconnectAttempts.current = 0;
|
reconnectAttempts.current = 0;
|
||||||
isReconnectingRef.current = false;
|
isReconnectingRef.current = false;
|
||||||
|
|
||||||
// Log activity for recent connections
|
|
||||||
logTerminalActivity();
|
logTerminalActivity();
|
||||||
|
|
||||||
// Execute post-connection actions
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
// Merge default config with host-specific config
|
|
||||||
const terminalConfig = {
|
const terminalConfig = {
|
||||||
...DEFAULT_TERMINAL_CONFIG,
|
...DEFAULT_TERMINAL_CONFIG,
|
||||||
...hostConfig.terminalConfig,
|
...hostConfig.terminalConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set environment variables
|
|
||||||
if (
|
if (
|
||||||
terminalConfig.environmentVariables &&
|
terminalConfig.environmentVariables &&
|
||||||
terminalConfig.environmentVariables.length > 0
|
terminalConfig.environmentVariables.length > 0
|
||||||
@@ -616,7 +603,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute startup snippet
|
|
||||||
if (terminalConfig.startupSnippetId) {
|
if (terminalConfig.startupSnippetId) {
|
||||||
try {
|
try {
|
||||||
const snippets = await getSnippets();
|
const snippets = await getSnippets();
|
||||||
@@ -638,7 +624,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute MOSH command
|
|
||||||
if (terminalConfig.autoMosh && ws.readyState === 1) {
|
if (terminalConfig.autoMosh && ws.readyState === 1) {
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -675,8 +660,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
connectionTimeoutRef.current = null;
|
connectionTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
} else if (msg.type === "keyboard_interactive_available") {
|
} else if (msg.type === "keyboard_interactive_available") {
|
||||||
// Keyboard-interactive auth is available (e.g., Warpgate OIDC)
|
|
||||||
// Show terminal immediately so user can see auth prompts
|
|
||||||
setKeyboardInteractiveDetected(true);
|
setKeyboardInteractiveDetected(true);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
if (connectionTimeoutRef.current) {
|
if (connectionTimeoutRef.current) {
|
||||||
@@ -684,8 +667,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
connectionTimeoutRef.current = null;
|
connectionTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
} else if (msg.type === "auth_method_not_available") {
|
} else if (msg.type === "auth_method_not_available") {
|
||||||
// Server doesn't support keyboard-interactive for "none" auth
|
|
||||||
// Show SSHAuthDialog for manual credential entry
|
|
||||||
setAuthDialogReason("no_keyboard");
|
setAuthDialogReason("no_keyboard");
|
||||||
setShowAuthDialog(true);
|
setShowAuthDialog(true);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
@@ -751,9 +732,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Clipboard API not available, fallback to textarea method
|
|
||||||
}
|
|
||||||
const textarea = document.createElement("textarea");
|
const textarea = document.createElement("textarea");
|
||||||
textarea.value = text;
|
textarea.value = text;
|
||||||
textarea.style.position = "fixed";
|
textarea.style.position = "fixed";
|
||||||
@@ -773,26 +752,21 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||||
return await navigator.clipboard.readText();
|
return await navigator.clipboard.readText();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Clipboard read not available or not permitted
|
|
||||||
}
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminal || !xtermRef.current) return;
|
if (!terminal || !xtermRef.current) return;
|
||||||
|
|
||||||
// Merge default config with host-specific config
|
|
||||||
const config = {
|
const config = {
|
||||||
...DEFAULT_TERMINAL_CONFIG,
|
...DEFAULT_TERMINAL_CONFIG,
|
||||||
...hostConfig.terminalConfig,
|
...hostConfig.terminalConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get theme colors
|
|
||||||
const themeColors =
|
const themeColors =
|
||||||
TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors;
|
TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors;
|
||||||
|
|
||||||
// Get font family with fallback
|
|
||||||
const fontConfig = TERMINAL_FONTS.find(
|
const fontConfig = TERMINAL_FONTS.find(
|
||||||
(f) => f.value === config.fontFamily,
|
(f) => f.value === config.fontFamily,
|
||||||
);
|
);
|
||||||
@@ -875,9 +849,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const pasteText = await readTextFromClipboard();
|
const pasteText = await readTextFromClipboard();
|
||||||
if (pasteText) terminal.paste(pasteText);
|
if (pasteText) terminal.paste(pasteText);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore clipboard operation errors
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
element?.addEventListener("contextmenu", handleContextMenu);
|
element?.addEventListener("contextmenu", handleContextMenu);
|
||||||
|
|
||||||
@@ -886,7 +858,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
|
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
|
||||||
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
|
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
|
||||||
|
|
||||||
// Handle backspace mode (Control-H)
|
|
||||||
if (
|
if (
|
||||||
config.backspaceMode === "control-h" &&
|
config.backspaceMode === "control-h" &&
|
||||||
e.key === "Backspace" &&
|
e.key === "Backspace" &&
|
||||||
@@ -943,7 +914,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
resizeTimeout.current = setTimeout(() => {
|
resizeTimeout.current = setTimeout(() => {
|
||||||
if (!isVisibleRef.current || !isReady) return;
|
if (!isVisibleRef.current || !isReady) return;
|
||||||
performFit();
|
performFit();
|
||||||
}, 50); // Reduced from 150ms to 50ms for snappier response
|
}, 50);
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(xtermRef.current);
|
resizeObserver.observe(xtermRef.current);
|
||||||
@@ -1022,31 +993,21 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible || !isReady || !fitAddonRef.current || !terminal) {
|
if (!isVisible || !isReady || !fitAddonRef.current || !terminal) {
|
||||||
// Reset fitted state when becoming invisible
|
|
||||||
if (!isVisible && isFitted) {
|
if (!isVisible && isFitted) {
|
||||||
setIsFitted(false);
|
setIsFitted(false);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When becoming visible, we need to:
|
|
||||||
// 1. Mark as not fitted
|
|
||||||
// 2. Clear any rendering artifacts
|
|
||||||
// 3. Fit to the container size
|
|
||||||
// 4. Mark as fitted (happens in performFit)
|
|
||||||
setIsFitted(false);
|
setIsFitted(false);
|
||||||
|
|
||||||
// Use double requestAnimationFrame to ensure container has laid out
|
|
||||||
let rafId1: number;
|
let rafId1: number;
|
||||||
let rafId2: number;
|
let rafId2: number;
|
||||||
|
|
||||||
rafId1 = requestAnimationFrame(() => {
|
rafId1 = requestAnimationFrame(() => {
|
||||||
rafId2 = requestAnimationFrame(() => {
|
rafId2 = requestAnimationFrame(() => {
|
||||||
// Force a hard refresh to clear any artifacts
|
|
||||||
hardRefresh();
|
hardRefresh();
|
||||||
// Fit the terminal to the new size
|
|
||||||
performFit();
|
performFit();
|
||||||
// Focus will happen after isFitted becomes true
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1056,7 +1017,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
};
|
};
|
||||||
}, [isVisible, isReady, splitScreen, terminal]);
|
}, [isVisible, isReady, splitScreen, terminal]);
|
||||||
|
|
||||||
// Focus the terminal after it's been fitted and is visible
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
isFitted &&
|
isFitted &&
|
||||||
@@ -1066,7 +1026,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
terminal &&
|
terminal &&
|
||||||
!splitScreen
|
!splitScreen
|
||||||
) {
|
) {
|
||||||
// Use requestAnimationFrame to ensure the terminal is actually visible in the DOM
|
|
||||||
const rafId = requestAnimationFrame(() => {
|
const rafId = requestAnimationFrame(() => {
|
||||||
terminal.focus();
|
terminal.focus();
|
||||||
});
|
});
|
||||||
@@ -1131,7 +1090,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
|
|
||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
style.innerHTML = `
|
style.innerHTML = `
|
||||||
/* Import popular terminal fonts from Google Fonts */
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap');
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
||||||
|
|||||||
@@ -192,7 +192,6 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
|||||||
|
|
||||||
await fetchTunnelStatuses();
|
await fetchTunnelStatuses();
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore tunnel action errors
|
|
||||||
} finally {
|
} finally {
|
||||||
setTunnelActions((prev) => ({ ...prev, [tunnelName]: false }));
|
setTunnelActions((prev) => ({ ...prev, [tunnelName]: false }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,16 +58,12 @@ export function Auth({
|
|||||||
}: AuthProps) {
|
}: AuthProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Detect if we're running in Electron's WebView/iframe
|
|
||||||
const isInElectronWebView = () => {
|
const isInElectronWebView = () => {
|
||||||
try {
|
try {
|
||||||
// Check if we're in an iframe AND the parent is Electron
|
|
||||||
if (window.self !== window.top) {
|
if (window.self !== window.top) {
|
||||||
// We're in an iframe, likely Electron's ElectronLoginForm
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Cross-origin iframe, can't access parent
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -108,7 +104,6 @@ export function Auth({
|
|||||||
}, [loggedIn]);
|
}, [loggedIn]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip when in Electron WebView iframe
|
|
||||||
if (isInElectronWebView()) {
|
if (isInElectronWebView()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -119,7 +114,6 @@ export function Auth({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip when in Electron WebView iframe
|
|
||||||
if (isInElectronWebView()) {
|
if (isInElectronWebView()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -136,7 +130,6 @@ export function Auth({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip when in Electron WebView iframe
|
|
||||||
if (isInElectronWebView()) {
|
if (isInElectronWebView()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -159,8 +152,6 @@ export function Auth({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip database health check when in Electron WebView iframe
|
|
||||||
// The parent Electron window will handle authentication
|
|
||||||
if (isInElectronWebView()) {
|
if (isInElectronWebView()) {
|
||||||
setDbHealthChecking(false);
|
setDbHealthChecking(false);
|
||||||
setDbConnectionFailed(false);
|
setDbConnectionFailed(false);
|
||||||
@@ -615,7 +606,6 @@ export function Auth({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show ElectronLoginForm when Electron has a configured server and user is not logged in
|
|
||||||
if (isElectron() && currentServerUrl && !loggedIn && !authLoading) {
|
if (isElectron() && currentServerUrl && !loggedIn && !authLoading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -797,7 +787,6 @@ export function Auth({
|
|||||||
{!loggedIn && !authLoading && !totpRequired && (
|
{!loggedIn && !authLoading && !totpRequired && (
|
||||||
<>
|
<>
|
||||||
{(() => {
|
{(() => {
|
||||||
// Check if any authentication method is available
|
|
||||||
const hasLogin = passwordLoginAllowed && !firstUser;
|
const hasLogin = passwordLoginAllowed && !firstUser;
|
||||||
const hasSignup =
|
const hasSignup =
|
||||||
(passwordLoginAllowed || firstUser) && registrationAllowed;
|
(passwordLoginAllowed || firstUser) && registrationAllowed;
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ export function ElectronLoginForm({
|
|||||||
const [currentUrl, setCurrentUrl] = useState(serverUrl);
|
const [currentUrl, setCurrentUrl] = useState(serverUrl);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Listen for messages from iframe
|
|
||||||
const handleMessage = async (event: MessageEvent) => {
|
const handleMessage = async (event: MessageEvent) => {
|
||||||
// Only accept messages from our configured server
|
|
||||||
try {
|
try {
|
||||||
const serverOrigin = new URL(serverUrl).origin;
|
const serverOrigin = new URL(serverUrl).origin;
|
||||||
if (event.origin !== serverOrigin) {
|
if (event.origin !== serverOrigin) {
|
||||||
@@ -43,25 +41,17 @@ export function ElectronLoginForm({
|
|||||||
!hasAuthenticatedRef.current &&
|
!hasAuthenticatedRef.current &&
|
||||||
!isAuthenticating
|
!isAuthenticating
|
||||||
) {
|
) {
|
||||||
console.log(
|
|
||||||
"[ElectronLoginForm] Received auth success from iframe",
|
|
||||||
);
|
|
||||||
hasAuthenticatedRef.current = true;
|
hasAuthenticatedRef.current = true;
|
||||||
setIsAuthenticating(true);
|
setIsAuthenticating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Save JWT to localStorage (Electron mode)
|
|
||||||
localStorage.setItem("jwt", data.token);
|
localStorage.setItem("jwt", data.token);
|
||||||
|
|
||||||
// Verify it was saved
|
|
||||||
const savedToken = localStorage.getItem("jwt");
|
const savedToken = localStorage.getItem("jwt");
|
||||||
if (!savedToken) {
|
if (!savedToken) {
|
||||||
throw new Error("Failed to save JWT to localStorage");
|
throw new Error("Failed to save JWT to localStorage");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[ElectronLoginForm] JWT saved successfully");
|
|
||||||
|
|
||||||
// Small delay to ensure everything is saved
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
|
||||||
onAuthSuccess();
|
onAuthSuccess();
|
||||||
@@ -86,37 +76,29 @@ export function ElectronLoginForm({
|
|||||||
}, [serverUrl, isAuthenticating, onAuthSuccess, t]);
|
}, [serverUrl, isAuthenticating, onAuthSuccess, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Inject script into iframe when it loads
|
|
||||||
const iframe = iframeRef.current;
|
const iframe = iframeRef.current;
|
||||||
if (!iframe) return;
|
if (!iframe) return;
|
||||||
|
|
||||||
const handleLoad = () => {
|
const handleLoad = () => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
// Update current URL when iframe loads
|
|
||||||
try {
|
try {
|
||||||
if (iframe.contentWindow) {
|
if (iframe.contentWindow) {
|
||||||
setCurrentUrl(iframe.contentWindow.location.href);
|
setCurrentUrl(iframe.contentWindow.location.href);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Cross-origin, can't access - use serverUrl
|
|
||||||
setCurrentUrl(serverUrl);
|
setCurrentUrl(serverUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Inject JavaScript to detect JWT
|
|
||||||
const injectedScript = `
|
const injectedScript = `
|
||||||
(function() {
|
(function() {
|
||||||
console.log('[Electron WebView] Script injected');
|
|
||||||
|
|
||||||
let hasNotified = false;
|
let hasNotified = false;
|
||||||
|
|
||||||
function postJWTToParent(token, source) {
|
function postJWTToParent(token, source) {
|
||||||
if (hasNotified) return;
|
if (hasNotified) return;
|
||||||
hasNotified = true;
|
hasNotified = true;
|
||||||
|
|
||||||
console.log('[Electron WebView] Posting JWT to parent, source:', source);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
window.parent.postMessage({
|
window.parent.postMessage({
|
||||||
type: 'AUTH_SUCCESS',
|
type: 'AUTH_SUCCESS',
|
||||||
@@ -163,7 +145,6 @@ export function ElectronLoginForm({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intercept localStorage.setItem
|
|
||||||
const originalSetItem = localStorage.setItem;
|
const originalSetItem = localStorage.setItem;
|
||||||
localStorage.setItem = function(key, value) {
|
localStorage.setItem = function(key, value) {
|
||||||
originalSetItem.apply(this, arguments);
|
originalSetItem.apply(this, arguments);
|
||||||
@@ -172,7 +153,6 @@ export function ElectronLoginForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Intercept sessionStorage.setItem
|
|
||||||
const originalSessionSetItem = sessionStorage.setItem;
|
const originalSessionSetItem = sessionStorage.setItem;
|
||||||
sessionStorage.setItem = function(key, value) {
|
sessionStorage.setItem = function(key, value) {
|
||||||
originalSessionSetItem.apply(this, arguments);
|
originalSessionSetItem.apply(this, arguments);
|
||||||
@@ -181,7 +161,6 @@ export function ElectronLoginForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Poll for JWT
|
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
if (hasNotified) {
|
if (hasNotified) {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
@@ -192,17 +171,14 @@ export function ElectronLoginForm({
|
|||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
// Stop after 5 minutes
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
}, 300000);
|
}, 300000);
|
||||||
|
|
||||||
// Initial check
|
|
||||||
checkAuth();
|
checkAuth();
|
||||||
})();
|
})();
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Try to inject the script
|
|
||||||
try {
|
try {
|
||||||
if (iframe.contentWindow) {
|
if (iframe.contentWindow) {
|
||||||
iframe.contentWindow.postMessage(
|
iframe.contentWindow.postMessage(
|
||||||
@@ -210,11 +186,9 @@ export function ElectronLoginForm({
|
|||||||
"*",
|
"*",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Also try direct execution if same origin
|
|
||||||
iframe.contentWindow.eval(injectedScript);
|
iframe.contentWindow.eval(injectedScript);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Cross-origin restrictions - this is expected for external servers
|
|
||||||
console.warn(
|
console.warn(
|
||||||
"[ElectronLoginForm] Cannot inject script due to cross-origin restrictions",
|
"[ElectronLoginForm] Cannot inject script due to cross-origin restrictions",
|
||||||
);
|
);
|
||||||
@@ -250,12 +224,10 @@ export function ElectronLoginForm({
|
|||||||
onChangeServer();
|
onChangeServer();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format URL for display (remove protocol)
|
|
||||||
const displayUrl = currentUrl.replace(/^https?:\/\//, "");
|
const displayUrl = currentUrl.replace(/^https?:\/\//, "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 w-screen h-screen bg-dark-bg flex flex-col">
|
<div className="fixed inset-0 w-screen h-screen bg-dark-bg flex flex-col">
|
||||||
{/* Navigation Bar */}
|
|
||||||
<div className="flex items-center justify-between p-4 bg-dark-bg border-b border-dark-border">
|
<div className="flex items-center justify-between p-4 bg-dark-bg border-b border-dark-border">
|
||||||
<button
|
<button
|
||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
|
|||||||
@@ -37,9 +37,7 @@ export function ElectronServerConfig({
|
|||||||
if (config?.serverUrl) {
|
if (config?.serverUrl) {
|
||||||
setServerUrl(config.serverUrl);
|
setServerUrl(config.serverUrl);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore config loading errors
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveConfig = async () => {
|
const handleSaveConfig = async () => {
|
||||||
@@ -54,7 +52,6 @@ export function ElectronServerConfig({
|
|||||||
try {
|
try {
|
||||||
let normalizedUrl = serverUrl.trim();
|
let normalizedUrl = serverUrl.trim();
|
||||||
|
|
||||||
// Ensure URL has http:// or https://
|
|
||||||
if (
|
if (
|
||||||
!normalizedUrl.startsWith("http://") &&
|
!normalizedUrl.startsWith("http://") &&
|
||||||
!normalizedUrl.startsWith("https://")
|
!normalizedUrl.startsWith("https://")
|
||||||
|
|||||||
@@ -60,9 +60,7 @@ function AppContent() {
|
|||||||
localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen));
|
localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen));
|
||||||
}, [isTopbarOpen]);
|
}, [isTopbarOpen]);
|
||||||
|
|
||||||
const handleSelectView = () => {
|
const handleSelectView = () => {};
|
||||||
// View switching is now handled by tabs context
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAuthSuccess = (authData: {
|
const handleAuthSuccess = (authData: {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
|||||||
@@ -133,8 +133,6 @@ export function AppView({
|
|||||||
prev.splitScreenTabsStr !== allSplitScreenTab.join(",");
|
prev.splitScreenTabsStr !== allSplitScreenTab.join(",");
|
||||||
const tabIdsChanged = prev.terminalTabIds !== currentTabIds;
|
const tabIdsChanged = prev.terminalTabIds !== currentTabIds;
|
||||||
|
|
||||||
// Only trigger hideThenFit if tabs were added/removed (not just reordered)
|
|
||||||
// or if current tab or split screen changed
|
|
||||||
const isJustReorder =
|
const isJustReorder =
|
||||||
!lengthChanged && tabIdsChanged && !currentTabChanged && !splitChanged;
|
!lengthChanged && tabIdsChanged && !currentTabChanged && !splitChanged;
|
||||||
|
|
||||||
@@ -145,7 +143,6 @@ export function AppView({
|
|||||||
hideThenFit();
|
hideThenFit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the ref for next comparison
|
|
||||||
prevStateRef.current = {
|
prevStateRef.current = {
|
||||||
terminalTabsLength: terminalTabs.length,
|
terminalTabsLength: terminalTabs.length,
|
||||||
currentTab,
|
currentTab,
|
||||||
@@ -186,10 +183,8 @@ export function AppView({
|
|||||||
|
|
||||||
const HEADER_H = 28;
|
const HEADER_H = 28;
|
||||||
|
|
||||||
// Create a stable map of terminal IDs to preserve component identity
|
|
||||||
const terminalIdMapRef = useRef<Set<number>>(new Set());
|
const terminalIdMapRef = useRef<Set<number>>(new Set());
|
||||||
|
|
||||||
// Track all terminal IDs that have ever existed
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
terminalTabs.forEach((t) => terminalIdMapRef.current.add(t.id));
|
terminalTabs.forEach((t) => terminalIdMapRef.current.add(t.id));
|
||||||
}, [terminalTabs]);
|
}, [terminalTabs]);
|
||||||
@@ -240,8 +235,6 @@ export function AppView({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render in a STABLE order by ID to prevent React from unmounting
|
|
||||||
// Sort by ID instead of array position
|
|
||||||
const sortedTerminalTabs = [...terminalTabs].sort((a, b) => a.id - b.id);
|
const sortedTerminalTabs = [...terminalTabs].sort((a, b) => a.id - b.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -628,7 +621,6 @@ export function AppView({
|
|||||||
const isTerminal = currentTabData?.type === "terminal";
|
const isTerminal = currentTabData?.type === "terminal";
|
||||||
const isSplitScreen = allSplitScreenTab.length > 0;
|
const isSplitScreen = allSplitScreenTab.length > 0;
|
||||||
|
|
||||||
// Get terminal background color for the current tab
|
|
||||||
const terminalConfig = {
|
const terminalConfig = {
|
||||||
...DEFAULT_TERMINAL_CONFIG,
|
...DEFAULT_TERMINAL_CONFIG,
|
||||||
...(currentTabData?.hostConfig as any)?.terminalConfig,
|
...(currentTabData?.hostConfig as any)?.terminalConfig,
|
||||||
@@ -642,7 +634,6 @@ export function AppView({
|
|||||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||||
const bottomMarginPx = 8;
|
const bottomMarginPx = 8;
|
||||||
|
|
||||||
// Determine background color based on current tab type
|
|
||||||
let containerBackground = "var(--color-dark-bg)";
|
let containerBackground = "var(--color-dark-bg)";
|
||||||
if (isFileManager && !isSplitScreen) {
|
if (isFileManager && !isSplitScreen) {
|
||||||
containerBackground = "var(--color-dark-bg-darkest)";
|
containerBackground = "var(--color-dark-bg-darkest)";
|
||||||
|
|||||||
@@ -33,12 +33,10 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
? host.name
|
? host.name
|
||||||
: `${host.username}@${host.ip}:${host.port}`;
|
: `${host.username}@${host.ip}:${host.port}`;
|
||||||
|
|
||||||
// Update host when prop changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHost(initialHost);
|
setHost(initialHost);
|
||||||
}, [initialHost]);
|
}, [initialHost]);
|
||||||
|
|
||||||
// Listen for host changes to immediately update config
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleHostsChanged = async () => {
|
const handleHostsChanged = async () => {
|
||||||
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
||||||
@@ -54,7 +52,6 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||||
}, [host.id]);
|
}, [host.id]);
|
||||||
|
|
||||||
// Parse stats config for monitoring settings
|
|
||||||
const statsConfig = useMemo(() => {
|
const statsConfig = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return host.statsConfig
|
return host.statsConfig
|
||||||
@@ -68,7 +65,6 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
|
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Don't poll if status monitoring is disabled
|
|
||||||
if (!shouldShowStatus) {
|
if (!shouldShowStatus) {
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
return;
|
return;
|
||||||
@@ -90,7 +86,6 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
} else if (err?.response?.status === 504) {
|
} else if (err?.response?.status === 504) {
|
||||||
setServerStatus("degraded");
|
setServerStatus("degraded");
|
||||||
} else if (err?.response?.status === 404) {
|
} else if (err?.response?.status === 404) {
|
||||||
// Status not available - monitoring disabled
|
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
} else {
|
} else {
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
@@ -100,7 +95,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
const intervalId = window.setInterval(fetchStatus, 10000); // Poll backend every 10 seconds
|
const intervalId = window.setInterval(fetchStatus, 10000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export function Tab({
|
|||||||
}: TabProps): React.ReactElement {
|
}: TabProps): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Firefox-style tab classes using cn utility
|
|
||||||
const tabBaseClasses = cn(
|
const tabBaseClasses = cn(
|
||||||
"relative flex items-center gap-1.5 px-3 w-full min-w-0",
|
"relative flex items-center gap-1.5 px-3 w-full min-w-0",
|
||||||
"rounded-t-lg border-t-2 border-l-2 border-r-2",
|
"rounded-t-lg border-t-2 border-l-2 border-r-2",
|
||||||
@@ -65,7 +64,6 @@ export function Tab({
|
|||||||
"bg-background/80 text-muted-foreground border-border hover:bg-background/90",
|
"bg-background/80 text-muted-foreground border-border hover:bg-background/90",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper function to split title into base and suffix
|
|
||||||
const splitTitle = (fullTitle: string): { base: string; suffix: string } => {
|
const splitTitle = (fullTitle: string): { base: string; suffix: string } => {
|
||||||
const match = fullTitle.match(/^(.*?)(\s*\(\d+\))$/);
|
const match = fullTitle.match(/^(.*?)(\s*\(\d+\))$/);
|
||||||
if (match) {
|
if (match) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
|
||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
@@ -97,24 +96,19 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addTab = (tabData: Omit<Tab, "id">): number => {
|
const addTab = (tabData: Omit<Tab, "id">): number => {
|
||||||
// Check if an ssh_manager tab already exists
|
|
||||||
if (tabData.type === "ssh_manager") {
|
if (tabData.type === "ssh_manager") {
|
||||||
const existingTab = tabs.find((t) => t.type === "ssh_manager");
|
const existingTab = tabs.find((t) => t.type === "ssh_manager");
|
||||||
if (existingTab) {
|
if (existingTab) {
|
||||||
// Update the existing tab with new data
|
|
||||||
// Create a new object reference to force React to detect the change
|
|
||||||
setTabs((prev) =>
|
setTabs((prev) =>
|
||||||
prev.map((t) =>
|
prev.map((t) =>
|
||||||
t.id === existingTab.id
|
t.id === existingTab.id
|
||||||
? {
|
? {
|
||||||
...t,
|
...t,
|
||||||
// Keep the original title (Host Manager)
|
|
||||||
title: existingTab.title,
|
title: existingTab.title,
|
||||||
hostConfig: tabData.hostConfig
|
hostConfig: tabData.hostConfig
|
||||||
? { ...tabData.hostConfig }
|
? { ...tabData.hostConfig }
|
||||||
: undefined,
|
: undefined,
|
||||||
initialTab: tabData.initialTab,
|
initialTab: tabData.initialTab,
|
||||||
// Add a timestamp to force re-render
|
|
||||||
_updateTimestamp: Date.now(),
|
_updateTimestamp: Date.now(),
|
||||||
}
|
}
|
||||||
: t,
|
: t,
|
||||||
@@ -222,7 +216,6 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
setTabs((prev) =>
|
setTabs((prev) =>
|
||||||
prev.map((tab) => {
|
prev.map((tab) => {
|
||||||
if (tab.hostConfig && tab.hostConfig.id === hostId) {
|
if (tab.hostConfig && tab.hostConfig.id === hostId) {
|
||||||
// Don't update the title for ssh_manager tabs - they should stay as "Host Manager"
|
|
||||||
if (tab.type === "ssh_manager") {
|
if (tab.type === "ssh_manager") {
|
||||||
return {
|
return {
|
||||||
...tab,
|
...tab,
|
||||||
@@ -230,7 +223,6 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other tabs (terminal, server, file_manager), update both config and title
|
|
||||||
return {
|
return {
|
||||||
...tab,
|
...tab,
|
||||||
hostConfig: newHostConfig,
|
hostConfig: newHostConfig,
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function TopNavbar({
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (justDroppedTabId !== null) {
|
if (justDroppedTabId !== null) {
|
||||||
const timer = setTimeout(() => setJustDroppedTabId(null), 50); // Clear after a short delay
|
const timer = setTimeout(() => setJustDroppedTabId(null), 50);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [justDroppedTabId]);
|
}, [justDroppedTabId]);
|
||||||
@@ -138,7 +138,6 @@ export function TopNavbar({
|
|||||||
|
|
||||||
const draggedIndex = dragState.draggedIndex;
|
const draggedIndex = dragState.draggedIndex;
|
||||||
|
|
||||||
// Build array of tab boundaries in ORIGINAL order
|
|
||||||
const tabBoundaries: {
|
const tabBoundaries: {
|
||||||
index: number;
|
index: number;
|
||||||
start: number;
|
start: number;
|
||||||
@@ -158,25 +157,21 @@ export function TopNavbar({
|
|||||||
end: accumulatedX + tabWidth,
|
end: accumulatedX + tabWidth,
|
||||||
mid: accumulatedX + tabWidth / 2,
|
mid: accumulatedX + tabWidth / 2,
|
||||||
});
|
});
|
||||||
accumulatedX += tabWidth + 4; // 4px gap
|
accumulatedX += tabWidth + 4;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tabBoundaries.length === 0) return null;
|
if (tabBoundaries.length === 0) return null;
|
||||||
|
|
||||||
// Calculate the dragged tab's center in container coordinates
|
|
||||||
const containerRect = containerRef.current.getBoundingClientRect();
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
const draggedTab = tabBoundaries[draggedIndex];
|
const draggedTab = tabBoundaries[draggedIndex];
|
||||||
// Convert absolute positions to container-relative coordinates
|
|
||||||
const currentX = dragState.currentX - containerRect.left;
|
const currentX = dragState.currentX - containerRect.left;
|
||||||
const startX = dragState.startX - containerRect.left;
|
const startX = dragState.startX - containerRect.left;
|
||||||
const offset = currentX - startX;
|
const offset = currentX - startX;
|
||||||
const draggedCenter = draggedTab.mid + offset;
|
const draggedCenter = draggedTab.mid + offset;
|
||||||
|
|
||||||
// Determine target index based on where the dragged tab's center is
|
|
||||||
let newTargetIndex = draggedIndex;
|
let newTargetIndex = draggedIndex;
|
||||||
|
|
||||||
if (offset < 0) {
|
if (offset < 0) {
|
||||||
// Moving left - find the leftmost tab whose midpoint we've passed
|
|
||||||
for (let i = draggedIndex - 1; i >= 0; i--) {
|
for (let i = draggedIndex - 1; i >= 0; i--) {
|
||||||
if (draggedCenter < tabBoundaries[i].mid) {
|
if (draggedCenter < tabBoundaries[i].mid) {
|
||||||
newTargetIndex = i;
|
newTargetIndex = i;
|
||||||
@@ -185,7 +180,6 @@ export function TopNavbar({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (offset > 0) {
|
} else if (offset > 0) {
|
||||||
// Moving right - find the rightmost tab whose midpoint we've passed
|
|
||||||
for (let i = draggedIndex + 1; i < tabBoundaries.length; i++) {
|
for (let i = draggedIndex + 1; i < tabBoundaries.length; i++) {
|
||||||
if (draggedCenter > tabBoundaries[i].mid) {
|
if (draggedCenter > tabBoundaries[i].mid) {
|
||||||
newTargetIndex = i;
|
newTargetIndex = i;
|
||||||
@@ -193,18 +187,14 @@ export function TopNavbar({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Edge case: if dragged past the last tab, target should be at the very end
|
|
||||||
const lastTabIndex = tabBoundaries.length - 1;
|
const lastTabIndex = tabBoundaries.length - 1;
|
||||||
if (lastTabIndex >= 0) {
|
if (lastTabIndex >= 0) {
|
||||||
// Ensure there's at least one tab
|
|
||||||
const lastTabEl = tabRefs.current.get(lastTabIndex);
|
const lastTabEl = tabRefs.current.get(lastTabIndex);
|
||||||
if (lastTabEl) {
|
if (lastTabEl) {
|
||||||
const lastTabRect = lastTabEl.getBoundingClientRect();
|
const lastTabRect = lastTabEl.getBoundingClientRect();
|
||||||
const containerRect = containerRef.current.getBoundingClientRect();
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
const lastTabEndInContainer = lastTabRect.right - containerRect.left;
|
const lastTabEndInContainer = lastTabRect.right - containerRect.left;
|
||||||
if (currentX > lastTabEndInContainer) {
|
if (currentX > lastTabEndInContainer) {
|
||||||
// When dragging past the last tab, insert at the very end
|
|
||||||
// Use the last valid index (length - 1) not length itself
|
|
||||||
newTargetIndex = lastTabIndex;
|
newTargetIndex = lastTabIndex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,13 +207,11 @@ export function TopNavbar({
|
|||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Firefox compatibility - track position via dragover
|
|
||||||
if (dragState.draggedIndex === null) return;
|
if (dragState.draggedIndex === null) return;
|
||||||
|
|
||||||
const containerRect = containerRef.current?.getBoundingClientRect();
|
const containerRect = containerRef.current?.getBoundingClientRect();
|
||||||
if (!containerRect) return;
|
if (!containerRect) return;
|
||||||
|
|
||||||
// Update currentX if we have a valid clientX (Firefox may not provide it in onDrag)
|
|
||||||
if (e.clientX !== 0) {
|
if (e.clientX !== 0) {
|
||||||
setDragState((prev) => ({
|
setDragState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -253,7 +241,6 @@ export function TopNavbar({
|
|||||||
if (fromIndex !== null && toIndex !== null && fromIndex !== toIndex) {
|
if (fromIndex !== null && toIndex !== null && fromIndex !== toIndex) {
|
||||||
prevTabsRef.current = tabs;
|
prevTabsRef.current = tabs;
|
||||||
|
|
||||||
// Set animation flag and clear drag state synchronously
|
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
setIsInDropAnimation(true);
|
setIsInDropAnimation(true);
|
||||||
setDragState({
|
setDragState({
|
||||||
@@ -356,14 +343,13 @@ export function TopNavbar({
|
|||||||
|
|
||||||
const isDraggingThisTab = dragState.draggedIndex === index;
|
const isDraggingThisTab = dragState.draggedIndex === index;
|
||||||
const isTheDraggedTab = tab.id === dragState.draggedId;
|
const isTheDraggedTab = tab.id === dragState.draggedId;
|
||||||
const isDroppedAndSnapping = tab.id === justDroppedTabId; // New condition
|
const isDroppedAndSnapping = tab.id === justDroppedTabId;
|
||||||
const dragOffset = isDraggingThisTab
|
const dragOffset = isDraggingThisTab
|
||||||
? dragState.currentX - dragState.startX
|
? dragState.currentX - dragState.startX
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
let transform = "";
|
let transform = "";
|
||||||
|
|
||||||
// Skip all transforms if we just dropped to prevent glitches
|
|
||||||
if (!isInDropAnimation) {
|
if (!isInDropAnimation) {
|
||||||
if (isDraggingThisTab) {
|
if (isDraggingThisTab) {
|
||||||
transform = `translateX(${dragOffset}px)`;
|
transform = `translateX(${dragOffset}px)`;
|
||||||
@@ -374,13 +360,11 @@ export function TopNavbar({
|
|||||||
const draggedOriginalIndex = dragState.draggedIndex;
|
const draggedOriginalIndex = dragState.draggedIndex;
|
||||||
const currentTargetIndex = dragState.targetIndex;
|
const currentTargetIndex = dragState.targetIndex;
|
||||||
|
|
||||||
// Determine if this tab should shift left or right
|
|
||||||
if (
|
if (
|
||||||
draggedOriginalIndex < currentTargetIndex && // Dragging rightwards
|
draggedOriginalIndex < currentTargetIndex &&
|
||||||
index > draggedOriginalIndex && // This tab is to the right of the original position
|
index > draggedOriginalIndex &&
|
||||||
index <= currentTargetIndex // This tab is at or before the target position
|
index <= currentTargetIndex
|
||||||
) {
|
) {
|
||||||
// Shift left to make space
|
|
||||||
const draggedTabWidth =
|
const draggedTabWidth =
|
||||||
tabRefs.current
|
tabRefs.current
|
||||||
.get(draggedOriginalIndex)
|
.get(draggedOriginalIndex)
|
||||||
@@ -388,11 +372,10 @@ export function TopNavbar({
|
|||||||
const gap = 4;
|
const gap = 4;
|
||||||
transform = `translateX(-${draggedTabWidth + gap}px)`;
|
transform = `translateX(-${draggedTabWidth + gap}px)`;
|
||||||
} else if (
|
} else if (
|
||||||
draggedOriginalIndex > currentTargetIndex && // Dragging leftwards
|
draggedOriginalIndex > currentTargetIndex &&
|
||||||
index >= currentTargetIndex && // This tab is at or after the target position
|
index >= currentTargetIndex &&
|
||||||
index < draggedOriginalIndex // This tab is to the left of the original position
|
index < draggedOriginalIndex
|
||||||
) {
|
) {
|
||||||
// Shift right to make space
|
|
||||||
const draggedTabWidth =
|
const draggedTabWidth =
|
||||||
tabRefs.current
|
tabRefs.current
|
||||||
.get(draggedOriginalIndex)
|
.get(draggedOriginalIndex)
|
||||||
@@ -424,7 +407,6 @@ export function TopNavbar({
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
e
|
e
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
// Middle mouse button (button === 1)
|
|
||||||
if (e.button === 1 && !disableClose) {
|
if (e.button === 1 && !disableClose) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleTabClose(tab.id);
|
handleTabClose(tab.id);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
|
||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
|
|||||||
@@ -101,9 +101,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
terminal as { refresh?: (start: number, end: number) => void }
|
terminal as { refresh?: (start: number, end: number) => void }
|
||||||
).refresh(0, terminal.rows - 1);
|
).refresh(0, terminal.rows - 1);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore terminal refresh errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function performFit() {
|
function performFit() {
|
||||||
@@ -177,9 +175,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
scheduleNotify(cols, rows);
|
scheduleNotify(cols, rows);
|
||||||
hardRefresh();
|
hardRefresh();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore resize notification errors
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
refresh: () => hardRefresh(),
|
refresh: () => hardRefresh(),
|
||||||
}),
|
}),
|
||||||
@@ -229,9 +225,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
`\r\n[${msg.message || t("terminal.disconnected")}]`,
|
`\r\n[${msg.message || t("terminal.disconnected")}]`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Ignore message parsing errors
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener("close", (event) => {
|
ws.addEventListener("close", (event) => {
|
||||||
|
|||||||
@@ -27,23 +27,16 @@ import {
|
|||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect if we're running inside a React Native WebView
|
|
||||||
*/
|
|
||||||
function isReactNativeWebView(): boolean {
|
function isReactNativeWebView(): boolean {
|
||||||
return typeof window !== "undefined" && !!(window as any).ReactNativeWebView;
|
return typeof window !== "undefined" && !!(window as any).ReactNativeWebView;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Post JWT token to React Native WebView for mobile app authentication
|
|
||||||
*/
|
|
||||||
function postJWTToWebView() {
|
function postJWTToWebView() {
|
||||||
if (!isReactNativeWebView()) {
|
if (!isReactNativeWebView()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get JWT from localStorage or cookies
|
|
||||||
const jwt = getCookie("jwt") || localStorage.getItem("jwt");
|
const jwt = getCookie("jwt") || localStorage.getItem("jwt");
|
||||||
|
|
||||||
if (!jwt) {
|
if (!jwt) {
|
||||||
@@ -51,7 +44,6 @@ function postJWTToWebView() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post message to React Native
|
|
||||||
(window as any).ReactNativeWebView.postMessage(
|
(window as any).ReactNativeWebView.postMessage(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "AUTH_SUCCESS",
|
type: "AUTH_SUCCESS",
|
||||||
@@ -263,7 +255,6 @@ export function Auth({
|
|||||||
userId: meRes.userId || null,
|
userId: meRes.userId || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Post JWT to React Native WebView if running in mobile app
|
|
||||||
postJWTToWebView();
|
postJWTToWebView();
|
||||||
|
|
||||||
setInternalLoggedIn(true);
|
setInternalLoggedIn(true);
|
||||||
@@ -431,7 +422,6 @@ export function Auth({
|
|||||||
userId: res.userId || null,
|
userId: res.userId || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Post JWT to React Native WebView if running in mobile app
|
|
||||||
postJWTToWebView();
|
postJWTToWebView();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
@@ -521,7 +511,6 @@ export function Auth({
|
|||||||
userId: meRes.userId || null,
|
userId: meRes.userId || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Post JWT to React Native WebView if running in mobile app
|
|
||||||
postJWTToWebView();
|
postJWTToWebView();
|
||||||
|
|
||||||
setInternalLoggedIn(true);
|
setInternalLoggedIn(true);
|
||||||
@@ -670,7 +659,6 @@ export function Auth({
|
|||||||
{!internalLoggedIn && !authLoading && !totpRequired && (
|
{!internalLoggedIn && !authLoading && !totpRequired && (
|
||||||
<>
|
<>
|
||||||
{(() => {
|
{(() => {
|
||||||
// Check if any authentication method is available
|
|
||||||
const hasLogin = passwordLoginAllowed && !firstUser;
|
const hasLogin = passwordLoginAllowed && !firstUser;
|
||||||
const hasSignup =
|
const hasSignup =
|
||||||
(passwordLoginAllowed || firstUser) && registrationAllowed;
|
(passwordLoginAllowed || firstUser) && registrationAllowed;
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export function Host({ host, onHostConnect }: HostProps): React.ReactElement {
|
|||||||
? host.name
|
? host.name
|
||||||
: `${host.username}@${host.ip}:${host.port}`;
|
: `${host.username}@${host.ip}:${host.port}`;
|
||||||
|
|
||||||
// Parse stats config for monitoring settings
|
|
||||||
const statsConfig = useMemo(() => {
|
const statsConfig = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return host.statsConfig
|
return host.statsConfig
|
||||||
@@ -34,7 +33,6 @@ export function Host({ host, onHostConnect }: HostProps): React.ReactElement {
|
|||||||
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
|
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Don't poll if status monitoring is disabled
|
|
||||||
if (!shouldShowStatus) {
|
if (!shouldShowStatus) {
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
return;
|
return;
|
||||||
@@ -56,7 +54,6 @@ export function Host({ host, onHostConnect }: HostProps): React.ReactElement {
|
|||||||
} else if (err?.response?.status === 504) {
|
} else if (err?.response?.status === 504) {
|
||||||
setServerStatus("degraded");
|
setServerStatus("degraded");
|
||||||
} else if (err?.response?.status === 404) {
|
} else if (err?.response?.status === 404) {
|
||||||
// Status not available - monitoring disabled
|
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
} else {
|
} else {
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
@@ -67,7 +64,7 @@ export function Host({ host, onHostConnect }: HostProps): React.ReactElement {
|
|||||||
|
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
|
|
||||||
const intervalId = window.setInterval(fetchStatus, 10000); // Poll backend every 10 seconds
|
const intervalId = window.setInterval(fetchStatus, 10000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
|
||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
|
|||||||
@@ -48,9 +48,7 @@ export function useDragToSystemDesktop({ sshSessionId }: UseDragToSystemProps) {
|
|||||||
store.put({ handle: dirHandle }, "lastSaveDir");
|
store.put({ handle: dirHandle }, "lastSaveDir");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Failed to save directory handle
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isFileSystemAPISupported = () => {
|
const isFileSystemAPISupported = () => {
|
||||||
|
|||||||
@@ -323,7 +323,6 @@ function createApiInstance(
|
|||||||
if (isSessionExpired && typeof window !== "undefined") {
|
if (isSessionExpired && typeof window !== "undefined") {
|
||||||
console.warn("Session expired - please log in again");
|
console.warn("Session expired - please log in again");
|
||||||
|
|
||||||
// Clear the JWT cookie to prevent reload loop
|
|
||||||
document.cookie =
|
document.cookie =
|
||||||
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user